feat: ui integration for note expiration dates (closes #5)
This commit is contained in:
parent
8535d011d0
commit
dd2d8f3c34
@ -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 {
|
||||||
|
@ -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}
|
||||||
|
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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.
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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}`
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user