Skip to content

Commit

Permalink
Implement client-side doc versioning
Browse files Browse the repository at this point in the history
  • Loading branch information
davidmyersdev committed Jan 16, 2023
1 parent d7f90ea commit 6e4cfcb
Show file tree
Hide file tree
Showing 16 changed files with 545 additions and 242 deletions.
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 '/components/DiscardableAction.vue'
import DocLink from '/components/DocLink.vue'
import TagLink from '/components/TagLink.vue'
import CodeSandbox from '/src/common/code_sandbox.js'
import { parseCodeblocks, parseReferences } from '/src/common/parsers'
import Doc from '/src/models/doc'
import {
DISCARD_DOCUMENT,
DUPLICATE_DOCUMENT,
RESTORE_DOCUMENT,
RESTRICT_DOCUMENT,
SHARE_DOCUMENT,
SET_RIGHT_SIDEBAR_VISIBILITY,
} from '/src/store/actions.js'
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>
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 Observable, type Subscription } from 'rxjs'
import { type Ref, type UnwrapRef, ref } from 'vue'
import { db } from '/src/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,
}
}
13 changes: 13 additions & 0 deletions composables/useDocVersions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const useDocVersions = () => {
const router = useRouter()
const { db } = useDatabase()
const { doc } = useDocs()
// Todo: Sort versions.
const { result: docVersions } = useQuery(() => db.docVersions.where({ docId: 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 '/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

0 comments on commit 6e4cfcb

Please sign in to comment.