Compare commits

..

43 Commits

Author SHA1 Message Date
Stefan Zermatten
8162c76185 Added basic ownership transfer for shared documents 2021-08-10 19:01:31 +02:00
Stefan Zermatten
e21586e9ce Added reasonable storage limits to most string and array schemas 2021-08-10 18:12:55 +02:00
Stefan Zermatten
4c2155d8ff Buffs applied property can now be set in both library and character 2021-08-10 17:02:27 +02:00
Stefan Zermatten
44cc46ed22 Added the ability to add buffs as already applied straight from a library 2021-08-10 16:47:05 +02:00
Stefan Zermatten
d2b5d5f01d Fixed swiping between tabs when spells tab is hidden 2021-08-10 15:20:04 +02:00
Stefan Zermatten
4492c47b00 Fixed item tree node view only using quantity display if quantity is a number 2021-08-10 15:03:30 +02:00
Stefan Zermatten
6600cea9fa Fixed references display in tree views 2021-08-10 15:02:24 +02:00
Stefan Zermatten
741a9b080a Finished implementing new slot fill dialog, including ability to test slots 2021-08-10 14:35:27 +02:00
Stefan Zermatten
b041db22e4 Merge branch 'version-2' into version-2-dev 2021-08-10 13:31:32 +02:00
Stefan Zermatten
a465e2ce87 Continued implementing new slot dialog 2021-08-10 13:29:11 +02:00
Stefan Zermatten
8ecefb03ad Started re-implementing slot fill dialog with different design pattern 2021-08-10 10:32:56 +02:00
Stefan Zermatten
9f62a78eb0 Began work on implementing string and array storage limits 2021-08-09 22:34:04 +02:00
Stefan Zermatten
16e2b1249f Increased slot filler publication load quantity to 50 2021-08-09 20:54:29 +02:00
Stefan Zermatten
a35f9221a2 Fixed granting XP being accidentally locked behind patreon paid benefits 2021-08-09 18:06:04 +02:00
Stefan Zermatten
6364549d50 Added pretty url's from v1 2021-08-09 18:00:55 +02:00
Stefan Zermatten
d999fb46a7 Fixed a warning about source map failure when trying to define a scss variable 2021-08-09 17:57:35 +02:00
Stefan Zermatten
ec01a2adb5 Merge pull request #274 from GuillaumeDerval/version-2
Fix items/buffs/... refresh after casting a spell
2021-08-09 17:11:41 +02:00
Guillaume Derval
1f64558100 Fixed items/buffs/... refresh after casting a spell 2021-08-07 23:20:26 +02:00
Stefan Zermatten
19a2798bf7 Fixed tree item search highlighting in dark mode 2021-08-02 01:13:15 +02:00
Stefan Zermatten
a5f2c2e0d2 Removed duplicate property button on tree tab 2021-08-02 00:54:59 +02:00
Stefan Zermatten
ee174210fd Added search to library tree views 2021-08-02 00:29:56 +02:00
Stefan Zermatten
1e38295164 All properties added to the sheet now use the type/library/create UX 2021-08-01 23:28:04 +02:00
Stefan Zermatten
758cb2f8bc Fixed search icons 2021-07-31 21:53:18 +02:00
Stefan Zermatten
36bb3c3181 New UX for inserting properties from libraries including text search and multi-add 2021-07-31 21:49:15 +02:00
Stefan Zermatten
02434de34c Drastically improved tree tab search UX for locating parts of the sheet 2021-07-31 15:19:54 +02:00
Stefan Zermatten
0dc0bea53e fixes #262, emails from DiceCloud should now be from no-reply@dicecloud.com 2021-07-27 16:59:09 +02:00
Stefan Zermatten
c392119430 Added suggested parents to PROPERTIES for use later with user guidance 2021-07-27 16:36:19 +02:00
Stefan Zermatten
4e2e0ca364 Improved display of referenced properties 2021-07-27 16:21:55 +02:00
Stefan Zermatten
4a8b72f163 Made sure the unsubscribe button always shows on libraries you are subscribed to 2021-07-27 15:51:09 +02:00
Stefan Zermatten
d916dc2b78 Removed add library node buttons from libraries the user deosn't have edit permissions for 2021-07-27 15:44:26 +02:00
Stefan Zermatten
56860ba96d Fixed proficiency Tree node view not showing the name if it is defined 2021-07-27 15:40:16 +02:00
Stefan Zermatten
b607755f9f Fixed attribute base value calculation errors not being cleared if no new errors were made 2021-07-27 15:37:57 +02:00
Stefan Zermatten
86d8fa4325 Fixed items not animating correctly on insert 2021-07-27 15:33:41 +02:00
Stefan Zermatten
2b08249e5e removed lazy from v-menu which caused a console warning 2021-07-27 15:29:05 +02:00
Stefan Zermatten
3133e664d5 Reduced stats computation precision to round off small decimal floating point errors 2021-07-27 15:28:44 +02:00
Stefan Zermatten
48f32e0a8d Removed floating point small decimal oddities in parts of inventory tab 2021-07-27 15:21:35 +02:00
Stefan Zermatten
c72785c9e7 Added components to spell viewer 2021-07-27 15:00:10 +02:00
Stefan Zermatten
421ff2aa7d Fixed DISABLE_PATREON not working, it's now a Meteor setting instead of an ENV variable 2021-07-27 14:31:54 +02:00
Stefan Zermatten
9a9e6491b9 Improved usability with better hints in property forms and property type selection 2021-07-26 18:19:29 +02:00
Stefan Zermatten
332258705c Added service worker 2021-07-21 13:44:55 +02:00
Stefan Zermatten
73ef109d4d Added calculation errors that were missing 2021-07-20 10:37:32 +02:00
Stefan Zermatten
fc240a34c4 Changed tiers to their open beta configuration 2021-07-17 12:50:38 +02:00
Stefan Zermatten
8ac4028f38 Removed limit from guest tiers for closed beta 2021-07-14 00:59:32 +02:00
150 changed files with 3519 additions and 1767 deletions

View File

