feat: UI/UX & logic upgrades

- fix: markdown preview (especially lists)
- feat: markdown codeblock syntax highlighting
- feat: UI style/layout (CSS, favicons)
- feat: noscript tag
- refactor: component organization (dir. structure)
- feat: improved client caching logic
- fix: focus-none on button click
This commit is contained in:
ae 2025-05-01 13:57:27 +03:00
parent 7bbe81b063
commit 09a4a74c42
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
47 changed files with 1848 additions and 1100 deletions

22
web/package-lock.json generated
View File

@ -8,7 +8,9 @@
"name": "qnote", "name": "qnote",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"marked": "^15.0.7" "highlight.js": "^11.11.1",
"marked": "^15.0.7",
"marked-highlight": "^2.2.1"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
@ -1552,6 +1554,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/import-meta-resolve": { "node_modules/import-meta-resolve": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
@ -1882,6 +1893,15 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/marked-highlight": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.1.tgz",
"integrity": "sha512-SiCIeEiQbs9TxGwle9/OwbOejHCZsohQRaNTY2u8euEXYt2rYUFoiImUirThU3Gd/o6Q1gHGtH9qloHlbJpNIA==",
"license": "MIT",
"peerDependencies": {
"marked": ">=4 <16"
}
},
"node_modules/mini-svg-data-uri": { "node_modules/mini-svg-data-uri": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",

View File

@ -32,6 +32,8 @@
"vite": "^6.0.0" "vite": "^6.0.0"
}, },
"dependencies": { "dependencies": {
"marked": "^15.0.7" "highlight.js": "^11.11.1",
"marked": "^15.0.7",
"marked-highlight": "^2.2.1"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@
localStorage.setItem("darkMode", darkMode) localStorage.setItem("darkMode", darkMode)
</script> </script>
</head> </head>
<noscript><h2>You'll need to enable JavaScript to use this app.</h2></noscript>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>

View File

@ -1,24 +1,39 @@
<script lang="ts"> <script lang="ts">
import { cError } from "$lib/client" import { cError } from "$lib/logic/client"
import { isPasswordValid } from "$lib/utils"
import { onMount } from "svelte" import { onMount } from "svelte"
import ThemeToggle from "./ThemeToggle.svelte" import ThemeToggle from "./ThemeToggle.svelte"
import { isPasswordValid, isUsernameValid } from "$lib/util/authValidation"
export let formName: string export let formName: string
export let handler: (username: string, password: string) => Promise<void> export let handler: (username: string, password: string) => Promise<void | null>
export let bottomText: string export let bottomText: string
export let bottomLink: string export let bottomLink: string
let username = "" let username = ""
let password = "" let password = ""
let usernameError = ""
let passwordError = "" let passwordError = ""
let isFormValid = false let isFormValid = false
// Clear any errors when swapping between login/signup views // TODO (optional feature):
onMount(() => cError.set(null)) // during `onMount`, check whether user is *already authenticated* (redirect straight to `/` if yes)
// clear any errors when swapping between login/signup views
onMount(() => ($cError = null))
const validateUsername = () => {
if (formName === "Login") {
// skip if logging into existing account
passwordError = ""
isFormValid = true
return
}
;[isFormValid, usernameError] = isUsernameValid(username)
}
const validatePassword = () => { const validatePassword = () => {
if (formName === "Login") { if (formName === "Login") {
// Skip if logging into existing account // skip if logging into existing account
passwordError = "" passwordError = ""
isFormValid = true isFormValid = true
return return
@ -27,17 +42,22 @@
;[isFormValid, passwordError] = isPasswordValid(password) ;[isFormValid, passwordError] = isPasswordValid(password)
} }
// Update validation on password change (reactive dependency) // reactive dependencies for monitoring changes to username and password
$: {
username
validateUsername()
}
$: { $: {
password password
validatePassword() validatePassword()
} }
const handleSubmit = async () => { const handleSubmit = async () => {
cError.set(null) $cError = null
if (formName === "Register" && !isFormValid) { if (formName === "Register" && !isFormValid) {
cError.set(passwordError) $cError = usernameError !== "" ? usernameError : passwordError
return return
} }
@ -46,7 +66,7 @@
</script> </script>
<div class="flex min-h-screen items-center justify-center"> <div class="flex min-h-screen items-center justify-center">
<div class="card rounded-4x1 grid w-[16rem] justify-items-center space-y-6 p-6"> <div class="auth-card rounded-4x1 grid w-[16rem] justify-items-center space-y-6 p-6">
{#if $cError} {#if $cError}
<div class="error max-w-full overflow-hidden text-ellipsis text-center"> <div class="error max-w-full overflow-hidden text-ellipsis text-center">
{$cError} {$cError}
@ -62,7 +82,7 @@
placeholder="Username" placeholder="Username"
required required
autocomplete="username" autocomplete="username"
class="w-full" class="auth-input-field"
/> />
</div> </div>
@ -74,11 +94,11 @@
placeholder="Password" placeholder="Password"
required required
autocomplete={formName === "Login" ? "current-password" : "new-password"} autocomplete={formName === "Login" ? "current-password" : "new-password"}
class="w-full" class="auth-input-field"
/> />
</div> </div>
<button type="submit" class="btn-primary w-full rounded-lg"> <button type="submit" class="auth-button">
{formName} {formName}
</button> </button>
</form> </form>
@ -90,7 +110,7 @@
</div> </div>
</div> </div>
<!-- TODO: add footer with a random quote (font-copernicus) --> <!-- TODO: add footer with a randomly picked quote (font-copernicus) -->
<div class="absolute right-4 top-4"> <div class="absolute right-4 top-4">
<ThemeToggle /> <ThemeToggle />

View File

@ -1,19 +1,44 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte" import { onMount } from "svelte"
import { marked } from "marked" import { Marked } from "marked"
import { TITLE_MAX_LENGTH } from "$lib/const" import { markedHighlight } from "marked-highlight"
import { apiClient, type FullNote } from "$lib/client" import hljs from "highlight.js"
import { cError, versionHistory } from "$lib/logic/client"
import ChevronDown from "$lib/icons/ChevronDown.svelte"
import ChevronUp from "$lib/icons/ChevronUp.svelte"
import EditPen from "$lib/icons/editor/EditPen.svelte"
import ViewEye from "$lib/icons/editor/ViewEye.svelte"
import Save from "$lib/icons/editor/Save.svelte"
import type { FullNote } from "$lib/logic/model"
// Props // props
export let note: FullNote export let note: FullNote
export let isEditing = false export let isEditing = false
export let isVersionDropdownOpen = false
export let saveNote: (title: string, content: string) => Promise<void> export let saveNote: (title: string, content: string) => Promise<void>
export let selectVersion: (versionID: string, isActiveVersion: boolean) => Promise<void>
// Local copy for editing (to prevent uploading every single keypress as unique version) // constants
const TITLE_MAX_LENGTH = 150
// local state
let editableTitle = note.title let editableTitle = note.title
let editableContent = note.content let editableContent = note.content
// Update the local copy when the note changes // markdown parser with syntax highlight support
const marked = new Marked(
markedHighlight({
emptyLangClass: "hljs",
langPrefix: "hljs language-",
highlight(code, lang, info) {
const language = hljs.getLanguage(lang) ? lang : "plaintext"
// console.log(`syntax highlight language: ${language}`)
return hljs.highlight(code, { language }).value
}
})
)
// update the local copy when the note changes
$: if (note && note.id) { $: if (note && note.id) {
editableTitle = note.title editableTitle = note.title
editableContent = note.content editableContent = note.content
@ -29,12 +54,12 @@
const { value } = event.target as HTMLTextAreaElement const { value } = event.target as HTMLTextAreaElement
editableContent = value editableContent = value
// Update title based on the first line if it starts with # // update title based on the first line if it starts with #
const firstLine = editableContent.split("\n")[0] const firstLine = editableContent.split("\n")[0]
if (firstLine && firstLine.startsWith("# ")) { if (firstLine && firstLine.startsWith("# ")) {
editableTitle = firstLine.substring(2).slice(0, TITLE_MAX_LENGTH) editableTitle = firstLine.substring(2).slice(0, TITLE_MAX_LENGTH)
} else if (!editableTitle || editableTitle === "Untitled Note") { } else if (!editableTitle || editableTitle === "Untitled Note") {
// Try to extract a title from the first non-empty line if there's no title yet // try to extract a title from the first non-empty line if there's no title yet
const firstNonEmptyLine = editableContent.split("\n").find((line) => line.trim().length > 0) const firstNonEmptyLine = editableContent.split("\n").find((line) => line.trim().length > 0)
if (firstNonEmptyLine) { if (firstNonEmptyLine) {
@ -46,17 +71,38 @@
} }
} }
const isLatestVersion = (note: FullNote | null) => {
if (!note) {
return true
}
return note.isActiveVersion
}
const toggleEditMode = () => {
if (isLatestVersion(note)) {
isEditing = !isEditing
isVersionDropdownOpen = false
} else {
$cError = "Editing historical versions is prohibited."
}
}
const exitEditMode = () => { const exitEditMode = () => {
isEditing = false isEditing = false
} }
const toggleVersionDropdown = () => {
isVersionDropdownOpen = !isVersionDropdownOpen
}
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
// Ctrl+Enter or Command+Enter keyboard shortcut for saving the current note // ctrl+enter or cmd+enter keyboard shortcut for saving the current note
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault() event.preventDefault()
handleSave() handleSave()
} }
// Esc keyboard shortcut for quitting edit mode // esc keyboard shortcut for quitting edit mode
else if (event.key === "Escape") { else if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
exitEditMode() exitEditMode()
@ -81,24 +127,17 @@
return "" return ""
} }
// Enable Github flavored markdown rendering // github flavored markdown rendering
marked.setOptions({ marked.setOptions({
breaks: false,
gfm: true, gfm: true,
breaks: true pedantic: false
}) })
let html = await marked(markdown) // remove the most common zerowidth characters from the start of the file
marked.parse(markdown.replace(/^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/, ""))
// Add spans to regular list items return await marked.parse(markdown)
html = html.replaceAll(/\<li\>([^\<].*)\<\/li\>/g, '<li><span class="list-text">$1</span></li>')
// Add spans to nested ordered/unordered lists
html = html.replaceAll(
/<li>([^<]+)(?=.*?<(?:ul|ol)[^>]*>)/g,
'<li><span class="list-text">$1</span></li>'
)
return html
} }
let textarea: HTMLTextAreaElement | null let textarea: HTMLTextAreaElement | null
@ -111,10 +150,13 @@
onMount(() => { onMount(() => {
if (isEditing && textarea) { if (isEditing && textarea) {
// Scrollbar is hidden in global CSS so flickering during resizing of the // scrollbar is hidden in global CSS so flickering during resizing of the
// textarea shouldn't be an issue anymore // textarea shouldn't be an issue anymore
autoResize() autoResize()
} }
// close the versioning dropdown menu if it has been left open in the previously viewed notes
isVersionDropdownOpen = false
}) })
$: if (isEditing && textarea) { $: if (isEditing && textarea) {
@ -134,28 +176,76 @@
placeholder="Title" placeholder="Title"
class="note-title-input" class="note-title-input"
/> />
<div class="note-char-count ml-3"> <div class="note-char-count ml-2">
{editableTitle.length}/{TITLE_MAX_LENGTH} characters {editableTitle.length}/{TITLE_MAX_LENGTH} characters
</div> </div>
<div class="note-char-count ml-3">
Last updated: {new Date(note.versionCreatedAt).toLocaleString()}
{#if !isEditing}
<!-- Minus 1 due to versioning beginning at 2 in the DB -->
• Version: {note.versionNumber - 1}
{/if}
</div>
{:else} {:else}
<h1 class="border-[var(--light-text)]/20 border-b pb-2 text-2xl font-bold"> <h1 class="note-title-display">
{note.title || "Untitled Note"} {note.title || "Untitled Note"}
</h1> </h1>
<div class="note-char-count"> {/if}
Last updated: {new Date(note.versionCreatedAt).toLocaleString()}
{#if !isEditing} <!-- TODO: add pagination support for versions dropdown -->
<!-- Minus 1 due to versioning beginning at 2 in the DB -->
• Version: {note.versionNumber - 1} <!-- Note action buttons -->
<div class="note-action-container">
<!-- Editor mode toggle button -->
{#if isLatestVersion(note)}
<button on:click={toggleEditMode} class="note-action-button w-28">
{#if isEditing}
<EditPen classString="mr-3 h-4 w-4" />
{:else}
<ViewEye classString="mr-3 h-4 w-4" />
{/if}
{isEditing ? "Editing" : "Viewing"}
</button>
{:else}
<button disabled class="note-action-button w-28 cursor-not-allowed opacity-40"
>Viewing</button
>
{/if}
<!-- Versioning dropdown -->
<div class="relative flex flex-col">
<button on:click={toggleVersionDropdown} class="note-action-button min-w-32">
Version {note.versionNumber - 1}
{#if isVersionDropdownOpen}
<ChevronUp classString="ml-1.5 h-4 w-4" />
{:else}
<ChevronDown classString="ml-1.5 h-4 w-4" />
{/if}
</button>
{#if isVersionDropdownOpen && $versionHistory && $versionHistory.length > 0}
<div class="note-versions-dropdown">
{#each $versionHistory as version, index}
<button
on:click={() => selectVersion(version.versionID, version.isActive)}
class="sidebar-action-button w-full flex-col rounded-none {index === 0
? 'rounded-t-lg'
: ''} {index === $versionHistory.length - 1 ? 'rounded-b-lg' : ''}
{version.isActive ? 'versions-dropdown-active-version' : ''}"
>
<p class="versions-dropdown-item-text">{version.title}</p>
<span class="versions-dropdown-item-meta"
>{version.createdAt.toLocaleString()}, v.{version.versionNumber}</span
>
</button>
{/each}
</div>
{/if} {/if}
</div> </div>
{/if}
{#if isEditing}
<button
on:click={() => saveNote(editableTitle, editableContent)}
class="note-action-icon-button"
>
<Save classString="h-5 w-5" />
</button>
{/if}
</div>
</div> </div>
<!-- Note content (takes up remaining vertical space) --> <!-- Note content (takes up remaining vertical space) -->
@ -170,20 +260,21 @@
placeholder="Markdown contents" placeholder="Markdown contents"
class="note-textarea" class="note-textarea"
></textarea> ></textarea>
<button
on:click={() => saveNote(editableTitle, editableContent)}
class="note-mobile-save-button"
>
<Save classString="h-6 w-6" />
</button>
</div> </div>
{:else} {:else}
<!-- Rendered markdown preview --> <!-- Rendered markdown preview -->
<div class="prose markdown-preview max-w-none p-4"> <div class="prose markdown-preview max-w-none">
{#await parseMarkdown(note.content) then html} {#await parseMarkdown(note.content) then html}
{@html html} {@html html}
{/await} {/await}
</div> </div>
{/if} {/if}
</div> </div>
{#if isEditing}
<div class="note-save-button">
<button on:click={handleSave} class="btn-primary rounded-full"> Save </button>
</div>
{/if}
</div> </div>

View File

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte" import { onDestroy, onMount } from "svelte"
import { goto } from "$app/navigation" import { goto } from "$app/navigation"
import ThemeToggle from "./ThemeToggle.svelte"
import Sidebar from "./Sidebar.svelte" import Sidebar from "./Sidebar.svelte"
import NoteEditor from "./NoteEditor.svelte" import NoteEditor from "./NoteEditor.svelte"
import SettingsModal from "./SettingsModal.svelte" import SettingsModal from "./SettingsModal.svelte"
@ -12,31 +11,33 @@
availableNotes, availableNotes,
versionHistory, versionHistory,
cError, cError,
type FullNote,
cSuccess cSuccess
} from "$lib/client" } from "$lib/logic/client"
import ToggleSidebar from "$lib/icons/ToggleSidebar.svelte"
import VersionArrow from "$lib/icons/VersionArrow.svelte"
import Close from "$lib/icons/Close.svelte" import Close from "$lib/icons/Close.svelte"
import { ERR_NOTIFICATION_DUR, SUC_NOTIFICATION_DUR } from "$lib/const"
import { generateGreeting } from "$lib/util/greetMessage" import { generateGreeting } from "$lib/util/greetMessage"
import { get } from "svelte/store" import { hashContent } from "$lib/util/contentValidation"
// State // error/success notification display durations
const ERR_NOTIFICATION_DUR = 8 * 1000 // 8 s.
const SUC_NOTIFICATION_DUR = 8 * 1000 // 8 s.
// local state
let isComponentReady = false let isComponentReady = false
let sidebarOpen = window.innerWidth > 768
let showSettings = false let showSettings = false
let showVersionsDropdown = false let isSidebarOpen = window.innerWidth > 768
let isVersionDropdownOpen = false
let isEditing = false let isEditing = false
let errorTimeout: ReturnType<typeof setTimeout> | null = null let errorTimeout: ReturnType<typeof setTimeout> | null = null
let successTimeout: ReturnType<typeof setTimeout> | null = null let successTimeout: ReturnType<typeof setTimeout> | null = null
let username = "friend"
let greetMessage = "What's up?" let greetMessage = "What's up?"
let currentContentHash = ""
onMount(async (): Promise<any> => { onMount(async (): Promise<any> => {
// The following fetch attempts to refresh any expired tokens automatically // the following fetch attempts to refresh any expired tokens automatically
await apiClient.getCurrentUser() await apiClient.getCurrentUser()
// If still no current user after the fetch attempt, redirect to login // if still no current user after the fetch attempt, redirect to login
if (!$currentUser) { if (!$currentUser) {
console.log("[VIEW] No user data found, routing to login page") console.log("[VIEW] No user data found, routing to login page")
goto("/login") goto("/login")
@ -44,19 +45,22 @@
} }
await loadNotes() await loadNotes()
const cUser = get(currentUser) username = $currentUser.username
greetMessage = generateGreeting(cUser ? cUser.username : "friend") greetMessage = generateGreeting(
// capitalization for the message
String(username).charAt(0).toUpperCase() + String(username).slice(1)
)
// Default to sidebar closed on mobile // default to sidebar being initially closed on mobile
const handleResize = () => { const handleResize = () => {
if (window.innerWidth < 768 && sidebarOpen) { if (window.innerWidth < 768 && isSidebarOpen) {
sidebarOpen = false isSidebarOpen = false
} }
} }
handleResize() handleResize()
// Keep listening to browser's resize events // keep listening to browser's resize events
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
isComponentReady = true isComponentReady = true
@ -74,95 +78,102 @@
} }
} }
const toggleVersionDropdown = () => { const toggleSettingsModal = () => {
showVersionsDropdown = !showVersionsDropdown
}
const toggleSidebar = () => {
sidebarOpen = !sidebarOpen
}
const closeSidebar = () => {
sidebarOpen = false
}
const toggleSettings = () => {
showSettings = !showSettings showSettings = !showSettings
} }
const toggleAdminModal = () => {
console.log("[DBG] Admin view is not implemented yet")
}
const toggleWebhookModal = () => {
console.log("[DBG] Webhooks aren't implemented yet")
}
const toggleTagModal = () => {
console.log("[DBG] Tags aren't implemented yet")
}
const createNewNote = async () => { const createNewNote = async () => {
const newNote = await apiClient.createNote() const newNote = await apiClient.createNote()
if (newNote) { if (newNote) {
// Refresh notes list // refresh notes list
await loadNotes() await loadNotes()
// Get the full note details of the newly created note // get the full note details of the newly created note
// (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, true) await selectNote(latestNote.id, true, false)
} }
isEditing = true // Open brand new notes in edit mode by default isEditing = true // open brand new notes in edit mode by default
} }
} }
const isLatestVersion = (note: FullNote | null) => { const selectNote = async (noteID: string, fetchRemote: boolean, clearSelection: boolean) => {
if (!note) { if (clearSelection) {
return true currentFullNote.set(null)
return
} }
return note.isActiveVersion
}
const selectNote = async (noteID: string, fetchRemote: boolean) => {
const note = await apiClient.getActiveFullNote(noteID, fetchRemote) const note = await apiClient.getActiveFullNote(noteID, fetchRemote)
if (note) { if (!note) {
currentFullNote.set(note) return
isEditing = false
} }
currentFullNote.set(note)
isEditing = false
currentContentHash = await hashContent(note.title + note.content)
console.log(`[VER] Current content hash: ${currentContentHash}`)
const history = await apiClient.getNoteHistory(noteID, fetchRemote) const history = await apiClient.getNoteHistory(noteID, fetchRemote)
if (history) { if (history) {
versionHistory.set(history) versionHistory.set(history)
} }
// MOBILE: Close sidebar when selecting a note from the list // close sidebar on mobile as it takes the whole screen width when open
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
closeSidebar() isSidebarOpen = false
} }
} }
const selectVersion = async (versionID: string) => { const selectVersion = async (versionID: string, isActiveVersion: boolean) => {
if ($currentFullNote) { if ($currentFullNote) {
const note = await apiClient.getFullVersion($currentFullNote.id, versionID) const note = await apiClient.getFullVersion($currentFullNote.id, versionID, isActiveVersion)
if (note) { if (note) {
currentFullNote.set(note) currentFullNote.set(note)
showVersionsDropdown = false isVersionDropdownOpen = false
} else {
$cError = "Version could not be fetched."
} }
} }
} }
const toggleEditMode = () => {
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) => {
if ($currentFullNote) { if ($currentFullNote) {
const newContentHash = await hashContent(title + content)
if (currentContentHash === newContentHash) {
console.log(`[VER] Content unchanged for ${$currentFullNote.id}, aborting request`)
$cSuccess = "No changes detected. Save skipped."
isEditing = false
return
}
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, true) await selectNote($currentFullNote.id, true, false)
// 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()
isEditing = false isEditing = false
@ -172,15 +183,15 @@
const deleteNote = async (noteID: string) => { const deleteNote = async (noteID: string) => {
await apiClient.deleteNote(noteID) await apiClient.deleteNote(noteID)
// If we're deleting the currently active note, clear the current note // if we're deleting the currently active note, clear the current note
if ($currentFullNote && $currentFullNote.id === noteID) { if ($currentFullNote && $currentFullNote.id === noteID) {
currentFullNote.set(null) currentFullNote.set(null)
} }
// Refresh the notes list due to updates being pushed to server // refresh the notes list due to updates being pushed to server
await loadNotes() await loadNotes()
cSuccess.set("Note deleted successfully.") $cSuccess = "Note deleted successfully."
} }
const logout = async () => { const logout = async () => {
@ -188,7 +199,7 @@
} }
const errorUnsubscribe = cError.subscribe((value) => { const errorUnsubscribe = cError.subscribe((value) => {
// Clear any existing timeout to avoid multiple timeouts // clear any existing timeout to avoid multiple timeouts
if (errorTimeout) { if (errorTimeout) {
clearTimeout(errorTimeout) clearTimeout(errorTimeout)
errorTimeout = null errorTimeout = null
@ -196,13 +207,13 @@
if (value) { if (value) {
errorTimeout = setTimeout(() => { errorTimeout = setTimeout(() => {
cError.set(null) $cError = null
}, ERR_NOTIFICATION_DUR) }, ERR_NOTIFICATION_DUR)
} }
}) })
const successUnsubscribe = cSuccess.subscribe((value) => { const successUnsubscribe = cSuccess.subscribe((value) => {
// Clear any existing timeout to avoid multiple timeouts // clear any existing timeout to avoid multiple timeouts
if (successTimeout) { if (successTimeout) {
clearTimeout(successTimeout) clearTimeout(successTimeout)
successTimeout = null successTimeout = null
@ -210,7 +221,7 @@
if (value) { if (value) {
successTimeout = setTimeout(() => { successTimeout = setTimeout(() => {
cSuccess.set(null) $cSuccess = null
}, SUC_NOTIFICATION_DUR) }, SUC_NOTIFICATION_DUR)
} }
}) })
@ -219,7 +230,7 @@
errorUnsubscribe() errorUnsubscribe()
successUnsubscribe() successUnsubscribe()
// Clear any pending timeouts // clear any pending timeouts
if (errorTimeout) { if (errorTimeout) {
clearTimeout(errorTimeout) clearTimeout(errorTimeout)
} }
@ -237,7 +248,7 @@
<div class="main-info-popup"> <div class="main-info-popup">
<div class="error"> <div class="error">
{$cError} {$cError}
<button class="main-info-popup-button" on:click={() => cError.set(null)}> <button class="main-info-popup-button" on:click={() => ($cError = null)}>
<Close /> <Close />
</button> </button>
</div> </div>
@ -249,100 +260,42 @@
<div class="main-info-popup"> <div class="main-info-popup">
<div class="success"> <div class="success">
{$cSuccess} {$cSuccess}
<button class="main-info-popup-button" on:click={() => cSuccess.set(null)}> <button class="main-info-popup-button" on:click={() => ($cSuccess = null)}>
<Close /> <Close />
</button> </button>
</div> </div>
</div> </div>
{/if} {/if}
<!-- Sidebar (2-way binding prop allows changing the same var. in either component) --> <!-- Sidebar (2-way binded state) -->
<Sidebar <Sidebar
bind:sidebarOpen bind:isSidebarOpen
{username}
notes={$availableNotes || []} notes={$availableNotes || []}
currentNote={$currentFullNote} currentNote={$currentFullNote}
{toggleSettings} {toggleSettingsModal}
{toggleAdminModal}
{toggleWebhookModal}
{toggleTagModal}
{logout} {logout}
{createNewNote} {createNewNote}
{selectNote} {selectNote}
{deleteNote} {deleteNote}
on:close={closeSidebar}
/> />
<!-- Main content area --> <!-- Main content area -->
<div <div class="content-wrapper ml-0 transition-all duration-300">
class="content-wrapper transition-all duration-300"
class:md:-ml-64={!sidebarOpen}
class:md:ml-0={sidebarOpen}
>
<!-- Top navbar -->
<header class="main-header">
<button
on:click={toggleSidebar}
class="btn-secondary rounded-lg p-2"
aria-label="Toggle sidebar"
>
<ToggleSidebar />
</button>
<div class="flex items-center space-x-4 pl-2">
{#if $currentFullNote}
<!-- Note edit/preview button (only shown for latest version) -->
{#if isLatestVersion($currentFullNote)}
<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}
</div>
<div class="flex items-center space-x-4 pl-2">
<ThemeToggle />
</div>
</header>
<!-- Note content area with fixed width --> <!-- Note content area with fixed width -->
<div class="note-content-fixed-width"> <div class="note-content-fixed-width">
<main class="main-content"> <main class="main-content">
{#if $currentFullNote} {#if $currentFullNote}
<NoteEditor note={$currentFullNote} bind:isEditing {saveNote} /> <NoteEditor
note={$currentFullNote}
bind:isVersionDropdownOpen
bind:isEditing
{saveNote}
{selectVersion}
/>
{:else} {:else}
<div class="greeting-container"> <div class="greeting-container">
<p class="greeting-message">{greetMessage}</p> <p class="greeting-message">{greetMessage}</p>
@ -368,7 +321,7 @@
<!-- Settings Modal --> <!-- Settings Modal -->
{#if showSettings} {#if showSettings}
<SettingsModal onClose={toggleSettings} /> <SettingsModal onClose={toggleSettingsModal} />
{/if} {/if}
</div> </div>
{/if} {/if}

View File

@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import { apiClient, cSuccess } from "$lib/client" import { apiClient, cSuccess, currentUser } from "$lib/logic/client"
import Close from "$lib/icons/Close.svelte" import Close from "$lib/icons/Close.svelte"
import { isPasswordValid } from "$lib/utils" import { isPasswordValid } from "$lib/util/authValidation"
import { onMount } from "svelte" import { onMount } from "svelte"
import { formatDate } from "$lib/util/contentVisual"
// Props // props
export let onClose: () => void export let onClose: () => void
const userData = $currentUser
let currentPassword = "" let currentPassword = ""
let newPassword = "" let newPassword = ""
let confirmPassword = "" let confirmPassword = ""
@ -21,10 +24,10 @@
let previouslyFocusedElement: HTMLElement | null = null let previouslyFocusedElement: HTMLElement | null = null
onMount(() => { onMount(() => {
// Store the element that had focus before opening the modal // store the element that had focus before opening the modal
previouslyFocusedElement = document.activeElement as HTMLElement previouslyFocusedElement = document.activeElement as HTMLElement
// Focus the first focusable element in the modal // focus the first focusable element in the modal
if (modalContent !== null) { if (modalContent !== null) {
const focusableElements = (modalContent as HTMLDivElement).querySelectorAll( const focusableElements = (modalContent as HTMLDivElement).querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
@ -36,7 +39,7 @@
} }
} }
// When component unmounts, restore focus // when component unmounts, restore focus
return () => { return () => {
if (previouslyFocusedElement) { if (previouslyFocusedElement) {
previouslyFocusedElement.focus() previouslyFocusedElement.focus()
@ -63,7 +66,7 @@
;[isNewPasswordValid, changePasswordModalError] = isPasswordValid(newPassword) ;[isNewPasswordValid, changePasswordModalError] = isPasswordValid(newPassword)
if (!isNewPasswordValid) { if (!isNewPasswordValid) {
// Error will be automatically displayed inside corresponding modal section // error will be automatically displayed inside corresponding modal section
return return
} }
@ -73,7 +76,7 @@
newPassword = "" newPassword = ""
confirmPassword = "" confirmPassword = ""
cSuccess.set("Password updated successfully.") $cSuccess = "Password updated successfully."
setTimeout(() => { setTimeout(() => {
onClose() onClose()
@ -96,14 +99,14 @@
try { try {
await apiClient.deleteCurrentUser(deleteConfirmPassword) await apiClient.deleteCurrentUser(deleteConfirmPassword)
// The API client will handle the redirect to login page after successful deletion // the API client will handle the redirect to login page after successful deletion
} catch (err) { } catch (err) {
deleteUserModalError = err instanceof Error ? err.message : "Unknown error." deleteUserModalError = err instanceof Error ? err.message : "Unknown error."
} }
} }
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
// Close the modal if user clicks outside of it // close the modal if user clicks outside of it
const target = event.target as HTMLElement const target = event.target as HTMLElement
if (target.classList.contains("modal-backdrop")) { if (target.classList.contains("modal-backdrop")) {
onClose() onClose()
@ -111,7 +114,7 @@
} }
const handleModalContentKeydown = (event: KeyboardEvent) => { const handleModalContentKeydown = (event: KeyboardEvent) => {
// Accessibility compliance handler, actual handling is done by the global keydown handler // accessibility compliance handler, actual handling is done by the global keydown handler
} }
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
@ -123,8 +126,6 @@
<svelte:window on:keydown={handleKeydown} /> <svelte:window on:keydown={handleKeydown} />
<!-- TODO: add user details section (username, creation date, update date, admin status, etc.) -->
<div <div
class="modal-backdrop" class="modal-backdrop"
on:click={handleClickOutside} on:click={handleClickOutside}
@ -136,7 +137,7 @@
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
bind:this={modalContent} bind:this={modalContent}
class="modal-content" class="modal-content max-w-md"
on:click|stopPropagation on:click|stopPropagation
on:keydown={handleModalContentKeydown} on:keydown={handleModalContentKeydown}
role="document" role="document"
@ -145,14 +146,45 @@
<!-- Header (title + close button) --> <!-- Header (title + close button) -->
<div class="modal-section flex items-center justify-between" role="heading" aria-level="1"> <div class="modal-section flex items-center justify-between" role="heading" aria-level="1">
<h2 class="text-xl font-bold">Settings</h2> <h2 class="text-xl font-bold">Settings</h2>
<button on:click={onClose} class="modal-close-button" aria-label="Close settings"> <button on:click={onClose} class="sidebar-button" aria-label="Close settings">
<Close /> <Close />
</button> </button>
</div> </div>
<!-- Account information -->
<div class="modal-section">
<h3 class="modal-section-title">Account Data</h3>
<table class="modal-table">
<tbody>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Username</th>
<td class="modal-table-row-item">{userData?.username}</td>
</tr>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Account created</th>
<td class="modal-table-row-item"
>{userData?.createdAt !== undefined ? formatDate(userData?.createdAt) : ""}</td
>
</tr>
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Password updated</th>
<td class="modal-table-row-item"
>{userData?.createdAt !== undefined ? formatDate(userData?.updatedAt) : ""}</td
>
</tr>
{#if userData?.isAdmin}
<tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Admin permissions</th>
<td class="modal-table-row-item">{userData?.isAdmin}</td>
</tr>
{/if}
</tbody>
</table>
</div>
<!-- Account settings --> <!-- Account settings -->
<div class="modal-section"> <div class="modal-section">
<h3 class="mb-4 text-lg font-bold">Account Settings</h3> <h3 class="modal-section-title">Account Settings</h3>
{#if changePasswordModalError} {#if changePasswordModalError}
<div class="error mb-4" role="alert"> <div class="error mb-4" role="alert">
@ -166,30 +198,43 @@
<!-- Current Password --> <!-- Current Password -->
<div class="form-group"> <div class="form-group">
<label class="form-label" for="currentPassword">Current Password</label> <label class="form-label" for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" bind:value={currentPassword} class="w-full" /> <input
type="password"
id="currentPassword"
bind:value={currentPassword}
class="auth-input-field"
/>
</div> </div>
<!-- New Password --> <!-- New Password -->
<div class="form-group"> <div class="form-group">
<label class="form-label" for="newPassword">New Password</label> <label class="form-label" for="newPassword">New Password</label>
<input type="password" id="newPassword" bind:value={newPassword} class="w-full" /> <input
type="password"
id="newPassword"
bind:value={newPassword}
class="auth-input-field"
/>
</div> </div>
<!-- Confirm New Password --> <!-- Confirm New Password -->
<div class="form-group"> <div class="form-group">
<label class="form-label" for="confirmPassword">Confirm New Password</label> <label class="form-label" for="confirmPassword">Confirm New Password</label>
<input type="password" id="confirmPassword" bind:value={confirmPassword} class="w-full" /> <input
type="password"
id="confirmPassword"
bind:value={confirmPassword}
class="auth-input-field"
/>
</div> </div>
<button on:click={changePassword} class="btn-primary w-full rounded-full"> <button on:click={changePassword} class="auth-button"> Change Password </button>
Change Password
</button>
</div> </div>
</div> </div>
<!-- Danger zone --> <!-- "Danger zone" -->
<div class="modal-section"> <div class="modal-section">
<h3 class="mb-4 text-lg font-bold text-red-500">Danger Zone</h3> <h3 class="modal-section-title text-red-500">Danger Zone</h3>
{#if deleteUserModalError} {#if deleteUserModalError}
<div class="error mb-4" role="alert"> <div class="error mb-4" role="alert">
@ -210,7 +255,7 @@
type="password" type="password"
id="deleteConfirmPassword" id="deleteConfirmPassword"
bind:value={deleteConfirmPassword} bind:value={deleteConfirmPassword}
class="w-full" class="auth-input-field"
/> />
</div> </div>
@ -221,14 +266,14 @@
type="text" type="text"
id="deleteConfirmText" id="deleteConfirmText"
bind:value={deleteConfirmText} bind:value={deleteConfirmText}
class="w-full" class="auth-input-field"
placeholder="DELETE" placeholder="DELETE"
/> />
</div> </div>
<button <button
on:click={deleteAccount} on:click={deleteAccount}
class="w-full rounded-full bg-red-500 font-bold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50" class="auth-button bg-red-500 text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
disabled={deleteConfirmText !== "DELETE" || !deleteConfirmPassword} disabled={deleteConfirmText !== "DELETE" || !deleteConfirmPassword}
> >
Delete My Account Delete My Account

View File

@ -1,40 +1,48 @@
<script lang="ts"> <script lang="ts">
import type { NoteMetadata, FullNote } from "$lib/client" import Create from "$lib/icons/sidebar/Create.svelte"
import Close from "$lib/icons/Close.svelte" import Delete from "$lib/icons/sidebar/Delete.svelte"
import Create from "$lib/icons/Create.svelte" import ChevronLeft from "$lib/icons/sidebar/ChevronLeft.svelte"
import Delete from "$lib/icons/Delete.svelte" import ChevronRight from "$lib/icons/sidebar/ChevronRight.svelte"
import Logout from "$lib/icons/Logout.svelte" import User from "$lib/icons/sidebar/User.svelte"
import Search from "$lib/icons/Search.svelte" import ChevronDown from "$lib/icons/ChevronDown.svelte"
import Settings from "$lib/icons/Settings.svelte" import ChevronUp from "$lib/icons/ChevronUp.svelte"
import Tag from "$lib/icons/sidebar/Tag.svelte"
import WebhookTruck from "$lib/icons/sidebar/WebhookTruck.svelte"
import Search from "$lib/icons/sidebar/Search.svelte"
import SettingsGear from "$lib/icons/sidebar/SettingsGear.svelte"
import AdminWrench from "$lib/icons/sidebar/AdminShield.svelte"
import Exit from "$lib/icons/sidebar/Exit.svelte"
import ThemeToggle from "./ThemeToggle.svelte"
import { formatDate } from "$lib/util/contentVisual"
import type { FullNote, NoteMetadata } from "$lib/logic/model"
// Props // props
export let sidebarOpen = true export let isSidebarOpen: boolean
export let username: string
export let notes: NoteMetadata[] = [] export let notes: NoteMetadata[] = []
export let currentNote: FullNote | null = null export let currentNote: FullNote | null = null
export let toggleSettings: () => void export let toggleSettingsModal: () => void
export let toggleAdminModal: () => void
export let toggleWebhookModal: () => void
export let toggleTagModal: () => 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, fetchRemote: boolean) => Promise<void> export let selectNote: (
noteID: string,
fetchRemote: boolean,
clearSelection: boolean
) => Promise<void>
export let deleteNote: (noteID: string) => Promise<void> export let deleteNote: (noteID: string) => Promise<void>
const formatDate = (dateString: string | Date): string => { // local state
if (!dateString) { let userMenuOpen = false
return ""
}
const d = new Date(dateString) const toggleSidebar = () => {
return d.toLocaleDateString(undefined, { isSidebarOpen = !isSidebarOpen
weekday: "short",
year: "2-digit",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric"
})
} }
const closeSidebar = () => { const toggleUserMenu = () => {
sidebarOpen = false userMenuOpen = !userMenuOpen
} }
const handleDeleteNote = (event: MouseEvent, noteID: string) => { const handleDeleteNote = (event: MouseEvent, noteID: string) => {
@ -47,115 +55,202 @@
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, true) // Sync with API due to pushing updates selectNote(noteID, true, false) // sync with API due to pushing updates
} }
} }
// Client-side search // client-side search
let searchQuery = "" let searchQuery = ""
$: filteredNotes = searchQuery $: filteredNotes = searchQuery
? notes.filter((note) => note.title.toLowerCase().includes(searchQuery.toLowerCase())) ? notes.filter((note) => note.title.toLowerCase().includes(searchQuery.toLowerCase()))
: notes : notes
</script> </script>
<!-- TODO: add admin modal button (+ component similar to the settings modal) if the user is an admin --> <!-- TODO: handle pagination support later if it becomes an issue (`util/itemPagination.ts`) -->
<!-- TODO: component-level paging support (via the implementation in `pages.ts`) -->
<aside <!-- Outmost sidebar container with collapsible functionality -->
class="sidebar z-10" <div class="relative z-10 flex h-full">
class:translate-x-0={sidebarOpen} <!-- Collapsed mini sidebar (always visible) -->
class:translate-x-[-100%]={!sidebarOpen} <div class="mini-sidebar">
class:fixed={true} <button
class:md:relative={true} on:click={toggleSidebar}
> class="sidebar-button"
<!-- Sidebar header --> aria-label={isSidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
<div class="sidebar-header"> >
<div class="mx-2 flex items-center justify-center"> {#if isSidebarOpen}
<button <ChevronLeft classString="h-5 w-5" />
on:click={createNewNote} {:else}
class="btn-secondary h-9 w-9 p-2 text-center" <ChevronRight classString="h-5 w-5" />
aria-label="Create new note" {/if}
> </button>
<Create />
</button>
<!-- Search bar --> <!-- Mini sidebar icons -->
<div class="pl-4.5 relative"> {#if !isSidebarOpen}
<input type="text" placeholder="Search" bind:value={searchQuery} class="search-bar" /> <div class="mt-6 flex flex-col gap-4">
<Search /> <button on:click={createNewNote} class="sidebar-button" aria-label="Create new note">
</div> <Create classString="h-5 w-5" />
</button>
<button on:click={toggleWebhookModal} class="sidebar-button" aria-label="View webhooks">
<WebhookTruck classString="h-5 w-5" />
</button>
<!-- Close button visible only on mobile -->
<div class="pl-4.5 relative">
<button <button
on:click={closeSidebar} on:click={toggleTagModal}
class="btn-secondary h-9 w-9 p-2 text-center md:hidden" class="sidebar-button"
aria-label="Close sidebar" aria-label="View tags & expiration"
> >
<Close /> <Tag classString="h-5 w-5" />
</button> </button>
</div> </div>
{/if}
<!-- Should be roughly in the center of the mini sidebar despite the absolute positioning -->
<div class="absolute bottom-4 left-3">
<ThemeToggle />
</div> </div>
</div> </div>
<!-- Notes list --> <!-- Expanded sidebar -->
<div class="flex-1 overflow-y-auto"> <aside
{#if filteredNotes.length > 0} class="sidebar"
<ul class="sidebar-divider" role="listbox"> class:translate-x-0={isSidebarOpen}
{#each filteredNotes as note} class:translate-x-[-100%]={!isSidebarOpen}
<li class:hidden={!isSidebarOpen}
class="sidebar-item" class:md:relative={true}
class:sidebar-item-active={currentNote && note.id === currentNote.id} >
on:click={() => selectNote(note.id, false)} <!-- Header with app logo and display name -->
on:keydown={(e) => handleNoteKeydown(e, note.id)} <div class="sidebar-header-container">
tabindex="0" <!-- svelte-ignore a11y_invalid_attribute -->
role="option" <a href="#" class="sidebar-header-link" on:click={() => selectNote("", false, true)}>
aria-selected={currentNote && note.id === currentNote.id} <img src="favicon.svg" alt="Logo" class="sidebar-header-logo" />
> <span class="sidebar-header-text">QueNote</span>
<div class="sidebar-item-content"> </a>
<!-- Note metadata description --> </div>
<h3 class="truncate font-bold">{note.title || "Untitled Note"}</h3>
<div class="sidebar-item-bottom-row">
<p class="sidebar-item-text mt-1 italic">
{formatDate(note.updatedAt)}
</p>
<!-- Note delete button --> <div class="sidebar-section-divider"></div>
<!-- Action buttons (same functionality as mini sidebar icons) -->
<div class="flex flex-col px-2 py-3">
<button on:click={createNewNote} class="sidebar-action-button mb-1">
<Create classString="mr-3 h-5 w-5" />
Create note
</button>
<button on:click={toggleWebhookModal} class="sidebar-action-button mb-1">
<WebhookTruck classString="mr-3 h-5 w-5" />
Webhooks
</button>
<button on:click={toggleTagModal} class="sidebar-action-button">
<Tag classString="mr-3 h-5 w-5" />
Note tags
</button>
</div>
<div class="relative flex flex-col px-2 pb-3">
<input type="text" placeholder="Search" bind:value={searchQuery} class="sidebar-search-bar" />
<Search classString="sidebar-search-bar-icon" />
</div>
<div class="sidebar-section-divider"></div>
<!-- Notes list -->
<div class="flex-1 overflow-y-auto p-2">
{#if filteredNotes.length > 0}
<ul class="space-y-1" role="listbox">
{#each filteredNotes as note}
<li
class="sidebar-list"
class:sidebar-list-active={currentNote && note.id === currentNote.id}
on:click={() => selectNote(note.id, false, false)}
on:keydown={(e) => handleNoteKeydown(e, note.id)}
tabindex="0"
role="option"
aria-selected={currentNote && note.id === currentNote.id}
>
<div class="flex w-full items-start justify-between">
<h3 class="sidebar-list-item-title">
{note.title || "Untitled Note"}
</h3>
<!-- Delete button -->
<button <button
on:click={(e) => handleDeleteNote(e, note.id)} on:click={(e) => handleDeleteNote(e, note.id)}
class="sidebar-item-delete" class="sidebar-button sidebar-list-item-delete-button"
aria-label="Delete note" aria-label="Delete note"
> >
<Delete /> <Delete classString="h-3 w-3" />
</button> </button>
</div> </div>
</div>
</li>
{/each}
</ul>
{:else}
<div class="sidebar-item-text p-4 text-center">
{searchQuery ? "No notes match your search" : "No notes yet"}
</div>
{/if}
</div>
<!-- Sidebar footer --> <p class="sidebar-list-item-metadata">
<div class="sidebar-footer"> {formatDate(note.updatedAt)}
<div class="flex justify-between"> </p>
<button on:click={logout} class="btn-secondary rounded-full p-2" aria-label="Logout"> </li>
<Logout /> {/each}
</button> </ul>
{:else}
<!-- It's better for UX that the logout button isn't on the right side --> <div class="sidebar-search-info">
<button {searchQuery ? "No notes match your search" : "No notes yet"}
on:click={toggleSettings} </div>
class="btn-secondary rounded-full p-2" {/if}
aria-label="Toggle settings"
>
<Settings />
</button>
</div> </div>
</div>
</aside> <div class="sidebar-section-divider"></div>
<!-- User section (single button) with dropdown -->
<div class="relative flex flex-col px-2 py-3">
<button
on:click={toggleUserMenu}
class="sidebar-action-button flex-wrap justify-between px-4 py-4"
>
<div class="sidebar-user-button-content-container">
<User classString="h-6 w-6 flex-shrink-0" />
<div class="sidebar-user-button-username-container">
<span class="sidebar-user-button-username-text">Logged in as {username}</span>
</div>
{#if userMenuOpen}
<ChevronDown classString="h-6 w-6 flex-shrink-0" />
{:else}
<ChevronUp classString="h-6 w-6 flex-shrink-0" />
{/if}
</div>
</button>
<!-- User actions dropdown menu -->
{#if userMenuOpen}
<div class="sidebar-user-dropdown">
<div class="flex flex-col p-0">
<button
on:click={toggleSettingsModal}
class="sidebar-action-button rounded-none rounded-t-lg border-0 border-b"
>
<SettingsGear classString="mr-3 h-5 w-5" />
Settings
</button>
<button
on:click={toggleAdminModal}
class="sidebar-action-button rounded-none border-0 border-y"
>
<AdminWrench classString="mr-3 h-5 w-5" />
Admin view
</button>
<button
on:click={logout}
class="sidebar-action-button rounded-none rounded-b-lg border-0 border-t"
>
<Exit classString="mr-3 h-5 w-5" />
Log out
</button>
</div>
</div>
{/if}
</div>
</aside>
</div>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Moon from "$lib/icons/Moon.svelte" import Moon from "$lib/icons/theme/Moon.svelte"
import Sun from "$lib/icons/Sun.svelte" import Sun from "$lib/icons/theme/Sun.svelte"
import { themeState } from "$lib/state.svelte" import { themeState } from "$lib/state.svelte"
import { onMount } from "svelte" import { onMount } from "svelte"
@ -15,7 +15,7 @@
document.documentElement.classList.toggle("dark", themeState.isDarkMode) document.documentElement.classList.toggle("dark", themeState.isDarkMode)
localStorage.setItem("darkMode", themeState.isDarkMode ? "true" : "false") localStorage.setItem("darkMode", themeState.isDarkMode ? "true" : "false")
}} }}
class="btn-secondary p-2" class="sidebar-button"
aria-label={themeState.isDarkMode ? "Switch to light mode" : "Switch to dark mode"} aria-label={themeState.isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
> >
{#if themeState.isDarkMode} {#if themeState.isDarkMode}

View File

@ -1,34 +0,0 @@
export const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api"
// Lifetimes of *in-memory* authentication tokens in milliseconds
export const AT_EXP_MS = 15 * 60 * 1000 // 15 min.
export const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h.
export const REFRESH_BUF = 30 * 1000 // 30 s.
// User registration password restrictions
export const MIN_PASSWORD_LENGTH = 12
export const MAX_PASSWORD_LENGTH = 72
export const MIN_PASSWORD_ENTROPY = 60.0
export const ENTROPY_CLASSES: Array<[RegExp, number]> = [
[/[a-z]/, 24], // 26
[/[A-Z]/, 24], // 26
[/\d/, 10],
[/[!@#$%^&*()\-_+=\[\]{}|;:'",.<>\/?`~\\]/, 32] // 40
]
export const TITLE_MAX_LENGTH = 150
export const UUID_REGEX = new RegExp(
"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
"i"
)
// View cookie configuration (holds UNIX timestamp value of the actual refresh token cookie's expiration date)
export const VIEW_COOKIE_PATH = import.meta.env.VITE_VIEW_COOKIE_PATH || "/"
export const VIEW_COOKIE_DOMAIN = import.meta.env.VITE_VIEW_COOKIE_DOMAIN || "localhost"
export const COOKIE_SAME_SITE = import.meta.env.VITE_COOKIE_SAME_SITE || "strict"
export const COOKIE_SECURE = import.meta.env.PROD ? true : false
// Error/success notification display durations
export const ERR_NOTIFICATION_DUR = 8 * 1000 // 8 s.
export const SUC_NOTIFICATION_DUR = 8 * 1000 // 8 s.

View File

@ -0,0 +1,13 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path d="M6 9L12 15L18 9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

View File

@ -0,0 +1,12 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path d="M6 15L12 9L18 15" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

View File

@ -1,3 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 201 B

After

Width:  |  Height:  |  Size: 280 B

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>

Before

Width:  |  Height:  |  Size: 251 B

View File

@ -1,14 +0,0 @@
<svg
viewBox="0 0 24 24"
class="general-sidebar-icon"
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 3V12M18.3611 5.64001C19.6195 6.8988 20.4764 8.50246 20.8234 10.2482C21.1704 11.994 20.992 13.8034 20.3107 15.4478C19.6295 17.0921 18.4759 18.4976 16.9959 19.4864C15.5159 20.4752 13.776 21.0029 11.9961 21.0029C10.2162 21.0029 8.47625 20.4752 6.99627 19.4864C5.51629 18.4976 4.36274 17.0921 3.68146 15.4478C3.00019 13.8034 2.82179 11.994 3.16882 10.2482C3.51584 8.50246 4.37272 6.8988 5.6311 5.64001"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

Before

Width:  |  Height:  |  Size: 626 B

View File

@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>

Before

Width:  |  Height:  |  Size: 235 B

View File

@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 252 B

View File

@ -0,0 +1,18 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path
d="M11.4001 18.1612L11.4001 18.1612L18.796 10.7653C17.7894 10.3464 16.5972 9.6582 15.4697 8.53068C14.342 7.40298 13.6537 6.21058 13.2348 5.2039L5.83882 12.5999L5.83879 12.5999C5.26166 13.1771 4.97307 13.4657 4.7249 13.7838C4.43213 14.1592 4.18114 14.5653 3.97634 14.995C3.80273 15.3593 3.67368 15.7465 3.41556 16.5208L2.05445 20.6042C1.92743 20.9852 2.0266 21.4053 2.31063 21.6894C2.59466 21.9734 3.01478 22.0726 3.39584 21.9456L7.47918 20.5844C8.25351 20.3263 8.6407 20.1973 9.00498 20.0237C9.43469 19.8189 9.84082 19.5679 10.2162 19.2751C10.5343 19.0269 10.823 18.7383 11.4001 18.1612Z"
/>
<path
d="M20.8482 8.71306C22.3839 7.17735 22.3839 4.68748 20.8482 3.15178C19.3125 1.61607 16.8226 1.61607 15.2869 3.15178L14.3999 4.03882C14.4121 4.0755 14.4246 4.11268 14.4377 4.15035C14.7628 5.0875 15.3763 6.31601 16.5303 7.47002C17.6843 8.62403 18.9128 9.23749 19.85 9.56262C19.8875 9.57563 19.9245 9.58817 19.961 9.60026L20.8482 8.71306Z"
/>
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path
d="M15 20V15H9V20M18 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H14.1716C14.702 4 15.2107 4.21071 15.5858 4.58579L19.4142 8.41421C19.7893 8.78929 20 9.29799 20 9.82843V18C20 19.1046 19.1046 20 18 20Z"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/></svg
>

View File

@ -0,0 +1,25 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<!-- TODO: this needs to be fixed :) -->
<svg
viewBox="0 -4 20 20"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-260.000000, -4563.000000)">
<g transform="translate(56.000000, 160.000000)">
<path
d="M216,4409.00052 C216,4410.14768 215.105,4411.07682 214,4411.07682 C212.895,4411.07682 212,4410.14768 212,4409.00052 C212,4407.85336 212.895,4406.92421 214,4406.92421 C215.105,4406.92421 216,4407.85336 216,4409.00052 M214,4412.9237 C211.011,4412.9237 208.195,4411.44744 206.399,4409.00052 C208.195,4406.55359 211.011,4405.0763 214,4405.0763 C216.989,4405.0763 219.805,4406.55359 221.601,4409.00052 C219.805,4411.44744 216.989,4412.9237 214,4412.9237 M214,4403 C209.724,4403 205.999,4405.41682 204,4409.00052 C205.999,4412.58422 209.724,4415 214,4415 C218.276,4415 222.001,4412.58422 224,4409.00052 C222.001,4405.41682 218.276,4403 214,4403"
fill="currentColor"
>
</path>
</g>
</g>
</g>
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
export let classString: string
</script>
<svg
viewBox="0 0 24 24"
fill="none"
class={classString}
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path
d="M11.302 21.6149C11.5234 21.744 11.6341 21.8086 11.7903 21.8421C11.9116 21.8681 12.0884 21.8681 12.2097 21.8421C12.3659 21.8086 12.4766 21.744 12.698 21.6149C14.646 20.4784 20 16.9084 20 12V6.6C20 6.04207 20 5.7631 19.8926 5.55048C19.7974 5.36198 19.6487 5.21152 19.4613 5.11409C19.25 5.00419 18.9663 5.00084 18.3988 4.99413C15.4272 4.95899 13.7136 4.71361 12 3C10.2864 4.71361 8.57279 4.95899 5.6012 4.99413C5.03373 5.00084 4.74999 5.00419 4.53865 5.11409C4.35129 5.21152 4.20259 5.36198 4.10739 5.55048C4 5.7631 4 6.04207 4 6.6V12C4 16.9084 9.35396 20.4784 11.302 21.6149Z"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

View File

@ -0,0 +1,13 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M15 6L9 12L15 18" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

View File

@ -0,0 +1,13 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9 6L15 12L9 18" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

View File

@ -0,0 +1,13 @@
<script lang="ts">
export let classString: string
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path d="M4 12H20M12 4V20" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

View File

@ -1,4 +1,14 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor"> <script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path d="M10 12V17" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M10 12V17" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 12V17" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M14 12V17" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 7H20" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M4 7H20" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />

Before

Width:  |  Height:  |  Size: 673 B

After

Width:  |  Height:  |  Size: 765 B

View File

@ -0,0 +1,21 @@
<script lang="ts">
export let classString: string
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<g id="Interface / Exit">
<path
id="Vector"
d="M12 15L15 12M15 12L12 9M15 12H4M4 7.24802V7.2002C4 6.08009 4 5.51962 4.21799 5.0918C4.40973 4.71547 4.71547 4.40973 5.0918 4.21799C5.51962 4 6.08009 4 7.2002 4H16.8002C17.9203 4 18.4796 4 18.9074 4.21799C19.2837 4.40973 19.5905 4.71547 19.7822 5.0918C20 5.5192 20 6.07899 20 7.19691V16.8036C20 17.9215 20 18.4805 19.7822 18.9079C19.5905 19.2842 19.2837 19.5905 18.9074 19.7822C18.48 20 17.921 20 16.8031 20H7.19691C6.07899 20 5.5192 20 5.0918 19.7822C4.71547 19.5905 4.40973 19.2839 4.21799 18.9076C4 18.4798 4 17.9201 4 16.8V16.75"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>

View File

@ -1,8 +1,12 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg <svg
xmlns="http://www.w3.org/2000/svg"
class="search-bar-icon"
fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke="currentColor"
> >
<path <path

Before

Width:  |  Height:  |  Size: 263 B

After

Width:  |  Height:  |  Size: 325 B

View File

@ -1,8 +1,12 @@
<script lang="ts">
export let classString: string
</script>
<svg <svg
xmlns="http://www.w3.org/2000/svg"
class="general-sidebar-icon"
fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" stroke="currentColor"
> >
<path <path

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,18 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path
d="M7.0498 7.0498H7.0598M10.5118 3H7.8C6.11984 3 5.27976 3 4.63803 3.32698C4.07354 3.6146 3.6146 4.07354 3.32698 4.63803C3 5.27976 3 6.11984 3 7.8V10.5118C3 11.2455 3 11.6124 3.08289 11.9577C3.15638 12.2638 3.27759 12.5564 3.44208 12.8249C3.6276 13.1276 3.88703 13.387 4.40589 13.9059L9.10589 18.6059C10.2939 19.7939 10.888 20.388 11.5729 20.6105C12.1755 20.8063 12.8245 20.8063 13.4271 20.6105C14.112 20.388 14.7061 19.7939 15.8941 18.6059L18.6059 15.8941C19.7939 14.7061 20.388 14.112 20.6105 13.4271C20.8063 12.8245 20.8063 12.1755 20.6105 11.5729C20.388 10.888 19.7939 10.2939 18.6059 9.10589L13.9059 4.40589C13.387 3.88703 13.1276 3.6276 12.8249 3.44208C12.5564 3.27759 12.2638 3.15638 11.9577 3.08289C11.6124 3 11.2455 3 10.5118 3ZM7.5498 7.0498C7.5498 7.32595 7.32595 7.5498 7.0498 7.5498C6.77366 7.5498 6.5498 7.32595 6.5498 7.0498C6.5498 6.77366 6.77366 6.5498 7.0498 6.5498C7.32595 6.5498 7.5498 6.77366 7.5498 7.0498Z"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path
d="M5 21C5 17.134 8.13401 14 12 14C15.866 14 19 17.134 19 21M16 7C16 9.20914 14.2091 11 12 11C9.79086 11 8 9.20914 8 7C8 4.79086 9.79086 3 12 3C14.2091 3 16 4.79086 16 7Z"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

View File

@ -0,0 +1,18 @@
<script lang="ts">
export let classString = "h-6 w-6"
</script>
<svg
viewBox="0 0 24 24"
class={classString}
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<path
d="M18.5 18C18.5 19.1046 17.6046 20 16.5 20C15.3954 20 14.5 19.1046 14.5 18M18.5 18C18.5 16.8954 17.6046 16 16.5 16C15.3954 16 14.5 16.8954 14.5 18M18.5 18H21.5M14.5 18H13.5M8.5 18C8.5 19.1046 7.60457 20 6.5 20C5.39543 20 4.5 19.1046 4.5 18M8.5 18C8.5 16.8954 7.60457 16 6.5 16C5.39543 16 4.5 16.8954 4.5 18M8.5 18H13.5M4.5 18C3.39543 18 2.5 17.1046 2.5 16V7.2C2.5 6.0799 2.5 5.51984 2.71799 5.09202C2.90973 4.71569 3.21569 4.40973 3.59202 4.21799C4.01984 4 4.5799 4 5.7 4H10.3C11.4201 4 11.9802 4 12.408 4.21799C12.7843 4.40973 13.0903 4.71569 13.282 5.09202C13.5 5.51984 13.5 6.0799 13.5 7.2V18M13.5 18V8H17.5L20.5 12M20.5 12V18M20.5 12H13.5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

View File

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 184 B

View File

Before

Width:  |  Height:  |  Size: 666 B

After

Width:  |  Height:  |  Size: 666 B

View File

@ -1,100 +1,37 @@
import { get, writable, type Writable } from "svelte/store" import { get, writable, type Writable } from "svelte/store"
import {
API_BASE_ADDR,
AT_EXP_MS,
COOKIE_SAME_SITE,
COOKIE_SECURE,
CSRF_EXP_MS,
REFRESH_BUF,
UUID_REGEX,
VIEW_COOKIE_DOMAIN,
VIEW_COOKIE_PATH
} from "./const"
import { goto } from "$app/navigation" import { goto } from "$app/navigation"
import { usersPagination } from "./pages" import { usersPagination } from "../util/itemPagination"
import {
FullNote,
NoteMetadata,
User,
VersionMetadata,
type ApiFullNoteResponse,
type ApiFullVersionResponse,
type ApiNoteMetadataResponse,
type ApiUserResponse,
type ApiVersionMetadataResponse,
type NewNoteResponse
} from "./model"
interface User { const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api"
id: string const UUID_REGEX = new RegExp(
username: string "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
isAdmin: boolean "i"
createdAt: Date )
updatedAt: Date
}
interface ApiUserResponse { // lifetimes of *in-memory* authentication tokens in milliseconds
id: string const AT_EXP_MS = 15 * 60 * 1000 // 15 min.
username: string const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h.
is_admin: boolean const REFRESH_BUF = 30 * 1000 // 30 s.
created_at: string
updated_at: string
}
export interface FullNote { // view cookie configuration (holds UNIX timestamp value of the actual refresh token cookie's expiration date)
id: string const VIEW_COOKIE_PATH = import.meta.env.VITE_VIEW_COOKIE_PATH || "/"
owner: string const VIEW_COOKIE_DOMAIN = import.meta.env.VITE_VIEW_COOKIE_DOMAIN || "localhost"
title: string const COOKIE_SAME_SITE = import.meta.env.VITE_COOKIE_SAME_SITE || "strict"
content: string const COOKIE_SECURE = import.meta.env.PROD ? true : false
versionNumber: number
versionCreatedAt: Date
isActiveVersion: boolean
noteCreatedAt: Date
noteUpdatedAt: Date
}
interface ApiFullNoteResponse { // some of these could just be local variables as not all of them are being used globally
note_id: string
owner_id: string
title: string
content: string
version_number: number
version_created_at: string
note_created_at: string
note_updated_at: string
}
export interface NoteMetadata {
id: string
owner: string
title: string
updatedAt: Date
}
interface ApiNoteMetadataResponse {
note_id: string
owner_id: string
title: string
updated_at: string
}
interface NewNoteResponse {
title: string
content: string
}
interface VersionMetadata {
versionID: string
title: string
versionNumber: number
isActive: boolean
createdAt: Date
}
interface ApiVersionMetadataResponse {
version_id: string
title: string
version_number: number
created_at: string
}
interface ApiFullVersionResponse {
version_id: string
title: string
content: string
version_number: number
created_at: string
}
// Some of these could just be local variables as not all of them are being 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)
@ -108,13 +45,13 @@ export const cSuccess: Writable<string | null> = writable(null)
class ApiClient { class ApiClient {
private viewCookieName: string private viewCookieName: string
private baseUrl: string private baseUrl: string
private lastAtUpdate = new Date(0) // Refreshing the page wipes access and CSRF tokens from memory -> Rotation needed private lastAtUpdate = new Date(0) // refreshing the page wipes access and CSRF tokens from memory -> Rotation needed
private lastCsrfUpdate = new Date(0) private lastCsrfUpdate = new Date(0)
private refreshInProgress = false private refreshInProgress = false
private activeVersion = -1 private activeVersion = -1
private loadedNotesCache = new Map<string, FullNote>() private loadedNotesCache = new Map<string, FullNote>()
private loadedHistoryCache = new Map<string, VersionMetadata[]>() private loadedHistoryCache = new Map<string, VersionMetadata[]>()
private loadedVersionsCache = new Map<string, FullNote>() // Key: noteID + versionID private loadedVersionsCache = new Map<string, FullNote>() // key: noteID + versionID
constructor(baseUrl: string) { constructor(baseUrl: string) {
this.baseUrl = baseUrl this.baseUrl = baseUrl
@ -129,12 +66,9 @@ class ApiClient {
cError.set(null) cError.set(null)
cSuccess.set(null) cSuccess.set(null)
// NOTE: If `handleResponse` is used, errors thrown from it will be caught here // NOTE: if `handleResponse` is used, errors thrown from it will be caught here
try { try {
// Only bearers (JWT access tokens) need to be considered here as the refresh token cookies
// will be automatically included to corresponding requests by browser and CSRF rotation is
// handled by the `refreshAccessToken` method
if (options.useBearerAuth) { if (options.useBearerAuth) {
try { try {
await this.checkAndRefreshAccessToken() await this.checkAndRefreshAccessToken()
@ -149,9 +83,9 @@ class ApiClient {
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : "Unknown error" const errMsg = err instanceof Error ? err.message : "Unknown error"
// The suspension option is handy when we want to display the error inside a modal instead of in a global notification // the suspension option is handy when we want to display the error inside a modal instead of in a global notification
if (options.suspendGlobalErr) { if (options.suspendGlobalErr) {
// Throw the same error to the next handler (should be handled inside the caller component) // throw the same error to the next handler (should be handled inside the caller component)
throw new Error(errMsg) throw new Error(errMsg)
} else { } else {
cError.set(errMsg) cError.set(errMsg)
@ -165,14 +99,14 @@ class ApiClient {
return null return null
} }
// Should be attached to routes that handle authentication with the bearer token (access token) // should be attached to routes that handle authentication with the bearer token (access token)
private async handleResponse<T>( private async handleResponse<T>(
response: Response, response: Response,
options: { useBearerAuth: boolean } options: { useBearerAuth: boolean }
): Promise<T> { ): Promise<T> {
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && options.useBearerAuth) { if (response.status === 401 && options.useBearerAuth) {
// This should never happen due to the token expiration checks we make client-side, // this should never happen due to the token expiration checks we make client-side,
// but it's still good to have as a fallback // but it's still good to have as a fallback
try { try {
console.log("[RES] Unexpected 401 caught, attempting to refresh...") console.log("[RES] Unexpected 401 caught, attempting to refresh...")
@ -184,7 +118,7 @@ class ApiClient {
} }
} }
// Capitalize the error message and display (only if not 401) // capitalize the error message and display (only if not 401)
const { error } = await response.json() const { error } = await response.json()
const dError = error[0].toUpperCase() + error.substr(1).toLowerCase() + "." const dError = error[0].toUpperCase() + error.substr(1).toLowerCase() + "."
@ -195,7 +129,7 @@ class ApiClient {
} }
private async checkAndRefreshAccessToken(): Promise<void> { private async checkAndRefreshAccessToken(): Promise<void> {
// Notably we must check whether we even have an authentication cookie present // notably we must check whether we even have an authentication cookie present
// as that's obviously required for completing the token rotation procedure // as that's obviously required for completing the token rotation procedure
if (this.refreshInProgress) { if (this.refreshInProgress) {
return return
@ -317,7 +251,7 @@ class ApiClient {
return return
} }
// Overwrite the view cookie with details that match with the real cookie // overwrite the view cookie with details that match with the real cookie
document.cookie = `${this.viewCookieName}=;path=${VIEW_COOKIE_PATH};domain=${VIEW_COOKIE_DOMAIN};expires=Thu, 01 Jan 1970 00:00:00 GMT;${COOKIE_SECURE ? "secure;" : ""}sameSite=${COOKIE_SAME_SITE}` document.cookie = `${this.viewCookieName}=;path=${VIEW_COOKIE_PATH};domain=${VIEW_COOKIE_DOMAIN};expires=Thu, 01 Jan 1970 00:00:00 GMT;${COOKIE_SECURE ? "secure;" : ""}sameSite=${COOKIE_SAME_SITE}`
} }
@ -333,77 +267,18 @@ class ApiClient {
return cookieValue return cookieValue
} }
private deserializeUser(apiResponse: ApiUserResponse): User {
return {
id: apiResponse.id,
username: apiResponse.username,
isAdmin: apiResponse.is_admin,
createdAt: new Date(apiResponse.created_at),
updatedAt: new Date(apiResponse.updated_at)
}
}
private deserializeNoteMetadatas(apiResponses: ApiNoteMetadataResponse[]): NoteMetadata[] {
return apiResponses.map((res) => {
return {
id: res.note_id,
owner: res.owner_id,
title: res.title,
updatedAt: new Date(res.updated_at)
}
})
}
private deserializeFullNote(apiResponse: ApiFullNoteResponse): FullNote {
return {
id: apiResponse.note_id,
owner: apiResponse.owner_id,
title: apiResponse.title,
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( private joinDeserializedVersion(
noteID: string, noteID: string,
apiResponse: ApiFullVersionResponse apiResponse: ApiFullVersionResponse
): FullNote | null { ): FullNote | null {
// Cache lookups are safe here due to this always being called *after* fetching the actual `FullNote` // cache lookups are safe here due to this always being called *after* fetching the actual `FullNote`
const cachedNote = this.loadedNotesCache.get(noteID) const cachedNote = this.loadedNotesCache.get(noteID)
if (!cachedNote) { if (!cachedNote) {
return null return null
} }
return { return cachedNote.joinWithVersionResponse(apiResponse)
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 | null> { public async register(username: string, password: string): Promise<void | null> {
@ -415,7 +290,7 @@ class ApiClient {
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
}) })
// Can't overwrite the function parameter // can't overwrite the function parameter
const data = await this.handleResponse<{ const data = await this.handleResponse<{
id: string id: string
username: string username: string
@ -485,7 +360,7 @@ class ApiClient {
} }
}) })
const data = await this.handleResponse<ApiUserResponse>(response, { useBearerAuth: true }) const data = await this.handleResponse<ApiUserResponse>(response, { useBearerAuth: true })
const user = this.deserializeUser(data) const user = new User(data)
currentUser.set(user) currentUser.set(user)
}, },
@ -522,7 +397,7 @@ class ApiClient {
currentUser.set(user || null) currentUser.set(user || null)
this.lastAtUpdate = new Date() this.lastAtUpdate = new Date()
}, },
{ useBearerAuth: true, suspendGlobalErr: true } // Error displayed inside the settings modal { useBearerAuth: true, suspendGlobalErr: true } // error displayed inside the settings modal
) )
} }
@ -547,7 +422,7 @@ class ApiClient {
await this.handleResponse<void>(response, { useBearerAuth: true }) await this.handleResponse<void>(response, { useBearerAuth: true })
await this.handleLocalLogout() await this.handleLocalLogout()
}, },
{ useBearerAuth: true, suspendGlobalErr: true } // Error displayed inside the settings modal { useBearerAuth: true, suspendGlobalErr: true } // error displayed inside the settings modal
) )
} }
@ -573,7 +448,7 @@ class ApiClient {
return users return users
}, },
{ useBearerAuth: true, suspendGlobalErr: true } // Error displayed inside the settings modal { useBearerAuth: true, suspendGlobalErr: true } // error displayed inside the settings modal
) )
} }
@ -603,7 +478,7 @@ class ApiClient {
await this.handleResponse<void>(response, { useBearerAuth: true }) await this.handleResponse<void>(response, { useBearerAuth: true })
}, },
{ useBearerAuth: true, suspendGlobalErr: true } // Error displayed inside the settings modal { useBearerAuth: true, suspendGlobalErr: true } // error displayed inside the settings modal
) )
} }
@ -625,7 +500,7 @@ class ApiClient {
}) })
if (data) { if (data) {
notes = this.deserializeNoteMetadatas(data) notes = NoteMetadata.fromApiResponseArray(data)
} }
console.log(`[NOTE] Got ${notes.length} note metadata results`) console.log(`[NOTE] Got ${notes.length} note metadata results`)
@ -637,7 +512,7 @@ class ApiClient {
} }
public async createNote(): Promise<NewNoteResponse | null> { public async createNote(): Promise<NewNoteResponse | null> {
// NOTE: The initial note version doesn't allow any user input, the first user-made modification // NOTE: the initial note version doesn't allow any user input, the first user-made modification
// is applied through the version creation endpoint // is applied through the version creation endpoint
return this.handleRequest( return this.handleRequest(
@ -659,7 +534,7 @@ class ApiClient {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
// Attempt cache lookup only if we didn't just push new updates // attempt cache lookup only if we didn't just push new updates
if (!fetchRemote) { if (!fetchRemote) {
const cachedNote = this.loadedNotesCache.get(noteID) const cachedNote = this.loadedNotesCache.get(noteID)
if (cachedNote != null) { if (cachedNote != null) {
@ -679,7 +554,7 @@ class ApiClient {
const data = await this.handleResponse<ApiFullNoteResponse>(response, { const data = await this.handleResponse<ApiFullNoteResponse>(response, {
useBearerAuth: true useBearerAuth: true
}) })
const note = this.deserializeFullNote(data) const note = new FullNote(data)
console.log(`[CACHE] Storing ${noteID}`) console.log(`[CACHE] Storing ${noteID}`)
this.loadedNotesCache.set(noteID, note) this.loadedNotesCache.set(noteID, note)
@ -742,7 +617,7 @@ class ApiClient {
const data = await this.handleResponse<ApiVersionMetadataResponse[]>(response, { const data = await this.handleResponse<ApiVersionMetadataResponse[]>(response, {
useBearerAuth: true useBearerAuth: true
}) })
const versions = this.deserializeVersionMetadatas(data) const versions = VersionMetadata.fromApiResponseArray(data, this.activeVersion)
this.loadedHistoryCache.set(noteID, versions) this.loadedHistoryCache.set(noteID, versions)
console.log(`[VER] Got and cached ${versions.length} version metadata results`) console.log(`[VER] Got and cached ${versions.length} version metadata results`)
@ -758,7 +633,7 @@ class ApiClient {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
// NOTE: Title's length limit is applied in the UI component, so we don't need to worry about it here // NOTE: title's length limit is applied in the UI component, so we don't need to worry about it here
return this.handleRequest( return this.handleRequest(
async () => { async () => {
@ -782,7 +657,11 @@ class ApiClient {
) )
} }
public async getFullVersion(noteID: string, versionID: string): Promise<FullNote | null> { public async getFullVersion(
noteID: string,
versionID: string,
isActiveVersion: boolean
): Promise<FullNote | null> {
if (!UUID_REGEX.test(noteID)) { if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
@ -791,10 +670,15 @@ class ApiClient {
throw new Error("Invalid version ID format.") throw new Error("Invalid version ID format.")
} }
// NOTE: No need to explicitly prevent attempting a cache hit as versions aren't editable if (isActiveVersion) {
// the active version will always get cached when the note is initially selected and loaded
const cachedActiveNote = this.loadedNotesCache.get(noteID)
if (cachedActiveNote !== undefined) {
return cachedActiveNote
}
}
// TODO: If accessing the active version, don't attempt to hit the cache, // NOTE: no need to explicitly prevent attempting a cache hit as versions aren't editable
// but instead load the contents from the currently active full note
const cachedVersion = this.loadedVersionsCache.get(noteID + versionID) const cachedVersion = this.loadedVersionsCache.get(noteID + versionID)
if (cachedVersion != null) { if (cachedVersion != null) {

144
web/src/lib/logic/model.ts Normal file
View File

@ -0,0 +1,144 @@
export interface ApiUserResponse {
id: string
username: string
is_admin: boolean
created_at: string
updated_at: string
}
export class User {
id: string
username: string
isAdmin: boolean
createdAt: Date
updatedAt: Date
constructor(apiResponse: ApiUserResponse) {
this.id = apiResponse.id
this.username = apiResponse.username
this.isAdmin = apiResponse.is_admin
this.createdAt = new Date(apiResponse.created_at)
this.updatedAt = new Date(apiResponse.updated_at)
}
}
export interface ApiFullNoteResponse {
note_id: string
owner_id: string
title: string
content: string
version_number: number
version_created_at: string
note_created_at: string
note_updated_at: string
}
export class FullNote {
id: string
owner: string
title: string
content: string
versionNumber: number
versionCreatedAt: Date
isActiveVersion: boolean
noteCreatedAt: Date
noteUpdatedAt: Date
constructor(apiResponse: ApiFullNoteResponse) {
this.id = apiResponse.note_id
this.owner = apiResponse.owner_id
this.title = apiResponse.title
this.content = apiResponse.content
this.versionNumber = apiResponse.version_number
this.versionCreatedAt = new Date(apiResponse.version_created_at)
this.isActiveVersion = true // this endpoint serves only the latest version
this.noteCreatedAt = new Date(apiResponse.note_created_at)
this.noteUpdatedAt = new Date(apiResponse.note_updated_at)
}
public joinWithVersionResponse(apiResponse: ApiFullVersionResponse): FullNote {
const newNote = new FullNote({
note_id: this.id,
owner_id: this.owner,
title: apiResponse.title,
content: apiResponse.content,
version_number: apiResponse.version_number,
version_created_at: apiResponse.created_at,
note_created_at: this.noteCreatedAt.toISOString(),
note_updated_at: this.noteUpdatedAt.toISOString()
})
// override the isActiveVersion property
newNote.isActiveVersion = this.versionNumber === apiResponse.version_number
return newNote
}
}
export interface ApiNoteMetadataResponse {
note_id: string
owner_id: string
title: string
updated_at: string
}
export class NoteMetadata {
id: string
owner: string
title: string
updatedAt: Date
constructor(apiResponse: ApiNoteMetadataResponse) {
this.id = apiResponse.note_id
this.owner = apiResponse.owner_id
this.title = apiResponse.title
this.updatedAt = new Date(apiResponse.updated_at)
}
static fromApiResponseArray(apiResponses: ApiNoteMetadataResponse[]): NoteMetadata[] {
return apiResponses.map((res) => new NoteMetadata(res))
}
}
export interface NewNoteResponse {
title: string
content: string
}
export interface ApiVersionMetadataResponse {
version_id: string
title: string
version_number: number
created_at: string
}
export class VersionMetadata {
versionID: string
title: string
versionNumber: number
isActive: boolean
createdAt: Date
constructor(apiResponse: ApiVersionMetadataResponse, activeVersionNumber: number) {
this.versionID = apiResponse.version_id
this.title = apiResponse.title
this.versionNumber = apiResponse.version_number
this.isActive = activeVersionNumber === apiResponse.version_number
this.createdAt = new Date(apiResponse.created_at)
}
static fromApiResponseArray(
apiResponses: ApiVersionMetadataResponse[],
activeVersionNumber: number
): VersionMetadata[] {
return apiResponses.map((res) => new VersionMetadata(res, activeVersionNumber))
}
}
export interface ApiFullVersionResponse {
version_id: string
title: string
content: string
version_number: number
created_at: string
}

View File

@ -1,9 +1,34 @@
import { const MIN_USERNAME_LENGTH = 3
ENTROPY_CLASSES, const MAX_USERNAME_LENGTH = 20
MAX_PASSWORD_LENGTH, const USERNAME_REGEX = RegExp("^[a-z0-9_]+$")
MIN_PASSWORD_ENTROPY,
MIN_PASSWORD_LENGTH const MIN_PASSWORD_LENGTH = 12
} from "./const" const MAX_PASSWORD_LENGTH = 72
const MIN_PASSWORD_ENTROPY = 60.0
// purposefully produce lower entropy than in the backend to prevent 400s
const ENTROPY_CLASSES: Array<[RegExp, number]> = [
[/[a-z]/, 24], // 26
[/[A-Z]/, 24], // 26
[/\d/, 10],
[/[!@#$%^&*()\-_+=\[\]{}|;:'",.<>\/?`~\\]/, 32] // 40
]
export const isUsernameValid = (username: string): [boolean, string] => {
if (username.length < MIN_USERNAME_LENGTH) {
return [false, `Username cannot be shorter than ${MIN_USERNAME_LENGTH} characters`]
}
if (username.length > MAX_USERNAME_LENGTH) {
return [false, `Username cannot be longer than ${MAX_USERNAME_LENGTH} characters`]
}
if (!USERNAME_REGEX.test(username)) {
return [false, "Username can only contain numbers, letters, and underscores"]
}
return [true, ""]
}
export const isPasswordValid = (password: string): [boolean, string] => { export const isPasswordValid = (password: string): [boolean, string] => {
if (password.length < MIN_PASSWORD_LENGTH) { if (password.length < MIN_PASSWORD_LENGTH) {
@ -35,7 +60,7 @@ const calculateEntropy = (password: string): number => {
} }
} }
// Empty password exception // empty password exception
if (poolSize === 0) { if (poolSize === 0) {
return 0 return 0
} }

View File

@ -0,0 +1,13 @@
export const hashContent = async (content: string) => {
// content string to byte array
const encoder = new TextEncoder()
const data = encoder.encode(content)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
// hash bytes to hex string
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
return hashHex
}

View File

@ -0,0 +1,15 @@
export const formatDate = (dateString: string | Date): string => {
if (!dateString) {
return ""
}
const d = new Date(dateString)
return d.toLocaleDateString(undefined, {
weekday: "short",
year: "2-digit",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric"
})
}

View File

@ -1,7 +1,7 @@
interface HolidayMap { interface HolidayMap {
[month: number]: { [month: number]: {
[day: number]: { [day: number]: {
name: string // Metadata for debugging name: string // metadata for debugging
greetings: string[] greetings: string[]
} }
} }
@ -48,8 +48,7 @@ const getHolidayGreeting = (username: string, date: Date): [string, string] | nu
greetings: [ greetings: [
`Happy May Day, ${username}!`, `Happy May Day, ${username}!`,
`Spring celebrations await, ${username}!`, `Spring celebrations await, ${username}!`,
`Enjoy the festivities, ${username}!`, `Enjoy the festivities, ${username}!`
`Hyvää Vappua, ${username}!`
] ]
} }
}, },
@ -110,8 +109,7 @@ const getHolidayGreeting = (username: string, date: Date): [string, string] | nu
`Happy Midsummer, ${username}!`, `Happy Midsummer, ${username}!`,
`Enjoying the longest days, ${username}?`, `Enjoying the longest days, ${username}?`,
`Summer solstice greetings, ${username}!`, `Summer solstice greetings, ${username}!`,
`Glad Midsommar, ${username}!`, `Glad Midsommar, ${username}!`
`Hyvää Juhannusta, ${username}!`
]) ])
] ]
} }
@ -127,7 +125,7 @@ const getHolidayGreeting = (username: string, date: Date): [string, string] | nu
} }
const calculateMidsummer = (year: number): number => { const calculateMidsummer = (year: number): number => {
// Saturday between 20th and 26th of June // saturday between 20th and 26th of June
const startDate = new Date(year, 5, 20) const startDate = new Date(year, 5, 20)
const dayOfWeek = startDate.getDay() const dayOfWeek = startDate.getDay()
@ -142,7 +140,7 @@ const calculateMidsummer = (year: number): number => {
} }
const gaussEaster = (year: number): [day: number, month: number] => { const gaussEaster = (year: number): [day: number, month: number] => {
// Directly from Gauss's Easter algorithm // directly from Gauss's Easter algorithm
const a = year % 19 const a = year % 19
const b = year % 4 const b = year % 4
const c = year % 7 const c = year % 7
@ -163,7 +161,7 @@ const gaussEaster = (year: number): [day: number, month: number] => {
} }
if (days > 31) { if (days > 31) {
// Jump to April // jump to April
return [days - 31, 4] return [days - 31, 4]
} }
@ -173,7 +171,7 @@ const gaussEaster = (year: number): [day: number, month: number] => {
const getTimeBasedGreeting = (username: string, date: Date): string => { const getTimeBasedGreeting = (username: string, date: Date): string => {
const hour = date.getHours() const hour = date.getHours()
// Early morning // early morning
if (0 <= hour && hour < 5) { if (0 <= hour && hour < 5) {
return getRandomGreeting([ return getRandomGreeting([
`Up late, ${username}?`, `Up late, ${username}?`,
@ -183,7 +181,7 @@ const getTimeBasedGreeting = (username: string, date: Date): string => {
]) ])
} }
// Morning // morning
if (5 <= hour && hour < 12) { if (5 <= hour && hour < 12) {
return getRandomGreeting([ return getRandomGreeting([
`Good morning, ${username}!`, `Good morning, ${username}!`,
@ -193,7 +191,7 @@ const getTimeBasedGreeting = (username: string, date: Date): string => {
]) ])
} }
// Afternoon // afternoon
if (12 <= hour && hour < 17) { if (12 <= hour && hour < 17) {
return getRandomGreeting([ return getRandomGreeting([
`Good afternoon, ${username}!`, `Good afternoon, ${username}!`,
@ -203,7 +201,7 @@ const getTimeBasedGreeting = (username: string, date: Date): string => {
]) ])
} }
// Evening // evening
if (17 <= hour && hour < 21) { if (17 <= hour && hour < 21) {
return getRandomGreeting([ return getRandomGreeting([
`Good evening, ${username}!`, `Good evening, ${username}!`,
@ -213,7 +211,7 @@ const getTimeBasedGreeting = (username: string, date: Date): string => {
]) ])
} }
// Night // night
return getRandomGreeting([ return getRandomGreeting([
`Good night, ${username}!`, `Good night, ${username}!`,
`Having a pleasant evening, ${username}?`, `Having a pleasant evening, ${username}?`,

View File

@ -3,10 +3,10 @@ import { writable } from "svelte/store"
interface PaginationState { interface PaginationState {
currentPage: number currentPage: number
pageSize: number pageSize: number
totalItems?: number // Page number rendering (+ caching metadata) totalItems?: number // page number rendering (+ caching metadata)
} }
// Limit and offset (`pageSize` and `currentPage`) values align initially with backend defaults, // limit and offset (`pageSize` and `currentPage`) values align initially with backend defaults,
// but should be adjusted based on the UI state/dimensions // but should be adjusted based on the UI state/dimensions
export const notesPagination = writable<PaginationState>({ export const notesPagination = writable<PaginationState>({

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import AuthForm from "$lib/components/AuthForm.svelte" import AuthForm from "$lib/components/AuthForm.svelte"
import { apiClient } from "$lib/client" import { apiClient } from "$lib/logic/client"
const loginHandler = (username: string, password: string) => { const loginHandler = (username: string, password: string) => {
return apiClient.login(username, password) as Promise<void> return apiClient.login(username, password) as Promise<void>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import AuthForm from "$lib/components/AuthForm.svelte" import AuthForm from "$lib/components/AuthForm.svelte"
import { apiClient } from "$lib/client" import { apiClient } from "$lib/logic/client"
const registerHandler = (username: string, password: string) => { const registerHandler = (username: string, password: string) => {
return apiClient.register(username, password) return apiClient.register(username, password)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 23 KiB