Added basic community library browser

This commit is contained in:
Stefan Zermatten
2023-06-12 22:16:20 +02:00
parent 9ae8d63fc4
commit c314c0ab05
17 changed files with 423 additions and 25 deletions

View File

@@ -29,6 +29,16 @@ let LibrarySchema = new SimpleSchema({
optional: true,
max: STORAGE_LIMITS.summary,
},
showInMarket: {
index: 1,
type: Boolean,
optional: true,
},
subscriberCount: {
index: 1,
type: Number,
optional: true,
},
});
LibrarySchema.extend(SharingSchema);
@@ -104,6 +114,29 @@ const updateLibraryDescription = new ValidatedMethod({
},
});
const updateLibraryShowInMarket = new ValidatedMethod({
name: 'libraries.updateShowInMarket',
validate: new SimpleSchema({
_id: {
type: String,
regEx: SimpleSchema.RegEx.id
},
value: {
type: Boolean,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id, value }) {
let library = Libraries.findOne(_id);
assertEditPermission(library, this.userId);
Libraries.update(_id, { $set: { showInMarket: value } });
},
});
const removeLibrary = new ValidatedMethod({
name: 'libraries.remove',
validate: new SimpleSchema({
@@ -130,4 +163,4 @@ export function removeLibaryWork(libraryId) {
LibraryNodes.remove({ 'ancestors.id': libraryId });
}
export { LibrarySchema, insertLibrary, updateLibraryName, updateLibraryDescription, removeLibrary };
export { LibrarySchema, insertLibrary, updateLibraryName, updateLibraryDescription, updateLibraryShowInMarket, removeLibrary };

View File

@@ -32,6 +32,16 @@ const LibraryCollectionSchema = new SimpleSchema({
type: String,
regEx: SimpleSchema.RegEx.Id,
},
showInMarket: {
index: 1,
type: Boolean,
optional: true,
},
subscriberCount: {
index: 1,
type: Number,
optional: true,
},
});
LibraryCollectionSchema.extend(SharingSchema);
@@ -48,12 +58,12 @@ const insertLibraryCollection = new ValidatedMethod({
run(libraryCollection) {
if (!this.userId) {
throw new Meteor.Error('LibraryCollections.methods.insert.denied',
'You need to be logged in to insert a library');
'You need to be logged in to insert a library');
}
let tier = getUserTier(this.userId);
if (!tier.paidBenefits){
if (!tier.paidBenefits) {
throw new Meteor.Error('LibraryCollections.methods.insert.denied',
`The ${tier.name} tier does not allow you to insert a library collection`);
`The ${tier.name} tier does not allow you to insert a library collection`);
}
libraryCollection.owner = this.userId;
return LibraryCollections.insert(libraryCollection);
@@ -72,7 +82,7 @@ const updateLibraryCollection = new ValidatedMethod({
},
update: {
type: LibraryCollectionSchema
.pick('name', 'description', 'libraries')
.pick('name', 'description', 'libraries', 'showInMarket')
.extend({ //make libraries optional
libraries: {
optional: true,
@@ -85,7 +95,7 @@ const updateLibraryCollection = new ValidatedMethod({
numRequests: 5,
timeInterval: 5000,
},
run({_id, update}){
run({ _id, update }) {
const libraryCollection = LibraryCollections.findOne(_id, {
fields: {
owner: 1,
@@ -93,7 +103,7 @@ const updateLibraryCollection = new ValidatedMethod({
}
});
assertEditPermission(libraryCollection, this.userId);
return LibraryCollections.update(_id, {$set: update});
return LibraryCollections.update(_id, { $set: update });
},
});
@@ -110,7 +120,7 @@ const removeLibraryCollection = new ValidatedMethod({
numRequests: 5,
timeInterval: 5000,
},
run({_id}){
run({ _id }) {
const libraryCollection = LibraryCollections.findOne(_id, {
fields: {
owner: 1,

View File

@@ -1,11 +1,12 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import Libraries from '/imports/api/library/Libraries.js';
import LibraryCollections from '/imports/api/library/LibraryCollections.js';
import '/imports/api/users/methods/deleteMyAccount.js';
import '/imports/api/users/methods/addEmail.js';
import '/imports/api/users/methods/removeEmail.js';
import '/imports/api/users/methods/updateFileStorageUsed.js';
import { some } from 'lodash';
const defaultLibraries = process.env.DEFAULT_LIBRARIES && process.env.DEFAULT_LIBRARIES.split(',') || [];
const defaultLibraryCollections = process.env.DEFAULT_LIBRARY_COLLECTIONS && process.env.DEFAULT_LIBRARY_COLLECTIONS.split(',') || [];
@@ -250,6 +251,29 @@ Meteor.users.setPreference = new ValidatedMethod({
},
});
if (Meteor.isServer) {
Accounts.onCreateUser(() => {
if (defaultLibraries?.length) {
Libraries.update({
_id: { $in: defaultLibraries }
}, {
$inc: { subscriberCount: 1 }
}, {
multi: true,
}, () => {/**/ });
}
if (defaultLibraryCollections?.length) {
LibraryCollections.update({
_id: { $in: defaultLibraryCollections }
}, {
$inc: { subscriberCount: 1 }
}, {
multi: true,
}, () => {/**/ });
}
});
}
Meteor.users.subscribeToLibrary = new ValidatedMethod({
name: 'users.subscribeToLibrary',
validate: new SimpleSchema({
@@ -264,15 +288,17 @@ Meteor.users.subscribeToLibrary = new ValidatedMethod({
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
timeInterval: 2000,
},
run({ libraryId, subscribe }) {
if (!this.userId) throw 'Can only subscribe if logged in';
if (subscribe) {
Libraries.update({ _id: libraryId }, { $inc: { subscriberCount: 1 } }, () => {/**/ });
return Meteor.users.update(this.userId, {
$addToSet: { subscribedLibraries: libraryId },
});
} else {
Libraries.update({ _id: libraryId }, { $inc: { subscriberCount: -1 } }, () => {/**/ });
return Meteor.users.update(this.userId, {
$pullAll: { subscribedLibraries: libraryId },
});
@@ -299,10 +325,12 @@ Meteor.users.subscribeToLibraryCollection = new ValidatedMethod({
run({ libraryCollectionId, subscribe }) {
if (!this.userId) throw 'Can only subscribe if logged in';
if (subscribe) {
LibraryCollections.update({ _id: libraryCollectionId }, { $inc: { subscriberCount: 1 } }, () => {/**/ });
return Meteor.users.update(this.userId, {
$addToSet: { subscribedLibraryCollections: libraryCollectionId },
});
} else {
LibraryCollections.update({ _id: libraryCollectionId }, { $inc: { subscriberCount: -1 } }, () => {/**/ });
return Meteor.users.update(this.userId, {
$pullAll: { subscribedLibraryCollections: libraryCollectionId },
});

View File

@@ -3,7 +3,7 @@
v-bind="$attrs"
:disabled="isDisabled"
:loading="loading"
@click.stop="click"
@click.stop.prevent="click"
>
<slot />
</v-btn>

View File

@@ -147,11 +147,27 @@
</v-btn>
</v-layout>
<v-layout
column
align-center
justify-center
class="ma-4"
class="text-caption text--disabled mt-8 mb-2"
>
Can't find what you're looking for?
</v-layout>
<v-layout
align-center
justify-center
wrap
class="mx-4 mb-4"
>
<v-btn
v-if="!dummySlot"
text
data-id="library-browser-button"
:disabled="!model"
@click="openLibraryBrowser"
>
Browse community libraries
</v-btn>
<v-btn
v-if="!dummySlot"
text
@@ -160,7 +176,7 @@
data-id="custom-button"
@click="insertCustomFiller"
>
Create custom
Create custom filler
</v-btn>
</v-layout>
<template v-if="!showDisabled && disabledNodeCount">
@@ -304,6 +320,12 @@ export default {
},
});
},
openLibraryBrowser() {
this.$store.commit('pushDialogStack', {
component: 'library-browser-dialog',
elementId: 'library-browser-button',
});
},
isDisabled(node) {
return node._disabledBySlotFillerCondition ||
node._disabledByAlreadyAdded ||

View File

@@ -15,6 +15,7 @@ import SelectLibraryNodeDialog from '/imports/client/ui/library/SelectLibraryNod
import SlotFillDialog from '/imports/client/ui/creature/slots/SlotFillDialog.vue';
import TierTooLowDialog from '/imports/client/ui/user/TierTooLowDialog.vue';
import TransferOwnershipDialog from '/imports/client/ui/sharing/TransferOwnershipDialog.vue';
import LibraryBrowserDialog from '/imports/client/ui/library/LibraryBrowserDialog.vue';
// Lazily load less common dialogs
const ArchiveDialog = () => import('/imports/client/ui/creature/archive/ArchiveDialog.vue');
@@ -48,6 +49,7 @@ export default {
HelpDialog,
InviteDialog,
LevelUpDialog,
LibraryBrowserDialog,
LibraryCollectionCreationDialog,
LibraryCollectionEditDialog,
LibraryCreationDialog,

View File

@@ -0,0 +1,35 @@
<template lang="html">
<dialog-base>
<template slot="toolbar">
<v-toolbar-title>
Community Libraries
</v-toolbar-title>
</template>
<library-browser slot="unwrapped-content" />
<template slot="actions">
<v-spacer />
<v-btn
text
@click="$store.dispatch('popDialogStack')"
>
Done
</v-btn>
</template>
</dialog-base>
</template>
<script lang="js">
import DialogBase from '/imports/client/ui/dialogStack/DialogBase.vue';
import LibraryBrowser from '/imports/client/ui/pages/LibraryBrowser.vue';
export default {
components: {
DialogBase,
LibraryBrowser,
},
};
</script>
<style lang="css" scoped>
</style>

View File

@@ -31,6 +31,11 @@
:value="model.description"
@change="(description, ack) => updateLibraryCollection({description}, ack)"
/>
<smart-switch
:value="model.showInMarket"
label="Show in community library browser"
@change="(showInMarket, ack) => updateLibraryCollection({showInMarket}, ack)"
/>
<smart-select
label="Libraries"
:items="libraryOptions"

View File

@@ -10,7 +10,7 @@
<v-app-bar-nav-icon @click="toggleDrawer" />
<v-btn
icon
@click="$router.push('/library')"
@click="back"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
@@ -34,6 +34,14 @@
>
<v-icon>mdi-cog</v-icon>
</v-btn>
<v-spacer slot="extension" />
<div
v-if="libraryCollection && libraryCollection.subscriberCount"
slot="extension"
class="mx-4 text--disabled"
>
{{ formatNumber(libraryCollection.subscriberCount) }} subscribers
</div>
</v-app-bar>
</template>
@@ -41,6 +49,7 @@
import LibraryCollections from '/imports/api/library/LibraryCollections.js';
import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js';
import { mapMutations } from 'vuex';
import formatter from '/imports/client/ui/utility/numberFormatter.js';
export default {
data() {
@@ -87,6 +96,9 @@ export default {
...mapMutations([
'toggleDrawer',
]),
formatNumber(num) {
return formatter.format(num);
},
subscribe(value) {
this.loading = true;
Meteor.users.subscribeToLibraryCollection.call({
@@ -103,6 +115,9 @@ export default {
data: { _id: this.$route.params.id },
});
},
back() {
return window.history.length > 2 ? this.$router.back() : this.$router.push('/library');
},
},
}
</script>

View File

@@ -31,6 +31,11 @@
:value="model.description"
@change="updateDescription"
/>
<smart-switch
:value="model.showInMarket"
label="Show in community library browser"
@change="updateShowInMarket"
/>
</template>
<template v-if="removedDocs.length">
<h3>Recently Deleted Properties</h3>
@@ -76,7 +81,7 @@
<script lang="js">
import DialogBase from '/imports/client/ui/dialogStack/DialogBase.vue';
import Libraries, { updateLibraryName, updateLibraryDescription, removeLibrary } from '/imports/api/library/Libraries.js';
import Libraries, { updateLibraryName, updateLibraryDescription, updateLibraryShowInMarket, removeLibrary } from '/imports/api/library/Libraries.js';
import LibraryNodes, { restoreLibraryNode } from '/imports/api/library/LibraryNodes.js';
import TreeNodeView from '/imports/client/ui/properties/treeNodeViews/TreeNodeView.vue';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue.js';
@@ -100,8 +105,15 @@ export default {
ack(error && error.reason || error);
});
},
updateShowInMarket(value, ack) {
updateLibraryShowInMarket.call({ _id: this._id, value }, (error) => {
ack(error && error.reason || error);
});
},
remove() {
let that = this;
const _id = this._id;
const $router = this.$router;
const $store = this.$store;
this.$store.commit('pushDialogStack', {
component: 'delete-confirmation-dialog',
elementId: 'delete-library-button',
@@ -111,15 +123,15 @@ export default {
},
callback(confirmation) {
if (!confirmation) return;
removeLibrary.call({ _id: that._id }, (error) => {
removeLibrary.call({ _id }, (error) => {
if (error) {
console.error(error);
snackbar({
text: error.reason,
});
} else {
that.$router.push({ name: 'library', replace: true });
that.$store.dispatch('popDialogStack');
$router.push({ name: 'library', replace: true });
$store.dispatch('popDialogStack');
}
});
}

View File

@@ -10,7 +10,7 @@
<v-app-bar-nav-icon @click="toggleDrawer" />
<v-btn
icon
@click="$router.push('/library')"
@click="back"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
@@ -34,11 +34,20 @@
>
<v-icon>mdi-cog</v-icon>
</v-btn>
<v-spacer slot="extension" />
<div
v-if="library && library.subscriberCount"
slot="extension"
class="mx-4 text--disabled"
>
{{ formatNumber(library.subscriberCount) }} subscribers
</div>
</v-app-bar>
</template>
<script lang="js">
import Libraries from '/imports/api/library/Libraries.js';
import formatter from '/imports/client/ui/utility/numberFormatter.js';
import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js';
import { mapMutations } from 'vuex';
@@ -87,6 +96,9 @@ export default {
...mapMutations([
'toggleDrawer',
]),
formatNumber(num) {
return formatter.format(num);
},
subscribe(value) {
this.loading = true;
Meteor.users.subscribeToLibrary.call({
@@ -103,6 +115,9 @@ export default {
data: { _id: this.$route.params.id },
});
},
back() {
return window.history.length > 2 ? this.$router.back() : this.$router.push('/library');
},
},
}
</script>

View File

@@ -29,11 +29,18 @@
<library-list v-else />
</v-fade-transition>
</v-card>
<div class="layout justify-end mt-2">
<div class="layout wrap justify-end mt-2">
<v-btn
text
to="/community-libraries"
>
Browse community libraries
</v-btn>
<v-btn
v-if="paidBenefits"
text
data-id="insert-library-collection-button"
color="accent"
:loading="loadingInsertLibraryCollection"
@click="insertLibraryCollection"
>

View File

@@ -0,0 +1,153 @@
<template>
<div
class="card-background"
style="height: 100%"
>
<v-container>
<v-fade-transition mode="out-in">
<v-row
v-if="$subReady.browseLibraries"
key="loaded-cards"
dense
>
<v-col
v-for="card in libraryCards"
:key="card._id"
cols="12"
sm="6"
md="4"
lg="3"
>
<v-sheet
class="fill-height"
rounded
outlined
:color="card.subscribed ? 'accent': ''"
>
<v-card
class="fill-height d-flex flex-column"
elevation="0"
:to="`/library${card._type === 'libraryCollection' ? '-collection' : ''}/${card._id}`"
>
<v-card-title>
{{ card.name }}
</v-card-title>
<v-card-subtitle v-if="card.subscriberCount">
{{ formatNumber(card.subscriberCount) }} subscribers
</v-card-subtitle>
<v-card-text>
<markdown-text :markdown="card.description" />
</v-card-text>
<v-spacer />
<v-card-actions>
<v-spacer />
<smart-btn
text
single-click
:color="card.subscribed ? '': 'accent'"
@click="ack => changeSubscribe(card, ack)"
>
{{ card.subscribed ? 'Unsubscribe' : 'Subscribe' }}
</smart-btn>
</v-card-actions>
</v-card>
</v-sheet>
</v-col>
</v-row>
<v-row
v-else
key="loading-spinner"
>
<v-col
cols="12"
class="d-flex align-center justify-center"
>
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
</v-col>
</v-row>
</v-fade-transition>
</v-container>
</div>
</template>
<script lang="js">
import { sortBy } from 'lodash';
import LibraryCollections from '/imports/api/library/LibraryCollections.js';
import Libraries from '/imports/api/library/Libraries.js';
import MarkdownText from '/imports/client/ui/components/MarkdownText.vue';
import formatter from '/imports/client/ui/utility/numberFormatter.js';
export default {
components: {
MarkdownText
},
data(){ return{
loadingInsertLibraryCollection: false,
openCollections: [],
}},
meteor: {
$subscribe: {
'browseLibraries': [],
},
collections(){
const user = Meteor.user() || {};
const subCollections = user.subscribedLibraryCollections || [];
return LibraryCollections.find({
showInMarket: true,
public: true,
}, {
sort: { subscriberCount: 1, name: 1 }
}).map(col => {
col.subscribed = subCollections.includes(col._id);
col._type = 'libraryCollection';
return col;
});
},
libraries(){
const user = Meteor.user() || {};
const subLibraries = user.subscribedLibraries || [];
return Libraries.find({
showInMarket: true,
public: true,
}, {
sort: { subscriberCount: 1, name: 1 }
}).map(lib => {
lib.subscribed = subLibraries.includes(lib._id);
lib._type = 'library';
return lib;
});
},
libraryCards() {
return sortBy([...this.libraries, ...this.collections], 'name');
},
},
methods: {
formatNumber(num) {
return formatter.format(num);
},
changeSubscribe(card, ack) {
const id = card._id;
const subscribe = !card.subscribed;
if (card._type === 'library') {
Meteor.users.subscribeToLibrary.call({
libraryId: id,
subscribe,
}, ack);
} else if (card._type === 'libraryCollection') {
Meteor.users.subscribeToLibraryCollection.call({
libraryCollectionId: id,
subscribe,
}, ack);
} else {
console.log('sub fail')
ack('Library or Library Collection not found')
}
},
},
};
</script>

View File

@@ -9,6 +9,7 @@ const CharacterListToolbarItems = () => import('/imports/client/ui/creature/crea
const Library = () => import('/imports/client/ui/pages/Library.vue');
const LibraryCollection = () => import('/imports/client/ui/pages/LibraryCollection.vue');
const LibraryCollectionToolbar = () => import('/imports/client/ui/library/LibraryCollectionToolbar.vue');
const LibraryBrowser = () => import('/imports/client/ui/pages/LibraryBrowser.vue');
const CharacterSheetPage = () => import('/imports/client/ui/pages/CharacterSheetPage.vue');
const CharacterSheetToolbar = () => import('/imports/client/ui/creature/character/CharacterSheetToolbar.vue');
const CharacterSheetRightDrawer = () => import('/imports/client/ui/creature/character/CharacterSheetRightDrawer.vue');
@@ -168,6 +169,15 @@ RouterFactory.configure(router => {
meta: {
title: 'Library Collection',
},
}, {
name: 'libraryBrowser',
path: '/community-libraries',
components: {
default: LibraryBrowser,
},
meta: {
title: 'Community Libraries',
},
}, {
name: 'characterSheet',
path: '/character/:id',

View File

@@ -0,0 +1,3 @@
const formatter = Intl.NumberFormat('en', { notation: 'compact' });
export default formatter;

View File

@@ -1,9 +1,11 @@
import { Migrations } from 'meteor/percolate:migrations';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { union } from 'lodash';
import Libraries from '/imports/api/library/Libraries.js';
import LibraryCollections from '/imports/api/library/LibraryCollections.js';
// Git version 2.0-beta.33
// Database version 1
// Git version 2.0.52
// Database version 2
Migrations.add({
version: 2,
name: 'Separates creature property tags from library tags',
@@ -13,10 +15,11 @@ Migrations.add({
const bulk = LibraryNodes.rawCollection().initializeUnorderedBulkOp();
LibraryNodes.find({}).forEach(prop => migratePropUp(bulk, prop));
bulk.execute();
countSubscribers();
},
down() {
console.log('migrating down library nodes 2 -> 1');
console.log('Migrating down library nodes 2 -> 1');
const bulk = LibraryNodes.rawCollection().initializeUnorderedBulkOp();
LibraryNodes.find({}).forEach(prop => migratePropDown(bulk, prop));
bulk.execute();
@@ -61,3 +64,30 @@ export function migratePropDown(bulk, prop) {
}
bulk.find({ _id: prop._id }).updateOne(update);
}
function countSubscribers() {
console.log('Migrating up libraries and collections to count subscribers');
const bulkLib = Libraries.rawCollection().initializeUnorderedBulkOp();
Libraries.find({}, {
fields: { _id: 1 }
}).forEach(lib => {
bulkLib.find({ _id: lib._id }).updateOne({
$set: {
subscriberCount: Meteor.users.find({ subscribedLibraries: lib._id }).count(),
}
});
});
bulkLib.execute();
const bulkLibCols = Libraries.rawCollection().initializeUnorderedBulkOp();
LibraryCollections.find({}, {
fields: { _id: 1 }
}).forEach(col => {
bulkLibCols.find({ _id: col._id }).updateOne({
$set: {
subscriberCount: Meteor.users.find({ subscribedLibraryCollections: col._id }).count(),
}
});
});
bulkLibCols.execute();
}

View File

@@ -141,6 +141,24 @@ Meteor.publish('libraries', function () {
});
});
Meteor.publish('browseLibraries', function () {
if (!this.userId) return [];
return [
Libraries.find({
showInMarket: true,
public: true,
}, {
sort: { name: 1 }
}),
LibraryCollections.find({
showInMarket: true,
public: true,
}, {
sort: { name: 1 }
}),
];
});
Meteor.publish('library', function (libraryId) {
if (!libraryId) return [];
libraryIdSchema.validate({ libraryId });