@@ -78,8 +78,12 @@ NPM_CONFIG_PRODUCTION=true
PROJECT_DIR=app
ROOT_URL=https://<url of your DiceCloud instance>
DEFAULT_LIBRARIES=<comma separated list of library ids that will be subscribed by default: "abc123,def456">
DISABLE_PATREON=<"true" if you want to prevent features being locked behind Patreon tiers>
```
To disable Patreon features and unlock all paid restrictions for all users of your deployment, replace
`"patreon": { "clientId": ... }"` with `"disablePatreon": true` in the public key of the METEOR_SETTINGS environment variable.
Alternatively run `meteor run --settings exampleMeteorSettings.json` to start the app with the example settings that disable Patreon by default.
Now, visiting [](http://localhost:3000/) should show you an empty instance of
DiceCloud running.

View File

@@ -1,3 +1,4 @@
import '/imports/ui/vueSetup.js';
import '/imports/ui/styles/stylesIndex.js';
import '/imports/client/config.js';
import '/imports/client/serviceWorker.js';

View File

@@ -0,0 +1,6 @@
{
"public": {
"environment": "production",
"disablePatreon": true
}
}

View File

View File

@@ -9,6 +9,8 @@ import { recomputeCreatureByDoc } from '/imports/api/creature/computation/method
import { doActionWork } from '/imports/api/creature/actions/doAction.js';
import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
import getAncestorContext from '/imports/api/creature/actions/getAncestorContext.js';
import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory';
import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties';
const castSpellWithSlot = new ValidatedMethod({
name: 'creatureProperties.castSpellWithSlot',
@@ -64,16 +66,24 @@ const castSpellWithSlot = new ValidatedMethod({
}
let actionContext = getAncestorContext(spell);
doActionWork({
doActionWork({
action: spell,
actionContext: {slotLevel, ...actionContext},
creature,
targets: target ? [target] : [],
method: this,
});
// Note this only recomputes the top-level creature, not the nearest one
recomputeCreatureByDoc(creature);
// Note these lines only recompute the top-level creature, not the nearest one
// The acting creature might have a new item
recomputeInventory(creature._id);
// The spell might add properties which need to be activated
recomputeInactiveProperties(creature._id);
recomputeCreatureByDoc(creature);
if (target){
recomputeInventory(target._id);
recomputeInactiveProperties(target._id);
recomputeCreatureByDoc(target);
}
},

View File

@@ -7,7 +7,7 @@ import { CreatureLogSchema, insertCreatureLogWork } from '/imports/api/creature/
import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
import { nodesToTree } from '/imports/api/parenting/parenting.js';
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
import applyProperties from '/imports/api/creature/actions/applyProperties.js';
import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';

View File

@@ -1,6 +1,7 @@
import computeStat from '/imports/api/creature/computation/engine/computeStat.js';
import computeProficiency from '/imports/api/creature/computation/engine/computeProficiency.js';
import evaluateCalculation from '/imports/api/creature/computation/engine/evaluateCalculation.js';
import stripFloatingPointOddities from '/imports/ui/utility/stripFloatingPointOddities.js';
import { union } from 'lodash';
export default function combineStat(stat, aggregator, memo){
@@ -34,6 +35,8 @@ function getAggregatorResult(stat, aggregator){
}
if (!stat.decimal && Number.isFinite(result)){
result = Math.floor(result);
} else if (Number.isFinite(result)){
result = stripFloatingPointOddities(result);
}
return result;
}

View File

@@ -93,6 +93,7 @@ export default function computeStat(stat, memo){
// Compute each active stat's baseValue calculation and apply it
if (!statInstance.inactive) {
delete statInstance.baseValueErrors;
let {
result,
context,

View File

@@ -1,4 +1,5 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let CreatureFolders = new Mongo.Collection('creatureFolders');
@@ -7,6 +8,7 @@ let creatureFolderSchema = new SimpleSchema({
type: String,
trim: false,
optional: true,
max: STORAGE_LIMITS.name,
},
creatures: {
type: Array,

View File

@@ -5,6 +5,7 @@ import ChildSchema from '/imports/api/parenting/ChildSchema.js';
import SoftRemovableSchema from '/imports/api/parenting/SoftRemovableSchema.js';
import propertySchemasIndex from '/imports/api/properties/computedPropertySchemasIndex.js';
import { storedIconsSchema } from '/imports/api/icons/Icons.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let CreatureProperties = new Mongo.Collection('creatureProperties');
@@ -16,9 +17,11 @@ let CreaturePropertySchema = new SimpleSchema({
tags: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'tags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
disabled: {
type: Boolean,
@@ -27,6 +30,13 @@ let CreaturePropertySchema = new SimpleSchema({
icon: {
type: storedIconsSchema,
optional: true,
max: STORAGE_LIMITS.icon,
},
// Reference to the library node that this property was copied from
libraryNodeId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
// Denormalised flag if this property is inactive on the sheet for any reason
// Including being disabled, or a decendent of a disabled property

View File

@@ -0,0 +1,44 @@
export default function getSlotFillFilter({slot, libraryIds}){
let filter = {
removed: {$ne: true},
$and: []
};
if (libraryIds){
filter['ancestors.id'] = {$in: libraryIds};
}
if (slot.slotType){
filter.$and.push({
$or: [{
type: slot.slotType
},{
type: 'slotFiller',
slotFillerType: slot.slotType,
}]
});
}
let tagsOr = [];
let tagsNor = [];
if (slot.slotTags && slot.slotTags.length){
tagsOr.push({tags: {$all: slot.slotTags}});
}
if (slot.extraTags && slot.extraTags.length){
slot.extraTags.forEach(extra => {
if (!extra.tags || !extra.tags.length) return;
if (extra.operation === 'OR'){
tagsOr.push({tags: {$all: extra.tags}});
} else if (extra.operation === 'NOT'){
tagsNor.push({tags: {$all: extra.tags}});
}
});
}
if (tagsOr.length){
filter.$and.push({$or: tagsOr});
}
if (tagsNor.length){
filter.$and.push({$nor: tagsNor});
}
if (!filter.$and.length){
delete filter.$and;
}
return filter;
}

View File

@@ -21,7 +21,11 @@ import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
const insertPropertyFromLibraryNode = new ValidatedMethod({
name: 'creatureProperties.insertPropertyFromLibraryNode',
validate: new SimpleSchema({
nodeId: {
nodeIds: {
type: Array,
max: 20,
},
'nodeIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
@@ -38,7 +42,7 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
numRequests: 5,
timeInterval: 5000,
},
run({nodeId, parentRef, order}) {
run({nodeIds, parentRef, order}) {
// get the new ancestry for the properties
let {parentDoc, ancestors} = getAncestry({parentRef});
@@ -53,54 +57,15 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
}
assertEditPermission(rootCreature, this.userId);
// Fetch the library node and its decendents, provided they have not been
// removed
// TODO: Check permission to read the library this node is in
let node = LibraryNodes.findOne({
_id: nodeId,
removed: {$ne: true},
});
if (!node) throw `Node not found for nodeId: ${nodeId}`;
let oldParent = node.parent;
let nodes = LibraryNodes.find({
'ancestors.id': nodeId,
removed: {$ne: true},
}).fetch();
// {libraryId: hasViewPermission}
//let libraryPermissionMemoir = {};
let node;
nodeIds.forEach(nodeId => {
// TODO: Check library view permission for each node before starting
node = insertPropertyFromNode(nodeId, ancestors, order);
});
// Convert all references into actual nodes
nodes = reifyNodeReferences(nodes);
// The root node is first in the array of nodes
// It must get the first generated ID to prevent flickering
nodes = [node, ...nodes];
// re-map all the ancestors
setLineageOfDocs({
docArray: nodes,
newAncestry: ancestors,
oldParent,
});
// Give the docs new IDs without breaking internal references
renewDocIds({
docArray: nodes,
collectionMap: {'libraryNodes': 'creatureProperties'}
});
// Order the root node
if (order === undefined){
setDocToLastOrder({
collection: CreatureProperties,
doc: node,
});
} else {
node.order = order;
}
// Insert the creature properties
CreatureProperties.batchInsert(nodes);
// get the root inserted doc
// get one of the root inserted docs
let rootId = node._id;
// Tree structure changed by inserts, reorder the tree
@@ -110,7 +75,7 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
});
// The library properties need to denormalise which of them are inactive
recomputeInactiveProperties(rootId);
recomputeInactiveProperties(rootCreature._id);
// Some of the library properties may be items or containers
recomputeInventory(rootCreature._id);
// Inserting a creature property invalidates dependencies: full recompute
@@ -120,6 +85,66 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
},
});
function insertPropertyFromNode(nodeId, ancestors, order){
// Fetch the library node and its decendents, provided they have not been
// removed
// TODO: Check permission to read the library this node is in
let node = LibraryNodes.findOne({
_id: nodeId,
removed: {$ne: true},
});
if (!node) throw `Node not found for nodeId: ${nodeId}`;
let oldParent = node.parent;
let nodes = LibraryNodes.find({
'ancestors.id': nodeId,
removed: {$ne: true},
}).fetch();
// Convert all references into actual nodes
nodes = reifyNodeReferences(nodes);
// The root node is first in the array of nodes
// It must get the first generated ID to prevent flickering
nodes = [node, ...nodes];
// set libraryNodeIds
storeLibraryNodeReferences(nodes, nodeId);
// re-map all the ancestors
setLineageOfDocs({
docArray: nodes,
newAncestry: ancestors,
oldParent,
});
// Give the docs new IDs without breaking internal references
renewDocIds({
docArray: nodes,
collectionMap: {'libraryNodes': 'creatureProperties'}
});
// Order the root node
if (order === undefined){
setDocToLastOrder({
collection: CreatureProperties,
doc: node,
});
} else {
node.order = order;
}
// Insert the creature properties
CreatureProperties.batchInsert(nodes);
return node;
}
function storeLibraryNodeReferences(nodes){
nodes.forEach(node => {
node.libraryNodeId = node._id;
});
}
// Covert node references into actual nodes
// TODO: check permissions for each library a reference node references
function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){
@@ -194,7 +219,7 @@ function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){
// TODO: Force the referencedNode to take the old id of the reference
// such that the reference's children can be kept
// Give the new referenced sub-tree new ids
renewDocIds({
docArray: addedNodes,

View File

@@ -4,6 +4,7 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js';
import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
import { get } from 'lodash';
const pushToProperty = new ValidatedMethod({
name: 'creatureProperties.push',
@@ -19,9 +20,26 @@ const pushToProperty = new ValidatedMethod({
let rootCreature = getRootCreatureAncestor(property);
assertEditPermission(rootCreature, this.userId);
let joinedPath = path.join('.');
// Respect maxCount
let schema = CreatureProperties.simpleSchema(property);
let maxCount = schema.get(joinedPath, 'maxCount');
if (Number.isFinite(maxCount)){
let array = get(property, path);
let currentCount = array ? array.length : 0;
if (currentCount >= maxCount){
throw new Meteor.Error(
'Array is full',
`Cannot have more than ${maxCount} values`
);
}
}
// Do work
CreatureProperties.update(_id, {
$push: {[path.join('.')]: value},
$push: {[joinedPath]: value},
}, {
selector: {type: property.type},
});

View File

@@ -2,6 +2,7 @@ import SimpleSchema from 'simpl-schema';
import deathSaveSchema from '/imports/api/properties/subSchemas/DeathSavesSchema.js'
import ColorSchema from '/imports/api/properties/subSchemas/ColorSchema.js';
import SharingSchema from '/imports/api/sharing/SharingSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
//set up the collection for creatures
let Creatures = new Mongo.Collection('creatures');
@@ -47,7 +48,7 @@ let CreatureSettingsSchema = new SimpleSchema({
discordWebhook: {
type: String,
optional: true,
max: 200,
max: STORAGE_LIMITS.url,
},
});
@@ -57,22 +58,27 @@ let CreatureSchema = new SimpleSchema({
type: String,
defaultValue: '',
optional: true,
max: STORAGE_LIMITS.name,
},
alignment: {
type: String,
optional: true
optional: true,
max: STORAGE_LIMITS.name,
},
gender: {
type: String,
optional: true
optional: true,
max: STORAGE_LIMITS.name,
},
picture: {
type: String,
optional: true
optional: true,
max: STORAGE_LIMITS.url,
},
avatarPicture: {
type: String,
optional: true,
max: STORAGE_LIMITS.url,
},
// Mechanics
deathSave: {

View File

@@ -0,0 +1,5 @@
import getSlug from 'speakingurl';
export default function getCreatureUrlName({name}){
return getSlug(name, {maintainCase: true}) || '-';
}

View File

@@ -1,5 +1,4 @@
import '/imports/api/creature/creatures/methods/insertCreature.js';
import '/imports/api/creature/creatures/methods/removeCreature.js';
import '/imports/api/creature/creatures/methods/restCreature.js';
import '/imports/api/creature/creatures/methods/transferCreatureOwnership.js';
import '/imports/api/creature/creatures/methods/updateCreature.js';

View File

@@ -57,7 +57,7 @@ const insertCreature = new ValidatedMethod({
if (Meteor.isServer){
// Insert the 5e ruleset as the default base
insertPropertyFromLibraryNode.call({
nodeId: 'iHbhfcg3AL5isSWbw',
nodeIds: ['iHbhfcg3AL5isSWbw'],
parentRef: {id: baseId, collection: 'creatureProperties'},
order: 0.5,
});

View File

@@ -1,55 +0,0 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import { assertOwnership } from '/imports/api/creature/creatures/creaturePermissions.js';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
const transferCreatureOwnership = new ValidatedMethod({
name: 'creatures.methods.transferOwnership',
validate: new SimpleSchema({
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
userId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({creatureId, userId}) {
assertOwnership(creatureId, this.userId);
let tier = getUserTier(userId);
let currentCharacterCount = Creatures.find({
owner: userId,
}, {
fields: {_id: 1},
}).count();
if (
tier.characterSlots !== -1 &&
currentCharacterCount >= tier.characterSlots
){
throw new Meteor.Error('Creatures.methods.transferOwnership.denied',
'The new owner is already at their character limit')
}
Creatures.update(creatureId, {
$set: {owner: userId},
});
return creatureId;
},
});
export default transferCreatureOwnership;

View File

@@ -1,6 +1,6 @@
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import { nodesToTree } from '/imports/api/parenting/parenting.js';
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
export default function recomputeInventory(creatureId){
let inventoryForest = nodesToTree({

View File

@@ -1,10 +1,10 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import { recomputeCreatureById } from '/imports/api/creature/computation/methods/recomputeCreature.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let Experiences = new Mongo.Collection('experiences');
@@ -12,6 +12,7 @@ let ExperienceSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// The amount of XP this experience gives
xp: {
@@ -90,11 +91,6 @@ const insertExperience = new ValidatedMethod({
throw new Meteor.Error('Experiences.methods.insert.denied',
'You need to be logged in to insert an experience');
}
let tier = getUserTier(this.userId);
if (!tier.paidBenefits){
throw new Meteor.Error('Experiences.methods.insert.denied',
`The ${tier.name} tier does not allow you to grant experience`);
}
let insertedIds = [];
creatureIds.forEach(creatureId => {
let id = insertExperienceForCreature({experience, creatureId, userId});
@@ -123,11 +119,6 @@ const removeExperience = new ValidatedMethod({
throw new Meteor.Error('Experiences.methods.remove.denied',
'You need to be logged in to remove an experience');
}
let tier = getUserTier(this.userId);
if (!tier.paidBenefits){
throw new Meteor.Error('Experiences.methods.remove.denied',
`The ${tier.name} tier does not allow you to remove an experience`);
}
let experience = Experiences.findOne(experienceId);
if (!experience) return;
let creatureId = experience.creatureId
@@ -168,11 +159,6 @@ const recomputeExperiences = new ValidatedMethod({
throw new Meteor.Error('Experiences.methods.recompute.denied',
'You need to be logged in to recompute a creature\'s experiences');
}
let tier = getUserTier(this.userId);
if (!tier.paidBenefits){
throw new Meteor.Error('Experiences.methods.recompute.denied',
`The ${tier.name} tier does not allow you to recompute a creature's experiences`);
}
assertEditPermission(creatureId, userId);
let xp = 0;

View File

@@ -1,14 +1,17 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let ExperienceSchema = new SimpleSchema({
title: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// Potentially long description of the event
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
// The real-world date that it occured
date: {
@@ -24,14 +27,17 @@ let ExperienceSchema = new SimpleSchema({
worldDate: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// Tags to better find this entry later
tags: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'tags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
// ID of the journal this entry belongs to
journalId: {

View File

@@ -10,6 +10,7 @@ import {
prettifyParseError
} from '/imports/parser/parser.js';
const PER_CREATURE_LOG_LIMIT = 100;
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
if (Meteor.isServer){
var sendWebhookAsCreature = require('/imports/server/discord/sendWebhook.js').sendWebhookAsCreature;
@@ -21,7 +22,7 @@ let CreatureLogSchema = new SimpleSchema({
content: {
type: Array,
defaultValue: [],
maxCount: 25,
maxCount: STORAGE_LIMITS.logContentCount,
},
'content.$': {
type: LogContentSchema,
@@ -45,6 +46,7 @@ let CreatureLogSchema = new SimpleSchema({
creatureName: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
});

View File

@@ -1,18 +1,21 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import RollDetailsSchema from '/imports/api/properties/subSchemas/RollDetailsSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let LogContentSchema = new SimpleSchema({
// The name of the field, included in discord webhook message
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// The details of the field, included in discord webhook message
// Markdown support
value: {
type: String,
optional: true,
max: STORAGE_LIMITS.summary,
},
context: {
type: Object,
@@ -21,6 +24,7 @@ let LogContentSchema = new SimpleSchema({
'context.errors':{
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.errorCount,
},
'context.errors.$': {
type: ErrorSchema,
@@ -28,6 +32,7 @@ let LogContentSchema = new SimpleSchema({
'context.rolls': {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.rollCount,
},
'context.rolls.$': {
type: RollDetailsSchema,

View File

@@ -2,6 +2,7 @@ import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let Icons = new Mongo.Collection('icons');
@@ -9,22 +10,27 @@ let iconsSchema = new SimpleSchema({
name: {
type: String,
unique: true,
max: STORAGE_LIMITS.name,
index: 1,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
tags: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.tagCount,
index: 1,
},
'tags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
shape: {
type: String,
max: STORAGE_LIMITS.icon,
},
});

View File

@@ -6,6 +6,7 @@ import simpleSchemaMixin from '/imports/api/creature/mixins/simpleSchemaMixin.js
import { assertEditPermission, assertOwnership } from '/imports/api/sharing/sharingPermissions.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { getUserTier } from '/imports/api/users/patreon/tiers.js'
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
/**
* Libraries are trees of library nodes where each node represents a character
@@ -21,11 +22,8 @@ let Libraries = new Mongo.Collection('libraries');
let LibrarySchema = new SimpleSchema({
name: {
type: String,
max: STORAGE_LIMITS.name,
},
isDefault: {
type: Boolean,
optional: true,
},
});
LibrarySchema.extend(SharingSchema);
@@ -39,7 +37,7 @@ const insertLibrary = new ValidatedMethod({
mixins: [
simpleSchemaMixin,
],
schema: LibrarySchema.omit('owner', 'isDefault'),
schema: LibrarySchema.omit('owner'),
run(library) {
if (!this.userId) {
throw new Meteor.Error('Libraries.methods.insert.denied',
@@ -78,30 +76,6 @@ const updateLibraryName = new ValidatedMethod({
},
});
const setLibraryDefault = new ValidatedMethod({
name: 'libraries.makeLibraryDefault',
validate: new SimpleSchema({
_id: {
type: String,
regEx: SimpleSchema.RegEx.id
},
isDefault: {
type: Boolean,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({_id, isDefault}) {
if (!Meteor.users.isAdmin()){
throw new Meteor.Error('Permission denied', 'User must be admin to set libraries as default');
}
return Libraries.update(_id, {$set: {isDefault}});
},
});
const removeLibrary = new ValidatedMethod({
name: 'libraries.remove',
validate: new SimpleSchema({
@@ -128,4 +102,4 @@ export function removeLibaryWork(libraryId){
LibraryNodes.remove({'ancestors.id': libraryId});
}
export { LibrarySchema, insertLibrary, setLibraryDefault, updateLibraryName, removeLibrary };
export { LibrarySchema, insertLibrary, updateLibraryName, removeLibrary };

View File

@@ -13,6 +13,7 @@ import SoftRemovableSchema from '/imports/api/parenting/SoftRemovableSchema.js';
import { storedIconsSchema } from '/imports/api/icons/Icons.js';
import '/imports/api/library/methods/index.js';
import { updateReferenceNodeWork } from '/imports/api/library/methods/updateReferenceNode.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let LibraryNodes = new Mongo.Collection('libraryNodes');
@@ -24,13 +25,16 @@ let LibraryNodeSchema = new SimpleSchema({
tags: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'tags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
icon: {
type: storedIconsSchema,
optional: true,
max: STORAGE_LIMITS.icon,
}
});

View File

@@ -55,7 +55,7 @@ function updateReferenceNodeWork(node, userId){
return;
}
cache = {
node: {name: doc.name, type: doc.type},
node: doc,
};
if (library){
cache.library = {name: library.name};

View File

@@ -1,11 +0,0 @@
import SimpleSchema from 'simpl-schema';
Blacklist = new Mongo.Collection("blacklist");
Schemas.Blacklist = new SimpleSchema({
userId: {
type: String,
},
});
Blacklist.attachSchema(Schemas.Blacklist);

View File

@@ -1,29 +0,0 @@
import SimpleSchema from 'simpl-schema';
ChangeLogs = new Mongo.Collection("changeLogs");
Schemas.ChangeLog = new SimpleSchema({
version: {
type: String,
},
changes: {
type: [String],
},
});
ChangeLogs.attachSchema(Schemas.ChangeLog);
ChangeLogs.allow({
insert: function(userId, doc) {
var user = Meteor.users.findOne(userId);
if (user) return _.contains(user.roles, "admin");
},
update: function(userId, doc, fields, modifier) {
var user = Meteor.users.findOne(userId);
if (user) return _.contains(user.roles, "admin");
},
remove: function(userId, doc) {
var user = Meteor.users.findOne(userId);
if (user) return _.contains(user.roles, "admin");
},
});

View File

@@ -1,81 +0,0 @@
import SimpleSchema from 'simpl-schema';
Reports = new Mongo.Collection("reports");
Schemas.Report = new SimpleSchema({
owner: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
title: {
type: String,
trim: false,
optional: true,
},
description: {
type: String,
trim: false,
optional: true,
},
type: {
type: String,
allowedValues: ["General Feedback", "Bug", "Suggested Change", "Feature Request"],
defaultValue: "General Feedback",
},
//the immediate impact of doing this action (eg. -1 rages)
severity: {
type: SimpleSchema.Integer,
defaultValue: 5,
min: 1,
max: 10,
},
metaData: {
type: Object,
blackbox: true,
},
});
Reports.attachSchema(Schemas.Report);
Meteor.methods({
insertReport: function(report) {
check(report, {
title: String,
description: String,
type: String,
severity: Number,
metaData: Object,
});
report.owner = this.userId;
var id = Reports.insert(report);
var user = Meteor.users.findOne(this.userId);
var sender = user &&
user.emails &&
user.emails[0] &&
user.emails[0].address ||
user.services &&
user.services.google &&
user.services.google.email ||
"reports@dicecloud.com";
var bodyText = "Report ID: " + id +
"\nSeverity: " + report.severity +
"\nType: " + report.type +
"\n\n" + report.description;
Email.send({
from: sender,
to: "stefan.zermatten@gmail.com",
subject: "DiceCloud feedback - " + report.title,
text: bodyText,
});
},
deleteReport: function(id) {
var user = Meteor.users.findOne(this.userId);
if (!_.contains(user.roles, "admin")){
throw new Meteor.Error(
"not admin",
"The user must be an administrator to delete feedback"
);
}
Reports.remove(id);
},
});

View File

@@ -1,4 +1,5 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const RefSchema = new SimpleSchema({
id: {
@@ -12,7 +13,8 @@ const RefSchema = new SimpleSchema({
index: 1
},
collection: {
type: String
type: String,
max: STORAGE_LIMITS.collectionName,
},
});
@@ -27,7 +29,7 @@ let ChildSchema = new SimpleSchema({
ancestors: {
type: Array,
defaultValue: [],
max: 100,
maxCount: STORAGE_LIMITS.ancestorCount,
},
'ancestors.$': {
type: RefSchema,

View File

@@ -1,4 +1,4 @@
import { nodesToTree } from '/imports/api/parenting/parenting.js';
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
export default function getDescendantsInDepthFirstOrder({
collection,

View File

@@ -0,0 +1,115 @@
import { union, difference, sortBy, findLast } from 'lodash';
export function nodeArrayToTree(nodes){
// Store a dict and list of all the nodes
let nodeIndex = {};
let nodeList = [];
nodes.forEach( node => {
let treeNode = {
node: node,
children: [],
};
nodeIndex[node._id] = treeNode;
nodeList.push(treeNode);
});
// Create a forest of trees
let forest = [];
// Either the node is a child of its nearest found ancestor, or in the forest as a root
nodeList.forEach(treeNode => {
let ancestorInForest = findLast(
treeNode.node.ancestors,
ancestor => !!nodeIndex[ancestor.id]
);
if (ancestorInForest){
nodeIndex[ancestorInForest.id].children.push(treeNode);
} else {
forest.push(treeNode);
}
});
return forest;
}
// Fetch the documents from a collection, and return the tree of those documents
export default function nodesToTree({
collection, ancestorId, filter, options = {},
includeFilteredDocAncestors = false, includeFilteredDocDescendants = false
}){
// Setup the filter
let collectionFilter = {
'ancestors.id': ancestorId,
'removed': {$ne: true},
};
if (filter){
collectionFilter = {
...collectionFilter,
...filter,
}
}
// Set up the options
let collectionSort = {
order: 1
};
if (options && options.sort){
collectionSort = {
...collectionSort,
...options.sort,
}
}
let collectionOptions = {
sort: collectionSort,
}
if (options){
collectionOptions = {
...collectionOptions,
...options,
}
}
// Find all the nodes that match the filter
let docs = collection.find(collectionFilter, collectionOptions).map(doc => {
if (!filter) return doc;
// Mark the nodes that were found by the custom filter
doc._matchedDocumentFilter = true;
return doc;
});
let ancestors = [];
let ancestorIds = [];
let docIds = [];
if (filter && (includeFilteredDocAncestors || includeFilteredDocDescendants)){
docIds = docs.map(doc => doc._id)
}
if (filter && includeFilteredDocAncestors){
// Add all ancestor ids to an array
docs.forEach(doc => {
ancestorIds = union(ancestorIds, doc.ancestors.map(ref => ref.id));
});
// Remove the IDs of docs we have already found
ancestorIds = difference(ancestorIds, docIds);
// Get the docs from the collection, don't worry about `removed` docs,
// if their descendant was not removed, neither are they
ancestors = collection.find({_id: {$in: ancestorIds}}).map(doc => {
// Mark that the nodes are ancestors of the found nodes
doc._ancestorOfMatchedDocument = true;
return doc;
});
}
let descendants = [];
if (filter && includeFilteredDocDescendants){
let exludeIds = union(ancestorIds, docIds);
descendants = collection.find({
'_id': {$nin: exludeIds},
'ancestors.id': {$in: docIds},
'removed': {$ne: true},
}).map(doc => {
// Mark that the nodes are descendants of the found nodes
doc._descendantOfMatchedDocument = true;
return doc;
});
}
let nodes = sortBy([
...ancestors,
...docs,
...descendants
], 'order');
// Find all the nodes
return nodeArrayToTree(nodes);
}

View File

@@ -1,6 +1,6 @@
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
import { flatten, findLast } from 'lodash';
import { flatten } from 'lodash';
const generalParents = [
'attribute',
@@ -217,41 +217,3 @@ export function getName(doc){
if (doc.ancestors[i].name) return doc.ancestors[i].name;
}
}
export function nodeArrayToTree(nodes){
// Store a dict and list of all the nodes
let nodeIndex = {};
let nodeList = [];
nodes.forEach( node => {
let treeNode = {
node: node,
children: [],
};
nodeIndex[node._id] = treeNode;
nodeList.push(treeNode);
});
// Create a forest of trees
let forest = [];
// Either the node is a child of its nearest found ancestor, or in the forest as a root
nodeList.forEach(treeNode => {
let ancestorInForest = findLast(
treeNode.node.ancestors,
ancestor => !!nodeIndex[ancestor.id]
);
if (ancestorInForest){
nodeIndex[ancestorInForest.id].children.push(treeNode);
} else {
forest.push(treeNode);
}
});
return forest;
}
export function nodesToTree({collection, ancestorId, filter = {}, options = {}}){
if (!('ancestors.id' in filter)) filter['ancestors.id'] = ancestorId;
if (!('removed' in filter)) filter['removed'] = {$ne: true};
if (!options.sort) options.sort = {order: 1};
if (!('order' in options.sort)) options.sort.order = 1;
let nodes = collection.find(filter, options);
return nodeArrayToTree(nodes);
}

View File

@@ -1,7 +1,9 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import { storedIconsSchema } from '/imports/api/icons/Icons.js'
import { storedIconsSchema } from '/imports/api/icons/Icons.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
/*
* Actions are things a character can do
* Any rolls that are children of actions will be rolled when taking the action
@@ -12,14 +14,17 @@ let ActionSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
summary: {
type: String,
optional: true,
max: STORAGE_LIMITS.summary,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
// What time-resource is used to take the action in combat
// long actions take longer than 1 round to cast
@@ -38,13 +43,6 @@ let ActionSchema = new SimpleSchema({
'multipleTargets',
],
},
tags: {
type: Array,
defaultValue: [],
},
'tags.$': {
type: String,
},
// Duplicate the ResourceSchema here so we can extend it elegantly.
resources: {
type: Object,
@@ -53,6 +51,7 @@ let ActionSchema = new SimpleSchema({
'resources.itemsConsumed': {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.resourcesCount,
},
'resources.itemsConsumed.$': {
type: Object,
@@ -67,6 +66,7 @@ let ActionSchema = new SimpleSchema({
'resources.itemsConsumed.$.tag': {
type: String,
optional: true,
max: STORAGE_LIMITS.tagLength,
},
'resources.itemsConsumed.$.quantity': {
type: Number,
@@ -75,10 +75,12 @@ let ActionSchema = new SimpleSchema({
'resources.itemsConsumed.$.itemId': {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
'resources.attributesConsumed': {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.resourcesCount,
},
'resources.attributesConsumed.$': {
type: Object,
@@ -93,6 +95,7 @@ let ActionSchema = new SimpleSchema({
'resources.attributesConsumed.$.variableName': {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
'resources.attributesConsumed.$.quantity': {
type: Number,
@@ -102,6 +105,7 @@ let ActionSchema = new SimpleSchema({
uses: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
// Integer of how many times it has already been used
usesUsed: {
@@ -120,14 +124,14 @@ const ComputedOnlyActionSchema = new SimpleSchema({
summaryCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'summaryCalculations.$': InlineComputationSchema,
descriptionCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'descriptionCalculations.$': InlineComputationSchema,
@@ -138,6 +142,7 @@ const ComputedOnlyActionSchema = new SimpleSchema({
usesErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'usesErrors.$':{
type: ErrorSchema,
@@ -158,15 +163,18 @@ const ComputedOnlyActionSchema = new SimpleSchema({
},
'resources.itemsConsumed.$.itemName': {
type: String,
max: STORAGE_LIMITS.name,
optional: true,
},
'resources.itemsConsumed.$.itemIcon': {
type: storedIconsSchema,
optional: true,
max: STORAGE_LIMITS.icon,
},
'resources.itemsConsumed.$.itemColor': {
type: String,
optional: true,
max: STORAGE_LIMITS.color,
},
'resources.attributesConsumed': Array,
'resources.attributesConsumed.$': Object,
@@ -182,6 +190,7 @@ const ComputedOnlyActionSchema = new SimpleSchema({
'resources.attributesConsumed.$.statName': {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// True if the uses left is zero, or any item or attribute consumed is
// insufficient

View File

@@ -1,5 +1,6 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const AdjustmentSchema = new SimpleSchema({
// The roll that determines how much to change the attribute
@@ -8,6 +9,7 @@ const AdjustmentSchema = new SimpleSchema({
type: String,
optional: true,
defaultValue: '1',
max: STORAGE_LIMITS.calculation,
},
// Who this adjustment applies to
target: {
@@ -23,6 +25,7 @@ const AdjustmentSchema = new SimpleSchema({
stat: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
operation: {
type: String,
@@ -39,6 +42,7 @@ const ComputedOnlyAdjustmentSchema = new SimpleSchema({
amountErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'amountErrors.$':{
type: ErrorSchema,

View File

@@ -1,6 +1,7 @@
import SimpleSchema from 'simpl-schema';
import { ActionSchema, ComputedOnlyActionSchema } from '/imports/api/properties/Actions.js';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
// Attacks are special instances of actions
let AttackSchema = new SimpleSchema()
@@ -11,18 +12,22 @@ let AttackSchema = new SimpleSchema()
type: String,
defaultValue: 'strength.modifier + proficiencyBonus',
optional: true,
max: STORAGE_LIMITS.calculation,
},
// Set better defaults for the action
actionType: {
type: String,
defaultValue: 'attack',
max: STORAGE_LIMITS.name,
},
tags: {
type: Array,
defaultValue: ['attack'],
maxCount: STORAGE_LIMITS.tagCount,
},
'tags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
});
@@ -36,6 +41,7 @@ const ComputedOnlyAttackSchema = new SimpleSchema()
rollBonusErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'rollBonusErrors.$':{
type: ErrorSchema,

View File

@@ -2,6 +2,7 @@ import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
/*
* Attributes are numbered stats of a character
@@ -10,6 +11,7 @@ let AttributeSchema = new SimpleSchema({
name: {
type: String,
defaultValue: 'New Attribute',
max: STORAGE_LIMITS.name,
},
// The technical, lowercase, single-word name used in formulae
variableName: {
@@ -17,6 +19,7 @@ let AttributeSchema = new SimpleSchema({
regEx: VARIABLE_NAME_REGEX,
min: 2,
defaultValue: 'newAttribute',
max: STORAGE_LIMITS.variableName,
},
// How it is displayed and computed is determined by type
attributeType: {
@@ -45,16 +48,19 @@ let AttributeSchema = new SimpleSchema({
spellSlotLevelCalculation: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
// The starting value, before effects
baseValueCalculation: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
// Description of what the attribute is used for
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
// The damage done to the attribute, always positive
damage: {
@@ -79,7 +85,7 @@ let ComputedOnlyAttributeSchema = new SimpleSchema({
descriptionCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'descriptionCalculations.$': InlineComputationSchema,
// The result of baseValueCalculation
@@ -93,6 +99,7 @@ let ComputedOnlyAttributeSchema = new SimpleSchema({
},
'baseValueErrors.$': {
type: ErrorSchema,
maxCount: STORAGE_LIMITS.errorCount,
},
// The result of spellSlotLevelCalculation
spellSlotLevelValue: {
@@ -102,6 +109,7 @@ let ComputedOnlyAttributeSchema = new SimpleSchema({
spellSlotLevelErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'spellSlotLevelErrors.$': {
type: ErrorSchema,

View File

@@ -1,18 +1,22 @@
import SimpleSchema from 'simpl-schema';
import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let BuffSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
duration: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
applied: {
type: Boolean,
@@ -34,7 +38,7 @@ let ComputedOnlyBuffSchema = new SimpleSchema({
descriptionCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'descriptionCalculations.$': InlineComputationSchema,
durationSpent: {
@@ -48,6 +52,7 @@ let ComputedOnlyBuffSchema = new SimpleSchema({
},
'appliedBy.name': {
type: String,
max: STORAGE_LIMITS.name,
},
'appliedBy.id': {
type: String,
@@ -55,6 +60,7 @@ let ComputedOnlyBuffSchema = new SimpleSchema({
},
'appliedBy.collection': {
type: String,
max: STORAGE_LIMITS.collectionName,
},
})

View File

@@ -1,21 +1,25 @@
import SimpleSchema from 'simpl-schema';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let ClassLevelSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// Only used by slot filling dialog, not computed
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
// The name of this class level's variable
variableName: {
type: String,
min: 2,
regEx: VARIABLE_NAME_REGEX,
max: STORAGE_LIMITS.variableName,
},
level: {
type: SimpleSchema.Integer,
@@ -32,6 +36,7 @@ let ClassLevelSchema = new SimpleSchema({
slotFillerCondition: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
});

View File

@@ -8,6 +8,8 @@ import {
} from '/imports/parser/parser.js';
import AccessorNode from '/imports/parser/parseTree/AccessorNode.js';
import SymbolNode from '/imports/parser/parseTree/SymbolNode.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
/*
* Constants are primitive values that can be used elsewhere in computations
*/
@@ -15,6 +17,7 @@ let ConstantSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// The technical, lowercase, single-word name used in formulae
variableName: {
@@ -22,15 +25,18 @@ let ConstantSchema = new SimpleSchema({
regEx: VARIABLE_NAME_REGEX,
min: 2,
defaultValue: 'newConstant',
max: STORAGE_LIMITS.variableName,
},
// The input value to be parsed, must return a constant node or an array
// of constant nodes to be valid
calculation: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
errors: {
type: Array,
maxCount: STORAGE_LIMITS.errorCount,
autoValue(){
let calc = this.field('calculation');
if (!calc.isSet && this.isModifier) {

View File

@@ -1,11 +1,13 @@
import SimpleSchema from 'simpl-schema';
import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let ContainerSchema = new SimpleSchema({
name: {
type: String,
optional: true,
trim: false
trim: false,
max: STORAGE_LIMITS.name,
},
carried: {
type: Boolean,
@@ -29,7 +31,8 @@ let ContainerSchema = new SimpleSchema({
description: {
type: String,
optional: true,
trim: false
trim: false,
max: STORAGE_LIMITS.description,
},
});
@@ -37,7 +40,7 @@ const ComputedOnlyContainerSchema = new SimpleSchema({
descriptionCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'descriptionCalculations.$': InlineComputationSchema,
// Weight of all the contents, zero if `contentsWeightless` is true

View File

@@ -1,5 +1,6 @@
import SimpleSchema from 'simpl-schema';
import DAMAGE_TYPES from '/imports/constants/DAMAGE_TYPES.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
/*
* DamageMultipliers are multipliers that affect how much damage is taken from
@@ -9,10 +10,12 @@ let DamageMultiplierSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
damageTypes: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.damageTypeCount,
},
// The technical, lowercase, single-word name used in formulae
'damageTypes.$': {
@@ -29,17 +32,21 @@ let DamageMultiplierSchema = new SimpleSchema({
excludeTags: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'excludeTags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
// Tags which must be present to be affected by this multiplier (AND)
includeTags: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'includeTags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
});

View File

@@ -1,6 +1,7 @@
import SimpleSchema from 'simpl-schema';
import DAMAGE_TYPES from '/imports/constants/DAMAGE_TYPES.js';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const DamageSchema = new SimpleSchema({
// The roll that determines how much to damage the attribute
@@ -9,6 +10,7 @@ const DamageSchema = new SimpleSchema({
type: String,
optional: true,
defaultValue: '1d8 + strength.modifier',
max: STORAGE_LIMITS.calculation,
},
// Who this damage applies to
target: {
@@ -35,6 +37,7 @@ const ComputedOnlyDamageSchema = new SimpleSchema({
amountErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'amountErrors.$':{
type: ErrorSchema,

View File

@@ -1,5 +1,7 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
/*
* Effects are reason-value attached to skills and abilities
* that modify their final value or presentation in some way
@@ -8,6 +10,7 @@ let EffectSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
operation: {
type: String,
@@ -30,14 +33,17 @@ let EffectSchema = new SimpleSchema({
calculation: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
//which stats the effect is applied to
stats: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.statsToTarget,
},
'stats.$': {
type: String,
max: STORAGE_LIMITS.variableName,
},
});
@@ -51,6 +57,7 @@ const ComputedOnlyEffectSchema = new SimpleSchema({
errors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'errors.$':{
type: ErrorSchema,

View File

@@ -1,17 +1,21 @@
import SimpleSchema from 'simpl-schema';
import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let FeatureSchema = new SimpleSchema({
name: {
type: String,
max: STORAGE_LIMITS.name,
},
summary: {
type: String,
optional: true,
max: STORAGE_LIMITS.summary,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
});
@@ -20,14 +24,14 @@ let ComputedOnlyFeatureSchema = new SimpleSchema({
summaryCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'summaryCalculations.$': InlineComputationSchema,
descriptionCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'descriptionCalculations.$': InlineComputationSchema,

View File

@@ -1,10 +1,12 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
// Folders organize a character sheet into a tree, particularly to group things
// like 'race' and 'background'
let FolderSchema = new SimpleSchema({
name: {
type: String,
max: STORAGE_LIMITS.name,
},
});

View File

@@ -1,19 +1,23 @@
import SimpleSchema from 'simpl-schema';
import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const ItemSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// Plural name of the item, if there is more than one
plural: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
// Number currently held
quantity: {
@@ -58,7 +62,7 @@ let ComputedOnlyItemSchema = new SimpleSchema({
descriptionCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'descriptionCalculations.$': InlineComputationSchema,
});

View File

@@ -1,18 +1,22 @@
import SimpleSchema from 'simpl-schema';
import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let NoteSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
summary: {
type: String,
optional: true,
max: STORAGE_LIMITS.summary,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
});
@@ -21,14 +25,14 @@ let ComputedOnlyNoteSchema = new SimpleSchema({
summaryCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'summaryCalculations.$': InlineComputationSchema,
descriptionCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'descriptionCalculations.$': InlineComputationSchema,

View File

@@ -1,17 +1,21 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let ProficiencySchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// The variableNames of the skills, tags, or attributes to apply proficiency to
stats: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.statsToTarget,
},
'stats.$': {
type: String,
max: STORAGE_LIMITS.variableName,
},
// A number representing how proficient the character is
// where 0.49 is half rounded down and 0.5 is half rounded up

View File

@@ -1,4 +1,5 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let ReferenceSchema = new SimpleSchema({
ref: {
@@ -13,6 +14,7 @@ let ReferenceSchema = new SimpleSchema({
'ref.collection': {
type: String,
optional: true,
max: STORAGE_LIMITS.collectionName,
},
// Denormalised store of referenced property's details
cache: {
@@ -22,6 +24,7 @@ let ReferenceSchema = new SimpleSchema({
'cache.error': {
type: String,
optional: true,
max: STORAGE_LIMITS.errorMessage,
},
'cache.node': {
type: Object,
@@ -30,9 +33,19 @@ let ReferenceSchema = new SimpleSchema({
'cache.node.name': {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
'cache.node.type': {
type: String,
max: STORAGE_LIMITS.variableName,
},
'cache.node.level': {
type: Number,
optional: true,
},
'cache.node.value': {
type: Number,
optional: true,
},
'cache.library': {
type: Object,
@@ -41,6 +54,7 @@ let ReferenceSchema = new SimpleSchema({
'cache.library.name': {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
});

View File

@@ -1,6 +1,7 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
/**
* Rolls are children to actions or other rolls, they are triggered with 0 or
@@ -24,6 +25,7 @@ let RollSchema = new SimpleSchema({
name: {
type: String,
defaultValue: 'New Roll',
max: STORAGE_LIMITS.name,
},
// The technical, lowercase, single-word name used in formulae
variableName: {
@@ -31,20 +33,13 @@ let RollSchema = new SimpleSchema({
regEx: VARIABLE_NAME_REGEX,
min: 2,
defaultValue: 'newRoll',
max: STORAGE_LIMITS.variableName,
},
// The roll, can be simplified, but only computed in context
roll: {
type: String,
optional: true,
},
// Effects can apply to this tag specifically
// Ranged spell attack, Ranged weapon attack, etc.
tags: {
type: Array,
defaultValue: [],
},
'tags.$': {
type: String,
max: STORAGE_LIMITS.calculation,
},
});
@@ -56,6 +51,7 @@ let ComputedOnlyRollSchema = new SimpleSchema({
rollErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'rollErrors.$':{
type: ErrorSchema,

View File

@@ -1,5 +1,6 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
// These are the rolls made when saves are called for
// For the saving throw bonus or proficiency, see ./Skills.js
@@ -7,11 +8,13 @@ let SavingThrowSchema = new SimpleSchema ({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// The computed DC
dc: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
// Who this saving throw applies to
target: {
@@ -27,6 +30,7 @@ let SavingThrowSchema = new SimpleSchema ({
stat: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
});
@@ -38,6 +42,7 @@ const ComputedOnlySavingThrowSchema = new SimpleSchema({
dcErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'dcErrors.$':{
type: ErrorSchema,

View File

@@ -1,6 +1,7 @@
import SimpleSchema from 'simpl-schema';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
/*
* Skills are anything that results in a modifier to be added to a D20
@@ -10,6 +11,7 @@ let SkillSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// The technical, lowercase, single-word name used in formulae
// Ignored for skilltype = save
@@ -17,11 +19,13 @@ let SkillSchema = new SimpleSchema({
type: String,
regEx: VARIABLE_NAME_REGEX,
min: 2,
max: STORAGE_LIMITS.variableName,
},
// The variable name of the ability this skill relies on
ability: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
// What type of skill is this
skillType: {
@@ -42,6 +46,7 @@ let SkillSchema = new SimpleSchema({
baseValueCalculation: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
// The base proficiency of this skill
baseProficiency: {
@@ -52,6 +57,7 @@ let SkillSchema = new SimpleSchema({
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
});
@@ -69,6 +75,7 @@ let ComputedOnlySkillSchema = new SimpleSchema({
baseValueErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'baseValueErrors.$': {
type: ErrorSchema,
@@ -107,6 +114,7 @@ let ComputedOnlySkillSchema = new SimpleSchema({
rollBonuses: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.rollBonusCount,
},
'rollBonuses.$': {
type: String,

View File

@@ -1,25 +1,29 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
// SlotFiller fillers specifically fill a slot with a bit more control than
// other properties
import SimpleSchema from 'simpl-schema';
let SlotFillerSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
picture: {
type: String,
optional: true,
max: STORAGE_LIMITS.url,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
// Overrides the type when searching for properties
slotFillerType: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
// Fill more than one quantity in a slot, like feats and ability score
// improvements, filtered out of UI if there isn't space in quantityExpected
@@ -32,6 +36,7 @@ let SlotFillerSchema = new SimpleSchema({
slotFillerCondition: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
});

View File

@@ -1,30 +1,66 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let SlotSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
slotType: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
slotTags: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'slotTags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
extraTags: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.extraTagsCount,
},
'extraTags.$': {
type: Object,
},
'extraTags.$._id': {
type: String,
regEx: SimpleSchema.RegEx.Id,
autoValue(){
if (!this.isSet) return Random.id();
}
},
'extraTags.$.operation': {
type: String,
allowedValues: ['OR', 'NOT'],
defaultValue: 'OR',
},
'extraTags.$.tags': {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'extraTags.$.tags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
quantityExpected: {
type: String,
optional: true,
defaultValue: '1',
max: STORAGE_LIMITS.calculation,
},
ignored: {
type: Boolean,
@@ -33,11 +69,24 @@ let SlotSchema = new SimpleSchema({
slotCondition: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
hideWhenFull: {
type: Boolean,
optional: true,
}
defaultValue: true,
},
unique: {
type: String,
allowedValues: [
// Can't choose the same slot filler twice in this slot
'uniqueInSlot',
// Can't choose the same slot filler twice accross the whole creature
'uniqueInCreature'
],
optional: true,
defaultValue: 'uniqueInSlot',
},
});
const ComputedOnlySlotSchema = new SimpleSchema({
@@ -49,6 +98,7 @@ const ComputedOnlySlotSchema = new SimpleSchema({
slotConditionErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'slotConditionErrors.$':{
type: ErrorSchema,
@@ -62,6 +112,7 @@ const ComputedOnlySlotSchema = new SimpleSchema({
quantityExpectedErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'quantityExpectedErrors.$':{
type: ErrorSchema,

View File

@@ -1,37 +1,36 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import InlineComputationSchema from '/imports/api/properties/subSchemas/InlineComputationSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let SpellListSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
description: {
type: String,
optional: true,
max: STORAGE_LIMITS.description,
},
variableName: {
type: String,
regEx: VARIABLE_NAME_REGEX,
min: 2,
optional: true,
},
// Calculation of how many spells in this list can be prepared
maxPrepared: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
// Calculation of The attack roll bonus used by spell attacks in this list
attackRollBonus: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
// Calculation of the save dc used by spells in this list
dc: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
});
@@ -39,7 +38,7 @@ const ComputedOnlySpellListSchema = new SimpleSchema({
descriptionCalculations: {
type: Array,
defaultValue: [],
maxCount: 32,
maxCount: STORAGE_LIMITS.inlineCalculationCount,
},
'descriptionCalculations.$': InlineComputationSchema,
@@ -51,6 +50,7 @@ const ComputedOnlySpellListSchema = new SimpleSchema({
maxPreparedErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'maxPreparedErrors.$':{
type: ErrorSchema,
@@ -64,6 +64,7 @@ const ComputedOnlySpellListSchema = new SimpleSchema({
attackRollBonusErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'attackRollBonusErrors.$':{
type: ErrorSchema,
@@ -77,6 +78,7 @@ const ComputedOnlySpellListSchema = new SimpleSchema({
dcErrors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'dcErrors.$':{
type: ErrorSchema,

View File

@@ -1,5 +1,6 @@
import { ActionSchema, ComputedOnlyActionSchema } from '/imports/api/properties/Actions.js';
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const magicSchools = [
'abjuration',
@@ -18,6 +19,7 @@ let SpellSchema = new SimpleSchema({})
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
// If it's always prepared, it doesn't count against the number of spells
// prepared in a spell list, and enabled should be true
@@ -42,15 +44,18 @@ let SpellSchema = new SimpleSchema({})
type: String,
optional: true,
defaultValue: 'action',
max: STORAGE_LIMITS.name,
},
range: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
duration: {
type: String,
optional: true,
defaultValue: 'Instantaneous',
max: STORAGE_LIMITS.name,
},
verbal: {
type: Boolean,
@@ -67,6 +72,7 @@ let SpellSchema = new SimpleSchema({})
material: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
ritual: {
type: Boolean,

View File

@@ -1,10 +1,12 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const ToggleSchema = new SimpleSchema({
name: {
type: String,
optional: true,
max: STORAGE_LIMITS.name,
},
disabled: {
type: Boolean,
@@ -19,6 +21,7 @@ const ToggleSchema = new SimpleSchema({
condition: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
});
@@ -32,6 +35,7 @@ const ComputedOnlyToggleSchema = new SimpleSchema({
errors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'errors.$': {
type: ErrorSchema,

View File

@@ -1,11 +1,14 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const ErrorSchema = new SimpleSchema({
message: {
type: String,
max: STORAGE_LIMITS.errorMessage,
},
type: {
type: String,
max: STORAGE_LIMITS.name,
},
});

View File

@@ -1,18 +1,22 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const InlineComputationSchema = new SimpleSchema({
// The part between bracers {}
calculation: {
type: String,
max: STORAGE_LIMITS.calculation,
},
result: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
errors: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.errorCount,
},
'errors.$': ErrorSchema,
});

View File

@@ -1,4 +1,5 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
const RollDetailsSchema = new SimpleSchema({
number: {
@@ -10,6 +11,7 @@ const RollDetailsSchema = new SimpleSchema({
values: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.diceRollValuesCount,
},
'values.$': {
type: Number,

View File

@@ -1,5 +1,6 @@
import SimpleSchema from 'simpl-schema';
import '/imports/api/sharing/sharing.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
let SharingSchema = new SimpleSchema({
owner: {
@@ -11,9 +12,9 @@ let SharingSchema = new SimpleSchema({
type: Array,
defaultValue: [],
index: 1,
max: 50,
maxCount: STORAGE_LIMITS.readersCount,
},
"readers.$": {
'readers.$': {
type: String,
regEx: SimpleSchema.RegEx.Id
},
@@ -21,9 +22,9 @@ let SharingSchema = new SimpleSchema({
type: Array,
defaultValue: [],
index: 1,
max: 20,
maxCount: STORAGE_LIMITS.writersCount,
},
"writers.$": {
'writers.$': {
type: String,
regEx: SimpleSchema.RegEx.Id
},

View File

@@ -5,6 +5,7 @@ import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
import { RefSchema } from '/imports/api/parenting/ChildSchema.js';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
const setPublic = new ValidatedMethod({
name: 'sharing.setPublic',
@@ -47,7 +48,7 @@ const updateUserSharePermissions = new ValidatedMethod({
run({docRef, userId, role}){
let doc = fetchDocByRef(docRef);
if (role === 'none'){
// only asser ownership if you aren't removing yourself
// only assert ownership if you aren't removing yourself
if (this.userId !== userId){
assertOwnership(doc, this.userId);
}
@@ -74,4 +75,58 @@ const updateUserSharePermissions = new ValidatedMethod({
},
});
export { setPublic, updateUserSharePermissions };
const transferOwnership = new ValidatedMethod({
name: 'sharing.transferOwnership',
validate: new SimpleSchema({
docRef: RefSchema,
userId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({docRef, userId}){
let doc = fetchDocByRef(docRef);
assertOwnership(doc, this.userId);
let collection = getCollectionByName(docRef.collection);
let tier = getUserTier(userId);
if (docRef.collection === 'creatures'){
let currentCharacterCount = collection.find({
owner: userId,
}, {
fields: {_id: 1},
}).count();
if (
tier.characterSlots !== -1 &&
currentCharacterCount >= tier.characterSlots
){
throw new Meteor.Error('Sharing.methods.transferOwnership.denied',
'The new owner is already at their character limit')
}
} else if (docRef.collection === 'libraries'){
if (!tier.paidBenefits){
throw new Meteor.Error('Sharing.methods.transferOwnership.denied',
'The new owner\'s Patreon tier does not have access to library ownership');
}
}
// First remove current permissions for the user
collection.update(docRef.id, {
$pullAll: { writers: userId, readers: userId },
});
// Then make the user the owner and the current owner a writer
return collection.update(docRef.id, {
$set: {owner: userId},
$addToSet: { writers: this.userId },
});
},
});
export { setPublic, updateUserSharePermissions, transferOwnership };

View File

@@ -2,6 +2,7 @@ import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import '/imports/api/users/deleteMyAccount.js';
import { some } from 'lodash';
const defaultLibraries = process.env.DEFAULT_LIBRARIES && process.env.DEFAULT_LIBRARIES.split(',') || [];
const userSchema = new SimpleSchema({
@@ -94,6 +95,10 @@ const userSchema = new SimpleSchema({
type: Boolean,
optional: true,
},
'preferences.hidePropertySelectDialogHelp': {
type: Boolean,
optional: true,
},
});
Meteor.users.attachSchema(userSchema);
@@ -155,7 +160,7 @@ Meteor.users.sendVerificationEmail = new ValidatedMethod({
throw new Meteor.Error('User not found',
'Can\'t send a validation email to a user that does not exist');
}
if (!_.some(user.emails, email => email.address === address)) {
if (!some(user.emails, email => email.address === address)) {
throw new Meteor.Error('Email address not found',
'The specified email address wasn\'t found on this user account');
}

View File

@@ -1,47 +1,47 @@
import { findLast } from 'lodash';
import getEntitledCents from '/imports/api/users/patreon/getEntitledCents.js';
import Invites from '/imports/api/users/Invites.js';
const patreonDisabled = !!process.env.DISABLE_PATREON;
const patreonDisabled = !!Meteor.settings?.public?.disablePatreon;
const TIERS = Object.freeze([
{
name: 'Commoner',
minimumEntitledCents: 0,
invites: 0,
characterSlots: 0, //5,
characterSlots: 5,
paidBenefits: false,
}, {
name: 'Dreamer',
minimumEntitledCents: 100,
invites: 0,
characterSlots: 0, //5,
characterSlots: 5,
paidBenefits: false,
}, {
name: 'Wanderer',
minimumEntitledCents: 300,
invites: 0,
characterSlots: 0, //5,
characterSlots: 5,
paidBenefits: false,
}, {
//cost per user $5
name: 'Adventurer',
minimumEntitledCents: 500,
invites: 0,
characterSlots: -1, //20,
characterSlots: 20,
paidBenefits: true,
}, {
//cost per user $3.33
name: 'Hero',
minimumEntitledCents: 1000,
invites: 2,
characterSlots: -1, //50,
characterSlots: 50,
paidBenefits: true,
}, {
//cost per user $3.333
name: 'Legend',
minimumEntitledCents: 2000,
invites: 5,
characterSlots: -1, //120,
characterSlots: 120,
paidBenefits: true,
}, {
//cost per user $3.125

View File

@@ -0,0 +1,5 @@
Meteor.startup(() => {
navigator.serviceWorker.register('/sw.js')
.then()
.catch(error => console.log('ServiceWorker registration failed: ', error));
});

View File

@@ -1,104 +1,157 @@
const PROPERTIES = Object.freeze({
action: {
icon: '$vuetify.icons.action',
name: 'Action'
name: 'Action',
helpText: 'Actions are things your character can do. When an action is taken, all the properties under it are activated.',
suggestedParents: ['classLevel', 'feature', 'item'],
},
attack: {
icon: '$vuetify.icons.attack',
name: 'Attack'
name: 'Attack',
helpText: 'Attacks are a special form of action that includes an attack roll. Attacks can critical hit, which doubles the number of damage dice in properties under the attack.',
suggestedParents: ['classLevel', 'feature', 'item'],
},
attribute: {
icon: '$vuetify.icons.attribute',
name: 'Attribute'
name: 'Attribute',
helpText: 'Attributes are the numbered statistics of your character, excluding rolls you might add proficiency bonus to, those are skills.',
examples: 'Ability scores, speed, hit points, ki',
suggestedParents: ['classLevel', 'buff'],
},
adjustment: {
icon: '$vuetify.icons.attribute_damage',
name: 'Attribute damage'
name: 'Attribute damage',
helpText: 'Attribute damage reduces the current value of an attribute when it is applied by an action. A negative value causes the attribute to increase instead, up to its normal maximum.',
suggestedParents: ['action', 'attack', 'savingThrow', 'spell'],
},
buff: {
icon: '$vuetify.icons.buff',
name: 'Buff'
name: 'Buff',
helpText: 'When a buff is activated as a child of an action, it will copy the properties under itself onto a target character.',
suggestedParents: ['action', 'attack', 'savingThrow', 'spell'],
},
classLevel: {
icon: '$vuetify.icons.class_level',
name: 'Class level'
name: 'Class level',
helpText: 'Class levels represent a single level gained in a class',
suggestedParents: ['class'],
},
constant: {
icon: 'mdi-anchor',
name: 'Constant'
name: 'Constant',
helpText: 'A constant can define a static value that can be used in calculations elsewhere in the sheet',
suggestedParents: [],
},
container: {
icon: 'mdi-bag-personal-outline',
name: 'Container'
name: 'Container',
helpText: 'A container holds items in the inventory',
examples: 'Coin pouch, backpack',
suggestedParents: ['folder'],
},
damage: {
icon: '$vuetify.icons.damage',
name: 'Damage'
name: 'Damage',
helpText: 'When damage is activated by an action it reduces the hit points of the target creature by the calculated amount.',
suggestedParents: ['action', 'attack', 'savingThrow', 'spell'],
},
damageMultiplier: {
icon: '$vuetify.icons.damage_multiplier',
name: 'Damage multiplier'
name: 'Damage multiplier',
helpText: 'Resistance, vulnerability, and immunity.',
suggestedParents: ['classLevel', 'feature', 'item'],
},
effect: {
icon: '$vuetify.icons.effect',
name: 'Effect'
name: 'Effect',
helpText: 'Effects change the value or state of attributes and skills.',
examples: '+2 Strength, Advantage on dexterity saving throws',
suggestedParents: ['buff', 'classLevel', 'feature', 'folder', 'item'],
},
feature: {
icon: 'mdi-text-subject',
name: 'Feature'
name: 'Feature',
helpText: 'Descriptive or narrative features your character has access to',
suggestedParents: ['classLevel', 'folder'],
},
folder: {
icon: 'mdi-folder-outline',
name: 'Folder'
name: 'Folder',
helpText: 'A way to organise other properties on the character',
suggestedParents: ['folder'],
},
item: {
icon: 'mdi-cube-outline',
name: 'Item'
name: 'Item',
helpText: 'Objects and equipment your charcter finds on their adventures',
suggestedParents: ['container'],
},
note: {
icon: 'mdi-note-outline',
name: 'Note'
name: 'Note',
helpText: 'Notes about your character and their adventures',
suggestedParents: ['folder'],
},
proficiency: {
icon: 'mdi-brightness-1',
name: 'Proficiency'
name: 'Proficiency',
helpText: 'Proficiencies apply your proficiency bonus to skills already on your character sheet.',
suggestedParents: ['buff', 'classLevel', 'feature', 'folder'],
},
roll: {
icon: '$vuetify.icons.roll',
name: 'Roll'
name: 'Roll',
helpText: 'When activated by an action, rolls perform a calculation and temporarily store the result for other properties under the same action to use',
suggestedParents: ['action', 'attack', 'savingThrow', 'spell'],
},
reference: {
icon: 'mdi-vector-link',
name: 'Reference',
libraryOnly: true,
helpText: 'A reference is a link to a different property in a library. When a reference gets copied to a character sheet, it is replaced with the referenced property and all its children.',
suggestedParents: [],
},
savingThrow: {
icon: '$vuetify.icons.saving_throw',
name: 'Saving throw'
name: 'Saving throw',
helpText: 'When a saving throw is activated by an action, it causes the target to make a saving throw, if the saving throw fails, the children properties of the saving throw are activated.',
suggestedParents: ['action', 'attack', 'spell'],
},
skill: {
icon: '$vuetify.icons.skill',
name: 'Skill'
name: 'Skill',
helpText: 'Skills, saves, languages, and weapon and tool proficiencies are all skills. Skills can have a default proficiency set. Proficiencies and effects can change the value and state of skills.',
suggestedParents: ['classLevel', 'folder'],
},
propertySlot: {
icon: 'mdi-power-socket-eu',
name: 'Slot'
name: 'Slot',
helpText: 'A slot in the character sheet is used to specify that a property needs to be selected from a library to fill the slot. The slot can determine what tags it is looking for, and any subscribed library property with matching tags can fill the slot',
suggestedParents: [],
},
slotFiller: {
icon: 'mdi-power-plug-outline',
name: 'Slot filler'
name: 'Slot filler',
helpText: 'A slot filler allows for more advanced logic when it attemptst to fill a slot. It can masquarade as any property type, and calculate whether it should fill a slot or not.',
suggestedParents: ['propertySlot'],
},
spellList: {
icon: '$vuetify.icons.spell_list',
name: 'Spell list'
name: 'Spell list',
helpText: 'A list of spells on your character sheet. It can provide a DC and spell attack bonus to the spells within',
suggestedParents: [],
},
spell: {
icon: '$vuetify.icons.spell',
name: 'Spell'
name: 'Spell',
helpText: 'A spell your character can potentially cast',
suggestedParents: ['spellList'],
},
toggle: {
icon: '$vuetify.icons.toggle',
name: 'Toggle'
name: 'Toggle',
helpText: 'Togggles allow parts of the character sheet to be turned on and off, either manually or as the result of a calculation.',
suggestedParents: [],
},
});

View File

@@ -0,0 +1,32 @@
const STORAGE_LIMITS = Object.freeze({
// String lengths
calculation: 256,
collectionName: 64,
color: 10000,
description: 49473, //the length of the Bee Movie script
errorMessage: 256,
icon: 10000,
name: 128,
summary: 10000,
tagLength: 128,
url: 256,
variableName: 64,
//Array counts
ancestorCount: 100,
damageTypeCount: 32,
diceRollValuesCount: 100,
errorCount: 32,
extraTagsCount: 5,
inlineCalculationCount: 32,
logContentCount: 32,
readersCount: 50,
resourcesCount: 32,
rollCount: 64,
rollBonusCount: 32,
statsToTarget: 32,
tagCount: 64,
writersCount: 20,
});
export default STORAGE_LIMITS;

View File

@@ -2,6 +2,7 @@ import ParseNode from '/imports/parser/parseTree/ParseNode.js';
import RollArrayNode from '/imports/parser/parseTree/RollArrayNode.js';
import ErrorNode from '/imports/parser/parseTree/ErrorNode.js';
import roll from '/imports/parser/roll.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
export default class RollNode extends ParseNode {
constructor({left, right}) {
@@ -42,9 +43,9 @@ export default class RollNode extends ParseNode {
if (context.doubleRolls){
number *= 2;
}
if (number > 100) return new ErrorNode({
if (number > STORAGE_LIMITS.diceRollValuesCount) return new ErrorNode({
node: this,
error: 'Can\'t roll more than 100 dice at once',
error: `Can't roll more than ${STORAGE_LIMITS.diceRollValuesCount} dice at once`,
context,
});
let diceSize = right.value;

View File

@@ -0,0 +1,4 @@
import { Accounts } from 'meteor/accounts-base'
Accounts.emailTemplates.from = 'no-reply@dicecloud.com';
Accounts.emailTemplates.siteName = 'DiceCloud';

View File

@@ -9,3 +9,4 @@ import '/imports/server/publications/tabletops.js';
import '/imports/server/publications/slotFillers.js';
import '/imports/server/publications/ownedDocuments.js';
import '/imports/server/publications/archivedCreatures.js';
import '/imports/server/publications/searchLibraryNodes.js';

View File

@@ -2,13 +2,6 @@ import SimpleSchema from 'simpl-schema';
import Libraries from '/imports/api/library/Libraries.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { assertViewPermission } from '/imports/api/sharing/sharingPermissions.js';
const standardLibraryIds = [
'SRDLibraryGA3XWsd',
];
Meteor.publish('standardLibraries', function(){
return Libraries.find({_id: {$in: standardLibraryIds}});
});
Meteor.publish('libraries', function(){
this.autorun(function (){
@@ -75,3 +68,24 @@ Meteor.publish('libraryNodes', function(libraryId){
];
});
});
Meteor.publish('descendantLibraryNodes', function(nodeId){
let node = LibraryNodes.findOne(nodeId);
let libraryId = node?.ancestors[0]?.id;
if (!libraryId) return [];
this.autorun(function (){
let userId = this.userId;
let library = Libraries.findOne(libraryId);
try { assertViewPermission(library, userId) }
catch(e){
return this.error(e);
}
return [
LibraryNodes.find({
'ancestors.id': nodeId,
}, {
sort: {order: 1},
}),
];
});
});

View File

@@ -0,0 +1,116 @@
import { check } from 'meteor/check';
import Libraries from '/imports/api/library/Libraries.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
Meteor.publish('searchLibraryNodes', function(){
let self = this;
this.autorun(function (){
let type = self.data('type');
if (!type) return [];
let userId = this.userId;
if (!userId) {
return [];
}
// Get all the ids of libraries the user can access
const user = Meteor.users.findOne(userId, {
fields: {subscribedLibraries: 1}
});
if (!user) return [];
const subs = user.subscribedLibraries || [];
let libraries = Libraries.find({
$or: [
{owner: this.userId},
{writers: this.userId},
{readers: this.userId},
{_id: {$in: subs}},
]
}, {
fields: {_id: 1, name: 1},
});
let libraryIds = libraries.map(lib => lib._id);
// Build a filter for nodes in those libraries that match the type
let filter = {
'ancestors.id': {$in: libraryIds},
removed: {$ne: true},
tags: {$ne: []}, // Only tagged library nodes are considered
};
if (type){
filter.$or = [{
type,
},{
type: 'slotFiller',
slotFillerType: type,
}];
}
this.autorun(function(){
// Get the limit of the documents the user can fetch
var limit = self.data('limit') || 32;
check(limit, Number);
// Get the search term
let searchTerm = self.data('searchTerm') || '';
check(searchTerm, String);
let options = undefined;
if (searchTerm){
filter.$text = {$search: searchTerm};
options = {
// relevant documents have a higher score.
fields: {
score: { $meta: 'textScore' }
},
sort: {
// `score` property specified in the projection fields above.
score: { $meta: 'textScore' },
'ancestors.0.id': 1,
name: 1,
order: 1,
}
}
} else {
delete filter.$text
options = {sort: {
'ancestors.0.id': 1,
name: 1,
order: 1,
}};
}
options.limit = limit;
this.autorun(function () {
self.setData('countAll', LibraryNodes.find(filter).count());
});
let cursor = LibraryNodes.find(filter, options);
Mongo.Collection._publishCursor(libraries, self, 'libraries');
let observeHandle = cursor.observeChanges({
added: function (id, fields) {
fields._searchResult = true;
self.added('libraryNodes', id, fields);
},
changed: function (id, fields) {
self.changed('libraryNodes', id, fields);
},
removed: function (id) {
self.removed('libraryNodes', id);
}
},
// Publications don't mutate the documents
{ nonMutatingCallbacks: true }
);
// register stop callback (expects lambda w/ no args).
this.onStop(function () {
observeHandle.stop();
});
// this.ready();
});
});
});

View File

@@ -2,6 +2,7 @@ import { check } from 'meteor/check';
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'
Meteor.publish('slotFillers', function(slotId){
let self = this;
@@ -21,7 +22,7 @@ Meteor.publish('slotFillers', function(slotId){
fields: {subscribedLibraries: 1}
});
const subs = user && user.subscribedLibraries || [];
let libraryIds = Libraries.find({
let libraries = Libraries.find({
$or: [
{owner: this.userId},
{writers: this.userId},
@@ -29,28 +30,16 @@ Meteor.publish('slotFillers', function(slotId){
{_id: {$in: subs}},
]
}, {
fields: {_id: 1},
}).map(lib => lib._id);
fields: {_id: 1, name: 1},
});
let libraryIds = libraries.map(lib => lib._id);
// Build a filter for nodes in those libraries that match the slot
let filter = {
'ancestors.id': {$in: libraryIds},
removed: {$ne: true},
};
if (slot.slotTags && slot.slotTags.length){
filter.tags = {$all: slot.slotTags};
}
if (slot.slotType){
filter.$or = [{
type: slot.slotType
},{
type: 'slotFiller',
slotFillerType: slot.slotType,
}];
}
let filter = getSlotFillFilter({slot, libraryIds});
this.autorun(function(){
// Get the limit of the documents the user can fetch
var limit = self.data('limit') || 20;
var limit = self.data('limit') || 50;
check(limit, Number);
// Get the search term
@@ -85,7 +74,7 @@ Meteor.publish('slotFillers', function(slotId){
self.setData('countAll', LibraryNodes.find(filter).count());
});
self.autorun(function () {
return LibraryNodes.find(filter, options);
return [LibraryNodes.find(filter, options), libraries];
});
});
});

View File

@@ -35,7 +35,7 @@
<text-field
ref="iconSearchField"
label="Search icons"
append-icon="mdi-search"
append-icon="mdi-magnify"
clearable
hide-details
class="ma-2"

View File

@@ -1,7 +1,10 @@
<template lang="html">
<v-sheet
class="tree-node"
:class="!hasChildren ? 'empty' : null"
:class="{
'empty': !hasChildren,
'found': node._matchedDocumentFilter,
}"
:data-id="`tree-node-${node._id}`"
>
<div
@@ -52,7 +55,7 @@
:children="computedChildren"
:group="group"
:organize="organize"
:selected-node-id="selectedNodeId"
:selected-node="selectedNode"
@reordered="e => $emit('reordered', e)"
@reorganized="e => $emit('reorganized', e)"
@selected="e => $emit('selected', e)"
@@ -80,6 +83,7 @@
import { canBeParent } from '/imports/api/parenting/parenting.js';
import { getPropertyIcon } from '/imports/constants/PROPERTIES.js';
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
import { some } from 'lodash';
export default {
name: 'TreeNode',
@@ -87,16 +91,33 @@
TreeNodeView,
},
props: {
node: Object,
group: String,
node: {
type: Object,
required: true,
},
group: {
type: String,
required: true,
},
organize: Boolean,
children: Array,
getChildren: Function,
selectedNodeId: String,
children: {
type: Array,
default: () => [],
},
getChildren: {
type: Function,
default: undefined,
},
selectedNode: {
type: Object,
default: undefined,
},
selected: Boolean,
},
data(){ return {
expanded: false,
data(){return {
expanded: this.node._ancestorOfMatchedDocument ||
some(this.selectedNode?.ancestors, ref => ref.id === this.node._id) ||
false,
}},
computed: {
hasChildren(){
@@ -119,6 +140,15 @@
return canBeParent(this.node.type);
},
},
watch: {
'node._ancestorOfMatchedDocument'(value){
this.expanded = !!value ||
some(this.selectedNode?.ancestors, ref => ref.id === this.node._id);
},
'selectedNode.ancestors'(value){
this.expanded = !!some(value, ref => ref.id === this.node._id) || this.expanded;
},
},
beforeCreate() {
this.$options.components.TreeNodeList = require('./TreeNodeList.vue').default
},
@@ -148,9 +178,12 @@
.empty .v-btn {
opacity: 0.4;
}
.found {
background: rgba(200, 0, 0, 0.1) !important;
}
.ghost {
opacity: 0.5;
background: #fbc8c8;
background: rgba(251, 0, 0, 0.3);
}
.v-icon.v-icon--disabled {
opacity: 0;

View File

@@ -18,8 +18,9 @@
:node="child.node"
:children="child.children"
:group="group"
:selected-node-id="selectedNodeId"
:selected="selectedNodeId === child.node._id"
:selected-node="selectedNode"
:selected="selectedNode && selectedNode._id === child.node._id"
:ancestors-of-selected-node="ancestorsOfSelectedNode"
:organize="organize"
:lazy="lazy"
@selected="e => $emit('selected', e)"
@@ -49,7 +50,14 @@
type: Array,
default: () => [],
},
selectedNodeId: String,
selectedNode: {
type: Object,
default: undefined,
},
ancestorsOfSelectedNode: {
type: Array,
default: () => [],
},
},
data(){ return {
expanded: false,

View File

@@ -0,0 +1,81 @@
<template lang="html">
<v-combobox
v-model="filterTerms"
:items="filterOptions"
prepend-inner-icon="mdi-magnify"
hide-no-data
hide-selected
multiple
clearable
small-chips
deletable-chips
/>
</template>
<script lang="js">
export default {
props: {
value: {
type: Object,
default: undefined,
},
},
data(){return {
filterTerms: [],
filterOptions: [
{text: 'Actions', value: 'action'},
{text: 'Attacks', value: 'attack'},
{text: 'Attributes', value: 'attribute'},
{text: 'Buffs', value: 'buff'},
{text: 'Class Levels', value: 'classLevel'},
{text: 'Damage Multipliers', value: 'damageMultiplier'},
{text: 'Effects', value: 'effect'},
{text: 'Experiences', value: 'experience'},
{text: 'Features', value: 'feature'},
{text: 'Folders', value: 'folder'},
{text: 'Notes', value: 'note'},
{text: 'Proficiencies', value: 'proficiency'},
{text: 'Rolls', value: 'roll'},
{text: 'Saving Throws', value: 'savingThrow'},
{text: 'Skills', value: 'skill'},
{text: 'Spell Lists', value: 'spellList'},
{text: 'Spells', value: 'spell'},
{text: 'Containers', value: 'container'},
{text: 'Items', value: 'item'},
],
}},
computed: {
filter(){
if (!this.filterTerms.length) return;
let typeFilters = [];
let nameFilters = [];
this.filterTerms.forEach(filter => {
if (filter.value){
typeFilters.push(filter.value);
} else {
// escape string
let term = filter.replace( /[-/\\^$*+?.()|[\]{}]/g, '\\$&' );
var reg = new RegExp( '.*' + term + '.*', 'i' );
nameFilters.push(reg)
}
});
let filter = {};
if (typeFilters.length){
filter.type = {$in: typeFilters};
}
if (nameFilters.length){
filter.name = {$in: nameFilters};
}
return filter;
},
},
watch:{
filter(value){
this.$emit('input', value);
}
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -1,4 +1,4 @@
<template>
if<template>
<div class="character-sheet fill-height">
<v-fade-transition mode="out-in">
<div
@@ -35,6 +35,10 @@
class="fill-height"
>
<v-tabs-items
:key=" '' +
creature.settings.hideSpellsTab +
creature.settings.showTreeTab
"
:value="$store.getters.tabById($route.params.id)"
class="card-background"
@change="e => $store.commit(
@@ -51,13 +55,17 @@
<v-tab-item>
<inventory-tab :creature-id="creatureId" />
</v-tab-item>
<v-tab-item v-show="!creature.settings.hideSpellsTab">
<v-tab-item
v-if="!creature.settings.hideSpellsTab"
>
<spells-tab :creature-id="creatureId" />
</v-tab-item>
<v-tab-item>
<character-tab :creature-id="creatureId" />
</v-tab-item>
<v-tab-item v-if="creature.settings.showTreeTab">
<v-tab-item
v-if="creature.settings.showTreeTab"
>
<tree-tab :creature-id="creatureId" />
</v-tab-item>
</v-tabs-items>

View File

@@ -31,41 +31,22 @@
:key="type"
color="primary"
:data-id="`insert-creature-property-type-${type}`"
:label="'New ' + properties[type].name"
:icon="properties[type].icon"
:label="getPropertyLabel(type)"
:icon="type ? properties[type].icon : 'mdi-plus'"
:disabled="!editPermission"
@click="insertPropertyOfType(type)"
@click="addProperty(type)"
/>
<template v-if="tabNumber === 5">
<labeled-fab
key="property"
color="primary"
data-id="insert-creature-property-btn"
label="New Property"
icon="mdi-pencil"
:disabled="!editPermission"
@click="insertTreeProperty"
/>
<labeled-fab
key="property"
color="primary"
data-id="insert-creature-property-from-library-btn"
label="Property From Library"
icon="mdi-library-shelves"
:disabled="!editPermission"
@click="propertyFromLibrary"
/>
</template>
</v-speed-dial>
</template>
<script lang="js">
import LabeledFab from '/imports/ui/components/LabeledFab.vue';
import { getHighestOrder } from '/imports/api/parenting/order.js';
import insertProperty, { insertPropertyAsChildOfTag } from '/imports/api/creature/creatureProperties/methods/insertProperty.js';
import insertProperty from '/imports/api/creature/creatureProperties/methods/insertProperty.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import PROPERTIES from '/imports/constants/PROPERTIES.js';
import insertPropertyFromLibraryNode from '/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js';
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
function getParentAndOrderFromSelectedTreeNode(creatureId){
// find the parent based on the currently selected property
@@ -144,98 +125,59 @@
return this.speedDialsByTab[tabs[this.tabNumber]];
},
speedDialsByTab() { return {
'stats': ['attribute', 'skill', 'action', 'attack'],
'stats': ['attribute', 'skill', 'action', 'attack', 'buff'],
'features': ['feature'],
'inventory': ['item', 'container'],
'spells': ['spellList', 'spell'],
'character': ['note'],
'tree': [],
'tree': [null],
};},
properties(){
return PROPERTIES;
},
},
methods: {
insertPropertyOfType(type){
getPropertyLabel(type){
if (type === 'buff') return 'Buff or Condition';
return type ? PROPERTIES[type].name : 'Property'
},
addProperty(forcedType){
let creatureId = this.creatureId;
let fab = hideFab();
// Open the dialog to insert the property
let {parentRef, order } = getParentAndOrderFromSelectedTreeNode(creatureId);
let parent;
try {
parent = fetchDocByRef(parentRef);
} catch (e) {
console.warn(e);
}
this.$store.commit('pushDialogStack', {
component: 'creature-property-creation-dialog',
elementId: 'insert-creature-property-type-' + type,
component: 'add-creature-property-dialog',
elementId: 'insert-creature-property-type-' + forcedType,
data: {
forcedType: type,
parentDoc: forcedType ? undefined : parent,
forcedType,
},
callback(creatureProperty){
if (!creatureProperty) return 'insert-creature-property-fab';
revealFab(fab);
// Insert the property
creatureProperty.order = getHighestOrder({
collection: CreatureProperties,
ancestorId: creatureId
}) + 1;
let tagDetails;
switch (type){
case 'item':
tagDetails = {tag: 'carried', name: 'Carried'};
break;
case 'container':
tagDetails = {tag: 'inventory', name: 'Inventory'};
break;
default:
tagDetails = {tag: `${type}s`};
break;
callback(result){
if (!result){
return 'insert-creature-property-fab';
}
if (Array.isArray(result)){
revealFab(fab);
let nodeIds = result;
let id = insertPropertyFromLibraryNode.call({nodeIds, parentRef, order});
return forcedType ? id : `tree-node-${id}`;
} else {
revealFab(fab);
let creatureProperty = result;
// Get order and parent
creatureProperty.order = order;
// Insert the property
let id = insertProperty.call({creatureProperty, parentRef});
return forcedType ? id : `tree-node-${id}`;
}
let id = insertPropertyAsChildOfTag.call({
creatureProperty,
creatureId,
tag: tagDetails.tag,
tagDefaultName: tagDetails.name,
});
return id;
}
});
},
insertTreeProperty(){
let creatureId = this.creatureId;
let fab = hideFab();
// Open the dialog to insert the property
this.$store.commit('pushDialogStack', {
component: 'creature-property-creation-dialog',
elementId: 'insert-creature-property-btn',
callback(creatureProperty){
if (!creatureProperty) return 'insert-creature-property-fab';
revealFab(fab);
// Get order and parent
let {parentRef, order } = getParentAndOrderFromSelectedTreeNode(creatureId);
creatureProperty.order = order;
// Insert the property
let id = insertProperty.call({creatureProperty, parentRef});
return `tree-node-${id}`;
}
});
},
propertyFromLibrary(){
let creatureId = this.creatureId;
let fab = hideFab();
this.$store.commit('pushDialogStack', {
component: 'creature-property-from-library-dialog',
elementId: 'insert-creature-property-from-library-btn',
callback(libraryNode){
if (!libraryNode) return 'insert-creature-property-fab';
revealFab(fab);
let nodeId = libraryNode._id;
let {parentRef, order } = getParentAndOrderFromSelectedTreeNode(creatureId);
let id = insertPropertyFromLibraryNode.call({nodeId, parentRef, order});
return `tree-node-${id}`;
}
});
},

View File

@@ -80,6 +80,10 @@
>
<v-tabs
v-if="creature && creature.settings"
:key=" '' +
creature.settings.hideSpellsTab +
creature.settings.showTreeTab
"
class="flex"
style="min-width: 0"
centered
@@ -102,7 +106,7 @@
<v-tab>
Inventory
</v-tab>
<v-tab v-show="!creature.settings.hideSpellsTab">
<v-tab v-if="!creature.settings.hideSpellsTab">
Spells
</v-tab>
<v-tab>
@@ -134,13 +138,13 @@ import getThemeColor from '/imports/ui/utility/getThemeColor.js';
import SharedIcon from '/imports/ui/components/SharedIcon.vue';
export default {
inject: {
context: { default: {} }
},
components: {
CharacterSheetFab,
SharedIcon,
},
inject: {
context: { default: {} }
},
computed: {
creatureId(){
return this.$route.params.id;

View File

@@ -15,7 +15,7 @@
</v-list-item-content>
<v-list-item-action>
<v-list-item-title>
{{ creature.denormalizedStats.weightCarried || 0 }} lb
{{ weightCarried }} lb
</v-list-item-title>
</v-list-item-action>
</v-list-item>
@@ -107,6 +107,7 @@ import ItemList from '/imports/ui/properties/components/inventory/ItemList.vue';
import getParentRefByTag from '/imports/api/creature/creatureProperties/methods/getParentRefByTag.js';
import BUILT_IN_TAGS from '/imports/constants/BUILT_IN_TAGS.js';
import CoinValue from '/imports/ui/components/CoinValue.vue';
import stripFloatingPointOddities from '/imports/ui/utility/stripFloatingPointOddities.js';
export default {
components: {
@@ -207,6 +208,11 @@ export default {
containerIds(){
return this.containers.map(container => container._id);
},
weightCarried(){
return stripFloatingPointOddities(
this.creature.denormalizedStats.weightCarried || 0
);
},
},
methods: {
clickProperty(_id){

View File

@@ -23,19 +23,11 @@
:disabled="organizeDisabled"
style="flex-grow: 0; height: 32px;"
/>
<v-combobox
<tree-search-input
ref="searchBox"
slot="extension"
v-model="filterString"
:items="filterOptions"
prepend-inner-icon="mdi-search"
v-model="filter"
class="mx-4"
hide-no-data
hide-selected
multiple
clearable
small-chips
deletable-chips
/>
</v-toolbar>
<creature-properties-tree
@@ -43,7 +35,7 @@
style="overflow-y: auto;"
:root="{collection: 'creatures', id: creatureId}"
:organize="organize"
:selected-node-id="selected"
:selected-node="selectedNode"
:filter="filter"
@selected="clickNode"
/>
@@ -51,9 +43,9 @@
<template slot="detail">
<creature-property-dialog
embedded
:_id="selected"
@removed="selected = undefined"
@duplicated="id => selected = id"
:_id="selectedNodeId"
@removed="selectedNodeId = undefined"
@duplicated="id => selectedNodeId = id"
/>
</template>
</tree-detail-layout>
@@ -65,13 +57,14 @@
import TreeDetailLayout from '/imports/ui/components/TreeDetailLayout.vue';
import CreaturePropertiesTree from '/imports/ui/creature/creatureProperties/CreaturePropertiesTree.vue';
import CreaturePropertyDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue';
import TreeSearchInput from '/imports/ui/components/tree/TreeSearchInput.vue';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
export default {
components: {
TreeDetailLayout,
TreeSearchInput,
CreaturePropertiesTree,
CreaturePropertyDialog,
},
@@ -87,52 +80,10 @@
data(){ return {
organize: false,
organizeDisabled: false,
selected: undefined,
selectedNodeId: undefined,
fab: false,
filterString: '',
filterOptions: [
{text: 'Actions', value: 'action'},
{text: 'Attacks', value: 'attack'},
{text: 'Attributes', value: 'attribute'},
{text: 'Buffs', value: 'buff'},
{text: 'Class Levels', value: 'classLevel'},
{text: 'Damage Multipliers', value: 'damageMultiplier'},
{text: 'Effects', value: 'effect'},
{text: 'Experiences', value: 'experience'},
{text: 'Features', value: 'feature'},
{text: 'Folders', value: 'folder'},
{text: 'Notes', value: 'note'},
{text: 'Proficiencies', value: 'proficiency'},
{text: 'Rolls', value: 'roll'},
{text: 'Saving Throws', value: 'savingThrow'},
{text: 'Skills', value: 'skill'},
{text: 'Spell Lists', value: 'spellList'},
{text: 'Spells', value: 'spell'},
{text: 'Containers', value: 'container'},
{text: 'Items', value: 'item'},
],
filter: undefined,
};},
computed: {
filter(){
if (!this.filterString.length) return;
let typeFilters = [];
let nameFilters = [];
this.filterString.forEach(filter => {
if (filter.value){
typeFilters.push(filter.value);
} else {
// escape string
let term = filter.replace( /[-/\\^$*+?.()|[\]{}]/g, '\\$&' );
var reg = new RegExp( '.*' + term + '.*', 'i' );
nameFilters.push(reg)
}
});
return {$or: [
{type: {$in: typeFilters}},
{name: {$in: nameFilters}},
]};
},
},
watch: {
filter(filter){
if (filter) {
@@ -144,14 +95,14 @@
},
'$vuetify.breakpoint.mdAndUp'(mdAndUp){
if (!mdAndUp){
this.selected = undefined;
this.selectedNodeId = undefined;
}
},
},
methods: {
clickNode(id){
if (this.$vuetify.breakpoint.mdAndUp){
this.selected = id;
this.selectedNodeId = id;
} else {
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
@@ -167,7 +118,7 @@
component: 'creature-property-dialog',
elementId: 'selected-node-card',
data: {
_id: this.selected,
_id: this.selectedNodeId,
startInEditTab: true,
},
});
@@ -175,9 +126,9 @@
getPropertyName,
},
meteor: {
selectedProperty(){
selectedNode(){
return CreatureProperties.findOne({
_id: this.selected,
_id: this.selectedNodeId,
removed: {$ne: true}
});
}

View File

@@ -0,0 +1,333 @@
<template lang="html">
<dialog-base>
<template slot="toolbar">
<v-toolbar-title class="mr-4">
<template v-if="tab === 2">
New
</template>{{ typeName }}
</v-toolbar-title>
<v-spacer />
<v-slide-x-reverse-transition hide-on-leave>
<v-switch
v-if="tab === 0"
:input-value="showPropertyHelp"
append-icon="mdi-help"
hide-details
flat
@change="propertyHelpChanged"
/>
<text-field
v-if="tab === 1"
prepend-inner-icon="mdi-magnify"
regular
hide-details
:value="searchValue"
:debounce="400"
@change="searchChanged"
/>
</v-slide-x-reverse-transition>
</template>
<v-tabs
slot="toolbar-extension"
v-model="tab"
>
<v-tab :disabled="!!forcedType">
{{ typeName || 'Type' }}
</v-tab>
<v-tab :disabled="!type">
Library
</v-tab>
<v-tab :disabled="!type">
Create
</v-tab>
</v-tabs>
<v-tabs-items
slot="unwrapped-content"
v-model="tab"
>
<v-tab-item :disabled="!!forcedType">
<property-selector
no-library-only-props
:parent-type="parentDoc && parentDoc.type"
@select="e => type = e"
/>
</v-tab-item>
<v-tab-item :disabled="!type">
<v-expansion-panels
multiple
inset
>
<v-expansion-panel
v-for="libraryNode in libraryNodes"
:key="libraryNode._id"
:model="libraryNode"
:data-id="libraryNode._id"
>
<v-expansion-panel-header>
<template #default="{ open }">
<v-checkbox
v-model="selectedNodeIds"
class="my-0 py-0 mr-2 flex-grow-0"
hide-details
:value="libraryNode._id"
:disabled="!selectedNodeIds.includes(libraryNode._id) &&
selectedNodeIds.length >= 20"
@click.stop
/>
<v-layout column>
<tree-node-view :model="libraryNode" />
<div class="text-caption">
{{ libraryNames[libraryNode.ancestors[0].id ] }}
</div>
</v-layout>
<template v-if="open">
<v-spacer />
<v-btn
icon
class="flex-grow-0"
@click.stop="openPropertyDetails(libraryNode._id)"
>
<v-icon>mdi-window-restore</v-icon>
</v-btn>
</template>
</template>
</v-expansion-panel-header>
<v-expansion-panel-content>
<library-node-expansion-content :model="libraryNode" />
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<v-layout
justify-center
>
<v-fade-transition mode="out-in">
<div
v-if="currentLimit < countAll"
class="layout justify-center align-stretch"
>
<v-btn
v-if="currentLimit < countAll"
key="load-more-btn"
:loading="!$subReady.searchLibraryNodes"
color="accent"
class="ma-4"
@click="loadMore"
>
Load More
</v-btn>
</div>
</v-fade-transition>
</v-layout>
</v-tab-item>
<v-tab-item :disabled="!type">
<v-card-text
v-if="!$slots['unwrapped-content']"
>
<component
:is="type"
v-if="type"
class="creature-property-form"
:model="model"
:errors="errors"
@change="change"
@push="push"
@pull="pull"
/>
</v-card-text>
</v-tab-item>
</v-tabs-items>
<template slot="actions">
<v-btn
text
@click="$store.dispatch('popDialogStack')"
>
Cancel
</v-btn>
<v-spacer />
<v-btn
v-if="tab === 2"
text
color="primary"
:disabled="!valid"
@click="$store.dispatch('popDialogStack', model)"
>
create
</v-btn>
<v-btn
v-else-if="tab === 1"
text
color="primary"
:disabled="!selectedNodeIds.length"
@click="$store.dispatch('popDialogStack', selectedNodeIds)"
>
<template v-if="selectedNodeIds.length >= 15">
{{ selectedNodeIds.length }}/20
</template>
Insert
</v-btn>
</template>
</dialog-base>
</template>
<script lang="js">
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
import LibraryNodeExpansionContent from '/imports/ui/library/LibraryNodeExpansionContent.vue';
import schemaFormMixin from '/imports/ui/properties/forms/shared/schemaFormMixin.js';
import propertyFormIndex from '/imports/ui/properties/forms/shared/propertyFormIndex.js';
import propertySchemasIndex from '/imports/api/properties/propertySchemasIndex.js';
import Libraries from '/imports/api/library/Libraries.js';
import getThemeColor from '/imports/ui/utility/getThemeColor.js';
import PropertySelector from '/imports/ui/properties/shared/PropertySelector.vue';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
const SKIP_LIBRARY_PROP_TYPES = ['note', 'damage', 'adjustment']
export default {
components: {
PropertySelector,
DialogBase,
TreeNodeView,
LibraryNodeExpansionContent,
...propertyFormIndex,
},
mixins: [schemaFormMixin],
props: {
forcedType: {
type: String,
default: undefined,
},
suggestedTypes: {
type: Array,
default: undefined,
},
suggestedType: {
type: String,
default: undefined,
},
parentDoc: {
type: Object,
default: undefined,
},
},
reactiveProvide: {
name: 'context',
include: ['debounceTime'],
},
data(){return {
selectedNodeIds: [],
type: this.forcedType || this.suggestedType,
model: {
type: this.type,
},
searchValue: undefined,
debounceTime: 0,
tab: 0,
};},
computed: {
typeName(){
return getPropertyName(this.type) || 'Property';
},
toolbarColor(){
return getThemeColor('secondary');
}
},
watch: {
type(newType){
this.changeType(newType);
},
},
mounted(){
this.changeType(this.type);
},
methods: {
propertyHelpChanged(value){
Meteor.users.setPreference.call({
preference: 'hidePropertySelectDialogHelp',
value: !value
}, error => {
if (!error) return;
console.error(error);
snackbar({
text: error.reason,
});
});
},
searchChanged(val, ack){
this._subs.searchLibraryNodes.setData('searchTerm', val);
this._subs.searchLibraryNodes.setData('limit', undefined);
this.selectedNode = undefined;
this.searchValue = val;
setTimeout(ack, 200);
},
loadMore(){
if (this.currentLimit >= this.countAll) return;
this._subs.searchLibraryNodes.setData('limit', this.currentLimit + 32);
},
insert(){
if (!this.selectedNodeIds.length) return;
this.$store.dispatch('popDialogStack', this.selectedNodeIds);
},
changeType(type){
this._subs.searchLibraryNodes.setData('type', type);
if (!type) return;
if (SKIP_LIBRARY_PROP_TYPES.includes(type)){
this.tab = 2;
} else {
this.tab = 1;
}
this.schema = propertySchemasIndex[type];
this.validationContext = this.schema.newContext();
let model = this.schema.clean({});
model.type = type;
this.model = model;
},
openPropertyDetails(id){
this.$store.commit('pushDialogStack', {
component: 'library-node-dialog',
elementId: id,
data: {
_id: id,
},
});
},
},
meteor: {
'$subscribe':{
'searchLibraryNodes': [],
},
showPropertyHelp(){
let user = Meteor.user();
return !(user?.preferences?.hidePropertySelectDialogHelp)
},
currentLimit(){
return this._subs.searchLibraryNodes.data('limit') || 32;
},
countAll(){
return this._subs.searchLibraryNodes.data('countAll');
},
libraryNodes(){
return LibraryNodes.find({
_searchResult: true
},{
sort: {
'ancestors.0.id': 1,
name: 1,
order: 1,
},
});
},
libraryNames(){
let names = {};
Libraries.find().forEach(lib => names[lib._id] = lib.name)
return names;
}
}
};
</script>
<style lang="css" scoped>
</style>

View File

@@ -4,7 +4,7 @@
:children="children"
:group="group"
:organize="organize"
:selected-node-id="selectedNodeId"
:selected-node="selectedNode"
@selected="e => $emit('selected', e)"
@reordered="reordered"
@reorganized="reorganized"
@@ -13,7 +13,7 @@
<script lang="js">
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { nodesToTree } from '/imports/api/parenting/parenting.js'
import nodesToTree from '/imports/api/parenting/nodesToTree.js'
import TreeNodeList from '/imports/ui/components/tree/TreeNodeList.vue';
import { organizeDoc, reorderDoc } from '/imports/api/parenting/organizeMethods.js';
@@ -24,8 +24,14 @@
props: {
root: Object,
organize: Boolean,
selectedNodeId: String,
filter: Object,
selectedNode: {
type: Object,
default: undefined,
},
filter: {
type: Object,
default: undefined,
},
group: {
type: String,
default: 'creatureProperties'
@@ -37,6 +43,8 @@
collection: CreatureProperties,
ancestorId: this.root.id,
filter: this.filter,
includeFilteredDocAncestors: true,
includeFilteredDocDescendants: true,
});
},
},

View File

@@ -98,6 +98,7 @@ import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js';
import { getHighestOrder } from '/imports/api/parenting/order.js';
import insertProperty from '/imports/api/creature/creatureProperties/methods/insertProperty.js';
import Breadcrumbs from '/imports/ui/creature/creatureProperties/Breadcrumbs.vue';
import insertPropertyFromLibraryNode from '/imports/api/creature/creatureProperties/methods/insertPropertyFromLibraryNode.js';
let formIndex = {};
for (let key in propertyFormIndex){
@@ -243,28 +244,37 @@ export default {
},
addProperty(){
let parentPropertyId = this.model._id;
// Open the dialog to insert the property
this.$store.commit('pushDialogStack', {
component: 'creature-property-creation-dialog',
component: 'add-creature-property-dialog',
elementId: 'insert-creature-property-btn',
callback(creatureProperty){
if (!creatureProperty) return;
// Get order and parent
data: {
parentDoc: this.model,
},
callback(result){
if (!result) return;
let parentRef = {
id: parentPropertyId,
collection: 'creatureProperties',
};
creatureProperty.order = getHighestOrder({
let order = getHighestOrder({
collection: CreatureProperties,
ancestorId: parentRef.id,
}) + 0.5;
// Insert the property
let id = insertProperty.call({creatureProperty, parentRef});
return `tree-node-${id}`;
if (Array.isArray(result)){
let nodeIds = result;
let id = insertPropertyFromLibraryNode.call({nodeIds, parentRef, order});
return `tree-node-${id}`;
} else {
let creatureProperty = result;
// Get order and parent
creatureProperty.order = order;
// Insert the property
let id = insertProperty.call({creatureProperty, parentRef});
return `tree-node-${id}`;
}
}
});
}
},
}
};
</script>

View File

@@ -46,44 +46,44 @@ import ColorPicker from '/imports/ui/components/ColorPicker.vue';
import schemaFormMixin from '/imports/ui/properties/forms/shared/schemaFormMixin.js';
export default {
components: {
...propertyFormIndex,
DialogBase,
components: {
...propertyFormIndex,
DialogBase,
ColorPicker,
},
mixins: [schemaFormMixin],
props: {
propertyName: String,
type: String,
},
},
mixins: [schemaFormMixin],
props: {
propertyName: String,
type: String,
},
reactiveProvide: {
name: 'context',
include: ['debounceTime'],
},
data(){return {
model: {
type: this.type,
},
schema: undefined,
validationContext: undefined,
data(){return {
model: {
type: this.type,
},
schema: undefined,
validationContext: undefined,
debounceTime: 0,
};},
watch: {
type(newType){
};},
watch: {
type(newType){
this.changeType(newType);
},
},
},
},
mounted(){
this.changeType(this.type);
},
methods:{
changeType(type){
if (!type) return;
this.schema = propertySchemasIndex[type];
this.validationContext = this.schema.newContext();
let model = this.schema.clean({});
model.type = type;
this.model = model;
this.schema = propertySchemasIndex[type];
this.validationContext = this.schema.newContext();
let model = this.schema.clean({});
model.type = type;
this.model = model;
}
},
}

View File

@@ -9,7 +9,7 @@
</v-toolbar-title>
<v-spacer />
<text-field
prepend-inner-icon="mdi-search"
prepend-inner-icon="mdi-magnify"
regular
hide-details
:value="searchValue"
@@ -18,135 +18,160 @@
@keyup.enter="insert"
/>
</template>
<div
class="library-nodes"
<property-description
:string="model.description"
/>
<p>
{{ slotPropertyTypeName }} with tags:
<template v-for="(tags, index) in tagsSearched.or">
<property-tags
:key="index"
:tags="tags"
:prefix="index ? 'OR' : undefined"
/>
</template>
<template v-for="(tags, index) in tagsSearched.not">
<property-tags
:key="index"
:tags="tags"
prefix="NOT"
/>
</template>
</p>
<v-expansion-panels
multiple
inset
>
<v-fade-transition mode="out-in">
<div v-if="libraryNodes && libraryNodes.length">
<section
class="layout wrap justify-between"
>
<v-card
v-for="node in libraryNodes"
:key="node._id"
hover
ripple
class="slot-card layout column justify-end"
:class="{'selected': node._id === (selectedNode && selectedNode._id)}"
:dark="node._id === (selectedNode && selectedNode._id)"
@click="selectedNode = node"
>
<v-img
v-if="node.picture"
:src="node.picture"
:height="200"
contain
class="slot-card-image"
/>
<v-card-title primary-title>
<tree-node-view
class="mr-2 text-h6 mb-0"
:class="{'theme--dark': node._id === (selectedNode && selectedNode._id)}"
:hide-icon="node.picture"
:model="node"
:color="node.color"
/>
</v-card-title>
<v-card-text
v-if="node.description"
class="pt-0"
<template v-for="libraryNode in libraryNodes">
<v-expansion-panel
v-if="showDisabled || !libraryNode._disabledBySlotFillerCondition"
:key="libraryNode._id"
:model="libraryNode"
:data-id="libraryNode._id"
:class="{disabled: isDisabled(libraryNode)}"
>
<v-expansion-panel-header>
<template #default="{ open }">
<v-layout
align-center
class="flex-grow-0 mr-2"
>
<property-description
class="slot-card-text line-clamp"
:string="node.description"
<v-checkbox
v-if="libraryNode._disabledByAlreadyAdded"
class="my-0 py-0"
hide-details
:input-value="true"
disabled
/>
</v-card-text>
</v-card>
</section>
</div>
<div
v-else-if="countAll"
class="ma-4"
>
<h4 v-if="numFiltered">
Requirements of {{ numFiltered }} library properties were not met.
</h4>
<h4 v-else>
Nothing suitable was found in your libraries.
</h4>
</div>
<div
v-else-if="$subReady.slotFillers"
class="ma-4"
>
<h4>
Nothing suitable was found in your libraries
<span v-if="searchValue">
matching "{{ searchValue }}"
</span>
</h4>
<p>
This slot requires a {{ slotPropertyTypeName }}
<template v-if="model.slotTags.length == 1">
with the tag <code>{{ model.slotTags[0] }}</code>,
</template>
<template v-else-if="model.slotTags.length > 1">
with the following tags:
<span
v-for="(tag, index) in model.slotTags"
:key="index"
<v-checkbox
v-else
v-model="selectedNodeIds"
class="my-0 py-0"
hide-details
:disabled="isDisabled(libraryNode)"
:value="libraryNode._id"
@click.stop
/>
</v-layout>
<v-layout column>
<v-layout align-center>
<tree-node-view :model="libraryNode" />
<div
v-if="libraryNode._disabledBySlotFillerCondition"
class="error--text text-no-wrap text-truncate"
>
{{ libraryNode.slotFillerCondition }}
</div>
</v-layout>
<div class="text-caption text-no-wrap text-truncate">
{{ libraryNames[libraryNode.ancestors[0].id ] }}
</div>
</v-layout>
<div
v-if="libraryNode.slotQuantityFilled && libraryNode.slotQuantityFilled !== 1"
class="text-overline flex-grow-0 text-no-wrap"
:class="{
'error--text': isDisabled(libraryNode) &&
libraryNode._disabledByQuantityFilled
}"
>
<code>{{ tag }}</code>,
</span>
{{ libraryNode.slotQuantityFilled }} slots
</div>
<template v-if="open">
<v-btn
icon
class="flex-grow-0"
@click.stop="openPropertyDetails(libraryNode._id)"
>
<v-icon>mdi-window-restore</v-icon>
</v-btn>
</template>
</template>
<span v-if="model.spaceLeft">
that fills less than {{ model.spaceLeft }} {{ model.spaceLeft == 1 && 'slot' || 'slots' }}
</span>
</p>
</v-expansion-panel-header>
<v-expansion-panel-content>
<library-node-expansion-content :model="libraryNode" />
</v-expansion-panel-content>
</v-expansion-panel>
</template>
</v-expansion-panels>
<v-layout
v-if="!$subReady.slotFillers || currentLimit < countAll"
column
align-center
justify-center
class="ma-3"
>
<v-btn
:loading="!$subReady.slotFillers"
color="accent"
@click="loadMore"
>
Load More
</v-btn>
</v-layout>
<template v-if="!showDisabled && disabledNodeCount">
<v-layout
column
align-center
justify-center
class="ma-3"
>
<div>
Requirements of {{ disabledNodeCount }} properties were not met
</div>
</v-fade-transition>
<v-fade-transition mode="out-in">
<div
v-if="!$subReady.slotFillers"
key="character-loading"
class="fill-height layout justify-center align-center"
<v-btn
class="mt-2"
elevation="0"
color="accent"
@click="showDisabled = true"
>
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
</div>
</v-fade-transition>
<v-fade-transition mode="out-in">
<div
v-if="currentLimit < countAll"
class="layout justify-center align-stretch"
>
<v-btn
:loading="!$subReady.slotFillers"
class="primary"
@click="loadMore"
>
Load More
</v-btn>
</div>
</v-fade-transition>
</div>
Show All
</v-btn>
</v-layout>
</template>
<template slot="actions">
<v-spacer />
<v-btn
text
@click="$store.dispatch('popDialogStack')"
>
Cancel
</v-btn>
<v-spacer />
<v-btn
text
:disabled="!selectedNode"
@click="insert"
color="primary"
:disabled="!dummySlot && !selectedNodeIds.length"
@click="$store.dispatch('popDialogStack', selectedNodeIds)"
>
Insert
<template v-if="model.spaceLeft">
{{ totalQuantitySelected }} / {{ model.spaceLeft }}
</template>
<template v-if="slotId">
Insert
</template>
<template v-else>
Close Test
</template>
</v-btn>
</template>
</dialog-base>
@@ -157,52 +182,74 @@ import Creatures from '/imports/api/creature/creatures/Creatures.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import PROPERTIES from '/imports/constants/PROPERTIES.js';
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
import PropertyDescription from '/imports/ui/properties/viewers/shared/PropertyDescription.vue'
import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
import getSlotFillFilter from '/imports/api/creature/creatureProperties/methods/getSlotFillFilter.js'
import Libraries from '/imports/api/library/Libraries.js';
import LibraryNodeExpansionContent from '/imports/ui/library/LibraryNodeExpansionContent.vue';
import PropertyTags from '/imports/ui/properties/viewers/shared/PropertyTags.vue';
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import { clone } from 'lodash';
export default {
components: {
DialogBase,
TreeNodeView,
PropertyDescription,
LibraryNodeExpansionContent,
PropertyTags,
},
props:{
slotId: {
type: String,
required: true,
default: undefined,
},
creatureId: {
type: String,
required: true,
default: undefined,
},
dummySlot: {
type: Object,
default: undefined,
},
},
data(){return {
selectedNode: undefined,
selectedNodeIds: [],
searchValue: undefined,
numFiltered: 0,
showDisabled: false,
disabledNodeCount: undefined,
}},
computed: {
slotPropertyTypeName(){
if (!this.model) return;
if (!this.model.slotType) return 'property';
let propName = getPropertyName(this.model.slotType);
return propName && propName.toLowerCase();
},
},
reactiveProvide: {
name: 'context',
include: ['creatureId'],
},
methods:{
getTitle(model){
if (!model) return;
if (model.name) return model.name;
let prop = PROPERTIES[model.type]
return prop && prop.name;
computed: {
tagsSearched(){
let or = [];
let not = [];
if (this.model.slotTags && this.model.slotTags.length){
or.push(this.model.slotTags);
}
this.model.extraTags?.forEach(extras => {
if (extras.tags?.length){
if(extras.operation === 'OR'){
or.push(extras.tags);
} else if (extras.operation === 'NOT'){
not.push(extras.tags);
}
}
});
return {or, not};
},
slotPropertyTypeName(){
if (!this.model) return;
if (!this.model.slotType) return 'Property';
let propName = getPropertyName(this.model.slotType);
return propName;
},
},
methods: {
searchChanged(val, ack){
this._subs['slotFillers'].setData('searchTerm', val);
this._subs['slotFillers'].setData('limit', undefined);
@@ -212,11 +259,28 @@ export default {
},
loadMore(){
if (this.currentLimit >= this.countAll) return;
this._subs['slotFillers'].setData('limit', this.currentLimit + 20);
this._subs['slotFillers'].setData('limit', this.currentLimit + 50);
},
insert(){
if (!this.selectedNode) return;
this.$store.dispatch('popDialogStack', this.selectedNode);
},
openPropertyDetails(id){
this.$store.commit('pushDialogStack', {
component: 'library-node-dialog',
elementId: id,
data: {
_id: id,
},
});
},
isDisabled(node){
return node._disabledBySlotFillerCondition ||
node._disabledByAlreadyAdded ||
(
node._disabledByQuantityFilled &&
!this.selectedNodeIds.includes(node._id)
)
}
},
meteor: {
@@ -226,58 +290,117 @@ export default {
},
},
model(){
return CreatureProperties.findOne(this.slotId);
if (this.slotId){
return CreatureProperties.findOne(this.slotId);
} else if (this.dummySlot) {
let model = clone(this.dummySlot)
model.quantityExpectedResult = +model.quantityExpected;
model.spaceLeft = model.quantityExpectedResult;
return model;
}
},
creature(){
if (!this.creatureId) return {variables: {}};
return Creatures.findOne(this.creatureId);
},
currentLimit(){
return this._subs['slotFillers'].data('limit') || 20;
return this._subs['slotFillers'].data('limit') || 50;
},
countAll(){
return this._subs['slotFillers'].data('countAll');
},
libraryNodes(){
let filter = {
alreadyAdded(){
let added = new Set();
if (!this.model.unique) return added;
let ancestorId;
if (this.model.unique === 'uniqueInSlot'){
ancestorId = this.model._id;
} else if (this.model.unique === 'uniqueInCreature'){
ancestorId = this.creatureId;
}
CreatureProperties.find({
'ancestors.id': ancestorId,
libraryNodeId: {$exists: true},
removed: {$ne: true},
};
if (this.model.slotTags && this.model.slotTags.length){
filter.tags = {$all: this.model.slotTags};
}
if (this.model.slotType){
filter.$or = [{
type: this.model.slotType
},{
type: 'slotFiller',
slotFillerType: this.model.slotType,
}];
}
}, {
fields: {libraryNodeId: 1},
}).forEach(prop => {
added.add(prop.libraryNodeId);
});
return added;
},
totalQuantitySelected(){
let quantitySelected = 0;
LibraryNodes.find({
_id: {$in: this.selectedNodeIds}
}, {
fields: {slotQuantityFilled: 1},
}).forEach(node => {
if (Number.isFinite(node.slotQuantityFilled)){
quantitySelected += node.slotQuantityFilled;
} else {
quantitySelected += 1;
}
});
return quantitySelected;
},
spaceLeft(){
if (this.model.quantityExpectedResult === 0) return undefined;
return this.model.spaceLeft - this.totalQuantitySelected;
},
libraryNames(){
let names = {};
Libraries.find().forEach(lib => names[lib._id] = lib.name)
return names;
},
libraryNodes(){
let filter = getSlotFillFilter({slot: this.model});
let nodes = LibraryNodes.find(filter, {
sort: {name: 1, order: 1}
}).fetch();
let totalNodes = nodes.length;
// Filter out slotFillers whose condition isn't met or are too big to fit
let disabledNodeCount = 0;
let activeNodeCount = 0;
let lastActiveNodeId = undefined;
// Mark slotFillers whose condition isn't met or are too big to fit
// the quantity to fill
nodes = nodes.filter(node => {
nodes.forEach(node => {
if (node.slotFillerCondition){
let {result} = evaluateString({
string: node.slotFillerCondition,
scope: this.creature.variables,
fn: 'reduce',
});
if (!result.value) return false;
if (!result.value){
node._disabledBySlotFillerCondition = true;
disabledNodeCount += 1;
}
}
let quantityToFill = node.type === 'slotFiller' ? node.slotQuantityFilled : 1;
if (
quantityToFill > this.spaceLeft
){
node._disabledByQuantityFilled = true;
}
if (this.alreadyAdded.has(node._id)){
node._disabledByAlreadyAdded = true;
}
if (
node.type === 'slotFiller' &&
this.model.spaceLeft > 0 &&
node.slotQuantityFilled > this.model.spaceLeft
!node._disabledBySlotFillerCondition &&
!node._disabledByQuantityFilled &&
!node._disabledByAlreadyAdded
){
return false;
activeNodeCount += 1;
lastActiveNodeId = node._id;
}
return true;
});
this.numFiltered = totalNodes - nodes.length;
if (nodes.length === 1) this.selectedNode = nodes[0];
this.disabledNodeCount = disabledNodeCount;
if (
activeNodeCount === 1 &&
this.$subReady.slotFillers &&
this.currentLimit >= this.countAll
) {
this.selectedNodeIds = [lastActiveNodeId]
}
return nodes;
},
}
@@ -285,17 +408,7 @@ export default {
</script>
<style lang="css" scoped>
.slot-card {
max-width: 500px;
width: 300px;
flex-grow: 1;
flex-shrink: 1;
margin: 4px;
}
.slot-card-text.line-clamp {
-webkit-line-clamp: 5;
}
.slot-card.selected {
background: #8E1B1B;
.disabled {
opacity: 0.7;
}
</style>

View File

@@ -90,10 +90,10 @@ export default {
slotId,
creatureId,
},
callback(node){
if(!node) return;
callback(nodeIds){
if (!nodeIds || !nodeIds.length) return;
let newPropertyId = insertPropertyFromLibraryNode.call({
nodeId: node._id,
nodeIds,
parentRef: {
'id': slotId,
'collection': 'creatureProperties',

View File

@@ -21,19 +21,25 @@
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<slot name="toolbar" />
<slot
slot="extension"
name="toolbar-extension"
/>
</v-toolbar>
</slot>
<div
v-if="$slots['unwrapped-content']"
id="base-dialog-body"
class="unwrapped-content"
@scroll.passive="onScroll"
>
<slot name="unwrapped-content" />
</div>
<v-card-text
v-if="!$slots['unwrapped-content']"
v-else
id="base-dialog-body"
v-scroll:#base-dialog-body="onScroll"
:class="{'dark-body': darkBody}"
@scroll.passive="onScroll"
>
<slot />
</v-card-text>
@@ -90,7 +96,7 @@
<style scoped>
.base-dialog-toolbar {
z-index: 1;
z-index: 2;
border-radius: 2px 2px 0 0;
}
#base-dialog-body, .unwrapped-content {

View File

@@ -1,3 +1,4 @@
import AddCreaturePropertyDialog from '/imports/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue';
import ArchiveDialog from '/imports/ui/creature/archive/ArchiveDialog.vue';
import CastSpellWithSlotDialog from '/imports/ui/properties/components/spells/CastSpellWithSlotDialog.vue';
import CreatureFormDialog from '/imports/ui/creature/CreatureFormDialog.vue';
@@ -20,9 +21,11 @@ import ShareDialog from '/imports/ui/sharing/ShareDialog.vue';
import SlotDetailsDialog from '/imports/ui/creature/slots/SlotDetailsDialog.vue';
import SlotFillDialog from '/imports/ui/creature/slots/SlotFillDialog.vue';
import TierTooLowDialog from '/imports/ui/user/TierTooLowDialog.vue';
import TransferOwnershipDialog from '/imports/ui/sharing/TransferOwnershipDialog.vue';
import UsernameDialog from '/imports/ui/user/UsernameDialog.vue';
export default {
AddCreaturePropertyDialog,
ArchiveDialog,
CastSpellWithSlotDialog,
CreatureFormDialog,
@@ -45,5 +48,6 @@ export default {
SlotDetailsDialog,
SlotFillDialog,
TierTooLowDialog,
TransferOwnershipDialog,
UsernameDialog,
};

View File

@@ -22,17 +22,17 @@ const dialogStackStore = {
});
updateHistory();
},
replaceDialog(state, {component, data, elementId, callback}){
const _id = Random.id();
replaceDialog(state, {component, data}){
if (!state.dialogs.length){
throw new Meteor.Error('can\'t replace dialog if no dialogs are open');
}
let currentDialog = state.dialogs[state.dialogs.length - 1]
Vue.set(state.dialogs, state.dialogs.length - 1, {
_id,
_id: currentDialog._id,
component,
data,
elementId,
callback,
elementId: currentDialog.elementId,
callback: currentDialog.callback,
});
},
popDialogStackMutation (state, result){

View File

@@ -66,9 +66,10 @@
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import CreatureFolders from '/imports/api/creature/creatureFolders/CreatureFolders.js';
import CreatureFolderList from '/imports/ui/creature/creatureList/CreatureFolderList.vue';
import getCreatureUrlName from '/imports/api/creature/creatures/getCreatureUrlName.js';
const characterTransform = function(char){
char.url = `/character/${char._id}/${char.urlName || '-'}`;
char.url = `/character/${char._id}/${getCreatureUrlName(char)}`;
char.initial = char.name && char.name[0] || '?';
return char;
};

View File

@@ -25,13 +25,20 @@
class="mx-3"
style="flex-grow: 0; height: 32px;"
/>
<tree-search-input
ref="searchBox"
slot="extension"
v-model="filter"
class="mx-4"
/>
<insert-library-node-button
v-if="libraryId"
style="bottom: -32px"
v-if="libraryId && canEditLibrary"
slot="extension"
style="bottom: -24px"
fab
:library-id="libraryId"
:selected-node-id="selected"
@selected="id => {if ($vuetify.breakpoint.mdAndUp) selected = id}"
:selected-node-id="selectedNodeId"
@selected="id => {if ($vuetify.breakpoint.mdAndUp) selectedNodeId = id}"
/>
</v-toolbar>
<div
@@ -41,8 +48,9 @@
<library-contents-container
:library-id="libraryId"
:organize-mode="organize"
:selected-node-id="selected"
:selected-node="selectedNode"
should-subscribe
:filter="filter"
@selected="clickNode"
/>
</div>
@@ -50,8 +58,9 @@
v-else
edit-mode
:organize-mode="organize"
:selected-node-id="selected"
:selected-node="selectedNode"
style="overflow-y: auto; padding: 12px;"
:filter="filter"
@selected="clickNode"
/>
</div>
@@ -61,10 +70,10 @@
style="overflow: hidden;"
>
<library-node-dialog
:_id="selected"
:_id="selectedNodeId"
embedded
@removed="selected = undefined"
@duplicated="id => {if ($vuetify.breakpoint.mdAndUp) selected = id}"
@removed="selectedNodeId = undefined"
@duplicated="id => {if ($vuetify.breakpoint.mdAndUp) selectedNodeId = id}"
/>
</div>
</tree-detail-layout>
@@ -82,6 +91,7 @@ import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import isDarkColor from '/imports/ui/utility/isDarkColor.js';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions.js';
import getThemeColor from '/imports/ui/utility/getThemeColor.js';
import TreeSearchInput from '/imports/ui/components/tree/TreeSearchInput.vue';
export default {
components: {
@@ -90,6 +100,7 @@ export default {
LibraryNodeDialog,
LibraryContentsContainer,
InsertLibraryNodeButton,
TreeSearchInput,
},
props: {
selection: Boolean,
@@ -100,7 +111,8 @@ export default {
},
data(){ return {
organize: false,
selected: undefined,
selectedNodeId: undefined,
filter: undefined,
};},
computed: {
isToolbarDark(){
@@ -120,12 +132,12 @@ export default {
this.$store.commit('pushDialogStack', {
component: 'library-node-edit-dialog',
elementId: 'selected-node-card',
data: {_id: this.selected},
data: {_id: this.selectedNodeId},
});
},
clickNode(id){
if (this.$vuetify.breakpoint.mdAndUp){
this.selected = id;
this.selectedNodeId = id;
} else {
this.$store.commit('pushDialogStack', {
component: 'library-node-dialog',
@@ -136,7 +148,7 @@ export default {
},
callback: result => {
if (result){
this.selected = id;
this.selectedNodeId = id;
}
},
});
@@ -175,7 +187,7 @@ export default {
},
selectedNode(){
return LibraryNodes.findOne({
_id: this.selected,
_id: this.selectedNodeId,
removed: {$ne: true}
});
},

View File

@@ -28,8 +28,9 @@
class="ma-2"
>
<insert-library-node-button
v-if="editPermission(library)"
:library-id="library._id"
:selected-node-id="selectedNodeId"
:selected-node-id="selectedNode && selectedNode._id"
@selected="e => $emit('selected', e)"
/>
<v-btn
@@ -45,7 +46,8 @@
:library-id="library._id"
:organize-mode="organizeMode && editPermission(library)"
:edit-mode="editMode"
:selected-node-id="selectedNodeId"
:selected-node="selectedNode"
:filter="filter"
should-subscribe
@selected="e => $emit('selected', e)"
/>
@@ -82,8 +84,12 @@ export default {
props: {
organizeMode: Boolean,
editMode: Boolean,
selectedNodeId: {
type: String,
selectedNode: {
type: Object,
default: undefined,
},
filter: {
type: Object,
default: undefined,
},
},

View File

@@ -7,7 +7,7 @@
group="library"
:children="libraryChildren"
:organize="organizeMode"
:selected-node-id="selectedNodeId"
:selected-node="selectedNode"
@selected="e => $emit('selected', e)"
@reordered="reordered"
@reorganized="reorganized"
@@ -29,7 +29,7 @@
<script lang="js">
import Libraries from '/imports/api/library/Libraries.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { nodesToTree } from '/imports/api/parenting/parenting.js'
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
import TreeNodeList from '/imports/ui/components/tree/TreeNodeList.vue';
import { organizeDoc, reorderDoc } from '/imports/api/parenting/organizeMethods.js';
@@ -38,10 +38,20 @@
TreeNodeList,
},
props: {
libraryId: String,
libraryId: {
type: String,
default: undefined,
},
organizeMode: Boolean,
selectedNodeId: String,
selectedNode: {
type: Object,
default: undefined,
},
shouldSubscribe: Boolean,
filter: {
type: Object,
default: undefined,
},
},
data(){return {
slowShouldSubscribe: this.shouldSubscribe,
@@ -76,7 +86,13 @@
},
libraryChildren(){
if (!this.library) return;
return nodesToTree({collection: LibraryNodes, ancestorId: this.library._id});
return nodesToTree({
collection: LibraryNodes,
ancestorId: this.library._id,
filter: this.filter,
includeFilteredDocAncestors: true,
includeFilteredDocDescendants: true,
});
},
},
methods: {

View File

@@ -0,0 +1,62 @@
<template lang="html">
<div>
<component
:is="model.type"
:model="model"
class="property-viewer"
/>
<tree-node-list
v-if="$subReady.descendantLibraryNodes"
group="library-node-expansion"
:children="propertyChildren"
@selected="clickChild"
/>
</div>
</template>
<script lang="js">
import nodesToTree from '/imports/api/parenting/nodesToTree.js'
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import propertyViewerIndex from '/imports/ui/properties/viewers/shared/propertyViewerIndex.js';
import TreeNodeList from '/imports/ui/components/tree/TreeNodeList.vue';
export default {
components: {
TreeNodeList,
...propertyViewerIndex,
},
props: {
model: {
type: Object,
required: true,
},
},
methods: {
clickChild(id){
this.$store.commit('pushDialogStack', {
component: 'library-node-dialog',
elementId: `tree-node-${id}`,
data: {
_id: id,
},
});
},
},
meteor: {
$subscribe: {
descendantLibraryNodes(){
return [this.model._id];
},
},
propertyChildren(){
return nodesToTree({
collection: LibraryNodes,
ancestorId: this.model._id
});
},
},
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -53,15 +53,16 @@ export default {
subscribed(){
let libraryId = this.$route.params.id;
let user = Meteor.user();
if (!user) return false;
let subs = user.subscribedLibraries;
return subs && subs.includes(libraryId);
return user?.subscribedLibraries?.includes(libraryId);
},
showSubscribeButton(){
let userId = Meteor.userId();
let user = Meteor.user();
let library = this.library;
if (!library) return;
if (
if (!user || !library) return;
let userId = user._id;
if (user.subscribedLibraries.includes(library._id)){
return true
} else if (
library.readers.includes(userId) ||
library.writers.includes(userId) ||
library.owner === userId

View File

@@ -82,9 +82,10 @@
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}/${char.urlName || '-'}`;
char.url = `/character/${char._id}/${getCreatureUrlName(char)}`;
char.initial = char.name && char.name[0] || '?';
return char;
};

Some files were not shown because too many files have changed in this diff Show More