Vastly improved new character UX

Characters now can limit which libraries they allow
This commit is contained in:
Stefan Zermatten
2022-07-18 13:45:14 +02:00
parent bf9639ae59
commit f8e9131bdd
21 changed files with 686 additions and 503 deletions

View File

@@ -131,15 +131,14 @@ function insertPropertyFromNode(nodeId, ancestors, order){
node.order = order;
}
// Mark root as dirty
node.dirty = true;
// Mark all nodes as dirty
dirtyNodes(nodes);
// Insert the creature properties
CreatureProperties.batchInsert(nodes);
return node;
}
function storeLibraryNodeReferences(nodes){
nodes.forEach(node => {
if (node.libraryNodeId) return;
@@ -147,6 +146,12 @@ function storeLibraryNodeReferences(nodes){
});
}
function dirtyNodes(nodes) {
nodes.forEach(node => {
node.dirty = true;
});
}
// Covert node references into actual nodes
// TODO: check permissions for each library a reference node references
function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){

View File

@@ -80,6 +80,27 @@ let CreatureSchema = new SimpleSchema({
optional: true,
max: STORAGE_LIMITS.url,
},
// Libraries
allowedLibraries: {
type: Array,
optional: true,
maxCount: 100,
},
'allowedLibraries.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
allowedLibraryCollections: {
type: Array,
optional: true,
maxCount: 100,
},
'allowedLibraryCollections.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
// Mechanics
deathSave: {
type: deathSaveSchema,
@@ -165,8 +186,8 @@ CreatureSchema.extend(SharingSchema);
Creatures.attachSchema(CreatureSchema);
import '/imports/api/creature/creatures/methods/index.js';
import '/imports/api/engine/actions/doAction.js';
export default Creatures;
export { CreatureSchema };
import '/imports/api/engine/actions/doAction.js';

View File

@@ -10,7 +10,7 @@ export default function defaultCharacterProperties(creatureId){
{
type: 'propertySlot',
name: 'Ruleset',
description: {text: 'Choose a starting point for your character, this will define the basic setup of your character sheet. Without a base, your sheet will be empty.'},
description: {text: 'Choose a starting point for your character, this will define the basic setup of your character sheet. Without a base ruleset, your sheet will be empty.'},
slotTags: ['base'],
tags: [],
quantityExpected: {calculation: '1'},

View File

@@ -1,57 +1,104 @@
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import simpleSchemaMixin from '/imports/api/creature/mixins/simpleSchemaMixin.js';
import Creatures, { CreatureSchema } from '/imports/api/creature/creatures/Creatures.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import defaultCharacterProperties from '/imports/api/creature/creatures/defaultCharacterProperties.js';
import insertPropertyFromLibraryNode from '/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js';
import assertHasCharactersSlots from '/imports/api/creature/creatures/methods/assertHasCharacterSlots.js';
import getSlotFillFilter from '/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js';
import getUserLibraryIds from '/imports/api/library/getUserLibraryIds.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { insertExperienceForCreature } from '/imports/api/creature/experience/Experiences.js';
import SimpleSchema from 'simpl-schema';
const insertCreature = new ValidatedMethod({
name: 'creatures.insertCreature',
validate: null,
mixins: [RateLimiterMixin],
mixins: [RateLimiterMixin, simpleSchemaMixin],
schema: CreatureSchema.pick(
'name',
'gender',
'alignment',
'allowedLibraries',
'allowedLibraryCollections',
).extend({
'startingLevel': {
type: SimpleSchema.Integer,
min: 0,
},
}),
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run() {
if (!this.userId) {
run({ name, gender, alignment, startingLevel,
allowedLibraries, allowedLibraryCollections }) {
const userId = this.userId
if (!userId) {
throw new Meteor.Error('Creatures.methods.insert.denied',
'You need to be logged in to insert a creature');
'You need to be logged in to insert a creature');
}
assertHasCharactersSlots(this.userId);
assertHasCharactersSlots(userId);
// Create the creature document
// Create the creature document
let creatureId = Creatures.insert({
owner: this.userId,
});
owner: userId,
name,
gender,
alignment,
allowedLibraries,
allowedLibraryCollections,
});
// Insert experience to get character to starting level
if (startingLevel) {
insertExperienceForCreature({
experience: {
name: 'Starting level',
levels: startingLevel,
creatureId
},
creatureId,
userId,
});
}
// Insert the default properties
// Not batchInsert because we want the properties cleaned by the schema
let baseId;
let baseId, rulesetSlot;
defaultCharacterProperties(creatureId).forEach(prop => {
let id = CreatureProperties.insert(prop);
if (prop.name === 'Ruleset'){
baseId = id;
rulesetSlot = prop;
}
});
if (Meteor.isServer){
// Insert the 5e ruleset as the default base
insertPropertyFromLibraryNode.call({
nodeIds: ['iHbhfcg3AL5isSWbw'],
parentRef: {id: baseId, collection: 'creatureProperties'},
order: 0.5,
});
// If the user only has a single ruleset subscribed, use it by default
if (Meteor.isServer) {
insertDefaultRuleset(baseId, userId, rulesetSlot);
}
return creatureId;
},
});
// If the user only has a single ruleset subscribed, insert it by default
function insertDefaultRuleset(baseId, userId, slot) {
const libraryIds = getUserLibraryIds(userId);
const filter = getSlotFillFilter({ slot, libraryIds });
const fillCursor = LibraryNodes.find(filter, { fields: { _id: 1 } });
const numRulesets = fillCursor.count();
if (numRulesets === 1) {
const ruleset = fillCursor.fetch()[0]
insertPropertyFromLibraryNode.call({
nodeIds: [ruleset._id],
parentRef: {id: baseId, collection: 'creatureProperties'},
order: 0.5,
});
}
}
export default insertCreature;

View File

@@ -47,7 +47,6 @@ let ExperienceSchema = new SimpleSchema({
Experiences.attachSchema(ExperienceSchema);
const insertExperienceForCreature = function({experience, creatureId, userId}){
assertEditPermission(creatureId, userId);
if (experience.xp){
Creatures.update(creatureId, {
$inc: { 'denormalizedStats.xp': experience.xp },
@@ -93,6 +92,7 @@ const insertExperience = new ValidatedMethod({
}
let insertedIds = [];
creatureIds.forEach(creatureId => {
assertEditPermission(creatureId, userId);
let id = insertExperienceForCreature({experience, creatureId, userId});
insertedIds.push(id);
});
@@ -181,4 +181,4 @@ const recomputeExperiences = new ValidatedMethod({
});
export default Experiences;
export { ExperienceSchema, insertExperience, removeExperience, recomputeExperiences };
export { ExperienceSchema, insertExperience, insertExperienceForCreature, removeExperience, recomputeExperiences };

View File

@@ -5,8 +5,13 @@ import { EJSON } from 'meteor/ejson';
export default function writeScope(creatureId, computation) {
if (!creatureId) throw 'creatureId is required';
const scope = computation.scope;
const variables = computation.variables || {};
let variables = computation.variables;
if (!variables) {
CreatureVariables.insert({ _creatureId: creatureId });
variables = {};
}
delete variables._id;
delete variables._creatureId;
let $set, $unset;
@@ -48,9 +53,9 @@ export default function writeScope(creatureId, computation) {
const update = {};
if ($set) update.$set = $set;
if ($unset) update.$unset = $unset;
CreatureVariables.upsert({_creatureId: creatureId}, update);
CreatureVariables.update({_creatureId: creatureId}, update);
}
if (computation.creature?.dirty) {
Creatures.update({_creatureId: creatureId}, {$unset: { dirty: 1 }});
Creatures.update({_id: creatureId}, {$unset: { dirty: 1 }});
}
}

View File

@@ -0,0 +1,39 @@
import LibraryCollections from '/imports/api/library/LibraryCollections.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import getUserLibraryIds from './getUserLibraryIds';
import { intersection, union } from 'lodash';
export default function getCreatureLibraryIds(creature, userId) {
if (!userId) return [];
// Get the ids of libraries the user is permitted to view
const userLibIds = getUserLibraryIds(userId);
// If given a creature Id, get the creature document
if (typeof creature === 'string') {
creature = Creatures.findOne(creature, {
fields: {
allowedLibraries: 1,
allowedLibraryCollections: 1,
}
});
if (!creature) return [];
}
// If the creature does not restrict the libraries, let it use them all
if (!creature.allowedLibraryCollections && !creature.allowedLibraries) {
return userLibIds;
}
// Get the ids of the libraries that the creature allows
const allowedCollections = creature.allowedLibraryCollections || [];
let creatureLibIds = creature.allowedLibraries || [];
LibraryCollections.find({
_id: { $in: allowedCollections }
}, { fields: { libraries: 1 } }).forEach(collection => {
creatureLibIds = union(creatureLibIds, collection.libraries);
});
// return all the ids that the creature allows and the user can view
return intersection(userLibIds, creatureLibIds);
}

View File

@@ -0,0 +1,31 @@
import LibraryCollections from '/imports/api/library/LibraryCollections.js';
import Libraries from '/imports/api/library/Libraries.js';
import { union } from 'lodash';
export default function getUserLibraryIds(userId) {
if (!userId) return [];
const user = Meteor.users.findOne(userId);
let subbedIds = user?.subscribedLibraries || [];
const subCollections = user?.subscribedLibraryCollections || [];
LibraryCollections.find({
$or: [
{ owner: userId },
{ writers: userId },
{ readers: userId },
{ _id: { $in: subCollections }, public: true },
]
}, { fields: { libraries: 1 } }).forEach(collection => {
subbedIds = union(subbedIds, collection.libraries);
});
const libraryIds = Libraries.find({
$or: [
{ owner: userId },
{ writers: userId },
{ readers: userId },
{ _id: { $in: subbedIds }, public: true },
]
}, {
fields: { _id: 1 }
}).map(lib => lib._id);
return libraryIds;
}

View File

@@ -71,7 +71,7 @@ const userSchema = new SimpleSchema({
subscribedLibraries: {
type: Array,
defaultValue: defaultLibraries,
max: 100,
maxCount: 100,
},
'subscribedLibraries.$': {
type: String,
@@ -80,7 +80,7 @@ const userSchema = new SimpleSchema({
subscribedLibraryCollections: {
type: Array,
defaultValue: defaultLibraryCollections,
max: 100,
maxCount: 100,
},
'subscribedLibraryCollections.$': {
type: String,

View File

@@ -3,6 +3,7 @@ import Libraries from '/imports/api/library/Libraries.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import getSlotFillFilter from '/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js'
import getUserLibraryIds from '/imports/api/library/getUserLibraryIds.js';
import { LIBRARY_NODE_TREE_FIELDS } from '/imports/server/publications/library.js';
const FIELDS = {
@@ -27,21 +28,17 @@ Meteor.publish('slotFillers', function(slotId, searchTerm){
}
// Get all the ids of libraries the user can access
const user = Meteor.users.findOne(userId, {
fields: {subscribedLibraries: 1}
});
const subs = user && user.subscribedLibraries || [];
let libraries = Libraries.find({
const libraryIds = getUserLibraryIds(userId);
const libraries = Libraries.find({
$or: [
{owner: this.userId},
{writers: this.userId},
{readers: this.userId},
{_id: {$in: subs}},
{ owner: userId },
{ writers: userId },
{ readers: userId },
{ _id: { $in: libraryIds }, public: true },
]
}, {
fields: {_id: 1, name: 1},
sort: { name: 1 }
});
let libraryIds = libraries.map(lib => lib._id);
// Build a filter for nodes in those libraries that match the slot
let filter = getSlotFillFilter({slot, libraryIds});
@@ -83,7 +80,10 @@ Meteor.publish('slotFillers', function(slotId, searchTerm){
self.setData('countAll', LibraryNodes.find(filter).count());
});
self.autorun(function () {
return [LibraryNodes.find(filter, options), libraries];
return [
LibraryNodes.find(filter, options),
libraries
];
});
});
});

View File

@@ -4,29 +4,28 @@
New Character
</v-toolbar-title>
<v-stepper
slot="unwrapped-content"
v-model="step"
class="no-shadow"
flat
non-linear
>
<v-stepper-header class="no-shadow">
<v-stepper-header>
<v-stepper-step
editable
:complete="step > 1"
step="1"
:rules="[() => biographyAlert || true]"
>
Name
Biography
<small v-if="biographyAlert">{{ biographyAlert }}</small>
</v-stepper-step>
<v-divider />
<v-stepper-step
editable
:complete="step > 2"
step="2"
>
Ability Scores
</v-stepper-step>
<v-divider />
<v-stepper-step
:complete="step > 3"
step="3"
>
Class
Libraries
</v-stepper-step>
</v-stepper-header>
@@ -34,195 +33,44 @@
<v-stepper-content step="1">
<v-text-field
v-model="name"
outlined
label="Name"
/>
<v-text-field
v-model="gender"
label="Gender"
class="mt-1"
:error="!name"
/>
<v-text-field
v-model="alignment"
outlined
label="Alignment"
/>
<v-text-field
v-model="gender"
outlined
label="Gender"
/>
<v-text-field
v-model.number="startingLevel"
outlined
label="Level"
type="number"
height="20"
min="0"
@keydown.tab="step++"
/>
</v-stepper-content>
<v-stepper-content step="2">
<v-text-field
v-model="race"
label="Race"
<v-switch
v-model="allSubscribedLibraries"
label="All user libraries"
/>
<v-layout
justify-center
align-center
>
<h3>Point Cost:</h3>
<h1
class="ml-2"
:class="cost > 27 ? 'error--text' : ''"
>
{{ cost }}
</h1>
<span class="ml-1">/27</span>
</v-layout>
<table class="point-buy-table mt-2">
<thead>
<tr class="font-weight-bold">
<td />
<td>Base Values</td>
<td>Race Bonus</td>
<td>Score</td>
<td>Modifier</td>
</tr>
</thead>
<tr>
<td>Strength</td>
<td>
<v-text-field
v-model.number="baseStrength"
type="number"
height="20"
reverse
min="8"
max="15"
/>
</td>
<td>
<v-text-field
v-model.number="strengthBonus"
type="number"
height="20"
reverse
/>
</td>
<td>{{ baseStrength + strengthBonus }}</td>
<td>{{ mod(baseStrength + strengthBonus) }}</td>
</tr>
<tr>
<td>Dexterity</td>
<td>
<v-text-field
v-model.number="baseDexterity"
type="number"
height="20"
reverse
min="8"
max="15"
/>
</td>
<td>
<v-text-field
v-model.number="dexterityBonus"
type="number"
height="20"
reverse
/>
</td>
<td>{{ baseDexterity + dexterityBonus }}</td>
<td>{{ mod(baseDexterity + dexterityBonus) }}</td>
</tr>
<tr>
<td>Constitution</td>
<td>
<v-text-field
v-model.number="baseConstitution"
type="number"
height="20"
reverse
min="8"
max="15"
/>
</td>
<td>
<v-text-field
v-model.number="constitutionBonus"
type="number"
height="20"
reverse
/>
</td>
<td>{{ baseConstitution + constitutionBonus }}</td>
<td>{{ mod(baseConstitution + constitutionBonus) }}</td>
</tr>
<tr>
<td>Intelligence</td>
<td>
<v-text-field
v-model.number="baseIntelligence"
type="number"
height="20"
reverse
min="8"
max="15"
/>
</td>
<td>
<v-text-field
v-model.number="intelligenceBonus"
type="number"
height="20"
reverse
/>
</td>
<td>{{ baseIntelligence + intelligenceBonus }}</td>
<td>{{ mod(baseIntelligence + intelligenceBonus) }}</td>
</tr>
<tr>
<td>Wisdom</td>
<td>
<v-text-field
v-model.number="baseWisdom"
type="number"
height="20"
reverse
min="8"
max="15"
/>
</td>
<td>
<v-text-field
v-model.number="wisdomBonus"
type="number"
height="20"
reverse
/>
</td>
<td>{{ baseWisdom + wisdomBonus }}</td>
<td>{{ mod(baseWisdom + wisdomBonus) }}</td>
</tr>
<tr>
<td>Charisma</td>
<td>
<v-text-field
v-model.number="baseCharisma"
type="number"
height="20"
reverse
min="8"
max="15"
/>
</td>
<td>
<v-text-field
v-model.number="charismaBonus"
type="number"
height="20"
reverse
/>
</td>
<td>{{ baseCharisma + charismaBonus }}</td>
<td>{{ mod(baseCharisma + charismaBonus) }}</td>
</tr>
</table>
</v-stepper-content>
<v-stepper-content step="3">
<v-text-field
v-model="cls"
label="Class"
/>
<v-select
v-model="hitDice"
:items="hitDiceItems"
label="Hit Dice"
<library-list
selection
:disabled="allSubscribedLibraries"
:libraries-selected="librariesSelected"
:library-collections-selected="libraryCollectionsSelected"
:libraries-selected-by-collections="librariesSelectedByCollections"
@select-library="selectLibrary"
@select-library-collection="selectLibraryCollection"
/>
</v-stepper-content>
</v-stepper-items>
@@ -243,15 +91,16 @@
</v-btn>
<v-spacer />
<v-btn
v-if="step < 3"
v-if="step < 2"
color="accent"
@click="step++"
>
Next
</v-btn>
<v-btn
:flat="step < 3"
:color="step < 3? '' : 'accent'"
:disabled="biographyAlert"
:text="step < 2"
:color="step < 2? '' : 'accent'"
@click="submit"
>
Create
@@ -261,109 +110,112 @@
</template>
<script lang="js">
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
const getCost = function(score){
const costs = {
8: 0,
9: 1,
10: 2,
11: 3,
12: 4,
13: 5,
14: 7,
15: 9,
};
if (costs[score] || costs[score] === 0){
return costs[score];
} else {
return NaN;
}
};
export default {
components: {
DialogBase,
import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js';
import { defer, union, without } from 'lodash';
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import insertCreature from '/imports/api/creature/creatures/methods/insertCreature.js';
import LibraryList from '/imports/ui/library/LibraryList.vue';
import LibraryCollections from '/imports/api/library/LibraryCollections.js';
export default {
components: {
DialogBase,
LibraryList,
},
data(){return {
step: 1,
name: 'New Character',
gender: '',
alignment: '',
startingLevel: 1,
librariesSelected: [],
libraryCollectionsSelected: [],
librariesSelectedByCollections: [],
allSubscribedLibraries: true,
}},
computed: {
biographyAlert() {
if (!this.name) return 'Name required';
return undefined;
}
},
meteor: {
$subscribe: {
'libraries': [],
},
data(){return {
step: 1,
name: 'New Character',
gender: '',
alignment: '',
race: 'Race',
baseStrength: 10,
baseDexterity: 10,
baseConstitution: 10,
baseIntelligence: 10,
baseWisdom: 10,
baseCharisma: 10,
strengthBonus: 0,
dexterityBonus: 0,
constitutionBonus: 0,
intelligenceBonus: 0,
wisdomBonus: 0,
charismaBonus: 0,
hitDiceItems: ['d6', 'd8', 'd10', 'd12'],
hitDice: 'd8',
cls: 'Class',
}},
computed: {
cost(){
return [
this.baseStrength,
this.baseDexterity,
this.baseConstitution,
this.baseIntelligence,
this.baseWisdom,
this.baseCharisma,
].map(getCost)
.reduce((memo, score) => memo + score, 0);
},
},
methods: {
mod(score){
let mod = Math.floor((score - 10) / 2);
if (mod >= 0) {
return `+${mod}`;
} else {
return `${mod}`;
}
},
submit(){
let char = {
name: this.name,
gender: this.gender,
alignment: this.alignment,
race: this.race,
baseStrength: this.baseStrength,
baseDexterity: this.baseDexterity,
baseConstitution: this.baseConstitution,
baseIntelligence: this.baseIntelligence,
baseWisdom: this.baseWisdom,
baseCharisma: this.baseCharisma,
strengthBonus: this.strengthBonus,
dexterityBonus: this.dexterityBonus,
constitutionBonus: this.constitutionBonus,
intelligenceBonus: this.intelligenceBonus,
wisdomBonus: this.wisdomBonus,
charismaBonus: this.charismaBonus,
hitDice: this.hitDice,
cls: this.cls,
};
this.$emit('pop', char);
},
},
};
},
methods: {
selectLibrary(libraryId, val) {
if (val) {
this.librariesSelected = union(this.librariesSelected, [libraryId]);
} else {
this.librariesSelected = without(this.librariesSelected, libraryId);
}
},
selectLibraryCollection(libraryCollectionId, val) {
const collection = LibraryCollections.findOne(libraryCollectionId);
if (!collection) return;
if (val) {
this.libraryCollectionsSelected = union(
this.libraryCollectionsSelected,
[libraryCollectionId]
);
this.librariesSelectedByCollections = union(
this.librariesSelectedByCollections,
collection.libraries
);
} else {
this.libraryCollectionsSelected = without(
this.libraryCollectionsSelected,
libraryCollectionId,
);
this.librariesSelectedByCollections = without(
this.librariesSelectedByCollections,
...collection.libraries
);
}
},
submit(){
let char = {
name: this.name,
gender: this.gender,
alignment: this.alignment,
startingLevel: this.startingLevel,
};
if (!this.allSubscribedLibraries) {
char.allowedLibraries = this.librariesSelected;
char.allowedLibraryCollections = this.libraryCollectionsSelected;
}
insertCreature.call(char, (error, creatureId) => {
if (error){
console.error(error);
snackbar({
text: error.reason,
});
} else {
this.$store.commit(
'setTabForCharacterSheet',
{id: creatureId, tab: 4}
);
this.$emit('pop', creatureId);
defer(() => {
this.$router.push({ name: 'characterSheet', params: {id: creatureId} });
});
return creatureId;
}
});
},
}
};
</script>
<style scoped>
.no-shadow {
box-shadow: none;
}
.point-buy-table {
width: 100%;
}
.point-buy-table td {
text-align: center;
padding: 0 8px 0 8px;
max-width: 50px;
}
.point-buy-table {
width: 100%;
}
.point-buy-table td {
text-align: center;
padding: 0 8px 0 8px;
max-width: 50px;
}
</style>

View File

@@ -19,6 +19,7 @@
:is-selected="selectedCreature === creature._id"
v-bind="selection ? {} : {to: creature.url}"
:dense="dense"
:data-id="dense ? undefined : creature._id"
@click="$emit('creature-selected', creature._id)"
/>
</draggable>

View File

@@ -2,7 +2,7 @@
<div class="slots-to-fill">
<v-slide-y-transition
group
class="d-flex flex-row flex-wrap"
leave-absolute
>
<slot-card
v-for="slot in slots"
@@ -10,6 +10,7 @@
:model="slot"
class="ma-1"
hover
style="display: inline-block !important; transition: all 0.3s !important;"
@ignore="ignoreSlot(slot._id)"
@click="fillSlot(slot._id)"
/>
@@ -54,7 +55,7 @@ export default {
},
callback(nodeIds){
if (!nodeIds || !nodeIds.length) return;
let newPropertyId = insertPropertyFromLibraryNode.call({
insertPropertyFromLibraryNode.call({
nodeIds,
parentRef: {
'id': slotId,
@@ -66,7 +67,6 @@ export default {
snackbar({text: error.reason || error.message || error.toString()});
}
});
return `slot-child-${newPropertyId}`;
}
});
},
@@ -100,5 +100,4 @@ export default {
</script>
<style>
</style>

View File

@@ -12,6 +12,8 @@
</p>
<v-text-field
v-if="name"
label="Confirmation"
outlined
v-model="inputName"
/>
<div class="layout justify-center">

View File

@@ -2,8 +2,19 @@
<v-list-item
style="min-height: 60px; min-width: 0;"
class="px-0 font-weight-bold"
:class="isSelected && !disabled && 'primary--text v-list-item--active'"
>
<v-list-item-avatar>
<v-list-item-action
v-if="selection"
>
<v-checkbox
:disabled="disabled"
:input-value="disabled || isSelected"
@change="e => $emit('select', e)"
@click.stop
/>
</v-list-item-action>
<v-list-item-avatar v-else>
<shared-icon :model="model" />
</v-list-item-avatar>
<v-list-item-title class="d-flex align-center">
@@ -56,6 +67,8 @@ export default {
open: Boolean,
selection: Boolean,
dense: Boolean,
isSelected: Boolean,
disabled: Boolean,
},
data(){return {
renaming: false,

View File

@@ -0,0 +1,202 @@
<template>
<v-list
expand
class="library-list"
>
<library-list-tile
v-for="library in librariesWithoutCollection"
:key="library._id"
:model="library"
:to="{ name: 'singleLibrary', params: { id: library._id }}"
:selection="selection"
:is-selected="librariesSelected && librariesSelected.includes(library._id)"
:selected-by-collection="librariesSelectedByCollections && librariesSelectedByCollections.includes(library._id)"
:disabled="disabled"
@select="val => $emit('select-library', library._id, val)"
/>
<v-list-group
v-for="libraryCollection in libraryCollections"
:key="libraryCollection._id"
v-model="openCollections[libraryCollection._id]"
group="library-collection"
:data-id="`library-collection-${libraryCollection._id}`"
>
<template #activator>
<library-collection-header
:open="openCollections[libraryCollection._id]"
:model="libraryCollection"
:selection="selection"
:is-selected="libraryCollectionsSelected && libraryCollectionsSelected.includes(libraryCollection._id)"
:disabled="disabled"
@select="val => $emit('select-library-collection', libraryCollection._id, val)"
/>
</template>
<library-list-tile
v-for="library in libraryCollection.libraryDocuments"
:key="library._id"
:model="library"
:to="{ name: 'singleLibrary', params: { id: library._id }}"
:selection="selection"
:is-selected="librariesSelected && librariesSelected.includes(library._id)"
:selected-by-collection="librariesSelectedByCollections && librariesSelectedByCollections.includes(library._id)"
:disabled="disabled"
class="ml-4"
@select="val => $emit('select-library', library._id, val)"
/>
</v-list-group>
<v-expand-transition>
<v-row
v-if="!$subReady.libraries"
align="center"
justify="center"
class="pa-4"
>
<v-progress-circular
indeterminate
color="primary"
size="32"
/>
</v-row>
</v-expand-transition>
</v-list>
</template>
<script lang="js">
import { union } from 'lodash';
import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js';
import LibraryCollections, { insertLibraryCollection } from '/imports/api/library/LibraryCollections.js';
import Libraries, { insertLibrary } from '/imports/api/library/Libraries.js';
import LibraryListTile from '/imports/ui/library/LibraryListTile.vue'
import LibraryCollectionHeader from '/imports/ui/library/LibraryCollectionHeader.vue';
export default {
components: {
LibraryListTile,
LibraryCollectionHeader,
},
props: {
selection: Boolean,
disabled: Boolean,
librariesSelected: {
type: Array,
default: undefined,
},
libraryCollectionsSelected: {
type: Array,
default: undefined,
},
librariesSelectedByCollections: {
type: Array,
default: undefined,
},
},
data(){ return{
loadingInsertLibraryCollection: false,
openCollections: [],
}},
meteor: {
$subscribe: {
'libraries': [],
},
libraryCollections(){
const userId = Meteor.userId();
if (!userId) return;
const subCollections = Meteor.user().subscribedLibraryCollections || [];
return LibraryCollections.find({
$or: [
{ owner: userId },
{ writers: userId },
{ readers: userId },
{ _id: { $in: subCollections }, public: true },
]
}, {
sort: { name: 1 }
}).map(libCollection => {
libCollection.libraryDocuments = Libraries.find({
_id: {$in: libCollection.libraries},
$or: [
{ owner: userId },
{ writers: userId },
{ readers: userId },
{ public: true },
]
}, {
sort: { name: 1 }
}).fetch();
return libCollection;
});
},
librariesWithoutCollection() {
const userId = Meteor.userId();
if (!this.libraryCollections) return;
// Collate the IDs of all the libraries in collections
let collectedLibraries = [];
this.libraryCollections.forEach(libCollection => {
collectedLibraries = union(collectedLibraries, libCollection.libraries);
});
// return the libraries with IDs not in that list
return Libraries.find(
{
_id: {$nin: collectedLibraries},
$or: [
{ owner: userId },
{ writers: userId },
{ readers: userId },
{ public: true },
]
},
{sort: {name: 1}}
);
},
},
methods: {
insertLibrary() {
const self = this;
if (this.paidBenefits){
this.$store.commit('pushDialogStack', {
component: 'library-creation-dialog',
elementId: 'insert-library-button',
callback(library){
if (!library) return;
return insertLibrary.call(library, (error, libraryId) => {
if (error){
console.error(error);
snackbar({
text: error.reason,
});
} else {
self.$router.push({
name: 'singleLibrary',
params: { id: libraryId }
});
}
});
}
});
} else {
this.$store.commit('pushDialogStack', {
component: 'tier-too-low-dialog',
elementId: 'insert-library-button',
});
}
},
insertLibraryCollection() {
this.$store.commit('pushDialogStack', {
component: 'library-collection-creation-dialog',
elementId: 'insert-library-collection-button',
callback(libraryCollection){
if (!libraryCollection) return;
const id = insertLibraryCollection.call(libraryCollection, error => {
if (!error) return;
console.error(error);
snackbar({
text: error.reason,
});
});
return `library-collection-${id}`
}
});
},
},
};
</script>

View File

@@ -4,9 +4,21 @@
>
<v-list-item
v-bind="$attrs"
:class="isSelected && 'primary--text v-list-item--active'"
:class="(isSelected || selectedByCollection) && !disabled && 'primary--text v-list-item--active'"
:to="selection ? undefined : to"
>
<v-list-item-avatar>
<v-list-item-action
v-if="selection"
>
<v-checkbox
:disabled="disabled"
:input-value="disabled || isSelected"
:off-icon="selectedByCollection ? 'mdi-checkbox-intermediate' : undefined"
@change="e => $emit('select', e)"
@click.stop
/>
</v-list-item-action>
<v-list-item-avatar v-else>
<shared-icon :model="model" />
</v-list-item-avatar>
<v-list-item-content>
@@ -31,7 +43,12 @@ export default {
},
selection: Boolean,
isSelected: Boolean,
dense: Boolean,
selectedByCollection: Boolean,
disabled: Boolean,
to: {
type: Object,
required: true,
}
}
}
</script>

View File

@@ -74,127 +74,108 @@
</template>
<script lang="js">
import { defer } from 'lodash';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import insertCreature from '/imports/api/creature/creatures/methods/insertCreature.js';
import CreatureFolders from '/imports/api/creature/creatureFolders/CreatureFolders.js';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import insertCreatureFolder from '/imports/api/creature/creatureFolders/methods.js/insertCreatureFolder.js';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
import CreatureFolderList from '/imports/ui/creature/creatureList/CreatureFolderList.vue';
import ArchiveButton from '/imports/ui/creature/creatureList/ArchiveButton.vue';
import getCreatureUrlName from '/imports/api/creature/creatures/getCreatureUrlName.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import CreatureFolders from '/imports/api/creature/creatureFolders/CreatureFolders.js';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import insertCreatureFolder from '/imports/api/creature/creatureFolders/methods.js/insertCreatureFolder.js';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
import CreatureFolderList from '/imports/ui/creature/creatureList/CreatureFolderList.vue';
import ArchiveButton from '/imports/ui/creature/creatureList/ArchiveButton.vue';
import getCreatureUrlName from '/imports/api/creature/creatures/getCreatureUrlName.js';
const characterTransform = function(char){
char.url = `/character/${char._id}/${getCreatureUrlName(char)}`;
char.initial = char.name && char.name[0] || '?';
return char;
};
export default {
components: {
CreatureFolderList,
ArchiveButton,
const characterTransform = function(char){
char.url = `/character/${char._id}/${getCreatureUrlName(char)}`;
char.initial = char.name && char.name[0] || '?';
return char;
};
export default {
components: {
CreatureFolderList,
ArchiveButton,
},
data(){ return{
fab: false,
loadingInsertFolder: false,
renamingFolder: undefined,
}},
meteor: {
$subscribe: {
'characterList': [],
},
data(){ return{
fab: false,
loadingInsertFolder: false,
renamingFolder: undefined,
}},
meteor: {
$subscribe: {
'characterList': [],
},
folders(){
const userId = Meteor.userId();
let folders = CreatureFolders.find(
{owner: userId, archived: {$ne: true}},
{sort: {order: 1}},
).map(folder => {
folder.creatures = Creatures.find(
{
_id: {$in: folder.creatures || []},
$or: [{readers: userId}, {writers: userId}, {owner: userId}],
}, {
sort: {name: 1},
}
).map(characterTransform);
return folder;
});
return folders;
},
CreaturesWithNoParty() {
var userId = Meteor.userId();
var charArrays = CreatureFolders.find({owner: userId}).map(p => p.creatures);
var folderChars = _.uniq(_.flatten(charArrays));
return Creatures.find(
folders(){
const userId = Meteor.userId();
let folders = CreatureFolders.find(
{owner: userId, archived: {$ne: true}},
{sort: {order: 1}},
).map(folder => {
folder.creatures = Creatures.find(
{
_id: {$nin: folderChars},
_id: {$in: folder.creatures || []},
$or: [{readers: userId}, {writers: userId}, {owner: userId}],
},
{sort: {name: 1}}
).map(characterTransform);
},
creatureCount(){
let userId = Meteor.userId();
return Creatures.find({
owner: userId,
}, {
fields: {_id: 1},
}).count();
},
tier(){
let userId = Meteor.userId();
return getUserTier(userId);
},
characterSpaceLeft(){
let tier = this.tier;
let currentCharacterCount = this.creatureCount;
if (tier.characterSlots === -1) return Number.POSITIVE_INFINITY;
return tier.characterSlots - currentCharacterCount
},
exceededCharacterSpace(){
let tier = this.tier;
let currentCharacterCount = this.creatureCount;
return tier.characterSlots !== -1 && currentCharacterCount > tier.characterSlots
},
},
methods: {
insertCharacter(){
insertCreature.call((error, creatureId) => {
if (error){
console.error(error);
snackbar({
text: error.reason,
});
} else {
this.$store.commit(
'setTabForCharacterSheet',
{id: creatureId, tab: 4}
);
defer(() => {
this.$store.commit('pushDialogStack', {
component: 'character-creation-dialog',
elementId: 'new-character-button',
data: {
creatureId,
},
});
})
this.$router.push({ path: `/character/${creatureId}` });
}, {
sort: {name: 1},
}
});
},
insertFolder(){
this.loadingInsertFolder = true;
insertCreatureFolder.call(error => {
this.loadingInsertFolder = false;
if (!error) return;
console.error(error);
snackbar({
text: error.reason,
});
});
},
).map(characterTransform);
return folder;
});
return folders;
},
};
CreaturesWithNoParty() {
var userId = Meteor.userId();
var charArrays = CreatureFolders.find({owner: userId}).map(p => p.creatures);
var folderChars = _.uniq(_.flatten(charArrays));
return Creatures.find(
{
_id: {$nin: folderChars},
$or: [{readers: userId}, {writers: userId}, {owner: userId}],
},
{sort: {name: 1}}
).map(characterTransform);
},
creatureCount(){
let userId = Meteor.userId();
return Creatures.find({
owner: userId,
}, {
fields: {_id: 1},
}).count();
},
tier(){
let userId = Meteor.userId();
return getUserTier(userId);
},
characterSpaceLeft(){
let tier = this.tier;
let currentCharacterCount = this.creatureCount;
if (tier.characterSlots === -1) return Number.POSITIVE_INFINITY;
return tier.characterSlots - currentCharacterCount
},
exceededCharacterSpace(){
let tier = this.tier;
let currentCharacterCount = this.creatureCount;
return tier.characterSlots !== -1 && currentCharacterCount > tier.characterSlots
},
},
methods: {
insertCharacter() {
const self = this;
self.$store.commit('pushDialogStack', {
component: 'character-creation-dialog',
elementId: 'new-character-button',
callback: creatureId => creatureId,
});
},
insertFolder(){
this.loadingInsertFolder = true;
insertCreatureFolder.call(error => {
this.loadingInsertFolder = false;
if (!error) return;
console.error(error);
snackbar({
text: error.reason,
});
});
},
},
};
</script>

View File

@@ -10,38 +10,7 @@
xl="8"
>
<v-card :class="{'mb-4': libraryCollections && libraryCollections.length}">
<v-list
expand
class="library-folder-list"
>
<library-list-tile
v-for="library in librariesWithoutCollection"
:key="library._id"
:model="library"
:to="{ name: 'singleLibrary', params: { id: library._id }}"
/>
<v-list-group
v-for="libraryCollection in libraryCollections"
:key="libraryCollection._id"
v-model="openCollections[libraryCollection._id]"
group="library-collection"
:data-id="`library-collection-${libraryCollection._id}`"
>
<template #activator>
<library-collection-header
:open="openCollections[libraryCollection._id]"
:model="libraryCollection"
/>
</template>
<library-list-tile
v-for="library in libraryCollection.libraryDocuments"
:key="library._id"
:model="library"
:to="{ name: 'singleLibrary', params: { id: library._id }}"
class="ml-4"
/>
</v-list-group>
</v-list>
<library-list />
<v-expand-transition>
<v-row
v-if="!$subReady.libraries"
@@ -92,13 +61,11 @@ import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js';
import LibraryCollections, { insertLibraryCollection } from '/imports/api/library/LibraryCollections.js';
import Libraries, { insertLibrary } from '/imports/api/library/Libraries.js';
import LibraryListTile from '/imports/ui/library/LibraryListTile.vue'
import LibraryCollectionHeader from '/imports/ui/library/LibraryCollectionHeader.vue';
import LibraryList from '/imports/ui/library/LibraryList.vue';
export default {
components: {
LibraryListTile,
LibraryCollectionHeader,
LibraryList
},
data(){ return{
loadingInsertLibraryCollection: false,

View File

@@ -163,7 +163,8 @@ RouterFactory.configure(router => {
meta: {
title: 'Library Collection',
},
},{
}, {
name: 'characterSheet',
path: '/character/:id',
alias: '/character/:id/:urlName',
components: {

View File

@@ -15,4 +15,4 @@ import '/imports/migrations/methods/index.js'
import '/imports/constants/MAINTENANCE_MODE.js';
import '/imports/api/creature/creatureProperties/methods/index.js';
import '/imports/api/creature/archive/methods/index.js';
import '/imports/api/creature/creatures/methods/index.js';