Finished tabletop management UI

This commit is contained in:
Thaum Rystra
2024-05-14 18:09:09 +02:00
parent 07a7849911
commit f47c311883
16 changed files with 1005 additions and 50 deletions

View File

@@ -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;

View File

@@ -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');
}
}

View File

@@ -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;

View 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;

View File

@@ -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,
};

View File

@@ -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>

View File

@@ -8,3 +8,9 @@
</v-alert>
</div>
</template>
<script lang="js">
export default {
}
</script>

View File

@@ -1,6 +1,7 @@
<template lang="html">
<v-container
v-if="!$subReady.tabletop"
key="Tabletop"
fluid
class="fill-height"
align="center"

View File

@@ -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>

View File

@@ -207,6 +207,9 @@ RouterFactory.configure(router => {
name: 'tabletops',
component: Tabletops,
beforeEnter: ensureLoggedIn,
meta: {
title: 'Tabletops',
},
}, {
path: '/tabletop/:id',
name: 'tabletop',

View File

@@ -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"

View 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>

View 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>

View 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>

View 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>

View File

@@ -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) {