feat: rest of the initial frontend implementation
This commit is contained in:
parent
b1c7fe165e
commit
fceae665cc
23
web/package-lock.json
generated
23
web/package-lock.json
generated
@ -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",
|
||||
|
@ -31,7 +31,6 @@
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.5",
|
||||
"marked": "^15.0.7"
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
)
|
||||
|
@ -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>
|
||||
|
51
web/src/lib/components/LoadingSpinner.svelte
Normal file
51
web/src/lib/components/LoadingSpinner.svelte
Normal 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>
|
241
web/src/lib/components/NoteEditor.svelte
Normal file
241
web/src/lib/components/NoteEditor.svelte
Normal 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>
|
177
web/src/lib/components/NoteView.svelte
Normal file
177
web/src/lib/components/NoteView.svelte
Normal 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>
|
229
web/src/lib/components/SettingsModal.svelte
Normal file
229
web/src/lib/components/SettingsModal.svelte
Normal 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>
|
107
web/src/lib/components/Sidebar.svelte
Normal file
107
web/src/lib/components/Sidebar.svelte
Normal 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>
|
@ -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"
|
||||
)
|
||||
|
9
web/src/lib/icons/Close.svelte
Normal file
9
web/src/lib/icons/Close.svelte
Normal 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 |
7
web/src/lib/icons/CreateNew.svelte
Normal file
7
web/src/lib/icons/CreateNew.svelte
Normal 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 |
14
web/src/lib/icons/Logout.svelte
Normal file
14
web/src/lib/icons/Logout.svelte
Normal 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 |
14
web/src/lib/icons/Search.svelte
Normal file
14
web/src/lib/icons/Search.svelte
Normal 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 |
6
web/src/lib/icons/Settings.svelte
Normal file
6
web/src/lib/icons/Settings.svelte
Normal 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 |
14
web/src/lib/icons/ToggleSidebar.svelte
Normal file
14
web/src/lib/icons/ToggleSidebar.svelte
Normal 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
48
web/src/lib/utils.ts
Normal 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)
|
||||
}
|
@ -7,6 +7,6 @@
|
||||
|
||||
{@render children()}
|
||||
|
||||
<div class="absolute top-4 right-4">
|
||||
<div class="absolute right-4 top-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user