Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Client-side doc versioning #237

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions components/Doc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,9 @@
</template>

<script>
import { micromark } from 'micromark'
import { gfm, gfmHtml } from 'micromark-extension-gfm'
import moment from 'moment'
import CoreDivider from '#root/components/CoreDivider.vue'

import {
DISCARD_DOCUMENT,
RESTORE_DOCUMENT,
} from '/src/store/actions.js';
import { DISCARD_DOCUMENT, RESTORE_DOCUMENT } from '#root/src/store/actions'

export default {
components: {
Expand Down
5 changes: 1 addition & 4 deletions components/DocList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,7 @@
<script>
import Doc from '#root/components/Doc.vue'
import Tag from '#root/components/Tag.vue'

import {
MERGE_DOCUMENTS,
} from '/src/store/actions.js'
import { MERGE_DOCUMENTS } from '#root/src/store/actions'

const REGEX_QUERY = /^\/(?<regex>.+)\/(?<flags>[a-z]*)$/s

Expand Down
250 changes: 250 additions & 0 deletions components/DocMeta.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
<template>
<CoreScrollable class="meta p-4 md:p-2">
<div class="flex flex-col flex-grow">
<CoreLink v-if="hasHistory" :to="{ path: `/docs/${doc.id}/versions` }" class="sidebar-link w-full">
<HistoryIcon class="w-5" />
<span class="ml-6 md:ml-3 flex-grow text-left">History ({{ docVersions.length }})</span>
</CoreLink>
<div v-if="docVersion" class="flex flex-col flex-grow">
<CoreLink @click="restoreDocVersion" :to="{ path: `/docs/${doc.id}` }" class="sidebar-link w-full">
<HistoryIcon class="w-5" />
<span class="ml-6 md:ml-3 flex-grow text-left">Restore Version</span>
</CoreLink>
</div>
<div v-else-if="doc" class="flex flex-col flex-grow">
<div>
<button @click.stop="duplicateDocument" class="sidebar-link w-full">
<DuplicateIcon class="w-5" />
<span class="ml-6 md:ml-3 flex-grow text-left">Duplicate</span>
</button>
<DiscardableAction v-if="doc.id" :discardedAt="doc.discardedAt" :onDiscard="discardDocument" :onRestore="restoreDocument" class="sidebar-link w-full"></DiscardableAction>
<button v-if="hasCodeblocks" @click="openSandbox" class="sidebar-link w-full">
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<span class="ml-6 md:ml-3 flex-grow text-left">Create Sandbox</span>
</button>
<div>
<div v-if="doc.public">
<button @click="restrictDocument" class="sidebar-link w-full">
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span class="ml-6 md:ml-3 flex-grow text-left">Make Private</span>
</button>
<button @click="copyPublicUrl" class="sidebar-link w-full">
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
<span class="ml-6 md:ml-3 flex-grow text-left">Copy Link</span>
</button>
<input ref="link" :value="publicUrl" type="text" class="form-text w-full mb-2" readonly data-test-public-url>
</div>
<div v-else class="mb-2">
<button @click="shareDocument" class="sidebar-link w-full" data-test-share-doc>
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
<span class="ml-6 md:ml-3 flex-grow text-left">Make Public</span>
</button>
</div>
</div>
</div>
<div class="mt-4">
<TagLink v-for="tag in doc.tags" :key="tag" :tag="tag" class="sidebar-link" />
</div>
<div class="mt-4">
<DocLink v-for="reference in references" :key="reference.id" :doc="reference" class="sidebar-link" />
</div>
<div class="mt-4">
<div v-for="task in doc.tasks" class="flex items-center px-3 py-2 my-1 md:px-2 md:py-1">
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="flex-grow overflow-hidden truncate ml-3">{{ task }}</span>
</div>
</div>
<div class="flex flex-col justify-end flex-grow px-3 md:p-2 mt-4 mb-3 md:mb-1">
<div v-if="doc.updatedAt">
<small class="text-gray-700">Last Saved</small>
<div class="capitalize pt-2 md:pt-1">{{ savedAt }}</div>
</div>
<div v-if="doc.createdAt" class="mt-3 md:mt-2">
<small class="text-gray-700">Created</small>
<div class="pt-2 md:pt-1">{{ createdAt }}</div>
</div>
<div v-if="doc.updatedAt" class="mt-3 md:mt-2">
<small class="text-gray-700">Updated</small>
<div class="pt-2 md:pt-1">{{ updatedAt }}</div>
</div>
<div v-if="doc.discardedAt" class="mt-3 md:mt-2">
<small class="text-gray-700">Discarded</small>
<div class="pt-2 md:pt-1">{{ discardedAt }}</div>
</div>
</div>
</div>
</div>
</CoreScrollable>
</template>

<script>
import { TrashIcon as DiscardIcon, DocumentDuplicateIcon as DuplicateIcon, ClockIcon as HistoryIcon, LockClosedIcon as PrivateIcon, LockOpenIcon as PublicIcon } from '@heroicons/vue/24/outline'
import moment from 'moment'
import { useStore } from 'vuex'
import DiscardableAction from '#root/components/DiscardableAction.vue'
import DocLink from '#root/components/DocLink.vue'
import TagLink from '#root/components/TagLink.vue'
import CodeSandbox from '#root/src/common/code_sandbox'
import { parseCodeblocks, parseReferences } from '#root/src/common/parsers'
import Doc from '#root/src/models/doc'

import {
DISCARD_DOCUMENT,
DUPLICATE_DOCUMENT,
RESTORE_DOCUMENT,
RESTRICT_DOCUMENT,
SHARE_DOCUMENT,
SET_RIGHT_SIDEBAR_VISIBILITY,
} from '#root/src/store/actions'

export default {
components: {
DiscardIcon,
DiscardableAction,
DocLink,
DuplicateIcon,
HistoryIcon,
PrivateIcon,
PublicIcon,
TagLink,
},
setup() {
const store = useStore()
const { doc } = useDocs()
const { docVersion, docVersions } = useDocVersions(doc)
const hasHistory = computed(() => docVersions.value.length > 0)

const restoreDocVersion = () => {
store.commit('EDIT_DOCUMENT', new Doc({ ...doc.value, text: docVersion.value.text }))
}

return {
docVersion,
docVersions,
hasHistory,
restoreDocVersion,
}
},
data() {
return {
now: moment(),
ticker: null,
}
},
computed: {
codeblocks() {
return parseCodeblocks(this.doc.text)
},
createdAt() {
if (this.$route.params.docId) {
return moment(this.doc.createdAt).format('ddd, MMM Do, YYYY [at] h:mm A')
}

return 'Not yet created'
},
discardedAt() {
return moment(this.doc.discardedAt).format('ddd, MMM Do, YYYY [at] h:mm A')
},
doc() {
return this.$store.getters.decrypted.find((doc) => doc.id === this.$route.params.docId)
},
hasCodeblocks() {
return this.codeblocks.length > 0
},
publicUrl() {
const path = this.$router.resolve({ path: `/public/${this.doc.id}` }).href

return `${location.protocol}//${location.host}${path}`
},
references() {
const references = parseReferences(this.doc.text)

return this.$store.getters.kept.filter((doc) => {
return references.includes(doc.id)
})
},
savedAt() {
if (this.$route.params.docId) {
if (this.now.diff(this.doc.updatedAt, 'seconds') < 5) {
return 'just now'
}
else {
return `${moment(this.doc.updatedAt).from(this.now, true)} ago`
}
}

return 'Not yet saved'
},
updatedAt() {
if (this.$route.params.docId) {
return moment(this.doc.updatedAt).format('ddd, MMM Do, YYYY [at] h:mm A')
}

return 'Not yet updated'
},
},
methods: {
async copyPublicUrl() {
// copy link to clipboard
this.$refs.link.select()
document.execCommand('copy')
},
async discardDocument() {
this.$store.dispatch(DISCARD_DOCUMENT, { id: this.doc.id })

this.$router.push({ path: '/docs/new' })
},
async duplicateDocument() {
const newDocId = await this.$store.dispatch(DUPLICATE_DOCUMENT, { id: this.doc.id })

this.$router.push({ path: `/docs/${newDocId}` })
},
async openSandbox() {
const files = this.codeblocks.reduce((agg, codeblock, index) => {
const filename = codeblock.filename || [index, (codeblock.language || 'txt')].join('.')

return {
...agg,
[filename]: {
content: codeblock.code,
},
}
}, {})

CodeSandbox.create(files).then(sandbox_id => CodeSandbox.open(sandbox_id))
},
async restoreDocument() {
this.$store.dispatch(RESTORE_DOCUMENT, { id: this.doc.id })
},
async restrictDocument() {
this.$store.dispatch(RESTRICT_DOCUMENT, { id: this.doc.id })
},
async shareDocument() {
this.$store.dispatch(SHARE_DOCUMENT, { id: this.doc.id })
},
async toggleMeta() {
this.$store.dispatch(SET_RIGHT_SIDEBAR_VISIBILITY, !this.$store.state.showRightSidebar)
},
},
async beforeUnmount() {
clearInterval(this.ticker)
},
async mounted() {
this.mounted = true

this.ticker = setInterval(() => {
this.now = moment()
}, 5000)
},
}
</script>
2 changes: 1 addition & 1 deletion components/SettingsEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ import {
SET_EDITOR_SPELLCHECK,
SET_EDITOR_TAB_SIZE,
SET_EDITOR_TOOLBAR,
} from '/src/store/modules/settings.js'
} from '#root/src/store/modules/settings'

export default {
computed: {
Expand Down
11 changes: 3 additions & 8 deletions components/settings/Encryption.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,9 @@
</template>

<script>
import { exportKeys, generateKeys } from '#root/src/common/crypto/asymmetric.js';

import { TOUCH_DOCUMENT } from '#root/src/store/actions.js';

import {
SET_CRYPTO_ENABLED,
SET_CRYPTO_KEYS,
} from '/src/store/modules/settings.js';
import { exportKeys, generateKeys } from '#root/src/common/crypto/asymmetric'
import { TOUCH_DOCUMENT } from '#root/src/store/actions'
import { SET_CRYPTO_ENABLED, SET_CRYPTO_KEYS } from '#root/src/store/modules/settings'

export default {
data() {
Expand Down
1 change: 1 addition & 0 deletions composables/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './useAppearance'
export * from './useAuth'
export * from './useDatabase'
export * from './useLayout'
export * from './usePinnedDocs'
export * from './useTiers'
45 changes: 45 additions & 0 deletions composables/useDatabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { tryOnScopeDispose } from '@vueuse/core'
import { liveQuery } from 'dexie'
import { type Subscription } from 'rxjs'
import { type Ref, ref } from 'vue'
import { db } from '#root/utils/database'

export const useDatabase = () => {
const observe = <T>(callback: () => T) => {
return useObservable<T>(liveQuery<T>(callback) as any)
}

return {
db,
observe,
}
}

type QueryReturnType<T, I = undefined> = { result: Ref<T | I> }

export function useQuery<T>(callback: () => Promise<T>): QueryReturnType<T>
export function useQuery<T>(callback: () => Promise<T>, initialValue: T): QueryReturnType<T, T>
export function useQuery<T>(callback: () => Promise<T>, initialValue?: T) {
const result = initialValue ? ref<T>(initialValue) : ref<T>()
const subscription = ref<Subscription>()

watch(callback, () => {
const observable = liveQuery<T>(callback)

subscription.value?.unsubscribe()
subscription.value = observable.subscribe({
next: (value) => {
result.value = value
},
error: (error) => {
console.error(error)
},
}) as any
}, { immediate: true })

tryOnScopeDispose(() => subscription.value?.unsubscribe())

return {
result,
}
}
12 changes: 12 additions & 0 deletions composables/useDocVersions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const useDocVersions = () => {
const router = useRouter()
const { db } = useDatabase()
const { doc } = useDocs()
const { result: docVersions } = useQuery(() => db.docs.where({ parentId: doc.value?.id || '' }).reverse().sortBy('updatedAt'), [])
const docVersion = computed(() => docVersions.value.find(version => version.id === router.currentRoute.value.params.versionId))

return {
docVersion,
docVersions,
}
}
4 changes: 2 additions & 2 deletions composables/useDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type Doc from '#root/src/models/doc'
export const useDocs = () => {
const store = useStore()
const router = useRouter()
const docs = computed(() => store.getters.decrypted)
const doc = computed(() => docs.value.find((doc: Doc) => doc.id === router.currentRoute.value.params.docId))
const docs = computed<Doc[]>(() => store.getters.decrypted)
const doc = computed<Doc | undefined>(() => docs.value.find((doc: Doc) => doc.id === router.currentRoute.value.params.docId))

return {
doc,
Expand Down
Loading