Compare commits

..

35 Commits
1.7.2 ... 1.8.0

Author SHA1 Message Date
Stefan Zermatten
26784f11b6 Merge branch 'feature-backup-restore' 2019-02-11 12:01:52 +02:00
Stefan Zermatten
23d43f7d43 Added character restore functionality 2019-02-11 11:58:45 +02:00
Stefan Zermatten
1ebb0d2527 Got character copying working 2019-02-11 11:11:51 +02:00
Stefan Zermatten
9d86cb8bee Added the copy character method 2019-02-11 10:21:11 +02:00
Stefan Zermatten
3343f8a813 Allowed canViewCharacter to take in a character instead of a charId to save a database read 2019-02-11 10:17:43 +02:00
Stefan Zermatten
0260824c2f Made gave backup and restore the ability to change ids for all docs 2019-02-11 10:09:18 +02:00
Stefan Zermatten
66ee3ff808 Merge remote-tracking branch 'origin/master' into feature-backup-restore 2019-02-11 09:45:05 +02:00
Stefan Zermatten
3f32535666 fixed link to dicecloud repo 2019-01-31 10:11:32 +02:00
Stefan Zermatten
4ea02c4fbb Fixed hidden link to localhost 2019-01-31 10:10:56 +02:00
Stefan Zermatten
b052e8dd19 Update README.md 2019-01-31 10:09:19 +02:00
Stefan Zermatten
e2822b9f22 added naive backup restore 2019-01-28 11:35:56 +02:00
Stefan Zermatten
c46b836985 Merge pull request #192 from Frogvall/master
Updated heat metal according to SRD/PHB
2018-11-14 10:14:19 +02:00
Frogvall
65d1bac0dc Updated heat metal according to SRD/PHB 2018-11-14 06:40:22 +01:00
Stefan Zermatten
fcae3056de Merge branch 'bugfix-67' 2018-11-13 12:57:28 +02:00
Stefan Zermatten
7d364c80c0 Added hotfix to fix password button until the relevant package can be updated, fixes #67 2018-11-13 12:57:02 +02:00
Stefan Zermatten
0ff6c08abd Merge branch 'bugfix-150' 2018-11-13 12:01:52 +02:00
Stefan Zermatten
1c95336843 Spell slot bubbles are limited to 10, overflow is shown numerically
fixes #150
2018-11-13 12:01:40 +02:00
Stefan Zermatten
b36720511b Merge branch 'bugfix-155' 2018-11-13 10:39:26 +02:00
Stefan Zermatten
261220fdd5 Fixes #155, buffs can now only be applied if you have write access
refactored applying buffs to be a method, not a client side operation. You can now only applied if you have write access to the receiving character.
2018-11-13 10:39:14 +02:00
Stefan Zermatten
64edc52cca Merge branch 'bugfix-170' 2018-11-13 10:08:08 +02:00
Stefan Zermatten
56f1bd2829 Fixes #170 and maybe some other more subtle problems regarding soft removes not cascading properly, orphaning objects 2018-11-13 10:07:55 +02:00
Stefan Zermatten
3b669fd2f9 Merge branch 'bugfix-177' 2018-11-13 09:32:24 +02:00
Stefan Zermatten
933878e158 fixes #177 2018-11-13 09:31:57 +02:00
Stefan Zermatten
0e6ca56316 Merge branch 'bugfix-191' 2018-11-13 09:28:06 +02:00
Stefan Zermatten
6599fe1ef8 fixes #191 2018-11-13 09:26:36 +02:00
Stefan Zermatten
f39baf43a1 Merge branch 'bugfix-187' 2018-11-13 08:59:34 +02:00
Stefan Zermatten
96f4e35e25 fixes #187 2018-11-13 08:58:42 +02:00
Stefan Zermatten
e17dbf6601 Upgrade to meteor 1.8 2018-10-10 14:28:48 +02:00
Stefan Zermatten
3f81d419f7 updated meteor 2018-10-10 14:00:46 +02:00
Stefan Zermatten
1c00f5aa04 Hotfix iOS sign in issues (maybe) 2018-10-04 10:05:02 +02:00
Stefan Zermatten
f5a32cb50a Cobbled together some semblance of an item library UI 2018-10-02 15:43:10 +02:00
Stefan Zermatten
f4d3368fb4 Updated cron job to clean database of soft removed documents 2018-09-25 10:58:01 +02:00
Stefan Zermatten
1de9fb558a Merge pull request #182 from mommothazaz123/patch-1
fix error in Characters.deny()
2018-09-14 10:06:43 +02:00
Andrew Zhu
06ffc94b4c fix error in Characters.deny()
Renamed `docs` to `doc`, since `doc.owner` was undefined.
2018-09-07 14:07:55 -07:00
Stefan Zermatten
74c6a423ee Allowed owners to give their characters to new owners, no UI yet. 2018-08-28 09:59:10 +02:00
49 changed files with 1813 additions and 661 deletions

View File

