diff --git a/web/src/app.css b/web/src/app.css index f5d19cf..f1b8456 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -262,6 +262,47 @@ @apply text-[var(--dark-text)]/60; } + /* Versions dropdown */ + .versions-dropdown { + @apply absolute right-0 z-10 mt-2 w-52 origin-top-right space-y-0.5 rounded-md bg-[var(--light-foreground)] shadow-lg ring-1 ring-[var(--light-foreground)]/10 focus:outline-none; + } + + .dark .versions-dropdown { + @apply bg-[var(--dark-foreground)] ring-[var(--dark-foreground)]/10; + } + + .versions-dropdown-item { + @apply w-full cursor-pointer bg-[var(--light-foreground)] p-3 transition-colors hover:bg-[var(--light-background)]; + } + + .dark .versions-dropdown-item { + @apply bg-[var(--dark-foreground)] hover:bg-[var(--dark-background)]; + } + + .versions-dropdown-item-active { + @apply bg-[var(--light-accent)]/20; + } + + .dark .versions-dropdown-item-active { + @apply bg-[var(--dark-accent)]/20; + } + + .versions-dropdown-item-text { + @apply text-xs text-[var(--light-text)]/60; + } + + .dark .versions-dropdown-item-text { + @apply text-[var(--dark-text)]/60; + } + + .versions-dropdown-divider { + @apply divide-y divide-[var(--light-text)]/20; + } + + .dark .versions-dropdown-divider { + @apply divide-[var(--dark-text)]/20; + } + /* Note editor */ .note-title-input { @apply w-full rounded-2xl border-b border-[var(--light-text)]/20 bg-transparent pb-2 text-2xl font-bold focus:border-[var(--light-accent)]; diff --git a/web/src/lib/client.ts b/web/src/lib/client.ts index 7bbea79..a172ff9 100644 --- a/web/src/lib/client.ts +++ b/web/src/lib/client.ts @@ -26,6 +26,7 @@ export interface FullNote { content: string versionNumber: number versionCreatedAt: Date + isActiveVersion: boolean noteCreatedAt: Date noteUpdatedAt: Date } @@ -60,23 +61,34 @@ interface NewNoteResponse { content: string } -interface VersionMetadataResponse { +interface VersionMetadata { versionID: string title: string + versionNumber: number + isActive: boolean + createdAt: Date } -interface FullVersionResponse { - versionID: string +interface ApiVersionMetadataResponse { + version_id: string + title: string + version_number: number + created_at: string +} + +interface ApiFullVersionResponse { + version_id: string title: string content: string - versionNumber: number - createdAt: Date + version_number: number + created_at: string } // Some of these could just be local variables as they're not used globally export const currentUser: Writable = writable(null) export const currentFullNote: Writable = writable(null) export const availableNotes: Writable = writable(null) +export const versionHistory: Writable = writable(null) export const accessToken: Writable = writable(null) export const csrfToken: Writable = writable(null) export const isPending: Writable = writable(false) @@ -88,8 +100,10 @@ class ApiClient { private lastAtUpdate = new Date(0) private lastCsrfUpdate = new Date(0) private refreshInProgress = false + private activeVersion = -1 private loadedNotesCache = new Map() - // TODO: private loadedVersionsCache = new Map() + private loadedHistoryCache = new Map() + private loadedVersionsCache = new Map() // Key: noteID + versionID constructor(baseUrl: string) { this.baseUrl = baseUrl @@ -293,11 +307,50 @@ class ApiClient { content: apiResponse.content, versionNumber: apiResponse.version_number, versionCreatedAt: new Date(apiResponse.version_created_at), + isActiveVersion: true, // This endpoint serves only the latest version noteCreatedAt: new Date(apiResponse.note_created_at), noteUpdatedAt: new Date(apiResponse.note_updated_at) } } + private deserializeVersionMetadatas( + apiResponses: ApiVersionMetadataResponse[] + ): VersionMetadata[] { + return apiResponses.map((res) => { + return { + versionID: res.version_id, + title: res.title, + versionNumber: res.version_number, + isActive: this.activeVersion === res.version_number, + createdAt: new Date(res.created_at) + } + }) + } + + private joinDeserializedVersion( + noteID: string, + apiResponse: ApiFullVersionResponse + ): FullNote | undefined { + // Cache lookups are safe here due to this always being called *after* fetching the actual `FullNote` + const cachedNote = this.loadedNotesCache.get(noteID) + + if (!cachedNote) { + return + } + + return { + id: cachedNote.id, + owner: cachedNote.owner, + title: apiResponse.title, + content: apiResponse.content, + versionNumber: apiResponse.version_number, + versionCreatedAt: new Date(apiResponse.created_at), + isActiveVersion: cachedNote.versionNumber === apiResponse.version_number, + noteCreatedAt: cachedNote.noteCreatedAt, + noteUpdatedAt: cachedNote.noteUpdatedAt + } + } + public async register(username: string, password: string): Promise { return this.handleRequest( async () => { @@ -541,15 +594,22 @@ class ApiClient { ) } - public async getFullNote(noteID: string): Promise { + public async getActiveFullNote( + noteID: string, + fetchRemote: boolean + ): Promise { if (!UUID_REGEX.test(noteID)) { throw new Error("Invalid note ID format.") } - const cachedNote = this.loadedNotesCache.get(noteID) - if (cachedNote != null) { - console.log(`cache hit ${noteID}`) - return cachedNote + // Attempt cache lookup only if we didn't just push new updates + if (!fetchRemote) { + const cachedNote = this.loadedNotesCache.get(noteID) + if (cachedNote != null) { + // console.log(`full note cache hit ${noteID}`) + this.activeVersion = cachedNote.versionNumber + return cachedNote + } } return this.handleRequest( @@ -567,6 +627,7 @@ class ApiClient { console.log(`caching ${noteID}`) this.loadedNotesCache.set(noteID, note) + this.activeVersion = note.versionNumber return note }, @@ -599,11 +660,22 @@ class ApiClient { ) } - public async getNoteHistory(noteID: string): Promise { + public async getNoteHistory( + noteID: string, + fetchRemote: boolean + ): Promise { if (!UUID_REGEX.test(noteID)) { throw new Error("Invalid note ID format.") } + if (!fetchRemote) { + const cachedVersions = this.loadedHistoryCache.get(noteID) + if (cachedVersions != null) { + // console.log(`full version cache hit ${noteID}`) + return cachedVersions + } + } + return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, { @@ -612,10 +684,13 @@ class ApiClient { } }) - const versions = await this.handleResponse(response, { + const data = await this.handleResponse(response, { useBearerAuth: false }) - console.log(`got ${versions.length} version metadata results`) + const versions = this.deserializeVersionMetadatas(data) + + console.log(`got ${versions.length} version metadata results, caching ${noteID}`) + this.loadedHistoryCache.set(noteID, versions) return versions }, @@ -652,10 +727,7 @@ class ApiClient { ) } - public async getFullVersion( - noteID: string, - versionID: string - ): Promise { + public async getFullVersion(noteID: string, versionID: string): Promise { if (!UUID_REGEX.test(noteID)) { throw new Error("Invalid note ID format.") } @@ -664,6 +736,18 @@ class ApiClient { throw new Error("Invalid version ID format.") } + // NOTE: Versions aren't editable so we don't need to prevent the system from attempting + // to locate each request's contents first from the cache + + const cachedVersion = this.loadedVersionsCache.get(noteID + versionID) + if (cachedVersion != null) { + // console.log(`full version cache hit [${noteID}, ${versionID}]`) + return cachedVersion + } + + // TODO: check if the requested version is the current version -> use `loadedNotesCache` + // (we probably have to modify the caching mechanism so we can look the regular note items up using versionID) + return this.handleRequest( async () => { const response = await fetch(`${this.baseUrl}/notes/${noteID}/${versionID}`, { @@ -671,7 +755,20 @@ class ApiClient { ...this.getAuthHeader() } }) - return await this.handleResponse(response, { useBearerAuth: false }) + + const data = await this.handleResponse(response, { + useBearerAuth: false + }) + const version = this.joinDeserializedVersion(noteID, data) + + if (!version) { + return + } + + console.log(`caching [${noteID}, ${versionID}]`) + this.loadedVersionsCache.set(noteID + versionID, version) + + return version }, { useBearerAuth: true } ) diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index 76c84b6..be24ef4 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -2,7 +2,7 @@ import { onMount } from "svelte" import { marked } from "marked" import { TITLE_MAX_LENGTH } from "$lib/const" - import type { FullNote } from "$lib/client" + import { apiClient, type FullNote } from "$lib/client" // Props export let note: FullNote @@ -138,7 +138,7 @@ {editableTitle.length}/{TITLE_MAX_LENGTH} characters
- Last updated: {new Date(note.noteUpdatedAt).toLocaleString()} + Last updated: {new Date(note.versionCreatedAt).toLocaleString()} {#if !isEditing} • Version: {note.versionNumber - 1} @@ -149,7 +149,7 @@ {note.title || "Untitled Note"}
- Last updated: {new Date(note.noteUpdatedAt).toLocaleString()} + Last updated: {new Date(note.versionCreatedAt).toLocaleString()} {#if !isEditing} • Version: {note.versionNumber - 1} diff --git a/web/src/lib/components/NoteView.svelte b/web/src/lib/components/NoteView.svelte index fc8e301..be13f08 100644 --- a/web/src/lib/components/NoteView.svelte +++ b/web/src/lib/components/NoteView.svelte @@ -8,19 +8,22 @@ import { apiClient, currentUser, - // isPending, currentFullNote, availableNotes, - cError + versionHistory, + cError, + type FullNote } from "$lib/client" import ToggleSidebar from "$lib/icons/ToggleSidebar.svelte" + import VersionArrow from "$lib/icons/VersionArrow.svelte" // Props export let isLoading = false // State - let sidebarOpen = false + let sidebarOpen = window.innerWidth > 768 let showSettings = false + let showVersionsDropdown = false let isEditing = false onMount(async (): Promise => { @@ -66,11 +69,14 @@ const notes = await apiClient.listNotes() if (notes) { - console.log(notes) availableNotes.set(notes) } } + const toggleVersionDropdown = () => { + showVersionsDropdown = !showVersionsDropdown + } + const toggleSidebar = () => { sidebarOpen = !sidebarOpen } @@ -94,24 +100,58 @@ // (we need to find the ID from the updated `availableNotes`) if ($availableNotes && $availableNotes.length > 0) { const latestNote = $availableNotes[0] // Assuming the latest note is first - await selectNote(latestNote.id) + await selectNote(latestNote.id, true) } isEditing = true // Open brand new notes in edit mode by default } } - const selectNote = async (noteId: string) => { - const note = await apiClient.getFullNote(noteId) + const isLatestVersion = (note: FullNote | null) => { + if (!note) { + return true + } + + return note.isActiveVersion + } + + const selectNote = async (noteID: string, fetchRemote: boolean) => { + const note = await apiClient.getActiveFullNote(noteID, fetchRemote) if (note) { currentFullNote.set(note) isEditing = false } + + const history = await apiClient.getNoteHistory(noteID, fetchRemote) + + if (history) { + versionHistory.set(history) + } + + // MOBILE: Close sidebar when selecting a note from the list + if (window.innerWidth < 768) { + closeSidebar() + } + } + + const selectVersion = async (versionID: string) => { + if ($currentFullNote) { + const note = await apiClient.getFullVersion($currentFullNote.id, versionID) + + if (note) { + currentFullNote.set(note) + showVersionsDropdown = false + } + } } const toggleEditMode = () => { - isEditing = !isEditing + if (isLatestVersion($currentFullNote)) { + isEditing = !isEditing + } else { + cError.set("Editing historical versions is prohibited.") + } } const saveNote = async (title: string, content: string) => { @@ -119,7 +159,7 @@ await apiClient.createVersion($currentFullNote.id, title, content) // Refresh the current note to get the latest version (+ assure that client and server are synced) - await selectNote($currentFullNote.id) + await selectNote($currentFullNote.id, true) // Refresh the notes list to update any changes (ordering based on `updatedAt` field) await loadNotes() @@ -174,9 +214,49 @@
{#if $currentFullNote} - + + {#if isLatestVersion($currentFullNote)} + + {:else} + + {/if} + + +
+ + + {#if showVersionsDropdown && $versionHistory && $versionHistory.length > 0} +
+ {#each $versionHistory as version} + + {/each} +
+ {/if} +
{/if}
diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index a1ec4eb..da1377e 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -13,7 +13,7 @@ export let toggleSettings: () => void export let logout: () => Promise export let createNewNote: () => Promise - export let selectNote: (noteId: string) => Promise + export let selectNote: (noteId: string, fetchRemote: boolean) => Promise const formatDate = (dateString: string | Date): string => { if (!dateString) { @@ -38,7 +38,7 @@ const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault() // Prevent page scroll on space - selectNote(noteID) + selectNote(noteID, true) // Sync with API due to pushing updates } } @@ -50,6 +50,7 @@ +