chore: run prettier on all src/ files to fix formatting

Co-Authored-By: Justin Edmund <justin@jedmund.com>
This commit is contained in:
Devin AI 2025-11-23 12:12:02 +00:00
parent d60eba6e90
commit 8cc5cedc9d
65 changed files with 1917 additions and 1681 deletions

View file

@ -3,90 +3,99 @@ import { goto } from '$app/navigation'
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
export interface RequestOptions<TBody = unknown> { export interface RequestOptions<TBody = unknown> {
method?: HttpMethod method?: HttpMethod
body?: TBody body?: TBody
signal?: AbortSignal signal?: AbortSignal
headers?: Record<string, string> headers?: Record<string, string>
} }
export interface ApiError extends Error { export interface ApiError extends Error {
status: number status: number
details?: unknown details?: unknown
} }
function getAuthHeader() { function getAuthHeader() {
return {} return {}
} }
async function handleResponse(res: Response) { async function handleResponse(res: Response) {
if (res.status === 401) { if (res.status === 401) {
// Redirect to login for unauthorized requests // Redirect to login for unauthorized requests
try { try {
goto('/admin/login') goto('/admin/login')
} catch {} } catch {}
} }
const contentType = res.headers.get('content-type') || '' const contentType = res.headers.get('content-type') || ''
const isJson = contentType.includes('application/json') const isJson = contentType.includes('application/json')
const data = isJson ? await res.json().catch(() => undefined) : undefined const data = isJson ? await res.json().catch(() => undefined) : undefined
if (!res.ok) { if (!res.ok) {
const err: ApiError = Object.assign(new Error('Request failed'), { const err: ApiError = Object.assign(new Error('Request failed'), {
status: res.status, status: res.status,
details: data details: data
}) })
throw err throw err
} }
return data return data
} }
export async function request<TResponse = unknown, TBody = unknown>( export async function request<TResponse = unknown, TBody = unknown>(
url: string, url: string,
opts: RequestOptions<TBody> = {} opts: RequestOptions<TBody> = {}
): Promise<TResponse> { ): Promise<TResponse> {
const { method = 'GET', body, signal, headers } = opts const { method = 'GET', body, signal, headers } = opts
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData const isFormData = typeof FormData !== 'undefined' && body instanceof FormData
const mergedHeaders: Record<string, string> = { const mergedHeaders: Record<string, string> = {
...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...getAuthHeader(), ...getAuthHeader(),
...(headers || {}) ...(headers || {})
} }
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: mergedHeaders, headers: mergedHeaders,
body: body ? (isFormData ? (body as any) : JSON.stringify(body)) : undefined, body: body ? (isFormData ? (body as any) : JSON.stringify(body)) : undefined,
signal, signal,
credentials: 'same-origin' credentials: 'same-origin'
}) })
return handleResponse(res) as Promise<TResponse> return handleResponse(res) as Promise<TResponse>
} }
export const api = { export const api = {
get: <T = unknown>(url: string, opts: Omit<RequestOptions, 'method' | 'body'> = {}) => get: <T = unknown>(url: string, opts: Omit<RequestOptions, 'method' | 'body'> = {}) =>
request<T>(url, { ...opts, method: 'GET' }), request<T>(url, { ...opts, method: 'GET' }),
post: <T = unknown, B = unknown>(url: string, body: B, opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}) => post: <T = unknown, B = unknown>(
request<T, B>(url, { ...opts, method: 'POST', body }), url: string,
put: <T = unknown, B = unknown>(url: string, body: B, opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}) => body: B,
request<T, B>(url, { ...opts, method: 'PUT', body }), opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}
patch: <T = unknown, B = unknown>(url: string, body: B, opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}) => ) => request<T, B>(url, { ...opts, method: 'POST', body }),
request<T, B>(url, { ...opts, method: 'PATCH', body }), put: <T = unknown, B = unknown>(
delete: <T = unknown>(url: string, opts: Omit<RequestOptions, 'method' | 'body'> = {}) => url: string,
request<T>(url, { ...opts, method: 'DELETE' }) body: B,
opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}
) => request<T, B>(url, { ...opts, method: 'PUT', body }),
patch: <T = unknown, B = unknown>(
url: string,
body: B,
opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}
) => request<T, B>(url, { ...opts, method: 'PATCH', body }),
delete: <T = unknown>(url: string, opts: Omit<RequestOptions, 'method' | 'body'> = {}) =>
request<T>(url, { ...opts, method: 'DELETE' })
} }
export function createAbortable() { export function createAbortable() {
let controller: AbortController | null = null let controller: AbortController | null = null
return { return {
nextSignal() { nextSignal() {
if (controller) controller.abort() if (controller) controller.abort()
controller = new AbortController() controller = new AbortController()
return controller.signal return controller.signal
}, },
abort() { abort() {
if (controller) controller.abort() if (controller) controller.abort()
} }
} }
} }

View file

@ -1,20 +1,20 @@
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline' export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
export interface AutoSaveStoreOptions<TPayload, TResponse = unknown> { export interface AutoSaveStoreOptions<TPayload, TResponse = unknown> {
debounceMs?: number debounceMs?: number
idleResetMs?: number idleResetMs?: number
getPayload: () => TPayload | null | undefined getPayload: () => TPayload | null | undefined
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse> save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
} }
export interface AutoSaveStore<TPayload, TResponse = unknown> { export interface AutoSaveStore<TPayload, TResponse = unknown> {
readonly status: AutoSaveStatus readonly status: AutoSaveStatus
readonly lastError: string | null readonly lastError: string | null
schedule: () => void schedule: () => void
flush: () => Promise<void> flush: () => Promise<void>
destroy: () => void destroy: () => void
prime: (payload: TPayload) => void prime: (payload: TPayload) => void
} }
/** /**
@ -35,109 +35,109 @@ export interface AutoSaveStore<TPayload, TResponse = unknown> {
* // Trigger save: autoSave.schedule() * // Trigger save: autoSave.schedule()
*/ */
export function createAutoSaveStore<TPayload, TResponse = unknown>( export function createAutoSaveStore<TPayload, TResponse = unknown>(
opts: AutoSaveStoreOptions<TPayload, TResponse> opts: AutoSaveStoreOptions<TPayload, TResponse>
): AutoSaveStore<TPayload, TResponse> { ): AutoSaveStore<TPayload, TResponse> {
const debounceMs = opts.debounceMs ?? 2000 const debounceMs = opts.debounceMs ?? 2000
const idleResetMs = opts.idleResetMs ?? 2000 const idleResetMs = opts.idleResetMs ?? 2000
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
let idleResetTimer: ReturnType<typeof setTimeout> | null = null let idleResetTimer: ReturnType<typeof setTimeout> | null = null
let controller: AbortController | null = null let controller: AbortController | null = null
let lastSentHash: string | null = null let lastSentHash: string | null = null
let status = $state<AutoSaveStatus>('idle') let status = $state<AutoSaveStatus>('idle')
let lastError = $state<string | null>(null) let lastError = $state<string | null>(null)
function setStatus(next: AutoSaveStatus) { function setStatus(next: AutoSaveStatus) {
if (idleResetTimer) { if (idleResetTimer) {
clearTimeout(idleResetTimer) clearTimeout(idleResetTimer)
idleResetTimer = null idleResetTimer = null
} }
status = next status = next
// Auto-transition from 'saved' to 'idle' after idleResetMs // Auto-transition from 'saved' to 'idle' after idleResetMs
if (next === 'saved') { if (next === 'saved') {
idleResetTimer = setTimeout(() => { idleResetTimer = setTimeout(() => {
status = 'idle' status = 'idle'
idleResetTimer = null idleResetTimer = null
}, idleResetMs) }, idleResetMs)
} }
} }
function prime(payload: TPayload) { function prime(payload: TPayload) {
lastSentHash = safeHash(payload) lastSentHash = safeHash(payload)
} }
function schedule() { function schedule() {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
timer = setTimeout(() => void run(), debounceMs) timer = setTimeout(() => void run(), debounceMs)
} }
async function run() { async function run() {
if (timer) { if (timer) {
clearTimeout(timer) clearTimeout(timer)
timer = null timer = null
} }
const payload = opts.getPayload() const payload = opts.getPayload()
if (!payload) return if (!payload) return
const hash = safeHash(payload) const hash = safeHash(payload)
if (lastSentHash && hash === lastSentHash) return if (lastSentHash && hash === lastSentHash) return
if (controller) controller.abort() if (controller) controller.abort()
controller = new AbortController() controller = new AbortController()
setStatus('saving') setStatus('saving')
lastError = null lastError = null
try { try {
const res = await opts.save(payload, { signal: controller.signal }) const res = await opts.save(payload, { signal: controller.signal })
lastSentHash = hash lastSentHash = hash
setStatus('saved') setStatus('saved')
if (opts.onSaved) opts.onSaved(res, { prime }) if (opts.onSaved) opts.onSaved(res, { prime })
} catch (e: any) { } catch (e: any) {
if (e?.name === 'AbortError') { if (e?.name === 'AbortError') {
// Newer save superseded this one // Newer save superseded this one
return return
} }
if (typeof navigator !== 'undefined' && navigator.onLine === false) { if (typeof navigator !== 'undefined' && navigator.onLine === false) {
setStatus('offline') setStatus('offline')
} else { } else {
setStatus('error') setStatus('error')
} }
lastError = e?.message || 'Auto-save failed' lastError = e?.message || 'Auto-save failed'
} }
} }
function flush() { function flush() {
return run() return run()
} }
function destroy() { function destroy() {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
if (idleResetTimer) clearTimeout(idleResetTimer) if (idleResetTimer) clearTimeout(idleResetTimer)
if (controller) controller.abort() if (controller) controller.abort()
} }
return { return {
get status() { get status() {
return status return status
}, },
get lastError() { get lastError() {
return lastError return lastError
}, },
schedule, schedule,
flush, flush,
destroy, destroy,
prime prime
} }
} }
function safeHash(obj: unknown): string { function safeHash(obj: unknown): string {
try { try {
return JSON.stringify(obj) return JSON.stringify(obj)
} catch { } catch {
// Fallback for circular structures; not expected for form payloads // Fallback for circular structures; not expected for form payloads
return String(obj) return String(obj)
} }
} }

View file

@ -1,139 +1,139 @@
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline' export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
export interface AutoSaveController { export interface AutoSaveController {
status: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void } status: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
lastError: { subscribe: (run: (v: string | null) => void) => () => void } lastError: { subscribe: (run: (v: string | null) => void) => () => void }
schedule: () => void schedule: () => void
flush: () => Promise<void> flush: () => Promise<void>
destroy: () => void destroy: () => void
prime: <T>(payload: T) => void prime: <T>(payload: T) => void
} }
interface CreateAutoSaveControllerOptions<TPayload, TResponse = unknown> { interface CreateAutoSaveControllerOptions<TPayload, TResponse = unknown> {
debounceMs?: number debounceMs?: number
idleResetMs?: number idleResetMs?: number
getPayload: () => TPayload | null | undefined getPayload: () => TPayload | null | undefined
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse> save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
} }
export function createAutoSaveController<TPayload, TResponse = unknown>( export function createAutoSaveController<TPayload, TResponse = unknown>(
opts: CreateAutoSaveControllerOptions<TPayload, TResponse> opts: CreateAutoSaveControllerOptions<TPayload, TResponse>
) { ) {
const debounceMs = opts.debounceMs ?? 2000 const debounceMs = opts.debounceMs ?? 2000
const idleResetMs = opts.idleResetMs ?? 2000 const idleResetMs = opts.idleResetMs ?? 2000
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
let idleResetTimer: ReturnType<typeof setTimeout> | null = null let idleResetTimer: ReturnType<typeof setTimeout> | null = null
let controller: AbortController | null = null let controller: AbortController | null = null
let lastSentHash: string | null = null let lastSentHash: string | null = null
let _status: AutoSaveStatus = 'idle' let _status: AutoSaveStatus = 'idle'
let _lastError: string | null = null let _lastError: string | null = null
const statusSubs = new Set<(v: AutoSaveStatus) => void>() const statusSubs = new Set<(v: AutoSaveStatus) => void>()
const errorSubs = new Set<(v: string | null) => void>() const errorSubs = new Set<(v: string | null) => void>()
function setStatus(next: AutoSaveStatus) { function setStatus(next: AutoSaveStatus) {
if (idleResetTimer) { if (idleResetTimer) {
clearTimeout(idleResetTimer) clearTimeout(idleResetTimer)
idleResetTimer = null idleResetTimer = null
} }
_status = next _status = next
statusSubs.forEach((fn) => fn(_status)) statusSubs.forEach((fn) => fn(_status))
// Auto-transition from 'saved' to 'idle' after idleResetMs // Auto-transition from 'saved' to 'idle' after idleResetMs
if (next === 'saved') { if (next === 'saved') {
idleResetTimer = setTimeout(() => { idleResetTimer = setTimeout(() => {
_status = 'idle' _status = 'idle'
statusSubs.forEach((fn) => fn(_status)) statusSubs.forEach((fn) => fn(_status))
idleResetTimer = null idleResetTimer = null
}, idleResetMs) }, idleResetMs)
} }
} }
function prime(payload: TPayload) { function prime(payload: TPayload) {
lastSentHash = safeHash(payload) lastSentHash = safeHash(payload)
} }
function schedule() { function schedule() {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
timer = setTimeout(() => void run(), debounceMs) timer = setTimeout(() => void run(), debounceMs)
} }
async function run() { async function run() {
if (timer) { if (timer) {
clearTimeout(timer) clearTimeout(timer)
timer = null timer = null
} }
const payload = opts.getPayload() const payload = opts.getPayload()
if (!payload) return if (!payload) return
const hash = safeHash(payload) const hash = safeHash(payload)
if (lastSentHash && hash === lastSentHash) return if (lastSentHash && hash === lastSentHash) return
if (controller) controller.abort() if (controller) controller.abort()
controller = new AbortController() controller = new AbortController()
setStatus('saving') setStatus('saving')
_lastError = null _lastError = null
try { try {
const res = await opts.save(payload, { signal: controller.signal }) const res = await opts.save(payload, { signal: controller.signal })
lastSentHash = hash lastSentHash = hash
setStatus('saved') setStatus('saved')
if (opts.onSaved) opts.onSaved(res, { prime }) if (opts.onSaved) opts.onSaved(res, { prime })
} catch (e: any) { } catch (e: any) {
if (e?.name === 'AbortError') { if (e?.name === 'AbortError') {
// Newer save superseded this one // Newer save superseded this one
return return
} }
if (typeof navigator !== 'undefined' && navigator.onLine === false) { if (typeof navigator !== 'undefined' && navigator.onLine === false) {
setStatus('offline') setStatus('offline')
} else { } else {
setStatus('error') setStatus('error')
} }
_lastError = e?.message || 'Auto-save failed' _lastError = e?.message || 'Auto-save failed'
errorSubs.forEach((fn) => fn(_lastError)) errorSubs.forEach((fn) => fn(_lastError))
} }
} }
function flush() { function flush() {
return run() return run()
} }
function destroy() { function destroy() {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
if (idleResetTimer) clearTimeout(idleResetTimer) if (idleResetTimer) clearTimeout(idleResetTimer)
if (controller) controller.abort() if (controller) controller.abort()
} }
return { return {
status: { status: {
subscribe(run: (v: AutoSaveStatus) => void) { subscribe(run: (v: AutoSaveStatus) => void) {
run(_status) run(_status)
statusSubs.add(run) statusSubs.add(run)
return () => statusSubs.delete(run) return () => statusSubs.delete(run)
} }
}, },
lastError: { lastError: {
subscribe(run: (v: string | null) => void) { subscribe(run: (v: string | null) => void) {
run(_lastError) run(_lastError)
errorSubs.add(run) errorSubs.add(run)
return () => errorSubs.delete(run) return () => errorSubs.delete(run)
} }
}, },
schedule, schedule,
flush, flush,
destroy, destroy,
prime prime
} }
} }
function safeHash(obj: unknown): string { function safeHash(obj: unknown): string {
try { try {
return JSON.stringify(obj) return JSON.stringify(obj)
} catch { } catch {
// Fallback for circular structures; not expected for form payloads // Fallback for circular structures; not expected for form payloads
return String(obj) return String(obj)
} }
} }

View file

