feat: frontend finetuning

This commit is contained in:
ae 2025-04-18 19:13:37 +03:00
parent 9805d4720e
commit c3f377c635
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
15 changed files with 441 additions and 238 deletions

View File

@ -12,6 +12,11 @@
--dark-text: rgb(238, 238, 238);
}
/* Scrollbar width */
::-webkit-scrollbar {
width: 0px;
}
@layer base {
body {
@apply bg-[var(--light-background)] text-[var(--light-text)] transition-colors duration-200;
@ -58,7 +63,6 @@
}
input:focus,
textarea:focus,
select:focus {
@apply ring-2 ring-[var(--light-accent)] outline-none;
}
@ -70,7 +74,6 @@
}
.dark input:focus,
.dark textarea:focus,
.dark select:focus {
@apply ring-[var(--dark-accent)];
}
@ -166,11 +169,11 @@
/* Sidebar */
.sidebar {
@apply fixed flex h-full w-64 flex-col overflow-hidden bg-[var(--light-foreground)] transition-all duration-300;
@apply fixed flex h-full w-64 flex-col overflow-hidden border-r border-[var(--light-foreground)] bg-[var(--light-foreground)] transition-all duration-300;
}
.dark .sidebar {
@apply bg-[var(--dark-foreground)];
@apply border-[var(--dark-foreground)] bg-[var(--dark-foreground)];
}
.sidebar-header,
@ -223,9 +226,29 @@
@apply divide-[var(--dark-text)]/20;
}
.search-bar {
@apply w-full rounded-md py-2 pr-3 pl-9;
}
.search-bar-icon {
@apply absolute top-3 left-7 h-4 w-4 text-[var(--light-text)]/60;
}
.dark .search-bar-icon {
@apply text-[var(--dark-text)]/60;
}
.general-sidebar-icon {
@apply h-4 w-4 text-[var(--light-text)]/60;
}
.dark .general-sidebar-icon {
@apply text-[var(--dark-text)]/60;
}
/* Note editor */
.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)];
@apply w-full rounded-2xl border-b border-[var(--light-text)]/20 bg-transparent pb-2 text-2xl font-bold focus:border-[var(--light-accent)];
}
.dark .note-title-input {
@ -233,7 +256,7 @@
}
.note-char-count {
@apply mt-1 text-xs text-[var(--light-text)]/60;
@apply mt-2 text-xs text-[var(--light-text)]/60;
}
.dark .note-char-count {
@ -241,11 +264,154 @@
}
.note-textarea {
@apply h-full min-h-[300px] w-full resize-none bg-[var(--light-background)] p-4 font-mono;
@apply h-full max-h-full w-full resize-none rounded-2xl bg-transparent p-3.5 font-mono outline-none focus:border-4 focus:border-[var(--light-accent)]/60;
}
.note-save-button {
@apply absolute right-10 bottom-10;
}
.dark .note-textarea {
@apply bg-[var(--dark-background)];
@apply focus:border-[var(--dark-accent)]/60;
}
/* Markdown preview */
.markdown-preview h1 {
@apply mt-6 mb-4 border-b border-[var(--light-foreground)] pb-3 text-3xl;
}
.dark .markdown-preview h1 {
@apply border-[var(--dark-foreground)];
}
.markdown-preview h2 {
@apply mt-6 mb-4 text-2xl;
}
.markdown-preview h3 {
@apply mt-5 mb-3 text-xl;
}
.markdown-preview p {
@apply my-4;
}
.markdown-preview ul,
.markdown-preview ol {
@apply my-4 pl-5;
}
.markdown-preview ul {
@apply list-disc;
}
.markdown-preview ol {
@apply list-decimal;
}
.markdown-preview li > ul,
.markdown-preview li > ol,
.markdown-preview ul > ul,
.markdown-preview ul > ol,
.markdown-preview ol > ol,
.markdown-preview ol > ul {
@apply my-1; /* Reduced vertical spacing for nested lists */
}
.markdown-preview ul ul:has(> li > input[type="checkbox"]) {
@apply pl-11;
}
.markdown-preview ul ul ul:has(> li > input[type="checkbox"]) {
@apply pl-11;
}
.markdown-preview li span {
@apply ml-1.5;
}
.markdown-preview li:has(> input[type="checkbox"]) {
/* Bullet removal */
@apply -ml-4.5 list-none;
}
.markdown-preview input[type="checkbox"] {
/* Actual checkbox styling */
@apply mr-1 h-4 w-4 appearance-none rounded-full border-[var(--light-text)]/30 bg-[var(--light-foreground)] p-0 align-middle;
}
.dark .markdown-preview input[type="checkbox"] {
@apply border-[var(--dark-text)]/30 bg-[var(--dark-foreground)];
}
.markdown-preview input[type="checkbox"]:checked {
@apply border-[var(--light-accent)] bg-[var(--light-accent)];
}
.dark .markdown-preview input[type="checkbox"]:checked {
@apply border-[var(--dark-accent)] bg-[var(--dark-accent)];
}
.markdown-preview hr {
@apply border-[var(--light-text)]/20;
}
.dark .markdown-preview hr {
@apply border-[var(--dark-text)]/20;
}
.markdown-preview code {
@apply rounded bg-[var(--light-foreground)] px-1 py-0.5 font-mono;
}
.dark .markdown-preview code {
@apply bg-[var(--dark-foreground)];
}
.markdown-preview pre {
@apply overflow-x-auto rounded-2xl bg-[var(--light-foreground)] p-3;
}
.dark .markdown-preview pre {
@apply bg-[var(--dark-foreground)];
}
.markdown-preview blockquote {
@apply my-4 border-l-4 border-[var(--light-accent)] pl-4 text-[var(--light-text)] opacity-70;
}
.dark .markdown-preview blockquote {
@apply border-[var(--dark-accent)] text-[var(--dark-text)];
}
.markdown-preview a {
@apply text-[var(--light-accent)] underline;
}
.dark .markdown-preview a {
@apply text-[var(--dark-accent)];
}
.markdown-preview table {
@apply my-4 w-full border-collapse;
}
.markdown-preview th,
.markdown-preview td {
@apply border border-[var(--light-text)]/20 p-2 text-left;
}
.dark .markdown-preview th,
.dark .markdown-preview td {
@apply border-[var(--dark-text)]/20;
}
.markdown-preview th {
@apply bg-[var(--light-foreground)];
}
.dark .markdown-preview th {
@apply bg-[var(--dark-foreground)];
}
/* Settings modal */
@ -292,7 +458,7 @@
}
.dark .main-header {
@apply flex items-center justify-between bg-[var(--dark-foreground)] p-4 shadow-sm;
@apply bg-[var(--dark-foreground)];
}
.main-content {

View File

@ -3,7 +3,7 @@ import { API_BASE_ADDR, AT_EXP_MS, CSRF_EXP_MS, REFRESH_BUF, UUID_REGEX } from "
import { goto } from "$app/navigation"
import { usersPagination } from "./pages"
interface UserResponse {
interface User {
id: string
username: string
isAdmin: boolean
@ -11,7 +11,15 @@ interface UserResponse {
updatedAt: Date
}
export interface FullNoteResponse {
interface ApiUserResponse {
id: string
username: string
is_admin: boolean
created_at: string
updated_at: string
}
export interface FullNote {
id: string
owner: string
title: string
@ -22,13 +30,31 @@ export interface FullNoteResponse {
noteUpdatedAt: Date
}
export interface NoteMetadataResponse {
interface ApiFullNoteResponse {
note_id: string
owner_id: string
title: string
content: string
version_number: number
version_created_at: string
note_created_at: string
note_updated_at: string
}
export interface NoteMetadata {
id: string
owner: string
title: string
updatedAt: Date
}
interface ApiNoteMetadataResponse {
note_id: string
owner_id: string
title: string
updated_at: string
}
interface NewNoteResponse {
title: string
content: string
@ -47,9 +73,9 @@ interface FullVersionResponse {
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 currentUser: Writable<User | null> = writable(null)
export const currentFullNote: Writable<FullNote | null> = writable(null)
export const availableNotes: Writable<NoteMetadata[] | null> = writable(null)
export const accessToken: Writable<string | null> = writable(null)
export const csrfToken: Writable<string | null> = writable(null)
export const isPending: Writable<boolean> = writable(false)
@ -235,6 +261,40 @@ class ApiClient {
return false
}
private deserializeUser(apiResponse: ApiUserResponse): User {
return {
id: apiResponse.id,
username: apiResponse.username,
isAdmin: apiResponse.is_admin,
createdAt: new Date(apiResponse.created_at),
updatedAt: new Date(apiResponse.updated_at)
}
}
private deserializeNoteMetadatas(apiResponses: ApiNoteMetadataResponse[]): NoteMetadata[] {
return apiResponses.map((res) => {
return {
id: res.note_id,
owner: res.owner_id,
title: res.title,
updatedAt: new Date(res.updated_at)
}
})
}
private deserializeFullNote(apiResponse: ApiFullNoteResponse): FullNote {
return {
id: apiResponse.note_id,
owner: apiResponse.owner_id,
title: apiResponse.title,
content: apiResponse.content,
versionNumber: apiResponse.version_number,
versionCreatedAt: new Date(apiResponse.version_created_at),
noteCreatedAt: new Date(apiResponse.note_created_at),
noteUpdatedAt: new Date(apiResponse.note_updated_at)
}
}
public async register(username: string, password: string): Promise<void> {
return this.handleRequest(
async () => {
@ -260,25 +320,20 @@ class ApiClient {
public async login(username: string, password: string): Promise<void> {
return this.handleRequest(
async () => {
const params = new URLSearchParams()
params.append("includeUser", "true")
const response = await fetch(`${this.baseUrl}/auth/login?${params}`, {
const response = await fetch(`${this.baseUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include"
})
const { access_token: token, user } = await this.handleResponse<{
const { access_token: token } = await this.handleResponse<{
access_token: string
user: UserResponse
}>(response, { useBearerAuth: false })
accessToken.set(token)
currentUser.set(user || null)
this.lastAtUpdate = new Date()
console.log(user)
goto("/")
},
{ useBearerAuth: false }
@ -317,7 +372,9 @@ class ApiClient {
...this.getAuthHeader()
}
})
const user = await this.handleResponse<UserResponse>(response, { useBearerAuth: false })
const data = await this.handleResponse<ApiUserResponse>(response, { useBearerAuth: false })
const user = this.deserializeUser(data)
console.log(user)
currentUser.set(user)
},
{ useBearerAuth: true }
@ -334,13 +391,14 @@ class ApiClient {
const response = await fetch(`${this.baseUrl}/auth/owner`, {
method: "PUT",
headers: {
...this.getAuthHeader()
...this.getAuthHeader(),
"Content-Type": "application/json"
},
body: JSON.stringify(data)
})
const { accessToken: token, user } = await this.handleResponse<{
accessToken: string
user: UserResponse
user: User
}>(response, { useBearerAuth: false })
accessToken.set(token)
currentUser.set(user || null)
@ -359,7 +417,7 @@ class ApiClient {
method: "DELETE",
headers: {
...this.getAuthHeader(),
credentials: "include"
"Content-Type": "application/json"
},
body: JSON.stringify({ password })
})
@ -377,7 +435,7 @@ class ApiClient {
)
}
public async adminListAll(): Promise<UserResponse[] | undefined> {
public async adminListAll(): Promise<User[] | undefined> {
const user = get(currentUser)
if (!user || !user.isAdmin) {
throw new Error("Admin privileges required.")
@ -394,7 +452,7 @@ class ApiClient {
}
})
const users = await this.handleResponse<UserResponse[]>(response, { useBearerAuth: false })
const users = await this.handleResponse<User[]>(response, { useBearerAuth: false })
console.log(`admin: got ${users.length} user results`)
return users
@ -433,7 +491,7 @@ class ApiClient {
)
}
public async listNotes(): Promise<NoteMetadataResponse[] | undefined> {
public async listNotes(): Promise<NoteMetadata[] | undefined> {
return this.handleRequest(
async () => {
const params = new URLSearchParams()
@ -445,11 +503,15 @@ class ApiClient {
}
})
// TODO: handle case where no notes have yet been created, i.e. `notes` from response is `null`
const notes = await this.handleResponse<NoteMetadataResponse[]>(response, {
let notes: NoteMetadata[] = []
let data = await this.handleResponse<ApiNoteMetadataResponse[]>(response, {
useBearerAuth: false
})
if (data) {
notes = this.deserializeNoteMetadatas(data)
}
console.log(`got ${notes.length} note metadata results`)
return notes
@ -476,19 +538,27 @@ class ApiClient {
)
}
public async getFullNote(noteID: string): Promise<FullNoteResponse | undefined> {
public async getFullNote(noteID: string): Promise<FullNote | undefined> {
if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.")
}
return this.handleRequest(
async () => {
const response = await fetch(`${this.baseUrl}/${noteID}`, {
const response = await fetch(`${this.baseUrl}/notes/${noteID}`, {
headers: {
...this.getAuthHeader()
}
})
return await this.handleResponse<FullNoteResponse>(response, { useBearerAuth: false })
const data = await this.handleResponse<ApiFullNoteResponse>(response, {
useBearerAuth: false
})
const note = this.deserializeFullNote(data)
console.log(note)
return note
},
{ useBearerAuth: true }
)
@ -555,8 +625,10 @@ class ApiClient {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, {
method: "POST",
headers: {
...this.getAuthHeader()
}
...this.getAuthHeader(),
"Content-Type": "application/json"
},
body: JSON.stringify({ title, content })
})
if (response.status === 204) {

View File

@ -2,6 +2,7 @@
import { cError } from "$lib/client"
import { isPasswordValid } from "$lib/utils"
import { onMount } from "svelte"
import ThemeToggle from "./ThemeToggle.svelte"
export let formName: string
export let handler: (username: string, password: string) => Promise<void>
export let bottomText: string
@ -49,7 +50,7 @@
</script>
<div class="flex min-h-screen items-center justify-center px-4">
<div class="card rounded-4x1 grid w-auto max-w-md justify-items-center space-y-6">
<div class="card grid w-auto max-w-md justify-items-center space-y-6 rounded-3xl">
{#if $cError}
<div class="error">
{$cError}
@ -92,3 +93,7 @@
</p>
</div>
</div>
<div class="absolute right-4 top-4">
<ThemeToggle />
</div>

View File

@ -1,15 +1,15 @@
<script lang="ts">
import { onMount } from "svelte"
import { marked } from "marked"
import { marked, Renderer } from "marked"
import { TITLE_MAX_LENGTH } from "$lib/const"
import type { FullNoteResponse } from "$lib/client"
import type { FullNote } from "$lib/client"
// Props
export let note: FullNoteResponse
export let note: FullNote
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)
// Local copy for editing (to prevent uploading every single keypress as unique version)
let editableTitle = note.title
let editableContent = note.content
@ -22,13 +22,13 @@
const handleContentChange = (
event: Event & { currentTarget: EventTarget & HTMLTextAreaElement }
) => {
if (!event.target) return
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("# ")) {
@ -51,14 +51,37 @@
}
const handleTitleChange = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
if (!event.target) return
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)
const parseMarkdown = async (markdown: string) => {
if (!markdown) {
return ""
}
// Enable Github flavored markdown rendering
marked.setOptions({
gfm: true,
breaks: true
})
let html = await marked(markdown)
// Add spans to regular list items
html = html.replaceAll(/\<li\>([^\<].*)\<\/li\>/g, '<li><span class="list-text">$1</span></li>')
// Add spans to nested ordered/unordered lists
html = html.replaceAll(
/<li>([^<]+)(?=.*?<(?:ul|ol)[^>]*>)/g,
'<li><span class="list-text">$1</span></li>'
)
return html
}
let textarea: HTMLTextAreaElement | null
@ -71,6 +94,8 @@
onMount(() => {
if (isEditing && textarea) {
// Scrollbar is hidden in global CSS so flickering during resizing of the
// textarea shouldn't be an issue anymore
autoResize()
}
})
@ -80,6 +105,8 @@
}
</script>
<!-- TODO: capture Ctrl+Enter keyboard shortcut to save the note contents -->
<div class="flex h-full flex-col">
<!-- Note title -->
<div class="mb-4">
@ -88,23 +115,31 @@
type="text"
bind:value={editableTitle}
on:input={handleTitleChange}
placeholder="Note title"
placeholder="Title"
class="note-title-input"
/>
<div class="note-char-count">
<div class="note-char-count ml-3">
{editableTitle.length}/{TITLE_MAX_LENGTH} characters
</div>
<div class="note-char-count ml-3">
Last updated: {new Date(note.noteUpdatedAt).toLocaleString()}
{#if !isEditing}
<!-- Minus 1 due to versioning beginning at 2 in the DB -->
• Version: {note.versionNumber - 1}
{/if}
</div>
{:else}
<h1 class="border-[var(--light-text)]/20 border-b pb-2 text-2xl font-bold">
{note.title || "Untitled Note"}
</h1>
<div class="note-char-count ml-1">
Last updated: {new Date(note.noteUpdatedAt).toLocaleString()}
{#if !isEditing}
<!-- Minus 1 due to versioning beginning at 2 in the DB -->
• Version: {note.versionNumber - 1}
{/if}
</div>
{/if}
<div class="note-char-count">
Last updated: {new Date(note.noteUpdatedAt).toLocaleString()}
{#if !isEditing}
• Version: {note.versionNumber}
{/if}
</div>
</div>
<!-- Note content -->
@ -115,127 +150,23 @@
bind:this={textarea}
bind:value={editableContent}
on:input={handleContentChange}
placeholder="Write your markdown note here..."
placeholder="Markdown contents"
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)}
{#await parseMarkdown(note.content) then html}
{@html html}
{/await}
</div>
{/if}
</div>
{#if isEditing}
<div class="note-save-button">
<button on:click={handleSave} class="btn-primary rounded-full"> Save </button>
</div>
{/if}
</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

@ -8,7 +8,7 @@
import {
apiClient,
currentUser,
isPending,
// isPending,
currentFullNote,
availableNotes,
cError
@ -32,6 +32,7 @@
// If still no current user after the fetch attempt, redirect to login
if (!$currentUser) {
console.log("no user data found, routing to auth page")
goto("/login")
return
}
@ -47,7 +48,9 @@
const loadNotes = async () => {
const notes = await apiClient.listNotes()
if (notes) {
console.log(notes)
availableNotes.set(notes)
}
}
@ -62,23 +65,28 @@
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
// (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
isEditing = true // Open brand new notes in edit mode by default
}
}
// TODO: add caching (!!!)
const selectNote = async (noteId: string) => {
console.log(`loading ${noteId}`)
const note = await apiClient.getFullNote(noteId)
if (note) {
currentFullNote.set(note)
isEditing = false
@ -109,15 +117,6 @@
</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">
@ -149,23 +148,34 @@
<header class="main-header">
<button
on:click={toggleSidebar}
class="btn-secondary rounded-md p-2"
class="btn-secondary rounded-full p-2"
aria-label="Toggle sidebar"
>
<ToggleSidebar />
</button>
<div class="flex items-center space-x-4">
<!-- Content unchanged -->
<div class="flex items-center space-x-4 pl-2">
{#if $currentFullNote}
<button on:click={toggleEditMode} class="btn-primary rounded-full">
{isEditing ? "Preview" : "Edit"}
</button>
{/if}
</div>
<div class="flex items-center space-x-4 pl-2">
<ThemeToggle />
</div>
</header>
<!-- Note content area -->
<main class="main-content">
{#if $currentFullNote}
<button on:click={toggleEditMode} class="btn-primary">
{isEditing ? "Preview" : "Edit"}
</button>
<NoteEditor note={$currentFullNote} {isEditing} {saveNote} />
{:else}
<div class="flex h-full flex-col items-center justify-center">
<p class="mb-4 text-lg">None selected</p>
<button on:click={createNewNote} class="btn-primary">Create note</button>
</div>
{/if}
</main>
</div>

View File

@ -123,6 +123,8 @@
<svelte:window on:keydown={handleKeydown} />
<!-- TODO: add user details section (username, creation date, update date, admin status, etc.) -->
<div
class="modal-backdrop"
on:click={handleClickOutside}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import type { NoteMetadataResponse, FullNoteResponse } from "$lib/client"
import type { NoteMetadata, FullNote } from "$lib/client"
import CreateNew from "$lib/icons/CreateNew.svelte"
import Logout from "$lib/icons/Logout.svelte"
import Search from "$lib/icons/Search.svelte"
@ -7,17 +7,27 @@
// Props
export let sidebarOpen = true
export let notes: NoteMetadataResponse[] = []
export let currentNote: FullNoteResponse | null = null
export let notes: NoteMetadata[] = []
export let currentNote: FullNote | 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 ""
if (!dateString) {
return ""
}
const d = new Date(dateString)
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
return d.toLocaleDateString(undefined, {
weekday: "short",
year: "2-digit",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric"
})
}
const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => {
@ -34,11 +44,13 @@
: notes
</script>
<!-- TODO: make the sidebar take up whole screen width on mobile -->
<!-- TODO: add admin modal (opens a view similar to the settings modal) button to the bottom (if the user is an admin) -->
<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>
<div class="flex items-center justify-between">
<button
on:click={createNewNote}
class="btn-primary rounded-full p-2"
@ -46,17 +58,12 @@
>
<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 />
<!-- Search bar -->
<div class="relative pl-4">
<input type="text" placeholder="Search" bind:value={searchQuery} class="search-bar" />
<Search />
</div>
</div>
</div>
@ -75,8 +82,8 @@
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 class="sidebar-item-text mt-1 italic">
{formatDate(note.updatedAt)}
</p>
</li>
{/each}
@ -93,13 +100,13 @@
<div class="flex justify-between">
<button
on:click={toggleSettings}
class="btn-secondary rounded-md p-2"
class="btn-secondary rounded-full p-2"
aria-label="Toggle settings"
>
<Settings />
</button>
<button on:click={logout} class="btn-secondary rounded-md p-2" aria-label="Logout">
<button on:click={logout} class="btn-secondary rounded-full p-2" aria-label="Logout">
<Logout />
</button>
</div>

View File

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 626 B

View File

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 184 B

View File

@ -1,6 +1,6 @@
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-[var(--light-text)]/60 absolute left-2 top-3 h-4 w-4"
class="search-bar-icon"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 263 B

View File

@ -1,6 +1,20 @@
<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
<svg
xmlns="http://www.w3.org/2000/svg"
class="general-sidebar-icon"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
d="M20.3499 8.92293L19.9837 8.7192C19.9269 8.68756 19.8989 8.67169 19.8714 8.65524C19.5983 8.49165 19.3682 8.26564 19.2002 7.99523C19.1833 7.96802 19.1674 7.93949 19.1348 7.8831C19.1023 7.82677 19.0858 7.79823 19.0706 7.76998C18.92 7.48866 18.8385 7.17515 18.8336 6.85606C18.8331 6.82398 18.8332 6.79121 18.8343 6.72604L18.8415 6.30078C18.8529 5.62025 18.8587 5.27894 18.763 4.97262C18.6781 4.70053 18.536 4.44993 18.3462 4.23725C18.1317 3.99685 17.8347 3.82534 17.2402 3.48276L16.7464 3.1982C16.1536 2.85658 15.8571 2.68571 15.5423 2.62057C15.2639 2.56294 14.9765 2.56561 14.6991 2.62789C14.3859 2.69819 14.0931 2.87351 13.5079 3.22396L13.5045 3.22555L13.1507 3.43741C13.0948 3.47091 13.0665 3.48779 13.0384 3.50338C12.7601 3.6581 12.4495 3.74365 12.1312 3.75387C12.0992 3.7549 12.0665 3.7549 12.0013 3.7549C11.9365 3.7549 11.9024 3.7549 11.8704 3.75387C11.5515 3.74361 11.2402 3.65759 10.9615 3.50224C10.9334 3.48658 10.9056 3.46956 10.8496 3.4359L10.4935 3.22213C9.90422 2.86836 9.60915 2.69121 9.29427 2.62057C9.0157 2.55807 8.72737 2.55634 8.44791 2.61471C8.13236 2.68062 7.83577 2.85276 7.24258 3.19703L7.23994 3.1982L6.75228 3.48124L6.74688 3.48454C6.15904 3.82572 5.86441 3.99672 5.6517 4.23614C5.46294 4.4486 5.32185 4.69881 5.2374 4.97018C5.14194 5.27691 5.14703 5.61896 5.15853 6.3027L5.16568 6.72736C5.16676 6.79166 5.16864 6.82362 5.16817 6.85525C5.16343 7.17499 5.08086 7.48914 4.92974 7.77096C4.9148 7.79883 4.8987 7.8267 4.86654 7.88237C4.83436 7.93809 4.81877 7.96579 4.80209 7.99268C4.63336 8.26452 4.40214 8.49186 4.12733 8.65572C4.10015 8.67193 4.0715 8.68752 4.01521 8.71871L3.65365 8.91908C3.05208 9.25245 2.75137 9.41928 2.53256 9.65669C2.33898 9.86672 2.19275 10.1158 2.10349 10.3872C2.00259 10.6939 2.00267 11.0378 2.00424 11.7255L2.00551 12.2877C2.00706 12.9708 2.00919 13.3122 2.11032 13.6168C2.19979 13.8863 2.34495 14.134 2.53744 14.3427C2.75502 14.5787 3.05274 14.7445 3.64974 15.0766L4.00808 15.276C4.06907 15.3099 4.09976 15.3266 4.12917 15.3444C4.40148 15.5083 4.63089 15.735 4.79818 16.0053C4.81625 16.0345 4.8336 16.0648 4.8683 16.1255C4.90256 16.1853 4.92009 16.2152 4.93594 16.2452C5.08261 16.5229 5.16114 16.8315 5.16649 17.1455C5.16707 17.1794 5.16658 17.2137 5.16541 17.2827L5.15853 17.6902C5.14695 18.3763 5.1419 18.7197 5.23792 19.0273C5.32287 19.2994 5.46484 19.55 5.65463 19.7627C5.86915 20.0031 6.16655 20.1745 6.76107 20.5171L7.25478 20.8015C7.84763 21.1432 8.14395 21.3138 8.45869 21.379C8.73714 21.4366 9.02464 21.4344 9.30209 21.3721C9.61567 21.3017 9.90948 21.1258 10.4964 20.7743L10.8502 20.5625C10.9062 20.5289 10.9346 20.5121 10.9626 20.4965C11.2409 20.3418 11.5512 20.2558 11.8695 20.2456C11.9015 20.2446 11.9342 20.2446 11.9994 20.2446C12.0648 20.2446 12.0974 20.2446 12.1295 20.2456C12.4484 20.2559 12.7607 20.3422 13.0394 20.4975C13.0639 20.5112 13.0885 20.526 13.1316 20.5519L13.5078 20.7777C14.0971 21.1315 14.3916 21.3081 14.7065 21.3788C14.985 21.4413 15.2736 21.4438 15.5531 21.3855C15.8685 21.3196 16.1657 21.1471 16.7586 20.803L17.2536 20.5157C17.8418 20.1743 18.1367 20.0031 18.3495 19.7636C18.5383 19.5512 18.6796 19.3011 18.764 19.0297C18.8588 18.7252 18.8531 18.3858 18.8417 17.7119L18.8343 17.2724C18.8332 17.2081 18.8331 17.1761 18.8336 17.1445C18.8383 16.8247 18.9195 16.5104 19.0706 16.2286C19.0856 16.2007 19.1018 16.1726 19.1338 16.1171C19.166 16.0615 19.1827 16.0337 19.1994 16.0068C19.3681 15.7349 19.5995 15.5074 19.8744 15.3435C19.9012 15.3275 19.9289 15.3122 19.9838 15.2818L19.9857 15.2809L20.3472 15.0805C20.9488 14.7472 21.2501 14.5801 21.4689 14.3427C21.6625 14.1327 21.8085 13.8839 21.8978 13.6126C21.9981 13.3077 21.9973 12.9658 21.9958 12.2861L21.9945 11.7119C21.9929 11.0287 21.9921 10.6874 21.891 10.3828C21.8015 10.1133 21.6555 9.86561 21.463 9.65685C21.2457 9.42111 20.9475 9.25526 20.3517 8.92378L20.3499 8.92293Z"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8.00033 12C8.00033 14.2091 9.79119 16 12.0003 16C14.2095 16 16.0003 14.2091 16.0003 12C16.0003 9.79082 14.2095 7.99996 12.0003 7.99996C9.79119 7.99996 8.00033 9.79082 8.00033 12Z"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"

Before

Width:  |  Height:  |  Size: 666 B

After

Width:  |  Height:  |  Size: 666 B

View File

@ -36,7 +36,9 @@ const calculateEntropy = (password: string) => {
}
// Empty password exception
if (poolSize === 0) return 0
if (poolSize === 0) {
return 0
}
const uniqueChars = new Set(password.split("")).size

View File

@ -1,12 +1,6 @@
<script lang="ts">
import ThemeToggle from "$lib/components/ThemeToggle.svelte"
import "../app.css"
let { children } = $props()
</script>
{@render children()}
<div class="absolute right-4 top-4">
<ThemeToggle />
</div>

View File

@ -11,7 +11,7 @@
// -> Prevents component flickering during auth checks
setTimeout(() => {
isLoading = false
}, 300)
}, 500)
})
</script>