Archive UI

This commit is contained in:
Stefan Zermatten
2021-06-22 14:59:18 +02:00
parent 3db589f775
commit 86d9383af0
11 changed files with 547 additions and 187 deletions

View File

@@ -12,9 +12,9 @@ import ArchivedCreatures from '/imports/api/creature/archive/ArchivedCreatures.j
function archiveCreature(creatureId){
// Build the archive document
const creature = Creatures.findOne(creatureId);
const properties = CreatureProperties.find({'ancestors.id': creatureId});
const experiences = Experiences.find({creatureId});
const logs = CreatureLogs.find({creatureId});
const properties = CreatureProperties.find({'ancestors.id': creatureId}).fetch();
const experiences = Experiences.find({creatureId}).fetch();
const logs = CreatureLogs.find({creatureId}).fetch();
let archiveCreature = {
owner: creature.owner,
archiveDate: new Date(),
@@ -51,11 +51,11 @@ const archiveCreatures = new ValidatedMethod({
timeInterval: 5000,
},
run({creatureIds}) {
for (let id in creatureIds){
for (let id of creatureIds){
assertOwnership(id, this.userId)
}
let archivedIds = [];
for (let id in creatureIds){
for (let id of creatureIds){
let archivedId = archiveCreature(id);
archivedIds.push(archivedId);
}

View File

@@ -7,6 +7,7 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur
import CreatureLogs from '/imports/api/creature/log/CreatureLogs.js';
import Experiences from '/imports/api/creature/experience/Experiences.js';
import ArchivedCreatures from '/imports/api/creature/archive/ArchivedCreatures.js';
import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature.js';
function restoreCreature(archiveId){
// Get the archive
@@ -15,15 +16,28 @@ function restoreCreature(archiveId){
// Insert the creature sub documents
// They still have their original _id's
Creatures.insert(archivedCreature.creature);
CreatureProperties.batchInsert(archivedCreature.properties);
Experiences.batchInsert(archivedCreature.experiences);
CreatureLogs.batchInsert(archivedCreature.logs);
try {
// Add all the properties
if (archivedCreature.properties && archivedCreature.properties.length){
CreatureProperties.batchInsert(archivedCreature.properties);
}
if (archivedCreature.experiences && archivedCreature.experiences.length){
Experiences.batchInsert(archivedCreature.experiences);
}
if (archivedCreature.logs && archivedCreature.logs.length){
CreatureLogs.batchInsert(archivedCreature.logs);
}
// Remove the archived creature
ArchivedCreatures.remove(archiveId);
} catch (e) {
// If the above fails, delete the inserted creature
removeCreatureWork(archivedCreature.creature._id);
throw e;
}
// Do not recompute. The creature was in a computed and ordered state when
// we archived it, just restore everything as-is
// Remove the archived creature
ArchivedCreatures.remove(archiveId);
return archivedCreature.creature._id;
}
@@ -45,14 +59,14 @@ const restoreCreatures = new ValidatedMethod({
timeInterval: 5000,
},
run({archiveIds}) {
for (let id in archiveIds){
for (let id of archiveIds){
let archivedCreature = ArchivedCreatures.findOne(id, {
fields: {owner: 1}
});
assertOwnership(archivedCreature, this.userId)
}
let creatureIds = [];
for (let id in archiveIds){
for (let id of archiveIds){
let creatureId = restoreCreature(id);
creatureIds.push(creatureId);
}

View File

@@ -11,6 +11,7 @@ Meteor.publish('archivedCreatures', function(){
}, {
fields: {
creature: 1,
owner: 1,
}
}
);

View File

@@ -1,26 +1,213 @@
<template lang="html">
<dialog-base>
<div>
TODO
</div>
<template #toolbar>
<v-toolbar-title>
{{ mode === 'archive' ? 'Archive' : 'Restore' }}
</v-toolbar-title>
<v-spacer />
<v-btn-toggle
v-model="mode"
mandatory
>
<v-btn value="archive">
<span>Archive</span>
<v-icon right>
mdi-archive-arrow-down
</v-icon>
</v-btn>
<v-btn value="restore">
<span>Restore</span>
<v-icon right>
mdi-archive-arrow-up-outline
</v-icon>
</v-btn>
</v-btn-toggle>
</template>
<creature-folder-list
selection
:creatures="mode === 'archive' ? CreaturesWithNoParty : archiveCreaturesWithNoParty"
:folders="mode === 'archive' ? folders : archivefolders"
:selected-creature="selectedCreature"
@creature-selected="id => selectedCreature = id"
/>
<v-spacer slot="actions" />
<v-btn
slot="actions"
text
:loading="archiveActionLoading"
:disabled="!numSelected"
color="primary"
@click="archiveAction"
>
{{ mode === 'archive' ? 'Archive' : 'Restore' }}
<template v-if="numSelected > 1">
{{ numSelected }} characters
</template>
<template v-else-if="numSelected === 1">
character
</template>
</v-btn>
<v-btn
slot="actions"
text
@click="$store.dispatch('popDialogStack')"
>
Done
Close
</v-btn>
</dialog-base>
</template>
<script lang="js">
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import ArchivedCreatures from '/imports/api/creature/archive/ArchivedCreatures.js';
import CreatureFolders from '/imports/api/creature/creatureFolders/CreatureFolders.js';
import CreatureFolderList from '/imports/ui/creature/creatureList/CreatureFolderList.vue';
import archiveCreatures from '/imports/api/creature/archive/methods/archiveCreatures.js';
import restoreCreatures from '/imports/api/creature/archive/methods/restoreCreatures.js';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
const characterTransform = function(char){
char.url = `/character/${char._id}/${char.urlName || '-'}`;
char.initial = char.name && char.name[0] || '?';
return char;
};
const creatureFields = {
'color': 1,
'avatarPicture': 1,
'name': 1,
'initial': 1,
'alignment': 1,
'gender': 1,
'race': 1,
'readers': 1,
'writers': 1,
'owner': 1,
};
export default {
components: {
DialogBase,
CreatureFolderList,
},
data(){return {
selectedCreature: null,
mode: 'archive',
archiveActionLoading: false,
}},
computed: {
numSelected(){
return this.selectedCreature ? 1 : 0;
},
},
watch: {
mode(){
this.selectedCreature = null;
},
},
methods: {
archiveAction(){
if (!this.selectedCreature) return;
this.archiveActionLoading = true;
if (this.mode === 'archive'){
archiveCreatures.call({
creatureIds: [this.selectedCreature],
}, error => {
this.archiveActionLoading = false;
if (!error) return;
console.error(error);
snackbar({text: error.reason});
});
} else if (this.mode === 'restore'){
let archiveId = ArchivedCreatures.findOne({
'creature._id': this.selectedCreature
})._id;
restoreCreatures.call({
archiveIds: [archiveId],
}, error => {
this.archiveActionLoading = false;
if (!error) return;
console.error(error);
snackbar({text: error.reason});
});
}
this.selectedCreature = null;
}
},
meteor: {
$subscribe: {
'archivedCreatures': [],
},
folders(){
const userId = Meteor.userId();
let folders = CreatureFolders.find(
{owner: userId, archived: {$ne: true}},
{sort: {order: 1}},
).map(folder => {
folder.creatures = Creatures.find(
{
_id: {$in: folder.creatures || []},
owner: userId,
}, {
sort: {name: 1},
fields: creatureFields,
}
).map(characterTransform);
return folder;
});
folders = folders.filter(folder => !!folder.creatures.length);
return folders;
},
CreaturesWithNoParty() {
var userId = Meteor.userId();
var charArrays = CreatureFolders.find({owner: userId}).map(p => p.creatures);
var folderChars = _.uniq(_.flatten(charArrays));
return Creatures.find(
{
_id: {$nin: folderChars},
owner: userId,
}, {
sort: {name: 1},
fields: creatureFields,
}
).map(characterTransform);
},
archivefolders(){
const userId = Meteor.userId();
let folders = CreatureFolders.find(
{owner: userId},
{sort: {order: 1}},
).map(folder => {
folder.creatures = ArchivedCreatures.find(
{
'creature._id': {$in: folder.creatures || []},
owner: userId,
}, {
sort: {'creature.name': 1},
fields: {creature: 1},
}
).map(arc => characterTransform(arc.creature));
return folder;
});
folders = folders.filter(folder => !!folder.creatures.length);
return folders;
},
archiveCreaturesWithNoParty() {
var userId = Meteor.userId();
var charArrays = CreatureFolders.find({owner: userId}).map(p => p.creatures);
var folderChars = _.uniq(_.flatten(charArrays));
return ArchivedCreatures.find(
{
'creature._id': {$nin: folderChars},
owner: userId,
}, {
sort: {'creature.name': 1},
fields: {creature: 1},
}
).map(arc => characterTransform(arc.creature));
},
}
}
</script>

View File

@@ -0,0 +1,38 @@
<template lang="html">
<v-btn
:icon="!text"
:text="text"
:data-id="randomId"
v-bind="$attrs"
@click="openArchive"
>
<template v-if="text">
Archive Characters
</template>
<v-icon :right="text">
mdi-archive
</v-icon>
</v-btn>
</template>
<script lang="js">
export default {
props: {
text: Boolean,
},
data(){return {
randomId: Random.id(),
}},
methods: {
openArchive(){
this.$store.commit('pushDialogStack', {
component: 'archive-dialog',
elementId: this.randomId,
});
}
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -7,23 +7,19 @@
<template v-else>
{{ characterSlots }}
</template>
<v-btn
icon
data-id="open-archive-btn"
@click="openArchive"
>
<v-icon>
mdi-archive
</v-icon>
</v-btn>
<archive-button />
</div>
</template>
<script lang="js">
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import ArchiveButton from '/imports/ui/creature/creatureList/ArchiveButton.vue';
export default {
components: {
ArchiveButton,
},
meteor: {
creatureCount(){
return Creatures.find({owner: Meteor.userId()}).count();
@@ -32,14 +28,6 @@ export default {
return getUserTier(Meteor.userId()).characterSlots;
}
},
methods: {
openArchive(){
this.$store.commit('pushDialogStack', {
component: 'archive-dialog',
elementId: 'open-archive-btn',
});
}
}
}
</script>

View File

@@ -0,0 +1,102 @@
<template lang="html">
<v-list-item style="min-height: 60px;">
<v-list-item-content>
<v-list-item-title>
<template v-if="!renaming">
{{ model.name }}
</template>
<text-field
v-if="renaming"
ref="name-input"
regular
hide-details
dense
:value="model.name"
@change="renameFolder"
@click.native.stop="()=>{}"
/>
</v-list-item-title>
</v-list-item-content>
<v-list-item-action v-if="!selection && (renaming || open)">
<v-btn
icon
style="flex-grow: 0"
@click.stop="renaming = !renaming"
>
<v-icon v-if="renaming">
mdi-check
</v-icon>
<v-icon v-else>
mdi-pencil
</v-icon>
</v-btn>
</v-list-item-action>
<v-list-item-action v-if="!selection && open">
<v-btn
icon
style="flex-grow: 0"
@click.stop="removeFolder"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</template>
<script lang="js">
import Vue from 'vue';
import updateCreatureFolderName from '/imports/api/creature/creatureFolders/methods.js/updateCreatureFolderName.js';
import removeCreatureFolder from '/imports/api/creature/creatureFolders/methods.js/removeCreatureFolder.js';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
export default {
props: {
model: {
type: Object,
required: true,
},
open: Boolean,
selection: Boolean,
},
data(){return {
renaming: false,
}},
watch: {
renaming(value){
if (!value) return;
Vue.nextTick(() => {
this.$refs['name-input'].focus();
});
},
},
methods:{
renameFolder(name, ack){
updateCreatureFolderName.call({
_id: this.model._id,
name
}, error => {
ack(error);
if (!error) return;
console.error(error);
snackbar({
text: error.reason,
});
});
},
removeFolder(){
removeCreatureFolder.call({
_id: this.model._id
}, error => {
if (!error) return;
console.error(error);
snackbar({
text: error.reason,
});
});
},
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,64 @@
<template lang="html">
<v-list expand>
<creature-list
:creatures="creatures"
:selection="selection"
:selected-creature="selectedCreature"
@creature-selected="id => $emit('creature-selected', id)"
/>
<v-list-group
v-for="folder in folders"
:key="folder._id"
v-model="openFolders[folder._id]"
group="folder"
>
<template #activator>
<creature-folder-header
:open="openFolders[folder._id]"
:model="folder"
:selection="selection"
/>
</template>
<creature-list
:creatures="folder.creatures"
:folder-id="folder._id"
:selection="selection"
:selected-creature="selectedCreature"
@creature-selected="id => $emit('creature-selected', id)"
/>
</v-list-group>
</v-list>
</template>
<script lang="js">
import CreatureFolderHeader from '/imports/ui/creature/creatureList/CreatureFolderHeader.vue';
import CreatureList from '/imports/ui/creature/creatureList/CreatureList.vue';
export default {
components: {
CreatureFolderHeader,
CreatureList,
},
props:{
creatures: {
type: Array,
default: () => [],
},
folders: {
type: Array,
default: () => [],
},
selection: Boolean,
selectedCreature: {
type: String,
default: undefined,
},
},
data(){return{
openFolders: {},
}},
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -1,25 +1,26 @@
<template lang="html">
<v-list>
<draggable
v-model="dataCreatures"
style="min-height: 24px;"
:sort="false"
:group="`creature-list`"
ghost-class="ghost"
draggable=".creature"
handle=".handle"
:animation="200"
@change="change"
>
<creature-list-tile
v-for="creature in dataCreatures"
:key="creature._id"
class="creature"
:to="creature.url"
:model="creature"
/>
</draggable>
</v-list>
<draggable
v-model="dataCreatures"
style="min-height: 24px;"
:sort="false"
:group="`creature-list`"
ghost-class="ghost"
draggable=".creature"
handle=".handle"
:animation="200"
@change="draggableChange"
>
<creature-list-tile
v-for="creature in dataCreatures"
:key="creature._id"
class="creature"
:model="creature"
:selection="selection"
:is-selected="selectedCreature === creature._id"
v-bind="selection ? {} : {to: creature.url}"
@click="$emit('creature-selected', creature._id)"
/>
</draggable>
</template>
<script lang="js">
@@ -42,6 +43,11 @@
type: String,
default: null,
},
selection: Boolean,
selectedCreature: {
type: String,
default: undefined,
},
},
data(){return {
dataCreatures: [],
@@ -55,9 +61,10 @@
this.dataCreatures = this.creatures;
},
methods: {
change({added, moved}){
draggableChange({added, moved}){
let event = added || moved;
if (event){
/*
// If this item is now adjacent to another, set the order accordingly
let order;
let before = this.dataCreatures[event.newIndex - 1];
@@ -69,6 +76,7 @@
} else {
order = -0.5;
}
*/
let doc = event.element;
moveCreatureToFolder.call({
creatureId: doc._id,
@@ -82,6 +90,9 @@
});
}
},
selectionChange(index){
this.$emit('creatureSelected', this.dataCreatures[index]._id)
},
}
}
</script>

View File

@@ -2,16 +2,31 @@
lang="html"
functional
>
<v-list-item v-bind="$attrs">
<v-list-item-avatar :color="model.color || 'grey'">
<img
v-if="model.avatarPicture"
:src="model.avatarPicture"
:alt="model.name"
>
<template v-else>
{{ model.initial }}
</template>
<v-list-item
v-bind="$attrs"
:class="isSelected && 'primary--text v-list-item--active'"
v-on="selection ? { click() {$emit('click')} } : {}"
>
<v-list-item-avatar
:color="isSelected ? 'red darken-1' : model.color || 'grey'"
class="white--text"
style="transition: background 0.3s;"
>
<v-fade-transition leave-absolute>
<v-icon v-if="isSelected">
mdi-check
</v-icon>
<img
v-else-if="model.avatarPicture"
:src="model.avatarPicture"
:alt="model.name"
>
<template v-else>
<span>
{{ model.initial }}
</span>
</template>
</v-fade-transition>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
@@ -25,12 +40,8 @@
<shared-icon :model="model" />
</v-list-item-action>
<v-list-item-action>
<v-checkbox
v-if="selection"
:input-value="selected && selected.has(model._id)"
@change="$emit('select')"
/>
<v-icon
v-if="!selection"
style="height: 100%; width: 40px; cursor: move;"
class="handle"
>
@@ -53,10 +64,7 @@ export default {
required: true,
},
selection: Boolean,
selected: {
type: Set,
default: () => new Set(),
},
isSelected: Boolean,
}
}
</script>

View File

@@ -1,107 +1,74 @@
<template>
<div
class="card-background pa-4"
class="card-background"
style="height: 100%"
>
<v-alert
v-if="exceededCharacterSpace"
type="error"
>
You have exceeded your maximum number of character slots, archive or delete
some characters.
</v-alert>
<v-card :class="{'mb-4': folders && folders.length}">
<creature-list :creatures="CreaturesWithNoParty" />
</v-card>
<v-expansion-panels
v-if="folders && folders.length"
multiple
>
<v-expansion-panel
v-for="folder in folders"
:key="folder._id"
>
<v-expansion-panel-header>
<template #default="{ open }">
<div v-if="renamingFolder !== folder._id">
{{ folder.name }}
</div>
<text-field
v-else
:ref="`name-input-${folder._id}`"
regular
hide-details
dense
:value="folder.name"
@change="(value, ack) => renameFolder(folder._id, value, ack)"
@click.native.stop="()=>{}"
<v-container>
<v-row justify="center">
<v-col
cols="12"
xl="8"
>
<v-alert
v-if="characterSpaceLeft < 0"
type="error"
>
You have exceeded your maximum number of character slots, archive or delete
some characters.
</v-alert>
<v-alert
v-else-if="characterSpaceLeft === 0"
type="info"
>
You have hit your maximum number of characters.
<archive-button
small
text
class="mx-2"
/>
</v-alert>
<v-card :class="{'mb-4': folders && folders.length}">
<creature-folder-list
:creatures="CreaturesWithNoParty"
:folders="folders"
/>
</v-card>
<div class="layout justify-end mt-2">
<v-btn
v-if="renamingFolder === folder._id || open"
icon
style="flex-grow: 0"
@click.stop="renamingFolder !== folder._id ? renamingFolder = folder._id : renamingFolder = undefined"
text
:loading="loadingInsertFolder"
@click="insertFolder"
>
<v-icon v-if="renamingFolder !== folder._id">
mdi-pencil
</v-icon>
<v-icon v-else>
mdi-check
</v-icon>
add folder
</v-btn>
<v-btn
v-if="open"
icon
style="flex-grow: 0"
@click.stop="removeFolder(folder._id)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
</v-expansion-panel-header>
<v-expansion-panel-content>
<creature-list
:creatures="folder.creatures"
:folder-id="folder._id"
/>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<div class="layout justify-end mt-2">
<v-btn
text
:loading="loadingInsertFolder"
@click="insertFolder"
>
add folder
</v-btn>
</div>
<v-btn
color="accent"
fab
fixed
bottom
right
data-id="new-character-button"
:disabled="!hasCharacterSpace"
@click="insertCharacter"
>
<v-icon>mdi-plus</v-icon>
</v-btn>
</div>
<v-btn
color="accent"
fab
fixed
bottom
right
data-id="new-character-button"
:disabled="characterSpaceLeft <= 0"
@click="insertCharacter"
>
<v-icon>mdi-plus</v-icon>
</v-btn>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script lang="js">
import Vue from 'vue';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import insertCreature from '/imports/api/creature/creatures/methods/insertCreature.js';
import CreatureFolders from '/imports/api/creature/creatureFolders/CreatureFolders.js';
import CreatureList from '/imports/ui/creature/creatureList/CreatureList.vue';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import insertCreatureFolder from '/imports/api/creature/creatureFolders/methods.js/insertCreatureFolder.js';
import updateCreatureFolderName from '/imports/api/creature/creatureFolders/methods.js/updateCreatureFolderName.js';
import removeCreatureFolder from '/imports/api/creature/creatureFolders/methods.js/removeCreatureFolder.js';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
import CreatureFolderList from '/imports/ui/creature/creatureList/CreatureFolderList.vue';
import ArchiveButton from '/imports/ui/creature/creatureList/ArchiveButton.vue';
const characterTransform = function(char){
char.url = `/character/${char._id}/${char.urlName || '-'}`;
@@ -110,7 +77,8 @@
};
export default {
components: {
CreatureList,
CreatureFolderList,
ArchiveButton,
},
data(){ return{
fab: false,
@@ -163,26 +131,17 @@
let userId = Meteor.userId();
return getUserTier(userId);
},
hasCharacterSpace(){
characterSpaceLeft(){
let tier = this.tier;
let currentCharacterCount = this.creatureCount;
return tier.characterSlots === -1 || currentCharacterCount < tier.characterSlots
if (tier.characterSlots === -1) return Number.POSITIVE_INFINITY;
return tier.characterSlots - currentCharacterCount
},
exceededCharacterSpace(){
let tier = this.tier;
let currentCharacterCount = this.creatureCount;
return tier.characterSlots !== -1 && currentCharacterCount > tier.characterSlots
}
},
watch:{
renamingFolder(newId){
if(newId){
Vue.nextTick(() => {
let input = this.$refs[`name-input-${newId}`];
input[0].focus();
});
}
}
},
},
methods: {
insertCharacter(){
@@ -213,18 +172,6 @@
});
});
},
renameFolder(_id, name, ack){
updateCreatureFolderName.call({_id, name}, ack);
},
removeFolder(_id){
removeCreatureFolder.call({_id}, error => {
if (!error) return;
console.error(error);
snackbar({
text: error.reason,
});
});
},
},
};
</script>