Finished tabletop management UI
This commit is contained in:
@@ -110,5 +110,6 @@ import '/imports/api/tabletop/methods/removeTabletop';
|
||||
import '/imports/api/tabletop/methods/insertTabletop';
|
||||
import '/imports/api/tabletop/methods/updateTabletop';
|
||||
import '/imports/api/tabletop/methods/addCreaturesToTabletop';
|
||||
import '/imports/api/tabletop/methods/updateTabletopSharing';
|
||||
|
||||
export default Tabletops;
|
||||
|
||||
@@ -1,41 +1,56 @@
|
||||
import Tabletops, { Tabletop } from '/imports/api/tabletop/Tabletops';
|
||||
|
||||
type TabletopOrId = Tabletop | string | undefined;
|
||||
|
||||
function assertTabletopExists(tabletop: Tabletop | undefined): asserts tabletop is Tabletop {
|
||||
if (!tabletop) {
|
||||
throw new Meteor.Error('Tabletop does not exist',
|
||||
'No tabletop could be found for the given tabletop id');
|
||||
'Tabletop not found');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertUserInTabletop(tabletopId, userId) {
|
||||
const tabletop = Tabletops.findOne(tabletopId, {
|
||||
fields: { gameMasters: 1, players: 1 }
|
||||
function getTabletop(tabletop: TabletopOrId): Tabletop | undefined {
|
||||
if (typeof tabletop === 'string') {
|
||||
return Tabletops.findOne(tabletop, {
|
||||
fields: { gameMasters: 1, players: 1, owner: 1, spectators: 1 }
|
||||
});
|
||||
} else {
|
||||
return tabletop
|
||||
}
|
||||
}
|
||||
|
||||
export function assertUserInTabletop(tabletopOrId: TabletopOrId, userId: string) {
|
||||
const tabletop = getTabletop(tabletopOrId);
|
||||
assertTabletopExists(tabletop);
|
||||
if (!tabletop.gameMasters.includes(userId) && !tabletop.players.includes(userId)) {
|
||||
throw new Meteor.Error('Not in tabletop',
|
||||
'The user is not a game master or a player in the given tabletop');
|
||||
'You are not a game master or a player in the tabletop');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertUserGameMasterOfTabletop(tabletopId, userId) {
|
||||
const tabletop = Tabletops.findOne(tabletopId, {
|
||||
fields: { gameMasters: 1 },
|
||||
});
|
||||
export function assertUserGameMasterOfTabletop(tabletopOrId: TabletopOrId, userId: string) {
|
||||
const tabletop = getTabletop(tabletopOrId);
|
||||
assertTabletopExists(tabletop);
|
||||
if (tabletop.gameMasters.includes(userId)) {
|
||||
throw new Meteor.Error('not-game-master',
|
||||
'The user is not a game master in the given tabletop');
|
||||
'You are not a game master in the tabletop');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertUserIsTabletopOwner(tabletopId, userId) {
|
||||
const tabletop = Tabletops.findOne(tabletopId, {
|
||||
fields: { owner: 1 },
|
||||
});
|
||||
export function assertCanEditTabletop(tabletopOrId: TabletopOrId, userId: string) {
|
||||
const tabletop = getTabletop(tabletopOrId);
|
||||
assertTabletopExists(tabletop);
|
||||
if (tabletop.owner === userId) {
|
||||
throw new Meteor.Error('not-owner',
|
||||
'The user is not the owner of the given tabletop');
|
||||
if (tabletop.owner !== userId && tabletop.gameMasters.includes(userId)) {
|
||||
throw new Meteor.Error('not-editor',
|
||||
'You are not an owner or game master of the tabletop');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertUserIsTabletopOwner(tabletopOrId: TabletopOrId, userId: string) {
|
||||
const tabletop = getTabletop(tabletopOrId);
|
||||
assertTabletopExists(tabletop);
|
||||
if (tabletop.owner !== userId) {
|
||||
throw new Meteor.Error('not-owner',
|
||||
'You are not the owner of the tabletop');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method';
|
||||
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
|
||||
import Tabletops from '../Tabletops';
|
||||
import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers';
|
||||
import { assertUserIsTabletopOwner } from './shared/tabletopPermissions';
|
||||
import { assertCanEditTabletop } from './shared/tabletopPermissions';
|
||||
|
||||
const removeTabletop = new ValidatedMethod({
|
||||
const updateTabletop = new ValidatedMethod({
|
||||
|
||||
name: 'tabletops.update',
|
||||
|
||||
@@ -15,6 +15,7 @@ const removeTabletop = new ValidatedMethod({
|
||||
'name',
|
||||
'description',
|
||||
'imageUrl',
|
||||
'public',
|
||||
];
|
||||
if (!allowedFields.includes(path[0])) {
|
||||
throw new Meteor.Error('tabletops.update.denied',
|
||||
@@ -31,11 +32,11 @@ const removeTabletop = new ValidatedMethod({
|
||||
|
||||
run({ _id, path, value }) {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('tabletops.remove.denied',
|
||||
'You need to be logged in to remove a tabletop');
|
||||
throw new Meteor.Error('tabletops.update.denied',
|
||||
'You need to be logged in to update a tabletop');
|
||||
}
|
||||
assertUserHasPaidBenefits(this.userId);
|
||||
assertUserIsTabletopOwner(_id, this.userId);
|
||||
assertCanEditTabletop(_id, this.userId);
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
Tabletops.update(_id, {
|
||||
@@ -50,4 +51,4 @@ const removeTabletop = new ValidatedMethod({
|
||||
|
||||
});
|
||||
|
||||
export default removeTabletop;
|
||||
export default updateTabletop;
|
||||
|
||||
121
app/imports/api/tabletop/methods/updateTabletopSharing.js
Normal file
121
app/imports/api/tabletop/methods/updateTabletopSharing.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { ValidatedMethod } from 'meteor/mdg:validated-method';
|
||||
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
|
||||
import Tabletops from '../Tabletops';
|
||||
import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers';
|
||||
import { assertCanEditTabletop, assertUserIsTabletopOwner } from './shared/tabletopPermissions';
|
||||
|
||||
const updateTabletopSharing = new ValidatedMethod({
|
||||
|
||||
name: 'tabletops.updateSharing',
|
||||
|
||||
validate({ tabletopId, userId, role }) {
|
||||
if (!userId) return false;
|
||||
if (!tabletopId) return false;
|
||||
// Allowed fields
|
||||
const roles = [
|
||||
'owner',
|
||||
'gameMaster',
|
||||
'player',
|
||||
'spectator',
|
||||
'none',
|
||||
];
|
||||
if (!roles.includes(role)) {
|
||||
throw new Meteor.Error('tabletops.updateSharing.denied',
|
||||
'Invalid role selected');
|
||||
}
|
||||
},
|
||||
|
||||
mixins: [RateLimiterMixin],
|
||||
// @ts-expect-error Rate limit not defined
|
||||
rateLimit: {
|
||||
numRequests: 5,
|
||||
timeInterval: 5000,
|
||||
},
|
||||
|
||||
run({ tabletopId, userId, role }) {
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('tabletops.update.denied',
|
||||
'You need to be logged in to update a tabletop');
|
||||
}
|
||||
const tabletop = Tabletops.findOne(tabletopId);
|
||||
assertUserHasPaidBenefits(this.userId);
|
||||
assertCanEditTabletop(tabletop, this.userId);
|
||||
|
||||
if (role === 'owner') {
|
||||
assertUserIsTabletopOwner(tabletop, this.userId);
|
||||
}
|
||||
|
||||
// Check that the new user exists
|
||||
if (Meteor.isServer) {
|
||||
const userToAdd = Meteor.users.findOne({ _id: userId }, { fields: { _id: 1 } });
|
||||
if (!userToAdd) {
|
||||
throw new Meteor.Error('User not found',
|
||||
'The user could not be found'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let update;
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
update = {
|
||||
$set: { owner: userId },
|
||||
$addToSet: {
|
||||
gameMasters: this.userId,
|
||||
},
|
||||
$pull: {
|
||||
players: this.userId,
|
||||
spectators: this.userId,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'gameMaster':
|
||||
update = {
|
||||
$addToSet: {
|
||||
gameMasters: userId,
|
||||
},
|
||||
$pull: {
|
||||
players: userId,
|
||||
spectators: userId,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'player':
|
||||
update = {
|
||||
$addToSet: {
|
||||
players: userId,
|
||||
},
|
||||
$pull: {
|
||||
gameMasters: userId,
|
||||
spectators: userId,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'spectator':
|
||||
update = {
|
||||
$addToSet: {
|
||||
spectators: userId,
|
||||
},
|
||||
$pull: {
|
||||
gameMasters: userId,
|
||||
players: userId,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'none':
|
||||
update = {
|
||||
$pull: {
|
||||
gameMasters: userId,
|
||||
players: userId,
|
||||
spectators: userId,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
if (!update) return;
|
||||
return Tabletops.update(tabletopId, update)
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default updateTabletopSharing;
|
||||
@@ -32,6 +32,7 @@ const LibraryNodeDialog = () => import('/imports/client/ui/library/LibraryNodeDi
|
||||
const MoveLibraryNodeDialog = () => import('/imports/client/ui/library/MoveLibraryNodeDialog.vue');
|
||||
const SelectCreaturesDialog = () => import('/imports/client/ui/tabletop/SelectCreaturesDialog.vue');
|
||||
const ShareDialog = () => import('/imports/client/ui/sharing/ShareDialog.vue');
|
||||
const TabletopDialog = () => import('/imports/client/ui/tabletop/TabletopDialog.vue');
|
||||
const UsernameDialog = () => import('/imports/client/ui/user/UsernameDialog.vue');
|
||||
|
||||
export default {
|
||||
@@ -66,5 +67,6 @@ export default {
|
||||
SlotFillDialog,
|
||||
TierTooLowDialog,
|
||||
TransferOwnershipDialog,
|
||||
TabletopDialog,
|
||||
UsernameDialog,
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</v-app-bar>
|
||||
<v-main>
|
||||
<connection-banner />
|
||||
<v-fade-transition mode="out-in">
|
||||
<v-fade-transition hide-on-leave>
|
||||
<router-view />
|
||||
</v-fade-transition>
|
||||
</v-main>
|
||||
|
||||
@@ -8,3 +8,9 @@
|
||||
</v-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
export default {
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template lang="html">
|
||||
<v-container
|
||||
v-if="!$subReady.tabletop"
|
||||
key="Tabletop"
|
||||
fluid
|
||||
class="fill-height"
|
||||
align="center"
|
||||
|
||||
@@ -1,24 +1,88 @@
|
||||
<template lang="html">
|
||||
<single-card-layout class="tabletops">
|
||||
<v-list
|
||||
v-if="tabletops.length"
|
||||
class="tabletops"
|
||||
<div
|
||||
key="tabletops"
|
||||
class="card-background tabletops"
|
||||
style="height: 100%"
|
||||
>
|
||||
<v-list-item
|
||||
<v-container>
|
||||
<v-fade-transition mode="out-in">
|
||||
<v-row
|
||||
v-if="!$subReady.tabletops"
|
||||
key="loading-spinner"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
class="d-flex align-center justify-center"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row
|
||||
v-else-if="tabletops.length"
|
||||
key="loaded-cards"
|
||||
dense
|
||||
>
|
||||
<v-col
|
||||
v-for="tabletop in tabletops"
|
||||
:key="tabletop._id"
|
||||
:to="`/tabletop/${tabletop._id}`"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
<v-card
|
||||
class="fill-height d-flex flex-column"
|
||||
:data-id="`tabletop-${tabletop._id}`"
|
||||
@click="clickTabletop(tabletop._id)"
|
||||
>
|
||||
<v-img
|
||||
v-if="tabletop.imageUrl"
|
||||
height="200"
|
||||
:src="tabletop.imageUrl"
|
||||
/>
|
||||
<v-card-title>
|
||||
{{ tabletop.name || 'Unnamed Tabletop' }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-card-text v-else>
|
||||
You don't own or belong to any tabletops yet
|
||||
</v-card-title>
|
||||
<v-card-text v-if="tabletop.description">
|
||||
<markdown-text
|
||||
class="line-clamp"
|
||||
:markdown="tabletop.description"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-spacer />
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
text
|
||||
:to="`/tabletop/${tabletop._id}`"
|
||||
@click.native.stop=""
|
||||
>
|
||||
Launch
|
||||
<v-icon right>
|
||||
mdi-play
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row
|
||||
v-else
|
||||
key="no-tabletops"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
class="d-flex align-center justify-center"
|
||||
>
|
||||
<h1>You don't have any tabletops yet</h1>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-container>
|
||||
<v-btn
|
||||
color="primary"
|
||||
fab
|
||||
@@ -30,18 +94,18 @@
|
||||
>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</single-card-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import SingleCardLayout from '/imports/client/ui/layouts/SingleCardLayout.vue'
|
||||
import Tabletops from '/imports/api/tabletop/Tabletops';
|
||||
import insertTabletop from '/imports/api/tabletop/methods/insertTabletop';
|
||||
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue';
|
||||
import MarkdownText from '/imports/client/ui/components/MarkdownText.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SingleCardLayout,
|
||||
MarkdownText,
|
||||
},
|
||||
data(){return {
|
||||
addTabletopLoading: false,
|
||||
@@ -64,10 +128,24 @@ export default {
|
||||
}
|
||||
this.addTabletopLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
clickTabletop(tabletopId) {
|
||||
this.$store.commit('pushDialogStack', {
|
||||
component: 'tabletop-dialog',
|
||||
elementId: `tabletop-${tabletopId}`,
|
||||
data: {
|
||||
tabletopId,
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.line-clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -207,6 +207,9 @@ RouterFactory.configure(router => {
|
||||
name: 'tabletops',
|
||||
component: Tabletops,
|
||||
beforeEnter: ensureLoggedIn,
|
||||
meta: {
|
||||
title: 'Tabletops',
|
||||
},
|
||||
}, {
|
||||
path: '/tabletop/:id',
|
||||
name: 'tabletop',
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
v-if="model.public && docRef.collection === 'libraries'"
|
||||
readonly
|
||||
label="Link"
|
||||
:value="'https://dicecloud.com' + $router.resolve({
|
||||
:value="window.location.origin + $router.resolve({
|
||||
name: 'singleLibrary',
|
||||
params: { id: model._id },
|
||||
}).href"
|
||||
|
||||
217
app/imports/client/ui/tabletop/TabletopDialog.vue
Normal file
217
app/imports/client/ui/tabletop/TabletopDialog.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template lang="html">
|
||||
<dialog-base v-if="model">
|
||||
<template #toolbar>
|
||||
<v-toolbar-title>
|
||||
{{ model.name || 'Unnamed Tabletop' }}
|
||||
</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-slide-x-transition>
|
||||
<v-btn
|
||||
v-if="editing"
|
||||
icon
|
||||
:disabled="editPermission === false"
|
||||
data-id="remove-btn"
|
||||
@click="removeTabletop"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-slide-x-transition>
|
||||
<v-btn
|
||||
tile
|
||||
outlined
|
||||
@click="toggleEditing"
|
||||
>
|
||||
<span style="width: 44px;">
|
||||
{{ editing ? 'Done' : 'Edit' }}
|
||||
</span>
|
||||
<v-slide-y-transition
|
||||
hide-on-leave
|
||||
>
|
||||
<v-icon
|
||||
v-if="editing"
|
||||
key="doneIcon"
|
||||
right
|
||||
>
|
||||
mdi-check
|
||||
</v-icon>
|
||||
<v-icon
|
||||
v-else
|
||||
key="createIcon"
|
||||
right
|
||||
>
|
||||
mdi-pencil
|
||||
</v-icon>
|
||||
</v-slide-y-transition>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-fade-transition
|
||||
mode="out-in"
|
||||
>
|
||||
<tabletop-form
|
||||
v-if="editing"
|
||||
key="tabletop-form"
|
||||
:model="model"
|
||||
:edit-permission="editPermission"
|
||||
:users="users"
|
||||
@change="changeEvent"
|
||||
@update-sharing="updateSharingEvent"
|
||||
/>
|
||||
<tabletop-viewer
|
||||
v-else
|
||||
key="tabletop-viewer"
|
||||
:model="model"
|
||||
:users="users"
|
||||
/>
|
||||
</v-fade-transition>
|
||||
<template #actions>
|
||||
<div
|
||||
class="layout"
|
||||
>
|
||||
<v-btn
|
||||
text
|
||||
@click="$store.dispatch('popDialogStack')"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="accent"
|
||||
:to="`/tabletop/${model._id}`"
|
||||
@click="$store.dispatch('popDialogStack')"
|
||||
>
|
||||
Launch
|
||||
<v-icon
|
||||
right
|
||||
dark
|
||||
>
|
||||
mdi-play
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</dialog-base>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue';
|
||||
import DialogBase from '/imports/client/ui/dialogStack/DialogBase.vue';
|
||||
import TabletopForm from '/imports/client/ui/tabletop/TabletopForm.vue';
|
||||
import Tabletops from '/imports/api/tabletop/Tabletops';
|
||||
import TabletopViewer from '/imports/client/ui/tabletop/TabletopViewer.vue';
|
||||
import { assertCanEditTabletop } from '/imports/api/tabletop/methods/shared/tabletopPermissions';
|
||||
import updateTabletop from '/imports/api/tabletop/methods/updateTabletop';
|
||||
import removeTabletop from '/imports/api/tabletop/methods/removeTabletop';
|
||||
import updateTabletopSharing from '/imports/api/tabletop/methods/updateTabletopSharing';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DialogBase,
|
||||
TabletopForm,
|
||||
TabletopViewer,
|
||||
},
|
||||
props: {
|
||||
tabletopId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
startInEditTab: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data(){ return {
|
||||
editing: !!this.startInEditTab,
|
||||
}},
|
||||
meteor: {
|
||||
$subscribe: {
|
||||
'tabletopUsers'() {
|
||||
return [this.tabletopId];
|
||||
},
|
||||
},
|
||||
model(){
|
||||
return Tabletops.findOne(this.tabletopId);
|
||||
},
|
||||
editPermission(){
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) return false;
|
||||
try {
|
||||
assertCanEditTabletop(this.model, userId);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
users() {
|
||||
return {
|
||||
owner: Meteor.users.findOne(this.model.owner),
|
||||
gameMasters: Meteor.users.find({
|
||||
_id: { $in: this.model.gameMasters }
|
||||
}).fetch(),
|
||||
players: Meteor.users.find({
|
||||
_id: { $in: this.model.players }
|
||||
}).fetch(),
|
||||
spectators: Meteor.users.find({
|
||||
_id: { $in: this.model.spectators }
|
||||
}).fetch(),
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
notImplemented() {
|
||||
snackbar({text: 'Not implemented'});
|
||||
},
|
||||
toggleEditing() {
|
||||
this.editing = !this.editing;
|
||||
},
|
||||
changeEvent({ path, value, ack }) {
|
||||
if (typeof path === 'string') {
|
||||
path = path.split('.');
|
||||
}
|
||||
updateTabletop.call({
|
||||
_id: this.tabletopId,
|
||||
path,
|
||||
value,
|
||||
}, (error) => {
|
||||
ack(error);
|
||||
});
|
||||
},
|
||||
updateSharingEvent({ userId, role, ack }) {
|
||||
updateTabletopSharing.call({
|
||||
tabletopId: this.tabletopId,
|
||||
userId,
|
||||
role
|
||||
}, error => {
|
||||
ack?.(error);
|
||||
});
|
||||
},
|
||||
removeTabletop() {
|
||||
const router = this.$router;
|
||||
const tabletopId = this.tabletopId;
|
||||
const store = this.$store;
|
||||
this.$store.commit('pushDialogStack', {
|
||||
component: 'delete-confirmation-dialog',
|
||||
elementId: 'remove-btn',
|
||||
data: {
|
||||
name: this.model.name,
|
||||
typeName: 'Tabletop'
|
||||
},
|
||||
callback(confirmation) {
|
||||
if (!confirmation) return;
|
||||
store.dispatch('popDialogStack');
|
||||
removeTabletop.call({ tabletopId }, (error) => {
|
||||
if (error) {
|
||||
snackbar({ text: error.reason || error.message || error.toString() });
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
//Navigate back to tabletops page if we aren't there already
|
||||
if (router.currentRoute.path !== '/tabletops') {
|
||||
router.push('/tabletops');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
265
app/imports/client/ui/tabletop/TabletopForm.vue
Normal file
265
app/imports/client/ui/tabletop/TabletopForm.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="tabletop-form">
|
||||
<v-row dense>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<text-field
|
||||
label="Name"
|
||||
:value="model.name"
|
||||
:error-messages="errors.name"
|
||||
:disabled="!editPermission"
|
||||
@change="(value, ack) => change('name', value, ack)"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<text-field
|
||||
label="Picture URL"
|
||||
hint="A link to a cover image for this tabletop"
|
||||
:disabled="!editPermission"
|
||||
:value="model.imageUrl"
|
||||
:error-messages="errors.imageUrl"
|
||||
@change="(value, ack) => change('imageUrl', value, ack)"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
>
|
||||
<text-area
|
||||
label="Description"
|
||||
:value="model.description"
|
||||
:disabled="!editPermission"
|
||||
@change="(value, ack) => change('description', value, ack)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<form-sections type="tabletop">
|
||||
<form-section name="Sharing">
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<smart-select
|
||||
label="Who can view"
|
||||
:items="[
|
||||
{text: 'Only people I share with', value: 'false'},
|
||||
{text: 'Anyone with link', value: 'true'}
|
||||
]"
|
||||
:value="!!model.public + ''"
|
||||
@change="(value, ack) => change('public', value === 'true', ack)"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="model.public"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<text-field
|
||||
readonly
|
||||
label="Link"
|
||||
:value="link"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
cols="12"
|
||||
class="mb-4 px-4"
|
||||
>
|
||||
<h3 class="mb-4">
|
||||
Add user
|
||||
</h3>
|
||||
<text-field
|
||||
label="Username or email"
|
||||
:value="userSearched"
|
||||
:debounce-time="300"
|
||||
:disabled="!editPermission"
|
||||
@change="(value, ack) => getUser({value, ack})"
|
||||
/>
|
||||
<smart-select
|
||||
label="Permission"
|
||||
:items="[
|
||||
{text: 'Game Master', value: 'gameMaster'},
|
||||
{text: 'Player', value: 'player'},
|
||||
{text: 'Spectator', value: 'spectator'},
|
||||
]"
|
||||
:value="newSharePermission"
|
||||
:disabled="!editPermission"
|
||||
@change="(value, ack) => { newSharePermission = value; ack();}"
|
||||
/>
|
||||
<smart-btn
|
||||
class="ml-2 mt-2"
|
||||
single-click
|
||||
:disabled="userFoundState !== 'found' || !editPermission"
|
||||
@click="ack => updateSharing(userId, newSharePermission, ack)"
|
||||
>
|
||||
Share
|
||||
</smart-btn>
|
||||
</v-col>
|
||||
|
||||
<property-field
|
||||
name="Owner"
|
||||
:cols="{cols: 12, md: 6}"
|
||||
>
|
||||
{{ users.owner.username || users.owner._id || '' }}
|
||||
</property-field>
|
||||
<v-col
|
||||
v-if="users.gameMasters.length"
|
||||
key="gameMasters"
|
||||
cols="12"
|
||||
md="6"
|
||||
class="mb-4"
|
||||
>
|
||||
<outlined-input name="Game Masters">
|
||||
<tabletop-user-list
|
||||
:users="users.gameMasters"
|
||||
:edit-permission="editPermission"
|
||||
:owner="model.owner"
|
||||
role="gameMaster"
|
||||
@set-role="e => $emit('update-sharing', e)"
|
||||
/>
|
||||
</outlined-input>
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
v-if="users.players.length"
|
||||
key="players"
|
||||
cols="12"
|
||||
md="6"
|
||||
class="mb-4"
|
||||
>
|
||||
<outlined-input name="Players">
|
||||
<tabletop-user-list
|
||||
:users="users.players"
|
||||
:edit-permission="editPermission"
|
||||
:owner="model.owner"
|
||||
role="player"
|
||||
@set-role="e => $emit('update-sharing', e)"
|
||||
/>
|
||||
</outlined-input>
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
v-if="users.spectators.length"
|
||||
key="spectators"
|
||||
cols="12"
|
||||
md="6"
|
||||
class="mb-4"
|
||||
>
|
||||
<outlined-input name="Spectators">
|
||||
<tabletop-user-list
|
||||
:users="users.spectators"
|
||||
:edit-permission="editPermission"
|
||||
:owner="model.owner"
|
||||
role="spectator"
|
||||
@set-role="e => $emit('update-sharing', e)"
|
||||
/>
|
||||
</outlined-input>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</form-section>
|
||||
</form-sections>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import OutlinedInput from '/imports/client/ui/properties/viewers/shared/OutlinedInput.vue';
|
||||
import TabletopUserList from '/imports/client/ui/tabletop/TabletopUserList.vue';
|
||||
import PropertyField from '/imports/client/ui/properties/viewers/shared/PropertyField.vue';
|
||||
import FormSection, { FormSections } from '/imports/client/ui/properties/forms/shared/FormSection.vue';
|
||||
|
||||
export default {
|
||||
name: 'TabletopViewer',
|
||||
components: {
|
||||
OutlinedInput,
|
||||
TabletopUserList,
|
||||
PropertyField,
|
||||
FormSection,
|
||||
FormSections,
|
||||
},
|
||||
props: {
|
||||
model: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
editPermission: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
users: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newSharePermission: 'player',
|
||||
userSearched: undefined,
|
||||
userFoundState: 'idle',
|
||||
userId: undefined,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
link() {
|
||||
return window.location.origin + this.$router.resolve({
|
||||
name: 'tabletop',
|
||||
params: { id: this.model._id },
|
||||
}).href
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
change(path, value, ack) {
|
||||
this.$emit('change', { path, value, ack });
|
||||
},
|
||||
updateSharing(userId, role, ack) {
|
||||
this.$emit('update-sharing', { userId, role, ack});
|
||||
},
|
||||
getUser({ value, ack }) {
|
||||
this.userSearched = value;
|
||||
if (!value) {
|
||||
this.userFoundState = 'idle';
|
||||
ack();
|
||||
return;
|
||||
}
|
||||
Meteor.users.findUserByUsernameOrEmail.call({
|
||||
usernameOrEmail: value
|
||||
}, (error, result) => {
|
||||
if (error) {
|
||||
ack(error && error.reason || error);
|
||||
this.userFoundState = 'failed';
|
||||
} else {
|
||||
this.userId = result;
|
||||
if (result) {
|
||||
if (this.users.gameMasters.includes(result)) {
|
||||
this.userFoundState = 'failed';
|
||||
ack('User is already a game master');
|
||||
} else if (this.users.players.includes(result)) {
|
||||
this.userFoundState = 'failed';
|
||||
ack('User is already a player');
|
||||
} else if (this.users.spectators.includes(result)) {
|
||||
this.userFoundState = 'failed';
|
||||
ack('User is already a spectator');
|
||||
} else {
|
||||
this.userFoundState = 'found';
|
||||
ack();
|
||||
}
|
||||
} else {
|
||||
this.userFoundState = 'notFound';
|
||||
ack('User not found');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
122
app/imports/client/ui/tabletop/TabletopUserList.vue
Normal file
122
app/imports/client/ui/tabletop/TabletopUserList.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template lang="html">
|
||||
<v-list
|
||||
two-lines
|
||||
class="sharedWith"
|
||||
>
|
||||
<v-slide-x-transition
|
||||
group
|
||||
leave-absolute
|
||||
>
|
||||
<v-list-item
|
||||
v-for="user in users"
|
||||
:key="user._id"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{ user.username || user._id }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action>
|
||||
<v-menu
|
||||
bottom
|
||||
left
|
||||
:data-id="'menu-' + user._id"
|
||||
>
|
||||
<template #activator="{ on }">
|
||||
<v-btn
|
||||
icon
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-if="role !== 'gameMaster'"
|
||||
:disabled="!editPermission"
|
||||
@click="$emit('set-role', {userId: user._id, role: 'gameMaster'})"
|
||||
>
|
||||
<v-list-item-action>
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-list-item-action>
|
||||
<v-list-item-title>Make game master</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="role !== 'player'"
|
||||
:disabled="!editPermission"
|
||||
@click="$emit('set-role', {userId: user._id, role: 'player'})"
|
||||
>
|
||||
<v-list-item-action>
|
||||
<v-icon>mdi-account-box</v-icon>
|
||||
</v-list-item-action>
|
||||
<v-list-item-title>Make player</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="role !== 'spectator'"
|
||||
:disabled="!editPermission"
|
||||
@click="$emit('set-role', {userId: user._id, role: 'spectator'})"
|
||||
>
|
||||
<v-list-item-action>
|
||||
<v-icon>mdi-eye</v-icon>
|
||||
</v-list-item-action>
|
||||
<v-list-item-title>Make spectator</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="role === 'gameMaster' && user._id !== owner"
|
||||
:disabled="!editPermission"
|
||||
@click="$emit('set-role', {userId: user._id, role: 'owner'})"
|
||||
>
|
||||
<v-list-item-action>
|
||||
<v-icon>mdi-signature</v-icon>
|
||||
</v-list-item-action>
|
||||
<v-list-item-title>Transfer Ownership</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
:disabled="!editPermission || user._id === currentUserId"
|
||||
@click="$emit('set-role', {userId: user._id, role: 'none'})"
|
||||
>
|
||||
<v-list-item-action>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-list-item-action>
|
||||
<v-list-item-title>Remove</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
</v-slide-x-transition>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
export default {
|
||||
name: 'TabletopUserList',
|
||||
props: {
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
owner: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
editPermission: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
meteor: {
|
||||
currentUserId() {
|
||||
return Meteor.userId();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
103
app/imports/client/ui/tabletop/TabletopViewer.vue
Normal file
103
app/imports/client/ui/tabletop/TabletopViewer.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div
|
||||
class="tabletop-viewer layout d-flex flex-wrap"
|
||||
style="flex-direction: row-reverse;"
|
||||
>
|
||||
<div
|
||||
class="tabletop-details pa-2"
|
||||
style="flex-basis: 500px; flex-grow: 999;"
|
||||
>
|
||||
<v-img
|
||||
v-if="model.imageUrl"
|
||||
:src="model.imageUrl"
|
||||
height="300"
|
||||
width="100%"
|
||||
content-class="reset-width"
|
||||
/>
|
||||
<p
|
||||
v-if="model.description"
|
||||
class="mt-4"
|
||||
>
|
||||
<markdown-text :markdown="model.description" />
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="sharing-details flex-grow-1 pa-2"
|
||||
style="flex-basis: 320px;"
|
||||
>
|
||||
<v-row dense>
|
||||
<property-field
|
||||
name="Owner"
|
||||
:cols="{ cols: 12 }"
|
||||
>
|
||||
{{ users.owner.username || users.owner._id || '' }}
|
||||
</property-field>
|
||||
<property-field
|
||||
v-if="users.gameMasters.length"
|
||||
name="Game Masters"
|
||||
:cols="{ cols: 12 }"
|
||||
>
|
||||
<li
|
||||
v-for="user in users.gameMasters"
|
||||
:key="user._id"
|
||||
>
|
||||
{{ user.username || user._id }}
|
||||
</li>
|
||||
</property-field>
|
||||
<property-field
|
||||
v-if="users.players.length"
|
||||
name="Players"
|
||||
:cols="{ cols: 12 }"
|
||||
>
|
||||
<li
|
||||
v-for="user in users.players"
|
||||
:key="user._id"
|
||||
>
|
||||
{{ user.username || user._id }}
|
||||
</li>
|
||||
</property-field>
|
||||
<property-field
|
||||
v-if="users.spectators.length"
|
||||
name="Spectators"
|
||||
:cols="{ cols: 12 }"
|
||||
>
|
||||
<li
|
||||
v-for="user in users.spectators"
|
||||
:key="user._id"
|
||||
>
|
||||
{{ user.username || user._id }}
|
||||
</li>
|
||||
</property-field>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import MarkdownText from '/imports/client/ui/components/MarkdownText.vue';
|
||||
import PropertyField from '/imports/client/ui/properties/viewers/shared/PropertyField.vue';
|
||||
|
||||
export default {
|
||||
name: 'TabletopViewer',
|
||||
components: {
|
||||
MarkdownText,
|
||||
PropertyField,
|
||||
},
|
||||
props: {
|
||||
model: {
|
||||
type: Object ,
|
||||
required: true,
|
||||
},
|
||||
users: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tabletop-viewer .reset-width {
|
||||
width: unset !important;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,26 @@ Meteor.publish('tabletops', function () {
|
||||
});
|
||||
});
|
||||
|
||||
Meteor.publish('tabletopUsers', function (tabletopId) {
|
||||
if (!tabletopId) return [];
|
||||
const tabletop = Tabletops.findOne(tabletopId);
|
||||
if (!tabletop) return [];
|
||||
const userIds = [
|
||||
tabletop.owner,
|
||||
...tabletop.gameMasters,
|
||||
...tabletop.players,
|
||||
...tabletop.spectators,
|
||||
]
|
||||
return Meteor.users.find({
|
||||
_id: { $in: userIds },
|
||||
}, {
|
||||
fields: {
|
||||
username: 1,
|
||||
},
|
||||
limit: 500,
|
||||
});
|
||||
})
|
||||
|
||||
Meteor.publish('tabletop', function (tabletopId) {
|
||||
var userId = this.userId;
|
||||
if (!userId) {
|
||||
|
||||
Reference in New Issue
Block a user