Compare commits

..

7 Commits

Author SHA1 Message Date
Stefan Zermatten
8d969bd447 Merge branch 'feature-library-ui' 2019-05-06 14:55:15 +02:00
Stefan Zermatten
b3aeaf06ea Fixed an error when deleting categories from library items without any settings 2019-05-06 14:54:30 +02:00
Stefan Zermatten
85e3b0724a Added a skip button to the new user experience 2019-05-06 14:51:58 +02:00
Stefan Zermatten
81a3ede86e Substantially improved item libraries UI, locked behind Patreon tier 5 2019-05-06 14:51:48 +02:00
Stefan Zermatten
d4864dda5f Fixed error on no meteor.settings file, updated meteor 2019-05-03 13:41:49 +02:00
Stefan Zermatten
5ce1b6aff8 closes #210
closes #211
2019-04-03 10:16:24 +02:00
Stefan Zermatten
41731212ef Added application performance monitoring 2019-03-07 14:53:52 +02:00
35 changed files with 844 additions and 606 deletions

View File

@@ -37,7 +37,7 @@ changes on the fly. Let's look at a hypothetical example.
Getting started
---------------
Running DiceCloud locally, either to host it yourself away from an internet
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.
@@ -45,18 +45,29 @@ You'll need to have installed:
- [git](https://www.atlassian.com/git/tutorials/install-git)
- [Meteor](https://www.meteor.com/install)
- [Bower](https://bower.io/)
Then, it's just a matter of cloning this repository into a folder, installing the bower dependencies and running
`meteor` in the app directory.
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`
You should see this:
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.
@@ -69,3 +80,14 @@ You should see this:
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.

View File

@@ -25,32 +25,32 @@ chuangbo:marked
reywood:iron-router-ga
meteor-base@1.4.0
mobile-experience@1.0.5
mongo@1.6.0
mongo@1.6.2
blaze-html-templates
session@1.1.8
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.4.0
shell-server@0.4.0
seba:minifiers-autoprefixer
nikogosovd:multiple-uihooks
templates:array
ecmascript@0.12.0
ecmascript@0.12.4
es5-shim@4.8.0
differential:vulcanize
reactive-dict@1.2.1
reactive-dict@1.3.0
ongoworks:speakingurl
service-configuration@1.0.11
google-config-ui@1.0.1
dynamic-import@0.5.0
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

View File

@@ -1 +1 @@
METEOR@1.8
METEOR@1.8.1

View File

@@ -1,17 +1,17 @@
accounts-base@1.4.3
accounts-base@1.4.4
accounts-google@1.3.2
accounts-oauth@1.1.16
accounts-password@1.5.1
accounts-ui@1.3.1
accounts-ui-unstyled@1.4.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.5.0
babel-compiler@7.2.0
autoupdate@1.6.0
babel-compiler@7.3.4
babel-runtime@1.3.0
base64@1.0.11
binary-heap@1.0.11
@@ -19,7 +19,7 @@ blaze@2.3.3
blaze-html-templates@1.1.2
blaze-tools@1.0.10
boilerplate-generator@1.6.0
caching-compiler@1.2.0
caching-compiler@1.2.1
caching-html-compiler@1.1.3
callback-hook@1.1.0
check@1.3.1
@@ -31,12 +31,12 @@ 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.5.0
ecmascript@0.12.0
dynamic-import@0.5.1
ecmascript@0.12.7
ecmascript-runtime@0.7.0
ecmascript-runtime-client@0.8.0
ecmascript-runtime-server@0.7.1
@@ -44,14 +44,14 @@ ecwyne:mathjs@0.25.0
ejson@1.1.0
email@1.2.3
es5-shim@4.8.0
fetch@0.1.0
fetch@0.1.1
geojson-utils@1.0.10
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
@@ -64,6 +64,7 @@ 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.8.0
littledata:synced-cron@1.5.1
@@ -72,44 +73,45 @@ 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.4.0
minifier-js@2.4.0
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
modern-browsers@0.1.4
modules@0.13.0
modules-runtime@0.10.2
momentjs:moment@2.22.2
mongo@1.6.0
mongo-decimal@0.1.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.1.1
oauth@1.2.3
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
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.1
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.1.0
seba:minifiers-autoprefixer@1.1.1
seba:minifiers-autoprefixer@1.1.2
service-configuration@1.0.11
session@1.1.8
session@1.2.0
sha@1.0.9
shell-server@0.4.0
socket-stream-client@0.2.2
@@ -119,7 +121,6 @@ spacebars-compiler@1.1.3
splendido:accounts-emails-field@1.2.0
splendido:accounts-meld@1.3.1
srp@1.0.12
standard-minifier-js@2.4.0
templates:array@1.0.3
templating@1.3.2
templating-compiler@1.3.3
@@ -132,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.7.0
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

View File

@@ -10,19 +10,21 @@ Schemas.Library = new SimpleSchema({
Libraries.attachSchema(Schemas.Library);
Libraries.after.remove(function(userId, library) {
LibraryItems.remove({library: library._id});
LibrarySpells.remove({library: library._id});
});
if (Meteor.isServer){
Libraries.after.remove(function(userId, library) {
LibraryItems.remove({library: library._id});
LibrarySpells.remove({library: library._id});
});
}
Meteor.methods({
removeLibrary: function(libraryId) {
unshareLibraryWithMe: function(libraryId) {
let library = Libraries.findOne(libraryId);
let userId = Meteor.userId();
let userId = Meteor.userId();
if (!library) return;
if (library.owner === userId){
Libraries.remove(libraryId);
throw new Meteor.error("Can't unshare, you own this")
} else {
if (_.contains(library.readers, userId)){
Libraries.update(libraryId, {$pull: {"readers": userId}});

View File

@@ -8,6 +8,8 @@ Router.plugin("ensureSignedIn", {
only: [
"profile",
"characterList",
"library",
"libraries",
]
});
@@ -118,11 +120,28 @@ Router.map(function() {
},
});
this.route("library", {
this.route("libraries", {
path: "/library",
waitOn: function(){
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";
},

View 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;
});

View 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',
},
});
});

View File

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

View File

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

View File

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

View File

@@ -44,14 +44,12 @@
Characters
</paper-icon-item>
</a>
{{#if isTier5}}
<a href="/library" tabindex="-1">
<paper-icon-item id="libary">
<iron-icon icon="book" item-icon></iron-icon>
Library (beta)
</paper-icon-item>
</a>
{{/if}}
<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>

View File

@@ -17,13 +17,6 @@ Template.appDrawer.helpers({
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
return (post && post.link) || 'https://www.patreon.com/dicecloud';
},
isTier5: function(){
let user = Meteor.user();
if (!user) return false;
patreon = user.patreon;
if (!patreon) return false;
return patreon.entitledCents >= 500 || patreon.entitledCentsOverride >= 500;
},
patreonTier: function(){
let user = Meteor.user();
if (!user) return;

View File

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

View File

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

View File

@@ -0,0 +1 @@

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

View 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);
}
}
});
},
})

View File

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

View File

@@ -2,31 +2,55 @@
<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>
<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;">
Item Library
{{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="libraryTabs" selected={{selectedTab}} class="app-grey white-text">
<paper-tab name="items">Items</paper-tab>
<paper-tab name="spells">Spells</paper-tab>
</paper-tabs>
</div>
-->
{{#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>
<div class="floatyButton">
<paper-fab id="addLibrary" icon="add"></paper-fab>
{{#simpleTooltip}}Add Library{{/simpleTooltip}}
</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>

View File

@@ -1,29 +1,120 @@
const librarySubs = new SubsManager();
Template.library.onCreated(function(){
this.selectedTab = new ReactiveVar("0");
this.searchTerm = new ReactiveVar("");
});
Template.library.helpers({
selectedTab(){
return Template.instance().selectedTab.get();
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({
"iron-select #libraryTabs": function(event, instance){
instance.selectedTab.set(event.target.selected);
"input .search-input, change .search-input": function(event, template){
const value = event.currentTarget.value;
template.searchTerm.set(value);
},
"click #addLibrary": function(event, instance){
var libraryId = Libraries.insert({
name: "New Library",
owner: Meteor.userId(),
});
"click #edit": function(event, instance){
event.stopPropagation();
var libraryId = this._id;
pushDialogStack({
template: "libraryDialog",
data: {libraryId},
element: event.currentTarget,
returnElement: () => instance.find(`.library-header[data-id='${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);
},
});

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

View File

@@ -0,0 +1,8 @@
Template.libraryDeleteConfirmation.events({
"click #deleteButton": function(event, instance) {
popDialogStack(true);
},
"click .cancelButton": function(event, instance){
popDialogStack();
},
});

View File

@@ -1,15 +1,39 @@
<template name="libraryDialog">
<div class="fit base-dialog layout vertical">
<app-toolbar>
<div main-title>{{library.name}}</div>
<paper-icon-button id="deleteButton"
role="button"
tabindex="0"
icon="delete">
<paper-icon-button 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"

View File

@@ -13,6 +13,10 @@ 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;
@@ -33,9 +37,17 @@ Template.libraryDialog.helpers({
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;
@@ -53,8 +65,10 @@ Template.libraryDialog.events({
}
}, 300),
"click #deleteButton": function(){
Meteor.call("removeLibrary", this.libraryId);
popDialogStack();
var library = Libraries.findOne(this.libraryId, {fields: {owner: 1}});
if (Meteor.userId() === library.owner){
popDialogStack({delete: true});
}
},
"input #userNameOrEmailInput":
function(event, instance){
@@ -64,11 +78,25 @@ Template.libraryDialog.events({
if (err){
console.error(err);
} else {
console.log(result);
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;

View File

@@ -8,32 +8,34 @@
<paper-icon-button id="deleteButton"
role="button"
tabindex="0"
icon="delete">
icon="delete"
disabled="{{cantEdit}}">
</paper-icon-button>
</app-toolbar>
<div class="form flex scroll-y" style="position: relative;">
<paper-input id="libraryItemLibraryNameInput" class="fullwidth" label="Library name (optional)" value={{item.libraryName}}></paper-input>
<paper-input id="libraryItemNameInput" class="fullwidth" label="Item name" value={{item.name}}></paper-input>
{{#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}}></paper-input>
<paper-input id="libraryItemQuantityInput" class="flex" label="Quantity" type="number" value={{item.quantity}}></paper-input>
<paper-checkbox id="incrementCheckbox" class="flex" checked={{item.settings.showIncrement}}>
<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}}></paper-input>
<paper-input id="libraryItemWeightInput" class="flex" label="Weight" type="number" value={{item.weight}}></paper-input>
<paper-checkbox id="attunementCheckbox" class="flex" checked={{item.requiresAttunement}}>
<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}}></paper-textarea>
<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">
<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>
@@ -47,21 +49,21 @@
<paper-item label="Conditional" name="conditional"> Conditional </paper-item>
</paper-listbox>
</paper-dropdown-menu>
<paper-input class="LibraryItemEffectStat flex" label="Attribute" value={{stat}}></paper-input>
<paper-input class="LibraryItemEffectValue flex" label="Value" value={{calculationOrValue}}></paper-input>
<paper-icon-button icon="delete" class="deleteEffect"></paper-icon-button>
<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">Add Effect</paper-button>
<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}}></paper-input>
<paper-input class="LibraryItemAttackDamageInput flex" label="Damage" value={{damage}}></paper-input>
<paper-input class="LibraryItemAttackDetailsInput flex" label="Details" value={{details}}></paper-input>
<paper-dropdown-menu label="Damage Type" class="damageTypeMenu">
<paper-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>
@@ -78,11 +80,14 @@
<paper-item label="Thunder" name="thunder"> Thunder </paper-item>
</paper-listbox>
</paper-dropdown-menu>
<paper-icon-button icon="delete" class="deleteAttack"></paper-icon-button>
<paper-icon-button icon="delete" class="deleteAttack" disabled="{{cantEdit}}"></paper-icon-button>
</div>
{{/each}}
<paper-button id="addAttack" class="red-button">Add Attack</paper-button>
<paper-button id="addAttack" class="red-button" disabled="{{cantEdit}}">Add Attack</paper-button>
</div>
{{else}}
<paper-spinner active></paper-spinner>
{{/if}}
</div>
</div>
</template>

View File

@@ -1,3 +1,9 @@
Template.libraryItemDialog.onCreated(function(){
this.autorun(() => {
this.subscribe('libraryItem', Template.currentData().itemId);
});
});
Template.libraryItemDialog.helpers({
item(){
return LibraryItems.findOne(this.itemId);
@@ -55,6 +61,20 @@ Template.libraryItemDialog.helpers({
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)
);
}
});

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

View File

@@ -1,7 +1,5 @@
import { format as formatUrl } from 'url';
const CLIENT_ID = Meteor.settings.public.patreon.clientId;
Template.profile.onCreated(function(){
this.showApiKey = new ReactiveVar(false);
this.loadingPatreon = new ReactiveVar(false);
@@ -17,20 +15,6 @@ Template.profile.helpers({
showApiKey: function(){
return Template.instance().showApiKey.get();
},
patreonLoginUrl: function(){
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',
},
});
},
patreon: function(){
let user = Meteor.user();
return user && user.patreon || {};

68
app/package-lock.json generated
View File

@@ -100,9 +100,9 @@
}
},
"bower": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/bower/-/bower-1.8.4.tgz",
"integrity": "sha1-54dqB23rgTf30GUl3F6MZtuC8oo="
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/bower/-/bower-1.8.8.tgz",
"integrity": "sha512-1SrJnXnkP9soITHptSO+ahx3QKp3cVzn8poI6ujqc5SeOkg5iqM1pK9H+DSc2OQ8SnO0jC/NG4Ur/UIwy7574A=="
},
"buffer-from": {
"version": "1.1.1",
@@ -1126,37 +1126,6 @@
"requires": {
"inherits": "~2.0.1",
"readable-stream": "^2.0.2"
},
"dependencies": {
"readable-stream": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
},
"dependencies": {
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
}
}
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
}
}
}
},
"stream-http": {
@@ -1169,37 +1138,6 @@
"readable-stream": "^2.3.3",
"to-arraybuffer": "^1.0.0",
"xtend": "^4.0.0"
},
"dependencies": {
"readable-stream": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
},
"dependencies": {
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
}
}
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
}
}
}
},
"string_decoder": {

View File

@@ -14,7 +14,7 @@
"@babel/runtime": "^7.1.2",
"@polymer/polymer": "^1.2.5-npm-test.2",
"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",

View File

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

View File

@@ -1,256 +1,262 @@
import request from 'request';
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;
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();
},
// 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 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);
};
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,
// 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);
}
}));
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,
}});
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);
}
}));
});
return members;
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,
});
})
}
// 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,
});
})

View File

@@ -25,16 +25,61 @@ Meteor.publish("standardLibrarySpells", function(level){
});
Meteor.publish("customLibraries", function(){
userId = this.userId;
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});
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 [];
}
});

View File

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