Compare commits

...

25 Commits

Author SHA1 Message Date
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
88 changed files with 2437 additions and 1226 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

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

@@ -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,56 @@ 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];
// 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;
}
// 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 +209,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

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

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

@@ -34,6 +34,14 @@ let ReferenceSchema = new SimpleSchema({
'cache.node.type': {
type: String,
},
'cache.node.level': {
type: Number,
optional: true,
},
'cache.node.value': {
type: Number,
optional: true,
},
'cache.library': {
type: Object,
optional: true,

View File

@@ -1,6 +1,5 @@
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';
let SpellListSchema = new SimpleSchema({
@@ -12,12 +11,6 @@ let SpellListSchema = new SimpleSchema({
type: String,
optional: true,
},
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,

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

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

@@ -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="type ? 'New ' + properties[type].name : 'New Property'"
: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
@@ -149,93 +130,50 @@
'inventory': ['item', 'container'],
'spells': ['spellList', 'spell'],
'character': ['note'],
'tree': [],
'tree': [null],
};},
properties(){
return PROPERTIES;
},
},
methods: {
insertPropertyOfType(type){
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

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

View File

@@ -93,7 +93,7 @@ export default {
callback(node){
if(!node) return;
let newPropertyId = insertPropertyFromLibraryNode.call({
nodeId: node._id,
nodeIds: [node._id],
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';
@@ -23,6 +24,7 @@ import TierTooLowDialog from '/imports/ui/user/TierTooLowDialog.vue';
import UsernameDialog from '/imports/ui/user/UsernameDialog.vue';
export default {
AddCreaturePropertyDialog,
ArchiveDialog,
CastSpellWithSlotDialog,
CreatureFormDialog,

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

@@ -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,61 @@
<template lang="html">
<div>
<component
:is="model.type"
:model="model"
class="property-viewer"
/>
<tree-node-list
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

@@ -8,7 +8,6 @@
<v-menu
v-if="context.creatureId"
transition="slide-y-transition"
lazy
:disabled="!context.editPermission"
>
<template #activator="{ on }">

View File

@@ -17,7 +17,7 @@
>
$vuetify.icons.weight
</v-icon>
{{ (model.contentsWeightless ? 0 : model.contentsWeight || 0) + (model.weight || 0) }}
{{ weight }}
</v-toolbar-title>
<v-toolbar-title
class="layout align-center"
@@ -31,7 +31,7 @@
$vuetify.icons.two_coins
</v-icon>
<coin-value
:value="(model.contentsValue || 0) + (model.value || 0)"
:value="value"
/>
</v-toolbar-title>
</template>
@@ -49,6 +49,7 @@ import ToolbarCard from '/imports/ui/components/ToolbarCard.vue';
import ItemList from '/imports/ui/properties/components/inventory/ItemList.vue';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import CoinValue from '/imports/ui/components/CoinValue.vue';
import stripFloatingPointOddities from '/imports/ui/utility/stripFloatingPointOddities.js';
export default {
components: {
@@ -62,6 +63,20 @@ export default {
required: true,
},
},
computed: {
weight(){
const contentWeight = this.model.contentsWeightless ?
0 :
this.model.contentsWeight || 0;
const ownWeight = this.model.weight || 0;
return stripFloatingPointOddities(contentWeight + ownWeight);
},
value(){
const contentValue = this.model.contentsValue || 0;
const ownValue = this.model.value || 0;
return contentValue + ownValue;
}
},
methods: {
clickContainer(_id){
this.$store.commit('pushDialogStack', {

View File

@@ -18,7 +18,7 @@
v-for="item in dataItems"
:key="item._id"
class="item"
:data-id="`item-list-tile-${item._id}`"
:data-id="item._id"
:model="item"
@click="clickProperty(item._id)"
/>
@@ -74,7 +74,7 @@ export default {
clickProperty(_id){
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `item-list-tile-${_id}`,
elementId: _id,
data: {_id},
});
},

View File

@@ -8,7 +8,7 @@
<text-field
ref="focusFirst"
label="Name"
prepend-inner-icon="mdi-search"
prepend-inner-icon="mdi-magnify"
regular
hide-details
:value="searchValue"

View File

@@ -30,6 +30,7 @@
label="Roll bonus"
:value="model.rollBonus"
:error-messages="errors.rollBonus"
hint="A number (or calculation which returns a number) that is added to a d20 when making the attack roll"
@change="change('rollBonus', ...arguments)"
/>
<calculation-error-list :errors="model.rollBonusErrors" />
@@ -67,9 +68,11 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>
<!--
<smart-select
label="Target"
style="flex-basis: 300px;"
@@ -79,6 +82,7 @@
:menu-props="{auto: true, lazy: true}"
@change="change('target', ...arguments)"
/>
-->
<div class="layout wrap">
<text-field
label="Uses"
@@ -92,7 +96,7 @@
<text-field
label="Uses used"
type="number"
hint="How many times this action has already been used"
hint="How many times this action has already been used: should be 0 in most cases"
style="flex-basis: 300px;"
:value="model.usesUsed"
:error-messages="errors.uses"
@@ -102,6 +106,7 @@
<smart-select
label="Reset"
clearable
hint="When number of uses used should be reset to zero"
style="flex-basis: 300px;"
:items="resetOptions"
:value="model.reset"

View File

@@ -11,8 +11,8 @@
@change="change('stat', ...arguments)"
/>
<text-field
label="Damage"
hint="The amount of damage to apply to the selected stat, can be a calculation or roll"
label="Amount"
hint="The amount of damage to apply to the selected stat, can be a calculation or roll. Negative values will restore the selected from previous damage. If the operation is set, this is the final value of the stat instead."
style="flex-basis: 300px;"
:value="model.amount"
:error-messages="errors.amount"
@@ -23,6 +23,7 @@
label="Operation"
class="mx-1"
style="flex-basis: 300px;"
hint="Should the attribute be damaged by the amount, or set to the amount"
:items="adjustmentOps"
:value="model.operation"
:error-messages="errors.operation"
@@ -43,6 +44,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>

View File

@@ -11,7 +11,7 @@
/>
<text-field
label="Quantity"
hint="How much of the attribute will be consumed"
hint="How much of the attribute will be consumed. If this amount is not available in the attribute, the action can't be taken"
style="flex-basis: 300px;"
:value="model.quantity"
:error-messages="errors.quantity"

View File

@@ -5,7 +5,7 @@
ref="focusFirst"
label="Base Value"
class="base-value-field"
hint="This is the value of the attribute before effects are applied"
hint="This is the value of the attribute before effects are applied. Can be a number or a calculation"
style="width: 332px;"
:value="model.baseValueCalculation"
:error-messages="errors.baseValueCalculation"
@@ -24,7 +24,7 @@
label="Variable name"
:value="model.variableName"
style="flex-basis: 300px;"
hint="Use this name in formulae to reference this attribute"
hint="Use this name in calculations to reference this attribute"
:error-messages="errors.variableName"
@change="change('variableName', ...arguments)"
/>
@@ -71,6 +71,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>
@@ -92,7 +93,7 @@
type="number"
class="damage-field text-center"
style="max-width: 300px;"
hint="The attribute's final value is reduced by this amount"
hint="The attribute's final value is reduced by this amount. Attribute damage can increase this value until it matches the attribute's computed value. Should mostly be left blank."
:disabled="!context.isLibraryForm"
:value="model.damage"
:error-messages="errors.damage"
@@ -106,6 +107,7 @@
label="Reset"
clearable
style="flex-basis: 300px;"
hint="When damage should be reset to zero"
:items="resetOptions"
:value="model.reset"
:error-messages="errors.reset"
@@ -123,14 +125,14 @@
import CalculationErrorList from '/imports/ui/properties/forms/shared/CalculationErrorList.vue';
export default {
inject: {
context: { default: {} }
},
components: {
FormSection,
CalculationErrorList,
},
mixins: [propertyFormMixin],
inject: {
context: { default: {} }
},
data(){
let data = {
attributeTypes: [

View File

@@ -38,6 +38,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>

View File

@@ -22,7 +22,7 @@
label="Class variable name"
:value="model.variableName"
style="flex-basis: 300px;"
hint="This should be the same for each level in a class"
hint="This should be the same for each level in a class, use `variablName.level` to reference the highest class level for a given class variable name in calculations"
:error-messages="errors.variableName"
@change="change('variableName', ...arguments)"
/>
@@ -50,6 +50,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library"
:value="model.tags"
:error-messages="errors.tags"
@change="change('tags', ...arguments)"

View File

@@ -11,13 +11,14 @@
label="Variable name"
:value="model.variableName"
style="flex-basis: 300px;"
hint="Use this name in formulae to reference this attribute"
hint="Use this name in calculations to reference this attribute"
:error-messages="errors.variableName"
@change="change('variableName', ...arguments)"
/>
</div>
<text-field
label="Value"
hint="Calculation of the constant value, use 'text' for a string value, [1,2,3] for a matrix, or 123 for a number"
:value="model.calculation"
:error-messages="errors.calculation"
@change="change('calculation', ...arguments)"

View File

@@ -12,6 +12,7 @@
<smart-switch
label="Carried"
class="mx-3"
hint="Whether this container and its contents count towards the creature's weight carried"
:value="model.carried"
:error-messages="errors.carried"
@change="change('carried', ...arguments)"
@@ -62,6 +63,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>

View File

@@ -5,13 +5,16 @@
ref="focusFirst"
label="Damage"
style="flex-basis: 300px;"
hint="A caculation including dice rolls of the damge to deal to the target when activated by an action"
:value="model.amount"
:error-messages="errors.amount"
@change="change('amount', ...arguments)"
/>
<calculation-error-list :errors="model.amountErrors" />
<smart-select
label="Damage Type"
style="flex-basis: 200px;"
hint="Use the Healing type to restore hit points"
:items="DAMAGE_TYPES"
:value="model.damageType"
:error-messages="errors.damageType"
@@ -34,6 +37,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
:error-messages="errors.tags"
@change="change('tags', ...arguments)"
@@ -44,8 +48,12 @@
<script lang="js">
import DAMAGE_TYPES from '/imports/constants/DAMAGE_TYPES.js';
import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js';
import CalculationErrorList from '/imports/ui/properties/forms/shared/CalculationErrorList.vue';
export default {
components: {
CalculationErrorList,
},
mixins: [propertyFormMixin],
props: {
parentTarget: {

View File

@@ -33,6 +33,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>

View File

@@ -11,6 +11,7 @@
label="Operation"
append-icon="mdi-menu-down"
class="mx-2"
:hint="operationHint"
:error-messages="errors.operation"
:menu-props="{transition: 'slide-y-transition', lazy: true}"
:items="operations"
@@ -42,6 +43,7 @@
multiple
chips
deletable-chips
hint="Which stats will this effect apply to"
:value="model.stats"
:items="attributeList"
:error-messages="errors.stats"
@@ -50,13 +52,21 @@
<text-field
label="Value"
class="mr-2"
hint="Number or calculation to determine the value of this effect"
:persistent-hint="needsValue"
:value="needsValue ? (model.calculation) : ' '"
:disabled="!needsValue"
:error-messages="errors.calculation"
:hint="!isFinite(model.calculation) && model.result ? model.result + '' : '' "
@change="change('calculation', ...arguments)"
/>
<v-expand-transition>
<text-field
v-if="!isFinite(+model.calculation) && model.result !== undefined"
disabled
label="Result"
:value="model.result"
/>
</v-expand-transition>
<calculation-error-list :errors="model.errors" />
<smart-combobox
label="Tags"
@@ -100,7 +110,7 @@
],
}},
computed: {
needsValue(){
needsValue(){
switch(this.model.operation) {
case 'base': return true;
case 'add': return true;
@@ -117,6 +127,23 @@
default: return true;
}
},
operationHint(){
switch(this.model.operation) {
case 'base': return 'Stats take their largest base value, and then apply all other effects';
case 'add': return 'Add this vaulue to the stat';
case 'mul': return 'Multiply the stat by this value';
case 'min': return 'The stat will be at least this value';
case 'max': return 'The stat will not exceed this value';
case 'set': return 'The stat will be set to this value';
case 'advantage': return 'If this stat is the basis for a check, that check will be at advantage';
case 'disadvantage': return 'If this stat is the basis for a check, that check will be at advantage';
case 'passiveAdd': return 'This value will be added to the passive check';
case 'fail': return 'Stat based on this attribute will always fail';
case 'conditional': return 'Add a text note to this stat';
case 'rollBonus': return 'Add this value to rolls based on this stat';
default: return '';
}
},
},
watch: {
'model.operation': {

View File

@@ -30,6 +30,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
:error-messages="errors.tags"
@change="change('tags', ...arguments)"

View File

@@ -10,7 +10,7 @@
/>
<text-field
label="Quantity"
hint="How much of the item will be consumed"
hint="How many will be consumed"
style="flex-basis: 300px;"
:value="model.quantity"
:error-messages="errors.quantity"

View File

@@ -30,6 +30,7 @@
label="Plural name"
:value="model.plural"
:error-messages="errors.plural"
hint="The plural name of your item. If your item's name is 'sword' plural name would be 'swords'"
@change="change('plural', ...arguments)"
/>
</div>
@@ -55,6 +56,7 @@
class="mx-1"
style="flex-basis: 300px;"
prepend-inner-icon="$vuetify.icons.weight"
hint="The weight of a single item in lbs. Can be a decimal value"
:value="model.weight"
:error-messages="errors.weight"
@change="change('weight', ...arguments)"
@@ -87,6 +89,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
:error-messages="errors.tags"
@change="change('tags', ...arguments)"

View File

@@ -10,7 +10,7 @@
<text-area
label="Summary"
hint="This will appear in the action card in the character sheet"
hint="This will appear in the card in the character sheet"
:value="model.summary"
:error-messages="errors.summary"
@change="change('summary', ...arguments)"
@@ -19,18 +19,19 @@
<text-area
label="Description"
hint="Text that does not fit in the summary"
:value="model.description"
:error-messages="errors.description"
@change="change('description', ...arguments)"
/>
<calculation-error-list :calculations="model.descriptionCalculations" />
<smart-combobox
label="Tags"
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>

View File

@@ -14,6 +14,7 @@
multiple
chips
deletable-chips
hint="Which skills does this proficiency apply to"
:value="model.stats"
:items="skillList"
:error-messages="errors.stats"
@@ -32,6 +33,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>

View File

@@ -1,33 +1,43 @@
<template lang="html">
<div class="folder-form layout justify-start wrap">
<v-text-field
label="Linked Property"
style="flex-basis: 300px;"
readonly
outlined
persistent-hint
:loading="linkLoading"
:value="
model.cache.node && model.cache.node.name ||
model.ref && model.ref.id
"
:hint="model.cache.library && model.cache.library.name"
:error-messages="model.cache.error || errors.ref"
prepend-inner-icon="mdi-vector-link"
append-icon="mdi-refresh"
data-id="change-ref"
@click="changeReference"
@click:prepend-inner="changeReference"
@click:append="updateReferenceNode"
/>
<div data-id="change-ref">
<v-input
:label="model.cache && model.cache.node ? '' : 'Linked Property'"
style="flex-basis: 300px; cursor: pointer;"
readonly
outlined
persistent-hint
:loading="linkLoading"
:value="
model.cache.node && model.cache.node.name ||
model.ref && model.ref.id
"
:hint="model.cache.library && model.cache.library.name"
:error-messages="model.cache.error || errors.ref"
prepend-icon="mdi-vector-link"
append-icon="mdi-refresh"
@click="changeReference"
@click:prepend="changeReference"
@click:append="updateReferenceNode"
>
<tree-node-view
v-if="model && model.cache && model.cache.node"
:model="model.cache.node"
/>
</v-input>
</div>
</div>
</template>
<script lang="js">
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js';
import updateReferenceNode from '/imports/api/library/methods/updateReferenceNode.js';
export default {
components: {
TreeNodeView,
},
mixins: [propertyFormMixin],
data(){return {
linkLoading: false,

View File

@@ -62,10 +62,10 @@
AttributesConsumedListForm,
ItemsConsumedListForm,
},
mixins: [propertyFormMixin],
inject: {
context: { default: {} }
},
mixins: [propertyFormMixin],
props: {
parentTarget: {
type: String,

View File

@@ -19,6 +19,7 @@
<text-field
ref="focusFirst"
label="Roll"
hint="The calculation that will be evaluated when the roll is triggered by an action. The result will be saved as the variable name in the context of the roll."
:value="model.roll"
:error-messages="errors.roll"
@change="change('roll', ...arguments)"
@@ -30,6 +31,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>

View File

@@ -8,15 +8,17 @@
@change="change('name', ...arguments)"
/>
<text-field
ref="focusFirst"
label="DC"
hint="A calculation of the DC that the target of an action needs to save against in order to succeed. If the saving throw is lower than the DC, the children of this property will be activated."
:value="model.dc"
:error-messages="errors.dc"
@change="change('dc', ...arguments)"
/>
<calculation-error-list :errors="model.dcErrors" />
<smart-combobox
label="Save"
hint="Which save the saving throw targets"
hint="Which stat the saving throw targets"
:value="model.stat"
:items="saveList"
:error-messages="errors.stat"
@@ -37,6 +39,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
:error-messages="errors.tags"
@change="change('tags', ...arguments)"

View File

@@ -32,6 +32,7 @@
:value="model.skillType"
:error-messages="errors.skillType"
:menu-props="{auto: true, lazy: true}"
:hint="skillTypeHints[model.skillType]"
@change="change('skillType', ...arguments)"
/>
@@ -42,7 +43,7 @@
@change="change('description', ...arguments)"
/>
<calculation-error-list :calculations="model.descriptionCalculations" />
<form-section
name="Advanced"
standalone
@@ -118,7 +119,17 @@
text: 'Utility',
value: 'utility',
},
]
],
skillTypeHints: {
skill: 'A normal character sheet skill like Athletics, Deception, or Investigation',
'save': 'A saving throw the character can make: Strength Save, etc.',
'check': 'An ability check that might include a proficiency bonus later eg. Initiative',
'tool': 'A tool proficiency. Be sure to add a base proficiency in the advanced section.',
'weapon': 'A weapon proficiency. Be sure to add a base proficiency in the advanced section.',
'armor': 'A armor proficiency. Be sure to add a base proficiency in the advanced section.',
'language': 'A language proficiency. Be sure to add a base proficiency in the advanced section.',
'utility': 'A skill that does not show up in the sheet, but can be used by other caclulations',
}
};},
meteor: {
abilityScoreList(){

View File

@@ -35,6 +35,7 @@
label="Type"
style="flex-basis: 300px;"
clearable
hint="The property type that this slot filler pretends to be when being searched for by a slot"
:items="slotTypes"
:value="model.slotFillerType"
:error-messages="errors.slotFillerType"
@@ -62,6 +63,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this slot filler in a library"
:value="model.tags"
:error-messages="errors.tags"
@change="change('tags', ...arguments)"

View File

@@ -11,6 +11,7 @@
label="Type"
style="flex-basis: 300px;"
clearable
hint="What property type is needed to fill this slot"
:items="slotTypes"
:value="model.slotType"
:error-messages="errors.slotType"
@@ -33,6 +34,7 @@
:error-messages="errors.quantityExpected"
@change="change('quantityExpected', ...arguments)"
/>
<calculation-error-list :errors="model.quantityExpectedErrors" />
<text-field
label="Condition"
hint="A caclulation to determine if this slot should be active"

View File

@@ -31,6 +31,7 @@
label="Level"
class="mx-1"
style="flex-basis: 300px;"
hint="The spell level"
:items="spellLevels"
:value="model.level"
:error-messages="errors.level"
@@ -110,6 +111,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
:error-messages="errors.tags"
@change="change('tags', ...arguments)"

View File

@@ -8,14 +8,6 @@
:error-messages="errors.name"
@change="change('name', ...arguments)"
/>
<text-field
label="Variable name"
:value="model.variableName"
style="flex-basis: 300px;"
hint="This name is used by spells to reference which lists they appear on"
:error-messages="errors.variableName"
@change="change('variableName', ...arguments)"
/>
</div>
<text-area
@@ -58,6 +50,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>

View File

@@ -33,6 +33,7 @@
<text-field
v-show="radioSelection === 'calculated'"
label="Condition"
hint="When this calculation returns a value that isn't false or zero the children will be active"
:value="model.condition"
:error-messages="errors.condition"
@change="change('condition', ...arguments)"
@@ -44,6 +45,7 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>

View File

@@ -0,0 +1,57 @@
<template lang="html">
<v-card
hover
style="height: 100%; overflow: hidden;"
@click="e => $emit('click', e)"
>
<v-card-title
class="subtitle pb-3"
style="text-align: center;"
>
<v-avatar tile>
<v-icon x-large>
{{ property.icon }}
</v-icon>
</v-avatar>
<span class="ml-3">
{{ property.name }}
</span>
</v-card-title>
<v-expand-transition>
<div
v-if="showPropertyHelp"
class="mx-4"
>
{{ property.helpText }}
<div style="height: 16px;" />
<div
v-if="property.examples"
class="text-caption"
>
{{ property.examples }}
<div style="height: 16px;" />
</div>
</div>
</v-expand-transition>
</v-card>
</template>
<script lang="js">
export default {
props: {
property: {
type: Object,
required: true,
}
},
meteor: {
showPropertyHelp(){
let user = Meteor.user();
return !(user?.preferences?.hidePropertySelectDialogHelp)
},
},
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -2,57 +2,115 @@
<div class="card-raised-background">
<v-container
fluid
grid-list-lg
fill-height
>
<v-layout
<v-row
wrap
fill-height
dense
justify="center"
justify-sm="start"
>
<template v-for="(property, type) in PROPERTIES">
<v-flex
<template v-if="properties.suggested">
<v-col
cols="12"
>
<v-subheader>
Suggested
</v-subheader>
</v-col>
<template v-for="(property, type) in properties.suggested">
<v-col
v-if="!noLibraryOnlyProps || !property.libraryOnly"
:key="type"
md="4"
sm="6"
cols="10"
>
<property-select-card
:property="property"
@click="$emit('select', type)"
/>
</v-col>
</template>
</template>
<v-col
v-if="properties.suggested"
cols="12"
>
<v-subheader>
More
</v-subheader>
</v-col>
<template v-for="(property, type) in properties.more">
<v-col
v-if="!noLibraryOnlyProps || !property.libraryOnly"
:key="type"
sm4
xs6
md="4"
sm="6"
cols="10"
>
<v-card
hover
style="height: 100%; overflow: hidden;"
<property-select-card
:property="property"
@click="$emit('select', type)"
>
<div
class="layout align-center justify-center"
style="min-height: 70px;"
>
<v-icon x-large>
{{ property.icon }}
</v-icon>
</div>
<h3
class="subtitle pb-3"
style="text-align: center;"
>
{{ property.name }}
</h3>
</v-card>
</v-flex>
/>
</v-col>
</template>
</v-layout>
</v-row>
</v-container>
</div>
</template>
<script lang="js">
import PROPERTIES from '/imports/constants/PROPERTIES.js';
import PropertySelectCard from '/imports/ui/properties/shared/PropertySelectCard.vue';
export default {
components: {
PropertySelectCard,
},
props: {
noLibraryOnlyProps: Boolean,
parentType: {
type: String,
default: undefined,
},
suggestedTypes: {
type: Array,
default: undefined,
},
},
data(){ return {
PROPERTIES,
};},
computed:{
properties(){
let suggested;
let more = {};
if (this.suggestedTypes){
for (const key in PROPERTIES){
let prop = PROPERTIES[key];
if (this.suggestedTypes.includes(prop.type)){
if (!suggested) suggested = {};
suggested[key] = prop;
} else {
more[key] = prop;
}
}
return {suggested, more};
} else if (this.parentType) {
for (const key in PROPERTIES){
let prop = PROPERTIES[key];
if (prop.suggestedParents.includes(this.parentType)){
if (!suggested) suggested = {};
suggested[key] = prop;
} else {
more[key] = prop;
}
}
return {suggested, more};
} else {
return {more: PROPERTIES};
}
},
},
}
</script>

View File

@@ -5,12 +5,23 @@
key="left"
class="step-1"
>
<v-toolbar-title slot="toolbar">
Property Type
</v-toolbar-title>
<template slot="toolbar">
<v-toolbar-title>
Property Type
</v-toolbar-title>
<v-spacer />
<v-switch
:input-value="showPropertyHelp"
append-icon="mdi-help"
hide-details
flat
@change="propertyHelpChanged"
/>
</template>
<property-selector
slot="unwrapped-content"
:no-library-only-props="noLibraryOnlyProps"
:parent-type="parentType"
@select="type => $emit('input', type)"
/>
</dialog-base>
@@ -28,6 +39,7 @@
<script lang="js">
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import PropertySelector from '/imports/ui/properties/shared/PropertySelector.vue';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
export default {
components: {
@@ -38,8 +50,33 @@ export default {
noLibraryOnlyProps: Boolean,
value: {
type: String,
default: undefined,
},
parentType: {
type: String,
default: undefined,
},
},
meteor: {
showPropertyHelp(){
let user = Meteor.user();
return !(user?.preferences?.hidePropertySelectDialogHelp)
},
},
methods: {
propertyHelpChanged(value){
Meteor.users.setPreference.call({
preference: 'hidePropertySelectDialogHelp',
value: !value
}, error => {
if (!error) return;
console.error(error);
snackbar({
text: error.reason,
});
});
}
}
};
</script>

View File

@@ -8,7 +8,7 @@
:value="model.value"
/>
<div class="text-no-wrap text-truncate">
<template v-if="model.stats && model.stats.length">
<template v-if="!model.name && model.stats && model.stats.length">
{{ model.stats.join(', ') }}
</template>
<template v-else>

View File

@@ -7,7 +7,14 @@
:color="model.color"
:class="selected && 'primary--text'"
/>
<div class="text-no-wrap text-truncate">
<tree-node-view
v-if="model.cache && model.cache.node && model.cache.node.type !== 'reference'"
:model="model.cache.node"
/>
<div
v-else
class="text-no-wrap text-truncate"
>
{{ model.cache.node && model.cache.node.name || title }}
</div>
</div>
@@ -15,8 +22,12 @@
<script lang="js">
import treeNodeViewMixin from '/imports/ui/properties/treeNodeViews/treeNodeViewMixin.js';
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
export default {
components: {
TreeNodeView,
},
mixins: [treeNodeViewMixin],
}
</script>

View File

@@ -134,6 +134,7 @@ import propertyViewerMixin from '/imports/ui/properties/viewers/shared/propertyV
import CoinValue from '/imports/ui/components/CoinValue.vue';
import IncrementButton from '/imports/ui/components/IncrementButton.vue';
import adjustQuantity from '/imports/api/creature/creatureProperties/methods/adjustQuantity.js';
import stripFloatingPointOddities from '/imports/ui/utility/stripFloatingPointOddities.js';
export default {
components:{
@@ -146,10 +147,10 @@ export default {
},
computed:{
totalValue(){
return this.model.value * this.model.quantity;
return stripFloatingPointOddities(this.model.value * this.model.quantity);
},
totalWeight(){
return this.model.weight * this.model.quantity;
return stripFloatingPointOddities(this.model.weight * this.model.quantity);
},
},
methods: {

View File

@@ -5,11 +5,12 @@
name="Error"
:value="model.cache.error"
/>
<property-field
v-else-if="model.ref && model.ref.id"
name="Linked Property"
:value="model.cache.node && model.cache.node.name || model.ref.id"
/>
<template v-else-if="model.ref && model.ref.id">
<div class="text-caption">
Linked Property
</div>
<tree-node-view :model="model.cache.node" />
</template>
<property-field
v-if="model.cache.library && model.cache.library.name"
name="Library"
@@ -20,8 +21,12 @@
<script lang="js">
import propertyViewerMixin from '/imports/ui/properties/viewers/shared/propertyViewerMixin.js'
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
export default {
components: {
TreeNodeView,
},
mixins: [propertyViewerMixin],
}
</script>

View File

@@ -1,6 +1,9 @@
<template lang="html">
<div class="spell-viewer">
<property-name :value="model.name" />
<p class="text-caption">
{{ levelAndSchool }}
</p>
<property-field
name="Casting time"
:value="model.castingTime"
@@ -10,12 +13,12 @@
:value="model.range"
/>
<property-field
name="Duration"
:value="model.duration"
name="Components"
:value="spellComponents"
/>
<property-field
name="Level"
:value="`${model.level} ${model.school}`"
name="Duration"
:value="model.duration"
/>
<property-description
:string="model.description"
@@ -26,9 +29,33 @@
</template>
<script lang="js">
import propertyViewerMixin from '/imports/ui/properties/viewers/shared/propertyViewerMixin.js'
import propertyViewerMixin from '/imports/ui/properties/viewers/shared/propertyViewerMixin.js';
const levelText = [
'cantrip', '1st-level', '2nd-level', '3rd-level', '4th-level', '5th-level',
'6th-level', '7th-level', '8th-level', '9th-level'
];
export default {
mixins: [propertyViewerMixin],
computed:{
levelAndSchool(){
if (this.model.level == 0){
return `${this.model.school} ${levelText[0]}`
} else {
return `${levelText[this.model.level]} ${this.model.school}`
}
},
spellComponents(){
let components = [];
if (this.model.ritual) components.push('Ritual');
if (this.model.concentration) components.push('Concentration');
if (this.model.verbal) components.push('Verbal');
if (this.model.somatic) components.push('Somatic');
if (this.model.material) components.push(`Material (${this.model.material})`);
return components.join(', ');
},
}
}
</script>

View File

@@ -0,0 +1,3 @@
export default function stripFloatingPointOddities(num, precision = 12){
return +parseFloat(num.toPrecision(precision))
}

1473
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@
"npm": "6.13.x"
},
"dependencies": {
"@babel/runtime": "^7.14.6",
"@babel/runtime": "^7.14.8",
"@chenfengyuan/vue-countdown": "^1.1.5",
"@tozd/vue-observer-utils": "^0.5.0",
"animejs": "^2.2.0",
@@ -32,7 +32,7 @@
"ignore-styles": "^5.0.1",
"lodash": "^4.17.20",
"marked": "^0.8.2",
"meteor-node-stubs": "^1.0.3",
"meteor-node-stubs": "^1.1.0",
"moo": "^0.5.1",
"nearley": "^2.19.1",
"qrcode": "^1.4.4",
@@ -46,13 +46,13 @@
"vue-reactive-provide": "^0.3.0",
"vue-router": "^3.5.2",
"vuedraggable": "^2.23.2",
"vuetify": "^2.5.6",
"vuetify": "^2.5.7",
"vuetify-upload-button": "^2.0.2",
"vuex": "^3.1.3"
},
"devDependencies": {
"eslint": "^6.8.0",
"eslint-plugin-vue": "^7.13.0",
"eslint": "^7.31.0",
"eslint-plugin-vue": "^7.14.0",
"eslint-plugin-vuetify": "^1.0.1",
"mem": "^6.1.1",
"sass": "^1.35.2"
@@ -88,10 +88,11 @@
}
],
"parserOptions": {
"ecmaVersion": 2018
"ecmaVersion": 2020
},
"env": {
"es6": true,
"es2021": true,
"es2020": true,
"browser": true,
"node": true,
"meteor": true

95
app/public/sw.js Normal file
View File

@@ -0,0 +1,95 @@
const HTMLToCache = '/';
const version = 'MSW V0.3';
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(version).then((cache) => {
cache.add(HTMLToCache).then(self.skipWaiting());
}));
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => Promise.all(cacheNames.map((cacheName) => {
if (version !== cacheName) return caches.delete(cacheName);
}))).then(self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
// only processes http:// & https:// requests, prevents chrome-extention:// errors
if (event.request.url.startsWith('http')){
const requestToFetch = event.request.clone();
event.respondWith(
caches.match(event.request.clone()).then((cached) => {
// We don't return cached HTML (except if fetch failed)
if (cached) {
const resourceType = cached.headers.get('content-type');
// We only return non css/js/html cached response e.g images
if (!hasHash(event.request.url) && !/text\/html/.test(resourceType)) {
return cached;
}
// If the CSS/JS didn't change since it's been cached, return the cached version
if (hasHash(event.request.url) && hasSameHash(event.request.url, cached.url)) {
return cached;
}
}
return fetch(requestToFetch).then((response) => {
const clonedResponse = response.clone();
const contentType = clonedResponse.headers.get('content-type');
if (!clonedResponse || clonedResponse.status !== 200 || clonedResponse.type !== 'basic'
|| /\/sockjs\//.test(event.request.url)) {
return response;
}
if (/html/.test(contentType)) {
caches.open(version).then(cache => cache.put(HTMLToCache, clonedResponse));
} else {
// Delete old version of a file
if (hasHash(event.request.url)) {
caches.open(version).then(cache => cache.keys().then(keys => keys.forEach((asset) => {
if (new RegExp(removeHash(event.request.url)).test(removeHash(asset.url))) {
cache.delete(asset);
}
})));
}
caches.open(version).then(cache => cache.put(event.request, clonedResponse));
}
return response;
}).catch(() => {
if (hasHash(event.request.url)) return caches.match(event.request.url);
// If the request URL hasn't been served from cache and isn't sockjs we suppose it's HTML
else if (!/\/sockjs\//.test(event.request.url)) return caches.match(HTMLToCache);
// Only for sockjs
return new Response('No connection to the server', {
status: 503,
statusText: 'No connection to the server',
headers: new Headers({ 'Content-Type': 'text/plain' }),
});
});
})
);
}
});
function removeHash(element) {
if (typeof element === 'string') return element.split('?hash=')[0];
}
function hasHash(element) {
if (typeof element === 'string') return /\?hash=.*/.test(element);
}
function hasSameHash(firstUrl, secondUrl) {
if (typeof firstUrl === 'string' && typeof secondUrl === 'string') {
return /\?hash=(.*)/.exec(firstUrl)[1] === /\?hash=(.*)/.exec(secondUrl)[1];
}
}
// Service worker created by Ilan Schemoul alias NitroBAY as a specific Service Worker for Meteor
// Please see https://github.com/NitroBAY/meteor-service-worker for the official project source

View File

@@ -1,7 +1,8 @@
import '/imports/server/config/accountsEmailConfig.js';
import '/imports/server/config/SimpleRestConfig.js';
import '/imports/server/config/simpleSchemaDebug.js';
import '/imports/server/config/SyncedCronConfig.js';
import '/imports/server/publications/index.js';
import '/imports/server/config/simpleSchemaDebug.js';
import '/imports/server/cron/deleteSoftRemovedDocuments.js';
import '/imports/api/parenting/organizeMethods.js';
import '/imports/api/users/patreon/updatePatreonOnLogin.js';