Skip to content

Commit bc4246e

Browse files
committed
Implement client-side doc versioning
1 parent 78b9214 commit bc4246e

File tree

16 files changed

+542
-242
lines changed

16 files changed

+542
-242
lines changed

components/DocMeta.vue

+250
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
<template>
2+
<CoreScrollable class="meta p-4 md:p-2">
3+
<div class="flex flex-col flex-grow">
4+
<CoreLink v-if="hasHistory" :to="{ path: `/docs/${doc.id}/versions` }" class="sidebar-link w-full">
5+
<HistoryIcon class="w-5" />
6+
<span class="ml-6 md:ml-3 flex-grow text-left">History ({{ docVersions.length }})</span>
7+
</CoreLink>
8+
<div v-if="docVersion" class="flex flex-col flex-grow">
9+
<CoreLink @click="restoreDocVersion" :to="{ path: `/docs/${doc.id}` }" class="sidebar-link w-full">
10+
<HistoryIcon class="w-5" />
11+
<span class="ml-6 md:ml-3 flex-grow text-left">Restore Version</span>
12+
</CoreLink>
13+
</div>
14+
<div v-else-if="doc" class="flex flex-col flex-grow">
15+
<div>
16+
<button @click.stop="duplicateDocument" class="sidebar-link w-full">
17+
<DuplicateIcon class="w-5" />
18+
<span class="ml-6 md:ml-3 flex-grow text-left">Duplicate</span>
19+
</button>
20+
<DiscardableAction v-if="doc.id" :discardedAt="doc.discardedAt" :onDiscard="discardDocument" :onRestore="restoreDocument" class="sidebar-link w-full"></DiscardableAction>
21+
<button v-if="hasCodeblocks" @click="openSandbox" class="sidebar-link w-full">
22+
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
23+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
24+
</svg>
25+
<span class="ml-6 md:ml-3 flex-grow text-left">Create Sandbox</span>
26+
</button>
27+
<div>
28+
<div v-if="doc.public">
29+
<button @click="restrictDocument" class="sidebar-link w-full">
30+
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
31+
<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" />
32+
</svg>
33+
<span class="ml-6 md:ml-3 flex-grow text-left">Make Private</span>
34+
</button>
35+
<button @click="copyPublicUrl" class="sidebar-link w-full">
36+
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
37+
<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" />
38+
</svg>
39+
<span class="ml-6 md:ml-3 flex-grow text-left">Copy Link</span>
40+
</button>
41+
<input ref="link" :value="publicUrl" type="text" class="form-text w-full mb-2" readonly data-test-public-url>
42+
</div>
43+
<div v-else class="mb-2">
44+
<button @click="shareDocument" class="sidebar-link w-full" data-test-share-doc>
45+
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
46+
<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" />
47+
</svg>
48+
<span class="ml-6 md:ml-3 flex-grow text-left">Make Public</span>
49+
</button>
50+
</div>
51+
</div>
52+
</div>
53+
<div class="mt-4">
54+
<TagLink v-for="tag in doc.tags" :key="tag" :tag="tag" class="sidebar-link" />
55+
</div>
56+
<div class="mt-4">
57+
<DocLink v-for="reference in references" :key="reference.id" :doc="reference" class="sidebar-link" />
58+
</div>
59+
<div class="mt-4">
60+
<div v-for="task in doc.tasks" class="flex items-center px-3 py-2 my-1 md:px-2 md:py-1">
61+
<svg height="1.25em" width="1.25em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
62+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
63+
</svg>
64+
<span class="flex-grow overflow-hidden truncate ml-3">{{ task }}</span>
65+
</div>
66+
</div>
67+
<div class="flex flex-col justify-end flex-grow px-3 md:p-2 mt-4 mb-3 md:mb-1">
68+
<div v-if="doc.updatedAt">
69+
<small class="text-gray-700">Last Saved</small>
70+
<div class="capitalize pt-2 md:pt-1">{{ savedAt }}</div>
71+
</div>
72+
<div v-if="doc.createdAt" class="mt-3 md:mt-2">
73+
<small class="text-gray-700">Created</small>
74+
<div class="pt-2 md:pt-1">{{ createdAt }}</div>
75+
</div>
76+
<div v-if="doc.updatedAt" class="mt-3 md:mt-2">
77+
<small class="text-gray-700">Updated</small>
78+
<div class="pt-2 md:pt-1">{{ updatedAt }}</div>
79+
</div>
80+
<div v-if="doc.discardedAt" class="mt-3 md:mt-2">
81+
<small class="text-gray-700">Discarded</small>
82+
<div class="pt-2 md:pt-1">{{ discardedAt }}</div>
83+
</div>
84+
</div>
85+
</div>
86+
</div>
87+
</CoreScrollable>
88+
</template>
89+
90+
<script>
91+
import { TrashIcon as DiscardIcon, DocumentDuplicateIcon as DuplicateIcon, ClockIcon as HistoryIcon, LockClosedIcon as PrivateIcon, LockOpenIcon as PublicIcon } from '@heroicons/vue/24/outline'
92+
import moment from 'moment'
93+
import { useStore } from 'vuex'
94+
import DiscardableAction from '#root/components/DiscardableAction.vue'
95+
import DocLink from '#root/components/DocLink.vue'
96+
import TagLink from '#root/components/TagLink.vue'
97+
import CodeSandbox from '#root/src/common/code_sandbox'
98+
import { parseCodeblocks, parseReferences } from '#root/src/common/parsers'
99+
import Doc from '#root/src/models/doc'
100+
101+
import {
102+
DISCARD_DOCUMENT,
103+
DUPLICATE_DOCUMENT,
104+
RESTORE_DOCUMENT,
105+
RESTRICT_DOCUMENT,
106+
SHARE_DOCUMENT,
107+
SET_RIGHT_SIDEBAR_VISIBILITY,
108+
} from '#root/src/store/actions'
109+
110+
export default {
111+
components: {
112+
DiscardIcon,
113+
DiscardableAction,
114+
DocLink,
115+
DuplicateIcon,
116+
HistoryIcon,
117+
PrivateIcon,
118+
PublicIcon,
119+
TagLink,
120+
},
121+
setup() {
122+
const store = useStore()
123+
const { doc } = useDocs()
124+
const { docVersion, docVersions } = useDocVersions(doc)
125+
const hasHistory = computed(() => docVersions.value.length > 0)
126+
127+
const restoreDocVersion = () => {
128+
store.commit('EDIT_DOCUMENT', new Doc({ ...doc.value, text: docVersion.value.text }))
129+
}
130+
131+
return {
132+
docVersion,
133+
docVersions,
134+
hasHistory,
135+
restoreDocVersion,
136+
}
137+
},
138+
data() {
139+
return {
140+
now: moment(),
141+
ticker: null,
142+
}
143+
},
144+
computed: {
145+
codeblocks() {
146+
return parseCodeblocks(this.doc.text)
147+
},
148+
createdAt() {
149+
if (this.$route.params.docId) {
150+
return moment(this.doc.createdAt).format('ddd, MMM Do, YYYY [at] h:mm A')
151+
}
152+
153+
return 'Not yet created'
154+
},
155+
discardedAt() {
156+
return moment(this.doc.discardedAt).format('ddd, MMM Do, YYYY [at] h:mm A')
157+
},
158+
doc() {
159+
return this.$store.getters.decrypted.find((doc) => doc.id === this.$route.params.docId)
160+
},
161+
hasCodeblocks() {
162+
return this.codeblocks.length > 0
163+
},
164+
publicUrl() {
165+
const path = this.$router.resolve({ path: `/public/${this.doc.id}` }).href
166+
167+
return `${location.protocol}//${location.host}${path}`
168+
},
169+
references() {
170+
const references = parseReferences(this.doc.text)
171+
172+
return this.$store.getters.kept.filter((doc) => {
173+
return references.includes(doc.id)
174+
})
175+
},
176+
savedAt() {
177+
if (this.$route.params.docId) {
178+
if (this.now.diff(this.doc.updatedAt, 'seconds') < 5) {
179+
return 'just now'
180+
}
181+
else {
182+
return `${moment(this.doc.updatedAt).from(this.now, true)} ago`
183+
}
184+
}
185+
186+
return 'Not yet saved'
187+
},
188+
updatedAt() {
189+
if (this.$route.params.docId) {
190+
return moment(this.doc.updatedAt).format('ddd, MMM Do, YYYY [at] h:mm A')
191+
}
192+
193+
return 'Not yet updated'
194+
},
195+
},
196+
methods: {
197+
async copyPublicUrl() {
198+
// copy link to clipboard
199+
this.$refs.link.select()
200+
document.execCommand('copy')
201+
},
202+
async discardDocument() {
203+
this.$store.dispatch(DISCARD_DOCUMENT, { id: this.doc.id })
204+
205+
this.$router.push({ path: '/docs/new' })
206+
},
207+
async duplicateDocument() {
208+
const newDocId = await this.$store.dispatch(DUPLICATE_DOCUMENT, { id: this.doc.id })
209+
210+
this.$router.push({ path: `/docs/${newDocId}` })
211+
},
212+
async openSandbox() {
213+
const files = this.codeblocks.reduce((agg, codeblock, index) => {
214+
const filename = codeblock.filename || [index, (codeblock.language || 'txt')].join('.')
215+
216+
return {
217+
...agg,
218+
[filename]: {
219+
content: codeblock.code,
220+
},
221+
}
222+
}, {})
223+
224+
CodeSandbox.create(files).then(sandbox_id => CodeSandbox.open(sandbox_id))
225+
},
226+
async restoreDocument() {
227+
this.$store.dispatch(RESTORE_DOCUMENT, { id: this.doc.id })
228+
},
229+
async restrictDocument() {
230+
this.$store.dispatch(RESTRICT_DOCUMENT, { id: this.doc.id })
231+
},
232+
async shareDocument() {
233+
this.$store.dispatch(SHARE_DOCUMENT, { id: this.doc.id })
234+
},
235+
async toggleMeta() {
236+
this.$store.dispatch(SET_RIGHT_SIDEBAR_VISIBILITY, !this.$store.state.showRightSidebar)
237+
},
238+
},
239+
async beforeUnmount() {
240+
clearInterval(this.ticker)
241+
},
242+
async mounted() {
243+
this.mounted = true
244+
245+
this.ticker = setInterval(() => {
246+
this.now = moment()
247+
}, 5000)
248+
},
249+
}
250+
</script>

