This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import 'server-only'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { auth } from '@/auth'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export const apiBase = () => process.env.API_BASE_URL || 'http://localhost:8080'
|
||||
|
||||
// Review SEC-2: the browser IP is forwarded to Go only when an operator has
|
||||
// pinned the trusted proxy boundary (TRUSTED_PROXY=1) that overwrites
|
||||
// X-Real-IP — the shipped nginx template does. X-Forwarded-For is never
|
||||
// used: its leftmost entry is client-supplied. The HMAC covers ip|unix_ts
|
||||
// and Go rejects timestamps older than ~60s, bounding header replay.
|
||||
export const observedClientIP = (headers: Headers): string | null => {
|
||||
if (process.env.TRUSTED_PROXY !== '1') return null
|
||||
const ip = headers.get('x-real-ip')
|
||||
return ip && ip.trim() ? ip.trim() : null
|
||||
}
|
||||
|
||||
export const signClientIP = (ip: string): { ts: string; sig: string } => {
|
||||
const secret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET
|
||||
if (!secret) throw new Error('Missing authentication secret')
|
||||
const ts = String(Math.floor(Date.now() / 1000))
|
||||
return { ts, sig: createHmac('sha256', secret).update(`${ip}|${ts}`).digest('hex') }
|
||||
}
|
||||
|
||||
// Only this trusted Next server signs assertions. The browser never chooses a
|
||||
// user ID, and Go still re-reads roles/ownership on every request. The typ
|
||||
// claim binds the token to the Bearer channel; Go rejects it as a cookie and
|
||||
// rejects long-lived session tokens presented as assertions. The ver claim
|
||||
// carries the login-time tokenVersion (review SEC-02): Go rejects assertions
|
||||
// whose version predates a revocation bump, so "sign out everywhere" also
|
||||
// kills NextAuth-backed browser sessions on their next backend call. Sessions
|
||||
// predating ver are rejected and must sign in again.
|
||||
export async function backendFetch(path: string, init: RequestInit = {}) {
|
||||
const session = await auth()
|
||||
const headers = new Headers(init.headers)
|
||||
const secret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET
|
||||
if (session?.user?.id) {
|
||||
if (!secret) throw new Error('Missing authentication secret')
|
||||
headers.set('Authorization', `Bearer ${jwt.sign({ uid: session.user.id, ver: session.user.ver, typ: 'assertion' }, secret, {
|
||||
algorithm: 'HS256', issuer: 'computer', expiresIn: '60s',
|
||||
})}`)
|
||||
}
|
||||
// Review AUTH-RATE/SEC-2: attest the browser IP for Go's login/signup
|
||||
// throttling so proxied users do not all share this server's peer-IP
|
||||
// bucket. The timestamped HMAC keeps the header unforgeable and
|
||||
// short-lived; Go falls back to the direct peer when it is missing or
|
||||
// invalid.
|
||||
const clientIP = headers.get('x-computer-client-ip')
|
||||
if (clientIP && secret) {
|
||||
const { ts, sig } = signClientIP(clientIP)
|
||||
headers.set('x-computer-client-ts', ts)
|
||||
headers.set('x-computer-client-sig', sig)
|
||||
}
|
||||
const viewAs = (await cookies()).get('computer_view_as')?.value
|
||||
if (viewAs) headers.set('Cookie', `computer_view_as=${encodeURIComponent(viewAs)}`)
|
||||
return fetch(`${apiBase()}${path}`, { ...init, headers, cache: 'no-store' })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { backendFetch } from '@/lib/api'
|
||||
export const VIEW_AS_COOKIE = 'computer_view_as'
|
||||
export type AuthUser = { id: string; name?: string | null; email?: string | null; image?: string | null; role: 'USER' | 'ADMIN'; actingAdminId?: string }
|
||||
async function account() { const res = await backendFetch('/api/me'); return res.ok ? res.json() : null }
|
||||
export async function getRealUser(): Promise<AuthUser | null> { return (await account())?.user ?? null }
|
||||
export async function getAuthUser(): Promise<AuthUser | null> { const me = await account(); return me ? { ...me.effectiveUser, ...(me.viewingAs ? { actingAdminId: me.user.id } : {}) } : null }
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Bookmark } from '@/components/app-shell'
|
||||
|
||||
// The API keeps its array response; follow its cursor until the collection is complete.
|
||||
export async function fetchBookmarks(query = new URLSearchParams(), signal?: AbortSignal): Promise<Bookmark[]> {
|
||||
const params = new URLSearchParams(query)
|
||||
const bookmarks = new Map<string, Bookmark>()
|
||||
const seen = new Set<string>()
|
||||
for (;;) {
|
||||
const response = await fetch(`/api/bookmarks?${params}`, { signal })
|
||||
if (!response.ok) throw new Error('Could not load bookmarks')
|
||||
const page: Bookmark[] = await response.json()
|
||||
for (const bookmark of page) bookmarks.set(bookmark.id, bookmark)
|
||||
const cursor = response.headers.get('X-Next-Cursor')
|
||||
if (!cursor) return [...bookmarks.values()]
|
||||
if (seen.has(cursor)) throw new Error('Invalid bookmark pagination')
|
||||
seen.add(cursor)
|
||||
params.set('cursor', cursor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Shared client-side layer over `GET /api/preview?url=`. The Go endpoint
|
||||
// dials the target site (netguard-protected, 8s bound), so results are
|
||||
// memoized per URL and concurrent requests are deduplicated. This is what
|
||||
// makes inline link cards in Markdown pages cheap to render.
|
||||
|
||||
export type LinkPreviewData = {
|
||||
title: string | null
|
||||
description: string | null
|
||||
favicon: string | null
|
||||
ogImage: string | null
|
||||
ogTitle: string | null
|
||||
ogDescription: string | null
|
||||
}
|
||||
|
||||
type Entry = { value: LinkPreviewData | null; at: number }
|
||||
|
||||
const cache = new Map<string, Entry>()
|
||||
const inflight = new Map<string, Promise<LinkPreviewData | null>>()
|
||||
|
||||
// Failures (offline target, blocked host, endpoint down) are transient, so
|
||||
// negative results expire quickly instead of sticking for the session.
|
||||
const NEGATIVE_TTL_MS = 60_000
|
||||
|
||||
export function cachedLinkPreview(url: string): LinkPreviewData | null | undefined {
|
||||
const hit = cache.get(url)
|
||||
if (!hit) return undefined
|
||||
if (hit.value === null && Date.now() - hit.at > NEGATIVE_TTL_MS) {
|
||||
cache.delete(url)
|
||||
return undefined
|
||||
}
|
||||
return hit.value
|
||||
}
|
||||
|
||||
export async function fetchLinkPreview(url: string): Promise<LinkPreviewData | null> {
|
||||
const hit = cachedLinkPreview(url)
|
||||
if (hit !== undefined) return hit
|
||||
const pending = inflight.get(url)
|
||||
if (pending) return pending
|
||||
const run = (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/preview?url=${encodeURIComponent(url)}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (!data || typeof data !== 'object') return null
|
||||
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null)
|
||||
const value: LinkPreviewData = {
|
||||
title: str(data.title),
|
||||
description: str(data.description),
|
||||
favicon: str(data.favicon),
|
||||
ogImage: str(data.ogImage),
|
||||
ogTitle: str(data.ogTitle),
|
||||
ogDescription: str(data.ogDescription),
|
||||
}
|
||||
// An object with nothing displayable counts as a miss.
|
||||
return value.title || value.description || value.ogImage ? value : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
inflight.delete(url)
|
||||
}
|
||||
})()
|
||||
inflight.set(url, run)
|
||||
const value = await run
|
||||
cache.set(url, { value, at: Date.now() })
|
||||
return value
|
||||
}
|
||||
|
||||
// undefined = still loading, null = nothing resolved. The value is derived
|
||||
// from the module cache during render (identical on server and client first
|
||||
// paint — no hydration mismatch); the effect only kicks off the fetch and
|
||||
// requests a re-render once the cache is populated.
|
||||
export function useLinkPreview(url: string | null): LinkPreviewData | null | undefined {
|
||||
const [, bump] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!url || cachedLinkPreview(url) !== undefined) return
|
||||
let active = true
|
||||
void fetchLinkPreview(url).then(() => { if (active) bump((n) => n + 1) })
|
||||
return () => { active = false }
|
||||
}, [url])
|
||||
return url ? cachedLinkPreview(url) : undefined
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Authelia OIDC configuration (STUB-AWARE).
|
||||
*
|
||||
* Authelia sign-in is only enabled when all three env vars hold real values:
|
||||
* OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET
|
||||
*
|
||||
* Empty values or obvious placeholders (REPLACE_ME, example.com, changeme, ...)
|
||||
* are treated as "not connected": the Authelia provider is not registered,
|
||||
* the login button is shown disabled, and email/password login keeps working.
|
||||
*
|
||||
* See docs/AUTHELIA.md for the full connection guide.
|
||||
*/
|
||||
|
||||
const PLACEHOLDER_PATTERNS = [/replace[_-]?me/i, /example\.(com|org|net)/i, /changeme/i, /placeholder/i, /^<.*>$/, /^your[_-]/i, /^todo$/i]
|
||||
|
||||
function isRealValue(value: string | undefined): value is string {
|
||||
const v = (value ?? '').trim()
|
||||
if (!v) return false
|
||||
return !PLACEHOLDER_PATTERNS.some((p) => p.test(v))
|
||||
}
|
||||
|
||||
export interface OidcStatus {
|
||||
enabled: boolean
|
||||
issuerSet: boolean
|
||||
clientIdSet: boolean
|
||||
clientSecretSet: boolean
|
||||
/** Only exposed when the issuer is a real value (never exposes secrets). */
|
||||
issuer: string | null
|
||||
}
|
||||
|
||||
export function getOidcStatus(): OidcStatus {
|
||||
const issuerSet = isRealValue(process.env.OIDC_ISSUER)
|
||||
const clientIdSet = isRealValue(process.env.OIDC_CLIENT_ID)
|
||||
const clientSecretSet = isRealValue(process.env.OIDC_CLIENT_SECRET)
|
||||
return {
|
||||
enabled: issuerSet && clientIdSet && clientSecretSet,
|
||||
issuerSet,
|
||||
clientIdSet,
|
||||
clientSecretSet,
|
||||
issuer: issuerSet ? process.env.OIDC_ISSUER!.trim() : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
export type PageSaveState = 'saved' | 'saving' | 'dirty' | 'conflict'
|
||||
|
||||
type Draft = {
|
||||
content: string
|
||||
revision: number
|
||||
savedRevision: number
|
||||
// The server version this content was edited from (review DATA-03). It is
|
||||
// persisted with the draft so a recovery after reload cannot silently
|
||||
// re-base onto a newer remote version.
|
||||
baseVersion: number | undefined
|
||||
// Server version reported by a 409 or detected during recovery, while
|
||||
// autosave is paused (review DATA-04). Typing never resolves a conflict.
|
||||
conflict: number | null
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
saving: Promise<boolean> | null
|
||||
}
|
||||
|
||||
// Storage envelope: content plus the server version it was based on. Plain
|
||||
// string drafts written by older builds are still recovered, but their
|
||||
// unknown base pauses recovery for review (DATA-06) — never a silent rebase.
|
||||
type Envelope = { content: string; baseVersion?: number }
|
||||
|
||||
// Thrown by the save callback when the server rejects a stale base version
|
||||
// (optimistic concurrency, review DATA-01). serverVersion is the revision the
|
||||
// server reported; the queue enters a paused conflict state instead of
|
||||
// adopting it — overwriting requires an explicit user decision (DATA-04).
|
||||
export class PageConflictError extends Error {
|
||||
constructor(readonly serverVersion?: number) { super('Page was modified elsewhere') }
|
||||
}
|
||||
|
||||
// Each page owns its debounce and serial save queue. A late response cannot
|
||||
// overwrite a newer edit, and tab-local drafts survive a failed save/navigation.
|
||||
export class PageAutosave {
|
||||
private drafts = new Map<string, Draft>()
|
||||
// Server revision per page: sent with content saves and refreshed from save
|
||||
// responses, so concurrent tabs/devices get 409s instead of silent overwrites.
|
||||
private versions = new Map<string, number>()
|
||||
constructor(
|
||||
// Resolves with the server's new version when the response carries one.
|
||||
private save: (id: string, content: string, version: number | undefined) => Promise<number | void>,
|
||||
private state: (id: string, state: PageSaveState) => void,
|
||||
) {}
|
||||
|
||||
setVersion(id: string, version: number) { this.versions.set(id, version) }
|
||||
|
||||
private key(id: string) { return `computer:page-draft:${id}` }
|
||||
private store(id: string, draft: Draft | null) {
|
||||
try {
|
||||
if (draft === null) sessionStorage.removeItem(this.key(id))
|
||||
else {
|
||||
const envelope: Envelope = { content: draft.content }
|
||||
if (draft.baseVersion !== undefined) envelope.baseVersion = draft.baseVersion
|
||||
sessionStorage.setItem(this.key(id), JSON.stringify(envelope))
|
||||
}
|
||||
} catch { /* Saving to the API still works if browser storage is unavailable. */ }
|
||||
}
|
||||
|
||||
// restore recovers an unsaved draft for id, comparing the version it was
|
||||
// based on with the freshly fetched server version (review DATA-03). A
|
||||
// mismatch means another session saved in between: the draft enters the
|
||||
// paused conflict state instead of being rebased onto the newer version.
|
||||
restore(id: string, serverVersion?: number): string | undefined {
|
||||
let draft = this.drafts.get(id)
|
||||
if (draft && draft.revision <= draft.savedRevision) draft = undefined
|
||||
if (!draft) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(this.key(id))
|
||||
if (raw !== null) {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
let content: string | undefined
|
||||
let baseVersion: number | undefined
|
||||
if (typeof parsed === 'string') content = parsed // legacy plain-string draft
|
||||
else if (parsed && typeof parsed === 'object') {
|
||||
const envelope = parsed as Envelope
|
||||
if (typeof envelope.content === 'string') content = envelope.content
|
||||
if (typeof envelope.baseVersion === 'number') baseVersion = envelope.baseVersion
|
||||
}
|
||||
if (content !== undefined) {
|
||||
draft = { content, revision: 1, savedRevision: 0, baseVersion, conflict: null, timer: null, saving: null }
|
||||
this.drafts.set(id, draft)
|
||||
}
|
||||
}
|
||||
} catch { /* Ignore unavailable storage or malformed drafts. */ }
|
||||
}
|
||||
if (!draft) return undefined
|
||||
// A known stale base (DATA-03) — or an unknown one (review DATA-06):
|
||||
// legacy plain-string drafts and envelopes stored before base tracking
|
||||
// cannot prove they predate the remote content, so recovery pauses them
|
||||
// for explicit review (compare via discard-to-load, copy, overwrite)
|
||||
// instead of letting the next keystroke save over a newer version.
|
||||
if (draft.conflict === null && typeof serverVersion === 'number' &&
|
||||
(draft.baseVersion === undefined || draft.baseVersion !== serverVersion)) {
|
||||
draft.conflict = serverVersion
|
||||
}
|
||||
this.state(id, draft.conflict !== null ? 'conflict' : 'dirty')
|
||||
return draft.content
|
||||
}
|
||||
|
||||
edit(id: string, content: string) {
|
||||
const draft = this.drafts.get(id) ?? {
|
||||
content, revision: 0, savedRevision: 0,
|
||||
baseVersion: this.versions.get(id), conflict: null, timer: null, saving: null,
|
||||
}
|
||||
draft.content = content
|
||||
draft.revision++
|
||||
if (draft.timer) { clearTimeout(draft.timer); draft.timer = null }
|
||||
this.drafts.set(id, draft)
|
||||
this.store(id, draft)
|
||||
// Review DATA-04: while a conflict is unresolved, typing keeps the local
|
||||
// draft but never schedules a save — keystrokes are not consent to
|
||||
// overwrite another session's work.
|
||||
if (draft.conflict !== null) { this.state(id, 'conflict'); return }
|
||||
this.state(id, 'dirty')
|
||||
draft.timer = setTimeout(() => { void this.flush(id) }, 800)
|
||||
}
|
||||
|
||||
async flush(id: string): Promise<boolean> {
|
||||
const draft = this.drafts.get(id)
|
||||
if (!draft) return true
|
||||
if (draft.timer) { clearTimeout(draft.timer); draft.timer = null }
|
||||
if (draft.saving) return draft.saving
|
||||
if (draft.conflict !== null) return false
|
||||
if (draft.revision === draft.savedRevision) return true
|
||||
draft.saving = this.persist(id, draft)
|
||||
try { return await draft.saving }
|
||||
finally { draft.saving = null }
|
||||
}
|
||||
|
||||
private async persist(id: string, draft: Draft): Promise<boolean> {
|
||||
while (draft.savedRevision < draft.revision) {
|
||||
const revision = draft.revision
|
||||
const content = draft.content
|
||||
this.state(id, 'saving')
|
||||
try {
|
||||
// Review CR-17: the expected revision stays bound to the content
|
||||
// snapshot this draft was actually edited from (its baseVersion).
|
||||
// The shared version map can move via other responses; consulting it
|
||||
// here would let a metadata mutation silently rebase a stale draft
|
||||
// onto a newer remote save. The base advances only from an
|
||||
// acknowledged save of this content (below) or an explicit
|
||||
// conflict resolution (resolveOverwrite).
|
||||
const base = draft.baseVersion ?? this.versions.get(id)
|
||||
const version = await this.save(id, content, base)
|
||||
if (typeof version === 'number') {
|
||||
this.versions.set(id, version)
|
||||
draft.baseVersion = version
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PageConflictError) {
|
||||
// Pause on the reported remote version without adopting it as the
|
||||
// save base; resolveOverwrite is the only path that rebases.
|
||||
draft.conflict = typeof error.serverVersion === 'number'
|
||||
? error.serverVersion
|
||||
: (this.versions.get(id) ?? 0)
|
||||
this.state(id, 'conflict')
|
||||
return false
|
||||
}
|
||||
this.state(id, 'dirty')
|
||||
return false
|
||||
}
|
||||
draft.savedRevision = revision
|
||||
// Review DATA-07: refresh the stored envelope after every successful
|
||||
// iteration, not only from edit(). If a later save in this loop fails
|
||||
// (or a reload interrupts it), recovery must see the newest content
|
||||
// with the advanced base — a stale stored base would flag an already
|
||||
// clean draft as a spurious conflict.
|
||||
this.store(id, draft)
|
||||
}
|
||||
if (draft.timer) { clearTimeout(draft.timer); draft.timer = null }
|
||||
this.store(id, null)
|
||||
this.state(id, 'saved')
|
||||
return true
|
||||
}
|
||||
|
||||
// conflictVersion reports the remote version a paused conflict was detected
|
||||
// against, or null when the page is not in the conflict state.
|
||||
conflictVersion(id: string): number | null {
|
||||
return this.drafts.get(id)?.conflict ?? null
|
||||
}
|
||||
|
||||
// resolveOverwrite is the explicit user decision to replace the newer
|
||||
// remote content with the local draft (review DATA-04).
|
||||
async resolveOverwrite(id: string): Promise<boolean> {
|
||||
const draft = this.drafts.get(id)
|
||||
if (!draft || draft.conflict === null) return true
|
||||
this.versions.set(id, draft.conflict)
|
||||
draft.baseVersion = draft.conflict
|
||||
draft.conflict = null
|
||||
// Review DATA-07: persist the rebased envelope immediately so a failed
|
||||
// overwrite save still recovers against the version it chose to replace.
|
||||
this.store(id, draft)
|
||||
this.state(id, 'dirty')
|
||||
return this.flush(id)
|
||||
}
|
||||
|
||||
// discard drops the local draft after an explicit user decision; the caller
|
||||
// reloads the page so the editor shows the remote content again.
|
||||
discard(id: string) {
|
||||
const draft = this.drafts.get(id)
|
||||
if (draft?.timer) clearTimeout(draft.timer)
|
||||
this.drafts.delete(id)
|
||||
this.store(id, null)
|
||||
this.state(id, 'saved')
|
||||
}
|
||||
|
||||
hasUnsaved() { return [...this.drafts.values()].some(draft => draft.revision > draft.savedRevision) }
|
||||
async flushAll() { return (await Promise.all([...this.drafts.keys()].map(id => this.flush(id)))).every(Boolean) }
|
||||
forget(id: string) {
|
||||
const draft = this.drafts.get(id)
|
||||
if (draft?.timer) clearTimeout(draft.timer)
|
||||
this.drafts.delete(id)
|
||||
this.versions.delete(id)
|
||||
this.store(id, null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PageSummary } from '@/lib/pages'
|
||||
|
||||
/**
|
||||
* Reorder pages by moving sourceId beside targetId. Pinned and unpinned pages
|
||||
* are separate groups (the server sorts pinned first), so a cross-group drop
|
||||
* returns 'blocked' instead of silently landing at a different boundary.
|
||||
* `after` places the source after the target; otherwise it goes before it.
|
||||
*/
|
||||
export function reorderPages(
|
||||
pages: PageSummary[],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
after = false,
|
||||
): PageSummary[] | 'blocked' | null {
|
||||
const from = pages.findIndex((p) => p.id === sourceId)
|
||||
const to = pages.findIndex((p) => p.id === targetId)
|
||||
if (from < 0 || to < 0 || sourceId === targetId) return null
|
||||
if (pages[from].pinned !== pages[to].pinned) return 'blocked'
|
||||
const next = [...pages]
|
||||
const [moved] = next.splice(from, 1)
|
||||
let insertAt = to + (after ? 1 : 0)
|
||||
if (from < insertAt) insertAt--
|
||||
next.splice(insertAt, 0, moved)
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const PAGES_CHANGED = 'computer:pages-changed'
|
||||
|
||||
export const notifyPagesChanged = () => window.dispatchEvent(new Event(PAGES_CHANGED))
|
||||
|
||||
// Notion-style breadcrumbs: the pages workspace announces which page is open
|
||||
// so the top bar can show "computer / Pages / <title>" without refetching.
|
||||
export const ACTIVE_PAGE_CHANGED = 'computer:active-page-changed'
|
||||
|
||||
export const notifyActivePage = (title: string | null) =>
|
||||
window.dispatchEvent(new CustomEvent<string | null>(ACTIVE_PAGE_CHANGED, { detail: title }))
|
||||
|
||||
export type PageSummary = { id: string; title: string; pinned: boolean; position: number; _count?: { bookmarks: number } }
|
||||
|
||||
// Pages start as "Untitled 1", "Untitled 2", ... until the user renames them.
|
||||
// Pick the lowest positive integer not already used by an untitled page.
|
||||
export function nextUntitledTitle(titles: string[]): string {
|
||||
const used = new Set<number>()
|
||||
for (const t of titles) {
|
||||
const m = /^Untitled (\d+)$/.exec(t.trim())
|
||||
if (m) used.add(parseInt(m[1], 10))
|
||||
}
|
||||
let n = 1
|
||||
while (used.has(n)) n++
|
||||
return `Untitled ${n}`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Short-TTL cache with in-flight deduplication for the reference data the
|
||||
// bookmark detail sheet needs on every open (identity, kanban columns,
|
||||
// contacts, tags). Mutation sites call invalidateReference() so a just-created
|
||||
// tag/column/contact shows up immediately instead of after the TTL.
|
||||
|
||||
export type ReferenceKind = 'me' | 'columns' | 'contacts' | 'tags'
|
||||
|
||||
const PATHS: Record<ReferenceKind, string> = {
|
||||
me: '/api/me',
|
||||
columns: '/api/kanban/columns',
|
||||
contacts: '/api/contacts',
|
||||
tags: '/api/tags',
|
||||
}
|
||||
|
||||
const TTL_MS = 30_000
|
||||
const cache = new Map<ReferenceKind, { at: number; promise: Promise<unknown> }>()
|
||||
|
||||
export function invalidateReference(kind: ReferenceKind) {
|
||||
cache.delete(kind)
|
||||
}
|
||||
|
||||
// Resolves to null on any failure; failures are not kept cached.
|
||||
export function fetchReference<T = unknown>(kind: ReferenceKind): Promise<T | null> {
|
||||
const hit = cache.get(kind)
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.promise as Promise<T | null>
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const res = await fetch(PATHS[kind])
|
||||
return res.ok ? ((await res.json()) as T) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
const entry = { at: Date.now(), promise }
|
||||
cache.set(kind, entry)
|
||||
void promise.then((v) => { if (v === null && cache.get(kind) === entry) cache.delete(kind) })
|
||||
return promise
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type Expense = {
|
||||
id: string
|
||||
amount: number
|
||||
category: string
|
||||
description: string
|
||||
date: Date
|
||||
}
|
||||
|
||||
export type ExpenseFormData = Omit<Expense, 'id' | 'date'> & {
|
||||
date: string
|
||||
}
|
||||
|
||||
export const EXPENSE_CATEGORIES = [
|
||||
'Food',
|
||||
'Transportation',
|
||||
'Housing',
|
||||
'Utilities',
|
||||
'Entertainment',
|
||||
'Healthcare',
|
||||
'Shopping',
|
||||
'Education',
|
||||
'Other'
|
||||
] as const
|
||||
|
||||
export type DateRange = {
|
||||
from: Date | undefined
|
||||
to: Date | undefined
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
Reference in New Issue
Block a user