feat: frontend vers. support & improved in-memory caching logic

This commit is contained in:
ae 2025-04-19 17:51:02 +03:00
parent c730fd47c7
commit cae360fc0e
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
6 changed files with 263 additions and 37 deletions

View File

@ -262,6 +262,47 @@
@apply text-[var(--dark-text)]/60; @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 editor */
.note-title-input { .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)]; @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)];

View File

@ -26,6 +26,7 @@ export interface FullNote {
content: string content: string
versionNumber: number versionNumber: number
versionCreatedAt: Date versionCreatedAt: Date
isActiveVersion: boolean
noteCreatedAt: Date noteCreatedAt: Date
noteUpdatedAt: Date noteUpdatedAt: Date
} }
@ -60,23 +61,34 @@ interface NewNoteResponse {
content: string content: string
} }
interface VersionMetadataResponse { interface VersionMetadata {
versionID: string versionID: string
title: string title: string
versionNumber: number
isActive: boolean
createdAt: Date
} }
interface FullVersionResponse { interface ApiVersionMetadataResponse {
versionID: string version_id: string
title: string
version_number: number
created_at: string
}
interface ApiFullVersionResponse {
version_id: string
title: string title: string
content: string content: string
versionNumber: number version_number: number
createdAt: Date created_at: string
} }
// Some of these could just be local variables as they're not used globally // Some of these could just be local variables as they're not used globally
export const currentUser: Writable<User | null> = writable(null) export const currentUser: Writable<User | null> = writable(null)
export const currentFullNote: Writable<FullNote | null> = writable(null) export const currentFullNote: Writable<FullNote | null> = writable(null)
export const availableNotes: Writable<NoteMetadata[] | null> = writable(null) export const availableNotes: Writable<NoteMetadata[] | null> = writable(null)
export const versionHistory: Writable<VersionMetadata[] | null> = writable(null)
export const accessToken: Writable<string | null> = writable(null) export const accessToken: Writable<string | null> = writable(null)
export const csrfToken: Writable<string | null> = writable(null) export const csrfToken: Writable<string | null> = writable(null)
export const isPending: Writable<boolean> = writable(false) export const isPending: Writable<boolean> = writable(false)
@ -88,8 +100,10 @@ class ApiClient {
private lastAtUpdate = new Date(0) private lastAtUpdate = new Date(0)
private lastCsrfUpdate = new Date(0) private lastCsrfUpdate = new Date(0)
private refreshInProgress = false private refreshInProgress = false
private activeVersion = -1
private loadedNotesCache = new Map<string, FullNote>() private loadedNotesCache = new Map<string, FullNote>()
// TODO: private loadedVersionsCache = new Map<string, FullVersion>() private loadedHistoryCache = new Map<string, VersionMetadata[]>()
private loadedVersionsCache = new Map<string, FullNote>() // Key: noteID + versionID
constructor(baseUrl: string) { constructor(baseUrl: string) {
this.baseUrl = baseUrl this.baseUrl = baseUrl
@ -293,11 +307,50 @@ class ApiClient {
content: apiResponse.content, content: apiResponse.content,
versionNumber: apiResponse.version_number, versionNumber: apiResponse.version_number,
versionCreatedAt: new Date(apiResponse.version_created_at), versionCreatedAt: new Date(apiResponse.version_created_at),
isActiveVersion: true, // This endpoint serves only the latest version
noteCreatedAt: new Date(apiResponse.note_created_at), noteCreatedAt: new Date(apiResponse.note_created_at),
noteUpdatedAt: new Date(apiResponse.note_updated_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<void> { public async register(username: string, password: string): Promise<void> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
@ -541,15 +594,22 @@ class ApiClient {
) )
} }
public async getFullNote(noteID: string): Promise<FullNote | undefined> { public async getActiveFullNote(
noteID: string,
fetchRemote: boolean
): Promise<FullNote | undefined> {
if (!UUID_REGEX.test(noteID)) { if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
const cachedNote = this.loadedNotesCache.get(noteID) // Attempt cache lookup only if we didn't just push new updates
if (cachedNote != null) { if (!fetchRemote) {
console.log(`cache hit ${noteID}`) const cachedNote = this.loadedNotesCache.get(noteID)
return cachedNote if (cachedNote != null) {
// console.log(`full note cache hit ${noteID}`)
this.activeVersion = cachedNote.versionNumber
return cachedNote
}
} }
return this.handleRequest( return this.handleRequest(
@ -567,6 +627,7 @@ class ApiClient {
console.log(`caching ${noteID}`) console.log(`caching ${noteID}`)
this.loadedNotesCache.set(noteID, note) this.loadedNotesCache.set(noteID, note)
this.activeVersion = note.versionNumber
return note return note
}, },
@ -599,11 +660,22 @@ class ApiClient {
) )
} }
public async getNoteHistory(noteID: string): Promise<VersionMetadataResponse[] | undefined> { public async getNoteHistory(
noteID: string,
fetchRemote: boolean
): Promise<VersionMetadata[] | undefined> {
if (!UUID_REGEX.test(noteID)) { if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.") 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( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, { const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, {
@ -612,10 +684,13 @@ class ApiClient {
} }
}) })
const versions = await this.handleResponse<VersionMetadataResponse[]>(response, { const data = await this.handleResponse<ApiVersionMetadataResponse[]>(response, {
useBearerAuth: false 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 return versions
}, },
@ -652,10 +727,7 @@ class ApiClient {
) )
} }
public async getFullVersion( public async getFullVersion(noteID: string, versionID: string): Promise<FullNote | undefined> {
noteID: string,
versionID: string
): Promise<FullVersionResponse | undefined> {
if (!UUID_REGEX.test(noteID)) { if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
@ -664,6 +736,18 @@ class ApiClient {
throw new Error("Invalid version ID format.") 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( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/${versionID}`, { const response = await fetch(`${this.baseUrl}/notes/${noteID}/${versionID}`, {
@ -671,7 +755,20 @@ class ApiClient {
...this.getAuthHeader() ...this.getAuthHeader()
} }
}) })
return await this.handleResponse<FullVersionResponse>(response, { useBearerAuth: false })
const data = await this.handleResponse<ApiFullVersionResponse>(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 } { useBearerAuth: true }
) )

View File

@ -2,7 +2,7 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import { marked } from "marked" import { marked } from "marked"
import { TITLE_MAX_LENGTH } from "$lib/const" import { TITLE_MAX_LENGTH } from "$lib/const"
import type { FullNote } from "$lib/client" import { apiClient, type FullNote } from "$lib/client"
// Props // Props
export let note: FullNote export let note: FullNote
@ -138,7 +138,7 @@
{editableTitle.length}/{TITLE_MAX_LENGTH} characters {editableTitle.length}/{TITLE_MAX_LENGTH} characters
</div> </div>
<div class="note-char-count ml-3"> <div class="note-char-count ml-3">
Last updated: {new Date(note.noteUpdatedAt).toLocaleString()} Last updated: {new Date(note.versionCreatedAt).toLocaleString()}
{#if !isEditing} {#if !isEditing}
<!-- Minus 1 due to versioning beginning at 2 in the DB --> <!-- Minus 1 due to versioning beginning at 2 in the DB -->
• Version: {note.versionNumber - 1} • Version: {note.versionNumber - 1}
@ -149,7 +149,7 @@
{note.title || "Untitled Note"} {note.title || "Untitled Note"}
</h1> </h1>
<div class="note-char-count"> <div class="note-char-count">
Last updated: {new Date(note.noteUpdatedAt).toLocaleString()} Last updated: {new Date(note.versionCreatedAt).toLocaleString()}
{#if !isEditing} {#if !isEditing}
<!-- Minus 1 due to versioning beginning at 2 in the DB --> <!-- Minus 1 due to versioning beginning at 2 in the DB -->
• Version: {note.versionNumber - 1} • Version: {note.versionNumber - 1}

View File

@ -8,19 +8,22 @@
import { import {
apiClient, apiClient,
currentUser, currentUser,
// isPending,
currentFullNote, currentFullNote,
availableNotes, availableNotes,
cError versionHistory,
cError,
type FullNote
} from "$lib/client" } from "$lib/client"
import ToggleSidebar from "$lib/icons/ToggleSidebar.svelte" import ToggleSidebar from "$lib/icons/ToggleSidebar.svelte"
import VersionArrow from "$lib/icons/VersionArrow.svelte"
// Props // Props
export let isLoading = false export let isLoading = false
// State // State
let sidebarOpen = false let sidebarOpen = window.innerWidth > 768
let showSettings = false let showSettings = false
let showVersionsDropdown = false
let isEditing = false let isEditing = false
onMount(async (): Promise<any> => { onMount(async (): Promise<any> => {
@ -66,11 +69,14 @@
const notes = await apiClient.listNotes() const notes = await apiClient.listNotes()
if (notes) { if (notes) {
console.log(notes)
availableNotes.set(notes) availableNotes.set(notes)
} }
} }
const toggleVersionDropdown = () => {
showVersionsDropdown = !showVersionsDropdown
}
const toggleSidebar = () => { const toggleSidebar = () => {
sidebarOpen = !sidebarOpen sidebarOpen = !sidebarOpen
} }
@ -94,24 +100,58 @@
// (we need to find the ID from the updated `availableNotes`) // (we need to find the ID from the updated `availableNotes`)
if ($availableNotes && $availableNotes.length > 0) { if ($availableNotes && $availableNotes.length > 0) {
const latestNote = $availableNotes[0] // Assuming the latest note is first 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 isEditing = true // Open brand new notes in edit mode by default
} }
} }
const selectNote = async (noteId: string) => { const isLatestVersion = (note: FullNote | null) => {
const note = await apiClient.getFullNote(noteId) if (!note) {
return true
}
return note.isActiveVersion
}
const selectNote = async (noteID: string, fetchRemote: boolean) => {
const note = await apiClient.getActiveFullNote(noteID, fetchRemote)
if (note) { if (note) {
currentFullNote.set(note) currentFullNote.set(note)
isEditing = false 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 = () => { const toggleEditMode = () => {
isEditing = !isEditing if (isLatestVersion($currentFullNote)) {
isEditing = !isEditing
} else {
cError.set("Editing historical versions is prohibited.")
}
} }
const saveNote = async (title: string, content: string) => { const saveNote = async (title: string, content: string) => {
@ -119,7 +159,7 @@
await apiClient.createVersion($currentFullNote.id, title, content) await apiClient.createVersion($currentFullNote.id, title, content)
// Refresh the current note to get the latest version (+ assure that client and server are synced) // 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) // Refresh the notes list to update any changes (ordering based on `updatedAt` field)
await loadNotes() await loadNotes()
@ -174,9 +214,49 @@
<div class="flex items-center space-x-4 pl-2"> <div class="flex items-center space-x-4 pl-2">
{#if $currentFullNote} {#if $currentFullNote}
<button on:click={toggleEditMode} class="btn-primary rounded-full"> <!-- Note edit/preview button (only shown for latest version) -->
{isEditing ? "Preview" : "Edit"} {#if isLatestVersion($currentFullNote)}
</button> <button on:click={toggleEditMode} class="btn-primary rounded-full">
{isEditing ? "Preview" : "Edit"}
</button>
{:else}
<button disabled class="btn-primary cursor-not-allowed rounded-full opacity-40">
Edit
</button>
{/if}
<!-- Version history dropdown menu -->
<div class="relative">
<button
on:click={toggleVersionDropdown}
class="btn-secondary flex items-center rounded-full"
>
<span>Versions</span>
<VersionArrow />
</button>
{#if showVersionsDropdown && $versionHistory && $versionHistory.length > 0}
<div class="versions-dropdown">
{#each $versionHistory as version}
<button
on:click={() => selectVersion(version.versionID)}
class="versions-dropdown-item {$currentFullNote.versionNumber ===
version.versionNumber
? 'versions-dropdown-item-active'
: ''}"
>
<p class="versions-dropdown-item-text truncate font-bold">{version.title}</p>
<span class="versions-dropdown-item-text"
>{version.createdAt.toLocaleString()}</span
>
{#if version.isActive}<span class="versions-dropdown-item-text ml-2 opacity-70"
>(active)</span
>{/if}
</button>
{/each}
</div>
{/if}
</div>
{/if} {/if}
</div> </div>

View File

@ -13,7 +13,7 @@
export let toggleSettings: () => void export let toggleSettings: () => void
export let logout: () => Promise<void> export let logout: () => Promise<void>
export let createNewNote: () => Promise<void> export let createNewNote: () => Promise<void>
export let selectNote: (noteId: string) => Promise<void> export let selectNote: (noteId: string, fetchRemote: boolean) => Promise<void>
const formatDate = (dateString: string | Date): string => { const formatDate = (dateString: string | Date): string => {
if (!dateString) { if (!dateString) {
@ -38,7 +38,7 @@
const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => { const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault() // Prevent page scroll on space event.preventDefault() // Prevent page scroll on space
selectNote(noteID) selectNote(noteID, true) // Sync with API due to pushing updates
} }
} }
@ -50,6 +50,7 @@
</script> </script>
<!-- TODO: add admin modal button (+ component similar to the settings modal) if the user is an admin --> <!-- TODO: add admin modal button (+ component similar to the settings modal) if the user is an admin -->
<!-- TODO: component-level paging support (via the implementation in `pages.ts`) -->
<aside <aside
class="sidebar z-10" class="sidebar z-10"
@ -96,7 +97,7 @@
<li <li
class="sidebar-item" class="sidebar-item"
class:sidebar-item-active={currentNote && note.id === currentNote.id} class:sidebar-item-active={currentNote && note.id === currentNote.id}
on:click={() => selectNote(note.id)} on:click={() => selectNote(note.id, false)}
on:keydown={(e) => handleNoteKeydown(e, note.id)} on:keydown={(e) => handleNoteKeydown(e, note.id)}
tabindex="0" tabindex="0"
role="option" role="option"

View File

@ -0,0 +1,7 @@
<svg class="ml-0.5 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 252 B