@ -4,58 +4,58 @@ import type { AutoSaveController } from './autoSave'
import type { AutoSaveStore } from './autoSave.svelte' import type { AutoSaveStore } from './autoSave.svelte'
interface AutoSaveLifecycleOptions { interface AutoSaveLifecycleOptions {
isReady?: () => boolean isReady?: () => boolean
onFlushError?: (error: unknown) => void onFlushError?: (error: unknown) => void
enableShortcut?: boolean enableShortcut?: boolean
} }
export function initAutoSaveLifecycle( export function initAutoSaveLifecycle(
controller: AutoSaveController | AutoSaveStore<any, any>, controller: AutoSaveController | AutoSaveStore<any, any>,
options: AutoSaveLifecycleOptions = {} options: AutoSaveLifecycleOptions = {}
) { ) {
const { isReady = () => true, onFlushError, enableShortcut = true } = options const { isReady = () => true, onFlushError, enableShortcut = true } = options
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
onDestroy(() => controller.destroy()) onDestroy(() => controller.destroy())
return return
} }
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (!enableShortcut) return if (!enableShortcut) return
if (!isReady()) return if (!isReady()) return
const key = event.key.toLowerCase() const key = event.key.toLowerCase()
const isModifier = event.metaKey || event.ctrlKey const isModifier = event.metaKey || event.ctrlKey
if (!isModifier || key !== 's') return if (!isModifier || key !== 's') return
event.preventDefault() event.preventDefault()
controller.flush().catch((error) => { controller.flush().catch((error) => {
onFlushError?.(error) onFlushError?.(error)
}) })
} }
if (enableShortcut) { if (enableShortcut) {
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
} }
const stopNavigating = beforeNavigate(async (navigation) => { const stopNavigating = beforeNavigate(async (navigation) => {
if (!isReady()) return if (!isReady()) return
navigation.cancel() navigation.cancel()
try { try {
await controller.flush() await controller.flush()
navigation.retry() navigation.retry()
} catch (error) { } catch (error) {
onFlushError?.(error) onFlushError?.(error)
} }
}) })
const stop = () => { const stop = () => {
if (enableShortcut) { if (enableShortcut) {
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleKeydown)
} }
stopNavigating?.() stopNavigating?.()
controller.destroy() controller.destroy()
} }
onDestroy(stop) onDestroy(stop)
return { stop } return { stop }
} }

View file

@ -1,49 +1,49 @@
export type Draft<T = unknown> = { payload: T; ts: number } export type Draft<T = unknown> = { payload: T; ts: number }
export function makeDraftKey(type: string, id: string | number) { export function makeDraftKey(type: string, id: string | number) {
return `admin:draft:${type}:${id}` return `admin:draft:${type}:${id}`
} }
export function saveDraft<T>(key: string, payload: T) { export function saveDraft<T>(key: string, payload: T) {
try { try {
const entry: Draft<T> = { payload, ts: Date.now() } const entry: Draft<T> = { payload, ts: Date.now() }
localStorage.setItem(key, JSON.stringify(entry)) localStorage.setItem(key, JSON.stringify(entry))
} catch { } catch {
// Ignore quota or serialization errors // Ignore quota or serialization errors
} }
} }
export function loadDraft<T = unknown>(key: string): Draft<T> | null { export function loadDraft<T = unknown>(key: string): Draft<T> | null {
try { try {
const raw = localStorage.getItem(key) const raw = localStorage.getItem(key)
if (!raw) return null if (!raw) return null
return JSON.parse(raw) as Draft<T> return JSON.parse(raw) as Draft<T>
} catch { } catch {
return null return null
} }
} }
export function clearDraft(key: string) { export function clearDraft(key: string) {
try { try {
localStorage.removeItem(key) localStorage.removeItem(key)
} catch {} } catch {}
} }
export function timeAgo(ts: number): string { export function timeAgo(ts: number): string {
const diff = Date.now() - ts const diff = Date.now() - ts
const sec = Math.floor(diff / 1000) const sec = Math.floor(diff / 1000)
if (sec < 5) return 'just now' if (sec < 5) return 'just now'
if (sec < 60) return `${sec} second${sec !== 1 ? 's' : ''} ago` if (sec < 60) return `${sec} second${sec !== 1 ? 's' : ''} ago`
const min = Math.floor(sec / 60) const min = Math.floor(sec / 60)
if (min < 60) return `${min} minute${min !== 1 ? 's' : ''} ago` if (min < 60) return `${min} minute${min !== 1 ? 's' : ''} ago`
const hr = Math.floor(min / 60) const hr = Math.floor(min / 60)
if (hr < 24) return `${hr} hour${hr !== 1 ? 's' : ''} ago` if (hr < 24) return `${hr} hour${hr !== 1 ? 's' : ''} ago`
const day = Math.floor(hr / 24) const day = Math.floor(hr / 24)
if (day <= 29) { if (day <= 29) {
if (day < 7) return `${day} day${day !== 1 ? 's' : ''} ago` if (day < 7) return `${day} day${day !== 1 ? 's' : ''} ago`
const wk = Math.floor(day / 7) const wk = Math.floor(day / 7)
return `${wk} week${wk !== 1 ? 's' : ''} ago` return `${wk} week${wk !== 1 ? 's' : ''} ago`
} }
// Beyond 29 days, show a normal localized date // Beyond 29 days, show a normal localized date
return new Date(ts).toLocaleDateString() return new Date(ts).toLocaleDateString()
} }

View file

