Compare commits
64 Commits
2.0-beta.7
...
1.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d969bd447 | ||
|
|
b3aeaf06ea | ||
|
|
85e3b0724a | ||
|
|
81a3ede86e | ||
|
|
d4864dda5f | ||
|
|
5ce1b6aff8 | ||
|
|
41731212ef | ||
|
|
ef9867d409 | ||
|
|
721300700e | ||
|
|
bc6dfbe498 | ||
|
|
0a22073d67 | ||
|
|
857213f157 | ||
|
|
b3371fca53 | ||
|
|
3fbb006783 | ||
|
|
2253672f43 | ||
|
|
ed6d557f8a | ||
|
|
4d642b56bb | ||
|
|
436c5bb785 | ||
|
|
8489ef5ec0 | ||
|
|
c9710bdb09 | ||
|
|
26784f11b6 | ||
|
|
23d43f7d43 | ||
|
|
1ebb0d2527 | ||
|
|
9d86cb8bee | ||
|
|
3343f8a813 | ||
|
|
0260824c2f | ||
|
|
66ee3ff808 | ||
|
|
cb71f6d380 | ||
|
|
2f04d9ec1c | ||
|
|
40c54524a7 | ||
|
|
b890a3b11e | ||
|
|
c9242a95f3 | ||
|
|
fedda62c7c | ||
|
|
612575d0e6 | ||
|
|
d1d22c0d89 | ||
|
|
b94f5ebb4b | ||
|
|
3f32535666 | ||
|
|
4ea02c4fbb | ||
|
|
b052e8dd19 | ||
|
|
e2822b9f22 | ||
|
|
c46b836985 | ||
|
|
65d1bac0dc | ||
|
|
fcae3056de | ||
|
|
7d364c80c0 | ||
|
|
0ff6c08abd | ||
|
|
1c95336843 | ||
|
|
b36720511b | ||
|
|
261220fdd5 | ||
|
|
64edc52cca | ||
|
|
56f1bd2829 | ||
|
|
3b669fd2f9 | ||
|
|
933878e158 | ||
|
|
0e6ca56316 | ||
|
|
6599fe1ef8 | ||
|
|
f39baf43a1 | ||
|
|
96f4e35e25 | ||
|
|
e17dbf6601 | ||
|
|
3f81d419f7 | ||
|
|
1c00f5aa04 | ||
|
|
f5a32cb50a | ||
|
|
f4d3368fb4 | ||
|
|
1de9fb558a | ||
|
|
06ffc94b4c | ||
|
|
74c6a423ee |
86
README.md
86
README.md
@@ -1,13 +1,93 @@
|
||||
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 run it locally, 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)
|
||||
|
||||
Then, it's just a matter of cloning this repository into a folder, installing the dependencies and running
|
||||
`meteor` in the app directory:
|
||||
|
||||
`git clone https://github.com/ThaumRystra/DiceCloud dicecloud`
|
||||
`cd dicecloud`
|
||||
`cd app`
|
||||
`bower install`
|
||||
`meteor npm install`
|
||||
`meteor`
|
||||
|
||||
If you edit the source code at this point, Meteor will rebuild the server with
|
||||
your changes.
|
||||
|
||||
If you want to simulate a production environment, run `meteor --production`
|
||||
|
||||
This will minimize all the files served to your browser, and load a lot faster,
|
||||
in exchange for not watching the source code for changes.
|
||||
|
||||
Note that this is not how you should deploy Meteor to your own web server, that
|
||||
is documented here: https://guide.meteor.com/deployment.html
|
||||
|
||||
After running `meteor` or `meteor --production`, you should see this, possibly
|
||||
mixed with other logged text:
|
||||
|
||||
```
|
||||
=> 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.
|
||||
|
||||
To stop the process when you are done (or if it gets stuck) press `ctrl-c`
|
||||
|
||||
## Adding default documents
|
||||
|
||||
Navigate to `/dataSources/srd/srdimport.js`, and follow the steps under
|
||||
'First Setup', running the code in your browser's console, while logged in to
|
||||
your own instance of DiceCloud.
|
||||
|
||||
Do not run code in your browser console on the live version of DiceCloud hosted
|
||||
at dicecloud.com, as doing so could result in a large number of denied requests
|
||||
to the server, and may get your account permanently banned.
|
||||
|
||||
3
app/.gitignore
vendored
3
app/.gitignore
vendored
@@ -4,6 +4,9 @@
|
||||
settings.json
|
||||
public/components
|
||||
public/_imports.html
|
||||
private/oldClient
|
||||
nohup.out
|
||||
node_modules
|
||||
dump
|
||||
.idea/
|
||||
.cache
|
||||
|
||||
@@ -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,32 +25,32 @@ chuangbo:marked
|
||||
reywood:iron-router-ga
|
||||
meteor-base@1.4.0
|
||||
mobile-experience@1.0.5
|
||||
mongo@1.5.0
|
||||
mongo@1.6.2
|
||||
blaze-html-templates
|
||||
session@1.1.7
|
||||
session@1.2.0
|
||||
jquery@1.11.10
|
||||
tracker@1.2.0
|
||||
logging@1.1.20
|
||||
reload@1.2.0
|
||||
reload@1.3.0
|
||||
ejson@1.1.0
|
||||
spacebars
|
||||
check@1.3.1
|
||||
useraccounts:iron-routing
|
||||
wizonesolutions:canonical
|
||||
standard-minifier-js@2.3.4
|
||||
shell-server@0.3.1
|
||||
shell-server@0.4.0
|
||||
seba:minifiers-autoprefixer
|
||||
nikogosovd:multiple-uihooks
|
||||
templates:array
|
||||
ecmascript@0.11.1
|
||||
ecmascript@0.12.4
|
||||
es5-shim@4.8.0
|
||||
differential:vulcanize
|
||||
reactive-dict@1.2.0
|
||||
percolate:synced-cron
|
||||
reactive-dict@1.3.0
|
||||
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.1
|
||||
ddp-rate-limiter@1.0.7
|
||||
rate-limit@1.0.9
|
||||
iron:router
|
||||
littledata:synced-cron
|
||||
montiapm:agent
|
||||
zodern:standard-minifier-js
|
||||
|
||||
@@ -1 +1 @@
|
||||
METEOR@1.7.0.3
|
||||
METEOR@1.8.1
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
accounts-base@1.4.2
|
||||
accounts-google@1.3.1
|
||||
accounts-oauth@1.1.15
|
||||
accounts-base@1.4.4
|
||||
accounts-google@1.3.2
|
||||
accounts-oauth@1.1.16
|
||||
accounts-password@1.5.1
|
||||
accounts-ui@1.3.0
|
||||
accounts-ui-unstyled@1.4.1
|
||||
accounts-ui@1.3.1
|
||||
accounts-ui-unstyled@1.4.2
|
||||
aldeed:collection2@2.10.0
|
||||
aldeed:collection2-core@1.2.0
|
||||
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.6.0
|
||||
babel-compiler@7.3.4
|
||||
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.1
|
||||
caching-html-compiler@1.1.3
|
||||
callback-hook@1.1.0
|
||||
check@1.3.1
|
||||
@@ -31,27 +31,29 @@ ddp@1.4.0
|
||||
ddp-client@2.3.3
|
||||
ddp-common@1.4.0
|
||||
ddp-rate-limiter@1.0.7
|
||||
ddp-server@2.2.0
|
||||
ddp-server@2.3.0
|
||||
deps@1.0.12
|
||||
diff-sequence@1.1.0
|
||||
diff-sequence@1.1.1
|
||||
differential:vulcanize@3.0.0
|
||||
dynamic-import@0.4.1
|
||||
ecmascript@0.11.1
|
||||
dynamic-import@0.5.1
|
||||
ecmascript@0.12.7
|
||||
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.1
|
||||
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
|
||||
http@1.4.2
|
||||
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
|
||||
@@ -62,61 +64,63 @@ iron:router@1.1.2
|
||||
iron:url@1.1.0
|
||||
jquery@1.11.11
|
||||
lai:collection-extensions@0.2.1_1
|
||||
lamhieu:meteorx@2.0.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
|
||||
matb33:collection-hooks@0.8.4
|
||||
mdg:validation-error@0.5.1
|
||||
meteor@1.9.2
|
||||
meteor@1.9.3
|
||||
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.2
|
||||
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-runtime@0.10.2
|
||||
momentjs:moment@2.22.2
|
||||
mongo@1.5.1
|
||||
modern-browsers@0.1.4
|
||||
modules@0.13.0
|
||||
modules-runtime@0.10.3
|
||||
momentjs:moment@2.24.0
|
||||
mongo@1.6.2
|
||||
mongo-decimal@0.1.1
|
||||
mongo-dev-server@1.1.0
|
||||
mongo-id@1.0.7
|
||||
mongo-livedata@1.0.12
|
||||
montiapm:agent@2.35.0
|
||||
nikogosovd:multiple-uihooks@0.1.8
|
||||
npm-bcrypt@0.9.3
|
||||
npm-mongo@3.0.11
|
||||
oauth@1.2.3
|
||||
oauth2@1.2.0
|
||||
npm-mongo@3.1.2
|
||||
oauth@1.2.8
|
||||
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
|
||||
promise@0.11.2
|
||||
raix:eventemitter@0.1.3
|
||||
random@1.1.0
|
||||
rate-limit@1.0.9
|
||||
reactive-dict@1.2.0
|
||||
reactive-dict@1.3.0
|
||||
reactive-var@1.0.11
|
||||
reload@1.2.0
|
||||
reload@1.3.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.2
|
||||
service-configuration@1.0.11
|
||||
session@1.1.7
|
||||
session@1.2.0
|
||||
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
|
||||
templates:array@1.0.3
|
||||
templating@1.3.2
|
||||
templating-compiler@1.3.3
|
||||
@@ -129,8 +133,9 @@ 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.4
|
||||
webapp-hashing@1.0.9
|
||||
wizonesolutions:canonical@0.0.5
|
||||
zimme:collection-behaviours@1.1.3
|
||||
zimme:collection-softremovable@1.0.5
|
||||
zodern:minifier-js@3.0.0
|
||||
zodern:standard-minifier-js@3.0.0
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,14 +2,40 @@ 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);
|
||||
|
||||
if (Meteor.isServer){
|
||||
Libraries.after.remove(function(userId, library) {
|
||||
LibraryItems.remove({library: library._id});
|
||||
LibrarySpells.remove({library: library._id});
|
||||
});
|
||||
}
|
||||
|
||||
Meteor.methods({
|
||||
unshareLibraryWithMe: function(libraryId) {
|
||||
let library = Libraries.findOne(libraryId);
|
||||
let userId = Meteor.userId();
|
||||
|
||||
if (!library) return;
|
||||
if (library.owner === userId){
|
||||
throw new Meteor.error("Can't unshare, you own this")
|
||||
} 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 +44,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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Schemas.LibraryAttacks = new SimpleSchema({
|
||||
name: {
|
||||
type: String,
|
||||
defaultValue: "New Attack",
|
||||
optional: true,
|
||||
trim: false,
|
||||
},
|
||||
details: {
|
||||
|
||||
@@ -9,7 +9,6 @@ Schemas.LibraryEffects = new SimpleSchema({
|
||||
defaultValue: "add",
|
||||
allowedValues: [
|
||||
"base",
|
||||
"proficiency",
|
||||
"add",
|
||||
"mul",
|
||||
"min",
|
||||
|
||||
@@ -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}});
|
||||
},
|
||||
})
|
||||
|
||||
26
app/Model/Meta/PatreonPosts.js
Normal file
26
app/Model/Meta/PatreonPosts.js
Normal file
@@ -0,0 +1,26 @@
|
||||
PatreonPosts = new Mongo.Collection("patreonPosts");
|
||||
|
||||
Schemas.PatreonPosts = new SimpleSchema({
|
||||
link: {
|
||||
type: String,
|
||||
},
|
||||
dateAdded: {
|
||||
type: Date,
|
||||
autoValue(){
|
||||
return new Date();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
PatreonPosts.attachSchema(Schemas.PatreonPosts);
|
||||
|
||||
PatreonPosts.allow({
|
||||
insert: function(userId, doc) {
|
||||
var user = Meteor.users.findOne(userId);
|
||||
if (user) return _.contains(user.roles, "admin");
|
||||
},
|
||||
remove: function(userId, doc) {
|
||||
var user = Meteor.users.findOne(userId);
|
||||
if (user) return _.contains(user.roles, "admin");
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,10 @@ Schemas.UserProfile = new SimpleSchema({
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
librarySubscriptions: {
|
||||
type: [String],
|
||||
defaultValue: [],
|
||||
},
|
||||
});
|
||||
|
||||
Schemas.User = new SimpleSchema({
|
||||
@@ -66,6 +70,45 @@ Schemas.User = new SimpleSchema({
|
||||
index: 1,
|
||||
optional: true,
|
||||
},
|
||||
lastPatreonPostClicked: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
patreon: {
|
||||
type: Object,
|
||||
optional: true,
|
||||
},
|
||||
"patreon.accessToken": {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
"patreon.refreshToken": {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
"patreon.tokenExpiryDate": {
|
||||
type: Date,
|
||||
optional: true,
|
||||
},
|
||||
"patreon.userId": {
|
||||
type: String,
|
||||
optional: true,
|
||||
index: 1,
|
||||
},
|
||||
"patreon.entitledCents": {
|
||||
type: Number,
|
||||
decimal: false,
|
||||
optional: true,
|
||||
},
|
||||
"patreon.entitledCentsOverride": {
|
||||
type: Number,
|
||||
decimal: false,
|
||||
optional: true,
|
||||
},
|
||||
"patreon.error": {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.users.attachSchema(Schemas.User);
|
||||
@@ -103,3 +146,11 @@ if (Meteor.isServer) Meteor.methods({
|
||||
Meteor.users.update(this.userId, {$set: {apiKey}});
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.methods({
|
||||
clickPatreonPost(link) {
|
||||
Meteor.users.update(this.userId, {$set: {
|
||||
lastPatreonPostClicked: link
|
||||
}});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,91 +1,268 @@
|
||||
Router.map(function() {
|
||||
this.route("vmixCharacter", {
|
||||
path: "/vmix-character/:_id/",
|
||||
where: "server",
|
||||
action: function() {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "vmixCharacter", () =>
|
||||
this.response.end(vMixCharacter(this.params._id))
|
||||
);
|
||||
},
|
||||
});
|
||||
this.route("vmixParty", {
|
||||
path: "/vmix-party/:_id/",
|
||||
where: "server",
|
||||
action: function() {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "vmixParty", () =>
|
||||
this.response.end(vMixParty(this.params._id))
|
||||
);
|
||||
},
|
||||
});
|
||||
Router.map(function () {
|
||||
this.route("vmixCharacter", {
|
||||
path: "/vmix-character/:_id/",
|
||||
where: "server",
|
||||
action: function () {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "vmixCharacter", () =>
|
||||
this.response.end(vMixCharacter(this.params._id))
|
||||
);
|
||||
},
|
||||
});
|
||||
this.route("vmixParty", {
|
||||
path: "/vmix-party/:_id/",
|
||||
where: "server",
|
||||
action: function () {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "vmixParty", () =>
|
||||
this.response.end(vMixParty(this.params._id))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
this.route("jsonCharacterSheet", {
|
||||
path: "/character/:_id/json",
|
||||
where: "server",
|
||||
action: function() {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "jsonCharacterSheet", () => {
|
||||
if (canViewCharacter(this.params._id, userIdFromKey(key))){
|
||||
this.response.end(JSONExport(this.params._id))
|
||||
} else {
|
||||
this.response.writeHead(403, "You do not have permission to view this character");
|
||||
this.response.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
this.route("jsonCharacterSheet", { // GET /character/:_id/json?key=:key
|
||||
path: "/character/:_id/json",
|
||||
where: "server",
|
||||
action: function () {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "jsonCharacterSheet", () => {
|
||||
if (canViewCharacter(this.params._id, userIdFromKey(key))) {
|
||||
this.response.end(JSONExport(this.params._id))
|
||||
} else {
|
||||
this.response.writeHead(403, "You do not have permission to view this character");
|
||||
this.response.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
this.route("getUserId", { // GET /api/user?username=:un&key=:key
|
||||
path: "/api/user",
|
||||
where: "server",
|
||||
action: function () {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
var username = query && query.username;
|
||||
ifKeyValid(key, this.response, "getUserId", () => {
|
||||
Meteor.call("getUserId", username, (err, result) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
this.response.writeHead(404, "User not found");
|
||||
this.response.end();
|
||||
} else {
|
||||
console.log(result);
|
||||
this.response.end(JSON.stringify({id: result}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.route("addSpellsToCharacter", { // POST /api/character/:_id/spellList/:listId
|
||||
path: "/api/character/:_id/spellList/:listId",
|
||||
where: "server"
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const spells = this.request.body;
|
||||
const charId = this.params._id;
|
||||
const listId = this.params.listId;
|
||||
Meteor.call("insertSpells", key, charId, listId, spells, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("createCharacter", { // POST /api/character
|
||||
path: "/api/character",
|
||||
where: "server"
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const character = this.request.body;
|
||||
Meteor.call("insertCharacter", key, character, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("deleteCharacter", { // DELETE /api/character/:_id
|
||||
path: "/api/character/:_id",
|
||||
where: "server"
|
||||
}).delete(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
Meteor.call("deleteCharacter", key, charId, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("transferCharacterOwnership", { // PUT /api/character/:_id/owner
|
||||
path: "/api/character/:_id/owner",
|
||||
where: "server"
|
||||
}).put(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const ownerId = this.request.body['id'];
|
||||
Meteor.call("transferCharacterOwnership", key, charId, ownerId, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("insertFeatures", { // POST /api/character/:_id/feature
|
||||
path: "/api/character/:_id/feature",
|
||||
where: "server",
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const features = this.request.body;
|
||||
Meteor.call("insertFeatures", key, charId, features, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("insertProfs", { // POST /api/character/:_id/prof
|
||||
path: "/api/character/:_id/prof",
|
||||
where: "server",
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const profs = this.request.body;
|
||||
Meteor.call("insertProfs", key, charId, profs, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("insertEffects", { // POST /api/character/:_id/effect
|
||||
path: "/api/character/:_id/effect",
|
||||
where: "server",
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const effects = this.request.body;
|
||||
Meteor.call("insertEffects", key, charId, effects, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("insertClasses", { // POST /api/character/:_id/class
|
||||
path: "/api/character/:_id/class",
|
||||
where: "server",
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const classes = this.request.body;
|
||||
Meteor.call("insertClasses", key, charId, classes, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var ifKeyValid = function(apiKey, response, method, callback){
|
||||
if (!apiKey){
|
||||
response.writeHead(403, "You must use an api key to access this api");
|
||||
response.end();
|
||||
} else if (!isKeyValid(apiKey)){
|
||||
response.writeHead(403, "API key is invalid");
|
||||
response.end();
|
||||
} else if (isRateLimited(apiKey, method)){
|
||||
response.writeHead(429, "Too many requests");
|
||||
response.end(JSON.stringify({
|
||||
"timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset
|
||||
}));
|
||||
} else {
|
||||
rateLimiter.increment({apiKey: apiKey, method: method})
|
||||
callback();
|
||||
}
|
||||
const startPOSTResponse = function (request) {
|
||||
request.response.setHeader("Content-Type", "application/json");
|
||||
const header = request.request.headers;
|
||||
return header && header['authorization'];
|
||||
};
|
||||
|
||||
var isKeyValid = function(apiKey){
|
||||
var user = Meteor.users.findOne({apiKey});
|
||||
if (!user) return false;
|
||||
var blackListed = Blacklist.findOne({userId: user._id});
|
||||
return !blackListed;
|
||||
var ifKeyValid = function (apiKey, response, method, callback) {
|
||||
if (!apiKey) {
|
||||
response.writeHead(403, "You must use an api key to access this api");
|
||||
response.end();
|
||||
} else if (!isKeyValid(apiKey)) {
|
||||
response.writeHead(403, "API key is invalid");
|
||||
response.end();
|
||||
} else if (isRateLimited(apiKey, method)) {
|
||||
response.writeHead(429, "Too many requests");
|
||||
response.end(JSON.stringify({
|
||||
"timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset
|
||||
}));
|
||||
} else {
|
||||
rateLimiter.increment({apiKey: apiKey, method: method});
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
var userIdFromKey = function(apiKey){
|
||||
var user = Meteor.users.findOne({apiKey}); // we know user exists from isKeyValid
|
||||
return user._id;
|
||||
}
|
||||
isKeyValid = function (apiKey) {
|
||||
var user = Meteor.users.findOne({apiKey});
|
||||
if (!user) return false;
|
||||
var blackListed = Blacklist.findOne({userId: user._id});
|
||||
return !blackListed;
|
||||
};
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.addRule({apiKey: String}, 5, 5000);
|
||||
userIdFromKey = function (apiKey) {
|
||||
var user = Meteor.users.findOne({apiKey}); // we know user exists from isKeyValid
|
||||
return user._id;
|
||||
};
|
||||
|
||||
rateLimiter = new RateLimiter();
|
||||
// global limit
|
||||
rateLimiter.addRule({apiKey: String}, 10, 1000);
|
||||
|
||||
// vmix stuff
|
||||
rateLimiter.addRule({apiKey: String, method: "vmixCharacter"}, 2, 10000);
|
||||
rateLimiter.addRule({apiKey: String, method: "vmixParty"}, 2, 10000);
|
||||
rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000);
|
||||
|
||||
var isRateLimited = function(apiKey, method){
|
||||
const limited = !rateLimiter.check({apiKey: apiKey, method: method}).allowed
|
||||
if (limited) {
|
||||
console.log(`Rate limit hit by API key ${apiKey}`);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
// bot API endpoints
|
||||
rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "getUserId"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "addSpellsToCharacter"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "createCharacter"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "transferCharacterOwnership"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "insertFeatures"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "insertProfs"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "insertEffects"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "insertClasses"}, 5, 5000);
|
||||
|
||||
isRateLimited = function (apiKey, method) {
|
||||
const limited = !rateLimiter.check({apiKey: apiKey, method: method}).allowed;
|
||||
if (limited) {
|
||||
console.log(`Rate limit hit by API key ${apiKey}`);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ Router.plugin("ensureSignedIn", {
|
||||
only: [
|
||||
"profile",
|
||||
"characterList",
|
||||
"library",
|
||||
"libraries",
|
||||
]
|
||||
});
|
||||
|
||||
@@ -118,10 +120,27 @@ Router.map(function() {
|
||||
},
|
||||
});
|
||||
|
||||
this.route("library", {
|
||||
this.route("libraries", {
|
||||
path: "/library",
|
||||
waitOn: function(){
|
||||
return subsManager.subscribe("standardLibraries");
|
||||
return subsManager.subscribe("customLibraries");
|
||||
},
|
||||
onAfterAction: function() {
|
||||
document.title = appName + " - Libraries";
|
||||
},
|
||||
fastRender: true,
|
||||
});
|
||||
|
||||
this.route("library", {
|
||||
path: "/library/:_id",
|
||||
waitOn: function(){
|
||||
return [
|
||||
subsManager.subscribe("libraryItems", this.params._id),
|
||||
subsManager.subscribe("singleLibrary", this.params._id),
|
||||
];
|
||||
},
|
||||
data: function() {
|
||||
return Libraries.findOne(this.params._id);
|
||||
},
|
||||
onAfterAction: function() {
|
||||
document.title = appName + " - Library";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "RPG Docs",
|
||||
"version": "0.0.0",
|
||||
"name": "dicecloud",
|
||||
"version": "1",
|
||||
"homepage": "",
|
||||
"authors": [
|
||||
"Stefan Zermatten"
|
||||
|
||||
7
app/client/globalHelpers/isTier5.js
Normal file
7
app/client/globalHelpers/isTier5.js
Normal file
@@ -0,0 +1,7 @@
|
||||
Template.registerHelper("isTier5", function(){
|
||||
let user = Meteor.user();
|
||||
if (!user) return false;
|
||||
patreon = user.patreon;
|
||||
if (!patreon) return false;
|
||||
return patreon.entitledCents >= 500 || patreon.entitledCentsOverride >= 500;
|
||||
});
|
||||
19
app/client/globalHelpers/patreonLoginUrl.js
Normal file
19
app/client/globalHelpers/patreonLoginUrl.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const CLIENT_ID = Meteor.settings &&
|
||||
Meteor.settings.public.patreon &&
|
||||
Meteor.settings.public.patreon.clientId;
|
||||
|
||||
Template.registerHelper("patreonLoginUrl", function() {
|
||||
if (!CLIENT_ID) return;
|
||||
return formatUrl({
|
||||
protocol: 'https',
|
||||
host: 'patreon.com',
|
||||
pathname: '/oauth2/authorize',
|
||||
query: {
|
||||
response_type: 'code',
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: Meteor.absoluteUrl() + 'patreon-redirect',
|
||||
state: Meteor.userId(),
|
||||
scope: 'identity',
|
||||
},
|
||||
});
|
||||
});
|
||||
5
app/client/lib/fixPasswordButton.js
Normal file
5
app/client/lib/fixPasswordButton.js
Normal file
@@ -0,0 +1,5 @@
|
||||
let pwdFormSubmit = AccountsTemplates.atPwdFormEvents["submit #at-pwd-form"]
|
||||
|
||||
Template.atPwdForm.events({
|
||||
"click .at-btn.submit": pwdFormSubmit,
|
||||
});
|
||||
8
app/client/style/iosButtonFix.css
Normal file
8
app/client/style/iosButtonFix.css
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -157,7 +157,7 @@ Template.inventory.events({
|
||||
}
|
||||
// Make the library item into a regular item
|
||||
let item = _.omit(result, "libraryName", "library", "attacks", "effects");
|
||||
delete item.settings.category;
|
||||
if (item.settings && item.settings.category) delete item.settings.category;
|
||||
// Update the item to match library item
|
||||
Items.update(itemId, {$set: item});
|
||||
// Copy over attacks and effects
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,19 +4,30 @@
|
||||
<p>
|
||||
To get started, add a feature
|
||||
</p>
|
||||
<div class="layout vertical end">
|
||||
<paper-button class="skip-button" style="color: #d13b2e">Skip</paper-button>
|
||||
</div>
|
||||
</paper-step>
|
||||
<paper-step id="step1" label="Add an effect">
|
||||
<p>
|
||||
Add a racial effect to set your speed
|
||||
</p>
|
||||
<div class="layout vertical end">
|
||||
<paper-button class="skip-button" style="color: #d13b2e">Skip</paper-button>
|
||||
</div>
|
||||
</paper-step>
|
||||
<paper-step id="step2" label="See the effect in action">
|
||||
<p>
|
||||
View your speed stat
|
||||
</p>
|
||||
<div class="layout vertical end">
|
||||
<paper-button class="skip-button" style="color: #d13b2e">Skip</paper-button>
|
||||
</div>
|
||||
</paper-step>
|
||||
<paper-step id="step3" label="Finish">
|
||||
Done! If you get stuck, be sure to check out the <a href="/guide">guide</a>, or ask for help using the feedback form
|
||||
<p>
|
||||
Done! If you get stuck, be sure to check out the <a href="/guide">guide</a>, or ask for help using the feedback form
|
||||
</p>
|
||||
<div class="layout vertical end">
|
||||
<paper-button class="done-button" style="color: #d13b2e">Finish</paper-button>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,10 @@ Template.newUserStepper.events({
|
||||
const stepper = instance.find("paper-stepper");
|
||||
stepper.continue();
|
||||
},
|
||||
"click .skip-button": function(event, instance){
|
||||
const stepper = instance.find("paper-stepper");
|
||||
stepper.continue();
|
||||
},
|
||||
});
|
||||
|
||||
Template.stats.events({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -81,4 +81,13 @@ Template.characterList.events({
|
||||
returnElement: instance.find(`.party[data-id='${partyId}']`),
|
||||
});
|
||||
},
|
||||
"click .restoreCharacter": function(event, instance) {
|
||||
pushDialogStack({
|
||||
template: "characterRestoreDialog",
|
||||
element: event.currentTarget,
|
||||
callback(dump){
|
||||
return;
|
||||
},
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<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}}
|
||||
{{#if loading}}
|
||||
<paper-spinner active></paper-spinner>
|
||||
{{/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>
|
||||
@@ -0,0 +1,66 @@
|
||||
Template.characterRestoreDialog.onCreated(function(){
|
||||
this.dump = {};
|
||||
this.valid = new ReactiveVar(false);
|
||||
this.error = new ReactiveVar(null);
|
||||
this.loading = new ReactiveVar(false);
|
||||
});
|
||||
|
||||
Template.characterRestoreDialog.helpers({
|
||||
invalid(){
|
||||
return !Template.instance().valid.get();
|
||||
},
|
||||
error(){
|
||||
return Template.instance().error.get();
|
||||
},
|
||||
loading(){
|
||||
return Template.instance().loading.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){
|
||||
let dump = instance.dump;
|
||||
if (!dump) return;
|
||||
Meteor.call('restoreCharacter', dump, (e, char) => {
|
||||
instance.loading.set(false);
|
||||
if (!char){
|
||||
instance.error.set(e.message)
|
||||
} else {
|
||||
popDialogStack();
|
||||
Router.go("characterSheet", {
|
||||
_id: char._id,
|
||||
urlName: char.urlName || '-',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -21,6 +21,9 @@
|
||||
<a href="/account" style="text-decoration: underline; cursor: pointer; font-size: 16px;">
|
||||
{{profileLink}}
|
||||
</a>
|
||||
<a href="/account" style="text-decoration: underline; cursor: pointer; font-size: 16px; margin-left: 8px;">
|
||||
{{patreonTier}} tier
|
||||
</a>
|
||||
{{else}}
|
||||
<a href="/sign-in" style="text-decoration: underline; cursor: pointer; font-size: 16px;">
|
||||
Sign in
|
||||
@@ -41,6 +44,12 @@
|
||||
Characters
|
||||
</paper-icon-item>
|
||||
</a>
|
||||
<a href="/library" tabindex="-1">
|
||||
<paper-icon-item id="libary">
|
||||
<iron-icon icon="book" item-icon></iron-icon>
|
||||
Libraries (beta)
|
||||
</paper-icon-item>
|
||||
</a>
|
||||
<a href="/guide" tabindex="-1">
|
||||
<paper-icon-item id="guide">
|
||||
<iron-icon icon="social:school" item-icon></iron-icon>
|
||||
@@ -51,9 +60,16 @@
|
||||
<iron-icon icon="bug-report" item-icon></iron-icon>
|
||||
Send Feedback
|
||||
</paper-icon-item>
|
||||
<a class="patreon" href="https://www.patreon.com/dicecloud" target="_blank" tabindex="-1">
|
||||
<a class="patreon" href="{{patreonLink}}" target="_blank" tabindex="-1">
|
||||
<paper-icon-item>
|
||||
<iron-icon icon="dicecloud:patreon" item-icon></iron-icon>
|
||||
<iron-icon id="patreon-link-icon" icon="dicecloud:patreon" item-icon></iron-icon>
|
||||
{{#if showPatreonBadge}}
|
||||
<paper-badge
|
||||
icon="av:new-releases"
|
||||
for="patreon-link-icon"
|
||||
label="New post">
|
||||
</paper-badge>
|
||||
{{/if}}
|
||||
Patreon
|
||||
</paper-icon-item>
|
||||
</a>
|
||||
|
||||
@@ -7,6 +7,30 @@ Template.appDrawer.helpers({
|
||||
var user = Meteor.user();
|
||||
return user.profile && user.profile.username || user.username || "My Account";
|
||||
},
|
||||
showPatreonBadge: function(){
|
||||
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
|
||||
let user = Meteor.user();
|
||||
if (!post || !user) return false;
|
||||
return post.link !== user.lastPatreonPostClicked;
|
||||
},
|
||||
patreonLink: function(){
|
||||
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
|
||||
return (post && post.link) || 'https://www.patreon.com/dicecloud';
|
||||
},
|
||||
patreonTier: function(){
|
||||
let user = Meteor.user();
|
||||
if (!user) return;
|
||||
patreon = user.patreon;
|
||||
if (!patreon) return "free";
|
||||
let entitledCents = patreon.entitledCents || 0;
|
||||
if (patreon.entitledCentsOverride > entitledCents){
|
||||
return "$" + (patreon.entitledCentsOverride / 100).toFixed(0);
|
||||
} else if (!patreon.entitledCents){
|
||||
return "free";
|
||||
} else {
|
||||
return "$" + (patreon.entitledCents / 100).toFixed(0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let drawerLayout;
|
||||
@@ -37,6 +61,9 @@ Template.appDrawer.events({
|
||||
closeDrawer(instance);
|
||||
},
|
||||
"click .patreon": function(event, instance){
|
||||
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
|
||||
let link = (post && post.link) || 'https://www.patreon.com/dicecloud';
|
||||
Meteor.call('clickPatreonPost', link);
|
||||
ga("send", "event", "externalLink", "patreon");
|
||||
},
|
||||
"click .github": function(event, instance){
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<template name="itemLibrary">
|
||||
{{#each items}}
|
||||
{{> libraryItem}}
|
||||
{{/each}}
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
Template.itemLibrary.helpers({
|
||||
items(){
|
||||
return Items.find({charId: {$in: [
|
||||
"SRDLibrary",
|
||||
]}});
|
||||
},
|
||||
});
|
||||
1
app/client/views/library/libraries.css
Normal file
1
app/client/views/library/libraries.css
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
35
app/client/views/library/libraries.html
Normal file
35
app/client/views/library/libraries.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<template name="libraries">
|
||||
<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" style="min-height: 56px;">
|
||||
<paper-icon-button icon="menu" drawer-toggle></paper-icon-button>
|
||||
<div class="flex layout horizontal center" style="height: 40px; margin-left: 8px;">
|
||||
Libraries
|
||||
</div>
|
||||
</div>
|
||||
</app-toolbar>
|
||||
</app-header>
|
||||
{{#if isTier5}}
|
||||
<div class="flex layout vertical center" style="position: relative; padding: 0 16px;">
|
||||
<paper-material class="card" style="padding: 32px; max-width: 800px; width: 100%;">
|
||||
{{#each library in libraries}}
|
||||
<a href="/library/{{library._id}}" tabindex="-1">
|
||||
<paper-item class="library" data-id="{{library._id}}">
|
||||
<paper-item-body>
|
||||
<div>{{library.name}}</div>
|
||||
</paper-item-body>
|
||||
</paper-item>
|
||||
</a>
|
||||
{{/each}}
|
||||
</paper-material>
|
||||
</div>
|
||||
<div class="floatyButton">
|
||||
<paper-fab id="addLibrary" icon="add"></paper-fab>
|
||||
{{#simpleTooltip}}Add Library{{/simpleTooltip}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{> patronsOnly }}
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
38
app/client/views/library/libraries.js
Normal file
38
app/client/views/library/libraries.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const librarySubs = new SubsManager();
|
||||
|
||||
Template.libraries.helpers({
|
||||
libraries(){
|
||||
let userId = Meteor.userId();
|
||||
let subs = Meteor.user() && Meteor.user().profile.librarySubscriptions;
|
||||
return Libraries.find({
|
||||
$or: [
|
||||
{readers: userId},
|
||||
{writers: userId},
|
||||
{owner: userId},
|
||||
{_id: {$in: subs}}
|
||||
],
|
||||
}, {
|
||||
sort: {name: 1},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Template.libraries.events({
|
||||
"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[data-id='${libraryId}']`),
|
||||
callback(data){
|
||||
if (data && data.delete){
|
||||
Libraries.remove(libraryId);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
})
|
||||
@@ -1,26 +1,56 @@
|
||||
<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">
|
||||
<div top-item class="layout horizontal center" style="min-height: 56px;">
|
||||
<paper-icon-button icon="menu" drawer-toggle></paper-icon-button>
|
||||
<div class="flex">
|
||||
Library
|
||||
<a href="/library"><paper-icon-button icon="arrow-back"></paper-icon-button></a>
|
||||
<div class="flex layout horizontal center" style="height: 40px; margin-left: 8px;">
|
||||
{{name}}
|
||||
</div>
|
||||
{{#if isTier5}}{{#if canUserEdit}}
|
||||
<paper-icon-button icon="settings" id="edit"></paper-icon-button>
|
||||
{{/if}}{{/if}}
|
||||
</div>
|
||||
<div bottom-item>
|
||||
<paper-tabs id="characterSheetTabs" selected={{selectedTab}} class="app-grey white-text">
|
||||
<paper-tab name="items">Items</paper-tab>
|
||||
<paper-tab name="spells">Spells</paper-tab>
|
||||
</paper-tabs>
|
||||
</div>
|
||||
{{#if isTier5}}
|
||||
<div bottom-item class="layout horizontal center">
|
||||
<paper-input label="Search" class="search-input">
|
||||
<iron-icon icon="search" prefix></iron-icon>
|
||||
</paper-input>
|
||||
<div class="flex"></div>
|
||||
{{#if canUserSubscribe}}
|
||||
<paper-button style="color: rgba(255,255,255,0.87);" id="subscribe">
|
||||
<iron-icon icon="add-circle"></iron-icon>
|
||||
Subscribe
|
||||
</paper-button>
|
||||
{{else if canUserUnsubscribe}}
|
||||
<paper-button style="color: rgba(255,255,255,0.87);" id="unsubscribe">
|
||||
<iron-icon icon="remove-circle"></iron-icon>
|
||||
Unsubscribe
|
||||
</paper-button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</app-toolbar>
|
||||
</app-header>
|
||||
<div class="flex" style="position: relative;">
|
||||
<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>
|
||||
{{#if isTier5}}
|
||||
<div class="flex layout vertical center" style="position: relative; padding: 0 16px;">
|
||||
<paper-material class="card" style="padding: 32px; max-width: 800px; width: 100%;">
|
||||
{{#each items}}
|
||||
<paper-item data-id={{_id}} class="item">
|
||||
<paper-item-body>
|
||||
<div>{{displayName}}</div>
|
||||
</paper-item-body>
|
||||
</paper-item>
|
||||
{{/each}}
|
||||
</paper-material>
|
||||
</div>
|
||||
<div class="floatyButton">
|
||||
<paper-fab id="addLibraryItem" icon="add"></paper-fab>
|
||||
{{#simpleTooltip}}Add Library Item{{/simpleTooltip}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{> patronsOnly }}
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
120
app/client/views/library/library.js
Normal file
120
app/client/views/library/library.js
Normal file
@@ -0,0 +1,120 @@
|
||||
const librarySubs = new SubsManager();
|
||||
|
||||
Template.library.onCreated(function(){
|
||||
this.searchTerm = new ReactiveVar("");
|
||||
});
|
||||
|
||||
Template.library.helpers({
|
||||
items(){
|
||||
let search = Template.instance().searchTerm.get();
|
||||
if (search){
|
||||
return LibraryItems.find(
|
||||
{
|
||||
library: this._id,
|
||||
$or: [
|
||||
{
|
||||
name: {$regex: new RegExp(".*" + search + ".*", "gi")}
|
||||
},
|
||||
{
|
||||
libraryname: {$regex: new RegExp(".*" + search + ".*", "gi")}
|
||||
},
|
||||
],
|
||||
},
|
||||
{sort: {name: 1}},
|
||||
);
|
||||
} else {
|
||||
return LibraryItems.find(
|
||||
{library: this._id},
|
||||
{sort: {name: 1}},
|
||||
);
|
||||
}
|
||||
},
|
||||
displayName(){
|
||||
return this.libraryName || this.name;
|
||||
},
|
||||
canUserSubscribe(){
|
||||
let user = Meteor.user();
|
||||
let userId = user._id;
|
||||
return !(
|
||||
_.contains(this.readers, userId) ||
|
||||
_.contains(this.writers, userId) ||
|
||||
this.owner === userId ||
|
||||
_.contains(user.profile.librarySubscriptions, this._id)
|
||||
);
|
||||
},
|
||||
canUserUnsubscribe(){
|
||||
let user = Meteor.user();
|
||||
let userId = user._id;
|
||||
return (
|
||||
_.contains(user.profile.librarySubscriptions, this._id) ||
|
||||
_.contains(this.readers, userId)
|
||||
);
|
||||
},
|
||||
canUserEdit(){
|
||||
let userId = Meteor.userId();
|
||||
return (
|
||||
_.contains(this.writers, userId) ||
|
||||
this.owner === userId
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Template.library.events({
|
||||
"input .search-input, change .search-input": function(event, template){
|
||||
const value = event.currentTarget.value;
|
||||
template.searchTerm.set(value);
|
||||
},
|
||||
"click #edit": function(event, instance){
|
||||
event.stopPropagation();
|
||||
var libraryId = this._id;
|
||||
pushDialogStack({
|
||||
template: "libraryDialog",
|
||||
data: {libraryId},
|
||||
element: event.currentTarget.parentElement.parentElement,
|
||||
callback(data){
|
||||
if (data && data.delete){
|
||||
Router.go('/library');
|
||||
Tracker.afterFlush(function(){
|
||||
Libraries.remove(libraryId);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
"click #addLibraryItem": 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[data-id='${itemId}']`),
|
||||
});
|
||||
},
|
||||
"click .item": function(event, instance){
|
||||
event.stopPropagation();
|
||||
var itemId = this._id;
|
||||
pushDialogStack({
|
||||
template: "libraryItemDialog",
|
||||
data: {itemId},
|
||||
element: event.currentTarget,
|
||||
returnElement: () => instance.find(`.item[data-id='${itemId}']`),
|
||||
});
|
||||
},
|
||||
"click #subscribe": function(event, instance){
|
||||
Meteor.users.update(Meteor.userId(), {
|
||||
$addToSet: {"profile.librarySubscriptions": this._id},
|
||||
});
|
||||
},
|
||||
"click #unsubscribe": function(event, instance){
|
||||
let userId = Meteor.userId();
|
||||
Meteor.users.update(userId, {
|
||||
$pull: {"profile.librarySubscriptions": this._id},
|
||||
});
|
||||
Meteor.call("unshareLibraryWithMe", this._id);
|
||||
},
|
||||
});
|
||||
18
app/client/views/library/libraryDeleteConfirmation.html
Normal file
18
app/client/views/library/libraryDeleteConfirmation.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<template name="libraryDeleteConfirmation">
|
||||
<div class="fit layout vertical">
|
||||
<app-header-layout has-scrolling-region class="feedback flex">
|
||||
<app-header fixed effects="waterfall">
|
||||
<app-toolbar>
|
||||
<div main-title>Delete Library</div>
|
||||
</app-toolbar>
|
||||
</app-header>
|
||||
<div class="form flex">
|
||||
Deleting a library cannot be undone<br>
|
||||
<paper-button id="deleteButton" raised>Delete Library and All Contents</paper-button>
|
||||
</div>
|
||||
</app-header-layout>
|
||||
<div class="buttons layout horizontal end-justified">
|
||||
<paper-button class="cancelButton"> Cancel </paper-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
8
app/client/views/library/libraryDeleteConfirmation.js
Normal file
8
app/client/views/library/libraryDeleteConfirmation.js
Normal file
@@ -0,0 +1,8 @@
|
||||
Template.libraryDeleteConfirmation.events({
|
||||
"click #deleteButton": function(event, instance) {
|
||||
popDialogStack(true);
|
||||
},
|
||||
"click .cancelButton": function(event, instance){
|
||||
popDialogStack();
|
||||
},
|
||||
});
|
||||
82
app/client/views/library/libraryDialog/libraryDialog.html
Normal file
82
app/client/views/library/libraryDialog/libraryDialog.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<template name="libraryDialog">
|
||||
<div class="fit base-dialog layout vertical">
|
||||
<app-toolbar>
|
||||
<paper-icon-button id="backButton"
|
||||
icon="arrow-back">
|
||||
</paper-icon-button>
|
||||
<div main-title>{{library.name}}</div>
|
||||
<paper-menu-button class="character-menu" horizontal-align="right">
|
||||
<paper-icon-button icon="delete" class="dropdown-trigger" disabled="{{notOwner}}">
|
||||
</paper-icon-button>
|
||||
<paper-menu class="dropdown-content black87">
|
||||
<paper-button id="deleteButton" disabled="{{notOwner}}" raised>
|
||||
Delete library and all its contents
|
||||
</paper-button>
|
||||
</paper-menu>
|
||||
</paper-menu-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>
|
||||
<hr style="margin: 24px 0; opacity: 0.4;">
|
||||
<paper-dropdown-menu label="Who can view and subscribe to this library">
|
||||
<dicecloud-selector class="visibilityDropdown dropdown-content" selected={{viewPermission}}>
|
||||
<paper-item name="whitelist">Only people I share with</paper-item>
|
||||
<paper-item name="public">Anyone with link</paper-item>
|
||||
</dicecloud-selector>
|
||||
</paper-dropdown-menu>
|
||||
{{#if library.public}}
|
||||
<div style="margin-top: 16px;">
|
||||
Share this link for others to subscribe to this library:
|
||||
</div>
|
||||
<a href="{{pathFor route='library' data=library}}" style="color: #d13b2e; font-size: 18px">
|
||||
{{urlFor route='library' data=library}}
|
||||
</a>
|
||||
{{/if}}
|
||||
<hr style="margin: 24px 0; opacity: 0.4;">
|
||||
<div class="paper-font-title" style="margin-top: 32px;">Share Directly</div>
|
||||
<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>
|
||||
123
app/client/views/library/libraryDialog/libraryDialog.js
Normal file
123
app/client/views/library/libraryDialog/libraryDialog.js
Normal file
@@ -0,0 +1,123 @@
|
||||
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);
|
||||
},
|
||||
viewPermission(){
|
||||
var library = Libraries.findOne(this.libraryId, {fields: {public: 1}});
|
||||
return library && library.public ? "public" : "whitelist";
|
||||
},
|
||||
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";
|
||||
}
|
||||
},
|
||||
notOwner: function(){
|
||||
var library = Libraries.findOne(this.libraryId, {fields: {owner: 1}});
|
||||
if (!library) return;
|
||||
return Meteor.userId() !== library.owner;
|
||||
},
|
||||
});
|
||||
|
||||
Template.libraryDialog.events({
|
||||
"click #backButton": function(){
|
||||
popDialogStack();
|
||||
},
|
||||
"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(){
|
||||
var library = Libraries.findOne(this.libraryId, {fields: {owner: 1}});
|
||||
if (Meteor.userId() === library.owner){
|
||||
popDialogStack({delete: true});
|
||||
}
|
||||
},
|
||||
"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 {
|
||||
instance.userId.set(result);
|
||||
}
|
||||
});
|
||||
},
|
||||
"iron-select .visibilityDropdown": function(event){
|
||||
var detail = event.originalEvent.detail;
|
||||
var value = detail.item.getAttribute("name");
|
||||
let public;
|
||||
if (value === "whitelist"){
|
||||
public = false;
|
||||
} else if (value === "public") {
|
||||
public = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
var library = Libraries.findOne(this.libraryId, {fields: {public: 1}});
|
||||
if (library.public === public) return;
|
||||
Libraries.update(this.libraryId, {$set: {public}});
|
||||
},
|
||||
"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}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
<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"
|
||||
disabled="{{cantEdit}}">
|
||||
</paper-icon-button>
|
||||
</app-toolbar>
|
||||
<div class="form flex scroll-y" style="position: relative;">
|
||||
{{#if ready}}
|
||||
<paper-input id="libraryItemLibraryNameInput" class="fullwidth" label="Library name (optional)" value={{item.libraryName}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-input id="libraryItemNameInput" class="fullwidth" label="Item name" value={{item.name}} disabled="{{cantEdit}}"></paper-input>
|
||||
<div class="layout horizontal center wrap">
|
||||
<paper-input id="libraryItemPluralInput" class="flex" label="Plural name" value={{item.plural}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-input id="libraryItemQuantityInput" class="flex" label="Quantity" type="number" value={{item.quantity}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-checkbox id="incrementCheckbox" class="flex" checked={{item.settings.showIncrement}} disabled="{{cantEdit}}">
|
||||
Show Increment
|
||||
</paper-checkbox>
|
||||
</div>
|
||||
<div class="layout horizontal center wrap">
|
||||
<paper-input id="libraryItemValueInput" class="flex" label="Value" type="number" value={{item.value}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-input id="libraryItemWeightInput" class="flex" label="Weight" type="number" value={{item.weight}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-checkbox id="attunementCheckbox" class="flex" checked={{item.requiresAttunement}} disabled="{{cantEdit}}">
|
||||
Requires Attunement
|
||||
</paper-checkbox>
|
||||
</div>
|
||||
<paper-textarea id="libraryItemDescriptionInput" label="Description" value={{item.description}} disabled="{{cantEdit}}"></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" disabled="{{cantEdit}}">
|
||||
<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}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-input class="LibraryItemEffectValue flex" label="Value" value={{calculationOrValue}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-icon-button icon="delete" class="deleteEffect" disabled="{{cantEdit}}"></paper-icon-button>
|
||||
</div>
|
||||
{{/each}}
|
||||
<paper-button id="addEffect" class="red-button" disabled="{{cantEdit}}">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}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-input class="LibraryItemAttackDamageInput flex" label="Damage" value={{damage}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-input class="LibraryItemAttackDetailsInput flex" label="Details" value={{details}} disabled="{{cantEdit}}"></paper-input>
|
||||
<paper-dropdown-menu label="Damage Type" class="damageTypeMenu" disabled="{{cantEdit}}">
|
||||
<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" disabled="{{cantEdit}}"></paper-icon-button>
|
||||
</div>
|
||||
{{/each}}
|
||||
<paper-button id="addAttack" class="red-button" disabled="{{cantEdit}}">Add Attack</paper-button>
|
||||
</div>
|
||||
{{else}}
|
||||
<paper-spinner active></paper-spinner>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
224
app/client/views/library/libraryItemDialog/libraryItemDialog.js
Normal file
224
app/client/views/library/libraryItemDialog/libraryItemDialog.js
Normal file
@@ -0,0 +1,224 @@
|
||||
Template.libraryItemDialog.onCreated(function(){
|
||||
this.autorun(() => {
|
||||
this.subscribe('libraryItem', Template.currentData().itemId);
|
||||
});
|
||||
});
|
||||
|
||||
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];
|
||||
},
|
||||
ready(){
|
||||
return Template.instance().subscriptionsReady();
|
||||
},
|
||||
cantEdit(){
|
||||
let item = LibraryItems.findOne(this.itemId);
|
||||
if (!item) return;
|
||||
let library = Libraries.findOne(item.library);
|
||||
if (!library) return;
|
||||
let userId = Meteor.userId();
|
||||
return !(
|
||||
library.owner === userId ||
|
||||
_.contains(library.writers, userId)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
27
app/client/views/patreon/patronsOnly.html
Normal file
27
app/client/views/patreon/patronsOnly.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<template name="patronsOnly">
|
||||
<div class="flex layout vertical center" style="position: relative; padding: 0 16px;">
|
||||
<paper-material class="card" style="padding: 32px; max-width: 800px; width: 100%;">
|
||||
<h3>
|
||||
This beta feature is available to Patreon Insiders who pledge $5 or more
|
||||
</h3>
|
||||
<div class="layout vertical center">
|
||||
<a href="https://www.patreon.com/join/dicecloud/checkout?rid=3002853">
|
||||
<paper-button raised> Become a Patron </paper-button>
|
||||
</a>
|
||||
<a href="{{patreonLoginUrl}}">
|
||||
<paper-button class="connectPatreon" style="color: #d13b2e; margin-top: 12px;">
|
||||
Connect Patreon account
|
||||
</paper-button>
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin-top: 32px;">
|
||||
With the Item Libraries beta you can create collections of items to use
|
||||
across your characters, and share them with other players.
|
||||
</p>
|
||||
<p>
|
||||
You can also subscribe to existing community libraries of items, saving
|
||||
time and effort manually entering item details.
|
||||
</p>
|
||||
</paper-material>
|
||||
</div>
|
||||
</template>
|
||||
@@ -62,6 +62,28 @@
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Patreon
|
||||
</td>
|
||||
{{#if patreon.accessToken}}
|
||||
<td>
|
||||
{{tier}} tier
|
||||
</td>
|
||||
<td>
|
||||
<paper-icon-button icon="refresh" class="refreshPatreon">
|
||||
</paper-icon-button>
|
||||
</td>
|
||||
{{else}}
|
||||
<td>
|
||||
<a href="{{patreonLoginUrl}}">
|
||||
<paper-button raised class="connectPatreon">
|
||||
Connect Patreon account
|
||||
</paper-button>
|
||||
</a>
|
||||
</td>
|
||||
{{/if}}
|
||||
</tr>
|
||||
</table>
|
||||
<div style="max-width: 250px">
|
||||
{{> atForm state="signIn"}}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { format as formatUrl } from 'url';
|
||||
|
||||
Template.profile.onCreated(function(){
|
||||
this.showApiKey = new ReactiveVar(false);
|
||||
this.loadingPatreon = new ReactiveVar(false);
|
||||
});
|
||||
|
||||
Template.profile.helpers({
|
||||
@@ -12,6 +15,26 @@ Template.profile.helpers({
|
||||
showApiKey: function(){
|
||||
return Template.instance().showApiKey.get();
|
||||
},
|
||||
patreon: function(){
|
||||
let user = Meteor.user();
|
||||
return user && user.patreon || {};
|
||||
},
|
||||
tier: function(){
|
||||
let user = Meteor.user();
|
||||
if (!user) return;
|
||||
patreon = user.patreon;
|
||||
if (!patreon) return;
|
||||
let entitledCents = patreon.entitledCents || 0;
|
||||
if (Template.instance().loadingPatreon.get()){
|
||||
return "loading..."
|
||||
} else if (patreon.entitledCentsOverride > entitledCents){
|
||||
return `$ ${(patreon.entitledCentsOverride / 100).toFixed(0)} (overridden)`;
|
||||
} else if (patreon.entitledCents === undefined){
|
||||
return "?";
|
||||
} else {
|
||||
return "$" + (patreon.entitledCents / 100).toFixed(0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Template.profile.events({
|
||||
@@ -39,4 +62,10 @@ Template.profile.events({
|
||||
Meteor.call("generateMyApiKey");
|
||||
instance.showApiKey.set(true);
|
||||
},
|
||||
"click .refreshPatreon": function(event, instance){
|
||||
instance.loadingPatreon.set(true);
|
||||
Meteor.call("updateMyPatreonDetails", (error) => {
|
||||
instance.loadingPatreon.set(false);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"polyfill": "/components/webcomponentsjs/webcomponents.min.js",
|
||||
"useShadowDom": true,
|
||||
"imports": [
|
||||
"/components/app-layout/app-layout.html",
|
||||
"/components/app-layout/app-layout.html",
|
||||
"/components/app-layout/app-layout.html",
|
||||
"/components/app-layout/app-layout.html",
|
||||
"/components/app-layout/app-scroll-effects/effects/waterfall.html",
|
||||
"/components/app-layout/app-scroll-effects/effects/parallax-background.html",
|
||||
"/components/app-layout/app-scroll-effects/effects/resize-title.html",
|
||||
|
||||
"/components/iron-collapse/iron-collapse.html",
|
||||
"/components/iron-collapse/iron-collapse.html",
|
||||
"/components/iron-icon/iron-icon.html",
|
||||
"/components/iron-icons/av-icons.html",
|
||||
"/components/iron-icons/editor-icons.html",
|
||||
@@ -21,7 +21,8 @@
|
||||
|
||||
"/components/neon-animation/neon-animation.html",
|
||||
|
||||
"/components/paper-button/paper-button.html",
|
||||
"/components/paper-button/paper-button.html",
|
||||
"/components/paper-badge/paper-badge.html",
|
||||
"/components/paper-swatch-picker/paper-swatch-picker.html",
|
||||
"/components/paper-dialog/paper-dialog.html",
|
||||
"/components/paper-dropdown-menu/paper-dropdown-menu.html",
|
||||
|
||||
471
app/lib/functions/api.js
Normal file
471
app/lib/functions/api.js
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
JSONExport = function (charId) {
|
||||
const character = {
|
||||
"attacks": Attacks.find({charId: charId}).fetch(),
|
||||
"characters": Characters.find({_id: charId}).fetch(),
|
||||
"classes": Classes.find({charId: charId}).fetch(),
|
||||
"containers": Containers.find({charId: charId}).fetch(),
|
||||
"effects": Effects.find({charId: charId}).fetch(),
|
||||
"experience": Experiences.find({charId: charId}).fetch(),
|
||||
"features": Features.find({charId: charId}).fetch(),
|
||||
"items": Items.find({charId: charId}).fetch(),
|
||||
"notes": Notes.find({charId: charId}).fetch(),
|
||||
"proficiencies": Proficiencies.find({charId: charId}).fetch(),
|
||||
"spellLists": SpellLists.find({charId: charId}).fetch(),
|
||||
"spells": Spells.find({charId: charId}).fetch()
|
||||
};
|
||||
return JSON.stringify(character);
|
||||
};
|
||||
|
||||
Meteor.methods({
|
||||
"insertSpells": function (key, charId, listId, spells) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "addSpellsToCharacter");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let spell of spells) {
|
||||
spell.charId = charId;
|
||||
try {
|
||||
Schemas.Spell.clean(spell);
|
||||
} catch (e) {
|
||||
// console.log(e);
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
spell.parent = {id: listId, collection: "SpellLists"};
|
||||
let id = Spells.direct.insert(spell, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
// console.log(id);
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more spells", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
},
|
||||
|
||||
"insertCharacter": function (key, character) {
|
||||
if (Meteor.isClient) return;
|
||||
assertAuthorized(key, "createCharacter");
|
||||
let error, id;
|
||||
|
||||
character.owner = userIdFromKey(key);
|
||||
try {
|
||||
Schemas.Character.clean(character);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
id = Characters.direct.insert(character, (err) => {
|
||||
if (err)
|
||||
error = err.message;
|
||||
});
|
||||
afterCharacterInsert(id);
|
||||
return {id: id};
|
||||
} else {
|
||||
throw new Meteor.Error(400, "Failed to insert character", JSON.stringify({err: error}));
|
||||
}
|
||||
},
|
||||
|
||||
"deleteCharacter": function (key, charId) {
|
||||
if (Meteor.isClient) return;
|
||||
assertAuthorized(key, "deleteCharacter");
|
||||
if (isOwner(charId, userIdFromKey(key))) {
|
||||
let error;
|
||||
|
||||
Characters.direct.remove({_id: charId}, (err) => {
|
||||
if (err)
|
||||
error = err.message;
|
||||
});
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to delete character", JSON.stringify({err: error}));
|
||||
} else {
|
||||
return {success: true};
|
||||
}
|
||||
} else {
|
||||
throw new Meteor.Error(403, "You do not have permission to delete the requested character");
|
||||
}
|
||||
},
|
||||
|
||||
"transferCharacterOwnership": function (key, charId, newOwner) {
|
||||
if (Meteor.isClient) return;
|
||||
assertAuthorized(key, "transferCharacterOwnership");
|
||||
if (isOwner(charId, userIdFromKey(key))) {
|
||||
let error;
|
||||
Characters.direct.update({_id: charId}, {"$set": {owner: newOwner}}, null,
|
||||
(err) => {
|
||||
if (err)
|
||||
error = err.message;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to update character", JSON.stringify({err: error}));
|
||||
} else {
|
||||
return {success: true};
|
||||
}
|
||||
} else {
|
||||
throw new Meteor.Error(403, "You do not have permission to transfer the requested character");
|
||||
}
|
||||
},
|
||||
|
||||
"insertFeatures": function (key, charId, features) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "insertFeatures");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let feature of features) {
|
||||
feature.charId = charId;
|
||||
try {
|
||||
Schemas.Feature.clean(feature);
|
||||
} catch (e) {
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
let id = Features.direct.insert(feature, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more features", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
"insertProfs": function (key, charId, profs) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "insertProfs");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let prof of profs) {
|
||||
prof.charId = charId;
|
||||
try {
|
||||
Schemas.Proficiency.clean(prof, {filter: false});
|
||||
} catch (e) {
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
let id = Proficiencies.direct.insert(prof, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more profs", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
},
|
||||
|
||||
"insertEffects": function (key, charId, effects) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "insertEffects");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let effect of effects) {
|
||||
effect.charId = charId;
|
||||
try {
|
||||
Schemas.Effect.clean(effect, {filter: false});
|
||||
} catch (e) {
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
let id = Effects.direct.insert(effect, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more effects", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
},
|
||||
|
||||
"insertClasses": function (key, charId, klasses) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "insertClasses");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let klass of klasses) {
|
||||
klass.charId = charId;
|
||||
try {
|
||||
Schemas.Class.clean(klass);
|
||||
} catch (e) {
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
let id = Classes.direct.insert(klass, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more classes", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var assertCanEdit = function (key, charId, method) {
|
||||
if (canEditCharacter(charId, userIdFromKey(key))) {
|
||||
assertAuthorized(key, method);
|
||||
} else {
|
||||
throw new Meteor.Error(403, "You do not have permission to edit the requested character");
|
||||
}
|
||||
};
|
||||
|
||||
var assertAuthorized = function (apiKey, method) {
|
||||
if (!apiKey) {
|
||||
throw new Meteor.Error(403, "You must use an api key to access this api");
|
||||
} else if (!isKeyValid(apiKey)) {
|
||||
throw new Meteor.Error(403, "API key is invalid");
|
||||
} else if (isRateLimited(apiKey, method)) {
|
||||
throw new Meteor.Error(429, "Too many requests", JSON.stringify({
|
||||
"timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset
|
||||
}));
|
||||
} else {
|
||||
rateLimiter.increment({apiKey: apiKey, method: method});
|
||||
}
|
||||
};
|
||||
|
||||
var afterCharacterInsert = function (charId) {
|
||||
// Effects
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Constitution modifier for each level",
|
||||
stat: "hitPoints",
|
||||
operation: "add",
|
||||
calculation: "level * constitutionMod",
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Proficiency bonus by level",
|
||||
stat: "proficiencyBonus",
|
||||
operation: "add",
|
||||
calculation: "floor(level / 4 + 1.75)",
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Dexterity Armor Bonus",
|
||||
stat: "armor",
|
||||
operation: "add",
|
||||
calculation: "dexterityArmor",
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Natural Armor",
|
||||
stat: "armor",
|
||||
operation: "base",
|
||||
value: 10,
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Natural Carrying Capacity",
|
||||
stat: "carryMultiplier",
|
||||
operation: "base",
|
||||
value: "1",
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
// Features
|
||||
let featureId = Features.direct.insert({
|
||||
name: "Base Ability Scores",
|
||||
charId: charId,
|
||||
enabled: true,
|
||||
alwaysEnabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "strength",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "dexterity",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "constitution",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "intelligence",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "wisdom",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "charisma",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
// Items
|
||||
let containerId = Containers.direct.insert({
|
||||
name: "Coin Pouch",
|
||||
charId: charId,
|
||||
isCarried: true,
|
||||
description: "A sturdy pouch for coins",
|
||||
color: "d",
|
||||
});
|
||||
Items.direct.insert({
|
||||
name: "Gold piece",
|
||||
plural: "Gold pieces",
|
||||
charId: charId,
|
||||
quantity: 0,
|
||||
weight: 0.02,
|
||||
value: 1,
|
||||
color: "n",
|
||||
parent: {
|
||||
id: containerId,
|
||||
collection: "Containers",
|
||||
},
|
||||
settings: {
|
||||
showIncrement: true,
|
||||
},
|
||||
});
|
||||
Items.direct.insert({
|
||||
name: "Silver piece",
|
||||
plural: "Silver pieces",
|
||||
charId: charId,
|
||||
quantity: 0,
|
||||
weight: 0.02,
|
||||
value: 0.1,
|
||||
color: "q",
|
||||
parent: {
|
||||
id: containerId,
|
||||
collection: "Containers",
|
||||
},
|
||||
settings: {
|
||||
showIncrement: true,
|
||||
},
|
||||
});
|
||||
Items.direct.insert({
|
||||
name: "Copper piece",
|
||||
plural: "Copper pieces",
|
||||
charId: charId,
|
||||
quantity: 0,
|
||||
weight: 0.02,
|
||||
value: 0.01,
|
||||
color: "s",
|
||||
parent: {
|
||||
id: containerId,
|
||||
collection: "Containers",
|
||||
},
|
||||
settings: {
|
||||
showIncrement: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
99
app/lib/functions/backupRestoreCharacter.js
Normal file
99
app/lib/functions/backupRestoreCharacter.js
Normal file
@@ -0,0 +1,99 @@
|
||||
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.direct.insert(characterDump.character);
|
||||
for (collectionName in characterDump.collections){
|
||||
let collection = Meteor.Collection.get(collectionName);
|
||||
for (doc of characterDump.collections[collectionName]){
|
||||
// delete problematic keys that shouldn't ever be available on insert
|
||||
delete doc.restoredAt;
|
||||
delete doc.restoredBy;
|
||||
// Insert the doc with no hooks
|
||||
collection.direct.insert(doc);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Meteor.methods({
|
||||
restoreCharacter(characterDump){
|
||||
characterDump.character.name += " - Restored"
|
||||
characterDump.character.owner = Meteor.userId();
|
||||
characterDump.character.readers = [];
|
||||
characterDump.character.writers = [];
|
||||
giveCharacterDumpNewIds(characterDump);
|
||||
restoreCharacter(characterDump);
|
||||
return characterDump.character
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
JSONExport = function(charId) {
|
||||
var character = {
|
||||
"attacks": Attacks.find({charId: charId}).fetch(),
|
||||
"characters": Characters.find({_id: charId}).fetch(),
|
||||
"classes": Classes.find({charId: charId}).fetch(),
|
||||
"containers": Containers.find({charId: charId}).fetch(),
|
||||
"effects": Effects.find({charId: charId}).fetch(),
|
||||
"experience": Experiences.find({charId: charId}).fetch(),
|
||||
"features": Features.find({charId: charId}).fetch(),
|
||||
"items": Items.find({charId: charId}).fetch(),
|
||||
"notes": Notes.find({charId: charId}).fetch(),
|
||||
"proficiencies": Proficiencies.find({charId: charId}).fetch(),
|
||||
"spellLists": SpellLists.find({charId: charId}).fetch(),
|
||||
"spells": Spells.find({charId: charId}).fetch()
|
||||
};
|
||||
return JSON.stringify(character);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
isOwner = function(charId, userId) {
|
||||
userId = userId || Meteor.userId();
|
||||
var char = Characters.findOne(charId, {fields: {owner: 1}});
|
||||
if (!char) return true;
|
||||
return (userId === char.owner);
|
||||
};
|
||||
|
||||
canEditCharacter = function(charId, userId){
|
||||
userId = userId || Meteor.userId();
|
||||
var char = Characters.findOne(charId, {fields: {owner: 1, writers: 1}});
|
||||
@@ -5,12 +12,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(
|
||||
char,
|
||||
{fields: {owner: 1, writers: 1, readers: 1, "settings.viewPermission": 1}}
|
||||
);
|
||||
}
|
||||
if (!char) return true;
|
||||
return userId === char.owner ||
|
||||
char.settings.viewPermission === "public" ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
26
app/lib/methods/characterCopyPaste.js
Normal file
26
app/lib/methods/characterCopyPaste.js
Normal 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;
|
||||
},
|
||||
});
|
||||
1460
app/package-lock.json
generated
1460
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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,18 @@
|
||||
},
|
||||
"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",
|
||||
"bower": "^1.8.8",
|
||||
"core-js": "^2.5.7",
|
||||
"fibers": "^2.0.2",
|
||||
"file-saver": "^2.0.1",
|
||||
"meteor-node-stubs": "^0.3.3",
|
||||
"qrcode": "^1.2.0",
|
||||
"source-map-support": "^0.5.6",
|
||||
"patreon": "^0.4.1",
|
||||
"qrcode": "^1.3.0",
|
||||
"request": "^2.88.0",
|
||||
"source-map-support": "^0.5.9",
|
||||
"underscore": "^1.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,4 +70,8 @@
|
||||
--paper-diff-slider-knob-color: #00BCD4;
|
||||
--paper-diff-slider-pin-color: #00BCD4;
|
||||
}
|
||||
.white-text paper-input {
|
||||
/* Input foreground color */
|
||||
--paper-input-container-input-color: rgba(255,255,255,0.87);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
262
app/server/patreon/patreon.js
Normal file
262
app/server/patreon/patreon.js
Normal file
@@ -0,0 +1,262 @@
|
||||
import request from 'request';
|
||||
|
||||
if (
|
||||
Meteor.settings &&
|
||||
Meteor.settings.public &&
|
||||
Meteor.settings.public.patreon
|
||||
) {
|
||||
const CLIENT_ID = Meteor.settings.public.patreon.clientId;
|
||||
const CLIENT_SECRET = Meteor.settings.patreon.clientSecret;
|
||||
const CREATOR_ACCESS_TOKEN = Meteor.settings.patreon.creatorAccessToken;
|
||||
const CAMPAIGN_ID = Meteor.settings.public.patreon.campaignId;
|
||||
|
||||
// Handle redirects from patreon
|
||||
Router.map(function () {
|
||||
this.route("patreon-redirect", {
|
||||
path: "/patreon-redirect",
|
||||
where: "server",
|
||||
action: function () {
|
||||
let route = this;
|
||||
let userId = route.params.query.state;
|
||||
let singleUseCode = route.params.query.code;
|
||||
requestToken(singleUseCode, Meteor.bindEnvironment((error, response, body) => {
|
||||
// Should return an access token, valid for 1 month, which needs to be
|
||||
// stored and used to make requests on behalf of the user
|
||||
if (error){
|
||||
writePatreonError(userId, error);
|
||||
return;
|
||||
}
|
||||
let token;
|
||||
try {
|
||||
token = JSON.parse(body);
|
||||
writePatreonToken(userId, token);
|
||||
} catch(error) {
|
||||
writePatreonError(userId, error);
|
||||
return;
|
||||
}
|
||||
updateIdentity(token.access_token, userId);
|
||||
}));
|
||||
route.response.writeHead(302, {
|
||||
'Location': Meteor.absoluteUrl() + "account",
|
||||
});
|
||||
route.response.end();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const requestToken = function(singleUseCode, callback){
|
||||
request({
|
||||
method: "POST",
|
||||
uri: "https://www.patreon.com/api/oauth2/token",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
qs: {
|
||||
code: singleUseCode,
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
redirect_uri: Meteor.absoluteUrl() + 'patreon-redirect',
|
||||
},
|
||||
}, callback);
|
||||
}
|
||||
|
||||
const getIdentity = function(accessToken, callback){
|
||||
request({
|
||||
uri: "https://www.patreon.com/api/oauth2/v2/identity",
|
||||
headers:{
|
||||
Authorization: "Bearer " + accessToken,
|
||||
},
|
||||
qs: {
|
||||
"include": "memberships",
|
||||
"fields[member]": "currently_entitled_amount_cents",
|
||||
}
|
||||
}, callback);
|
||||
};
|
||||
|
||||
// Should return a new access token for the user
|
||||
// callback is called with (error, response, body)
|
||||
const refreshAccessToken = Meteor.wrapAsync(function(refreshToken, userId, callback){
|
||||
request({
|
||||
method: "POST",
|
||||
uri: "https://www.patreon.com/api/oauth2/token",
|
||||
qs: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
}
|
||||
}, Meteor.bindEnvironment((error, response, body) => {
|
||||
// Should return an access token, valid for 1 month, which needs to be
|
||||
// stored and used to make requests on behalf of the user
|
||||
if (error){
|
||||
callback(error)
|
||||
return;
|
||||
}
|
||||
let token;
|
||||
try {
|
||||
token = JSON.parse(body);
|
||||
writePatreonToken(userId, token);
|
||||
callback(undefined, token.access_token);
|
||||
} catch(error) {
|
||||
callback(error);
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
const updateIdentity = Meteor.wrapAsync(function(accessToken, userId, callback){
|
||||
getIdentity(accessToken, Meteor.bindEnvironment((error, response, body) => {
|
||||
if (error){
|
||||
writePatreonError(userId, error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let identity = JSON.parse(body);
|
||||
let membership = identity.included[0];
|
||||
let entitledAmount = membership && membership.attributes
|
||||
.currently_entitled_amount_cents || 0;
|
||||
let patreonUserId = identity.data.id;
|
||||
writeEntitledCentsAndId(userId, entitledAmount, patreonUserId);
|
||||
if (callback) callback();
|
||||
} catch(error) {
|
||||
writePatreonError(userId, error);
|
||||
if(callback) callback(error);
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
Meteor.methods({
|
||||
updateMyPatreonDetails(){
|
||||
const userId = this.userId;
|
||||
if (!userId) throw new Meteor.Error("not-logged-in", "You must be logged in to update Patreon details");
|
||||
const user = Meteor.users.findOne(userId, {fields: {patreon: 1}});
|
||||
Meteor.users.update(userId, {$unset: {"patreon.entitledCents": 1}});
|
||||
if (!user.patreon || !user.patreon.accessToken){
|
||||
throw new Meteor.Error("no-patreon-access", "Patreon access token not found for this user");
|
||||
}
|
||||
let accessToken = user.patreon.accessToken;
|
||||
if (user.patreon.tokenExpiryDate < new Date()){
|
||||
// Token expired, refresh it before continuing
|
||||
accessToken = refreshAccessToken(user.patreon.refreshToken, userId);
|
||||
}
|
||||
updateIdentity(accessToken, userId);
|
||||
},
|
||||
});
|
||||
|
||||
const writePatreonToken = function(userId, {
|
||||
access_token, refresh_token, expires_in
|
||||
}){
|
||||
// The expiry date is now plus `expires_in` seconds
|
||||
let expiryDate = new Date();
|
||||
expiryDate.setSeconds(expiryDate.getSeconds() + expires_in);
|
||||
// Expire a day early so we don't accidentally miss it
|
||||
expiryDate.setDate(expiryDate.getDate() - 1);
|
||||
|
||||
// Write
|
||||
Meteor.users.update(userId, {
|
||||
$set: {
|
||||
"patreon.accessToken": access_token,
|
||||
"patreon.refreshToken": refresh_token,
|
||||
"patreon.tokenExpiryDate": expiryDate,
|
||||
},
|
||||
$unset: {
|
||||
"patreon.error": 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const writeEntitledCentsAndId = function(userId, amount, patreonUserId){
|
||||
Meteor.users.update(userId, {
|
||||
$set: {
|
||||
"patreon.entitledCents": amount,
|
||||
"patreon.userId": patreonUserId,
|
||||
},
|
||||
$unset: {
|
||||
"patreon.error": 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const writePatreonError = function(userId, error){
|
||||
console.error({patreonError: error});
|
||||
Meteor.users.update(userId, {
|
||||
$set: {
|
||||
"patreon.error": error.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const requestMembers = Meteor.wrapAsync(function(cursor, members, callback){
|
||||
request({
|
||||
uri: `https://www.patreon.com/api/oauth2/v2/campaigns/${CAMPAIGN_ID}/members`,
|
||||
headers:{
|
||||
Authorization: "Bearer " + CREATOR_ACCESS_TOKEN,
|
||||
},
|
||||
qs: {
|
||||
"include": "user",
|
||||
"fields[member]": "currently_entitled_amount_cents",
|
||||
"page[cursor]": cursor,
|
||||
}
|
||||
}, (error, reponse, body) => {
|
||||
if (error){
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
let json = JSON.parse(body);
|
||||
if (json.errors) {
|
||||
callback(json.errors);
|
||||
return;
|
||||
}
|
||||
let newMembers = json.data.map(member => ({
|
||||
id: member.relationships.user.data.id,
|
||||
entitledCents: member.attributes.currently_entitled_amount_cents,
|
||||
}));
|
||||
members.push(...newMembers);
|
||||
let next = json.meta.pagination.cursors && json.meta.pagination.cursors.next;
|
||||
if (next){
|
||||
callback(undefined, next);
|
||||
} else {
|
||||
callback(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const updatePatreonMembersEntitledCents = function(){
|
||||
let next = "";
|
||||
let members = [];
|
||||
do {
|
||||
next = requestMembers(next, members);
|
||||
} while (next)
|
||||
members.forEach(({id, entitledCents}) => {
|
||||
Meteor.users.update({
|
||||
"patreon.userId": id
|
||||
}, {$set: {
|
||||
"patreon.entitledCents":entitledCents,
|
||||
}});
|
||||
});
|
||||
return members;
|
||||
}
|
||||
|
||||
// Method to run a manual update
|
||||
Meteor.methods({
|
||||
updatePatreonMembersEntitledCents(){
|
||||
const user = Meteor.users.findOne(this.userId);
|
||||
if (!user || !_.contains(user.roles, "admin")) throw new Meteor.Error(
|
||||
"permission-error", "You need to be logged in as an admin to run this method"
|
||||
);
|
||||
return updatePatreonMembersEntitledCents();
|
||||
},
|
||||
});
|
||||
|
||||
// Cron job to run the update automatically
|
||||
Meteor.startup(() => {
|
||||
SyncedCron.add({
|
||||
name: "updatePatreonMembersEntitledCents",
|
||||
schedule: function(parser) {
|
||||
return parser.text('every 4 hours');
|
||||
},
|
||||
job: updatePatreonMembersEntitledCents,
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -23,3 +23,63 @@ Meteor.publish("standardLibrarySpells", function(level){
|
||||
sort: {name: 1},
|
||||
});
|
||||
});
|
||||
|
||||
Meteor.publish("customLibraries", function(){
|
||||
const userId = this.userId;
|
||||
let user = Meteor.user()
|
||||
let subs = user && user.profile && user.profile.librarySubscriptions;
|
||||
return Libraries.find({
|
||||
$or: [
|
||||
{readers: userId},
|
||||
{writers: userId},
|
||||
{owner: userId},
|
||||
{public: true, _id: {$in: subs || []}},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
Meteor.publish("singleLibrary", function(id){
|
||||
const userId = this.userId;
|
||||
return Libraries.find({
|
||||
_id: id,
|
||||
$or: [
|
||||
{readers: userId},
|
||||
{writers: userId},
|
||||
{owner: userId},
|
||||
{public: true},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
Meteor.publish("libraryItems", function(libraryId){
|
||||
return LibraryItems.find({
|
||||
library: libraryId
|
||||
}, {
|
||||
fields: {
|
||||
name: 1,
|
||||
libraryName: 1,
|
||||
library: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Meteor.publish("libraryItem", function(itemId){
|
||||
let cursor = LibraryItems.find(itemId);
|
||||
let item = cursor.fetch()[0];
|
||||
let userId = Meteor.userId();
|
||||
if (!item) return [];
|
||||
let library = Libraries.findOne(item.library);
|
||||
if (!library) {
|
||||
throw new Meteor.Error("Library item " + item._id + " is an orphan");
|
||||
}
|
||||
if (
|
||||
library.public ||
|
||||
library.owner === userId ||
|
||||
_.contains(library.readers, userId) ||
|
||||
_.contains(library.writers, userId)
|
||||
) {
|
||||
return cursor;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
Meteor.publish("user", function(){
|
||||
return Meteor.users.find(this.userId, {fields: {
|
||||
roles: 1,
|
||||
username: 1,
|
||||
profile: 1,
|
||||
apiKey: 1,
|
||||
}});
|
||||
return [
|
||||
Meteor.users.find(this.userId, {fields: {
|
||||
roles: 1,
|
||||
username: 1,
|
||||
profile: 1,
|
||||
apiKey: 1,
|
||||
librarySubscriptions: 1,
|
||||
lastPatreonPostClicked: 1,
|
||||
patreon: 1,
|
||||
}}),
|
||||
PatreonPosts.find({},{sort: {dateAdded: -1}, limit: 1})
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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 doesn’t 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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// This all gets run in the console by an admin.
|
||||
// Probably a good idea to reset the server after running big updates
|
||||
// Only do if the library doesn't exist yet
|
||||
|
||||
// First Setup
|
||||
// -----------
|
||||
|
||||
// Add the SRD library with the correct static ID:
|
||||
id = Libraries.insert({
|
||||
_id: "SRDLibraryGA3XWsd",
|
||||
owner: Meteor.userId(),
|
||||
@@ -8,19 +11,23 @@ id = Libraries.insert({
|
||||
});
|
||||
|
||||
// First copy-paste the JSON into your console like `items = <pasted JSON>`
|
||||
// First import, don't do this if the library is already populated
|
||||
_.each(items, (item) => {
|
||||
item.settings = {category: }; // "adventuringGear", "armor", "weapons", "tools"
|
||||
// replace "adventuringGear" with appropriate category: "armor", "weapons", "tools"
|
||||
// if needed
|
||||
item.settings = {category: "adventuringGear"};
|
||||
item.library = "SRDLibraryGA3XWsd"
|
||||
LibraryItems.insert(item)
|
||||
});
|
||||
|
||||
// First copy-paste the JSON into your console like `spells = <pasted JSON>`
|
||||
_.each(spells, (spell) => {
|
||||
spell.library = "SRDLibraryGA3XWsd"
|
||||
LibrarySpells.insert(spell)
|
||||
});
|
||||
|
||||
// Update the library using names as keys
|
||||
// Updating the Libary
|
||||
// -------------------
|
||||
|
||||
// Make sure you're subscribed to all item categories
|
||||
handles = _.map(["weapons", "armor", "adventuringGear", "tools"],
|
||||
category => Meteor.subscribe("standardLibraryItems", category)
|
||||
|
||||
Reference in New Issue
Block a user