composables/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './useAppearance'
22
export * from './useAuth'
3+
export * from './useDatabase'
34
export * from './useLayout'
45
export * from './usePinnedDocs'
56
export * from './useTiers'

composables/useDatabase.ts

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { tryOnScopeDispose } from '@vueuse/core'
2+
import { liveQuery } from 'dexie'
3+
import { type Subscription } from 'rxjs'
4+
import { type Ref, ref } from 'vue'
5+
import { db } from '#root/src/database'
6+
7+
export const useDatabase = () => {
8+
const observe = <T>(callback: () => T) => {
9+
return useObservable<T>(liveQuery<T>(callback) as any)
10+
}
11+
12+
return {
13+
db,
14+
observe,
15+
}
16+
}
17+
18+
type QueryReturnType<T, I = undefined> = { result: Ref<T | I> }
19+
20+
export function useQuery<T>(callback: () => Promise<T>): QueryReturnType<T>
21+
export function useQuery<T>(callback: () => Promise<T>, initialValue: T): QueryReturnType<T, T>
22+
export function useQuery<T>(callback: () => Promise<T>, initialValue?: T) {
23+
const result = initialValue ? ref<T>(initialValue) : ref<T>()
24+
const subscription = ref<Subscription>()
25+
26+
watch(callback, () => {
27+
const observable = liveQuery<T>(callback)
28+
29+
subscription.value?.unsubscribe()
30+
subscription.value = observable.subscribe({
31+
next: (value) => {
32+
result.value = value
33+
},
34+
error: (error) => {
35+
console.error(error)
36+
},
37+
}) as any
38+
}, { immediate: true })
39+
40+
tryOnScopeDispose(() => subscription.value?.unsubscribe())
41+
42+
return {
43+
result,
44+
}
45+
}

composables/useDocVersions.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const useDocVersions = () => {
2+
const router = useRouter()
3+
const { db } = useDatabase()
4+
const { doc } = useDocs()
5+
// Todo: Sort versions.
6+
const { result: docVersions } = useQuery(() => db.docVersions.where({ docId: doc.value?.id || '' }).reverse().sortBy('updatedAt'), [])
7+
const docVersion = computed(() => docVersions.value.find(version => version.id === router.currentRoute.value.params.versionId))
8+
9+
return {
10+
docVersion,
11+
docVersions,
12+
}
13+
}

composables/useDocs.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import type Doc from '#root/src/models/doc'
44
export const useDocs = () => {
55
const store = useStore()
66
const router = useRouter()
7-
const docs = computed(() => store.getters.decrypted)
8-
const doc = computed(() => docs.value.find((doc: Doc) => doc.id === router.currentRoute.value.params.docId))
7+
const docs = computed<Doc[]>(() => store.getters.decrypted)
8+
const doc = computed<Doc | undefined>(() => docs.value.find((doc: Doc) => doc.id === router.currentRoute.value.params.docId))
99

1010
return {
1111
doc,

0 commit comments

Comments
 (0)