feat: ui integration for note expiration dates (closes #5)

This commit is contained in:
ae 2025-05-04 13:41:21 +03:00
parent 8535d011d0
commit dd2d8f3c34
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
8 changed files with 76 additions and 14 deletions

View File

@ -15,6 +15,7 @@
--light-foreground: #e0e0e0; --light-foreground: #e0e0e0;
--light-accent: #303052; --light-accent: #303052;
--light-text: rgb(34, 40, 49); --light-text: rgb(34, 40, 49);
--light-highlight-text: #c28e4a;
--light-error-text: #4b0000; --light-error-text: #4b0000;
--light-error-background: #fcaaaae1; --light-error-background: #fcaaaae1;
--light-success-text: #004b0a; --light-success-text: #004b0a;
@ -24,6 +25,7 @@
--dark-foreground: #222222; --dark-foreground: #222222;
--dark-accent: #bebff7; --dark-accent: #bebff7;
--dark-text: rgb(238, 238, 238); --dark-text: rgb(238, 238, 238);
--dark-highlight-text: #f7debe;
--dark-error-text: #fcadaa; --dark-error-text: #fcadaa;
--dark-error-background: #4b0000e0; --dark-error-background: #4b0000e0;
--dark-success-text: #adfcaa; --dark-success-text: #adfcaa;
@ -499,6 +501,14 @@
@apply font-copernicus max-w-full pb-1 text-2xl wrap-break-word; @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 */ /* Limit title (display as input is always only one row tall) to max. 4 rows tall on short screens */
@media (max-width: 768px) { @media (max-width: 768px) {
.note-title-display { .note-title-display {

View File

@ -10,6 +10,7 @@
import ViewEye from "$lib/icons/editor/ViewEye.svelte" import ViewEye from "$lib/icons/editor/ViewEye.svelte"
import Save from "$lib/icons/editor/Save.svelte" import Save from "$lib/icons/editor/Save.svelte"
import type { FullNote } from "$lib/logic/model" import type { FullNote } from "$lib/logic/model"
import { formatTitleWithHighlight } from "$lib/util/contentVisual"
// props // props
export let note: FullNote export let note: FullNote
@ -20,10 +21,12 @@
// constants // constants
const TITLE_MAX_LENGTH = 150 const TITLE_MAX_LENGTH = 150
const ZEROWIDTH_CHARACTERS = /^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/
// local state // local state
let editableTitle = note.title let editableTitle = note.title
let editableContent = note.content let editableContent = note.content
let displayTitle = formatTitleWithHighlight(note.title)
// markdown parser with syntax highlight support // markdown parser with syntax highlight support
const marked = new Marked( const marked = new Marked(
@ -42,6 +45,7 @@
$: if (note && note.id) { $: if (note && note.id) {
editableTitle = note.title editableTitle = note.title
editableContent = note.content editableContent = note.content
displayTitle = formatTitleWithHighlight(note.title)
} }
const handleContentChange = ( const handleContentChange = (
@ -110,6 +114,8 @@
} }
const handleSave = () => { const handleSave = () => {
// only update the display title once the user saves the edits
displayTitle = formatTitleWithHighlight(editableTitle)
saveNote(editableTitle, editableContent) saveNote(editableTitle, editableContent)
} }
@ -135,7 +141,7 @@
}) })
// remove the most common zerowidth characters from the start of the file // 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) return await marked.parse(markdown)
} }
@ -181,7 +187,7 @@
</div> </div>
{:else} {:else}
<h1 class="note-title-display"> <h1 class="note-title-display">
{note.title || "Untitled Note"} {@html displayTitle}
</h1> </h1>
{/if} {/if}

View File

@ -3,7 +3,7 @@
import Close from "$lib/icons/Close.svelte" import Close from "$lib/icons/Close.svelte"
import { isPasswordValid } from "$lib/util/authValidation" import { isPasswordValid } from "$lib/util/authValidation"
import { onMount } from "svelte" import { onMount } from "svelte"
import { formatDate } from "$lib/util/contentVisual" import { formatDateLong } from "$lib/util/contentVisual"
// props // props
export let onClose: () => void export let onClose: () => void
@ -163,13 +163,13 @@
<tr class="modal-table-row"> <tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Account created</th> <th class="modal-table-row-item modal-table-head">Account created</th>
<td class="modal-table-row-item" <td class="modal-table-row-item"
>{userData?.createdAt !== undefined ? formatDate(userData?.createdAt) : ""}</td >{userData?.createdAt !== undefined ? formatDateLong(userData?.createdAt) : ""}</td
> >
</tr> </tr>
<tr class="modal-table-row"> <tr class="modal-table-row">
<th class="modal-table-row-item modal-table-head">Password updated</th> <th class="modal-table-row-item modal-table-head">Password updated</th>
<td class="modal-table-row-item" <td class="modal-table-row-item"
>{userData?.createdAt !== undefined ? formatDate(userData?.updatedAt) : ""}</td >{userData?.createdAt !== undefined ? formatDateLong(userData?.updatedAt) : ""}</td
> >
</tr> </tr>
{#if userData?.isAdmin} {#if userData?.isAdmin}

View File

@ -13,7 +13,7 @@
import AdminWrench from "$lib/icons/sidebar/AdminShield.svelte" import AdminWrench from "$lib/icons/sidebar/AdminShield.svelte"
import Exit from "$lib/icons/sidebar/Exit.svelte" import Exit from "$lib/icons/sidebar/Exit.svelte"
import ThemeToggle from "./ThemeToggle.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" import type { FullNote, NoteMetadata } from "$lib/logic/model"
// props // props
@ -172,7 +172,7 @@
> >
<div class="flex w-full items-start justify-between"> <div class="flex w-full items-start justify-between">
<h3 class="sidebar-list-item-title"> <h3 class="sidebar-list-item-title">
{note.title || "Untitled Note"} {parseExpirationPrefix(note.title)[0] || "Untitled Note"}
</h3> </h3>
<!-- Delete button --> <!-- Delete button -->
@ -186,7 +186,11 @@
</div> </div>
<p class="sidebar-list-item-metadata"> <p class="sidebar-list-item-metadata">
{formatDate(note.updatedAt)} {formatDateLong(note.updatedAt)}
</p>
<p class="sidebar-list-item-metadata">
Expires {note.expiresAt !== null ? formatDateShort(note.expiresAt) : "never"}
</p> </p>
</li> </li>
{/each} {/each}

View File

@ -15,10 +15,7 @@ import {
} from "./model" } from "./model"
const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api" const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api"
const UUID_REGEX = new RegExp( 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
"^[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 // lifetimes of *in-memory* authentication tokens in milliseconds
const AT_EXP_MS = 15 * 60 * 1000 // 15 min. const AT_EXP_MS = 15 * 60 * 1000 // 15 min.

View File

@ -29,6 +29,7 @@ export interface ApiFullNoteResponse {
content: string content: string
version_number: number version_number: number
version_created_at: string version_created_at: string
note_expires_at: string
note_created_at: string note_created_at: string
note_updated_at: string note_updated_at: string
} }
@ -41,6 +42,7 @@ export class FullNote {
versionNumber: number versionNumber: number
versionCreatedAt: Date versionCreatedAt: Date
isActiveVersion: boolean isActiveVersion: boolean
noteExpiresAt: Date | null
noteCreatedAt: Date noteCreatedAt: Date
noteUpdatedAt: Date noteUpdatedAt: Date
@ -52,6 +54,8 @@ export class FullNote {
this.versionNumber = apiResponse.version_number this.versionNumber = apiResponse.version_number
this.versionCreatedAt = new Date(apiResponse.version_created_at) this.versionCreatedAt = new Date(apiResponse.version_created_at)
this.isActiveVersion = true // this endpoint serves only the latest version 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.noteCreatedAt = new Date(apiResponse.note_created_at)
this.noteUpdatedAt = new Date(apiResponse.note_updated_at) this.noteUpdatedAt = new Date(apiResponse.note_updated_at)
} }
@ -64,6 +68,7 @@ export class FullNote {
content: apiResponse.content, content: apiResponse.content,
version_number: apiResponse.version_number, version_number: apiResponse.version_number,
version_created_at: apiResponse.created_at, version_created_at: apiResponse.created_at,
note_expires_at: this.noteExpiresAt ? this.noteExpiresAt.toISOString() : "",
note_created_at: this.noteCreatedAt.toISOString(), note_created_at: this.noteCreatedAt.toISOString(),
note_updated_at: this.noteUpdatedAt.toISOString() note_updated_at: this.noteUpdatedAt.toISOString()
}) })
@ -79,6 +84,7 @@ export interface ApiNoteMetadataResponse {
note_id: string note_id: string
owner_id: string owner_id: string
title: string title: string
expires_at: string
updated_at: string updated_at: string
} }
@ -86,12 +92,14 @@ export class NoteMetadata {
id: string id: string
owner: string owner: string
title: string title: string
expiresAt: Date | null
updatedAt: Date updatedAt: Date
constructor(apiResponse: ApiNoteMetadataResponse) { constructor(apiResponse: ApiNoteMetadataResponse) {
this.id = apiResponse.note_id this.id = apiResponse.note_id
this.owner = apiResponse.owner_id this.owner = apiResponse.owner_id
this.title = apiResponse.title this.title = apiResponse.title
this.expiresAt = apiResponse.expires_at !== null ? new Date(apiResponse.expires_at) : null
this.updatedAt = new Date(apiResponse.updated_at) this.updatedAt = new Date(apiResponse.updated_at)
} }

View File

@ -1,6 +1,6 @@
const MIN_USERNAME_LENGTH = 3 const MIN_USERNAME_LENGTH = 3
const MAX_USERNAME_LENGTH = 20 const MAX_USERNAME_LENGTH = 20
const USERNAME_REGEX = RegExp("^[a-z0-9_]+$") const USERNAME_REGEX = /^[a-z0-9_]+$/
const MIN_PASSWORD_LENGTH = 12 const MIN_PASSWORD_LENGTH = 12
const MAX_PASSWORD_LENGTH = 72 const MAX_PASSWORD_LENGTH = 72

View File

@ -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) { if (!dateString) {
return "" return ""
} }
@ -13,3 +16,37 @@ export const formatDate = (dateString: string | Date): string => {
minute: "numeric" 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 `<span class="note-title-exp-highlight">${expirationPrefix}</span> ${cleanTitle}`
}