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
22
web/package-lock.json
generated
@ -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",
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1191
web/src/app.css
@ -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>
|
||||||
|
@ -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 />
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
@ -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.
|
|
13
web/src/lib/icons/ChevronDown.svelte
Normal 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>
|
12
web/src/lib/icons/ChevronUp.svelte
Normal 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>
|
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
18
web/src/lib/icons/editor/EditPen.svelte
Normal 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>
|
18
web/src/lib/icons/editor/Save.svelte
Normal 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
|
||||||
|
>
|
25
web/src/lib/icons/editor/ViewEye.svelte
Normal 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>
|
18
web/src/lib/icons/sidebar/AdminShield.svelte
Normal 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>
|
13
web/src/lib/icons/sidebar/ChevronLeft.svelte
Normal 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>
|
13
web/src/lib/icons/sidebar/ChevronRight.svelte
Normal 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>
|
13
web/src/lib/icons/sidebar/Create.svelte
Normal 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>
|
@ -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 |
21
web/src/lib/icons/sidebar/Exit.svelte
Normal 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>
|
@ -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 |
@ -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 |
18
web/src/lib/icons/sidebar/Tag.svelte
Normal 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>
|
18
web/src/lib/icons/sidebar/User.svelte
Normal 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>
|
18
web/src/lib/icons/sidebar/WebhookTruck.svelte
Normal 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>
|
Before Width: | Height: | Size: 184 B After Width: | Height: | Size: 184 B |
Before Width: | Height: | Size: 666 B After Width: | Height: | Size: 666 B |
@ -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
@ -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
|
||||||
|
}
|
@ -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
|
||||||
}
|
}
|
13
web/src/lib/util/contentValidation.ts
Normal 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
|
||||||
|
}
|
15
web/src/lib/util/contentVisual.ts
Normal 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"
|
||||||
|
})
|
||||||
|
}
|
@ -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}?`,
|
||||||
|
@ -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>({
|
@ -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>
|
||||||
|
@ -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)
|
||||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 23 KiB |