@ -127,38 +127,54 @@ export function createListFilters<T>(
*/ */
export const commonSorts = { export const commonSorts = {
/** Sort by date field, newest first */ /** Sort by date field, newest first */
dateDesc: <T>(field: keyof T) => (a: T, b: T) => dateDesc:
new Date(b[field] as string).getTime() - new Date(a[field] as string).getTime(), <T>(field: keyof T) =>
(a: T, b: T) =>
new Date(b[field] as string).getTime() - new Date(a[field] as string).getTime(),
/** Sort by date field, oldest first */ /** Sort by date field, oldest first */
dateAsc: <T>(field: keyof T) => (a: T, b: T) => dateAsc:
new Date(a[field] as string).getTime() - new Date(b[field] as string).getTime(), <T>(field: keyof T) =>
(a: T, b: T) =>
new Date(a[field] as string).getTime() - new Date(b[field] as string).getTime(),
/** Sort by string field, A-Z */ /** Sort by string field, A-Z */
stringAsc: <T>(field: keyof T) => (a: T, b: T) => stringAsc:
String(a[field] || '').localeCompare(String(b[field] || '')), <T>(field: keyof T) =>
(a: T, b: T) =>
String(a[field] || '').localeCompare(String(b[field] || '')),
/** Sort by string field, Z-A */ /** Sort by string field, Z-A */
stringDesc: <T>(field: keyof T) => (a: T, b: T) => stringDesc:
String(b[field] || '').localeCompare(String(a[field] || '')), <T>(field: keyof T) =>
(a: T, b: T) =>
String(b[field] || '').localeCompare(String(a[field] || '')),
/** Sort by number field, ascending */ /** Sort by number field, ascending */
numberAsc: <T>(field: keyof T) => (a: T, b: T) => numberAsc:
Number(a[field]) - Number(b[field]), <T>(field: keyof T) =>
(a: T, b: T) =>
Number(a[field]) - Number(b[field]),
/** Sort by number field, descending */ /** Sort by number field, descending */
numberDesc: <T>(field: keyof T) => (a: T, b: T) => numberDesc:
Number(b[field]) - Number(a[field]), <T>(field: keyof T) =>
(a: T, b: T) =>
Number(b[field]) - Number(a[field]),
/** Sort by status field, published first */ /** Sort by status field, published first */
statusPublishedFirst: <T>(field: keyof T) => (a: T, b: T) => { statusPublishedFirst:
if (a[field] === b[field]) return 0 <T>(field: keyof T) =>
return a[field] === 'published' ? -1 : 1 (a: T, b: T) => {
}, if (a[field] === b[field]) return 0
return a[field] === 'published' ? -1 : 1
},
/** Sort by status field, draft first */ /** Sort by status field, draft first */
statusDraftFirst: <T>(field: keyof T) => (a: T, b: T) => { statusDraftFirst:
if (a[field] === b[field]) return 0 <T>(field: keyof T) =>
return a[field] === 'draft' ? -1 : 1 (a: T, b: T) => {
} if (a[field] === b[field]) return 0
return a[field] === 'draft' ? -1 : 1
}
} }

View file

@ -137,7 +137,8 @@
{#if searchError} {#if searchError}
<div class="error-message"> <div class="error-message">
<strong>Error:</strong> {searchError} <strong>Error:</strong>
{searchError}
</div> </div>
{/if} {/if}
@ -152,13 +153,7 @@
<h3>Results</h3> <h3>Results</h3>
<div class="result-tabs"> <div class="result-tabs">
<button <button class="tab" class:active={true} onclick={() => {}}> Raw JSON </button>
class="tab"
class:active={true}
onclick={() => {}}
>
Raw JSON
</button>
<button <button
class="copy-btn" class="copy-btn"
onclick={async () => { onclick={async () => {
@ -277,7 +272,8 @@
margin-bottom: $unit-half; margin-bottom: $unit-half;
} }
input, select { input,
select {
width: 100%; width: 100%;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);

View file

@ -12,7 +12,6 @@
let isBlinking = $state(false) let isBlinking = $state(false)
let isPlayingMusic = $state(forcePlayingMusic) let isPlayingMusic = $state(forcePlayingMusic)
const scale = new Spring(1, { const scale = new Spring(1, {
stiffness: 0.1, stiffness: 0.1,
damping: 0.125 damping: 0.125

View file

@ -51,18 +51,25 @@
connected = state.connected connected = state.connected
// Flash indicator when update is received // Flash indicator when update is received
if (state.lastUpdate && (!lastUpdate || state.lastUpdate.getTime() !== lastUpdate.getTime())) { if (
state.lastUpdate &&
(!lastUpdate || state.lastUpdate.getTime() !== lastUpdate.getTime())
) {
updateFlash = true updateFlash = true
setTimeout(() => updateFlash = false, 500) setTimeout(() => (updateFlash = false), 500)
} }
lastUpdate = state.lastUpdate lastUpdate = state.lastUpdate
// Calculate smart interval based on track remaining time // Calculate smart interval based on track remaining time
const nowPlayingAlbum = state.albums.find(a => a.isNowPlaying) const nowPlayingAlbum = state.albums.find((a) => a.isNowPlaying)
if (nowPlayingAlbum?.nowPlayingTrack && nowPlayingAlbum.appleMusicData?.tracks && nowPlayingAlbum.lastScrobbleTime) { if (
nowPlayingAlbum?.nowPlayingTrack &&
nowPlayingAlbum.appleMusicData?.tracks &&
nowPlayingAlbum.lastScrobbleTime
) {
const track = nowPlayingAlbum.appleMusicData.tracks.find( const track = nowPlayingAlbum.appleMusicData.tracks.find(
t => t.name === nowPlayingAlbum.nowPlayingTrack (t) => t.name === nowPlayingAlbum.nowPlayingTrack
) )
if (track?.durationMs) { if (track?.durationMs) {
@ -109,7 +116,7 @@
// Calculate initial remaining time // Calculate initial remaining time
const calculateRemaining = () => { const calculateRemaining = () => {
const elapsed = Date.now() - lastUpdate.getTime() const elapsed = Date.now() - lastUpdate.getTime()
const remaining = (updateInterval * 1000) - elapsed const remaining = updateInterval * 1000 - elapsed
return Math.max(0, Math.ceil(remaining / 1000)) return Math.max(0, Math.ceil(remaining / 1000))
} }
@ -213,7 +220,7 @@
{#if dev} {#if dev}
<div class="debug-panel" class:minimized={isMinimized}> <div class="debug-panel" class:minimized={isMinimized}>
<div class="debug-header" onclick={() => isMinimized = !isMinimized}> <div class="debug-header" onclick={() => (isMinimized = !isMinimized)}>
<h3>Debug Panel</h3> <h3>Debug Panel</h3>
<button class="minimize-btn" aria-label={isMinimized ? 'Expand' : 'Minimize'}> <button class="minimize-btn" aria-label={isMinimized ? 'Expand' : 'Minimize'}>
{isMinimized ? '▲' : '▼'} {isMinimized ? '▲' : '▼'}
@ -226,21 +233,21 @@
<button <button
class="tab" class="tab"
class:active={activeTab === 'nowplaying'} class:active={activeTab === 'nowplaying'}
onclick={() => activeTab = 'nowplaying'} onclick={() => (activeTab = 'nowplaying')}
> >
Now Playing Now Playing
</button> </button>
<button <button
class="tab" class="tab"
class:active={activeTab === 'albums'} class:active={activeTab === 'albums'}
onclick={() => activeTab = 'albums'} onclick={() => (activeTab = 'albums')}
> >
Albums Albums
</button> </button>
<button <button
class="tab" class="tab"
class:active={activeTab === 'cache'} class:active={activeTab === 'cache'}
onclick={() => activeTab = 'cache'} onclick={() => (activeTab = 'cache')}
> >
Cache Cache
</button> </button>
@ -251,13 +258,21 @@
<div class="section"> <div class="section">
<h4>Connection</h4> <h4>Connection</h4>
<p class="status" class:connected> <p class="status" class:connected>
Status: {#if connected}<CheckIcon class="icon status-icon success" /> Connected{:else}<XIcon class="icon status-icon error" /> Disconnected{/if} Status: {#if connected}<CheckIcon class="icon status-icon success" /> Connected{:else}<XIcon
class="icon status-icon error"
/> Disconnected{/if}
</p> </p>
<p class:flash={updateFlash}> <p class:flash={updateFlash}>
Last Update: {lastUpdate ? lastUpdate.toLocaleTimeString() : 'Never'} Last Update: {lastUpdate ? lastUpdate.toLocaleTimeString() : 'Never'}
</p> </p>
<p>Next Update: {formatTime(nextUpdateIn)}</p> <p>Next Update: {formatTime(nextUpdateIn)}</p>
<p>Interval: {updateInterval}s {trackRemainingTime > 0 ? `(smart mode)` : nowPlaying ? '(fast mode)' : '(normal)'}</p> <p>
Interval: {updateInterval}s {trackRemainingTime > 0
? `(smart mode)`
: nowPlaying
? '(fast mode)'
: '(normal)'}
</p>
{#if trackRemainingTime > 0} {#if trackRemainingTime > 0}
<p>Track Remaining: {formatTime(trackRemainingTime)}</p> <p>Track Remaining: {formatTime(trackRemainingTime)}</p>
{/if} {/if}
@ -274,7 +289,10 @@
{/if} {/if}
{#if nowPlaying.album.appleMusicData} {#if nowPlaying.album.appleMusicData}
<p class="preview"> <p class="preview">
<span>Preview:</span> {#if nowPlaying.album.appleMusicData.previewUrl}<CheckIcon class="icon success" /> Available{:else}<XIcon class="icon error" /> Not found{/if} <span>Preview:</span>
{#if nowPlaying.album.appleMusicData.previewUrl}<CheckIcon
class="icon success"
/> Available{:else}<XIcon class="icon error" /> Not found{/if}
</p> </p>
{/if} {/if}
</div> </div>
@ -290,8 +308,16 @@
<div class="albums-list"> <div class="albums-list">
{#each albums as album} {#each albums as album}
{@const albumId = `${album.artist.name}:${album.name}`} {@const albumId = `${album.artist.name}:${album.name}`}
<div class="album-item" class:playing={album.isNowPlaying} class:expanded={expandedAlbumId === albumId}> <div
<div class="album-header" onclick={() => expandedAlbumId = expandedAlbumId === albumId ? null : albumId}> class="album-item"
class:playing={album.isNowPlaying}
class:expanded={expandedAlbumId === albumId}
>
<div
class="album-header"
onclick={() =>
(expandedAlbumId = expandedAlbumId === albumId ? null : albumId)}
>
<div class="album-content"> <div class="album-content">
<div class="album-info"> <div class="album-info">
<span class="name">{album.name}</span> <span class="name">{album.name}</span>
@ -306,7 +332,9 @@
{album.appleMusicData.tracks?.length || 0} tracks {album.appleMusicData.tracks?.length || 0} tracks
</span> </span>
<span class="meta-item"> <span class="meta-item">
{#if album.appleMusicData.previewUrl}<CheckIcon class="icon success inline" /> Preview{:else}<XIcon class="icon error inline" /> No preview{/if} {#if album.appleMusicData.previewUrl}<CheckIcon
class="icon success inline"
/> Preview{:else}<XIcon class="icon error inline" /> No preview{/if}
</span> </span>
{:else} {:else}
<span class="meta-item">No Apple Music data</span> <span class="meta-item">No Apple Music data</span>
@ -315,7 +343,10 @@
</div> </div>
<button <button
class="clear-cache-btn" class="clear-cache-btn"
onclick={(e) => { e.stopPropagation(); clearAlbumCache(album) }} onclick={(e) => {
e.stopPropagation()
clearAlbumCache(album)
}}
disabled={clearingAlbums.has(albumId)} disabled={clearingAlbums.has(albumId)}
title="Clear Apple Music cache for this album" title="Clear Apple Music cache for this album"
> >
@ -333,9 +364,18 @@
{#if album.appleMusicData.searchMetadata} {#if album.appleMusicData.searchMetadata}
<h5>Search Information</h5> <h5>Search Information</h5>
<div class="search-metadata"> <div class="search-metadata">
<p><strong>Search Query:</strong> <code>{album.appleMusicData.searchMetadata.searchQuery}</code></p> <p>
<p><strong>Search Time:</strong> {new Date(album.appleMusicData.searchMetadata.searchTime).toLocaleString()}</p> <strong>Search Query:</strong>
<p><strong>Status:</strong> <code>{album.appleMusicData.searchMetadata.searchQuery}</code>
</p>
<p>
<strong>Search Time:</strong>
{new Date(
album.appleMusicData.searchMetadata.searchTime
).toLocaleString()}
</p>
<p>
<strong>Status:</strong>
{#if album.appleMusicData.searchMetadata.found} {#if album.appleMusicData.searchMetadata.found}
<CheckIcon class="icon success inline" /> Found <CheckIcon class="icon success inline" /> Found
{:else} {:else}
@ -343,59 +383,76 @@
{/if} {/if}
</p> </p>
{#if album.appleMusicData.searchMetadata.error} {#if album.appleMusicData.searchMetadata.error}
<p><strong>Error:</strong> <span class="error-text">{album.appleMusicData.searchMetadata.error}</span></p> <p>
<strong>Error:</strong>
<span class="error-text"
>{album.appleMusicData.searchMetadata.error}</span
>
</p>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if album.appleMusicData.appleMusicId} {#if album.appleMusicData.appleMusicId}
<h5>Apple Music Details</h5> <h5>Apple Music Details</h5>
<p><strong>Apple Music ID:</strong> {album.appleMusicData.appleMusicId}</p> <p>
<strong>Apple Music ID:</strong>
{album.appleMusicData.appleMusicId}
</p>
{/if} {/if}
{#if album.appleMusicData.releaseDate} {#if album.appleMusicData.releaseDate}
<p><strong>Release Date:</strong> {album.appleMusicData.releaseDate}</p> <p><strong>Release Date:</strong> {album.appleMusicData.releaseDate}</p>
{/if} {/if}
{#if album.appleMusicData.recordLabel} {#if album.appleMusicData.recordLabel}
<p><strong>Label:</strong> {album.appleMusicData.recordLabel}</p> <p><strong>Label:</strong> {album.appleMusicData.recordLabel}</p>
{/if} {/if}
{#if album.appleMusicData.genres?.length} {#if album.appleMusicData.genres?.length}
<p><strong>Genres:</strong> {album.appleMusicData.genres.join(', ')}</p> <p><strong>Genres:</strong> {album.appleMusicData.genres.join(', ')}</p>
{/if} {/if}
{#if album.appleMusicData.previewUrl} {#if album.appleMusicData.previewUrl}
<p><strong>Preview URL:</strong> <code>{album.appleMusicData.previewUrl}</code></p> <p>
{/if} <strong>Preview URL:</strong>
<code>{album.appleMusicData.previewUrl}</code>
</p>
{/if}
{#if album.appleMusicData.tracks?.length} {#if album.appleMusicData.tracks?.length}
<div class="tracks-section"> <div class="tracks-section">
<h6>Tracks ({album.appleMusicData.tracks.length})</h6> <h6>Tracks ({album.appleMusicData.tracks.length})</h6>
<div class="tracks-list"> <div class="tracks-list">
{#each album.appleMusicData.tracks as track, i} {#each album.appleMusicData.tracks as track, i}
<div class="track-item"> <div class="track-item">
<span class="track-number">{i + 1}.</span> <span class="track-number">{i + 1}.</span>
<span class="track-name">{track.name}</span> <span class="track-name">{track.name}</span>
{#if track.durationMs} {#if track.durationMs}
<span class="track-duration">{Math.floor(track.durationMs / 60000)}:{String(Math.floor((track.durationMs % 60000) / 1000)).padStart(2, '0')}</span> <span class="track-duration"
{/if} >{Math.floor(track.durationMs / 60000)}:{String(
{#if track.previewUrl} Math.floor((track.durationMs % 60000) / 1000)
<CheckIcon class="icon success inline" title="Has preview" /> ).padStart(2, '0')}</span
{/if} >
</div> {/if}
{/each} {#if track.previewUrl}
<CheckIcon class="icon success inline" title="Has preview" />
{/if}
</div>
{/each}
</div>
</div> </div>
</div> {/if}
{/if}
<div class="raw-data"> <div class="raw-data">
<h6>Raw Data</h6> <h6>Raw Data</h6>
<pre>{JSON.stringify(album.appleMusicData, null, 2)}</pre> <pre>{JSON.stringify(album.appleMusicData, null, 2)}</pre>
</div> </div>
{:else} {:else}
<h5>No Apple Music Data</h5> <h5>No Apple Music Data</h5>
<p class="no-data">This album was not searched in Apple Music or the search is pending.</p> <p class="no-data">
This album was not searched in Apple Music or the search is pending.
</p>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -426,11 +483,7 @@
</div> </div>
<div class="cache-actions"> <div class="cache-actions">
<button <button onclick={clearAllMusicCache} disabled={isClearing} class="clear-all-btn">
onclick={clearAllMusicCache}
disabled={isClearing}
class="clear-all-btn"
>
{isClearing ? 'Clearing...' : 'Clear All Music Cache'} {isClearing ? 'Clearing...' : 'Clear All Music Cache'}
</button> </button>
@ -463,10 +516,7 @@
{isClearing ? 'Clearing...' : 'Clear Not Found Cache'} {isClearing ? 'Clearing...' : 'Clear Not Found Cache'}
</button> </button>
<button <button onclick={() => searchModal?.open()} class="search-btn">
onclick={() => searchModal?.open()}
class="search-btn"
>
Test Apple Music Search Test Apple Music Search
</button> </button>
</div> </div>
@ -912,7 +962,8 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: $unit; gap: $unit;
.clear-all-btn, .clear-not-found-btn { .clear-all-btn,
.clear-not-found-btn {
flex: 1; flex: 1;
min-width: 140px; min-width: 140px;
padding: $unit * 1.5; padding: $unit * 1.5;

View file

@ -250,8 +250,8 @@
background: $gray-95; background: $gray-95;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', font-family:
monospace; 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.9em; font-size: 0.9em;
color: $text-color; color: $text-color;
} }

View file

@ -8,11 +8,13 @@
let { album, getAlbumArtwork }: Props = $props() let { album, getAlbumArtwork }: Props = $props()
const trackText = $derived(`${album.artist.name} — ${album.name}${ const trackText = $derived(
album.appleMusicData?.releaseDate `${album.artist.name} — ${album.name}${
? ` (${new Date(album.appleMusicData.releaseDate).getFullYear()})` album.appleMusicData?.releaseDate
: '' ? ` (${new Date(album.appleMusicData.releaseDate).getFullYear()})`
} — ${album.nowPlayingTrack || album.name}`) : ''
} — ${album.nowPlayingTrack || album.name}`
)
</script> </script>
<nav class="now-playing-bar"> <nav class="now-playing-bar">

View file

@ -229,8 +229,8 @@
.metadata-value { .metadata-value {
font-size: 0.875rem; font-size: 0.875rem;
color: $gray-10; color: $gray-10;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', font-family:
monospace; 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
} }
} }

View file

@ -112,9 +112,9 @@
if (!album) return if (!album) return
try { try {
const response = await fetch(`/api/albums/${album.id}`, { const response = await fetch(`/api/albums/${album.id}`, {
credentials: 'same-origin' credentials: 'same-origin'
}) })
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
albumMedia = data.media || [] albumMedia = data.media || []
@ -275,10 +275,7 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if !isLoading} {#if !isLoading}
<AutoSaveStatus <AutoSaveStatus status="idle" lastSavedAt={album?.updatedAt} />
status="idle"
lastSavedAt={album?.updatedAt}
/>
{/if} {/if}
</div> </div>
</header> </header>

View file

@ -38,7 +38,14 @@
ondelete?: (event: CustomEvent<{ album: Album; event: MouseEvent }>) => void ondelete?: (event: CustomEvent<{ album: Album; event: MouseEvent }>) => void
} }
let { album, isDropdownActive = false, ontoggledropdown, onedit, ontogglepublish, ondelete }: Props = $props() let {
album,
isDropdownActive = false,
ontoggledropdown,
onedit,
ontogglepublish,
ondelete
}: Props = $props()
function formatRelativeTime(dateString: string): string { function formatRelativeTime(dateString: string): string {
const date = new Date(dateString) const date = new Date(dateString)

View file

@ -1,117 +1,119 @@
<script lang="ts"> <script lang="ts">
import type { AutoSaveStatus } from '$lib/admin/autoSave' import type { AutoSaveStatus } from '$lib/admin/autoSave'
import { formatTimeAgo } from '$lib/utils/time' import { formatTimeAgo } from '$lib/utils/time'
interface Props { interface Props {
statusStore?: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void } statusStore?: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
errorStore?: { subscribe: (run: (v: string | null) => void) => () => void } errorStore?: { subscribe: (run: (v: string | null) => void) => () => void }
status?: AutoSaveStatus status?: AutoSaveStatus
error?: string | null error?: string | null
lastSavedAt?: Date | string | null lastSavedAt?: Date | string | null
showTimestamp?: boolean showTimestamp?: boolean
compact?: boolean compact?: boolean
} }
let { let {
statusStore, statusStore,
errorStore, errorStore,
status: statusProp, status: statusProp,
error: errorProp, error: errorProp,
lastSavedAt, lastSavedAt,
showTimestamp = true, showTimestamp = true,
compact = true compact = true
}: Props = $props() }: Props = $props()
// Support both old subscription-based stores and new reactive values // Support both old subscription-based stores and new reactive values
let status = $state<AutoSaveStatus>('idle') let status = $state<AutoSaveStatus>('idle')
let errorText = $state<string | null>(null) let errorText = $state<string | null>(null)
let refreshKey = $state(0) // Used to force re-render for time updates let refreshKey = $state(0) // Used to force re-render for time updates
$effect(() => { $effect(() => {
// If using direct props (new runes-based store) // If using direct props (new runes-based store)
if (statusProp !== undefined) { if (statusProp !== undefined) {
status = statusProp status = statusProp
errorText = errorProp ?? null errorText = errorProp ?? null
return return
} }
// Otherwise use subscriptions (old store) // Otherwise use subscriptions (old store)
if (!statusStore) return if (!statusStore) return
const unsub = statusStore.subscribe((v) => (status = v)) const unsub = statusStore.subscribe((v) => (status = v))
let unsubErr: (() => void) | null = null let unsubErr: (() => void) | null = null
if (errorStore) unsubErr = errorStore.subscribe((v) => (errorText = v)) if (errorStore) unsubErr = errorStore.subscribe((v) => (errorText = v))
return () => { return () => {
unsub() unsub()
if (unsubErr) unsubErr() if (unsubErr) unsubErr()
} }
}) })
// Auto-refresh timestamp every 30 seconds // Auto-refresh timestamp every 30 seconds
$effect(() => { $effect(() => {
if (!lastSavedAt || !showTimestamp) return if (!lastSavedAt || !showTimestamp) return
const interval = setInterval(() => { const interval = setInterval(() => {
refreshKey++ refreshKey++
}, 30000) }, 30000)
return () => clearInterval(interval) return () => clearInterval(interval)
}) })
const label = $derived.by(() => { const label = $derived.by(() => {
// Force dependency on refreshKey to trigger re-computation // Force dependency on refreshKey to trigger re-computation
refreshKey refreshKey
switch (status) { switch (status) {
case 'saving': case 'saving':
return 'Saving…' return 'Saving…'
case 'saved': case 'saved':
case 'idle': case 'idle':
return lastSavedAt && showTimestamp return lastSavedAt && showTimestamp
? `Saved ${formatTimeAgo(lastSavedAt)}` ? `Saved ${formatTimeAgo(lastSavedAt)}`
: 'All changes saved' : 'All changes saved'
case 'offline': case 'offline':
return 'Offline' return 'Offline'
case 'error': case 'error':
return errorText ? `Error — ${errorText}` : 'Save failed' return errorText ? `Error — ${errorText}` : 'Save failed'
default: default:
return '' return ''
} }
}) })
</script> </script>
{#if label} {#if label}
<div class="autosave-status" class:compact> <div class="autosave-status" class:compact>
{#if status === 'saving'} {#if status === 'saving'}
<span class="spinner" aria-hidden="true"></span> <span class="spinner" aria-hidden="true"></span>
{/if} {/if}
<span class="text">{label}</span> <span class="text">{label}</span>
</div> </div>
{/if} {/if}
<style lang="scss"> <style lang="scss">
.autosave-status { .autosave-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
color: $gray-40; color: $gray-40;
font-size: 0.875rem; font-size: 0.875rem;
&.compact { &.compact {
font-size: 0.75rem; font-size: 0.75rem;
} }
} }
.spinner { .spinner {
width: 12px; width: 12px;
height: 12px; height: 12px;
border: 2px solid $gray-80; border: 2px solid $gray-80;
border-top-color: $gray-40; border-top-color: $gray-40;
border-radius: 50%; border-radius: 50%;
animation: spin 0.9s linear infinite; animation: spin 0.9s linear infinite;
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
} transform: rotate(360deg);
}
}
</style> </style>

View file

@ -11,7 +11,8 @@
<div class="draft-banner"> <div class="draft-banner">
<div class="draft-banner-content"> <div class="draft-banner-content">
<span class="draft-banner-text"> <span class="draft-banner-text">
Unsaved draft found{#if timeAgo} (saved {timeAgo}){/if}. Unsaved draft found{#if timeAgo}
(saved {timeAgo}){/if}.
</span> </span>
<div class="draft-banner-actions"> <div class="draft-banner-actions">
<button class="draft-banner-button" type="button" onclick={onRestore}>Restore</button> <button class="draft-banner-button" type="button" onclick={onRestore}>Restore</button>

View file

@ -50,43 +50,46 @@
let showDraftPrompt = $state(false) let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null) let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0) let timeTicker = $state(0)
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null)) const draftTimeText = $derived.by(() =>
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
)
function buildPayload() { function buildPayload() {
return { return {
title, title,
slug, slug,
type: 'essay', type: 'essay',
status, status,
content, content,
tags, tags,
updatedAt updatedAt
} }
} }
// Autosave store (edit mode only) // Autosave store (edit mode only)
let autoSave = mode === 'edit' && postId let autoSave =
? createAutoSaveStore({ mode === 'edit' && postId
debounceMs: 2000, ? createAutoSaveStore({
getPayload: () => (hasLoaded ? buildPayload() : null), debounceMs: 2000,
save: async (payload, { signal }) => { getPayload: () => (hasLoaded ? buildPayload() : null),
const response = await fetch(`/api/posts/${postId}`, { save: async (payload, { signal }) => {
method: 'PUT', const response = await fetch(`/api/posts/${postId}`, {
headers: { 'Content-Type': 'application/json' }, method: 'PUT',
body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin', body: JSON.stringify(payload),
signal credentials: 'same-origin',
signal
})
if (!response.ok) throw new Error('Failed to save')
return await response.json()
},
onSaved: (saved: any, { prime }) => {
updatedAt = saved.updatedAt
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
}) })
if (!response.ok) throw new Error('Failed to save') : null
return await response.json()
},
onSaved: (saved: any, { prime }) => {
updatedAt = saved.updatedAt
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
})
: null
const tabOptions = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
@ -107,14 +110,14 @@ let autoSave = mode === 'edit' && postId
] ]
// Auto-generate slug from title // Auto-generate slug from title
$effect(() => { $effect(() => {
if (title && !slug) { if (title && !slug) {
slug = title slug = title
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, '')
} }
}) })
// Prime autosave on initial load (edit mode only) // Prime autosave on initial load (edit mode only)
$effect(() => { $effect(() => {
@ -126,7 +129,12 @@ $effect(() => {
// Trigger autosave when form data changes // Trigger autosave when form data changes
$effect(() => { $effect(() => {
title; slug; status; content; tags; activeTab title
slug
status
content
tags
activeTab
if (hasLoaded && autoSave) { if (hasLoaded && autoSave) {
autoSave.schedule() autoSave.schedule()
} }
@ -142,14 +150,14 @@ $effect(() => {
} }
}) })
// Show restore prompt if a draft exists // Show restore prompt if a draft exists
$effect(() => { $effect(() => {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<any>(draftKey)
if (draft) { if (draft) {
showDraftPrompt = true showDraftPrompt = true
draftTimestamp = draft.ts draftTimestamp = draft.ts
} }
}) })
function restoreDraft() { function restoreDraft() {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<any>(draftKey)
@ -297,8 +305,8 @@ $effect(() => {
const savedPost = await response.json() const savedPost = await response.json()
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`) toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
clearDraft(draftKey) clearDraft(draftKey)
if (mode === 'create') { if (mode === 'create') {
goto(`/admin/posts/${savedPost.id}/edit`) goto(`/admin/posts/${savedPost.id}/edit`)
@ -311,7 +319,6 @@ $effect(() => {
isSaving = false isSaving = false
} }
} }
</script> </script>
<AdminPage> <AdminPage>
@ -341,7 +348,8 @@ $effect(() => {
<div class="draft-banner"> <div class="draft-banner">
<div class="draft-banner-content"> <div class="draft-banner-content">
<span class="draft-banner-text"> <span class="draft-banner-text">
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. Unsaved draft found{#if draftTimeText}
(saved {draftTimeText}){/if}.
</span> </span>
<div class="draft-banner-actions"> <div class="draft-banner-actions">
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button> <button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
@ -381,11 +389,7 @@ $effect(() => {
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" /> <Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
<DropdownSelectField <DropdownSelectField label="Status" bind:value={status} options={statusOptions} />
label="Status"
bind:value={status}
options={statusOptions}
/>
<div class="tags-field"> <div class="tags-field">
<label class="input-label">Tags</label> <label class="input-label">Tags</label>

View file

@ -236,7 +236,7 @@
const isOverLimit = $derived(characterCount > CHARACTER_LIMIT) const isOverLimit = $derived(characterCount > CHARACTER_LIMIT)
const canSave = $derived( const canSave = $derived(
(postType === 'post' && (characterCount > 0 || attachedPhotos.length > 0) && !isOverLimit) || (postType === 'post' && (characterCount > 0 || attachedPhotos.length > 0) && !isOverLimit) ||
(postType === 'essay' && essayTitle.length > 0 && content) (postType === 'essay' && essayTitle.length > 0 && content)
) )
</script> </script>

View file

@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import Button from './Button.svelte' import Button from './Button.svelte'
import { formatFileSize, getFileType, isVideoFile, formatDuration, formatBitrate } from '$lib/utils/mediaHelpers' import {
formatFileSize,
getFileType,
isVideoFile,
formatDuration,
formatBitrate
} from '$lib/utils/mediaHelpers'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
interface Props { interface Props {

View file

@ -47,49 +47,52 @@
let showDraftPrompt = $state(false) let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null) let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0) let timeTicker = $state(0)
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null)) const draftTimeText = $derived.by(() =>
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
)
function buildPayload() { function buildPayload() {
return { return {
title: title.trim(), title: title.trim(),
slug: createSlug(title), slug: createSlug(title),
type: 'photo', type: 'photo',
status, status,
content, content,
featuredImage: featuredImage ? featuredImage.url : null, featuredImage: featuredImage ? featuredImage.url : null,
tags: tags tags: tags
? tags ? tags
.split(',') .split(',')
.map((tag) => tag.trim()) .map((tag) => tag.trim())
.filter(Boolean) .filter(Boolean)
: [], : [],
updatedAt updatedAt
} }
} }
// Autosave store (edit mode only) // Autosave store (edit mode only)
let autoSave = mode === 'edit' && postId let autoSave =
? createAutoSaveStore({ mode === 'edit' && postId
debounceMs: 2000, ? createAutoSaveStore({
getPayload: () => (hasLoaded ? buildPayload() : null), debounceMs: 2000,
save: async (payload, { signal }) => { getPayload: () => (hasLoaded ? buildPayload() : null),
const response = await fetch(`/api/posts/${postId}`, { save: async (payload, { signal }) => {
method: 'PUT', const response = await fetch(`/api/posts/${postId}`, {
headers: { 'Content-Type': 'application/json' }, method: 'PUT',
body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin', body: JSON.stringify(payload),
signal credentials: 'same-origin',
signal
})
if (!response.ok) throw new Error('Failed to save')
return await response.json()
},
onSaved: (saved: any, { prime }) => {
updatedAt = saved.updatedAt
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
}) })
if (!response.ok) throw new Error('Failed to save') : null
return await response.json()
},
onSaved: (saved: any, { prime }) => {
updatedAt = saved.updatedAt
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
})
: null
// Prime autosave on initial load (edit mode only) // Prime autosave on initial load (edit mode only)
$effect(() => { $effect(() => {
@ -101,7 +104,11 @@ let autoSave = mode === 'edit' && postId
// Trigger autosave when form data changes // Trigger autosave when form data changes
$effect(() => { $effect(() => {
title; status; content; featuredImage; tags title
status
content
featuredImage
tags
if (hasLoaded && autoSave) { if (hasLoaded && autoSave) {
autoSave.schedule() autoSave.schedule()
} }
@ -117,13 +124,13 @@ let autoSave = mode === 'edit' && postId
} }
}) })
$effect(() => { $effect(() => {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<any>(draftKey)
if (draft) { if (draft) {
showDraftPrompt = true showDraftPrompt = true
draftTimestamp = draft.ts draftTimestamp = draft.ts
} }
}) })
function restoreDraft() { function restoreDraft() {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<any>(draftKey)
@ -330,11 +337,11 @@ $effect(() => {
throw new Error(`Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`) throw new Error(`Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`)
} }
const savedPost = await response.json() const savedPost = await response.json()
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`) toast.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`)
clearDraft(draftKey) clearDraft(draftKey)
// Redirect to posts list or edit page // Redirect to posts list or edit page
if (mode === 'create') { if (mode === 'create') {
@ -397,7 +404,8 @@ $effect(() => {
<div class="draft-banner"> <div class="draft-banner">
<div class="draft-banner-content"> <div class="draft-banner-content">
<span class="draft-banner-text"> <span class="draft-banner-text">
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. Unsaved draft found{#if draftTimeText}
(saved {draftTimeText}){/if}.
</span> </span>
<div class="draft-banner-actions"> <div class="draft-banner-actions">
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button> <button class="draft-banner-button" onclick={restoreDraft}>Restore</button>

View file

@ -43,7 +43,11 @@
} }
</script> </script>
<div class="dropdown-container" use:clickOutside={{ enabled: isOpen }} onclickoutside={handleClickOutside}> <div
class="dropdown-container"
use:clickOutside={{ enabled: isOpen }}
onclickoutside={handleClickOutside}
>
<Button <Button
bind:this={buttonRef} bind:this={buttonRef}
variant="primary" variant="primary"

View file

@ -165,9 +165,7 @@
{#if isDropdownOpen} {#if isDropdownOpen}
<div class="dropdown-menu"> <div class="dropdown-menu">
<button class="dropdown-item" type="button" onclick={handleEdit}> <button class="dropdown-item" type="button" onclick={handleEdit}> Edit post </button>
Edit post
</button>
<button class="dropdown-item" type="button" onclick={handleTogglePublish}> <button class="dropdown-item" type="button" onclick={handleTogglePublish}>
{post.status === 'published' ? 'Unpublish' : 'Publish'} post {post.status === 'published' ? 'Unpublish' : 'Publish'} post
</button> </button>

View file

@ -81,7 +81,9 @@
const hasFeaturedImage = $derived( const hasFeaturedImage = $derived(
!!(formData.featuredImage && featuredImageMedia) || !!featuredImageMedia !!(formData.featuredImage && featuredImageMedia) || !!featuredImageMedia
) )
const hasBackgroundColor = $derived(!!(formData.backgroundColor && formData.backgroundColor?.trim())) const hasBackgroundColor = $derived(
!!(formData.backgroundColor && formData.backgroundColor?.trim())
)
const hasLogo = $derived(!!(formData.logoUrl && logoMedia) || !!logoMedia) const hasLogo = $derived(!!(formData.logoUrl && logoMedia) || !!logoMedia)
// Auto-disable toggles when content is removed // Auto-disable toggles when content is removed

View file

@ -43,21 +43,22 @@
const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null) const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null)
// Autosave (edit mode only) // Autosave (edit mode only)
const autoSave = mode === 'edit' const autoSave =
? createAutoSaveStore({ mode === 'edit'
debounceMs: 2000, ? createAutoSaveStore({
getPayload: () => (hasLoaded ? formStore.buildPayload() : null), debounceMs: 2000,
save: async (payload, { signal }) => { getPayload: () => (hasLoaded ? formStore.buildPayload() : null),
return await api.put(`/api/projects/${project?.id}`, payload, { signal }) save: async (payload, { signal }) => {
}, return await api.put(`/api/projects/${project?.id}`, payload, { signal })
onSaved: (savedProject: any, { prime }) => { },
project = savedProject onSaved: (savedProject: any, { prime }) => {
formStore.populateFromProject(savedProject) project = savedProject
prime(formStore.buildPayload()) formStore.populateFromProject(savedProject)
if (draftKey) clearDraft(draftKey) prime(formStore.buildPayload())
} if (draftKey) clearDraft(draftKey)
}) }
: null })
: null
// Draft recovery helper // Draft recovery helper
const draftRecovery = useDraftRecovery<Partial<ProjectFormData>>({ const draftRecovery = useDraftRecovery<Partial<ProjectFormData>>({
@ -89,7 +90,8 @@
// Trigger autosave when formData changes (edit mode) // Trigger autosave when formData changes (edit mode)
$effect(() => { $effect(() => {
// Establish dependencies on fields // Establish dependencies on fields
formStore.fields; activeTab formStore.fields
activeTab
if (mode === 'edit' && hasLoaded && autoSave) { if (mode === 'edit' && hasLoaded && autoSave) {
autoSave.schedule() autoSave.schedule()
} }
@ -143,9 +145,9 @@
let savedProject: Project let savedProject: Project
if (mode === 'edit') { if (mode === 'edit') {
savedProject = await api.put(`/api/projects/${project?.id}`, payload) as Project savedProject = (await api.put(`/api/projects/${project?.id}`, payload)) as Project
} else { } else {
savedProject = await api.post('/api/projects', payload) as Project savedProject = (await api.post('/api/projects', payload)) as Project
} }
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
@ -168,8 +170,6 @@
isSaving = false isSaving = false
} }
} }
</script> </script>
<AdminPage> <AdminPage>
@ -225,7 +225,11 @@
handleSave() handleSave()
}} }}
> >
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} /> <ProjectMetadataForm
bind:formData={formStore.fields}
validationErrors={formStore.validationErrors}
onSave={handleSave}
/>
</form> </form>
</div> </div>
</div> </div>
@ -239,7 +243,11 @@
handleSave() handleSave()
}} }}
> >
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} /> <ProjectBrandingForm
bind:formData={formStore.fields}
validationErrors={formStore.validationErrors}
onSave={handleSave}
/>
</form> </form>
</div> </div>
</div> </div>

View file

@ -131,9 +131,7 @@
{#if isDropdownOpen} {#if isDropdownOpen}
<div class="dropdown-menu"> <div class="dropdown-menu">
<button class="dropdown-item" type="button" onclick={handleEdit}> <button class="dropdown-item" type="button" onclick={handleEdit}> Edit project </button>
Edit project
</button>
<button class="dropdown-item" type="button" onclick={handleTogglePublish}> <button class="dropdown-item" type="button" onclick={handleTogglePublish}>
{project.status === 'published' ? 'Unpublish' : 'Publish'} project {project.status === 'published' ? 'Unpublish' : 'Publish'} project
</button> </button>

View file

@ -24,7 +24,7 @@
mode: 'create' | 'edit' mode: 'create' | 'edit'
} }
let { postType, postId, initialData, mode }: Props = $props() let { postType, postId, initialData, mode }: Props = $props()
// State // State
let isSaving = $state(false) let isSaving = $state(false)
@ -38,7 +38,7 @@ let { postType, postId, initialData, mode }: Props = $props()
let linkDescription = $state(initialData?.linkDescription || '') let linkDescription = $state(initialData?.linkDescription || '')
let title = $state(initialData?.title || '') let title = $state(initialData?.title || '')
// Character count for posts // Character count for posts
const maxLength = 280 const maxLength = 280
const textContent = $derived.by(() => { const textContent = $derived.by(() => {
if (!content.content) return '' if (!content.content) return ''
@ -50,178 +50,185 @@ let { postType, postId, initialData, mode }: Props = $props()
const isOverLimit = $derived(charCount > maxLength) const isOverLimit = $derived(charCount > maxLength)
// Check if form has content // Check if form has content
const hasContent = $derived.by(() => { const hasContent = $derived.by(() => {
// For posts, check if either content exists or it's a link with URL // For posts, check if either content exists or it's a link with URL
const hasTextContent = textContent.trim().length > 0 const hasTextContent = textContent.trim().length > 0
const hasLinkContent = linkUrl && linkUrl.trim().length > 0 const hasLinkContent = linkUrl && linkUrl.trim().length > 0
return hasTextContent || hasLinkContent return hasTextContent || hasLinkContent
}) })
// Draft backup // Draft backup
const draftKey = $derived(makeDraftKey('post', postId ?? 'new')) const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
let showDraftPrompt = $state(false) let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null) let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0) let timeTicker = $state(0)
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null)) const draftTimeText = $derived.by(() =>
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
)
function buildPayload() { function buildPayload() {
const payload: any = { const payload: any = {
type: 'post', type: 'post',
status, status,
content, content,
updatedAt updatedAt
} }
if (linkUrl && linkUrl.trim()) { if (linkUrl && linkUrl.trim()) {
payload.title = title || linkUrl payload.title = title || linkUrl
payload.link_url = linkUrl payload.link_url = linkUrl
payload.linkDescription = linkDescription payload.linkDescription = linkDescription
} else if (title) { } else if (title) {
payload.title = title payload.title = title
} }
return payload return payload
} }
// Autosave store (edit mode only) // Autosave store (edit mode only)
let autoSave = mode === 'edit' && postId let autoSave =
? createAutoSaveStore({ mode === 'edit' && postId
debounceMs: 2000, ? createAutoSaveStore({
getPayload: () => (hasLoaded ? buildPayload() : null), debounceMs: 2000,
save: async (payload, { signal }) => { getPayload: () => (hasLoaded ? buildPayload() : null),
const response = await fetch(`/api/posts/${postId}`, { save: async (payload, { signal }) => {
method: 'PUT', const response = await fetch(`/api/posts/${postId}`, {
headers: { 'Content-Type': 'application/json' }, method: 'PUT',
body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin', body: JSON.stringify(payload),
signal credentials: 'same-origin',
signal
})
if (!response.ok) throw new Error('Failed to save')
return await response.json()
},
onSaved: (saved: any, { prime }) => {
updatedAt = saved.updatedAt
prime(buildPayload())
if (draftKey) clearDraft(draftKey)
}
}) })
if (!response.ok) throw new Error('Failed to save') : null
return await response.json()
}, // Prime autosave on initial load (edit mode only)
onSaved: (saved: any, { prime }) => { $effect(() => {
updatedAt = saved.updatedAt if (mode === 'edit' && initialData && !hasLoaded && autoSave) {
prime(buildPayload()) autoSave.prime(buildPayload())
if (draftKey) clearDraft(draftKey) hasLoaded = true
}
})
// Trigger autosave when form data changes
$effect(() => {
status
content
linkUrl
linkDescription
title
if (hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Save draft only when autosave fails
$effect(() => {
if (hasLoaded && autoSave) {
const saveStatus = autoSave.status
if (saveStatus === 'error' || saveStatus === 'offline') {
saveDraft(draftKey, buildPayload())
} }
})
: null
// Prime autosave on initial load (edit mode only)
$effect(() => {
if (mode === 'edit' && initialData && !hasLoaded && autoSave) {
autoSave.prime(buildPayload())
hasLoaded = true
}
})
// Trigger autosave when form data changes
$effect(() => {
status; content; linkUrl; linkDescription; title
if (hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Save draft only when autosave fails
$effect(() => {
if (hasLoaded && autoSave) {
const saveStatus = autoSave.status
if (saveStatus === 'error' || saveStatus === 'offline') {
saveDraft(draftKey, buildPayload())
} }
} })
})
$effect(() => { $effect(() => {
const draft = loadDraft<any>(draftKey) const draft = loadDraft<any>(draftKey)
if (draft) { if (draft) {
showDraftPrompt = true showDraftPrompt = true
draftTimestamp = draft.ts draftTimestamp = draft.ts
}
})
function restoreDraft() {
const draft = loadDraft<any>(draftKey)
if (!draft) return
const p = draft.payload
status = p.status ?? status
content = p.content ?? content
if (p.link_url) {
linkUrl = p.link_url
linkDescription = p.linkDescription ?? linkDescription
title = p.title ?? title
} else {
title = p.title ?? title
}
showDraftPrompt = false
clearDraft(draftKey)
}
function dismissDraft() {
showDraftPrompt = false
clearDraft(draftKey)
}
// Auto-update draft time text every minute when prompt visible
$effect(() => {
if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
// Navigation guard: flush autosave before navigating away (only if unsaved)
beforeNavigate(async (navigation) => {
if (hasLoaded && autoSave) {
if (autoSave.status === 'saved') {
return
} }
// Flush any pending changes before allowing navigation to proceed })
try {
await autoSave.flush()
} catch (error) {
console.error('Autosave flush failed:', error)
}
}
})
// Warn before closing browser tab/window if there are unsaved changes function restoreDraft() {
$effect(() => { const draft = loadDraft<any>(draftKey)
if (!hasLoaded || !autoSave) return if (!draft) return
const p = draft.payload
function handleBeforeUnload(event: BeforeUnloadEvent) { status = p.status ?? status
if (autoSave!.status !== 'saved') { content = p.content ?? content
event.preventDefault() if (p.link_url) {
event.returnValue = '' linkUrl = p.link_url
linkDescription = p.linkDescription ?? linkDescription
title = p.title ?? title
} else {
title = p.title ?? title
} }
showDraftPrompt = false
clearDraft(draftKey)
} }
window.addEventListener('beforeunload', handleBeforeUnload) function dismissDraft() {
return () => window.removeEventListener('beforeunload', handleBeforeUnload) showDraftPrompt = false
}) clearDraft(draftKey)
}
// Keyboard shortcut: Cmd/Ctrl+S to save immediately // Auto-update draft time text every minute when prompt visible
$effect(() => { $effect(() => {
if (!hasLoaded || !autoSave) return if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id)
}
})
function handleKeydown(e: KeyboardEvent) { // Navigation guard: flush autosave before navigating away (only if unsaved)
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { beforeNavigate(async (navigation) => {
e.preventDefault() if (hasLoaded && autoSave) {
autoSave!.flush().catch((error) => { if (autoSave.status === 'saved') {
return
}
// Flush any pending changes before allowing navigation to proceed
try {
await autoSave.flush()
} catch (error) {
console.error('Autosave flush failed:', error) console.error('Autosave flush failed:', error)
}) }
} }
} })
document.addEventListener('keydown', handleKeydown) // Warn before closing browser tab/window if there are unsaved changes
return () => document.removeEventListener('keydown', handleKeydown) $effect(() => {
}) if (!hasLoaded || !autoSave) return
// Cleanup autosave on unmount function handleBeforeUnload(event: BeforeUnloadEvent) {
$effect(() => { if (autoSave!.status !== 'saved') {
if (autoSave) { event.preventDefault()
return () => autoSave.destroy() event.returnValue = ''
} }
}) }
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
$effect(() => {
if (!hasLoaded || !autoSave) return
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error)
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// Cleanup autosave on unmount
$effect(() => {
if (autoSave) {
return () => autoSave.destroy()
}
})
async function handleSave(publishStatus: 'draft' | 'published') { async function handleSave(publishStatus: 'draft' | 'published') {
if (isOverLimit) { if (isOverLimit) {
@ -275,11 +282,11 @@ $effect(() => {
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`) throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
} }
const savedPost = await response.json() const savedPost = await response.json()
toast.dismiss(loadingToastId) toast.dismiss(loadingToastId)
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`) toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
clearDraft(draftKey) clearDraft(draftKey)
// Redirect back to posts list after creation // Redirect back to posts list after creation
goto('/admin/posts') goto('/admin/posts')
@ -336,7 +343,8 @@ $effect(() => {
<div class="draft-banner"> <div class="draft-banner">
<div class="draft-banner-content"> <div class="draft-banner-content">
<span class="draft-banner-text"> <span class="draft-banner-text">
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. Unsaved draft found{#if draftTimeText}
(saved {draftTimeText}){/if}.
</span> </span>
<div class="draft-banner-actions"> <div class="draft-banner-actions">
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button> <button class="draft-banner-button" onclick={restoreDraft}>Restore</button>

View file

@ -66,14 +66,7 @@
> >
<span class="status-dot"></span> <span class="status-dot"></span>
<span class="status-label">{currentConfig.label}</span> <span class="status-label">{currentConfig.label}</span>
<svg <svg class="chevron" class:open={isOpen} width="12" height="12" viewBox="0 0 12 12" fill="none">
class="chevron"
class:open={isOpen}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path <path
d="M3 4.5L6 7.5L9 4.5" d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor" stroke="currentColor"
@ -96,12 +89,7 @@
{#if viewUrl && currentStatus === 'published'} {#if viewUrl && currentStatus === 'published'}
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a <a href={viewUrl} target="_blank" rel="noopener noreferrer" class="dropdown-item view-link">
href={viewUrl}
target="_blank"
rel="noopener noreferrer"
class="dropdown-item view-link"
>
View on site View on site
</a> </a>
{/if} {/if}

View file

@ -225,7 +225,6 @@
// Short delay to prevent flicker // Short delay to prevent flicker
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
let url = `/api/media?page=${page}&limit=24` let url = `/api/media?page=${page}&limit=24`
if (filterType !== 'all') { if (filterType !== 'all') {

View file

@ -375,10 +375,7 @@
const afterPos = nodePos + actualNode.nodeSize const afterPos = nodePos + actualNode.nodeSize
// Insert the duplicated node // Insert the duplicated node
editor.chain() editor.chain().focus().insertContentAt(afterPos, nodeCopy).run()
.focus()
.insertContentAt(afterPos, nodeCopy)
.run()
isMenuOpen = false isMenuOpen = false
} }

View file

@ -33,7 +33,10 @@ export async function adminFetch(
let detail: string | undefined let detail: string | undefined
try { try {
const json = await response.clone().json() const json = await response.clone().json()
detail = typeof json === 'object' && json !== null && 'error' in json ? String(json.error) : undefined detail =
typeof json === 'object' && json !== null && 'error' in json
? String(json.error)
: undefined
} catch { } catch {
try { try {
detail = await response.clone().text() detail = await response.clone().text()

View file

@ -340,11 +340,14 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
// Log all songs for debugging // Log all songs for debugging
songs.forEach((s, index) => { songs.forEach((s, index) => {
logger.music('debug', `Song ${index + 1}: "${s.attributes?.name}" by "${s.attributes?.artistName}" on "${s.attributes?.albumName}"`) logger.music(
'debug',
`Song ${index + 1}: "${s.attributes?.name}" by "${s.attributes?.artistName}" on "${s.attributes?.albumName}"`
)
}) })
// Find matching song // Find matching song
const matchingSong = songs.find(s => { const matchingSong = songs.find((s) => {
const songName = s.attributes?.name || '' const songName = s.attributes?.name || ''
const artistName = s.attributes?.artistName || '' const artistName = s.attributes?.artistName || ''
const albumName = s.attributes?.albumName || '' const albumName = s.attributes?.albumName || ''
@ -357,7 +360,8 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
const artistSearchLower = artist.toLowerCase() const artistSearchLower = artist.toLowerCase()
// Check if the song name matches what we're looking for // Check if the song name matches what we're looking for
const songMatches = songNameLower === albumSearchLower || const songMatches =
songNameLower === albumSearchLower ||
songNameLower.includes(albumSearchLower) || songNameLower.includes(albumSearchLower) ||
albumSearchLower.includes(songNameLower) albumSearchLower.includes(songNameLower)
@ -365,7 +369,8 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
const artistNameNormalized = artistNameLower.replace(/\s+/g, '') const artistNameNormalized = artistNameLower.replace(/\s+/g, '')
const artistSearchNormalized = artistSearchLower.replace(/\s+/g, '') const artistSearchNormalized = artistSearchLower.replace(/\s+/g, '')
const artistMatches = artistNameLower === artistSearchLower || const artistMatches =
artistNameLower === artistSearchLower ||
artistNameNormalized === artistSearchNormalized || artistNameNormalized === artistSearchNormalized ||
artistNameLower.includes(artistSearchLower) || artistNameLower.includes(artistSearchLower) ||
artistSearchLower.includes(artistNameLower) || artistSearchLower.includes(artistNameLower) ||
@ -373,7 +378,10 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
artistSearchNormalized.includes(artistNameNormalized) artistSearchNormalized.includes(artistNameNormalized)
if (songMatches && artistMatches) { if (songMatches && artistMatches) {
logger.music('debug', `Found matching song: "${songName}" by "${artistName}" on album "${albumName}"`) logger.music(
'debug',
`Found matching song: "${songName}" by "${artistName}" on album "${albumName}"`
)
return true return true
} }
@ -397,7 +405,10 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
} }
// If no album found, create a synthetic album from the song // If no album found, create a synthetic album from the song
logger.music('debug', `Creating synthetic album from single: "${matchingSong.attributes?.name}"`) logger.music(
'debug',
`Creating synthetic album from single: "${matchingSong.attributes?.name}"`
)
return { return {
id: `single-${matchingSong.id}`, id: `single-${matchingSong.id}`,
type: 'albums' as const, type: 'albums' as const,
@ -449,11 +460,13 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
if ((attributes as any).isSingle && (attributes as any)._singleSongPreview) { if ((attributes as any).isSingle && (attributes as any)._singleSongPreview) {
logger.music('debug', 'Processing synthetic single album') logger.music('debug', 'Processing synthetic single album')
previewUrl = (attributes as any)._singleSongPreview previewUrl = (attributes as any)._singleSongPreview
tracks = [{ tracks = [
name: attributes.name, {
previewUrl: (attributes as any)._singleSongPreview, name: attributes.name,
durationMs: undefined // We'd need to fetch the song details for duration previewUrl: (attributes as any)._singleSongPreview,
}] durationMs: undefined // We'd need to fetch the song details for duration
}
]
} }
// Always fetch tracks to get preview URLs // Always fetch tracks to get preview URLs
else if (appleMusicAlbum.id) { else if (appleMusicAlbum.id) {

View file

@ -9,12 +9,38 @@ export interface CacheConfig {
export class CacheManager { export class CacheManager {
private static cacheTypes: Map<string, CacheConfig> = new Map([ private static cacheTypes: Map<string, CacheConfig> = new Map([
['lastfm-recent', { prefix: 'lastfm:recent:', defaultTTL: 30, description: 'Last.fm recent tracks' }], [
['lastfm-album', { prefix: 'lastfm:albuminfo:', defaultTTL: 3600, description: 'Last.fm album info' }], 'lastfm-recent',
['apple-album', { prefix: 'apple:album:', defaultTTL: 86400, description: 'Apple Music album data' }], { prefix: 'lastfm:recent:', defaultTTL: 30, description: 'Last.fm recent tracks' }
['apple-notfound', { prefix: 'notfound:apple-music:', defaultTTL: 3600, description: 'Apple Music not found records' }], ],
['apple-failure', { prefix: 'failure:apple-music:', defaultTTL: 86400, description: 'Apple Music API failures' }], [
['apple-ratelimit', { prefix: 'ratelimit:apple-music:', defaultTTL: 3600, description: 'Apple Music rate limit state' }] 'lastfm-album',
{ prefix: 'lastfm:albuminfo:', defaultTTL: 3600, description: 'Last.fm album info' }
],
[
'apple-album',
{ prefix: 'apple:album:', defaultTTL: 86400, description: 'Apple Music album data' }
],
[
'apple-notfound',
{
prefix: 'notfound:apple-music:',
defaultTTL: 3600,
description: 'Apple Music not found records'
}
],
[
'apple-failure',
{ prefix: 'failure:apple-music:', defaultTTL: 86400, description: 'Apple Music API failures' }
],
[
'apple-ratelimit',
{
prefix: 'ratelimit:apple-music:',
defaultTTL: 3600,
description: 'Apple Music rate limit state'
}
]
]) ])
/** /**
@ -118,7 +144,10 @@ export class CacheManager {
} }
} }
logger.music('info', `Cleared ${totalDeleted} cache entries for album "${album}" by "${artist}"`) logger.music(
'info',
`Cleared ${totalDeleted} cache entries for album "${album}" by "${artist}"`
)
return totalDeleted return totalDeleted
} }
@ -152,14 +181,21 @@ export class CacheManager {
export const cache = { export const cache = {
lastfm: { lastfm: {
getRecent: (username: string) => CacheManager.get('lastfm-recent', username), getRecent: (username: string) => CacheManager.get('lastfm-recent', username),
setRecent: (username: string, data: string) => CacheManager.set('lastfm-recent', username, data), setRecent: (username: string, data: string) =>
getAlbum: (artist: string, album: string) => CacheManager.get('lastfm-album', `${artist}:${album}`), CacheManager.set('lastfm-recent', username, data),
setAlbum: (artist: string, album: string, data: string) => CacheManager.set('lastfm-album', `${artist}:${album}`, data) getAlbum: (artist: string, album: string) =>
CacheManager.get('lastfm-album', `${artist}:${album}`),
setAlbum: (artist: string, album: string, data: string) =>
CacheManager.set('lastfm-album', `${artist}:${album}`, data)
}, },
apple: { apple: {
getAlbum: (artist: string, album: string) => CacheManager.get('apple-album', `${artist}:${album}`), getAlbum: (artist: string, album: string) =>
setAlbum: (artist: string, album: string, data: string, ttl?: number) => CacheManager.set('apple-album', `${artist}:${album}`, data, ttl), CacheManager.get('apple-album', `${artist}:${album}`),
isNotFound: (artist: string, album: string) => CacheManager.get('apple-notfound', `${artist}:${album}`), setAlbum: (artist: string, album: string, data: string, ttl?: number) =>
markNotFound: (artist: string, album: string, ttl?: number) => CacheManager.set('apple-notfound', `${artist}:${album}`, '1', ttl) CacheManager.set('apple-album', `${artist}:${album}`, data, ttl),
isNotFound: (artist: string, album: string) =>
CacheManager.get('apple-notfound', `${artist}:${album}`),
markNotFound: (artist: string, album: string, ttl?: number) =>
CacheManager.set('apple-notfound', `${artist}:${album}`, '1', ttl)
} }
} }

View file

@ -167,17 +167,17 @@ export async function uploadFile(
const isVideo = file.type.startsWith('video/') const isVideo = file.type.startsWith('video/')
const thumbnailUrl = isVideo const thumbnailUrl = isVideo
? cloudinary.url(result.public_id + '.jpg', { ? cloudinary.url(result.public_id + '.jpg', {
resource_type: 'video', resource_type: 'video',
transformation: [ transformation: [
{ width: 1920, crop: 'scale', quality: 'auto:good' }, // 'scale' maintains aspect ratio { width: 1920, crop: 'scale', quality: 'auto:good' }, // 'scale' maintains aspect ratio
{ start_offset: 'auto' } // Let Cloudinary pick the most interesting frame { start_offset: 'auto' } // Let Cloudinary pick the most interesting frame
], ],
secure: true secure: true
}) })
: cloudinary.url(result.public_id, { : cloudinary.url(result.public_id, {
...imageSizes.thumbnail, ...imageSizes.thumbnail,
secure: true secure: true
}) })
// Extract dominant color using smart selection // Extract dominant color using smart selection
let dominantColor: string | undefined let dominantColor: string | undefined

View file

@ -95,8 +95,8 @@ export async function uploadFileLocally(
} }
// Extract video metadata // Extract video metadata
const videoStream = metadata.streams.find(s => s.codec_type === 'video') const videoStream = metadata.streams.find((s) => s.codec_type === 'video')
const audioStream = metadata.streams.find(s => s.codec_type === 'audio') const audioStream = metadata.streams.find((s) => s.codec_type === 'audio')
if (videoStream) { if (videoStream) {
width = videoStream.width || 0 width = videoStream.width || 0

View file

@ -50,7 +50,7 @@ function createMusicStream() {
nowPlaying: nowPlayingAlbum nowPlaying: nowPlayingAlbum
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}` ? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
: 'none', : 'none',
albums: albums.map(a => ({ albums: albums.map((a) => ({
name: a.name, name: a.name,
artist: a.artist.name, artist: a.artist.name,
isNowPlaying: a.isNowPlaying, isNowPlaying: a.isNowPlaying,
@ -144,11 +144,13 @@ function createMusicStream() {
albums: derived({ subscribe }, ($state) => $state.albums) as Readable<Album[]>, albums: derived({ subscribe }, ($state) => $state.albums) as Readable<Album[]>,
// Helper to check if any album is playing // Helper to check if any album is playing
nowPlaying: derived({ subscribe }, ($state) => { nowPlaying: derived({ subscribe }, ($state) => {
const playing = $state.albums.find(a => a.isNowPlaying) const playing = $state.albums.find((a) => a.isNowPlaying)
return playing ? { return playing
album: playing, ? {
track: playing.nowPlayingTrack album: playing,
} : null track: playing.nowPlayingTrack
}
: null
}) as Readable<{ album: Album; track?: string } | null> }) as Readable<{ album: Album; track?: string } | null>
} }
} }

View file

@ -9,9 +9,7 @@ export function createProjectFormStore(initialProject?: Project | null) {
let original = $state<ProjectFormData | null>(null) let original = $state<ProjectFormData | null>(null)
// Derived state using $derived rune // Derived state using $derived rune
const isDirty = $derived( const isDirty = $derived(original ? JSON.stringify(fields) !== JSON.stringify(original) : false)
original ? JSON.stringify(fields) !== JSON.stringify(original) : false
)
// Initialize from project if provided // Initialize from project if provided
if (initialProject) { if (initialProject) {
@ -96,7 +94,8 @@ export function createProjectFormStore(initialProject?: Project | null) {
role: fields.role, role: fields.role,
projectType: fields.projectType, projectType: fields.projectType,
externalUrl: fields.externalUrl, externalUrl: fields.externalUrl,
featuredImage: fields.featuredImage && fields.featuredImage !== '' ? fields.featuredImage : null, featuredImage:
fields.featuredImage && fields.featuredImage !== '' ? fields.featuredImage : null,
logoUrl: fields.logoUrl && fields.logoUrl !== '' ? fields.logoUrl : null, logoUrl: fields.logoUrl && fields.logoUrl !== '' ? fields.logoUrl : null,
backgroundColor: fields.backgroundColor, backgroundColor: fields.backgroundColor,
highlightColor: fields.highlightColor, highlightColor: fields.highlightColor,

View file

@ -63,7 +63,10 @@ export class LastfmStreamManager {
} }
// Check for now playing updates for non-recent albums // Check for now playing updates for non-recent albums
const nowPlayingUpdates = await this.getNowPlayingUpdatesForNonRecentAlbums(enrichedAlbums, freshData) const nowPlayingUpdates = await this.getNowPlayingUpdatesForNonRecentAlbums(
enrichedAlbums,
freshData
)
if (nowPlayingUpdates.length > 0) { if (nowPlayingUpdates.length > 0) {
update.nowPlayingUpdates = nowPlayingUpdates update.nowPlayingUpdates = nowPlayingUpdates
} }

View file

@ -163,13 +163,19 @@ export class NowPlayingDetector {
for (const track of tracks) { for (const track of tracks) {
if (track.nowPlaying) { if (track.nowPlaying) {
hasOfficialNowPlaying = true hasOfficialNowPlaying = true
logger.music('debug', `Last.fm reports "${track.name}" by ${track.artist.name} as now playing`) logger.music(
'debug',
`Last.fm reports "${track.name}" by ${track.artist.name} as now playing`
)
break break
} }
} }
if (!hasOfficialNowPlaying) { if (!hasOfficialNowPlaying) {
logger.music('debug', 'No official now playing from Last.fm, will use duration-based detection') logger.music(
'debug',
'No official now playing from Last.fm, will use duration-based detection'
)
} }
// Process all tracks // Process all tracks
@ -230,8 +236,11 @@ export class NowPlayingDetector {
this.updateRecentTracks(newRecentTracks) this.updateRecentTracks(newRecentTracks)
// Log summary // Log summary
const nowPlayingCount = Array.from(albums.values()).filter(a => a.isNowPlaying).length const nowPlayingCount = Array.from(albums.values()).filter((a) => a.isNowPlaying).length
logger.music('debug', `Detected ${nowPlayingCount} album(s) as now playing out of ${albums.size} recent albums`) logger.music(
'debug',
`Detected ${nowPlayingCount} album(s) as now playing out of ${albums.size} recent albums`
)
// Ensure only one album is marked as now playing // Ensure only one album is marked as now playing
return this.ensureSingleNowPlaying(albums, newRecentTracks) return this.ensureSingleNowPlaying(albums, newRecentTracks)

View file

@ -34,7 +34,10 @@ export class SimpleLastfmStreamManager {
limit: 50, limit: 50,
extended: true extended: true
}) })
logger.music('debug', `📊 Got ${recentTracksResponse.tracks?.length || 0} tracks from Last.fm`) logger.music(
'debug',
`📊 Got ${recentTracksResponse.tracks?.length || 0} tracks from Last.fm`
)
// Cache for other uses but always use fresh for now playing // Cache for other uses but always use fresh for now playing
await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse) await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse)
@ -64,7 +67,7 @@ export class SimpleLastfmStreamManager {
// Check if anything changed // Check if anything changed
const currentState = JSON.stringify( const currentState = JSON.stringify(
enrichedAlbums.map(a => ({ enrichedAlbums.map((a) => ({
key: `${a.artist.name}:${a.name}`, key: `${a.artist.name}:${a.name}`,
isNowPlaying: a.isNowPlaying, isNowPlaying: a.isNowPlaying,
track: a.nowPlayingTrack track: a.nowPlayingTrack

View file

@ -15,7 +15,10 @@ export class SimpleNowPlayingDetector {
const isPlaying = elapsed >= 0 && elapsed <= maxPlayTime const isPlaying = elapsed >= 0 && elapsed <= maxPlayTime
logger.music('debug', `Track playing check: elapsed=${Math.round(elapsed/1000)}s, duration=${Math.round(durationMs/1000)}s, maxPlay=${Math.round(maxPlayTime/1000)}s, isPlaying=${isPlaying}`) logger.music(
'debug',
`Track playing check: elapsed=${Math.round(elapsed / 1000)}s, duration=${Math.round(durationMs / 1000)}s, maxPlay=${Math.round(maxPlayTime / 1000)}s, isPlaying=${isPlaying}`
)
// Track is playing if we're within the duration + buffer // Track is playing if we're within the duration + buffer
return isPlaying return isPlaying
@ -30,16 +33,22 @@ export class SimpleNowPlayingDetector {
recentTracks: any[], recentTracks: any[],
appleMusicDataLookup: (artistName: string, albumName: string) => Promise<any> appleMusicDataLookup: (artistName: string, albumName: string) => Promise<any>
): Promise<Album[]> { ): Promise<Album[]> {
logger.music('debug', `Processing ${albums.length} albums with ${recentTracks.length} recent tracks`) logger.music(
'debug',
`Processing ${albums.length} albums with ${recentTracks.length} recent tracks`
)
// First check if Last.fm reports anything as officially playing // First check if Last.fm reports anything as officially playing
const officialNowPlaying = recentTracks.find(track => track.nowPlaying) const officialNowPlaying = recentTracks.find((track) => track.nowPlaying)
if (officialNowPlaying) { if (officialNowPlaying) {
// Trust Last.fm's official now playing status // Trust Last.fm's official now playing status
logger.music('debug', `✅ Last.fm official now playing: "${officialNowPlaying.name}" by ${officialNowPlaying.artist.name}`) logger.music(
'debug',
`✅ Last.fm official now playing: "${officialNowPlaying.name}" by ${officialNowPlaying.artist.name}`
)
return albums.map(album => ({ return albums.map((album) => ({
...album, ...album,
isNowPlaying: isNowPlaying:
album.name === officialNowPlaying.album.name && album.name === officialNowPlaying.album.name &&
@ -74,14 +83,17 @@ export class SimpleNowPlayingDetector {
if (!mostRecentTrack) { if (!mostRecentTrack) {
// No recent tracks, nothing is playing // No recent tracks, nothing is playing
logger.music('debug', '❌ No recent tracks found, nothing is playing') logger.music('debug', '❌ No recent tracks found, nothing is playing')
return albums.map(album => ({ return albums.map((album) => ({
...album, ...album,
isNowPlaying: false, isNowPlaying: false,
nowPlayingTrack: undefined nowPlayingTrack: undefined
})) }))
} }
logger.music('debug', `Most recent track: "${mostRecentTrack.name}" by ${mostRecentTrack.artist.name} from ${mostRecentTrack.album.name}`) logger.music(
'debug',
`Most recent track: "${mostRecentTrack.name}" by ${mostRecentTrack.artist.name} from ${mostRecentTrack.album.name}`
)
logger.music('debug', `Scrobbled at: ${mostRecentTrack.date}`) logger.music('debug', `Scrobbled at: ${mostRecentTrack.date}`)
// Check if the most recent track is still playing // Check if the most recent track is still playing
@ -112,28 +124,39 @@ export class SimpleNowPlayingDetector {
logger.music('debug', `⚠️ No duration found for track "${mostRecentTrack.name}"`) logger.music('debug', `⚠️ No duration found for track "${mostRecentTrack.name}"`)
// Fallback: assume track is playing if scrobbled within last 5 minutes // Fallback: assume track is playing if scrobbled within last 5 minutes
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime() const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes if (timeSinceScrobble < 5 * 60 * 1000) {
// 5 minutes
isPlaying = true isPlaying = true
playingTrack = mostRecentTrack.name playingTrack = mostRecentTrack.name
logger.music('debug', `⏰ Using time-based fallback: track scrobbled ${Math.round(timeSinceScrobble/1000)}s ago, assuming still playing`) logger.music(
'debug',
`⏰ Using time-based fallback: track scrobbled ${Math.round(timeSinceScrobble / 1000)}s ago, assuming still playing`
)
} }
} }
} }
} catch (error) { } catch (error) {
logger.error('Error checking track duration:', error as Error, undefined, 'music') logger.error('Error checking track duration:', error as Error, undefined, 'music')
logger.music('debug', `❌ Failed to get Apple Music data for ${mostRecentTrack.artist.name} - ${mostRecentTrack.album.name}`) logger.music(
'debug',
`❌ Failed to get Apple Music data for ${mostRecentTrack.artist.name} - ${mostRecentTrack.album.name}`
)
// Fallback when Apple Music lookup fails // Fallback when Apple Music lookup fails
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime() const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes if (timeSinceScrobble < 5 * 60 * 1000) {
// 5 minutes
isPlaying = true isPlaying = true
playingTrack = mostRecentTrack.name playingTrack = mostRecentTrack.name
logger.music('debug', `⏰ Using time-based fallback after Apple Music error: track scrobbled ${Math.round(timeSinceScrobble/1000)}s ago`) logger.music(
'debug',
`⏰ Using time-based fallback after Apple Music error: track scrobbled ${Math.round(timeSinceScrobble / 1000)}s ago`
)
} }
} }
// Update albums with the result // Update albums with the result
return albums.map(album => { return albums.map((album) => {
const key = `${album.artist.name}:${album.name}` const key = `${album.artist.name}:${album.name}`
const isThisAlbumPlaying = isPlaying && key === albumKey const isThisAlbumPlaying = isPlaying && key === albumKey
return { return {

View file

@ -1,5 +1,5 @@
<script> <script>
import { page } from '$app/stores'; import { page } from '$app/stores'
</script> </script>
<div class="error-container"> <div class="error-container">

View file

@ -1,6 +1,10 @@
import { fail, redirect } from '@sveltejs/kit' import { fail, redirect } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types' import type { Actions, PageServerLoad } from './$types'
import { clearSessionCookie, setSessionCookie, validateAdminPassword } from '$lib/server/admin/session' import {
clearSessionCookie,
setSessionCookie,
validateAdminPassword
} from '$lib/server/admin/session'
export const load = (async ({ cookies }) => { export const load = (async ({ cookies }) => {
// Ensure we start with a clean session when hitting the login page // Ensure we start with a clean session when hitting the login page

View file

@ -361,213 +361,207 @@
</AdminHeader> </AdminHeader>
<!-- Filters --> <!-- Filters -->
<AdminFilters> <AdminFilters>
{#snippet left()} {#snippet left()}
<Select <Select
value={filterType} value={filterType}
options={typeFilterOptions} options={typeFilterOptions}
size="small" size="small"
variant="minimal" variant="minimal"
onchange={(e) => handleTypeFilterChange((e.target as HTMLSelectElement).value)} onchange={(e) => handleTypeFilterChange((e.target as HTMLSelectElement).value)}
/> />
<Select <Select
value={publishedFilter} value={publishedFilter}
options={publishedFilterOptions} options={publishedFilterOptions}
size="small" size="small"
variant="minimal" variant="minimal"
onchange={(e) => handlePublishedFilterChange((e.target as HTMLSelectElement).value)} onchange={(e) => handlePublishedFilterChange((e.target as HTMLSelectElement).value)}
/> />
{/snippet} {/snippet}
{#snippet right()} {#snippet right()}
<Select <Select
value={sortBy} value={sortBy}
options={sortOptions} options={sortOptions}
size="small" size="small"
variant="minimal" variant="minimal"
onchange={(e) => handleSortChange((e.target as HTMLSelectElement).value)} onchange={(e) => handleSortChange((e.target as HTMLSelectElement).value)}
/> />
<Input <Input
type="search" type="search"
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Search files..." placeholder="Search files..."
buttonSize="small" buttonSize="small"
fullWidth={false} fullWidth={false}
pill={true} pill={true}
prefixIcon prefixIcon
>
<svg
slot="prefix"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
> >
<svg <path
slot="prefix" stroke-linecap="round"
xmlns="http://www.w3.org/2000/svg" stroke-linejoin="round"
fill="none" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
viewBox="0 0 24 24" />
stroke-width="1.5" </svg>
stroke="currentColor" </Input>
> {/snippet}
<path </AdminFilters>
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
</Input>
{/snippet}
</AdminFilters>
{#if isMultiSelectMode && media.length > 0} {#if isMultiSelectMode && media.length > 0}
<div class="bulk-actions"> <div class="bulk-actions">
<div class="bulk-actions-left"> <div class="bulk-actions-left">
<button <button
onclick={selectAllMedia} onclick={selectAllMedia}
class="btn btn-secondary btn-small" class="btn btn-secondary btn-small"
disabled={selectedMediaIds.size === media.length} disabled={selectedMediaIds.size === media.length}
> >
Select All ({media.length}) Select All ({media.length})
</button> </button>
<button <button
onclick={clearSelection} onclick={clearSelection}
class="btn btn-secondary btn-small" class="btn btn-secondary btn-small"
disabled={selectedMediaIds.size === 0} disabled={selectedMediaIds.size === 0}
> >
Clear Selection Clear Selection
</button> </button>
</div>
<div class="bulk-actions-right">
{#if selectedMediaIds.size > 0}
<button
onclick={handleBulkMarkPhotography}
class="btn btn-secondary btn-small"
title="Mark selected items as photography"
>
Mark Photography
</button>
<button
onclick={handleBulkUnmarkPhotography}
class="btn btn-secondary btn-small"
title="Remove photography status from selected items"
>
Remove Photography
</button>
<button
onclick={() => (showBulkAlbumModal = true)}
class="btn btn-secondary btn-small"
title="Add or remove selected items from albums"
>
Manage Albums
</button>
<button
onclick={handleBulkDelete}
class="btn btn-danger btn-small"
disabled={isDeleting}
>
{isDeleting
? 'Deleting...'
: `Delete ${selectedMediaIds.size} file${selectedMediaIds.size > 1 ? 's' : ''}`}
</button>
{/if}
</div>
</div> </div>
{/if} <div class="bulk-actions-right">
{#if selectedMediaIds.size > 0}
<button
onclick={handleBulkMarkPhotography}
class="btn btn-secondary btn-small"
title="Mark selected items as photography"
>
Mark Photography
</button>
<button
onclick={handleBulkUnmarkPhotography}
class="btn btn-secondary btn-small"
title="Remove photography status from selected items"
>
Remove Photography
</button>
<button
onclick={() => (showBulkAlbumModal = true)}
class="btn btn-secondary btn-small"
title="Add or remove selected items from albums"
>
Manage Albums
</button>
<button onclick={handleBulkDelete} class="btn btn-danger btn-small" disabled={isDeleting}>
{isDeleting
? 'Deleting...'
: `Delete ${selectedMediaIds.size} file${selectedMediaIds.size > 1 ? 's' : ''}`}
</button>
{/if}
</div>
</div>
{/if}
{#if media.length === 0} {#if media.length === 0}
<EmptyState title="No media files found" message="Upload your first file to get started."> <EmptyState title="No media files found" message="Upload your first file to get started.">
{#snippet action()} {#snippet action()}
<Button variant="primary" onclick={openUploadModal}>Upload your first file</Button> <Button variant="primary" onclick={openUploadModal}>Upload your first file</Button>
{/snippet} {/snippet}
</EmptyState> </EmptyState>
{:else} {:else}
<div class="media-grid"> <div class="media-grid">
{#each media as item} {#each media as item}
<div class="media-item-wrapper" class:multiselect={isMultiSelectMode}> <div class="media-item-wrapper" class:multiselect={isMultiSelectMode}>
{#if isMultiSelectMode} {#if isMultiSelectMode}
<div class="selection-checkbox"> <div class="selection-checkbox">
<input <input
type="checkbox" type="checkbox"
checked={selectedMediaIds.has(item.id)} checked={selectedMediaIds.has(item.id)}
onchange={() => toggleMediaSelection(item.id)} onchange={() => toggleMediaSelection(item.id)}
id="media-{item.id}" id="media-{item.id}"
/> />
<label for="media-{item.id}" class="checkbox-label"></label> <label for="media-{item.id}" class="checkbox-label"></label>
</div> </div>
{/if} {/if}
<button <button
class="media-item" class="media-item"
type="button" type="button"
onclick={() => onclick={() =>
isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)} isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}" title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)} class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
> >
{#if item.mimeType.startsWith('image/')} {#if item.mimeType.startsWith('image/')}
<img <img
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url} src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
alt={item.description || item.filename} alt={item.description || item.filename}
/> />
{:else if isVideoFile(item.mimeType)} {:else if isVideoFile(item.mimeType)}
{#if item.thumbnailUrl} {#if item.thumbnailUrl}
<div class="video-thumbnail-wrapper"> <div class="video-thumbnail-wrapper">
<img src={item.thumbnailUrl} alt={item.description || item.filename} /> <img src={item.thumbnailUrl} alt={item.description || item.filename} />
<div class="video-overlay"> <div class="video-overlay">
<PlayIcon class="play-icon" /> <PlayIcon class="play-icon" />
</div>
</div> </div>
{:else} </div>
<div class="file-placeholder video-placeholder">
<PlayIcon class="video-icon" />
<span class="file-type">Video</span>
</div>
{/if}
{:else} {:else}
<div class="file-placeholder"> <div class="file-placeholder video-placeholder">
<span class="file-type">{getFileType(item.mimeType)}</span> <PlayIcon class="video-icon" />
<span class="file-type">Video</span>
</div> </div>
{/if} {/if}
<div class="media-info"> {:else}
<span class="filename">{item.filename}</span> <div class="file-placeholder">
<div class="media-info-bottom"> <span class="file-type">{getFileType(item.mimeType)}</span>
<div class="media-indicators">
{#if item.isPhotography}
<span class="indicator-pill photography" title="Photography"> Photo </span>
{/if}
{#if item.description}
<span class="indicator-pill alt-text" title="Description: {item.description}">
Alt
</span>
{:else}
<span class="indicator-pill no-alt-text" title="No description">
No Alt
</span>
{/if}
</div>
<span class="filesize">{formatFileSize(item.size)}</span>
</div>
</div> </div>
</button> {/if}
</div> <div class="media-info">
{/each} <span class="filename">{item.filename}</span>
</div> <div class="media-info-bottom">
{/if} <div class="media-indicators">
{#if item.isPhotography}
<span class="indicator-pill photography" title="Photography"> Photo </span>
{/if}
{#if item.description}
<span class="indicator-pill alt-text" title="Description: {item.description}">
Alt
</span>
{:else}
<span class="indicator-pill no-alt-text" title="No description"> No Alt </span>
{/if}
</div>
<span class="filesize">{formatFileSize(item.size)}</span>
</div>
</div>
</button>
</div>
{/each}
</div>
{/if}
{#if totalPages > 1} {#if totalPages > 1}
<div class="pagination"> <div class="pagination">
<button <button
onclick={() => handlePageChange(currentPage - 1)} onclick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1} disabled={currentPage === 1}
class="pagination-btn" class="pagination-btn"
> >
Previous Previous
</button> </button>
<span class="pagination-info"> <span class="pagination-info">
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</span> </span>
<button <button
onclick={() => handlePageChange(currentPage + 1)} onclick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
class="pagination-btn" class="pagination-btn"
> >
Next Next
</button> </button>
</div> </div>
{/if} {/if}
</AdminPage> </AdminPage>
<!-- Media Details Modal --> <!-- Media Details Modal -->

View file

@ -49,7 +49,9 @@
let showCleanupModal = $state(false) let showCleanupModal = $state(false)
let cleaningUp = $state(false) let cleaningUp = $state(false)
const allSelected = $derived(auditData && selectedFiles.size >= Math.min(20, auditData.orphanedFiles.length)) const allSelected = $derived(
auditData && selectedFiles.size >= Math.min(20, auditData.orphanedFiles.length)
)
const hasSelection = $derived(selectedFiles.size > 0) const hasSelection = $derived(selectedFiles.size > 0)
const selectedSize = $derived( const selectedSize = $derived(
auditData?.orphanedFiles auditData?.orphanedFiles

View file

@ -37,8 +37,8 @@
function addFiles(newFiles: File[]) { function addFiles(newFiles: File[]) {
// Filter for supported file types (images and videos) // Filter for supported file types (images and videos)
const supportedFiles = newFiles.filter((file) => const supportedFiles = newFiles.filter(
file.type.startsWith('image/') || file.type.startsWith('video/') (file) => file.type.startsWith('image/') || file.type.startsWith('video/')
) )
if (supportedFiles.length !== newFiles.length) { if (supportedFiles.length !== newFiles.length) {
@ -305,7 +305,9 @@
</div> </div>
<h3>Drop media files here</h3> <h3>Drop media files here</h3>
<p>or click to browse and select files</p> <p>or click to browse and select files</p>
<p class="upload-hint">Images: JPG, PNG, GIF, WebP, SVG | Videos: WebM, MP4, OGG, MOV, AVI</p> <p class="upload-hint">
Images: JPG, PNG, GIF, WebP, SVG | Videos: WebM, MP4, OGG, MOV, AVI
</p>
{:else} {:else}
<div class="compact-content"> <div class="compact-content">
<svg <svg

View file

@ -14,31 +14,31 @@
import type { PageData } from './$types' import type { PageData } from './$types'
import type { AdminPost } from '$lib/types/admin' import type { AdminPost } from '$lib/types/admin'
const { data, form } = $props<{ data: PageData; form?: { message?: string } }>() const { data, form } = $props<{ data: PageData; form?: { message?: string } }>()
let showInlineComposer = true let showInlineComposer = true
let showDeleteConfirmation = false let showDeleteConfirmation = false
let postToDelete: AdminPost | null = null let postToDelete: AdminPost | null = null
const actionError = form?.message ?? '' const actionError = form?.message ?? ''
const posts = data.items ?? [] const posts = data.items ?? []
// Create reactive filters // Create reactive filters
const filters = createListFilters(posts, { const filters = createListFilters(posts, {
filters: { filters: {
type: { field: 'postType', default: 'all' }, type: { field: 'postType', default: 'all' },
status: { field: 'status', default: 'all' } status: { field: 'status', default: 'all' }
}, },
sorts: { sorts: {
newest: commonSorts.dateDesc<AdminPost>('createdAt'), newest: commonSorts.dateDesc<AdminPost>('createdAt'),
oldest: commonSorts.dateAsc<AdminPost>('createdAt'), oldest: commonSorts.dateAsc<AdminPost>('createdAt'),
'title-asc': commonSorts.stringAsc<AdminPost>('title'), 'title-asc': commonSorts.stringAsc<AdminPost>('title'),
'title-desc': commonSorts.stringDesc<AdminPost>('title'), 'title-desc': commonSorts.stringDesc<AdminPost>('title'),
'status-published': commonSorts.statusPublishedFirst<AdminPost>('status'), 'status-published': commonSorts.statusPublishedFirst<AdminPost>('status'),
'status-draft': commonSorts.statusDraftFirst<AdminPost>('status') 'status-draft': commonSorts.statusDraftFirst<AdminPost>('status')
}, },
defaultSort: 'newest' defaultSort: 'newest'
}) })
let toggleForm: HTMLFormElement | null = null let toggleForm: HTMLFormElement | null = null
let toggleIdField: HTMLInputElement | null = null let toggleIdField: HTMLInputElement | null = null
@ -48,17 +48,17 @@ const filters = createListFilters(posts, {
let deleteForm: HTMLFormElement | null = null let deleteForm: HTMLFormElement | null = null
let deleteIdField: HTMLInputElement | null = null let deleteIdField: HTMLInputElement | null = null
const typeFilterOptions = [ const typeFilterOptions = [
{ value: 'all', label: 'All posts' }, { value: 'all', label: 'All posts' },
{ value: 'post', label: 'Posts' }, { value: 'post', label: 'Posts' },
{ value: 'essay', label: 'Essays' } { value: 'essay', label: 'Essays' }
] ]
const statusFilterOptions = [ const statusFilterOptions = [
{ value: 'all', label: 'All statuses' }, { value: 'all', label: 'All statuses' },
{ value: 'published', label: 'Published' }, { value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' } { value: 'draft', label: 'Draft' }
] ]
const sortOptions = [ const sortOptions = [
{ value: 'newest', label: 'Newest first' }, { value: 'newest', label: 'Newest first' },
@ -120,9 +120,7 @@ const statusFilterOptions = [
<AdminPage> <AdminPage>
<AdminHeader title="Universe" slot="header"> <AdminHeader title="Universe" slot="header">
{#snippet actions()} {#snippet actions()}
<Button variant="primary" buttonSize="medium" onclick={handleNewEssay}> <Button variant="primary" buttonSize="medium" onclick={handleNewEssay}>New Essay</Button>
New Essay
</Button>
{/snippet} {/snippet}
</AdminHeader> </AdminHeader>

View file

@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto, beforeNavigate } from '$app/navigation' import { goto, beforeNavigate } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { api } from '$lib/admin/api' import { api } from '$lib/admin/api'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Composer from '$lib/components/admin/composer' import Composer from '$lib/components/admin/composer'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte' import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
@ -32,14 +32,16 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
let tagInput = $state('') let tagInput = $state('')
let showMetadata = $state(false) let showMetadata = $state(false)
let metadataButtonRef: HTMLButtonElement let metadataButtonRef: HTMLButtonElement
let showDeleteConfirmation = $state(false) let showDeleteConfirmation = $state(false)
// Draft backup // Draft backup
const draftKey = $derived(makeDraftKey('post', $page.params.id)) const draftKey = $derived(makeDraftKey('post', $page.params.id))
let showDraftPrompt = $state(false) let showDraftPrompt = $state(false)
let draftTimestamp = $state<number | null>(null) let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0) let timeTicker = $state(0)
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null)) const draftTimeText = $derived.by(() =>
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
)
const postTypeConfig = { const postTypeConfig = {
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true }, post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
@ -183,16 +185,16 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
} }
} }
onMount(async () => { onMount(async () => {
// Wait a tick to ensure page params are loaded // Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
await loadPost() await loadPost()
const draft = loadDraft<any>(draftKey) const draft = loadDraft<any>(draftKey)
if (draft) { if (draft) {
showDraftPrompt = true showDraftPrompt = true
draftTimestamp = draft.ts draftTimestamp = draft.ts
} }
}) })
async function loadPost() { async function loadPost() {
const postId = $page.params.id const postId = $page.params.id
@ -243,7 +245,7 @@ onMount(async () => {
hasLoaded = true hasLoaded = true
} else { } else {
// Fallback error messaging // Fallback error messaging
loadError = 'Post not found' loadError = 'Post not found'
} }
} catch (error) { } catch (error) {
loadError = 'Network error occurred while loading post' loadError = 'Network error occurred while loading post'
@ -353,7 +355,13 @@ onMount(async () => {
// Trigger autosave when form data changes // Trigger autosave when form data changes
$effect(() => { $effect(() => {
// Establish dependencies // Establish dependencies
title; slug; status; content; tags; excerpt; postType title
slug
status
content
tags
excerpt
postType
if (hasLoaded) { if (hasLoaded) {
autoSave.schedule() autoSave.schedule()
} }
@ -433,13 +441,13 @@ onMount(async () => {
return () => autoSave.destroy() return () => autoSave.destroy()
}) })
// Auto-update draft time text every minute when prompt visible // Auto-update draft time text every minute when prompt visible
$effect(() => { $effect(() => {
if (showDraftPrompt) { if (showDraftPrompt) {
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000) const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
return () => clearInterval(id) return () => clearInterval(id)
} }
}) })
</script> </script>
<svelte:head> <svelte:head>
@ -521,7 +529,8 @@ $effect(() => {
<div class="draft-banner"> <div class="draft-banner">
<div class="draft-banner-content"> <div class="draft-banner-content">
<span class="draft-banner-text"> <span class="draft-banner-text">
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}. Unsaved draft found{#if draftTimeText}
(saved {draftTimeText}){/if}.
</span> </span>
<div class="draft-banner-actions"> <div class="draft-banner-actions">
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button> <button class="draft-banner-button" onclick={restoreDraft}>Restore</button>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { api } from '$lib/admin/api' import { api } from '$lib/admin/api'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Composer from '$lib/components/admin/composer' import Composer from '$lib/components/admin/composer'

View file

@ -114,11 +114,7 @@
<AdminPage> <AdminPage>
<AdminHeader title="Work" slot="header"> <AdminHeader title="Work" slot="header">
{#snippet actions()} {#snippet actions()}
<Button <Button variant="primary" buttonSize="medium" onclick={() => goto('/admin/projects/new')}>
variant="primary"
buttonSize="medium"
onclick={() => goto('/admin/projects/new')}
>
New project New project
</Button> </Button>
{/snippet} {/snippet}
@ -126,20 +122,20 @@
<AdminFilters> <AdminFilters>
{#snippet left()} {#snippet left()}
<Select <Select
value={filters.values.type} value={filters.values.type}
options={typeFilterOptions} options={typeFilterOptions}
size="small" size="small"
variant="minimal" variant="minimal"
onchange={(e) => filters.set('type', (e.target as HTMLSelectElement).value)} onchange={(e) => filters.set('type', (e.target as HTMLSelectElement).value)}
/> />
<Select <Select
value={filters.values.status} value={filters.values.status}
options={statusFilterOptions} options={statusFilterOptions}
size="small" size="small"
variant="minimal" variant="minimal"
onchange={(e) => filters.set('status', (e.target as HTMLSelectElement).value)} onchange={(e) => filters.set('status', (e.target as HTMLSelectElement).value)}
/> />
{/snippet} {/snippet}
{#snippet right()} {#snippet right()}
<Select <Select

View file

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { page } from '$app/stores' import { page } from '$app/stores'
import ProjectForm from '$lib/components/admin/ProjectForm.svelte' import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
import type { Project } from '$lib/types/project' import type { Project } from '$lib/types/project'
import { api } from '$lib/admin/api' import { api } from '$lib/admin/api'
let project = $state<Project | null>(null) let project = $state<Project | null>(null)
let isLoading = $state(true) let isLoading = $state(true)

View file

@ -23,11 +23,14 @@ export const POST: RequestHandler = async ({ request }) => {
}) })
} catch (error) { } catch (error) {
console.error('Apple Music search error:', error) console.error('Apple Music search error:', error)
return new Response(JSON.stringify({ return new Response(
error: error instanceof Error ? error.message : 'Unknown error' JSON.stringify({
}), { error: error instanceof Error ? error.message : 'Unknown error'
status: 500, }),
headers: { 'Content-Type': 'application/json' } {
}) status: 500,
headers: { 'Content-Type': 'application/json' }
}
)
} }
} }

View file

@ -36,13 +36,16 @@ export const POST: RequestHandler = async ({ request }) => {
deleted = await redis.del(key) deleted = await redis.del(key)
} }
return new Response(JSON.stringify({ return new Response(
success: true, JSON.stringify({
deleted, success: true,
key: key || pattern deleted,
}), { key: key || pattern
headers: { 'Content-Type': 'application/json' } }),
}) {
headers: { 'Content-Type': 'application/json' }
}
)
} catch (error) { } catch (error) {
logger.error('Failed to clear cache:', error as Error) logger.error('Failed to clear cache:', error as Error)
return new Response('Internal server error', { status: 500 }) return new Response('Internal server error', { status: 500 })

View file

@ -27,13 +27,16 @@ export const GET: RequestHandler = async ({ url }) => {
}) })
) )
return new Response(JSON.stringify({ return new Response(
total: keys.length, JSON.stringify({
showing: keysWithValues.length, total: keys.length,
keys: keysWithValues showing: keysWithValues.length,
}), { keys: keysWithValues
headers: { 'Content-Type': 'application/json' } }),
}) {
headers: { 'Content-Type': 'application/json' }
}
)
} catch (error) { } catch (error) {
console.error('Failed to get Redis keys:', error) console.error('Failed to get Redis keys:', error)
return new Response('Internal server error', { status: 500 }) return new Response('Internal server error', { status: 500 })

View file

@ -14,20 +14,26 @@ export const GET: RequestHandler = async ({ url }) => {
try { try {
const result = await findAlbum(artist, album) const result = await findAlbum(artist, album)
return new Response(JSON.stringify({ return new Response(
artist, JSON.stringify({
album, artist,
found: !!result, album,
result found: !!result,
}), { result
headers: { 'Content-Type': 'application/json' } }),
}) {
headers: { 'Content-Type': 'application/json' }
}
)
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ return new Response(
error: error instanceof Error ? error.message : 'Unknown error' JSON.stringify({
}), { error: error instanceof Error ? error.message : 'Unknown error'
status: 500, }),
headers: { 'Content-Type': 'application/json' } {
}) status: 500,
headers: { 'Content-Type': 'application/json' }
}
)
} }
} }

View file

@ -20,36 +20,45 @@ export const GET: RequestHandler = async () => {
const jpSongs = jpResults.results?.songs?.data || [] const jpSongs = jpResults.results?.songs?.data || []
const usSongs = usResults.results?.songs?.data || [] const usSongs = usResults.results?.songs?.data || []
const hachiko = [...jpSongs, ...usSongs].find(s => const hachiko = [...jpSongs, ...usSongs].find(
s.attributes?.name?.toLowerCase() === 'hachikō' && (s) =>
s.attributes?.artistName?.includes('藤井') s.attributes?.name?.toLowerCase() === 'hachikō' &&
s.attributes?.artistName?.includes('藤井')
) )
return new Response(JSON.stringify({ return new Response(
searchQuery, JSON.stringify({
jpSongsFound: jpSongs.length, searchQuery,
usSongsFound: usSongs.length, jpSongsFound: jpSongs.length,
hachikoFound: !!hachiko, usSongsFound: usSongs.length,
hachikoDetails: hachiko ? { hachikoFound: !!hachiko,
name: hachiko.attributes?.name, hachikoDetails: hachiko
artist: hachiko.attributes?.artistName, ? {
album: hachiko.attributes?.albumName, name: hachiko.attributes?.name,
preview: hachiko.attributes?.previews?.[0]?.url artist: hachiko.attributes?.artistName,
} : null, album: hachiko.attributes?.albumName,
allSongs: [...jpSongs, ...usSongs].map(s => ({ preview: hachiko.attributes?.previews?.[0]?.url
name: s.attributes?.name, }
artist: s.attributes?.artistName, : null,
album: s.attributes?.albumName allSongs: [...jpSongs, ...usSongs].map((s) => ({
})) name: s.attributes?.name,
}), { artist: s.attributes?.artistName,
headers: { 'Content-Type': 'application/json' } album: s.attributes?.albumName
}) }))
}),
{
headers: { 'Content-Type': 'application/json' }
}
)
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ return new Response(
error: error instanceof Error ? error.message : 'Unknown error' JSON.stringify({
}), { error: error instanceof Error ? error.message : 'Unknown error'
status: 500, }),
headers: { 'Content-Type': 'application/json' } {
}) status: 500,
headers: { 'Content-Type': 'application/json' }
}
)
} }
} }

View file

@ -265,7 +265,12 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
searchMetadata, searchMetadata,
error: true error: true
} }
await redis.set(`apple:album:${album.artist.name}:${album.name}`, JSON.stringify(errorData), 'EX', 1800) await redis.set(
`apple:album:${album.artist.name}:${album.name}`,
JSON.stringify(errorData),
'EX',
1800
)
} }
// Return album with search metadata if Apple Music search fails // Return album with search metadata if Apple Music search fails

View file

@ -53,7 +53,7 @@ export const GET: RequestHandler = async ({ request }) => {
let remainingMs = 0 let remainingMs = 0
if (nowPlayingAlbum?.nowPlayingTrack && nowPlayingAlbum.appleMusicData?.tracks) { if (nowPlayingAlbum?.nowPlayingTrack && nowPlayingAlbum.appleMusicData?.tracks) {
const track = nowPlayingAlbum.appleMusicData.tracks.find( const track = nowPlayingAlbum.appleMusicData.tracks.find(
t => t.name === nowPlayingAlbum.nowPlayingTrack (t) => t.name === nowPlayingAlbum.nowPlayingTrack
) )
if (track?.durationMs && nowPlayingAlbum.lastScrobbleTime) { if (track?.durationMs && nowPlayingAlbum.lastScrobbleTime) {
@ -68,7 +68,7 @@ export const GET: RequestHandler = async ({ request }) => {
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}` ? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
: 'none', : 'none',
remainingMs: remainingMs, remainingMs: remainingMs,
albumsWithStatus: update.albums.map(a => ({ albumsWithStatus: update.albums.map((a) => ({
name: a.name, name: a.name,
artist: a.artist.name, artist: a.artist.name,
isNowPlaying: a.isNowPlaying, isNowPlaying: a.isNowPlaying,
@ -100,7 +100,10 @@ export const GET: RequestHandler = async ({ request }) => {
// Apply new interval if it changed significantly (more than 1 second difference) // Apply new interval if it changed significantly (more than 1 second difference)
if (Math.abs(targetInterval - currentInterval) > 1000) { if (Math.abs(targetInterval - currentInterval) > 1000) {
currentInterval = targetInterval currentInterval = targetInterval
logger.music('debug', `Adjusting interval to ${currentInterval}ms (playing: ${isPlaying}, remaining: ${Math.round(remainingMs/1000)}s)`) logger.music(
'debug',
`Adjusting interval to ${currentInterval}ms (playing: ${isPlaying}, remaining: ${Math.round(remainingMs / 1000)}s)`
)
// Reset interval with new timing // Reset interval with new timing
if (intervalId) { if (intervalId) {

View file

@ -139,7 +139,10 @@ export const POST: RequestHandler = async (event) => {
const allowedTypes = [...allowedImageTypes, ...allowedVideoTypes] const allowedTypes = [...allowedImageTypes, ...allowedVideoTypes]
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
return errorResponse('Invalid file type. Allowed types: Images (JPEG, PNG, WebP, GIF, SVG) and Videos (WebM, MP4, OGG, MOV, AVI)', 400) return errorResponse(
'Invalid file type. Allowed types: Images (JPEG, PNG, WebP, GIF, SVG) and Videos (WebM, MP4, OGG, MOV, AVI)',
400
)
} }
// Validate file size - different limits for images and videos // Validate file size - different limits for images and videos

View file

@ -153,56 +153,57 @@ export const PUT: RequestHandler = async (event) => {
// PATCH /api/posts/[id] - Partially update a post // PATCH /api/posts/[id] - Partially update a post
export const PATCH: RequestHandler = async (event) => { export const PATCH: RequestHandler = async (event) => {
if (!checkAdminAuth(event)) { if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401) return errorResponse('Unauthorized', 401)
} }
try { try {
const id = parseInt(event.params.id) const id = parseInt(event.params.id)
if (isNaN(id)) { if (isNaN(id)) {
return errorResponse('Invalid post ID', 400) return errorResponse('Invalid post ID', 400)
} }
const data = await event.request.json() const data = await event.request.json()
// Check for existence and concurrency // Check for existence and concurrency
const existing = await prisma.post.findUnique({ where: { id } }) const existing = await prisma.post.findUnique({ where: { id } })
if (!existing) return errorResponse('Post not found', 404) if (!existing) return errorResponse('Post not found', 404)
if (data.updatedAt) { if (data.updatedAt) {
const incoming = new Date(data.updatedAt) const incoming = new Date(data.updatedAt)
if (existing.updatedAt.getTime() !== incoming.getTime()) { if (existing.updatedAt.getTime() !== incoming.getTime()) {
return errorResponse('Conflict: post has changed', 409) return errorResponse('Conflict: post has changed', 409)
} }
} }
const updateData: any = {} const updateData: any = {}
if (data.status !== undefined) { if (data.status !== undefined) {
updateData.status = data.status updateData.status = data.status
if (data.status === 'published' && !existing.publishedAt) { if (data.status === 'published' && !existing.publishedAt) {
updateData.publishedAt = new Date() updateData.publishedAt = new Date()
} else if (data.status === 'draft') { } else if (data.status === 'draft') {
updateData.publishedAt = null updateData.publishedAt = null
} }
} }
if (data.title !== undefined) updateData.title = data.title if (data.title !== undefined) updateData.title = data.title
if (data.slug !== undefined) updateData.slug = data.slug if (data.slug !== undefined) updateData.slug = data.slug
if (data.type !== undefined) updateData.postType = data.type if (data.type !== undefined) updateData.postType = data.type
if (data.content !== undefined) updateData.content = data.content if (data.content !== undefined) updateData.content = data.content
if (data.featuredImage !== undefined) updateData.featuredImage = data.featuredImage if (data.featuredImage !== undefined) updateData.featuredImage = data.featuredImage
if (data.attachedPhotos !== undefined) if (data.attachedPhotos !== undefined)
updateData.attachments = data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null updateData.attachments =
if (data.tags !== undefined) updateData.tags = data.tags data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null
if (data.publishedAt !== undefined) updateData.publishedAt = data.publishedAt if (data.tags !== undefined) updateData.tags = data.tags
if (data.publishedAt !== undefined) updateData.publishedAt = data.publishedAt
const post = await prisma.post.update({ where: { id }, data: updateData }) const post = await prisma.post.update({ where: { id }, data: updateData })
logger.info('Post partially updated', { id: post.id, fields: Object.keys(updateData) }) logger.info('Post partially updated', { id: post.id, fields: Object.keys(updateData) })
return jsonResponse(post) return jsonResponse(post)
} catch (error) { } catch (error) {
logger.error('Failed to partially update post', error as Error) logger.error('Failed to partially update post', error as Error)
return errorResponse('Failed to update post', 500) return errorResponse('Failed to update post', 500)
} }
} }
// DELETE /api/posts/[id] - Delete a post // DELETE /api/posts/[id] - Delete a post

View file

@ -320,7 +320,10 @@
if (isMobile) { if (isMobile) {
const viewport = document.querySelector('meta[name="viewport"]') const viewport = document.querySelector('meta[name="viewport"]')
if (viewport) { if (viewport) {
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes') viewport.setAttribute(
'content',
'width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes'
)
} }
} }

View file

@ -89,7 +89,8 @@ export const GET: RequestHandler = async (event) => {
section: 'universe', section: 'universe',
id: post.id.toString(), id: post.id.toString(),
title: title:
post.title || new Date(post.publishedAt || post.createdAt).toLocaleDateString('en-US', { post.title ||
new Date(post.publishedAt || post.createdAt).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
@ -177,13 +178,13 @@ ${
item.type === 'album' && item.coverPhoto item.type === 'album' && item.coverPhoto
? ` ? `
<enclosure url="${item.coverPhoto.url.startsWith('http') ? item.coverPhoto.url : event.url.origin + item.coverPhoto.url}" type="image/jpeg" length="${item.coverPhoto.size || 0}"/> <enclosure url="${item.coverPhoto.url.startsWith('http') ? item.coverPhoto.url : event.url.origin + item.coverPhoto.url}" type="image/jpeg" length="${item.coverPhoto.size || 0}"/>
<media:thumbnail url="${(item.coverPhoto.thumbnailUrl || item.coverPhoto.url).startsWith('http') ? (item.coverPhoto.thumbnailUrl || item.coverPhoto.url) : event.url.origin + (item.coverPhoto.thumbnailUrl || item.coverPhoto.url)}"/> <media:thumbnail url="${(item.coverPhoto.thumbnailUrl || item.coverPhoto.url).startsWith('http') ? item.coverPhoto.thumbnailUrl || item.coverPhoto.url : event.url.origin + (item.coverPhoto.thumbnailUrl || item.coverPhoto.url)}"/>
<media:content url="${item.coverPhoto.url.startsWith('http') ? item.coverPhoto.url : event.url.origin + item.coverPhoto.url}" type="image/jpeg"/>` <media:content url="${item.coverPhoto.url.startsWith('http') ? item.coverPhoto.url : event.url.origin + item.coverPhoto.url}" type="image/jpeg"/>`
: item.type === 'post' && item.featuredImage : item.type === 'post' && item.featuredImage
? ` ? `
<enclosure url="${item.featuredImage.startsWith('http') ? item.featuredImage : event.url.origin + item.featuredImage}" type="image/jpeg" length="0"/> <enclosure url="${item.featuredImage.startsWith('http') ? item.featuredImage : event.url.origin + item.featuredImage}" type="image/jpeg" length="0"/>
<media:content url="${item.featuredImage.startsWith('http') ? item.featuredImage : event.url.origin + item.featuredImage}" type="image/jpeg"/>` <media:content url="${item.featuredImage.startsWith('http') ? item.featuredImage : event.url.origin + item.featuredImage}" type="image/jpeg"/>`
: '' : ''
} }
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''} ${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
<author>noreply@jedmund.com (Justin Edmund)</author> <author>noreply@jedmund.com (Justin Edmund)</author>
@ -215,9 +216,9 @@ ${item.location ? `<category domain="location">${escapeXML(item.location)}</cate
'Content-Type': 'application/rss+xml; charset=utf-8', 'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=300, s-maxage=600, stale-while-revalidate=86400', 'Cache-Control': 'public, max-age=300, s-maxage=600, stale-while-revalidate=86400',
'Last-Modified': lastBuildDate, 'Last-Modified': lastBuildDate,
'ETag': etag, ETag: etag,
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'Vary': 'Accept-Encoding', Vary: 'Accept-Encoding',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Max-Age': '86400' 'Access-Control-Max-Age': '86400'