diff --git a/web/src/app.css b/web/src/app.css index 70ac8dd..0d805ca 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -15,6 +15,7 @@ --light-foreground: #e0e0e0; --light-accent: #303052; --light-text: rgb(34, 40, 49); + --light-highlight-text: #c28e4a; --light-error-text: #4b0000; --light-error-background: #fcaaaae1; --light-success-text: #004b0a; @@ -24,6 +25,7 @@ --dark-foreground: #222222; --dark-accent: #bebff7; --dark-text: rgb(238, 238, 238); + --dark-highlight-text: #f7debe; --dark-error-text: #fcadaa; --dark-error-background: #4b0000e0; --dark-success-text: #adfcaa; @@ -499,6 +501,14 @@ @apply font-copernicus max-w-full pb-1 text-2xl wrap-break-word; } + .note-title-exp-highlight { + @apply text-[var(--light-highlight-text)]; + } + + .dark .note-title-exp-highlight { + @apply text-[var(--dark-highlight-text)]; + } + /* Limit title (display as input is always only one row tall) to max. 4 rows tall on short screens */ @media (max-width: 768px) { .note-title-display { diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index d0c8700..f09a671 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -10,6 +10,7 @@ import ViewEye from "$lib/icons/editor/ViewEye.svelte" import Save from "$lib/icons/editor/Save.svelte" import type { FullNote } from "$lib/logic/model" + import { formatTitleWithHighlight } from "$lib/util/contentVisual" // props export let note: FullNote @@ -20,10 +21,12 @@ // constants const TITLE_MAX_LENGTH = 150 + const ZEROWIDTH_CHARACTERS = /^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/ // local state let editableTitle = note.title let editableContent = note.content + let displayTitle = formatTitleWithHighlight(note.title) // markdown parser with syntax highlight support const marked = new Marked( @@ -42,6 +45,7 @@ $: if (note && note.id) { editableTitle = note.title editableContent = note.content + displayTitle = formatTitleWithHighlight(note.title) } const handleContentChange = ( @@ -110,6 +114,8 @@ } const handleSave = () => { + // only update the display title once the user saves the edits + displayTitle = formatTitleWithHighlight(editableTitle) saveNote(editableTitle, editableContent) } @@ -135,7 +141,7 @@ }) // remove the most common zerowidth characters from the start of the file - marked.parse(markdown.replace(/^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/, "")) + marked.parse(markdown.replace(ZEROWIDTH_CHARACTERS, "")) return await marked.parse(markdown) } @@ -181,7 +187,7 @@ {:else}

- {note.title || "Untitled Note"} + {@html displayTitle}

