feat: rest of the initial frontend implementation

This commit is contained in:
ae 2025-04-16 22:10:44 +03:00
parent b1c7fe165e
commit fceae665cc
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
21 changed files with 1273 additions and 110 deletions

23
web/package-lock.json generated
View File

@ -8,7 +8,6 @@
"name": "sveltetest",
"version": "0.0.1",
"dependencies": {
"dompurify": "^3.2.5",
"marked": "^15.0.7"
},
"devDependencies": {
@ -832,9 +831,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.20.5",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.5.tgz",
"integrity": "sha512-zT/97KvVUo19jEGZa972ls7KICjPCB53j54TVxnEFT5VEwL16G+YFqRVwJbfxh7AmS7/Ptr1rKF7Qt4FBMDNlw==",
"version": "2.20.7",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.7.tgz",
"integrity": "sha512-dVbLMubpJJSLI4OYB+yWYNHGAhgc2bVevWuBjDj8jFUXIJOAnLwYP3vsmtcgoxNGUXoq0rHS5f7MFCsryb6nzg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1183,13 +1182,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@ -1409,15 +1401,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.130",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.130.tgz",

View File

@ -31,7 +31,6 @@
"vite": "^6.0.0"
},
"dependencies": {
"dompurify": "^3.2.5",
"marked": "^15.0.7"
}
}

View File

