Overhauled how documentation works
This commit is contained in:
@@ -1,3 +1,278 @@
|
||||
if (Meteor.isServer) throw 'Client side only collection, don\'t import on server';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ValidatedMethod } from 'meteor/mdg:validated-method';
|
||||
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
|
||||
import SimpleSchema from 'simpl-schema';
|
||||
import { softRemove } from '/imports/api/parenting/softRemove.js';
|
||||
import SoftRemovableSchema from '/imports/api/parenting/SoftRemovableSchema.js';
|
||||
import { storedIconsSchema } from '/imports/api/icons/Icons.js';
|
||||
import '/imports/api/library/methods/index.js';
|
||||
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
|
||||
import { restore } from '/imports/api/parenting/softRemove.js';
|
||||
import { reorderDocs } from '/imports/api/parenting/order.js';
|
||||
import { getAncestry } from '/imports/api/parenting/parenting.js';
|
||||
|
||||
const Docs = new Mongo.Collection('docs');
|
||||
|
||||
const RefSchema = new SimpleSchema({
|
||||
id: {
|
||||
type: String,
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
index: 1
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
max: STORAGE_LIMITS.collectionName,
|
||||
},
|
||||
urlName: {
|
||||
type: String,
|
||||
regEx: /[a-z]+(?:[a-z]|-)+/,
|
||||
min: 2,
|
||||
max: STORAGE_LIMITS.variableName,
|
||||
optional: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
max: STORAGE_LIMITS.description,
|
||||
optional: true,
|
||||
},
|
||||
});
|
||||
|
||||
let ChildSchema = new SimpleSchema({
|
||||
order: {
|
||||
type: Number,
|
||||
},
|
||||
parent: {
|
||||
type: RefSchema,
|
||||
optional: true,
|
||||
},
|
||||
ancestors: {
|
||||
type: Array,
|
||||
defaultValue: [],
|
||||
maxCount: STORAGE_LIMITS.ancestorCount,
|
||||
},
|
||||
'ancestors.$': {
|
||||
type: RefSchema,
|
||||
},
|
||||
});
|
||||
|
||||
let DocSchema = new SimpleSchema({
|
||||
_id: {
|
||||
type: String,
|
||||
regEx: SimpleSchema.RegEx.Id,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
max: STORAGE_LIMITS.description,
|
||||
},
|
||||
urlName: {
|
||||
type: String,
|
||||
regEx: /[a-z]+(?:[a-z]|-)+/,
|
||||
min: 2,
|
||||
max: STORAGE_LIMITS.variableName,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
published: {
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
},
|
||||
icon: {
|
||||
type: storedIconsSchema,
|
||||
optional: true,
|
||||
max: STORAGE_LIMITS.icon,
|
||||
},
|
||||
});
|
||||
|
||||
let schema = new SimpleSchema({});
|
||||
schema.extend(DocSchema);
|
||||
schema.extend(ChildSchema);
|
||||
schema.extend(SoftRemovableSchema);
|
||||
Docs.attachSchema(schema);
|
||||
|
||||
function assertDocsEditPermission(userId) {
|
||||
if (!userId || typeof userId !== 'string') throw new Meteor.Error('No user id provided');
|
||||
const user = Meteor.users.findOne(userId);
|
||||
if (!user) throw new Meteor.Error('User does not exist');
|
||||
if (!user?.roles?.includes?.('docsWriter')) throw ('Permission denied')
|
||||
}
|
||||
|
||||
function getDocLink(doc, urlName) {
|
||||
if (!urlName) urlName = doc.urlName;
|
||||
const address = ['/docs'];
|
||||
doc.ancestors?.forEach(a => {
|
||||
address.push(a.urlName);
|
||||
});
|
||||
address.push(urlName);
|
||||
return address.join('/');
|
||||
}
|
||||
|
||||
const insertDoc = new ValidatedMethod({
|
||||
name: 'docs.insert',
|
||||
validate: null,
|
||||
mixins: [RateLimiterMixin],
|
||||
rateLimit: {
|
||||
numRequests: 5,
|
||||
timeInterval: 5000,
|
||||
},
|
||||
run({ doc, parentRef }) {
|
||||
delete doc._id;
|
||||
assertDocsEditPermission(this.userId);
|
||||
// get the new ancestry for the properties
|
||||
if (parentRef) {
|
||||
var { ancestors } = getAncestry({
|
||||
parentRef,
|
||||
inheritedFields: { name: 1, urlName: 1 },
|
||||
});
|
||||
}
|
||||
doc.parent = parentRef;
|
||||
doc.ancestors = ancestors;
|
||||
|
||||
const lastOrder = Docs.find({}, { sort: { order: -1 } }).fetch()[0]?.order || 0;
|
||||
doc.order = lastOrder + 1;
|
||||
doc.urlName = 'new-doc-' + (lastOrder + 1);
|
||||
|
||||
doc.href = getDocLink(doc);
|
||||
if (Docs.findOne({ href: doc.href })) {
|
||||
throw new Meteor.Error('Link collision', 'A document with the same URL already exists');
|
||||
}
|
||||
|
||||
const docId = Docs.insert(doc);
|
||||
reorderDocs({
|
||||
collection: Docs,
|
||||
ancestorId: 'root',
|
||||
});
|
||||
return docId;
|
||||
},
|
||||
});
|
||||
|
||||
const updateDoc = new ValidatedMethod({
|
||||
name: 'docs.update',
|
||||
validate({ _id, path }) {
|
||||
if (!_id) return false;
|
||||
// We cannot change these fields with a simple update
|
||||
switch (path[0]) {
|
||||
case '_is':
|
||||
return false;
|
||||
}
|
||||
},
|
||||
mixins: [RateLimiterMixin],
|
||||
rateLimit: {
|
||||
numRequests: 5,
|
||||
timeInterval: 5000,
|
||||
},
|
||||
run({ _id, path, value }) {
|
||||
assertDocsEditPermission(this.userId);
|
||||
let pathString = path.join('.');
|
||||
let modifier;
|
||||
// unset empty values
|
||||
if (value === null || value === undefined) {
|
||||
modifier = { $unset: { [pathString]: 1 } };
|
||||
} else {
|
||||
modifier = { $set: { [pathString]: value } };
|
||||
}
|
||||
if (pathString === 'urlName') {
|
||||
const doc = Docs.findOne(_id);
|
||||
const newLink = getDocLink(doc, value);
|
||||
if (Docs.findOne({ href: newLink })) {
|
||||
throw new Meteor.Error('Link collision', 'A document with the same URL already exists');
|
||||
}
|
||||
modifier.$set = modifier.$set || {};
|
||||
modifier.$set.href = newLink;
|
||||
}
|
||||
reorderDocs({
|
||||
collection: Docs,
|
||||
ancestorId: 'root',
|
||||
});
|
||||
return Docs.update(_id, modifier);
|
||||
},
|
||||
});
|
||||
|
||||
const pushToDoc = new ValidatedMethod({
|
||||
name: 'docs.push',
|
||||
validate: null,
|
||||
mixins: [RateLimiterMixin],
|
||||
rateLimit: {
|
||||
numRequests: 5,
|
||||
timeInterval: 5000,
|
||||
},
|
||||
run({ _id, path, value }) {
|
||||
assertDocsEditPermission(this.userId);
|
||||
return Docs.update(_id, {
|
||||
$push: { [path.join('.')]: value },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const pullFromDoc = new ValidatedMethod({
|
||||
name: 'docs.pull',
|
||||
validate: null,
|
||||
mixins: [RateLimiterMixin],
|
||||
rateLimit: {
|
||||
numRequests: 5,
|
||||
timeInterval: 5000,
|
||||
},
|
||||
run({ _id, path, itemId }) {
|
||||
assertDocsEditPermission(this.userId);
|
||||
return Docs.update(_id, {
|
||||
$pull: { [path.join('.')]: { _id: itemId } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const softRemoveDoc = new ValidatedMethod({
|
||||
name: 'docs.softRemove',
|
||||
validate: new SimpleSchema({
|
||||
_id: SimpleSchema.RegEx.Id
|
||||
}).validator(),
|
||||
mixins: [RateLimiterMixin],
|
||||
rateLimit: {
|
||||
numRequests: 5,
|
||||
timeInterval: 5000,
|
||||
},
|
||||
run({ _id }) {
|
||||
assertDocsEditPermission(this.userId);
|
||||
softRemove({ _id, collection: Docs });
|
||||
reorderDocs({
|
||||
collection: Docs,
|
||||
ancestorId: 'root',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const restoreDoc = new ValidatedMethod({
|
||||
name: 'docs.restore',
|
||||
validate: new SimpleSchema({
|
||||
_id: SimpleSchema.RegEx.Id
|
||||
}).validator(),
|
||||
mixins: [RateLimiterMixin],
|
||||
rateLimit: {
|
||||
numRequests: 5,
|
||||
timeInterval: 5000,
|
||||
},
|
||||
run({ _id }) {
|
||||
assertDocsEditPermission(this.userId);
|
||||
restore({ _id, collection: Docs });
|
||||
reorderDocs({
|
||||
collection: Docs,
|
||||
ancestorId: 'root',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export {
|
||||
DocSchema,
|
||||
insertDoc,
|
||||
updateDoc,
|
||||
pushToDoc,
|
||||
pullFromDoc,
|
||||
softRemoveDoc,
|
||||
restoreDoc,
|
||||
};
|
||||
|
||||
export default Docs;
|
||||
|
||||
@@ -23,6 +23,7 @@ export default {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
singleClick: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -52,18 +53,23 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
click() {
|
||||
this.timesClicked += 1;
|
||||
this.debounceClicks();
|
||||
if (this.singleClick) {
|
||||
this.loading = true;
|
||||
} else {
|
||||
this.timesClicked += 1;
|
||||
this.debounceClicks();
|
||||
}
|
||||
this.$emit('click', this.acknowledgeChange);
|
||||
},
|
||||
clicks() {
|
||||
this.$emit('clicks', this.timesClicked, this.acknowledgeChange);
|
||||
this.loading = true;
|
||||
this.$emit('clicks', this.timesClicked, this.acknowledgeChange);
|
||||
this.timesClicked = 0;
|
||||
},
|
||||
acknowledgeChange(error){
|
||||
this.loading = false;
|
||||
if (error) {
|
||||
console.error(error)
|
||||
snackbar({ text: error.reason || error.message || error.toString() });
|
||||
}
|
||||
},
|
||||
|
||||
44
app/imports/client/ui/docs/DocBreadcrumbs.vue
Normal file
44
app/imports/client/ui/docs/DocBreadcrumbs.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<v-breadcrumbs
|
||||
:items="items"
|
||||
divider=">"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
|
||||
export default {
|
||||
props: {
|
||||
doc: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
const items = [{
|
||||
text: 'Home',
|
||||
to: '/docs',
|
||||
exact: true,
|
||||
}];
|
||||
if (!this.doc?.ancestors) return items;
|
||||
const address = ['/docs']
|
||||
this.doc.ancestors?.forEach(a => {
|
||||
address.push(a.urlName);
|
||||
items.push({
|
||||
text: a.name,
|
||||
to: address.join('/'),
|
||||
exact: true,
|
||||
});
|
||||
});
|
||||
address.push(this.doc.urlName);
|
||||
items.push({
|
||||
text: this.doc.name,
|
||||
to: address.join('/'),
|
||||
exact: true,
|
||||
});
|
||||
return items;
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
57
app/imports/client/ui/docs/DocCard.vue
Normal file
57
app/imports/client/ui/docs/DocCard.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<v-card
|
||||
:to="doc.href"
|
||||
class="d-flex flex-column"
|
||||
@mouseover="hovering = true"
|
||||
@mouseleave="hovering = false"
|
||||
>
|
||||
<v-card-title>
|
||||
<svg-icon
|
||||
v-if="doc && doc.icon"
|
||||
:shape="doc.icon.shape"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ doc.name }}
|
||||
</v-card-title>
|
||||
<v-card-text v-if="doc.description">
|
||||
<markdown-text
|
||||
v-if="doc"
|
||||
:markdown="doc.description"
|
||||
/>
|
||||
</v-card-text>
|
||||
<card-highlight :active="hovering" />
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import MarkdownText from '/imports/client/ui/components/MarkdownText.vue';
|
||||
import CardHighlight from '/imports/client/ui/components/CardHighlight.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MarkdownText,
|
||||
CardHighlight,
|
||||
},
|
||||
props: {
|
||||
doc: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {return {
|
||||
hovering: false,
|
||||
}},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-card {
|
||||
height: 240px;
|
||||
}
|
||||
.v-card__text{
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 75%, transparent 95%);
|
||||
mask-image: linear-gradient(to bottom, black 75%, transparent 95%);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
213
app/imports/client/ui/docs/DocEditForm.vue
Normal file
213
app/imports/client/ui/docs/DocEditForm.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<v-row justify="center">
|
||||
<template v-if="doc">
|
||||
<v-col
|
||||
cols="12"
|
||||
lg="8"
|
||||
>
|
||||
<v-row dense>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<text-field
|
||||
label="Title"
|
||||
:value="doc.name"
|
||||
@change="(value, ack) => change({path: ['name'], value, ack})"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
class="d-flex"
|
||||
>
|
||||
<text-field
|
||||
label="URL title"
|
||||
:value="doc.urlName"
|
||||
hint="Only letters, numbers, and dashes"
|
||||
@change="(value, ack) => change({path: ['urlName'], value, ack})"
|
||||
/>
|
||||
<v-menu
|
||||
bottom
|
||||
left
|
||||
transition="slide-y-transition"
|
||||
>
|
||||
<template #activator="{ on }">
|
||||
<v-btn
|
||||
icon
|
||||
style="height: 56px; width: 56px;"
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon>mdi-dots-vertical</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
@click="remove()"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
Delete
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<smart-switch
|
||||
label="Published"
|
||||
:value="doc.published"
|
||||
@change="(value, ack) => change({path: ['published'], value, ack})"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
class="d-flex align-center"
|
||||
>
|
||||
<icon-picker
|
||||
label="Icon"
|
||||
:value="doc.icon"
|
||||
@change="(value, ack) => change({path: ['icon'], value, ack})"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
>
|
||||
<text-area
|
||||
auto-grow
|
||||
label="Body"
|
||||
:value="doc.description"
|
||||
@change="(value, ack) => change({path: ['description'], value, ack})"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</template>
|
||||
<v-col
|
||||
cols="12"
|
||||
lg="8"
|
||||
>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="child in childDocs"
|
||||
:key="child._id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
xl="3"
|
||||
>
|
||||
<doc-card :doc="child" />
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
xl="3"
|
||||
>
|
||||
<smart-btn
|
||||
single-click
|
||||
outlined
|
||||
color="accent"
|
||||
style="width: 100%; height: 240px;"
|
||||
@click="ack => add({ ack })"
|
||||
>
|
||||
Add child
|
||||
</smart-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import Docs, {
|
||||
insertDoc,
|
||||
pullFromDoc,
|
||||
pushToDoc,
|
||||
restoreDoc,
|
||||
softRemoveDoc,
|
||||
updateDoc,
|
||||
} from '/imports/api/docs/Docs.js';
|
||||
import { get } from 'lodash';
|
||||
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue.js';
|
||||
import DocCard from '/imports/client/ui/docs/DocCard.vue';
|
||||
import getDocLink from '/imports/client/ui/docs/getDocLink.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DocCard
|
||||
},
|
||||
props: {
|
||||
doc: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
childDocs: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
docId() {
|
||||
return this.doc?._id;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
change({ path, value, ack }) {
|
||||
updateDoc.call({ _id: this.docId, path, value }, ack);
|
||||
if (path[0] === 'urlName' && path.length === 1 && value) {
|
||||
this.$router.push(getDocLink(this.doc, value));
|
||||
}
|
||||
},
|
||||
push({ path, value, ack }) {
|
||||
pushToDoc.call({ _id: this.docId, path, value }, ack);
|
||||
},
|
||||
pull({ path, ack }) {
|
||||
let itemId = get(this.model, path)._id;
|
||||
path.pop();
|
||||
pullFromDoc.call({ _id: this.docId, path, itemId }, ack);
|
||||
},
|
||||
add({ ack }) {
|
||||
insertDoc.call({
|
||||
doc: {
|
||||
name: 'New Doc',
|
||||
},
|
||||
parentRef: this.doc && {
|
||||
id: this.docId,
|
||||
collection: 'docs',
|
||||
},
|
||||
}, ack);
|
||||
},
|
||||
remove({ ack } = {}) {
|
||||
const _id = this.docId;
|
||||
const docName = this.doc.name;
|
||||
let parentHref = '/docs';
|
||||
if (this.doc.parent) {
|
||||
const parent = Docs.findOne({ _id: this.doc.parent.id });
|
||||
parentHref = parent?.href || parentHref;
|
||||
}
|
||||
softRemoveDoc.call({ _id }, (error) => {
|
||||
ack?.(error);
|
||||
if (!error) {
|
||||
snackbar({
|
||||
text: `Deleted ${docName}`,
|
||||
callbackName: 'undo',
|
||||
callback() {
|
||||
restoreDoc.call({ _id });
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
this.$router.push(parentHref);
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
30
app/imports/client/ui/docs/DocListItem.vue
Normal file
30
app/imports/client/ui/docs/DocListItem.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<v-list-item
|
||||
:to="doc.href"
|
||||
style="min-width: 180px;"
|
||||
>
|
||||
<v-list-item-avatar v-if="icon">
|
||||
<svg-icon
|
||||
v-if="doc && doc.icon"
|
||||
:shape="doc.icon.shape"
|
||||
/>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{ doc.name }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
export default {
|
||||
props: {
|
||||
doc: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
icon: Boolean,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
55
app/imports/client/ui/docs/DocToolbar.vue
Normal file
55
app/imports/client/ui/docs/DocToolbar.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template lang="html">
|
||||
<v-app-bar
|
||||
app
|
||||
color="secondary"
|
||||
dark
|
||||
tabs
|
||||
extended
|
||||
dense
|
||||
>
|
||||
<v-app-bar-nav-icon @click="toggleDrawer" />
|
||||
<v-toolbar-title>
|
||||
Documentation
|
||||
</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="canEdit"
|
||||
icon
|
||||
@click="toggleEdit"
|
||||
>
|
||||
<v-icon v-if="editing">
|
||||
mdi-check
|
||||
</v-icon>
|
||||
<v-icon v-else>
|
||||
mdi-pencil
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import { mapMutations } from 'vuex';
|
||||
import { Session } from 'meteor/session';
|
||||
|
||||
export default {
|
||||
meteor: {
|
||||
editing() {
|
||||
return Session.get('editingDocs');
|
||||
},
|
||||
canEdit() {
|
||||
const user = Meteor.user();
|
||||
if (!user) return false;
|
||||
return user.roles?.includes('docsWriter');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapMutations([
|
||||
'toggleDrawer',
|
||||
]),
|
||||
toggleEdit() {
|
||||
if (!this.canEdit) return;
|
||||
Session.set('editingDocs', !Session.get('editingDocs'));
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
135
app/imports/client/ui/docs/DocViewer.vue
Normal file
135
app/imports/client/ui/docs/DocViewer.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<v-row justify="center">
|
||||
<v-col
|
||||
cols="12"
|
||||
lg="8"
|
||||
>
|
||||
<v-card
|
||||
style="float: right; z-index: 5;"
|
||||
class="ma-4"
|
||||
>
|
||||
<v-fade-transition mode="out-in">
|
||||
<v-list
|
||||
v-if="siblingDocs.length > 1"
|
||||
>
|
||||
<doc-list-item
|
||||
v-for="sibling in siblingDocs"
|
||||
:key="sibling._id"
|
||||
:doc="sibling"
|
||||
:icon="siblingHasIcon"
|
||||
/>
|
||||
</v-list>
|
||||
</v-fade-transition>
|
||||
</v-card>
|
||||
<v-fade-transition mode="out-in">
|
||||
<div
|
||||
:key="doc && doc.name || 'Documentation Home'"
|
||||
class="d-flex align-center mb-4"
|
||||
>
|
||||
<v-avatar
|
||||
v-if="(doc && doc.icon) || (!doc)"
|
||||
size="56"
|
||||
>
|
||||
<svg-icon
|
||||
v-if="doc && doc.icon"
|
||||
large
|
||||
:shape="doc.icon.shape"
|
||||
/>
|
||||
<v-icon
|
||||
v-else-if="!doc"
|
||||
large
|
||||
>
|
||||
mdi-home
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
<h1
|
||||
v-if="doc"
|
||||
>
|
||||
{{ doc.name }}
|
||||
</h1>
|
||||
<h1
|
||||
v-else
|
||||
>
|
||||
Documentation
|
||||
</h1>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
<v-fade-transition mode="out-in">
|
||||
<markdown-text
|
||||
v-if="doc"
|
||||
:key="doc._id"
|
||||
:markdown="doc.description"
|
||||
@click="mdClick"
|
||||
/>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
lg="8"
|
||||
>
|
||||
<v-fade-transition
|
||||
mode="out-in"
|
||||
leave-absolute
|
||||
hide-on-leave
|
||||
>
|
||||
<v-row :key="doc && doc._id">
|
||||
<v-col
|
||||
v-for="child in childDocs"
|
||||
:key="child._id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
xl="3"
|
||||
>
|
||||
<doc-card :doc="child" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-fade-transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import MarkdownText from '/imports/client/ui/components/MarkdownText.vue';
|
||||
import DocCard from '/imports/client/ui/docs/DocCard.vue';
|
||||
import DocListItem from '/imports/client/ui/docs/DocListItem.vue';
|
||||
import { find } from 'lodash';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DocListItem,
|
||||
MarkdownText,
|
||||
DocCard,
|
||||
},
|
||||
props: {
|
||||
doc: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
childDocs: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
siblingDocs: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
siblingHasIcon() {
|
||||
return !!find(this.siblingDocs, doc => doc.icon);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mdClick(e) {
|
||||
const target = e.target || e.srcElement;
|
||||
const href = target && target.href;
|
||||
if (!href) return;
|
||||
const path = href.split('/docs/')[1];
|
||||
if (!path) return;
|
||||
e.preventDefault();
|
||||
this.$router.push('/docs/' + path);
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
9
app/imports/client/ui/docs/getDocLink.js
Normal file
9
app/imports/client/ui/docs/getDocLink.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export default function getDocLink(doc, urlName) {
|
||||
if (!urlName) urlName = doc.urlName;
|
||||
const address = ['/docs'];
|
||||
doc.ancestors?.forEach(a => {
|
||||
address.push(a.urlName);
|
||||
});
|
||||
address.push(urlName);
|
||||
return address.join('/');
|
||||
}
|
||||
150
app/imports/client/ui/pages/DocsPage.vue
Normal file
150
app/imports/client/ui/pages/DocsPage.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<v-container class="documentation">
|
||||
<v-row
|
||||
justify="center"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="8"
|
||||
>
|
||||
<doc-breadcrumbs :doc="doc" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-fade-transition mode="out-in">
|
||||
<v-progress-circular
|
||||
v-if="!$subReady.docs"
|
||||
key="loading"
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="32"
|
||||
/>
|
||||
<v-row
|
||||
v-else-if="docNotFound"
|
||||
key="failed"
|
||||
justify="center"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="8"
|
||||
>
|
||||
<h1>Documentation not found</h1>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<doc-edit-form
|
||||
v-else-if="editing"
|
||||
key="editing"
|
||||
:doc="doc"
|
||||
:child-docs="childDocs"
|
||||
/>
|
||||
<doc-viewer
|
||||
v-else
|
||||
key="viewing"
|
||||
:doc="doc"
|
||||
:child-docs="childDocs"
|
||||
:sibling-docs="siblingDocs"
|
||||
/>
|
||||
</v-fade-transition>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import Docs from '/imports/api/docs/Docs.js';
|
||||
import DocEditForm from '/imports/client/ui/docs/DocEditForm.vue';
|
||||
import DocViewer from '/imports/client/ui/docs/DocViewer.vue';
|
||||
import DocBreadcrumbs from '/imports/client/ui/docs/DocBreadcrumbs.vue';
|
||||
import { Session } from 'meteor/session';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DocBreadcrumbs,
|
||||
DocViewer,
|
||||
DocEditForm,
|
||||
},
|
||||
data() { return {
|
||||
docNotFound: false,
|
||||
}},
|
||||
computed: {
|
||||
path() {
|
||||
return this.$route.params.docPath;
|
||||
},
|
||||
doc() {
|
||||
if (!this.docs?.length) return;
|
||||
return this.docs[this.docs.length - 1];
|
||||
},
|
||||
title() {
|
||||
if (this.doc) {
|
||||
return this.doc.name;
|
||||
} else if (this.docNotFound) {
|
||||
return 'Doc not found';
|
||||
} else {
|
||||
return 'Documentation'
|
||||
}
|
||||
},
|
||||
root() {
|
||||
return !this.path;
|
||||
}
|
||||
},
|
||||
meteor: {
|
||||
$subscribe: {
|
||||
'docs': [],
|
||||
},
|
||||
docs() {
|
||||
const docs = [];
|
||||
this.docNotFound = false;
|
||||
if (this.root) {
|
||||
return docs;
|
||||
}
|
||||
let currentDoc = undefined;
|
||||
this.path.split('/').forEach(urlName => {
|
||||
currentDoc = Docs.findOne({
|
||||
urlName, 'parent.id': currentDoc?._id,
|
||||
removed: { $ne: true },
|
||||
})
|
||||
if (currentDoc) {
|
||||
docs.push(currentDoc);
|
||||
} else {
|
||||
this.docNotFound = true;
|
||||
}
|
||||
});
|
||||
return docs;
|
||||
},
|
||||
childDocs() {
|
||||
if (!this.doc) return Docs.find({
|
||||
'parent': undefined,
|
||||
removed: { $ne: true },
|
||||
});
|
||||
return Docs.find({
|
||||
'parent.id': this.doc._id,
|
||||
removed: { $ne: true },
|
||||
})
|
||||
},
|
||||
siblingDocs() {
|
||||
if (!this.doc) return [];
|
||||
return Docs.find({
|
||||
'parent.id': this.doc.parent?.id,
|
||||
removed: { $ne: true },
|
||||
});
|
||||
},
|
||||
editing() {
|
||||
return Session.get('editingDocs');
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
title: {
|
||||
immediate: true,
|
||||
handler(value) {
|
||||
this.$store.commit('setPageTitle', value);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.documentation .fade-transition-enter-active {
|
||||
transition: all .25s linear !important;
|
||||
}
|
||||
.documentation .fade-transition-leave-active {
|
||||
transition: all .1s linear !important;
|
||||
}
|
||||
</style>
|
||||
@@ -36,7 +36,8 @@ const TabletopRightDrawer = () => import('/imports/client/ui/tabletop/TabletopRi
|
||||
const Admin = () => import('/imports/client/ui/pages/Admin.vue');
|
||||
const Maintenance = () => import('/imports/client/ui/pages/Maintenance.vue');
|
||||
const Files = () => import('/imports/client/ui/pages/Files.vue');
|
||||
const Documentation = () => import('/imports/client/ui/pages/Documentation.vue');
|
||||
const DocsPage = () => import('/imports/client/ui/pages/DocsPage.vue');
|
||||
const DocToolbar = () => import('/imports/client/ui/docs/DocToolbar.vue');
|
||||
|
||||
// Not found
|
||||
const NotFound = () => import('/imports/client/ui/pages/NotFound.vue');
|
||||
@@ -264,17 +265,10 @@ RouterFactory.configure(router => {
|
||||
title: 'Functions',
|
||||
},
|
||||
}, {
|
||||
path: '/docs/:docPath([^/]+.*)',
|
||||
path: '/docs/:docPath([^/]+.*)?',
|
||||
components: {
|
||||
default: Documentation,
|
||||
},
|
||||
meta: {
|
||||
title: 'Documentation',
|
||||
},
|
||||
}, {
|
||||
path: '/docs',
|
||||
components: {
|
||||
default: Documentation,
|
||||
default: DocsPage,
|
||||
toolbar: DocToolbar,
|
||||
},
|
||||
meta: {
|
||||
title: 'Documentation',
|
||||
|
||||
@@ -1,35 +1,17 @@
|
||||
import { propsByDocsPath } from '/imports/constants/PROPERTIES.js';
|
||||
import Docs from '/imports/api/docs/Docs.js';
|
||||
|
||||
|
||||
// Manual doc paths
|
||||
const docPaths = [
|
||||
'computed-fields',
|
||||
'inline-calculations',
|
||||
'dependency-loops',
|
||||
'docs',
|
||||
'tags',
|
||||
'walkthroughs/create-a-class',
|
||||
];
|
||||
const docs = new Map();
|
||||
docPaths.forEach(path => {
|
||||
docs.set(path, Assets.getText(`docs/${path}.md`))
|
||||
});
|
||||
|
||||
// Doc paths for properties
|
||||
propsByDocsPath.forEach(prop => {
|
||||
docs.set(prop.docsPath, Assets.getText(`docs/${prop.docsPath}.md`));
|
||||
});
|
||||
|
||||
Meteor.publish('docs', function (path) {
|
||||
if (!path) {
|
||||
docs.forEach((text, path) => {
|
||||
this.added('docs', path, { text });
|
||||
Meteor.publish('docs', function () {
|
||||
const filter = { published: true, removed: { $ne: true } };
|
||||
if (this.userId) {
|
||||
const user = Meteor.users.findOne(this.userId, {
|
||||
fields: {
|
||||
'roles': 1,
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const text = docs.get(path);
|
||||
if (text) {
|
||||
this.added('docs', path, { text });
|
||||
if (user?.roles?.includes('docsWriter')) {
|
||||
delete filter.published;
|
||||
delete filter.removed
|
||||
}
|
||||
}
|
||||
this.ready();
|
||||
return Docs.find(filter);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user