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}`
+}