@ -3,13 +3,13 @@
:root {
--light-background: #f5f5f5;
--light-foreground: #e0e0e0;
--light-accent: #f3b421;
--light-text: #222831;
--light-accent: #1e2400;
--light-text: rgb(34, 40, 49);
--dark-background: #222831;
--dark-foreground: #393e46;
--dark-accent: #ffd369;
--dark-text: #eeeeee;
--dark-background: #181818;
--dark-foreground: #222222;
--dark-accent: #e0e2c2;
--dark-text: rgb(238, 238, 238);
}
@layer base {
@ -163,4 +163,83 @@
.container-page {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
/* Sidebar specific classes */
.sidebar {
@apply fixed flex h-full w-64 flex-col overflow-hidden bg-[var(--light-foreground)] transition-all duration-300;
}
.sidebar-header,
.sidebar-footer {
@apply border-[var(--light-text)]/20 p-4;
}
.sidebar-header {
@apply border-b;
}
.sidebar-footer {
@apply border-t;
}
.sidebar-item {
@apply cursor-pointer p-3 transition-colors hover:bg-[var(--light-background)];
}
.sidebar-item-active {
@apply bg-[var(--light-background)];
}
.sidebar-item-text {
@apply text-xs text-[var(--light-text)]/60;
}
.sidebar-divider {
@apply divide-y divide-[var(--light-text)]/20;
}
/* Note editor specific classes */
.note-title-input {
@apply w-full border-b border-[var(--light-text)]/20 bg-transparent pb-2 text-2xl font-bold focus:border-[var(--light-accent)];
}
.note-char-count {
@apply mt-1 text-xs text-[var(--light-text)]/60;
}
.note-textarea {
@apply h-full min-h-[300px] w-full resize-none bg-[var(--light-background)] p-4 font-mono;
}
/* Settings modal specific classes */
.modal-backdrop {
/* @apply bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black; */
@apply fixed inset-0 z-50 flex items-center justify-center bg-black;
}
.modal-content {
@apply mx-4 max-h-[90vh] w-full max-w-md overflow-y-auto rounded-lg bg-[var(--light-background)] shadow-lg;
}
.modal-section {
@apply border-b border-[var(--light-text)]/20 p-4;
}
.modal-close-button {
@apply text-[var(--light-text)]/60 hover:text-[var(--light-text)];
}
/* Loading spinner specific classes */
.spinner-dot {
@apply bg-[var(--light-accent)];
}
/* Main layout classes */
.main-header {
@apply flex items-center justify-between bg-[var(--light-foreground)] p-4 shadow-sm;
}
.main-content {
@apply flex-1 overflow-auto p-6;
}
}

View File

@ -1,4 +1,4 @@
import { derived, get, writable, type Writable } from "svelte/store"
import { get, writable, type Writable } from "svelte/store"
import { API_BASE_ADDR, AT_EXP_MS, CSRF_EXP_MS, REFRESH_BUF, UUID_REGEX } from "./const"
import { goto } from "$app/navigation"
import { usersPagination } from "./pages"
@ -11,10 +11,48 @@ interface UserResponse {
updatedAt: Date
}
export interface FullNoteResponse {
id: string
owner: string
title: string
content: string
versionNumber: number
versionCreatedAt: Date
noteCreatedAt: Date
noteUpdatedAt: Date
}
export interface NoteMetadataResponse {
id: string
owner: string
title: string
updatedAt: Date
}
interface NewNoteResponse {
title: string
content: string
}
interface VersionMetadataResponse {
versionID: string
title: string
}
interface FullVersionResponse {
versionID: string
title: string
content: string
versionNumber: number
createdAt: Date
}
export const currentUser: Writable<UserResponse | null> = writable(null)
export const currentFullNote: Writable<FullNoteResponse | null> = writable(null)
export const availableNotes: Writable<NoteMetadataResponse[] | null> = writable(null)
export const accessToken: Writable<string | null> = writable(null)
export const csrfToken: Writable<string | null> = writable(null)
export const isLoading: Writable<boolean> = writable(false)
export const isPending: Writable<boolean> = writable(false)
export const cError: Writable<string | null> = writable(null)
class ApiClient {
@ -31,10 +69,10 @@ class ApiClient {
fn: () => Promise<T>,
options: { useBearerAuth: boolean }
): Promise<T | undefined> {
isLoading.set(true)
isPending.set(true)
cError.set(null)
// 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 {
// Only bearers (JWT access tokens) need to be considered here as the refresh token cookies
@ -48,14 +86,17 @@ class ApiClient {
cError.set(err instanceof Error ? err.message : "Unknown error")
console.log(`error: ${get(cError)}`)
} finally {
isLoading.set(false)
isPending.set(false)
}
}
// Should be attached to routes that handle authentication with the bearer token (access token)
private async handleResponse<T>(response: Response): Promise<T> {
private async handleResponse<T>(
response: Response,
options: { useBearerAuth: boolean }
): Promise<T> {
if (!response.ok) {
if (response.status === 401) {
if (response.status === 401 && options.useBearerAuth) {
// This should never happen due to the token expiration checks we make client-side,
// but it's still good to have as a fallback
try {
@ -70,13 +111,12 @@ class ApiClient {
// Capitalize the error message and display (only if not 401)
const { error } = await response.json()
console.log(`response error: ${error}`)
const dError = error[0].toUpperCase() + error.substr(1).toLowerCase() + "."
throw new Error(dError)
}
return response.json()
return await response.json()
}
private async checkAndRefreshAccessToken(): Promise<void> {
@ -112,7 +152,7 @@ class ApiClient {
throw new Error("Refreshing token failed.")
}
const { accessToken: newToken } = await response.json()
const { access_token: newToken } = await response.json()
accessToken.set(newToken)
this.lastAtUpdate = new Date()
},
@ -180,9 +220,9 @@ class ApiClient {
const data = await this.handleResponse<{
id: string
username: string
}>(response)
}>(response, { useBearerAuth: false })
console.log(`'${data.username} -> ${data.id}'`)
console.log(`${data.username} -> ${data.id}`)
await goto("/login")
},
{ useBearerAuth: false }
@ -200,16 +240,17 @@ class ApiClient {
body: JSON.stringify({ username, password })
})
const { accessToken: token, user } = await this.handleResponse<{
accessToken: string
const { access_token: token, user } = await this.handleResponse<{
access_token: string
user: UserResponse
}>(response)
}>(response, { useBearerAuth: false })
accessToken.set(token)
currentUser.set(user || null)
this.lastAtUpdate = new Date()
console.log(user)
goto("/")
},
{ useBearerAuth: false }
)
@ -231,7 +272,7 @@ class ApiClient {
return
}
await this.handleResponse<void>(response)
await this.handleResponse<void>(response, { useBearerAuth: false })
await this.handleLocalLogout()
},
{ useBearerAuth: true }
@ -246,7 +287,7 @@ class ApiClient {
...this.getAuthHeader()
}
})
const user = await this.handleResponse<UserResponse>(response)
const user = await this.handleResponse<UserResponse>(response, { useBearerAuth: false })
currentUser.set(user)
},
{ useBearerAuth: true }
@ -270,7 +311,7 @@ class ApiClient {
const { accessToken: token, user } = await this.handleResponse<{
accessToken: string
user: UserResponse
}>(response)
}>(response, { useBearerAuth: false })
accessToken.set(token)
currentUser.set(user || null)
this.lastAtUpdate = new Date()
@ -293,12 +334,12 @@ class ApiClient {
})
if (response.status === 204) {
console.log("deletion succesful")
console.log("deletion successful")
await this.handleLocalLogout()
return
}
await this.handleResponse<void>(response)
await this.handleResponse<void>(response, { useBearerAuth: false })
await this.handleLocalLogout()
},
{ useBearerAuth: true }
@ -316,14 +357,14 @@ class ApiClient {
const params = new URLSearchParams()
params.append("limit", `${get(usersPagination).pageSize}`)
params.append("offset", `${get(usersPagination).currentPage}`)
const response = await fetch(`${this.baseUrl}/auth/admin/all`, {
const response = await fetch(`${this.baseUrl}/auth/admin/all?${params}`, {
headers: {
...this.getAuthHeader()
}
})
const users = await this.handleResponse<UserResponse[]>(response)
console.log(`admin: got ${users.length} results`)
const users = await this.handleResponse<UserResponse[]>(response, { useBearerAuth: false })
console.log(`admin: got ${users.length} user results`)
return users
},
@ -355,7 +396,167 @@ class ApiClient {
return
}
await this.handleResponse<void>(response)
await this.handleResponse<void>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async listNotes(): Promise<NoteMetadataResponse[] | undefined> {
return this.handleRequest(
async () => {
const params = new URLSearchParams()
params.append("limit", `${get(usersPagination).pageSize}`)
params.append("offset", `${get(usersPagination).currentPage}`)
const response = await fetch(`${this.baseUrl}/notes?${params}`, {
headers: {
...this.getAuthHeader()
}
})
const notes = await this.handleResponse<NoteMetadataResponse[]>(response, {
useBearerAuth: false
})
console.log(`got ${notes.length} note metadata results`)
return notes
},
{ useBearerAuth: true }
)
}
public async createNote(): Promise<NewNoteResponse | undefined> {
// NOTE: The initial note version doesn't allow any user input, the first user-made modification
// is applied through the version creation endpoint
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes`, {
method: "POST",
headers: {
...this.getAuthHeader()
}
})
return await this.handleResponse<NewNoteResponse>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async getFullNote(noteID: string): Promise<FullNoteResponse | undefined> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/${noteID}`, {
headers: {
...this.getAuthHeader()
}
})
return await this.handleResponse<FullNoteResponse>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async deleteNote(noteID: string): Promise<void> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}`, {
method: "DELETE",
headers: {
...this.getAuthHeader()
}
})
if (response.status === 204) {
console.log("deletion successful")
return
}
await this.handleResponse<void>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async getNoteHistory(noteID: string): Promise<VersionMetadataResponse[] | undefined> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, {
headers: {
...this.getAuthHeader()
}
})
const versions = await this.handleResponse<VersionMetadataResponse[]>(response, {
useBearerAuth: false
})
console.log(`got ${versions.length} version metadata results`)
return versions
},
{ useBearerAuth: true }
)
}
public async createVersion(noteID: string, title: string, content: string): Promise<void> {
if (!UUID_REGEX.test(noteID)) {
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
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, {
method: "POST",
headers: {
...this.getAuthHeader()
}
})
if (response.status === 204) {
console.log("creation successful")
return
}
await this.handleResponse<void>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)
}
public async getFullVersion(
noteID: string,
versionID: string
): Promise<FullVersionResponse | undefined> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
if (!UUID_REGEX.test(versionID)) {
throw new Error("Invalid version ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/${versionID}`, {
headers: {
...this.getAuthHeader()
}
})
return await this.handleResponse<FullVersionResponse>(response, { useBearerAuth: false })
},
{ useBearerAuth: true }
)