{/if} diff --git a/web/src/lib/components/SettingsModal.svelte b/web/src/lib/components/SettingsModal.svelte index 5eb1cde..b4b1325 100644 --- a/web/src/lib/components/SettingsModal.svelte +++ b/web/src/lib/components/SettingsModal.svelte @@ -3,7 +3,7 @@ import Close from "$lib/icons/Close.svelte" import { isPasswordValid } from "$lib/util/authValidation" import { onMount } from "svelte" - import { formatDate } from "$lib/util/contentVisual" + import { formatDateLong } from "$lib/util/contentVisual" // props export let onClose: () => void @@ -163,13 +163,13 @@ Account created {userData?.createdAt !== undefined ? formatDate(userData?.createdAt) : ""}{userData?.createdAt !== undefined ? formatDateLong(userData?.createdAt) : ""} Password updated {userData?.createdAt !== undefined ? formatDate(userData?.updatedAt) : ""}{userData?.createdAt !== undefined ? formatDateLong(userData?.updatedAt) : ""} {#if userData?.isAdmin} diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 4ec588b..a3cf897 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -13,7 +13,7 @@ import AdminWrench from "$lib/icons/sidebar/AdminShield.svelte" import Exit from "$lib/icons/sidebar/Exit.svelte" import ThemeToggle from "./ThemeToggle.svelte" - import { formatDate } from "$lib/util/contentVisual" + import { formatDateLong, formatDateShort, parseExpirationPrefix } from "$lib/util/contentVisual" import type { FullNote, NoteMetadata } from "$lib/logic/model" // props @@ -172,7 +172,7 @@ >
@@ -186,7 +186,11 @@
+ + {/each} diff --git a/web/src/lib/logic/client.ts b/web/src/lib/logic/client.ts index 062a101..7db4eaf 100644 --- a/web/src/lib/logic/client.ts +++ b/web/src/lib/logic/client.ts @@ -15,10 +15,7 @@ import { } from "./model" const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api" -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" -) +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i // lifetimes of *in-memory* authentication tokens in milliseconds const AT_EXP_MS = 15 * 60 * 1000 // 15 min. diff --git a/web/src/lib/logic/model.ts b/web/src/lib/logic/model.ts index a83b061..fe34c88 100644 --- a/web/src/lib/logic/model.ts +++ b/web/src/lib/logic/model.ts @@ -29,6 +29,7 @@ export interface ApiFullNoteResponse { content: string version_number: number version_created_at: string + note_expires_at: string note_created_at: string note_updated_at: string } @@ -41,6 +42,7 @@ export class FullNote { versionNumber: number versionCreatedAt: Date isActiveVersion: boolean + noteExpiresAt: Date | null noteCreatedAt: Date noteUpdatedAt: Date @@ -52,6 +54,8 @@ export class FullNote { this.versionNumber = apiResponse.version_number this.versionCreatedAt = new Date(apiResponse.version_created_at) this.isActiveVersion = true // this endpoint serves only the latest version + this.noteExpiresAt = + apiResponse.note_expires_at !== null ? new Date(apiResponse.note_expires_at) : null this.noteCreatedAt = new Date(apiResponse.note_created_at) this.noteUpdatedAt = new Date(apiResponse.note_updated_at) } @@ -64,6 +68,7 @@ export class FullNote { content: apiResponse.content, version_number: apiResponse.version_number, version_created_at: apiResponse.created_at, + note_expires_at: this.noteExpiresAt ? this.noteExpiresAt.toISOString() : "", note_created_at: this.noteCreatedAt.toISOString(), note_updated_at: this.noteUpdatedAt.toISOString() }) @@ -79,6 +84,7 @@ export interface ApiNoteMetadataResponse { note_id: string owner_id: string title: string + expires_at: string updated_at: string } @@ -86,12 +92,14 @@ export class NoteMetadata { id: string owner: string title: string + expiresAt: Date | null updatedAt: Date constructor(apiResponse: ApiNoteMetadataResponse) { this.id = apiResponse.note_id this.owner = apiResponse.owner_id this.title = apiResponse.title + this.expiresAt = apiResponse.expires_at !== null ? new Date(apiResponse.expires_at) : null this.updatedAt = new Date(apiResponse.updated_at) } diff --git a/web/src/lib/util/authValidation.ts b/web/src/lib/util/authValidation.ts index 4f336db..59e1805 100644 --- a/web/src/lib/util/authValidation.ts +++ b/web/src/lib/util/authValidation.ts @@ -1,6 +1,6 @@ const MIN_USERNAME_LENGTH = 3 const MAX_USERNAME_LENGTH = 20 -const USERNAME_REGEX = RegExp("^[a-z0-9_]+$") +const USERNAME_REGEX = /^[a-z0-9_]+$/ const MIN_PASSWORD_LENGTH = 12 const MAX_PASSWORD_LENGTH = 72 diff --git a/web/src/lib/util/contentVisual.ts b/web/src/lib/util/contentVisual.ts index c239e05..678006e 100644 --- a/web/src/lib/util/contentVisual.ts +++ b/web/src/lib/util/contentVisual.ts @@ -1,4 +1,7 @@ -export const formatDate = (dateString: string | Date): string => { +// Format: '@exp:2025-06-15' or '@exp:+7d' (or '@expires') +const EXPIRATION_DATE_REGEX = /^(@(?:exp|expires):(?:(?:\d{4}-\d{2}-\d{2})|(?:\+\d+[dwmy])))\s*(.*)/ + +export const formatDateLong = (dateString: string | Date): string => { if (!dateString) { return "" } @@ -13,3 +16,37 @@ export const formatDate = (dateString: string | Date): string => { minute: "numeric" }) } + +export const formatDateShort = (dateString: string | Date): string => { + if (!dateString) { + return "" + } + + const d = new Date(dateString) + return d.toLocaleDateString(undefined, { + weekday: "short", + year: "2-digit", + month: "short" + }) +} + +export const parseExpirationPrefix = (title: string): [string, string] => { + const match = title.match(EXPIRATION_DATE_REGEX) + + if (match && match[1]) { + console.log(`[UTIL] Extracted expiration prefix: '${match[1]}'`) + // return [clean title, expiration prefix] + return [match[2], match[1]] + } + + return [title, ""] +} + +export const formatTitleWithHighlight = (title: string): string => { + const [cleanTitle, expirationPrefix] = parseExpirationPrefix(title) + if (expirationPrefix === "") { + return title + } + + return `${expirationPrefix} ${cleanTitle}` +}