@@ -1,13 +1,71 @@
RPG Docs
DiceCloud
========
This is the repo for [DiceCloud](dicecloud.com).
DiceCloud is a free, auditable, real-time character sheet for D&D 5e.
Philosophy
----------
Setting up your character on DiceCloud takes a little longer than
just filling it in on a paper character sheet would. The goal of using an
online sheet is to make actually playing the game more streamlined, and
ultimately more fun. So putting a little extra effort into setting up a
character now pays off over and over again once you're playing.
The idea is to track where each number comes from, and allow you to easily make
changes on the fly. Let's look at a hypothetical example.
> You need to swim through a sunken section of dungeon to fetch the quest's Thing.
> You'll need to take off your magical Plate Armor of +1 Constitution to swim
> without sinking, of course.
>
> Taking it off will take away that disadvantage on
> stealth checks, change your armor class, your speed and your constitution, and
> which in turn changes your hit points and your constitution saving throw.
> Working out all those changes in the middle of a game will drag the game to a
> halt.
>
> Fortunately you have DiceCloud, so it's a matter of dragging
> your Plate Armor +1 Con from your "equipment" box to your "backpack" box and
> you're done. Your hitpoints change correctly, your saving throws are up to date,
> your armor class goes back to reflecting the fact that you have natural armor
> from being a dragonborn. Your character sheet keeps up and you
> ultimately get more time to play the game. Huzzah!
Getting started
---------------
`git clone https://github.com/ThaumRystra/DiceCloud1 dicecloud`
Running DiceCloud locally, either to host it yourself away from an internet
connection, or to contribute to developing it further, is fairly
straightforward and it should work on Linux, Windows, and Mac.
You'll need to have installed:
- [git](https://www.atlassian.com/git/tutorials/install-git)
- [Meteor](https://www.meteor.com/install)
- [Bower](https://bower.io/)
Then, it's just a matter of cloning this repository into a folder, installing the bower dependencies and running
`meteor` in the app directory.
`git clone https://github.com/ThaumRystra/DiceCloud dicecloud`
`cd dicecloud`
`cd app`
`bower install`
`meteor`
You should see this:
```
=> Started proxy.
=> Started MongoDB.
=> Started your app.
=> App running at: http://localhost:3000/
```
Now, visiting http://localhost:3000/ should show you an empty instance of
DiceCloud running.

2
app/.gitignore vendored
View File

@@ -4,6 +4,8 @@
settings.json
public/components
public/_imports.html
private/oldClient
nohup.out
node_modules
dump
.cache

View File

@@ -4,7 +4,7 @@
# but you can also edit it by hand.
accounts-password@1.5.1
accounts-ui@1.3.0
accounts-ui@1.3.1
random@1.1.0
dburles:collection-helpers
reactive-var@1.0.11
@@ -17,7 +17,7 @@ dburles:mongo-collection-instances
percolate:migrations
ecwyne:mathjs
useraccounts:polymer
accounts-google@1.3.1
accounts-google@1.3.2
splendido:accounts-meld
email@1.2.3
meteorhacks:subs-manager
@@ -25,9 +25,9 @@ chuangbo:marked
reywood:iron-router-ga
meteor-base@1.4.0
mobile-experience@1.0.5
mongo@1.5.0
mongo@1.6.0
blaze-html-templates
session@1.1.7
session@1.1.8
jquery@1.11.10
tracker@1.2.0
logging@1.1.20
@@ -37,20 +37,20 @@ spacebars
check@1.3.1
useraccounts:iron-routing
wizonesolutions:canonical
standard-minifier-js@2.3.4
shell-server@0.3.1
standard-minifier-js@2.4.0
shell-server@0.4.0
seba:minifiers-autoprefixer
nikogosovd:multiple-uihooks
templates:array
ecmascript@0.11.1
ecmascript@0.12.0
es5-shim@4.8.0
differential:vulcanize
reactive-dict@1.2.0
percolate:synced-cron
reactive-dict@1.2.1
ongoworks:speakingurl
service-configuration@1.0.11
google-config-ui@1.0.0
dynamic-import@0.4.0
google-config-ui@1.0.1
dynamic-import@0.5.0
ddp-rate-limiter@1.0.7
rate-limit@1.0.9
iron:router
littledata:synced-cron

View File

@@ -1 +1 @@
METEOR@1.7.0.3
METEOR@1.8

View File

@@ -1,8 +1,8 @@
accounts-base@1.4.2
accounts-google@1.3.1
accounts-oauth@1.1.15
accounts-base@1.4.3
accounts-google@1.3.2
accounts-oauth@1.1.16
accounts-password@1.5.1
accounts-ui@1.3.0
accounts-ui@1.3.1
accounts-ui-unstyled@1.4.1
aldeed:collection2@2.10.0
aldeed:collection2-core@1.2.0
@@ -10,16 +10,16 @@ aldeed:schema-deny@1.1.0
aldeed:schema-index@1.1.1
aldeed:simple-schema@1.5.4
allow-deny@1.1.0
autoupdate@1.4.1
babel-compiler@7.1.1
babel-runtime@1.2.2
autoupdate@1.5.0
babel-compiler@7.2.0
babel-runtime@1.3.0
base64@1.0.11
binary-heap@1.0.10
blaze@2.3.2
binary-heap@1.0.11
blaze@2.3.3
blaze-html-templates@1.1.2
blaze-tools@1.0.10
boilerplate-generator@1.5.0
caching-compiler@1.1.12
boilerplate-generator@1.6.0
caching-compiler@1.2.0
caching-html-compiler@1.1.3
callback-hook@1.1.0
check@1.3.1
@@ -35,23 +35,25 @@ ddp-server@2.2.0
deps@1.0.12
diff-sequence@1.1.0
differential:vulcanize@3.0.0
dynamic-import@0.4.1
ecmascript@0.11.1
dynamic-import@0.5.0
ecmascript@0.12.0
ecmascript-runtime@0.7.0
ecmascript-runtime-client@0.7.1
ecmascript-runtime-server@0.7.0
ecmascript-runtime-client@0.8.0
ecmascript-runtime-server@0.7.1
ecwyne:mathjs@0.25.0
ejson@1.1.0
email@1.2.3
es5-shim@4.8.0
fetch@0.1.0
geojson-utils@1.0.10
google-config-ui@1.0.0
google-oauth@1.2.5
google-config-ui@1.0.1
google-oauth@1.2.6
hot-code-push@1.0.4
html-tools@1.0.11
htmljs@1.0.11
http@1.4.1
id-map@1.1.0
inter-process-messaging@0.1.0
iron:controller@1.0.12
iron:core@1.0.11
iron:dynamic-template@1.0.12
@@ -63,7 +65,8 @@ iron:url@1.1.0
jquery@1.11.11
lai:collection-extensions@0.2.1_1
launch-screen@1.1.1
less@2.7.12
less@2.8.0
littledata:synced-cron@1.5.1
livedata@1.0.18
localstorage@1.2.0
logging@1.1.20
@@ -72,51 +75,51 @@ mdg:validation-error@0.5.1
meteor@1.9.2
meteor-base@1.4.0
meteorhacks:subs-manager@1.6.4
minifier-css@1.3.1
minifier-js@2.3.5
minimongo@1.4.4
minifier-css@1.4.0
minifier-js@2.4.0
minimongo@1.4.5
mobile-experience@1.0.5
mobile-status-bar@1.0.14
modern-browsers@0.1.2
modules@0.12.2
modules@0.13.0
modules-runtime@0.10.2
momentjs:moment@2.22.2
mongo@1.5.1
mongo@1.6.0
mongo-decimal@0.1.0
mongo-dev-server@1.1.0
mongo-id@1.0.7
nikogosovd:multiple-uihooks@0.1.8
npm-bcrypt@0.9.3
npm-mongo@3.0.11
npm-mongo@3.1.1
oauth@1.2.3
oauth2@1.2.0
oauth2@1.2.1
observe-sequence@1.0.16
ongoworks:speakingurl@9.0.0
ordered-dict@1.1.0
percolate:migrations@0.9.8
percolate:synced-cron@1.3.2
promise@0.11.1
raix:eventemitter@0.1.3
random@1.1.0
rate-limit@1.0.9
reactive-dict@1.2.0
reactive-dict@1.2.1
reactive-var@1.0.11
reload@1.2.0
retry@1.1.0
reywood:iron-router-ga@0.7.1
routepolicy@1.0.13
seba:minifiers-autoprefixer@1.0.1
routepolicy@1.1.0
seba:minifiers-autoprefixer@1.1.1
service-configuration@1.0.11
session@1.1.7
session@1.1.8
sha@1.0.9
shell-server@0.3.1
shell-server@0.4.0
socket-stream-client@0.2.2
softwarerero:accounts-t9n@1.3.11
spacebars@1.0.15
spacebars-compiler@1.1.3
splendido:accounts-emails-field@1.2.0
splendido:accounts-meld@1.3.1
srp@1.0.10
standard-minifier-js@2.3.4
srp@1.0.12
standard-minifier-js@2.4.0
templates:array@1.0.3
templating@1.3.2
templating-compiler@1.3.3
@@ -129,7 +132,7 @@ url@1.2.0
useraccounts:core@1.14.2
useraccounts:iron-routing@1.14.2
useraccounts:polymer@1.14.2
webapp@1.6.2
webapp@1.7.0
webapp-hashing@1.0.9
wizonesolutions:canonical@0.0.5
zimme:collection-behaviours@1.1.3

View File

@@ -65,3 +65,37 @@ makeParent(Buffs, ["name", "enabled"]); //parents of effects, attacks, proficien
Buffs.allow(CHARACTER_SUBSCHEMA_ALLOW);
Buffs.deny(CHARACTER_SUBSCHEMA_DENY);
Meteor.methods({
applyBuff: function(buffId, targetId){
if (!Meteor.call("canWriteCharacter", targetId)){
throw new Meteor.Error(
"Access denied",
"You do not have permission to buff this character"
);
}
let buff = CustomBuffs.findOne(buffId);
if (!buff) return;
var parentCol = Meteor.isClient ?
window[buff.parent.collection] : global[buff.parent.collection]
var parent = parentCol.findOne(buff.parent.id);
//insert new buff
newBuffId = Buffs.insert({
charId: targetId,
name: buff.name,
description: buff.description,
lifeTime: {total: buff.lifeTime.total},
type: "custom",
appliedBy: buff.charId,
appliedByDetails: {
name: parent && parent.name || "",
collection: buff.parent.collection,
},
});
Meteor.call("cloneChildren", buffId, {id: newBuffId, collection: "Buffs"})
}
})

View File

@@ -582,8 +582,8 @@ Characters.allow({
});
Characters.deny({
update: function(userId, docs, fields, modifier) {
// can't change owners
return _.contains(fields, "owner");
update: function(userId, doc, fields, modifier) {
// can't change owners unless you are the current owner
return _.contains(fields, "owner") && doc.owner !== userId;
}
});

View File

@@ -2,14 +2,38 @@ Libraries = new Mongo.Collection("library");
Schemas.Library = new SimpleSchema({
name: {type: String},
owner: {type: String, regEx: SimpleSchema.RegEx.Id},
readers: {type: [String], regEx: SimpleSchema.RegEx.Id, defaultValue: []},
writers: {type: [String], regEx: SimpleSchema.RegEx.Id, defaultValue: []},
public: {type: Boolean, defaultValue: false},
owner: {type: String, regEx: SimpleSchema.RegEx.Id, index: 1},
readers: {type: [String], regEx: SimpleSchema.RegEx.Id, defaultValue: [], index: 1},
writers: {type: [String], regEx: SimpleSchema.RegEx.Id, defaultValue: [], index: 1},
public: {type: Boolean, defaultValue: false, index: 1},
});
Libraries.attachSchema(Schemas.Library);
Libraries.after.remove(function(userId, library) {
LibraryItems.remove({library: library._id});
LibrarySpells.remove({library: library._id});
});
Meteor.methods({
removeLibrary: function(libraryId) {
let library = Libraries.findOne(libraryId);
let userId = Meteor.userId();
if (!library) return;
if (library.owner === userId){
Libraries.remove(libraryId);
} else {
if (_.contains(library.readers, userId)){
Libraries.update(libraryId, {$pull: {"readers": userId}});
}
if (_.contains(library.writers, userId)){
Libraries.update(libraryId, {$pull: {"writers": userId}});
}
}
},
});
Libraries.allow({
insert(userId, doc) {
return userId && doc.owner === userId;
@@ -18,16 +42,14 @@ Libraries.allow({
return canEdit(userId, doc);
},
remove(userId, doc) {
return canEdit(userId, doc);
return userId && doc.owner === userId;
},
fetch: ["owner", "writers"],
});
Libraries.deny({
// For now, only admins can manage libraries
insert(userId, doc){
var user = Meteor.users.findOne(userId);
return !user || !_.contains(user.roles, "admin");
return !Meteor.users.findOne(userId);
},
update(userId, doc, fields, modifier) {
// Can't change owners

View File

@@ -1,7 +1,7 @@
Schemas.LibraryAttacks = new SimpleSchema({
name: {
type: String,
defaultValue: "New Attack",
optional: true,
trim: false,
},
details: {

View File

@@ -9,7 +9,6 @@ Schemas.LibraryEffects = new SimpleSchema({
defaultValue: "add",
allowedValues: [
"base",
"proficiency",
"add",
"mul",
"min",

View File

@@ -42,3 +42,50 @@ LibraryItems.allow({
},
fetch: ["library"],
});
Meteor.methods({
updateLibraryItemEffect: function({itemId, effectIndex, field, value, unsetField}){
let libraryId = LibraryItems.findOne(itemId).library;
let userId = Meteor.userId();
if (!Libraries.canEdit(userId, libraryId)) return;
let modifier = {
$set: {
[`effects.${effectIndex}.${field}`]: value,
}
};
if (unsetField){
modifier.$unset = {
[`effects.${effectIndex}.${unsetField}`]: 1,
}
}
LibraryItems.update(itemId, modifier);
},
removeLibraryItemEffect: function({itemId, effectIndex}){
let libraryId = LibraryItems.findOne(itemId).library;
let userId = Meteor.userId();
if (!Libraries.canEdit(userId, libraryId)) return;
LibraryItems.update(itemId, {$unset : {
[`effects.${effectIndex}`] : 1,
}});
LibraryItems.update(itemId, {$pull : {"effects" : null}});
},
updateLibraryItemAttack: function({itemId, attackIndex, field, value}){
let libraryId = LibraryItems.findOne(itemId).library;
let userId = Meteor.userId();
if (!Libraries.canEdit(userId, libraryId)) return;
LibraryItems.update(itemId, {
$set: {
[`attacks.${attackIndex}.${field}`]: value,
}
});
},
removeLibraryItemAttack: function({itemId, attackIndex}){
let libraryId = LibraryItems.findOne(itemId).library;
let userId = Meteor.userId();
if (!Libraries.canEdit(userId, libraryId)) return;
LibraryItems.update(itemId, {$unset : {
[`attacks.${attackIndex}`] : 1,
}});
LibraryItems.update(itemId, {$pull : {"attacks" : null}});
},
})

View File

@@ -3,6 +3,10 @@ Schemas.UserProfile = new SimpleSchema({
type: String,
optional: true,
},
librarySubscriptions: {
type: [String],
defaultValue: [],
},
});
Schemas.User = new SimpleSchema({

View File

@@ -121,7 +121,7 @@ Router.map(function() {
this.route("library", {
path: "/library",
waitOn: function(){
return subsManager.subscribe("standardLibraries");
return subsManager.subscribe("customLibraries");
},
onAfterAction: function() {
document.title = appName + " - Library";

View File

@@ -1,6 +1,6 @@
{
"name": "RPG Docs",
"version": "0.0.0",
"name": "dicecloud",
"version": "1",
"homepage": "",
"authors": [
"Stefan Zermatten"

View File

@@ -0,0 +1,5 @@
let pwdFormSubmit = AccountsTemplates.atPwdFormEvents["submit #at-pwd-form"]
Template.atPwdForm.events({
"click .at-btn.submit": pwdFormSubmit,
});

View File

@@ -0,0 +1,8 @@
/*
* iOS doens't believe in click events for some elements.
* This is here to convince it to allow buttons to be clickable
*/
button {
cursor: pointer;
}

View File

@@ -1,47 +1,5 @@
const applyBuff = function(targetId, buff) {
var parent = global[buff.parent.collection].findOne(buff.parent.id);
//insert new buff
newBuffId = Buffs.insert({
charId: targetId,
name: buff.name,
description: buff.description,
lifeTime: {total: buff.lifeTime.total},
type: "custom",
appliedBy: buff.charId,
appliedByDetails: {
name: parent.name,
collection: buff.parent.collection,
},
});
//insert children
Attacks.find({"parent.id": buff._id}).forEach(function(doc){
temp = _.clone(doc);
temp.parent.id = newBuffId;
temp.parent.collection = "Buffs";
delete temp._id;
Attacks.insert(temp);
});
Effects.find({"parent.id": buff._id}).forEach(function(doc){
temp = _.clone(doc);
temp.parent.id = newBuffId;
temp.parent.collection = "Buffs";
delete temp._id;
Effects.insert(temp);
});
Proficiencies.find({"parent.id": buff._id}).forEach(function(doc){
temp = _.clone(doc);
temp.parent.id = newBuffId;
temp.parent.collection = "Buffs";
delete temp._id;
Proficiencies.insert(temp);
});
Meteor.call("applyBuff", buff._id, targetId)
let target;
if (targetId == buff.charId) {
target = "self";

View File

@@ -17,6 +17,7 @@ Template.characterSettings.events({
"change #hideSpellcasting": function(event, instance){
var value = instance.find("#hideSpellcasting").checked;
if (this.settings.hideSpellcasting !== value){
Session.set(this._id + ".selectedTab", "0");
Characters.update(
this._id,
{$set: {"settings.hideSpellcasting": value}}

View File

@@ -53,7 +53,7 @@ Template.shareDialog.events({
Characters.update(this._id, {$set: {"settings.viewPermission": value}});
},
"input #userNameOrEmailInput":
function(event, instance){
_.debounce(function(event, instance){
var userName = instance.find("#userNameOrEmailInput").value;
instance.userId.set(undefined);
Meteor.call("getUserId", userName, function(err, result) {
@@ -64,7 +64,7 @@ Template.shareDialog.events({
instance.userId.set(result);
}
});
},
}, 300),
"click #shareButton": function(event, instance){
var self = this;
var permission = instance.find("#accessLevelMenu").selected;

View File

@@ -31,9 +31,17 @@
Settings
</paper-icon-item>
<paper-icon-item id="characterExport">
<iron-icon icon="content-copy" item-icon></iron-icon>
<iron-icon icon="exit-to-app" item-icon></iron-icon>
Export to Improved Initiative
</paper-icon-item>
<paper-icon-item id="characterCopy">
<iron-icon icon="content-copy" item-icon></iron-icon>
Make a copy
</paper-icon-item>
<paper-icon-item id="characterDump">
<iron-icon icon="file-download" item-icon></iron-icon>
Download a backup
</paper-icon-item>
</paper-menu>
</paper-menu-button>
{{else}}

View File

@@ -234,6 +234,18 @@ Template.characterSheet.events({
element: event.currentTarget.parentElement.parentElement,
});
},
"click #characterCopy": function(event, instance){
Meteor.call("copyCharacter", this._id, (error, char) => {
if (error){
console.error(error);
} else {
Router.go(`/character/${char._id}/${char.urlName || "-"}`);
}
});
},
"click #characterDump": function(event, instance){
saveCharacterDump(this._id);
},
"click #unshareCharacter": function(event, instance){
pushDialogStack({
data: this,

View File

@@ -48,6 +48,25 @@
{{/unless}}
</iron-collapse>
{{/each}}
{{#each customLibraries}}
<div class="paper-font-body2 category-header clickable">
<iron-icon icon="chevron-right" class="{{#if isOpen _id}}open{{/if}}">
</iron-icon>
{{name}}
</div>
<iron-collapse opened={{isOpen _id}}>
<table style="width: 100%">
<tbody>
{{#each item in (itemsInLibrary _id)}}
{{>libraryItem item=item selected=(isSelected item)}}
{{/each}}
</tbody>
</table>
{{#unless ready _id}}
<paper-spinner active></paper-spinner>
{{/unless}}
</iron-collapse>
{{/each}}
{{/if}}
</div>
</div>

View File

@@ -7,6 +7,13 @@ const categories = [
{name: "Tools", key: "tools"},
];
const categoryKeys = [
"weapons",
"armor",
"adventuringGear",
"tools",
];
Template.itemLibraryDialog.onCreated(function(){
this.selectedItem = new ReactiveVar();
this.searchTerm = new ReactiveVar();
@@ -14,10 +21,17 @@ Template.itemLibraryDialog.onCreated(function(){
this.readyDict = new ReactiveDict();
this.searchReady = new ReactiveVar();
librarySubs.subscribe("standardLibraries");
librarySubs.subscribe("customLibraries");
this.autorun(() => {
// Subscribe to all open categories
_.each(this.categoriesOpen.get(), (key) => {
var handle = librarySubs.subscribe("standardLibraryItems", key);
let handle;
if (_.contains(categoryKeys, key)){
handle = librarySubs.subscribe("standardLibraryItems", key);
} else {
handle = librarySubs.subscribe("libraryItems", key);
}
this.autorun(() => {
this.readyDict.set(key, handle.ready());
});
@@ -70,12 +84,29 @@ Template.itemLibraryDialog.helpers({
const searchTerm = Template.instance().searchTerm.get();
if (!searchTerm) return;
return LibraryItems.find({
library: "SRDLibraryGA3XWsd",
name: {
$regex: new RegExp(".*" + searchTerm + ".*", "gi")
},
});
},
customLibraries(){
let userId = Meteor.userId();
return Libraries.find({
$or: [
{readers: userId},
{writers: userId},
{owner: userId},
],
});
},
itemsInLibrary(libraryId){
return LibraryItems.find({
library: libraryId,
}, {
sort: {name: 1},
});
},
});
Template.itemLibraryDialog.events({
@@ -93,7 +124,7 @@ Template.itemLibraryDialog.events({
},
"click .category-header": function(event, template){
let cats = template.categoriesOpen.get();
const key = this.key;
const key = this.key || this._id;
// Toggle whether this key is in the array or not
if (_.contains(cats, key)){
cats = _.without(cats, key);

View File

@@ -11,16 +11,20 @@
<div class="bottom list">
{{#each levels}}{{#if showSlots ..}}
<div class="item-slot">
<div class="item spellSlot layout horizontal center">
<div style="margin-right: 16px">
<div class="white spellSlot layout horizontal center ">
<div class="spellLevelName" style="margin-right: 16px; margin-left: 8px; cursor: pointer;">
{{name}}
</div>
<div class="flex layout horizontal center">
<div class="flex layout horizontal center wrap">
{{#each slotBubbles ..}}
<paper-icon-button class="slotBubble"
icon={{icon}}
disabled={{disabled}}>
</paper-icon-button>
{{#unless overflow}}
<paper-icon-button class="slotBubble"
icon={{icon}}
disabled={{disabled}}>
</paper-icon-button>
{{else}}
<div class="paper-font-subhead">+{{overflow}}</div>
{{/unless}}
{{/each}}
</div>
</div>

View File

@@ -122,6 +122,7 @@ Template.spells.helpers({
return false;
},
slotBubbles: function(char){
const MAX_SLOTS = 10;
var baseSlots = Characters.calculate.attributeBase(
char._id, "level" + this.level + "SpellSlots"
);
@@ -130,16 +131,28 @@ Template.spells.helpers({
);
var slotsUsed = baseSlots - currentSlots;
var bubbles = [];
var i;
for (i = 0; i < currentSlots; i++){
var i, overflowFilled, overflowEmpty;
var filledSlots = currentSlots;
var maxEmptySlots = Math.max(MAX_SLOTS - filledSlots + 1, 1);
var emptySlots = slotsUsed;
if (baseSlots > MAX_SLOTS){
filledSlots = Math.min(MAX_SLOTS, filledSlots);
overflowFilled = Math.max(currentSlots - MAX_SLOTS, 0);
emptySlots = Math.min(maxEmptySlots, emptySlots);
overflowEmpty = Math.max(slotsUsed - maxEmptySlots, 0);
}
for (i = 0; i < filledSlots; i++){
bubbles.push({
icon: "radio-button-checked",
disabled: i !== currentSlots - 1 || !canEditCharacter(char._id), //last full slot not disabled
disabled: i !== filledSlots - 1 || !canEditCharacter(char._id), //last full slot not disabled
attribute: "level" + this.level + "SpellSlots",
charId: char._id,
});
}
for (i = 0; i < slotsUsed; i++){
if (overflowFilled){
bubbles.push({overflow: overflowFilled});
}
for (i = 0; i < emptySlots; i++){
bubbles.push({
icon: "radio-button-unchecked",
disabled: i !== 0 || !canEditCharacter(char._id), //first empty slot not disabled
@@ -147,6 +160,9 @@ Template.spells.helpers({
charId: char._id,
});
}
if (overflowEmpty){
bubbles.push({overflow: overflowEmpty});
}
return bubbles;
},
slotStatName: function() {
@@ -178,7 +194,7 @@ Template.spells.events({
}
event.stopPropagation();
},
"click .spellSlot": function(event, instance) {
"click .spellLevelName": function(event, instance) {
var name = "Level " + this.level + " Spell Slots";
var stat = "level" + this.level + "SpellSlots";
var charId = instance.data._id;

View File

@@ -58,6 +58,13 @@
</paper-fab>
{{#simpleTooltip class="always"}} New Character {{/simpleTooltip}}
</div>
<div>
<paper-fab icon="file-upload"
class="restoreCharacter"
mini>
</paper-fab>
{{#simpleTooltip class="always"}} Restore from backup {{/simpleTooltip}}
</div>
{{/fabMenu}}
</div>
</app-header-layout>

View File

@@ -81,4 +81,20 @@ Template.characterList.events({
returnElement: instance.find(`.party[data-id='${partyId}']`),
});
},
"click .restoreCharacter": function(event, instance) {
pushDialogStack({
template: "characterRestoreDialog",
element: event.currentTarget,
callback(dump){
if (!dump) return;
dump.character.name += " - Restored"
giveCharacterDumpNewIds(dump);
restoreCharacter(dump);
Router.go("characterSheet", {
_id: dump.character._id,
urlName: dump.character.urlName || '-',
});
},
})
},
});

View File

@@ -0,0 +1,31 @@
<template name="characterRestoreDialog">
<div class="fit layout vertical">
<app-header-layout has-scrolling-region class="new-character-dialog flex">
<app-header fixed effects="waterfall">
<app-toolbar>
<div main-title>Restore Character</div>
</app-toolbar>
</app-header>
<div class="form">
<p>
Restore a character from a backup file, this will create a new copy of
the restored character
</p>
<paper-input class="fileInput" type="file" label="File"></paper-input><br>
{{#if error}}
<p style="color: red;">
{{error}}
</p>
{{/if}}
</div>
</app-header-layout>
<div class="buttons layout horizontal end-justified">
<paper-button class="cancelButton">
Cancel
</paper-button>
<paper-button class="addButton" disabled={{invalid}}>
Restore
</paper-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,49 @@
Template.characterRestoreDialog.onCreated(function(){
this.dump = {};
this.valid = new ReactiveVar(false);
this.error = new ReactiveVar(null);
});
Template.characterRestoreDialog.helpers({
invalid(){
return !Template.instance().valid.get();
},
error(){
return Template.instance().error.get();
},
});
const fail = function(instance){
instance.valid.set(false);
instance.error.set("Failed to convert file into a valid character");
instance.dump = undefined;
};
Template.characterRestoreDialog.events({
"input .fileInput": function(event, instance){
let input = event.currentTarget.$.input;
let reader = new FileReader();
reader.onload = function(){
let dumpString = reader.result;
try {
let dump = JSON.parse(dumpString);
if (dump && dump.character && dump.collections){
instance.valid.set(true);
instance.error.set(null);
instance.dump = dump;
} else {
fail(instance);
}
} catch (e) {
fail(instance);
}
};
reader.readAsText(input.files[0]);
},
"click .cancelButton": function(event, instance){
popDialogStack();
},
"click .addButton": function(event, instance){
popDialogStack(instance.dump);
},
});

View File

@@ -1,5 +1,29 @@
<template name="itemLibrary">
{{#each items}}
{{> libraryItem}}
{{#each libraries}}
<div class="paper-font-subhead library-header layout horizontal center" data-id={{_id}} style="height: 40px;">
<iron-icon icon="chevron-right" class="{{#if isOpen _id}}open{{/if}}">
</iron-icon>
<div class="flex">{{name}}</div>
{{#if isOpen _id}}
<div class="relative">
<paper-icon-button icon="create" class="editLibrary"></paper-icon-button>
{{#simpleTooltip}}Edit Library{{/simpleTooltip}}
</div>
<div class="relative">
<paper-icon-button icon="add" class="addItem"></paper-icon-button>
{{#simpleTooltip}}Add Item{{/simpleTooltip}}
</div>
{{/if}}
</div>
<iron-collapse opened={{isOpen _id}}>
{{#each libraryItems}}
<paper-item class="short item-name" data-id={{_id}}>
{{name}}
</paper-item>
{{/each}}
{{#unless ready _id}}
<paper-spinner active></paper-spinner>
{{/unless}}
</iron-collapse>
{{/each}}
</template>

View File

@@ -1,7 +1,94 @@
const librarySubs = new SubsManager();
Template.itemLibrary.onCreated(function(){
this.selectedTab = new ReactiveVar("0");
this.librariesOpen = new ReactiveVar([]);
this.readyDict = new ReactiveDict();
this.autorun(() => {
// Subscribe to all open libraries
_.each(this.librariesOpen.get(), (libraryId) => {
var handle = librarySubs.subscribe("libraryItems", libraryId);
this.autorun(() => {
this.readyDict.set(libraryId, handle.ready());
});
});
});
});
Template.itemLibrary.helpers({
items(){
return Items.find({charId: {$in: [
"SRDLibrary",
]}});
selectedTab(){
return Template.instance().selectedTab.get();
},
libraries(){
let userId = Meteor.userId();
return Libraries.find({
$or: [
{readers: userId},
{writers: userId},
{owner: userId},
],
});
},
libraryItems(){
return LibraryItems.find({
library: this._id
},{
sort: {name: 1}
});
},
ready(libraryId){
return Template.instance().readyDict.get(libraryId);
},
isOpen(libraryId){
const librariesOpen = Template.instance().librariesOpen.get();
return _.contains(librariesOpen, libraryId);
},
});
Template.itemLibrary.events({
"click .library-header": function(event, template){
let libs = template.librariesOpen.get();
const libraryId = this._id;
// Toggle whether this key is in the array or not
if (_.contains(libs, libraryId)){
libs = _.without(libs, libraryId);
} else {
libs.push(libraryId);
}
template.librariesOpen.set(libs);
},
"click .editLibrary": function(event, instance){
event.stopPropagation();
var libraryId = this._id;
pushDialogStack({
template: "libraryDialog",
data: {libraryId},
element: event.currentTarget.parentElement.parentElement,
returnElement: () => instance.find(`.library-header[data-id='${libraryId}']`),
});
},
"click .addItem": function(event, instance){
event.stopPropagation();
var libraryId = this._id;
var itemId = LibraryItems.insert({
name: "New Library Item",
library: libraryId,
});
pushDialogStack({
template: "libraryItemDialog",
data: {itemId},
element: event.currentTarget,
returnElement: () => instance.find(`.item-name[data-id='${itemId}']`),
});
},
"click .item-name": function(event, instance){
event.stopPropagation();
var itemId = this._id;
pushDialogStack({
template: "libraryItemDialog",
data: {itemId},
element: event.currentTarget,
returnElement: () => instance.find(`.item-name[data-id='${itemId}']`),
});
},
})

View File

@@ -0,0 +1,88 @@
<template name="libraryItemDialog">
<div class="fit base-dialog layout vertical">
<app-toolbar>
<paper-icon-button id="backButton"
icon="arrow-back">
</paper-icon-button>
<div main-title>{{item.name}}</div>
<paper-icon-button id="deleteButton"
role="button"
tabindex="0"
icon="delete">
</paper-icon-button>
</app-toolbar>
<div class="form flex scroll-y" style="position: relative;">
<paper-input id="libraryItemLibraryNameInput" class="fullwidth" label="Library name (optional)" value={{item.libraryName}}></paper-input>
<paper-input id="libraryItemNameInput" class="fullwidth" label="Item name" value={{item.name}}></paper-input>
<div class="layout horizontal center wrap">
<paper-input id="libraryItemPluralInput" class="flex" label="Plural name" value={{item.plural}}></paper-input>
<paper-input id="libraryItemQuantityInput" class="flex" label="Quantity" type="number" value={{item.quantity}}></paper-input>
<paper-checkbox id="incrementCheckbox" class="flex" checked={{item.settings.showIncrement}}>
Show Increment
</paper-checkbox>
</div>
<div class="layout horizontal center wrap">
<paper-input id="libraryItemValueInput" class="flex" label="Value" type="number" value={{item.value}}></paper-input>
<paper-input id="libraryItemWeightInput" class="flex" label="Weight" type="number" value={{item.weight}}></paper-input>
<paper-checkbox id="attunementCheckbox" class="flex" checked={{item.requiresAttunement}}>
Requires Attunement
</paper-checkbox>
</div>
<paper-textarea id="libraryItemDescriptionInput" label="Description" value={{item.description}}></paper-textarea>
<div style="margin-top: 8px;">
<div class="paper-font-subhead">Effects</div>
{{#each indexedEffects}}
<div class="effect layout horizontal center wrap">
<paper-dropdown-menu label="Operation" class="operationMenu">
<paper-listbox class="dropdown-content" selected={{operationIndex operation}}>
<paper-item label="Base Value" name="base"> Base Value </paper-item>
<paper-item label="Add" name="add"> Add </paper-item>
<paper-item label="Multiply" name="mul"> Multiply </paper-item>
<paper-item label="Min" name="min"> Min </paper-item>
<paper-item label="Max" name="max"> Max </paper-item>
<paper-item label="Advantage" name="advantage"> Advantage </paper-item>
<paper-item label="Disadvantage" name="disadvantage"> Disadvantage </paper-item>
<paper-item label="PassiveAdd" name="passiveAdd"> PassiveAdd </paper-item>
<paper-item label="Fail" name="fail"> Fail </paper-item>
<paper-item label="Conditional" name="conditional"> Conditional </paper-item>
</paper-listbox>
</paper-dropdown-menu>
<paper-input class="LibraryItemEffectStat flex" label="Attribute" value={{stat}}></paper-input>
<paper-input class="LibraryItemEffectValue flex" label="Value" value={{calculationOrValue}}></paper-input>
<paper-icon-button icon="delete" class="deleteEffect"></paper-icon-button>
</div>
{{/each}}
<paper-button id="addEffect" class="red-button">Add Effect</paper-button>
</div>
<div style="margin-top: 8px;">
<div class="paper-font-subhead">Attacks</div>
{{#each indexedAttacks}}
<div class="effect layout horizontal center wrap">
<paper-input class="LibraryItemAttackBonusInput flex" label="Attack Bonus" value={{attackBonus}}></paper-input>
<paper-input class="LibraryItemAttackDamageInput flex" label="Damage" value={{damage}}></paper-input>
<paper-input class="LibraryItemAttackDetailsInput flex" label="Details" value={{details}}></paper-input>
<paper-dropdown-menu label="Damage Type" class="damageTypeMenu">
<paper-listbox class="dropdown-content" selected={{damageTypeIndex damageType}}>
<paper-item label="Bludgeoning" name="bludgeoning"> Bludgeoning </paper-item>
<paper-item label="Piercing" name="piercing"> Piercing </paper-item>
<paper-item label="Slashing" name="slashing"> Slashing </paper-item>
<paper-item label="Acid" name="acid"> Acid </paper-item>
<paper-item label="Cold" name="cold"> Cold </paper-item>
<paper-item label="Fire" name="fire"> Fire </paper-item>
<paper-item label="Force" name="force"> Force </paper-item>
<paper-item label="Lightning" name="lightning"> Lightning </paper-item>
<paper-item label="Necrotic" name="necrotic"> Necrotic </paper-item>
<paper-item label="Poison" name="poison"> Poison </paper-item>
<paper-item label="Psychic" name="psychic"> Psychic </paper-item>
<paper-item label="Radiant" name="radiant"> Radiant </paper-item>
<paper-item label="Thunder" name="thunder"> Thunder </paper-item>
</paper-listbox>
</paper-dropdown-menu>
<paper-icon-button icon="delete" class="deleteAttack"></paper-icon-button>
</div>
{{/each}}
<paper-button id="addAttack" class="red-button">Add Attack</paper-button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,204 @@
Template.libraryItemDialog.helpers({
item(){
return LibraryItems.findOne(this.itemId);
},
calculationOrValue(){
return this.calculation || this.value;
},
indexedEffects(){
let item = LibraryItems.findOne(this.itemId);
if (!item) return;
return _.map(item.effects, (effect, index) => {
if (!effect) return;
effect.index = index;
return effect;
});
},
indexedAttacks(){
let item = LibraryItems.findOne(this.itemId);
if (!item) return;
return _.map(item.attacks, (attack, index) => {
if (!attack) return;
attack.index = index;
return attack;
});
},
operationIndex(operation){
const ref = {
base: 0,
add: 1,
mul: 2,
min: 3,
max: 4,
advantage: 5,
disadvantage: 6,
passiveAdd: 7,
fail: 8,
conditional: 9,
};
return ref[operation];
},
damageTypeIndex(damageType){
const ref = {
bludgeoning: 0,
piercing: 1,
slashing: 2,
acid: 3,
cold: 4,
fire: 5,
force: 6,
lightning: 7,
necrotic: 8,
poison: 9,
psychic: 10,
radiant: 11,
thunder: 12,
};
return ref[damageType];
}
});
const bind = function(field){
return _.debounce(function(event){
const input = event.currentTarget;
var value = input.value;
LibraryItems.update(this.itemId, {
$set: {[field]: value}
}, {
removeEmptyStrings: false,
trimStrings: false,
});
}, 300);
};
Template.libraryItemDialog.events({
"click #backButton": function(){
popDialogStack();
},
"click #deleteButton": function(){
LibraryItems.remove(this.itemId);
popDialogStack();
},
"input #libraryItemLibraryNameInput": bind("libraryName"),
"input #libraryItemNameInput": bind("name"),
"input #libraryItemPluralInput": bind("plural"),
"input #libraryItemQuantityInput": bind("quantity"),
"input #libraryItemValueInput": bind("value"),
"input #libraryItemWeightInput": bind("weight"),
"change #attunementCheckbox": function(event){
LibraryItems.update(this.itemId, {
$set: {requiresAttunement: event.currentTarget.checked}
});
},
"change #incrementCheckbox": function(event){
LibraryItems.update(this.itemId, {
$set: {"settings.showIncrement": event.currentTarget.checked}
});
},
"input #libraryItemDescriptionInput": bind("description"),
// Effects
"click #addEffect": function(event, template){
LibraryItems.update(template.data.itemId, {
$push: {
effects: {operation: "add"}
}
});
},
"iron-select .operationMenu": function(event, template){
var detail = event.originalEvent.detail;
var opName = detail.item.getAttribute("name");
if (opName == this.operation) return;
Meteor.call("updateLibraryItemEffect", {
itemId: template.data.itemId,
effectIndex: this.index,
field: "operation",
value: opName,
});
},
"input .LibraryItemEffectStat": _.debounce(function(event, template){
Meteor.call("updateLibraryItemEffect", {
itemId: template.data.itemId,
effectIndex: this.index,
field: "stat",
value: event.currentTarget.value,
});
}, 300),
"input .LibraryItemEffectValue": _.debounce(function(event, template){
let value = event.currentTarget.value;
if (value && _.isFinite(+value)){
Meteor.call("updateLibraryItemEffect", {
itemId: template.data.itemId,
effectIndex: this.index,
field: "value",
unsetField: "calculation",
value,
});
} else {
Meteor.call("updateLibraryItemEffect", {
itemId: template.data.itemId,
effectIndex: this.index,
field: "calculation",
unsetField: "value",
value,
});
}
}, 300),
"click .deleteEffect": function (event, template) {
Meteor.call("removeLibraryItemEffect", {
itemId: template.data.itemId,
effectIndex: this.index,
});
},
// Attacks
"click #addAttack": function(event, template){
LibraryItems.update(template.data.itemId, {
$push: {
attacks: {damageType: "slashing"}
}
});
},
"iron-select .damageTypeMenu": function(event, template){
var detail = event.originalEvent.detail;
var damageType = detail.item.getAttribute("name");
if (damageType == this.damageType) return;
Meteor.call("updateLibraryItemAttack", {
itemId: template.data.itemId,
attackIndex: this.index,
field: "damageType",
value: damageType,
});
},
"input .LibraryItemAttackBonusInput": _.debounce(function(event, template){
Meteor.call("updateLibraryItemAttack", {
itemId: template.data.itemId,
attackIndex: this.index,
field: "attackBonus",
value: event.currentTarget.value,
});
}, 300),
"input .LibraryItemAttackDamageInput": _.debounce(function(event, template){
Meteor.call("updateLibraryItemAttack", {
itemId: template.data.itemId,
attackIndex: this.index,
field: "damage",
value: event.currentTarget.value,
});
}, 300),
"input .LibraryItemAttackDetailsInput": _.debounce(function(event, template){
Meteor.call("updateLibraryItemAttack", {
itemId: template.data.itemId,
attackIndex: this.index,
field: "details",
value: event.currentTarget.value,
});
}, 300),
"click .deleteAttack": function (event, template) {
Meteor.call("removeLibraryItemAttack", {
itemId: template.data.itemId,
attackIndex: this.index,
});
},
});

View File

@@ -0,0 +1,19 @@
.library .item-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.library .library-header {
font-weight: 500;
cursor: pointer;
}
.library .library-header iron-icon {
transition: transform 0.3s ease;
}
.library .library-header iron-icon.open {
transform: rotate(90deg);
}

View File

@@ -1,26 +1,32 @@
<template name="library">
<div class="fit layout vertical character-sheet">
<div class="fit layout vertical library">
<app-header fixed effects="waterfall">
<app-toolbar class="medium-tall app-grey white-text">
<div top-item class="layout horizontal center">
<paper-icon-button icon="menu" drawer-toggle></paper-icon-button>
<div class="flex">
Library
<div class="flex layout horizontal center" style="height: 40px; margin-left: 8px;">
Item Library
</div>
</div>
<!--
<div bottom-item>
<paper-tabs id="characterSheetTabs" selected={{selectedTab}} class="app-grey white-text">
<paper-tabs id="libraryTabs" selected={{selectedTab}} class="app-grey white-text">
<paper-tab name="items">Items</paper-tab>
<paper-tab name="spells">Spells</paper-tab>
</paper-tabs>
</div>
-->
</app-toolbar>
</app-header>
<div class="flex" style="position: relative;">
<iron-pages id="tabPages" class="fit" selected={{selectedTab}}>
<!-- <iron-pages id="tabPages" class="fit" selected={{selectedTab}}> -->
<div name="items" class="tab-page fit">{{> itemLibrary}}</div>
<div name="spells" class="tab-page fit">{{! {{> spellLibrary}} }}</div>
</iron-pages>
<!-- <div name="spells" class="tab-page fit">{{! {{> spellLibrary}} }}</div>
</iron-pages> -->
</div>
<div class="floatyButton">
<paper-fab id="addLibrary" icon="add"></paper-fab>
{{#simpleTooltip}}Add Library{{/simpleTooltip}}
</div>
</div>
</template>

View File

@@ -0,0 +1,29 @@
const librarySubs = new SubsManager();
Template.library.onCreated(function(){
this.selectedTab = new ReactiveVar("0");
});
Template.library.helpers({
selectedTab(){
return Template.instance().selectedTab.get();
},
});
Template.library.events({
"iron-select #libraryTabs": function(event, instance){
instance.selectedTab.set(event.target.selected);
},
"click #addLibrary": function(event, instance){
var libraryId = Libraries.insert({
name: "New Library",
owner: Meteor.userId(),
});
pushDialogStack({
template: "libraryDialog",
data: {libraryId},
element: event.currentTarget,
returnElement: () => instance.find(`.library-header[data-id='${libraryId}']`),
});
},
})

View File

@@ -0,0 +1,58 @@
<template name="libraryDialog">
<div class="fit base-dialog layout vertical">
<app-toolbar>
<div main-title>{{library.name}}</div>
<paper-icon-button id="deleteButton"
role="button"
tabindex="0"
icon="delete">
</paper-icon-button>
</app-toolbar>
<div class="form flex scroll-y" style="position: relative;">
<paper-input id="libraryNameInput" class="fullwidth" label="Name" value={{library.name}}></paper-input>
<div class="layout horizontal center wrap">
<paper-input class="flex" id="userNameOrEmailInput" label="Share with username or email" floatinglabel></paper-input>
<paper-button id="shareButton"
class="red-button"
style="width: 80px; height: 37px; margin-top: 16px;"
raised
disabled={{shareButtonDisabled}}>Share</paper-button>
<paper-radio-group id="accessLevelMenu" selected="read">
<paper-radio-button name="read">View Only</paper-radio-button>
<paper-radio-button name="write">Can Edit</paper-radio-button>
</paper-radio-group>
</div>
<p style="color: red;">{{userFindError}}</p>
<div>
{{#if readers.length}}
<div class="paper-font-subhead">
Can View
</div>
{{#each id in readers}}
<div class="layout horizontal center">
{{#with id=id}}
<paper-icon-button class="deleteShare" icon="delete">
</paper-icon-button>
{{/with}}
<div class="flex">{{username id}}</div>
</div>
{{/each}}
{{/if}}
{{#if writers.length}}
<div class="paper-font-subhead">
Can Edit
</div>
{{#each id in writers}}
<div class="layout horizontal center">
{{#with id=id}}
<paper-icon-button class="deleteShare" icon="delete">
</paper-icon-button>
{{/with}}
<div class="flex">{{username id}}</div>
</div>
{{/each}}
{{/if}}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,95 @@
Template.libraryDialog.onCreated(function(){
this.userId = new ReactiveVar();
this.autorun(() => {
var library = Libraries.findOne(Template.currentData().libraryId, {
fields: {readers: 1, writers: 1, owner: 1}
});
if (!library) return;
this.subscribe("userNames", _.union(library.readers, library.writers, [library.owner]));
});
});
Template.libraryDialog.helpers({
library(){
return Libraries.findOne(this.libraryId);
},
readers: function(){
var library = Libraries.findOne(this.libraryId, {fields: {readers: 1}});
return library && library.readers;
},
writers: function(){
var library = Libraries.findOne(this.libraryId, {fields: {writers: 1}});
return library && library.writers
},
username: function(id){
const user = Meteor.users.findOne(id);
return user && user.username || "user: " + id;
},
shareButtonDisabled: function(){
return !Template.instance().userId.get();
},
userFindError: function(){
if (!Template.instance().userId.get()){
return "User not found";
}
},
});
Template.libraryDialog.events({
"input #libraryNameInput": _.debounce(function(event){
const input = event.currentTarget;
var name = input.value;
if (!name){
input.invalid = true;
input.errorMessage = "Name is required";
} else {
input.invalid = false;
Libraries.update(this.libraryId, {
$set: {name}
}, {
removeEmptyStrings: false,
trimStrings: false,
});
}
}, 300),
"click #deleteButton": function(){
Meteor.call("removeLibrary", this.libraryId);
popDialogStack();
},
"input #userNameOrEmailInput":
function(event, instance){
var userName = instance.find("#userNameOrEmailInput").value;
instance.userId.set(undefined);
Meteor.call("getUserId", userName, function(err, result) {
if (err){
console.error(err);
} else {
console.log(result);
instance.userId.set(result);
}
});
},
"click #shareButton": function(event, instance){
var self = this;
var permission = instance.find("#accessLevelMenu").selected;
if (!permission) throw "no permission set";
var userId = instance.userId.get();
if (!userId) return;
if (permission === "write"){
Libraries.update(self.libraryId, {
$addToSet: {writers: userId},
$pull: {readers: userId},
});
} else {
Libraries.update(self.libraryId, {
$addToSet: {readers: userId},
$pull: {writers: userId},
});
}
},
"click .deleteShare": function(event, instance) {
Libraries.update(instance.data.libraryId, {
$pull: {writers: this.id, readers: this.id}
});
},
});

View File

@@ -0,0 +1,83 @@
import { saveAs } from 'file-saver';
let characterCollections = [];
Meteor.startup(() => {
characterCollections = [
Actions,
Attacks,
Buffs,
Classes,
Conditions,
CustomBuffs,
Effects,
Experiences,
Features,
Notes,
Proficiencies,
SpellLists,
Spells,
TemporaryHitPoints,
Items,
Containers,
];
});
dumpCharacter = function(charId){
let characterDump = {collections: {}};
characterDump.character = Characters.findOne(charId);
characterCollections.forEach(c => {
characterDump.collections[c._name] = c.find({charId}).fetch();
});
return characterDump;
};
saveCharacterDump = function(charId){
let dump = dumpCharacter(charId);
let textDump = JSON.stringify(dump, null, 2);
let charName = dump.character.name;
let blob = new Blob([textDump], {type: "application/json;charset=utf-8"});
saveAs(blob, `${charName}.JSON`);
};
giveCharacterDumpNewIds = function(characterDump){
// Give the character a new Id
const oldCharId = characterDump.character._id;
const newCharId = Random.id();
characterDump.character._id = newCharId;
let idMap = {[oldCharId]: newCharId}; // {oldId: newId}
// Give all documents a new Id, and store the mapping from old to new
for (let colName in characterDump.collections){
for (let doc of characterDump.collections[colName]){
let oldId = doc._id;
let newId = Random.id();
doc._id = newId;
idMap[oldId] = newId;
}
}
// Replace all references to old Ids with new ones
for (let colName in characterDump.collections){
for (let doc of characterDump.collections[colName]){
// Replace the character Id with the new one
doc.charId = newCharId;
// Replace the parent reference id with a new id
if (doc.parent && doc.parent.id){
let newParentId = idMap[doc.parent.id];
if(!newParentId) throw `Can't find the mapping for id ${doc.parent.id}`;
doc.parent.id = newParentId;
}
}
}
}
restoreCharacter = function(characterDump){
Characters.insert(characterDump.character);
for (collectionName in characterDump.collections){
let collection = Meteor.Collection.get(collectionName);
for (doc in characterDump[collectionName]){
collection.insert(doc);
}
}
};

View File

@@ -3,10 +3,12 @@ var childSchema = new SimpleSchema({
"parent.collection": {type: String},
"parent.id": {type: String, regEx: SimpleSchema.RegEx.Id, index: 1},
"parent.group": {type: String, optional: true},
"removed": {type: Boolean, optional: true, index: 1},
"removedWith": {
optional: true,
type: String,
regEx: SimpleSchema.RegEx.Id,
index: 1,
},
});
@@ -143,17 +145,17 @@ var checkPermission = function(userId, charId){
return true;
};
var cascadeSoftRemove = function(id, removedWithId){
var cascadeSoftRemove = function(parentId, removedWithId){
_.each(childCollections, function(treeCollection){
treeCollection.update(
{"parent.id": id},
{"parent.id": parentId},
{$set: {
removed: true,
removedWith: removedWithId,
}},
{multi: true}
);
treeCollection.find({"parent.id": id}).forEach(function(doc){
treeCollection.find({"parent.id": parentId, removed: true}).forEach(function(doc){
cascadeSoftRemove(doc._id, removedWithId);
});
});

View File

@@ -5,12 +5,14 @@ canEditCharacter = function(charId, userId){
return (userId === char.owner || _.contains(char.writers, userId));
};
canViewCharacter = function(charId, userId){
canViewCharacter = function(char, userId){
userId = userId || Meteor.userId();
var char = Characters.findOne(
charId,
{fields: {owner: 1, writers: 1, readers: 1, "settings.viewPermission": 1}}
);
if (typeof char !== 'object'){
char = Characters.findOne(
charId,
{fields: {owner: 1, writers: 1, readers: 1, "settings.viewPermission": 1}}
);
}
if (!char) return true;
return userId === char.owner ||
char.settings.viewPermission === "public" ||

View File

@@ -1,14 +1,9 @@
Meteor.methods({
"getUserId": function(username){
if (!username) return;
regex = new RegExp("^" + username + "$", "i")
var user = Meteor.users.findOne(
{$or: [
{username: username},
{"emails.address": regex},
{"services.google.email": regex},
]}
);
if (Meteor.isClient) return;
let user = Accounts.findUserByUsername(username) ||
Accounts.findUserByEmail(username);
return user && user._id;
}
});

View File

@@ -0,0 +1,26 @@
// Uses '/lib/functions/backupRestoreCharacter.js' to do most the work
Meteor.methods({
copyCharacter: function(charId) {
const userId = Meteor.userId();
let character = Characters.findOne(charId);
// Need at least view level permission to make a copy for yourself
if (!canViewCharacter(character, userId)) return;
let characterDump = dumpCharacter(charId);
giveCharacterDumpNewIds(characterDump);
// Remove all readers and writers, make this user the new owner
characterDump.character.readers = [];
characterDump.character.writers = [];
characterDump.character.owner = userId;
// Rename the character so it's obviously a copy
characterDump.character.name += " - Copy";
// Write the character back to the database
restoreCharacter(characterDump);
return characterDump.character;
},
});

1061
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "rpg-docs",
"name": "dicecloud",
"version": "0.10.0",
"description": "Unofficial Online Realtime D&D 5e App",
"scripts": {
@@ -11,16 +11,16 @@
},
"author": "Stefan Zermatten",
"dependencies": {
"@babel/runtime": "^7.0.0-beta.49",
"@babel/runtime": "^7.1.2",
"@polymer/polymer": "^1.2.5-npm-test.2",
"babel-runtime": "^6.26.0",
"bcrypt": "^1.0.3",
"bower": "^1.7.9",
"core-js": "^2.5.7",
"fibers": "^2.0.2",
"file-saver": "^2.0.0",
"meteor-node-stubs": "^0.3.3",
"qrcode": "^1.2.0",
"source-map-support": "^0.5.6",
"qrcode": "^1.3.0",
"source-map-support": "^0.5.9",
"underscore": "^1.9.1"
}
}

View File

@@ -15,29 +15,33 @@ Meteor.startup(() => {
const now = new Date();
const thirtyMinutesAgo = new Date(now.getTime() - 30*60000);
_.each(collections, (collection) => {
numRemoved += collection.remove({
collection.remove({
removed: true,
removedAt: {$lt: thirtyMinutesAgo} // dates *before* 30 minutes ago
}, function(error, result){
if (error){
console.error(error);
}
});
});
return numRemoved;
};
SyncedCron.add({
name: "Delete all soft removed items that haven't been restored",
name: "deleteSoftRemovedDocs",
schedule: function(parser) {
return parser.text('every 6 hours');
return parser.text('every 2 hours');
},
job: function() {
deleteOldSoftRemovedDocs();
}
job: deleteOldSoftRemovedDocs,
});
SyncedCron.start();
// Add a method to manually trigger removal
Meteor.methods({
deleteOldSoftRemovedDocs() {
const user = Meteor.users.findOne(this.userId);
if (user && _.contains(user.roles, "admin")){
this.unblock();
return deleteOldSoftRemovedDocs();
}
},

View File

@@ -23,3 +23,18 @@ Meteor.publish("standardLibrarySpells", function(level){
sort: {name: 1},
});
});
Meteor.publish("customLibraries", function(){
userId = this.userId;
return Libraries.find({
$or: [
{readers: userId},
{writers: userId},
{owner: userId},
],
});
});
Meteor.publish("libraryItems", function(libraryId){
return LibraryItems.find({library: libraryId});
});

View File

@@ -4,5 +4,6 @@ Meteor.publish("user", function(){
username: 1,
profile: 1,
apiKey: 1,
librarySubscriptions: 1,
}});
});

View File

@@ -2194,9 +2194,9 @@
}
},
{
"castingTime": "bonus action",
"castingTime": "action",
"description": "Choose a manufactured metal object, such as a metal weapon or a suit of heavy or medium metal armor, that you can see within range. You cause the object to glow red-hot. Any creature in physical contact with the object takes 2d8 fire damage when you cast the spell. Until the spell ends, you can use a bonus action on each of your subsequent turns to cause this damage again.\n\nIf a creature is holding or wearing the object and takes the damage from it, the creature must succeed on a DC {DC} Constitution saving throw or drop the object if it can. If it doesnt drop the object, it has disadvantage on attack rolls and ability checks until the start of your next turn.\n\n***At Higher Levels.*** When you cast this spell using a spell slot of 3rd level or higher, the damage increases by 1d8 for each slot level above 2nd.",
"duration": "Instantaneous",
"duration": "Concentration, up to 1 minute",
"level": 2,
"range": "60 feet",
"school": "Transmutation",
@@ -2204,8 +2204,9 @@
"name": "Heat Metal",
"components": {
"verbal": true,
"somatic": false,
"concentration": false
"somatic": true,
"concentration": true,
"material": "a piece of iron and a flame"
}
},
{