View File

@ -1,11 +1,6 @@
<script lang="ts">
import { cError } from "$lib/client"
import {
ENTROPY_CLASSES,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_ENTROPY,
MIN_PASSWORD_LENGTH
} from "$lib/const"
import { isPasswordValid } from "$lib/utils"
import { onMount } from "svelte"
export let formName: string
export let handler: (username: string, password: string) => Promise<void>
@ -20,27 +15,6 @@
// Clear any errors when swapping between login/signup views
onMount(() => cError.set(null))
const calculateEntropy = (password: string) => {
let poolSize = 0
for (const [eClass, poolPlus] of ENTROPY_CLASSES) {
if (eClass.test(password)) {
poolSize += poolPlus
}
}
// Empty password exception
if (poolSize === 0) return 0
const uniqueChars = new Set(password.split("")).size
const basicEntropy = password.length * Math.log2(poolSize)
const diversityAdjustedEntropy =
Math.log2(poolSize) + (password.length - 1) * Math.log2(uniqueChars)
return Math.min(basicEntropy, diversityAdjustedEntropy)
}
const validatePassword = () => {
if (formName === "Login") {
// Skip if logging into existing account
@ -49,29 +23,7 @@
return
}
if (password.length < MIN_PASSWORD_LENGTH) {
passwordError = `Password cannot be shorter than ${MIN_PASSWORD_LENGTH} characters`
isFormValid = false
return
}
if (password.length > MAX_PASSWORD_LENGTH) {
passwordError = `Password cannot be longer than ${MAX_PASSWORD_LENGTH} characters`
isFormValid = false
return
}
const entropy = calculateEntropy(password)
console.log(`entropy: ${entropy}`)
if (entropy < MIN_PASSWORD_ENTROPY) {
passwordError =
"Password is not complex enough (add uppercase, lowercase, numbers, and symbols)"
isFormValid = false
return
}
passwordError = ""
isFormValid = true
;[isFormValid, passwordError] = isPasswordValid(password)
}
// Update validation on password change (reactive dependency)
@ -97,16 +49,14 @@
</script>
<div class="flex min-h-screen items-center justify-center px-4">
<div class="card w-full max-w-md space-y-6">
<h1 class="text-center text-3xl font-bold">{formName}</h1>
<div class="card rounded-4x1 grid w-auto max-w-md justify-items-center space-y-6">
{#if $cError}
<div class="error">
{$cError}
</div>
{/if}
<form class="space-y-6" on:submit|preventDefault={handleSubmit}>
<form class="space-y-5" on:submit|preventDefault={handleSubmit}>
<div class="form-group">
<label for="username" class="form-label"> Username </label>
<input
@ -115,7 +65,7 @@
bind:value={username}
required
autocomplete="username"
class="w-full"
class="w-auto self-center"
/>
</div>
@ -131,7 +81,7 @@
/>
</div>
<button type="submit" class="btn-primary w-full">
<button type="submit" class="btn-primary w-full rounded-full">
{formName}
</button>
</form>

View File

@ -0,0 +1,51 @@
<script>
export let size = "40px"
// export let useDarkAccent = false
</script>
<div class="spinner" style="width: {size}; height: {size};">
<div class="bounce1 spinner-dot"></div>
<div class="bounce2 spinner-dot"></div>
<div class="bounce3 spinner-dot"></div>
</div>
<style>
.spinner {
position: relative;
}
.spinner > div {
width: 25%;
height: 25%;
border-radius: 100%;
display: inline-block;
animation: sk-bouncedelay 1.4s infinite ease-in-out both;
position: absolute;
top: 37.5%;
}
.spinner .bounce1 {
left: 0;
animation-delay: -0.32s;
}
.spinner .bounce2 {
left: 37.5%;
animation-delay: -0.16s;
}
.spinner .bounce3 {
left: 75%;
}
@keyframes sk-bouncedelay {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
</style>

View File

@ -0,0 +1,241 @@
<script lang="ts">
import { onMount } from "svelte"
import { marked } from "marked"
import { TITLE_MAX_LENGTH } from "$lib/const"
import type { FullNoteResponse } from "$lib/client"
// Props
export let note: FullNoteResponse
export let isEditing = false
export let saveNote: (title: string, content: string) => Promise<void>
// Local copy for editing (to prevent uploading every single keypress as a unique version)
let editableTitle = note.title
let editableContent = note.content
// Update the local copy when the note changes
$: if (note && note.id) {
editableTitle = note.title
editableContent = note.content
}
const handleContentChange = (
event: Event & { currentTarget: EventTarget & HTMLTextAreaElement }
) => {
if (!event.target) return
const { value } = event.target as HTMLTextAreaElement
editableContent = value
// TODO: assure this is the correct implementation & max. title length restriction is
// applied correctly before sending anything out
// Update title based on the first line if it starts with #
const firstLine = editableContent.split("\n")[0]
if (firstLine && firstLine.startsWith("# ")) {
editableTitle = firstLine.substring(2).slice(0, TITLE_MAX_LENGTH)
} else if (!editableTitle || editableTitle === "Untitled Note") {
// 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)
if (firstNonEmptyLine) {
editableTitle =
firstNonEmptyLine.length > TITLE_MAX_LENGTH
? firstNonEmptyLine.substring(0, TITLE_MAX_LENGTH - 3) + "..."
: firstNonEmptyLine
}
}
}
const handleSave = () => {
saveNote(editableTitle, editableContent)
}
const handleTitleChange = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
if (!event.target) return
const { value } = event.target as HTMLInputElement
editableTitle = value.slice(0, TITLE_MAX_LENGTH)
}
const parseMarkdown = (markdown: string) => {
if (!markdown) return ""
return marked(markdown)
}
let textarea: HTMLTextAreaElement | null
const autoResize = () => {
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = textarea.scrollHeight + "px"
}
}
onMount(() => {
if (isEditing && textarea) {
autoResize()
}
})
$: if (isEditing && textarea) {
setTimeout(autoResize, 0)
}
</script>
<div class="flex h-full flex-col">
<!-- Note title -->
<div class="mb-4">
{#if isEditing}
<input
type="text"
bind:value={editableTitle}
on:input={handleTitleChange}
placeholder="Note title"
class="note-title-input"
/>
<div class="note-char-count">
{editableTitle.length}/{TITLE_MAX_LENGTH} characters
</div>
{:else}
<h1 class="border-[var(--light-text)]/20 border-b pb-2 text-2xl font-bold">
{note.title || "Untitled Note"}
</h1>
{/if}
<div class="note-char-count">
Last updated: {new Date(note.noteUpdatedAt).toLocaleString()}
{#if !isEditing}
• Version: {note.versionNumber}
{/if}
</div>
</div>
<!-- Note content -->
<div class="flex-1 overflow-auto">
{#if isEditing}
<div class="relative h-full">
<textarea
bind:this={textarea}
bind:value={editableContent}
on:input={handleContentChange}
placeholder="Write your markdown note here..."
class="note-textarea"
></textarea>
<div class="absolute bottom-4 right-4">
<button on:click={handleSave} class="btn-primary"> Save </button>
</div>
</div>
{:else}
<!-- Rendered markdown preview -->
<div class="prose markdown-preview max-w-none p-4">
{@html parseMarkdown(note.content)}
</div>
{/if}
</div>
</div>
<style>
:global(.markdown-preview h1) {
font-size: 1.8rem;
margin: 1.5rem 0 1rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--light-foreground);
}
:global(.dark .markdown-preview h1) {
border-bottom-color: var(--dark-foreground);
}
:global(.markdown-preview h2) {
font-size: 1.5rem;
margin: 1.5rem 0 1rem;
}
:global(.markdown-preview h3) {
font-size: 1.3rem;
margin: 1.2rem 0 0.8rem;
}
:global(.markdown-preview p) {
margin: 1rem 0;
}
:global(.markdown-preview ul, .markdown-preview ol) {
margin: 1rem 0;
padding-left: 1.5rem;
}
:global(.markdown-preview ul) {
list-style-type: disc;
}
:global(.markdown-preview ol) {
list-style-type: decimal;
}
:global(.markdown-preview code) {
font-family: monospace;
padding: 0.2rem 0.4rem;
border-radius: 3px;
background-color: var(--light-foreground);
}
:global(.dark .markdown-preview code) {
background-color: var(--dark-foreground);
}
:global(.markdown-preview pre) {
background-color: var(--light-foreground);
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
}
:global(.dark .markdown-preview pre) {
background-color: var(--dark-foreground);
}
:global(.markdown-preview blockquote) {
border-left: 4px solid var(--light-accent);
padding-left: 1rem;
margin: 1rem 0;
color: var(--light-text);
opacity: 0.7;
}
:global(.dark .markdown-preview blockquote) {
border-left-color: var(--dark-accent);
color: var(--dark-text);
opacity: 0.7;
}
:global(.markdown-preview a) {
color: var(--light-accent);
text-decoration: underline;
}
:global(.dark .markdown-preview a) {
color: var(--dark-accent);
}
:global(.markdown-preview table) {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
:global(.markdown-preview th, .markdown-preview td) {
border: 1px solid rgba(var(--light-text-rgb, 34, 40, 49), 0.2);
padding: 8px;
text-align: left;
}
:global(.dark .markdown-preview th, .dark .markdown-preview td) {
border: 1px solid rgba(var(--dark-text-rgb, 238, 238, 238), 0.2);
}
:global(.markdown-preview th) {
background-color: var(--light-foreground);
}
:global(.dark .markdown-preview th) {
background-color: var(--dark-foreground);
}
</style>

View File

@ -0,0 +1,177 @@
<script lang="ts">
import { onMount } from "svelte"
import { goto } from "$app/navigation"
import ThemeToggle from "./ThemeToggle.svelte"
import Sidebar from "./Sidebar.svelte"
import NoteEditor from "./NoteEditor.svelte"
import SettingsModal from "./SettingsModal.svelte"
import {
apiClient,
currentUser,
isPending,
currentFullNote,
availableNotes,
cError
} from "$lib/client"
import ToggleSidebar from "$lib/icons/ToggleSidebar.svelte"
// Props
export let isLoading = false
// State
let sidebarOpen = true
let showSettings = false
let isEditing = false
onMount(async () => {
isLoading = true
try {
// The following fetch attempts to refresh any expired tokens automatically
await apiClient.getCurrentUser()
// If still no current user after the fetch attempt, redirect to login
if (!$currentUser) {
goto("/login")
return
}
await loadNotes()
} catch (error) {
console.error(`error during auth: ${error}`)
goto("/login")
} finally {
isLoading = false
}
})
const loadNotes = async () => {
const notes = await apiClient.listNotes()
if (notes) {
availableNotes.set(notes)
}
}
const toggleSidebar = () => {
sidebarOpen = !sidebarOpen
}
const toggleSettings = () => {
showSettings = !showSettings
}
const createNewNote = async () => {
const newNote = await apiClient.createNote()
if (newNote) {
// Refresh notes list
await loadNotes()
// Get the full note details of the newly created note
// We need to find the ID from the updated availableNotes
if ($availableNotes && $availableNotes.length > 0) {
const latestNote = $availableNotes[0] // Assuming the latest note is first
await selectNote(latestNote.id)
}
isEditing = true
}
}
const selectNote = async (noteId: string) => {
const note = await apiClient.getFullNote(noteId)
if (note) {
currentFullNote.set(note)
isEditing = false
}
}
const toggleEditMode = () => {
isEditing = !isEditing
}
const saveNote = async (title: string, content: string) => {
if ($currentFullNote) {
await apiClient.createVersion($currentFullNote.id, title, content)
// Refresh the current note to get the latest version (+ assure that client and server are synced)
await selectNote($currentFullNote.id)
// Refresh the notes list to update any changes (ordering based on `updatedAt` field)
await loadNotes()
isEditing = false
}
}
const logout = async () => {
await apiClient.logout()
}
</script>
<div class="flex h-screen bg-[var(--light-background)]">
<!-- Loading overlay -->
{#if $isPending}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="rounded-lg bg-[var(--light-background)] p-6 shadow-lg">
<p class="text-center">Loading...</p>
</div>
</div>
{/if}
<!-- Error notification -->
{#if $cError}
<div class="fixed right-4 top-4 z-50 max-w-md">
<div class="error">
{$cError}
<button class="ml-2 text-red-700" on:click={() => cError.set(null)}> × </button>
</div>
</div>
{/if}
<!-- Sidebar -->
<Sidebar
{sidebarOpen}
notes={$availableNotes || []}
currentNote={$currentFullNote}
{toggleSettings}
{logout}
{createNewNote}
{selectNote}
/>
<!-- Main content -->
<div
class="flex flex-1 flex-col overflow-hidden transition-all duration-300"
class:ml-64={sidebarOpen}
class:ml-0={!sidebarOpen}
>
<!-- Top navbar -->
<header class="main-header">
<button
on:click={toggleSidebar}
class="btn-secondary rounded-md p-2"
aria-label="Toggle sidebar"
>
<ToggleSidebar />
</button>
<div class="flex items-center space-x-4">
<!-- Content unchanged -->
</div>
</header>
<!-- Note content area -->
<main class="main-content">
{#if $currentFullNote}
<button on:click={toggleEditMode} class="btn-primary">
{isEditing ? "Preview" : "Edit"}
</button>
{/if}
</main>
</div>
<!-- Settings Modal -->
{#if showSettings}
<SettingsModal onClose={toggleSettings} />
{/if}
</div>

View File

@ -0,0 +1,229 @@
<script lang="ts">
import { apiClient, cError } from "$lib/client"
import Close from "$lib/icons/Close.svelte"
import { isPasswordValid } from "$lib/utils"
import { onMount } from "svelte"
// Props
export let onClose: () => void
let currentPassword = ""
let newPassword = ""
let confirmPassword = ""
let passwordError = ""
let isNewPasswordValid = true
let deleteConfirmPassword = ""
let deleteConfirmText = ""
let modalContent: HTMLDivElement | null = null
let previouslyFocusedElement: HTMLElement | null = null
onMount(() => {
// Store the element that had focus before opening the modal
previouslyFocusedElement = document.activeElement as HTMLElement
// Focus the first focusable element in the modal
if (modalContent !== null) {
const focusableElements = (modalContent as HTMLDivElement).querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusableElements.length > 0) {
;(focusableElements[0] as HTMLElement).focus()
} else {
;(modalContent as HTMLDivElement).focus()
}
}
// When component unmounts, restore focus
return () => {
if (previouslyFocusedElement) {
previouslyFocusedElement.focus()
}
}
})
const changePassword = async () => {
if (!currentPassword) {
passwordError = "Current password is required"
return
}
if (!newPassword) {
passwordError = "New password is required"
return
}
if (newPassword !== confirmPassword) {
passwordError = "New passwords do not match"
return
}
;[isNewPasswordValid, passwordError] = isPasswordValid(newPassword)
if (!isNewPasswordValid) {
// Error will be automatically displayed
return
}
try {
await apiClient.updateCurrentUserPassword(currentPassword, newPassword)
currentPassword = ""
newPassword = ""
confirmPassword = ""
cError.set("Password updated successfully")
setTimeout(() => {
cError.set(null)
onClose()
}, 2000)
} catch (error) {
// Error handling is done by the API client
}
}
const deleteAccount = async () => {
if (!deleteConfirmPassword) {
passwordError = "Password is required to delete your account"
return
}
if (deleteConfirmText !== "DELETE") {
passwordError = "Please type DELETE to confirm account deletion"
return
}
try {
await apiClient.deleteCurrentUser(deleteConfirmPassword)
// The API client will handle the redirect to login page after successful deletion
} catch (error) {
// Error handling is done by the API client
}
}
const handleClickOutside = (event: MouseEvent) => {
// Close the modal if user clicks outside of it
const target = event.target as HTMLElement
if (target.classList.contains("modal-backdrop")) {
onClose()
}
}
const handleModalContentKeydown = (event: KeyboardEvent) => {
// Accessibility compliance handler, actual handling is done by the global keydown handler
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose()
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<div
class="modal-backdrop"
on:click={handleClickOutside}
on:keydown={handleKeydown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={modalContent}
class="modal-content"
on:click|stopPropagation
on:keydown={handleModalContentKeydown}
role="document"
tabindex="-1"
>
<!-- Header -->
<div class="modal-section flex items-center justify-between" role="heading" aria-level="1">
<h2 class="text-xl font-bold">Settings</h2>
<button on:click={onClose} class="modal-close-button" aria-label="Close settings">
<Close />
</button>
</div>
<!-- Account settings -->
<div class="modal-section">
<h3 class="mb-4 text-lg font-bold">Account Settings</h3>
{#if passwordError}
<div class="error mb-4" role="alert">
{passwordError}
</div>
{/if}
<div class="space-y-4">
<h4 class="font-bold">Change Password</h4>
<!-- Current Password -->
<div class="form-group">
<label class="form-label" for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" bind:value={currentPassword} class="w-full" />
</div>
<!-- New Password -->
<div class="form-group">
<label class="form-label" for="newPassword">New Password</label>
<input type="password" id="newPassword" bind:value={newPassword} class="w-full" />
</div>
<!-- Confirm New Password -->
<div class="form-group">
<label class="form-label" for="confirmPassword">Confirm New Password</label>
<input type="password" id="confirmPassword" bind:value={confirmPassword} class="w-full" />
</div>
<button on:click={changePassword} class="btn-primary w-full"> Change Password </button>
</div>
</div>
<!-- Danger zone -->
<div class="p-4">
<h3 class="mb-4 text-lg font-bold text-red-500">Danger Zone</h3>
<div class="space-y-4">
<p class="text-sm">
Deleting your account will permanently remove all your notes and account information. This
action cannot be undone.
</p>
<!-- Confirm Password -->
<div class="form-group">
<label class="form-label" for="deleteConfirmPassword">Your Password</label>
<input
type="password"
id="deleteConfirmPassword"
bind:value={deleteConfirmPassword}
class="w-full"
/>
</div>
<!-- Type DELETE to confirm -->
<div class="form-group">
<label class="form-label" for="deleteConfirmText">Type DELETE to confirm</label>
<input
type="text"
id="deleteConfirmText"
bind:value={deleteConfirmText}
class="w-full"
placeholder="DELETE"
/>
</div>
<button
on:click={deleteAccount}
class="w-full rounded bg-red-500 px-4 py-2 font-bold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
disabled={deleteConfirmText !== "DELETE" || !deleteConfirmPassword}
>
Delete My Account
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,107 @@
<script lang="ts">
import type { NoteMetadataResponse, FullNoteResponse } from "$lib/client"
import CreateNew from "$lib/icons/CreateNew.svelte"
import Logout from "$lib/icons/Logout.svelte"
import Search from "$lib/icons/Search.svelte"
import Settings from "$lib/icons/Settings.svelte"
// Props
export let sidebarOpen = true
export let notes: NoteMetadataResponse[] = []
export let currentNote: FullNoteResponse | null = null
export let toggleSettings: () => void
export let logout: () => Promise<void>
export let createNewNote: () => Promise<void>
export let selectNote: (noteId: string) => Promise<void>
const formatDate = (dateString: string | Date): string => {
if (!dateString) return ""
const d = new Date(dateString)
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault() // Prevent page scroll on space
selectNote(noteID)
}
}
// Client-side search
let searchQuery = ""
$: filteredNotes = searchQuery
? notes.filter((note) => note.title.toLowerCase().includes(searchQuery.toLowerCase()))
: notes
</script>
<aside class="sidebar" class:translate-x-0={sidebarOpen} class:translate-x-[-100%]={!sidebarOpen}>
<!-- Sidebar header -->
<div class="sidebar-header">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl font-bold">Notes</h1>
<button
on:click={createNewNote}
class="btn-primary rounded-full p-2"
aria-label="Create new note"
>
<CreateNew />
</button>
</div>
<!-- Search bar -->
<div class="relative">
<input
type="text"
placeholder="Search notes..."
bind:value={searchQuery}
class="w-full rounded-md py-2 pl-8 pr-2"
/>
<Search />
</div>
</div>
<!-- Notes list -->
<div class="flex-1 overflow-y-auto">
{#if filteredNotes.length > 0}
<ul class="sidebar-divider" role="listbox">
{#each filteredNotes as note}
<li
class="sidebar-item"
class:sidebar-item-active={currentNote && note.id === currentNote.id}
on:click={() => selectNote(note.id)}
on:keydown={(e) => handleNoteKeydown(e, note.id)}
tabindex="0"
role="option"
aria-selected={currentNote && note.id === currentNote.id}
>
<h3 class="truncate font-bold">{note.title || "Untitled Note"}</h3>
<p class="sidebar-item-text mt-1">
Last updated: {formatDate(note.updatedAt)}
</p>
</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 -->
<div class="sidebar-footer">
<div class="flex justify-between">
<button
on:click={toggleSettings}
class="btn-secondary rounded-md p-2"
aria-label="Toggle settings"
>
<Settings />
</button>
<button on:click={logout} class="btn-secondary rounded-md p-2" aria-label="Logout">
<Logout />
</button>
</div>
</div>
</aside>

View File

@ -11,6 +11,8 @@ export const MIN_PASSWORD_LENGTH = 12
export const MAX_PASSWORD_LENGTH = 72
export const MIN_PASSWORD_ENTROPY = 60.0
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 File

@ -0,0 +1,9 @@
<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="M6 18L18 6M6 6l12 12" />
</svg>

After

Width:  |  Height:  |  Size: 223 B

View File

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

After

Width:  |  Height:  |  Size: 251 B

View File

@ -0,0 +1,14 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.9999 2C10.2385 2 7.99991 4.23858 7.99991 7C7.99991 7.55228 8.44762 8 8.99991 8C9.55219 8 9.99991 7.55228 9.99991 7C9.99991 5.34315 11.3431 4 12.9999 4H16.9999C18.6568 4 19.9999 5.34315 19.9999 7V17C19.9999 18.6569 18.6568 20 16.9999 20H12.9999C11.3431 20 9.99991 18.6569 9.99991 17C9.99991 16.4477 9.55219 16 8.99991 16C8.44762 16 7.99991 16.4477 7.99991 17C7.99991 19.7614 10.2385 22 12.9999 22H16.9999C19.7613 22 21.9999 19.7614 21.9999 17V7C21.9999 4.23858 19.7613 2 16.9999 2H12.9999Z"
fill="#000000"
/>
<path
d="M13.9999 11C14.5522 11 14.9999 11.4477 14.9999 12C14.9999 12.5523 14.5522 13 13.9999 13V11Z"
fill="#000000"
/>
<path
d="M5.71783 11C5.80685 10.8902 5.89214 10.7837 5.97282 10.682C6.21831 10.3723 6.42615 10.1004 6.57291 9.90549C6.64636 9.80795 6.70468 9.72946 6.74495 9.67492L6.79152 9.61162L6.804 9.59454L6.80842 9.58848C6.80846 9.58842 6.80892 9.58778 5.99991 9L6.80842 9.58848C7.13304 9.14167 7.0345 8.51561 6.58769 8.19098C6.14091 7.86637 5.51558 7.9654 5.19094 8.41215L5.18812 8.41602L5.17788 8.43002L5.13612 8.48679C5.09918 8.53682 5.04456 8.61033 4.97516 8.7025C4.83623 8.88702 4.63874 9.14542 4.40567 9.43937C3.93443 10.0337 3.33759 10.7481 2.7928 11.2929L2.08569 12L2.7928 12.7071C3.33759 13.2519 3.93443 13.9663 4.40567 14.5606C4.63874 14.8546 4.83623 15.113 4.97516 15.2975C5.04456 15.3897 5.09918 15.4632 5.13612 15.5132L5.17788 15.57L5.18812 15.584L5.19045 15.5872C5.51509 16.0339 6.14091 16.1336 6.58769 15.809C7.0345 15.4844 7.13355 14.859 6.80892 14.4122L5.99991 15C6.80892 14.4122 6.80897 14.4123 6.80892 14.4122L6.804 14.4055L6.79152 14.3884L6.74495 14.3251C6.70468 14.2705 6.64636 14.1921 6.57291 14.0945C6.42615 13.8996 6.21831 13.6277 5.97282 13.318C5.89214 13.2163 5.80685 13.1098 5.71783 13H13.9999V11H5.71783Z"
fill="#000000"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-[var(--light-text)]/60 absolute left-2 top-3 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>

After

Width:  |  Height:  |  Size: 305 B

View File

@ -0,0 +1,6 @@
<svg viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"
><path
d="M772.672 575.808V448.192l70.848-70.848a370.688 370.688 0 0 0-56.512-97.664l-96.64 25.92-110.528-63.808-25.92-96.768a374.72 374.72 0 0 0-112.832 0l-25.92 96.768-110.528 63.808-96.64-25.92c-23.68 29.44-42.816 62.4-56.576 97.664l70.848 70.848v127.616l-70.848 70.848c13.76 35.264 32.832 68.16 56.576 97.664l96.64-25.92 110.528 63.808 25.92 96.768a374.72 374.72 0 0 0 112.832 0l25.92-96.768 110.528-63.808 96.64 25.92c23.68-29.44 42.816-62.4 56.512-97.664l-70.848-70.848z m39.744 254.848l-111.232-29.824-55.424 32-29.824 111.36c-37.76 10.24-77.44 15.808-118.4 15.808-41.024 0-80.768-5.504-118.464-15.808l-29.888-111.36-55.424-32-111.168 29.824A447.552 447.552 0 0 1 64 625.472L145.472 544v-64L64 398.528A447.552 447.552 0 0 1 182.592 193.28l111.168 29.824 55.424-32 29.888-111.36A448.512 448.512 0 0 1 497.472 64c41.024 0 80.768 5.504 118.464 15.808l29.824 111.36 55.424 32 111.232-29.824c56.32 55.68 97.92 126.144 118.592 205.184L849.472 480v64l81.536 81.472a447.552 447.552 0 0 1-118.592 205.184zM497.536 627.2a115.2 115.2 0 1 0 0-230.4 115.2 115.2 0 0 0 0 230.4z m0 76.8a192 192 0 1 1 0-384 192 192 0 0 1 0 384z"
fill="#000000"
/></svg
>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 235 B

48
web/src/lib/utils.ts Normal file
View File

@ -0,0 +1,48 @@
import {
ENTROPY_CLASSES,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_ENTROPY,
MIN_PASSWORD_LENGTH
} from "./const"
export const isPasswordValid = (password: string): [boolean, string] => {
if (password.length < MIN_PASSWORD_LENGTH) {
return [false, `Password cannot be shorter than ${MIN_PASSWORD_LENGTH} characters`]
}
if (password.length > MAX_PASSWORD_LENGTH) {
return [false, `Password cannot be longer than ${MAX_PASSWORD_LENGTH} characters`]
}
const entropy = calculateEntropy(password)
console.log(`entropy: ${entropy}`)
if (entropy < MIN_PASSWORD_ENTROPY) {
return [
false,
"Password is not complex enough (add uppercase, lowercase, numbers, and symbols)"
]
}
return [true, ""]
}
const calculateEntropy = (password: string) => {
let poolSize = 0
for (const [eClass, poolPlus] of ENTROPY_CLASSES) {
if (eClass.test(password)) {
poolSize += poolPlus
}
}
// Empty password exception
if (poolSize === 0) return 0
const uniqueChars = new Set(password.split("")).size
const basicEntropy = password.length * Math.log2(poolSize)
const diversityAdjustedEntropy =
Math.log2(poolSize) + (password.length - 1) * Math.log2(uniqueChars)
return Math.min(basicEntropy, diversityAdjustedEntropy)
}

View File

@ -7,6 +7,6 @@
{@render children()}
<div class="absolute top-4 right-4">
<div class="absolute right-4 top-4">
<ThemeToggle />
</div>

View File

@ -1,2 +1,34 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script>
import NoteView from "$lib/components/NoteView.svelte"
import LoadingSpinner from "$lib/components/LoadingSpinner.svelte"
import { onMount } from "svelte"
import { fade } from "svelte/transition"
let isLoading = true
onMount(() => {
// Set a small timeout to ensure the loading state shows
// -> Prevents component flickering during auth checks
setTimeout(() => {
isLoading = false
}, 300)
})
</script>
{#if isLoading}
<div class="loading-container" transition:fade={{ duration: 200 }}>
<LoadingSpinner />
</div>
{:else}
<NoteView />
{/if}
<style>
.loading-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100%;
}
</style>