chore: run prettier on all src/ files to fix formatting
Co-Authored-By: Justin Edmund <justin@jedmund.com>
This commit is contained in:
parent
d60eba6e90
commit
8cc5cedc9d
65 changed files with 1917 additions and 1681 deletions
|
|
@ -3,90 +3,99 @@ import { goto } from '$app/navigation'
|
|||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
|
||||
export interface RequestOptions<TBody = unknown> {
|
||||
method?: HttpMethod
|
||||
body?: TBody
|
||||
signal?: AbortSignal
|
||||
headers?: Record<string, string>
|
||||
method?: HttpMethod
|
||||
body?: TBody
|
||||
signal?: AbortSignal
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ApiError extends Error {
|
||||
status: number
|
||||
details?: unknown
|
||||
status: number
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
function getAuthHeader() {
|
||||
return {}
|
||||
return {}
|
||||
}
|
||||
|
||||
async function handleResponse(res: Response) {
|
||||
if (res.status === 401) {
|
||||
// Redirect to login for unauthorized requests
|
||||
try {
|
||||
goto('/admin/login')
|
||||
} catch {}
|
||||
}
|
||||
if (res.status === 401) {
|
||||
// Redirect to login for unauthorized requests
|
||||
try {
|
||||
goto('/admin/login')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
const isJson = contentType.includes('application/json')
|
||||
const data = isJson ? await res.json().catch(() => undefined) : undefined
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
const isJson = contentType.includes('application/json')
|
||||
const data = isJson ? await res.json().catch(() => undefined) : undefined
|
||||
|
||||
if (!res.ok) {
|
||||
const err: ApiError = Object.assign(new Error('Request failed'), {
|
||||
status: res.status,
|
||||
details: data
|
||||
})
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
if (!res.ok) {
|
||||
const err: ApiError = Object.assign(new Error('Request failed'), {
|
||||
status: res.status,
|
||||
details: data
|
||||
})
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function request<TResponse = unknown, TBody = unknown>(
|
||||
url: string,
|
||||
opts: RequestOptions<TBody> = {}
|
||||
url: string,
|
||||
opts: RequestOptions<TBody> = {}
|
||||
): 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 mergedHeaders: Record<string, string> = {
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...getAuthHeader(),
|
||||
...(headers || {})
|
||||
}
|
||||
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData
|
||||
const mergedHeaders: Record<string, string> = {
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...getAuthHeader(),
|
||||
...(headers || {})
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: mergedHeaders,
|
||||
body: body ? (isFormData ? (body as any) : JSON.stringify(body)) : undefined,
|
||||
signal,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: mergedHeaders,
|
||||
body: body ? (isFormData ? (body as any) : JSON.stringify(body)) : undefined,
|
||||
signal,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
|
||||
return handleResponse(res) as Promise<TResponse>
|
||||
return handleResponse(res) as Promise<TResponse>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(url: string, opts: Omit<RequestOptions, 'method' | 'body'> = {}) =>
|
||||
request<T>(url, { ...opts, method: 'GET' }),
|
||||
post: <T = unknown, B = unknown>(url: string, body: B, opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}) =>
|
||||
request<T, B>(url, { ...opts, method: 'POST', body }),
|
||||
put: <T = unknown, B = unknown>(url: string, 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' })
|
||||
get: <T = unknown>(url: string, opts: Omit<RequestOptions, 'method' | 'body'> = {}) =>
|
||||
request<T>(url, { ...opts, method: 'GET' }),
|
||||
post: <T = unknown, B = unknown>(
|
||||
url: string,
|
||||
body: B,
|
||||
opts: Omit<RequestOptions<B>, 'method' | 'body'> = {}
|
||||
) => request<T, B>(url, { ...opts, method: 'POST', body }),
|
||||
put: <T = unknown, B = unknown>(
|
||||
url: string,
|
||||
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() {
|
||||
let controller: AbortController | null = null
|
||||
return {
|
||||
nextSignal() {
|
||||
if (controller) controller.abort()
|
||||
controller = new AbortController()
|
||||
return controller.signal
|
||||
},
|
||||
abort() {
|
||||
if (controller) controller.abort()
|
||||
}
|
||||
}
|
||||
let controller: AbortController | null = null
|
||||
return {
|
||||
nextSignal() {
|
||||
if (controller) controller.abort()
|
||||
controller = new AbortController()
|
||||
return controller.signal
|
||||
},
|
||||
abort() {
|
||||
if (controller) controller.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
|
||||
|
||||
export interface AutoSaveStoreOptions<TPayload, TResponse = unknown> {
|
||||
debounceMs?: number
|
||||
idleResetMs?: number
|
||||
getPayload: () => TPayload | null | undefined
|
||||
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
|
||||
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
|
||||
debounceMs?: number
|
||||
idleResetMs?: number
|
||||
getPayload: () => TPayload | null | undefined
|
||||
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
|
||||
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
|
||||
}
|
||||
|
||||
export interface AutoSaveStore<TPayload, TResponse = unknown> {
|
||||
readonly status: AutoSaveStatus
|
||||
readonly lastError: string | null
|
||||
schedule: () => void
|
||||
flush: () => Promise<void>
|
||||
destroy: () => void
|
||||
prime: (payload: TPayload) => void
|
||||
readonly status: AutoSaveStatus
|
||||
readonly lastError: string | null
|
||||
schedule: () => void
|
||||
flush: () => Promise<void>
|
||||
destroy: () => void
|
||||
prime: (payload: TPayload) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -35,109 +35,109 @@ export interface AutoSaveStore<TPayload, TResponse = unknown> {
|
|||
* // Trigger save: autoSave.schedule()
|
||||
*/
|
||||
export function createAutoSaveStore<TPayload, TResponse = unknown>(
|
||||
opts: AutoSaveStoreOptions<TPayload, TResponse>
|
||||
opts: AutoSaveStoreOptions<TPayload, TResponse>
|
||||
): AutoSaveStore<TPayload, TResponse> {
|
||||
const debounceMs = opts.debounceMs ?? 2000
|
||||
const idleResetMs = opts.idleResetMs ?? 2000
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let idleResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
let lastSentHash: string | null = null
|
||||
const debounceMs = opts.debounceMs ?? 2000
|
||||
const idleResetMs = opts.idleResetMs ?? 2000
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let idleResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
let lastSentHash: string | null = null
|
||||
|
||||
let status = $state<AutoSaveStatus>('idle')
|
||||
let lastError = $state<string | null>(null)
|
||||
let status = $state<AutoSaveStatus>('idle')
|
||||
let lastError = $state<string | null>(null)
|
||||
|
||||
function setStatus(next: AutoSaveStatus) {
|
||||
if (idleResetTimer) {
|
||||
clearTimeout(idleResetTimer)
|
||||
idleResetTimer = null
|
||||
}
|
||||
function setStatus(next: AutoSaveStatus) {
|
||||
if (idleResetTimer) {
|
||||
clearTimeout(idleResetTimer)
|
||||
idleResetTimer = null
|
||||
}
|
||||
|
||||
status = next
|
||||
status = next
|
||||
|
||||
// Auto-transition from 'saved' to 'idle' after idleResetMs
|
||||
if (next === 'saved') {
|
||||
idleResetTimer = setTimeout(() => {
|
||||
status = 'idle'
|
||||
idleResetTimer = null
|
||||
}, idleResetMs)
|
||||
}
|
||||
}
|
||||
// Auto-transition from 'saved' to 'idle' after idleResetMs
|
||||
if (next === 'saved') {
|
||||
idleResetTimer = setTimeout(() => {
|
||||
status = 'idle'
|
||||
idleResetTimer = null
|
||||
}, idleResetMs)
|
||||
}
|
||||
}
|
||||
|
||||
function prime(payload: TPayload) {
|
||||
lastSentHash = safeHash(payload)
|
||||
}
|
||||
function prime(payload: TPayload) {
|
||||
lastSentHash = safeHash(payload)
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => void run(), debounceMs)
|
||||
}
|
||||
function schedule() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => void run(), debounceMs)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
async function run() {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
const payload = opts.getPayload()
|
||||
if (!payload) return
|
||||
const payload = opts.getPayload()
|
||||
if (!payload) return
|
||||
|
||||
const hash = safeHash(payload)
|
||||
if (lastSentHash && hash === lastSentHash) return
|
||||
const hash = safeHash(payload)
|
||||
if (lastSentHash && hash === lastSentHash) return
|
||||
|
||||
if (controller) controller.abort()
|
||||
controller = new AbortController()
|
||||
if (controller) controller.abort()
|
||||
controller = new AbortController()
|
||||
|
||||
setStatus('saving')
|
||||
lastError = null
|
||||
try {
|
||||
const res = await opts.save(payload, { signal: controller.signal })
|
||||
lastSentHash = hash
|
||||
setStatus('saved')
|
||||
if (opts.onSaved) opts.onSaved(res, { prime })
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') {
|
||||
// Newer save superseded this one
|
||||
return
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
setStatus('offline')
|
||||
} else {
|
||||
setStatus('error')
|
||||
}
|
||||
lastError = e?.message || 'Auto-save failed'
|
||||
}
|
||||
}
|
||||
setStatus('saving')
|
||||
lastError = null
|
||||
try {
|
||||
const res = await opts.save(payload, { signal: controller.signal })
|
||||
lastSentHash = hash
|
||||
setStatus('saved')
|
||||
if (opts.onSaved) opts.onSaved(res, { prime })
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') {
|
||||
// Newer save superseded this one
|
||||
return
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
setStatus('offline')
|
||||
} else {
|
||||
setStatus('error')
|
||||
}
|
||||
lastError = e?.message || 'Auto-save failed'
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return run()
|
||||
}
|
||||
function flush() {
|
||||
return run()
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (idleResetTimer) clearTimeout(idleResetTimer)
|
||||
if (controller) controller.abort()
|
||||
}
|
||||
function destroy() {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (idleResetTimer) clearTimeout(idleResetTimer)
|
||||
if (controller) controller.abort()
|
||||
}
|
||||
|
||||
return {
|
||||
get status() {
|
||||
return status
|
||||
},
|
||||
get lastError() {
|
||||
return lastError
|
||||
},
|
||||
schedule,
|
||||
flush,
|
||||
destroy,
|
||||
prime
|
||||
}
|
||||
return {
|
||||
get status() {
|
||||
return status
|
||||
},
|
||||
get lastError() {
|
||||
return lastError
|
||||
},
|
||||
schedule,
|
||||
flush,
|
||||
destroy,
|
||||
prime
|
||||
}
|
||||
}
|
||||
|
||||
function safeHash(obj: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(obj)
|
||||
} catch {
|
||||
// Fallback for circular structures; not expected for form payloads
|
||||
return String(obj)
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(obj)
|
||||
} catch {
|
||||
// Fallback for circular structures; not expected for form payloads
|
||||
return String(obj)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,139 +1,139 @@
|
|||
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
|
||||
|
||||
export interface AutoSaveController {
|
||||
status: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
|
||||
lastError: { subscribe: (run: (v: string | null) => void) => () => void }
|
||||
schedule: () => void
|
||||
flush: () => Promise<void>
|
||||
destroy: () => void
|
||||
prime: <T>(payload: T) => void
|
||||
status: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
|
||||
lastError: { subscribe: (run: (v: string | null) => void) => () => void }
|
||||
schedule: () => void
|
||||
flush: () => Promise<void>
|
||||
destroy: () => void
|
||||
prime: <T>(payload: T) => void
|
||||
}
|
||||
|
||||
interface CreateAutoSaveControllerOptions<TPayload, TResponse = unknown> {
|
||||
debounceMs?: number
|
||||
idleResetMs?: number
|
||||
getPayload: () => TPayload | null | undefined
|
||||
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
|
||||
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
|
||||
debounceMs?: number
|
||||
idleResetMs?: number
|
||||
getPayload: () => TPayload | null | undefined
|
||||
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
|
||||
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
|
||||
}
|
||||
|
||||
export function createAutoSaveController<TPayload, TResponse = unknown>(
|
||||
opts: CreateAutoSaveControllerOptions<TPayload, TResponse>
|
||||
opts: CreateAutoSaveControllerOptions<TPayload, TResponse>
|
||||
) {
|
||||
const debounceMs = opts.debounceMs ?? 2000
|
||||
const idleResetMs = opts.idleResetMs ?? 2000
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let idleResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
let lastSentHash: string | null = null
|
||||
const debounceMs = opts.debounceMs ?? 2000
|
||||
const idleResetMs = opts.idleResetMs ?? 2000
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let idleResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
let lastSentHash: string | null = null
|
||||
|
||||
let _status: AutoSaveStatus = 'idle'
|
||||
let _lastError: string | null = null
|
||||
const statusSubs = new Set<(v: AutoSaveStatus) => void>()
|
||||
const errorSubs = new Set<(v: string | null) => void>()
|
||||
let _status: AutoSaveStatus = 'idle'
|
||||
let _lastError: string | null = null
|
||||
const statusSubs = new Set<(v: AutoSaveStatus) => void>()
|
||||
const errorSubs = new Set<(v: string | null) => void>()
|
||||
|
||||
function setStatus(next: AutoSaveStatus) {
|
||||
if (idleResetTimer) {
|
||||
clearTimeout(idleResetTimer)
|
||||
idleResetTimer = null
|
||||
}
|
||||
function setStatus(next: AutoSaveStatus) {
|
||||
if (idleResetTimer) {
|
||||
clearTimeout(idleResetTimer)
|
||||
idleResetTimer = null
|
||||
}
|
||||
|
||||
_status = next
|
||||
statusSubs.forEach((fn) => fn(_status))
|
||||
_status = next
|
||||
statusSubs.forEach((fn) => fn(_status))
|
||||
|
||||
// Auto-transition from 'saved' to 'idle' after idleResetMs
|
||||
if (next === 'saved') {
|
||||
idleResetTimer = setTimeout(() => {
|
||||
_status = 'idle'
|
||||
statusSubs.forEach((fn) => fn(_status))
|
||||
idleResetTimer = null
|
||||
}, idleResetMs)
|
||||
}
|
||||
}
|
||||
// Auto-transition from 'saved' to 'idle' after idleResetMs
|
||||
if (next === 'saved') {
|
||||
idleResetTimer = setTimeout(() => {
|
||||
_status = 'idle'
|
||||
statusSubs.forEach((fn) => fn(_status))
|
||||
idleResetTimer = null
|
||||
}, idleResetMs)
|
||||
}
|
||||
}
|
||||
|
||||
function prime(payload: TPayload) {
|
||||
lastSentHash = safeHash(payload)
|
||||
}
|
||||
function prime(payload: TPayload) {
|
||||
lastSentHash = safeHash(payload)
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => void run(), debounceMs)
|
||||
}
|
||||
function schedule() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => void run(), debounceMs)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
async function run() {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
const payload = opts.getPayload()
|
||||
if (!payload) return
|
||||
const payload = opts.getPayload()
|
||||
if (!payload) return
|
||||
|
||||
const hash = safeHash(payload)
|
||||
if (lastSentHash && hash === lastSentHash) return
|
||||
const hash = safeHash(payload)
|
||||
if (lastSentHash && hash === lastSentHash) return
|
||||
|
||||
if (controller) controller.abort()
|
||||
controller = new AbortController()
|
||||
if (controller) controller.abort()
|
||||
controller = new AbortController()
|
||||
|
||||
setStatus('saving')
|
||||
_lastError = null
|
||||
try {
|
||||
const res = await opts.save(payload, { signal: controller.signal })
|
||||
lastSentHash = hash
|
||||
setStatus('saved')
|
||||
if (opts.onSaved) opts.onSaved(res, { prime })
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') {
|
||||
// Newer save superseded this one
|
||||
return
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
setStatus('offline')
|
||||
} else {
|
||||
setStatus('error')
|
||||
}
|
||||
_lastError = e?.message || 'Auto-save failed'
|
||||
errorSubs.forEach((fn) => fn(_lastError))
|
||||
}
|
||||
}
|
||||
setStatus('saving')
|
||||
_lastError = null
|
||||
try {
|
||||
const res = await opts.save(payload, { signal: controller.signal })
|
||||
lastSentHash = hash
|
||||
setStatus('saved')
|
||||
if (opts.onSaved) opts.onSaved(res, { prime })
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') {
|
||||
// Newer save superseded this one
|
||||
return
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
setStatus('offline')
|
||||
} else {
|
||||
setStatus('error')
|
||||
}
|
||||
_lastError = e?.message || 'Auto-save failed'
|
||||
errorSubs.forEach((fn) => fn(_lastError))
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return run()
|
||||
}
|
||||
function flush() {
|
||||
return run()
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (idleResetTimer) clearTimeout(idleResetTimer)
|
||||
if (controller) controller.abort()
|
||||
}
|
||||
function destroy() {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (idleResetTimer) clearTimeout(idleResetTimer)
|
||||
if (controller) controller.abort()
|
||||
}
|
||||
|
||||
return {
|
||||
status: {
|
||||
subscribe(run: (v: AutoSaveStatus) => void) {
|
||||
run(_status)
|
||||
statusSubs.add(run)
|
||||
return () => statusSubs.delete(run)
|
||||
}
|
||||
},
|
||||
lastError: {
|
||||
subscribe(run: (v: string | null) => void) {
|
||||
run(_lastError)
|
||||
errorSubs.add(run)
|
||||
return () => errorSubs.delete(run)
|
||||
}
|
||||
},
|
||||
schedule,
|
||||
flush,
|
||||
destroy,
|
||||
prime
|
||||
}
|
||||
return {
|
||||
status: {
|
||||
subscribe(run: (v: AutoSaveStatus) => void) {
|
||||
run(_status)
|
||||
statusSubs.add(run)
|
||||
return () => statusSubs.delete(run)
|
||||
}
|
||||
},
|
||||
lastError: {
|
||||
subscribe(run: (v: string | null) => void) {
|
||||
run(_lastError)
|
||||
errorSubs.add(run)
|
||||
return () => errorSubs.delete(run)
|
||||
}
|
||||
},
|
||||
schedule,
|
||||
flush,
|
||||
destroy,
|
||||
prime
|
||||
}
|
||||
}
|
||||
|
||||
function safeHash(obj: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(obj)
|
||||
} catch {
|
||||
// Fallback for circular structures; not expected for form payloads
|
||||
return String(obj)
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(obj)
|
||||
} catch {
|
||||
// Fallback for circular structures; not expected for form payloads
|
||||
return String(obj)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,58 +4,58 @@ import type { AutoSaveController } from './autoSave'
|
|||
import type { AutoSaveStore } from './autoSave.svelte'
|
||||
|
||||
interface AutoSaveLifecycleOptions {
|
||||
isReady?: () => boolean
|
||||
onFlushError?: (error: unknown) => void
|
||||
enableShortcut?: boolean
|
||||
isReady?: () => boolean
|
||||
onFlushError?: (error: unknown) => void
|
||||
enableShortcut?: boolean
|
||||
}
|
||||
|
||||
export function initAutoSaveLifecycle(
|
||||
controller: AutoSaveController | AutoSaveStore<any, any>,
|
||||
options: AutoSaveLifecycleOptions = {}
|
||||
controller: AutoSaveController | AutoSaveStore<any, any>,
|
||||
options: AutoSaveLifecycleOptions = {}
|
||||
) {
|
||||
const { isReady = () => true, onFlushError, enableShortcut = true } = options
|
||||
const { isReady = () => true, onFlushError, enableShortcut = true } = options
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
onDestroy(() => controller.destroy())
|
||||
return
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
onDestroy(() => controller.destroy())
|
||||
return
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!enableShortcut) return
|
||||
if (!isReady()) return
|
||||
const key = event.key.toLowerCase()
|
||||
const isModifier = event.metaKey || event.ctrlKey
|
||||
if (!isModifier || key !== 's') return
|
||||
event.preventDefault()
|
||||
controller.flush().catch((error) => {
|
||||
onFlushError?.(error)
|
||||
})
|
||||
}
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!enableShortcut) return
|
||||
if (!isReady()) return
|
||||
const key = event.key.toLowerCase()
|
||||
const isModifier = event.metaKey || event.ctrlKey
|
||||
if (!isModifier || key !== 's') return
|
||||
event.preventDefault()
|
||||
controller.flush().catch((error) => {
|
||||
onFlushError?.(error)
|
||||
})
|
||||
}
|
||||
|
||||
if (enableShortcut) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
if (enableShortcut) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
|
||||
const stopNavigating = beforeNavigate(async (navigation) => {
|
||||
if (!isReady()) return
|
||||
navigation.cancel()
|
||||
try {
|
||||
await controller.flush()
|
||||
navigation.retry()
|
||||
} catch (error) {
|
||||
onFlushError?.(error)
|
||||
}
|
||||
})
|
||||
const stopNavigating = beforeNavigate(async (navigation) => {
|
||||
if (!isReady()) return
|
||||
navigation.cancel()
|
||||
try {
|
||||
await controller.flush()
|
||||
navigation.retry()
|
||||
} catch (error) {
|
||||
onFlushError?.(error)
|
||||
}
|
||||
})
|
||||
|
||||
const stop = () => {
|
||||
if (enableShortcut) {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
stopNavigating?.()
|
||||
controller.destroy()
|
||||
}
|
||||
const stop = () => {
|
||||
if (enableShortcut) {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
stopNavigating?.()
|
||||
controller.destroy()
|
||||
}
|
||||
|
||||
onDestroy(stop)
|
||||
onDestroy(stop)
|
||||
|
||||
return { stop }
|
||||
return { stop }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,49 @@
|
|||
export type Draft<T = unknown> = { payload: T; ts: 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) {
|
||||
try {
|
||||
const entry: Draft<T> = { payload, ts: Date.now() }
|
||||
localStorage.setItem(key, JSON.stringify(entry))
|
||||
} catch {
|
||||
// Ignore quota or serialization errors
|
||||
}
|
||||
try {
|
||||
const entry: Draft<T> = { payload, ts: Date.now() }
|
||||
localStorage.setItem(key, JSON.stringify(entry))
|
||||
} catch {
|
||||
// Ignore quota or serialization errors
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDraft<T = unknown>(key: string): Draft<T> | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as Draft<T>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as Draft<T>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDraft(key: string) {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts
|
||||
const sec = Math.floor(diff / 1000)
|
||||
if (sec < 5) return 'just now'
|
||||
if (sec < 60) return `${sec} second${sec !== 1 ? 's' : ''} ago`
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min} minute${min !== 1 ? 's' : ''} ago`
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return `${hr} hour${hr !== 1 ? 's' : ''} ago`
|
||||
const day = Math.floor(hr / 24)
|
||||
if (day <= 29) {
|
||||
if (day < 7) return `${day} day${day !== 1 ? 's' : ''} ago`
|
||||
const wk = Math.floor(day / 7)
|
||||
return `${wk} week${wk !== 1 ? 's' : ''} ago`
|
||||
}
|
||||
// Beyond 29 days, show a normal localized date
|
||||
return new Date(ts).toLocaleDateString()
|
||||
const diff = Date.now() - ts
|
||||
const sec = Math.floor(diff / 1000)
|
||||
if (sec < 5) return 'just now'
|
||||
if (sec < 60) return `${sec} second${sec !== 1 ? 's' : ''} ago`
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return `${min} minute${min !== 1 ? 's' : ''} ago`
|
||||
const hr = Math.floor(min / 60)
|
||||
if (hr < 24) return `${hr} hour${hr !== 1 ? 's' : ''} ago`
|
||||
const day = Math.floor(hr / 24)
|
||||
if (day <= 29) {
|
||||
if (day < 7) return `${day} day${day !== 1 ? 's' : ''} ago`
|
||||
const wk = Math.floor(day / 7)
|
||||
return `${wk} week${wk !== 1 ? 's' : ''} ago`
|
||||
}
|
||||
// Beyond 29 days, show a normal localized date
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,38 +127,54 @@ export function createListFilters<T>(
|
|||
*/
|
||||
export const commonSorts = {
|
||||
/** Sort by date field, newest first */
|
||||
dateDesc: <T>(field: keyof T) => (a: T, b: T) =>
|
||||
new Date(b[field] as string).getTime() - new Date(a[field] as string).getTime(),
|
||||
dateDesc:
|
||||
<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 */
|
||||
dateAsc: <T>(field: keyof T) => (a: T, b: T) =>
|
||||
new Date(a[field] as string).getTime() - new Date(b[field] as string).getTime(),
|
||||
dateAsc:
|
||||
<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 */
|
||||
stringAsc: <T>(field: keyof T) => (a: T, b: T) =>
|
||||
String(a[field] || '').localeCompare(String(b[field] || '')),
|
||||
stringAsc:
|
||||
<T>(field: keyof T) =>
|
||||
(a: T, b: T) =>
|
||||
String(a[field] || '').localeCompare(String(b[field] || '')),
|
||||
|
||||
/** Sort by string field, Z-A */
|
||||
stringDesc: <T>(field: keyof T) => (a: T, b: T) =>
|
||||
String(b[field] || '').localeCompare(String(a[field] || '')),
|
||||
stringDesc:
|
||||
<T>(field: keyof T) =>
|
||||
(a: T, b: T) =>
|
||||
String(b[field] || '').localeCompare(String(a[field] || '')),
|
||||
|
||||
/** Sort by number field, ascending */
|
||||
numberAsc: <T>(field: keyof T) => (a: T, b: T) =>
|
||||
Number(a[field]) - Number(b[field]),
|
||||
numberAsc:
|
||||
<T>(field: keyof T) =>
|
||||
(a: T, b: T) =>
|
||||
Number(a[field]) - Number(b[field]),
|
||||
|
||||
/** Sort by number field, descending */
|
||||
numberDesc: <T>(field: keyof T) => (a: T, b: T) =>
|
||||
Number(b[field]) - Number(a[field]),
|
||||
numberDesc:
|
||||
<T>(field: keyof T) =>
|
||||
(a: T, b: T) =>
|
||||
Number(b[field]) - Number(a[field]),
|
||||
|
||||
/** Sort by status field, published first */
|
||||
statusPublishedFirst: <T>(field: keyof T) => (a: T, b: T) => {
|
||||
if (a[field] === b[field]) return 0
|
||||
return a[field] === 'published' ? -1 : 1
|
||||
},
|
||||
statusPublishedFirst:
|
||||
<T>(field: keyof T) =>
|
||||
(a: T, b: T) => {
|
||||
if (a[field] === b[field]) return 0
|
||||
return a[field] === 'published' ? -1 : 1
|
||||
},
|
||||
|
||||
/** Sort by status field, draft first */
|
||||
statusDraftFirst: <T>(field: keyof T) => (a: T, b: T) => {
|
||||
if (a[field] === b[field]) return 0
|
||||
return a[field] === 'draft' ? -1 : 1
|
||||
}
|
||||
statusDraftFirst:
|
||||
<T>(field: keyof T) =>
|
||||
(a: T, b: T) => {
|
||||
if (a[field] === b[field]) return 0
|
||||
return a[field] === 'draft' ? -1 : 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,8 @@
|
|||
|
||||
{#if searchError}
|
||||
<div class="error-message">
|
||||
<strong>Error:</strong> {searchError}
|
||||
<strong>Error:</strong>
|
||||
{searchError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -152,13 +153,7 @@
|
|||
<h3>Results</h3>
|
||||
|
||||
<div class="result-tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={true}
|
||||
onclick={() => {}}
|
||||
>
|
||||
Raw JSON
|
||||
</button>
|
||||
<button class="tab" class:active={true} onclick={() => {}}> Raw JSON </button>
|
||||
<button
|
||||
class="copy-btn"
|
||||
onclick={async () => {
|
||||
|
|
@ -277,7 +272,8 @@
|
|||
margin-bottom: $unit-half;
|
||||
}
|
||||
|
||||
input, select {
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
let isBlinking = $state(false)
|
||||
let isPlayingMusic = $state(forcePlayingMusic)
|
||||
|
||||
|
||||
const scale = new Spring(1, {
|
||||
stiffness: 0.1,
|
||||
damping: 0.125
|
||||
|
|
|
|||
|
|
@ -51,18 +51,25 @@
|
|||
connected = state.connected
|
||||
|
||||
// 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
|
||||
setTimeout(() => updateFlash = false, 500)
|
||||
setTimeout(() => (updateFlash = false), 500)
|
||||
}
|
||||
|
||||
lastUpdate = state.lastUpdate
|
||||
|
||||
// Calculate smart interval based on track remaining time
|
||||
const nowPlayingAlbum = state.albums.find(a => a.isNowPlaying)
|
||||
if (nowPlayingAlbum?.nowPlayingTrack && nowPlayingAlbum.appleMusicData?.tracks && nowPlayingAlbum.lastScrobbleTime) {
|
||||
const nowPlayingAlbum = state.albums.find((a) => a.isNowPlaying)
|
||||
if (
|
||||
nowPlayingAlbum?.nowPlayingTrack &&
|
||||
nowPlayingAlbum.appleMusicData?.tracks &&
|
||||
nowPlayingAlbum.lastScrobbleTime
|
||||
) {
|
||||
const track = nowPlayingAlbum.appleMusicData.tracks.find(
|
||||
t => t.name === nowPlayingAlbum.nowPlayingTrack
|
||||
(t) => t.name === nowPlayingAlbum.nowPlayingTrack
|
||||
)
|
||||
|
||||
if (track?.durationMs) {
|
||||
|
|
@ -109,7 +116,7 @@
|
|||
// Calculate initial remaining time
|
||||
const calculateRemaining = () => {
|
||||
const elapsed = Date.now() - lastUpdate.getTime()
|
||||
const remaining = (updateInterval * 1000) - elapsed
|
||||
const remaining = updateInterval * 1000 - elapsed
|
||||
return Math.max(0, Math.ceil(remaining / 1000))
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +220,7 @@
|
|||
|
||||
{#if dev}
|
||||
<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>
|
||||
<button class="minimize-btn" aria-label={isMinimized ? 'Expand' : 'Minimize'}>
|
||||
{isMinimized ? '▲' : '▼'}
|
||||
|
|
@ -226,21 +233,21 @@
|
|||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'nowplaying'}
|
||||
onclick={() => activeTab = 'nowplaying'}
|
||||
onclick={() => (activeTab = 'nowplaying')}
|
||||
>
|
||||
Now Playing
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'albums'}
|
||||
onclick={() => activeTab = 'albums'}
|
||||
onclick={() => (activeTab = 'albums')}
|
||||
>
|
||||
Albums
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'cache'}
|
||||
onclick={() => activeTab = 'cache'}
|
||||
onclick={() => (activeTab = 'cache')}
|
||||
>
|
||||
Cache
|
||||
</button>
|
||||
|
|
@ -251,13 +258,21 @@
|
|||
<div class="section">
|
||||
<h4>Connection</h4>
|
||||
<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 class:flash={updateFlash}>
|
||||
Last Update: {lastUpdate ? lastUpdate.toLocaleTimeString() : 'Never'}
|
||||
</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}
|
||||
<p>Track Remaining: {formatTime(trackRemainingTime)}</p>
|
||||
{/if}
|
||||
|
|
@ -274,7 +289,10 @@
|
|||
{/if}
|
||||
{#if nowPlaying.album.appleMusicData}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -290,8 +308,16 @@
|
|||
<div class="albums-list">
|
||||
{#each albums as album}
|
||||
{@const albumId = `${album.artist.name}:${album.name}`}
|
||||
<div class="album-item" class:playing={album.isNowPlaying} class:expanded={expandedAlbumId === albumId}>
|
||||
<div class="album-header" onclick={() => expandedAlbumId = expandedAlbumId === albumId ? null : albumId}>
|
||||
<div
|
||||
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-info">
|
||||
<span class="name">{album.name}</span>
|
||||
|
|
@ -306,7 +332,9 @@
|
|||
{album.appleMusicData.tracks?.length || 0} tracks
|
||||
</span>
|
||||
<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>
|
||||
{:else}
|
||||
<span class="meta-item">No Apple Music data</span>
|
||||
|
|
@ -315,7 +343,10 @@
|
|||
</div>
|
||||
<button
|
||||
class="clear-cache-btn"
|
||||
onclick={(e) => { e.stopPropagation(); clearAlbumCache(album) }}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
clearAlbumCache(album)
|
||||
}}
|
||||
disabled={clearingAlbums.has(albumId)}
|
||||
title="Clear Apple Music cache for this album"
|
||||
>
|
||||
|
|
@ -333,9 +364,18 @@
|
|||
{#if album.appleMusicData.searchMetadata}
|
||||
<h5>Search Information</h5>
|
||||
<div class="search-metadata">
|
||||
<p><strong>Search Query:</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>
|
||||
<p>
|
||||
<strong>Search Query:</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}
|
||||
<CheckIcon class="icon success inline" /> Found
|
||||
{:else}
|
||||
|
|
@ -343,59 +383,76 @@
|
|||
{/if}
|
||||
</p>
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if album.appleMusicData.appleMusicId}
|
||||
<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 album.appleMusicData.releaseDate}
|
||||
<p><strong>Release Date:</strong> {album.appleMusicData.releaseDate}</p>
|
||||
{/if}
|
||||
{#if album.appleMusicData.releaseDate}
|
||||
<p><strong>Release Date:</strong> {album.appleMusicData.releaseDate}</p>
|
||||
{/if}
|
||||
|
||||
{#if album.appleMusicData.recordLabel}
|
||||
<p><strong>Label:</strong> {album.appleMusicData.recordLabel}</p>
|
||||
{/if}
|
||||
{#if album.appleMusicData.recordLabel}
|
||||
<p><strong>Label:</strong> {album.appleMusicData.recordLabel}</p>
|
||||
{/if}
|
||||
|
||||
{#if album.appleMusicData.genres?.length}
|
||||
<p><strong>Genres:</strong> {album.appleMusicData.genres.join(', ')}</p>
|
||||
{/if}
|
||||
{#if album.appleMusicData.genres?.length}
|
||||
<p><strong>Genres:</strong> {album.appleMusicData.genres.join(', ')}</p>
|
||||
{/if}
|
||||
|
||||
{#if album.appleMusicData.previewUrl}
|
||||
<p><strong>Preview URL:</strong> <code>{album.appleMusicData.previewUrl}</code></p>
|
||||
{/if}
|
||||
{#if album.appleMusicData.previewUrl}
|
||||
<p>
|
||||
<strong>Preview URL:</strong>
|
||||
<code>{album.appleMusicData.previewUrl}</code>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if album.appleMusicData.tracks?.length}
|
||||
<div class="tracks-section">
|
||||
<h6>Tracks ({album.appleMusicData.tracks.length})</h6>
|
||||
<div class="tracks-list">
|
||||
{#each album.appleMusicData.tracks as track, i}
|
||||
<div class="track-item">
|
||||
<span class="track-number">{i + 1}.</span>
|
||||
<span class="track-name">{track.name}</span>
|
||||
{#if track.durationMs}
|
||||
<span class="track-duration">{Math.floor(track.durationMs / 60000)}:{String(Math.floor((track.durationMs % 60000) / 1000)).padStart(2, '0')}</span>
|
||||
{/if}
|
||||
{#if track.previewUrl}
|
||||
<CheckIcon class="icon success inline" title="Has preview" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if album.appleMusicData.tracks?.length}
|
||||
<div class="tracks-section">
|
||||
<h6>Tracks ({album.appleMusicData.tracks.length})</h6>
|
||||
<div class="tracks-list">
|
||||
{#each album.appleMusicData.tracks as track, i}
|
||||
<div class="track-item">
|
||||
<span class="track-number">{i + 1}.</span>
|
||||
<span class="track-name">{track.name}</span>
|
||||
{#if track.durationMs}
|
||||
<span class="track-duration"
|
||||
>{Math.floor(track.durationMs / 60000)}:{String(
|
||||
Math.floor((track.durationMs % 60000) / 1000)
|
||||
).padStart(2, '0')}</span
|
||||
>
|
||||
{/if}
|
||||
{#if track.previewUrl}
|
||||
<CheckIcon class="icon success inline" title="Has preview" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="raw-data">
|
||||
<h6>Raw Data</h6>
|
||||
<pre>{JSON.stringify(album.appleMusicData, null, 2)}</pre>
|
||||
</div>
|
||||
<div class="raw-data">
|
||||
<h6>Raw Data</h6>
|
||||
<pre>{JSON.stringify(album.appleMusicData, null, 2)}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<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}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -426,11 +483,7 @@
|
|||
</div>
|
||||
|
||||
<div class="cache-actions">
|
||||
<button
|
||||
onclick={clearAllMusicCache}
|
||||
disabled={isClearing}
|
||||
class="clear-all-btn"
|
||||
>
|
||||
<button onclick={clearAllMusicCache} disabled={isClearing} class="clear-all-btn">
|
||||
{isClearing ? 'Clearing...' : 'Clear All Music Cache'}
|
||||
</button>
|
||||
|
||||
|
|
@ -463,10 +516,7 @@
|
|||
{isClearing ? 'Clearing...' : 'Clear Not Found Cache'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => searchModal?.open()}
|
||||
class="search-btn"
|
||||
>
|
||||
<button onclick={() => searchModal?.open()} class="search-btn">
|
||||
Test Apple Music Search
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -912,7 +962,8 @@
|
|||
flex-wrap: wrap;
|
||||
gap: $unit;
|
||||
|
||||
.clear-all-btn, .clear-not-found-btn {
|
||||
.clear-all-btn,
|
||||
.clear-not-found-btn {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: $unit * 1.5;
|
||||
|
|
|
|||
|
|
@ -250,8 +250,8 @@
|
|||
background: $gray-95;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New',
|
||||
monospace;
|
||||
font-family:
|
||||
'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: $text-color;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@
|
|||
|
||||
let { album, getAlbumArtwork }: Props = $props()
|
||||
|
||||
const trackText = $derived(`${album.artist.name} — ${album.name}${
|
||||
album.appleMusicData?.releaseDate
|
||||
? ` (${new Date(album.appleMusicData.releaseDate).getFullYear()})`
|
||||
: ''
|
||||
} — ${album.nowPlayingTrack || album.name}`)
|
||||
const trackText = $derived(
|
||||
`${album.artist.name} — ${album.name}${
|
||||
album.appleMusicData?.releaseDate
|
||||
? ` (${new Date(album.appleMusicData.releaseDate).getFullYear()})`
|
||||
: ''
|
||||
} — ${album.nowPlayingTrack || album.name}`
|
||||
)
|
||||
</script>
|
||||
|
||||
<nav class="now-playing-bar">
|
||||
|
|
|
|||
|
|
@ -229,8 +229,8 @@
|
|||
.metadata-value {
|
||||
font-size: 0.875rem;
|
||||
color: $gray-10;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New',
|
||||
monospace;
|
||||
font-family:
|
||||
'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,9 +112,9 @@
|
|||
if (!album) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/albums/${album.id}`, {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
const response = await fetch(`/api/albums/${album.id}`, {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
albumMedia = data.media || []
|
||||
|
|
@ -275,10 +275,7 @@
|
|||
</div>
|
||||
<div class="header-actions">
|
||||
{#if !isLoading}
|
||||
<AutoSaveStatus
|
||||
status="idle"
|
||||
lastSavedAt={album?.updatedAt}
|
||||
/>
|
||||
<AutoSaveStatus status="idle" lastSavedAt={album?.updatedAt} />
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,14 @@
|
|||
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 {
|
||||
const date = new Date(dateString)
|
||||
|
|
|
|||
|
|
@ -1,117 +1,119 @@
|
|||
<script lang="ts">
|
||||
import type { AutoSaveStatus } from '$lib/admin/autoSave'
|
||||
import { formatTimeAgo } from '$lib/utils/time'
|
||||
import type { AutoSaveStatus } from '$lib/admin/autoSave'
|
||||
import { formatTimeAgo } from '$lib/utils/time'
|
||||
|
||||
interface Props {
|
||||
statusStore?: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
|
||||
errorStore?: { subscribe: (run: (v: string | null) => void) => () => void }
|
||||
status?: AutoSaveStatus
|
||||
error?: string | null
|
||||
lastSavedAt?: Date | string | null
|
||||
showTimestamp?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
interface Props {
|
||||
statusStore?: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
|
||||
errorStore?: { subscribe: (run: (v: string | null) => void) => () => void }
|
||||
status?: AutoSaveStatus
|
||||
error?: string | null
|
||||
lastSavedAt?: Date | string | null
|
||||
showTimestamp?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
statusStore,
|
||||
errorStore,
|
||||
status: statusProp,
|
||||
error: errorProp,
|
||||
lastSavedAt,
|
||||
showTimestamp = true,
|
||||
compact = true
|
||||
}: Props = $props()
|
||||
let {
|
||||
statusStore,
|
||||
errorStore,
|
||||
status: statusProp,
|
||||
error: errorProp,
|
||||
lastSavedAt,
|
||||
showTimestamp = true,
|
||||
compact = true
|
||||
}: Props = $props()
|
||||
|
||||
// Support both old subscription-based stores and new reactive values
|
||||
let status = $state<AutoSaveStatus>('idle')
|
||||
let errorText = $state<string | null>(null)
|
||||
let refreshKey = $state(0) // Used to force re-render for time updates
|
||||
// Support both old subscription-based stores and new reactive values
|
||||
let status = $state<AutoSaveStatus>('idle')
|
||||
let errorText = $state<string | null>(null)
|
||||
let refreshKey = $state(0) // Used to force re-render for time updates
|
||||
|
||||
$effect(() => {
|
||||
// If using direct props (new runes-based store)
|
||||
if (statusProp !== undefined) {
|
||||
status = statusProp
|
||||
errorText = errorProp ?? null
|
||||
return
|
||||
}
|
||||
$effect(() => {
|
||||
// If using direct props (new runes-based store)
|
||||
if (statusProp !== undefined) {
|
||||
status = statusProp
|
||||
errorText = errorProp ?? null
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise use subscriptions (old store)
|
||||
if (!statusStore) return
|
||||
// Otherwise use subscriptions (old store)
|
||||
if (!statusStore) return
|
||||
|
||||
const unsub = statusStore.subscribe((v) => (status = v))
|
||||
let unsubErr: (() => void) | null = null
|
||||
if (errorStore) unsubErr = errorStore.subscribe((v) => (errorText = v))
|
||||
return () => {
|
||||
unsub()
|
||||
if (unsubErr) unsubErr()
|
||||
}
|
||||
})
|
||||
const unsub = statusStore.subscribe((v) => (status = v))
|
||||
let unsubErr: (() => void) | null = null
|
||||
if (errorStore) unsubErr = errorStore.subscribe((v) => (errorText = v))
|
||||
return () => {
|
||||
unsub()
|
||||
if (unsubErr) unsubErr()
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-refresh timestamp every 30 seconds
|
||||
$effect(() => {
|
||||
if (!lastSavedAt || !showTimestamp) return
|
||||
// Auto-refresh timestamp every 30 seconds
|
||||
$effect(() => {
|
||||
if (!lastSavedAt || !showTimestamp) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
refreshKey++
|
||||
}, 30000)
|
||||
const interval = setInterval(() => {
|
||||
refreshKey++
|
||||
}, 30000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
const label = $derived.by(() => {
|
||||
// Force dependency on refreshKey to trigger re-computation
|
||||
refreshKey
|
||||
const label = $derived.by(() => {
|
||||
// Force dependency on refreshKey to trigger re-computation
|
||||
refreshKey
|
||||
|
||||
switch (status) {
|
||||
case 'saving':
|
||||
return 'Saving…'
|
||||
case 'saved':
|
||||
case 'idle':
|
||||
return lastSavedAt && showTimestamp
|
||||
? `Saved ${formatTimeAgo(lastSavedAt)}`
|
||||
: 'All changes saved'
|
||||
case 'offline':
|
||||
return 'Offline'
|
||||
case 'error':
|
||||
return errorText ? `Error — ${errorText}` : 'Save failed'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
switch (status) {
|
||||
case 'saving':
|
||||
return 'Saving…'
|
||||
case 'saved':
|
||||
case 'idle':
|
||||
return lastSavedAt && showTimestamp
|
||||
? `Saved ${formatTimeAgo(lastSavedAt)}`
|
||||
: 'All changes saved'
|
||||
case 'offline':
|
||||
return 'Offline'
|
||||
case 'error':
|
||||
return errorText ? `Error — ${errorText}` : 'Save failed'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if label}
|
||||
<div class="autosave-status" class:compact>
|
||||
{#if status === 'saving'}
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="text">{label}</span>
|
||||
</div>
|
||||
<div class="autosave-status" class:compact>
|
||||
{#if status === 'saving'}
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="text">{label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.autosave-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: $gray-40;
|
||||
font-size: 0.875rem;
|
||||
.autosave-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: $gray-40;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&.compact {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
&.compact {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid $gray-80;
|
||||
border-top-color: $gray-40;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid $gray-80;
|
||||
border-top-color: $gray-40;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
<div class="draft-banner">
|
||||
<div class="draft-banner-content">
|
||||
<span class="draft-banner-text">
|
||||
Unsaved draft found{#if timeAgo} (saved {timeAgo}){/if}.
|
||||
Unsaved draft found{#if timeAgo}
|
||||
(saved {timeAgo}){/if}.
|
||||
</span>
|
||||
<div class="draft-banner-actions">
|
||||
<button class="draft-banner-button" type="button" onclick={onRestore}>Restore</button>
|
||||
|
|
|
|||
|
|
@ -50,43 +50,46 @@
|
|||
let showDraftPrompt = $state(false)
|
||||
let draftTimestamp = $state<number | null>(null)
|
||||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
||||
const draftTimeText = $derived.by(() =>
|
||||
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
|
||||
)
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
title,
|
||||
slug,
|
||||
type: 'essay',
|
||||
status,
|
||||
content,
|
||||
tags,
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
function buildPayload() {
|
||||
return {
|
||||
title,
|
||||
slug,
|
||||
type: 'essay',
|
||||
status,
|
||||
content,
|
||||
tags,
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
// Autosave store (edit mode only)
|
||||
let autoSave = mode === 'edit' && postId
|
||||
? createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
const response = await fetch(`/api/posts/${postId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'same-origin',
|
||||
signal
|
||||
// Autosave store (edit mode only)
|
||||
let autoSave =
|
||||
mode === 'edit' && postId
|
||||
? createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
const response = await fetch(`/api/posts/${postId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
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')
|
||||
return await response.json()
|
||||
},
|
||||
onSaved: (saved: any, { prime }) => {
|
||||
updatedAt = saved.updatedAt
|
||||
prime(buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
})
|
||||
: null
|
||||
: null
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'metadata', label: 'Metadata' },
|
||||
|
|
@ -107,14 +110,14 @@ let autoSave = mode === 'edit' && postId
|
|||
]
|
||||
|
||||
// Auto-generate slug from title
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Prime autosave on initial load (edit mode only)
|
||||
$effect(() => {
|
||||
|
|
@ -126,7 +129,12 @@ $effect(() => {
|
|||
|
||||
// Trigger autosave when form data changes
|
||||
$effect(() => {
|
||||
title; slug; status; content; tags; activeTab
|
||||
title
|
||||
slug
|
||||
status
|
||||
content
|
||||
tags
|
||||
activeTab
|
||||
if (hasLoaded && autoSave) {
|
||||
autoSave.schedule()
|
||||
}
|
||||
|
|
@ -142,14 +150,14 @@ $effect(() => {
|
|||
}
|
||||
})
|
||||
|
||||
// Show restore prompt if a draft exists
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
}
|
||||
})
|
||||
// Show restore prompt if a draft exists
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
}
|
||||
})
|
||||
|
||||
function restoreDraft() {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
|
|
@ -297,8 +305,8 @@ $effect(() => {
|
|||
const savedPost = await response.json()
|
||||
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
|
||||
if (mode === 'create') {
|
||||
goto(`/admin/posts/${savedPost.id}/edit`)
|
||||
|
|
@ -311,7 +319,6 @@ $effect(() => {
|
|||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
|
|
@ -341,7 +348,8 @@ $effect(() => {
|
|||
<div class="draft-banner">
|
||||
<div class="draft-banner-content">
|
||||
<span class="draft-banner-text">
|
||||
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
|
||||
Unsaved draft found{#if draftTimeText}
|
||||
(saved {draftTimeText}){/if}.
|
||||
</span>
|
||||
<div class="draft-banner-actions">
|
||||
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
|
||||
|
|
@ -381,11 +389,7 @@ $effect(() => {
|
|||
|
||||
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
|
||||
|
||||
<DropdownSelectField
|
||||
label="Status"
|
||||
bind:value={status}
|
||||
options={statusOptions}
|
||||
/>
|
||||
<DropdownSelectField label="Status" bind:value={status} options={statusOptions} />
|
||||
|
||||
<div class="tags-field">
|
||||
<label class="input-label">Tags</label>
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@
|
|||
const isOverLimit = $derived(characterCount > CHARACTER_LIMIT)
|
||||
const canSave = $derived(
|
||||
(postType === 'post' && (characterCount > 0 || attachedPhotos.length > 0) && !isOverLimit) ||
|
||||
(postType === 'essay' && essayTitle.length > 0 && content)
|
||||
(postType === 'essay' && essayTitle.length > 0 && content)
|
||||
)
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
<script lang="ts">
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -47,49 +47,52 @@
|
|||
let showDraftPrompt = $state(false)
|
||||
let draftTimestamp = $state<number | null>(null)
|
||||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
||||
const draftTimeText = $derived.by(() =>
|
||||
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
|
||||
)
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
title: title.trim(),
|
||||
slug: createSlug(title),
|
||||
type: 'photo',
|
||||
status,
|
||||
content,
|
||||
featuredImage: featuredImage ? featuredImage.url : null,
|
||||
tags: tags
|
||||
? tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
function buildPayload() {
|
||||
return {
|
||||
title: title.trim(),
|
||||
slug: createSlug(title),
|
||||
type: 'photo',
|
||||
status,
|
||||
content,
|
||||
featuredImage: featuredImage ? featuredImage.url : null,
|
||||
tags: tags
|
||||
? tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
// Autosave store (edit mode only)
|
||||
let autoSave = mode === 'edit' && postId
|
||||
? createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
const response = await fetch(`/api/posts/${postId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'same-origin',
|
||||
signal
|
||||
// Autosave store (edit mode only)
|
||||
let autoSave =
|
||||
mode === 'edit' && postId
|
||||
? createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
const response = await fetch(`/api/posts/${postId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
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')
|
||||
return await response.json()
|
||||
},
|
||||
onSaved: (saved: any, { prime }) => {
|
||||
updatedAt = saved.updatedAt
|
||||
prime(buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
})
|
||||
: null
|
||||
: null
|
||||
|
||||
// Prime autosave on initial load (edit mode only)
|
||||
$effect(() => {
|
||||
|
|
@ -101,7 +104,11 @@ let autoSave = mode === 'edit' && postId
|
|||
|
||||
// Trigger autosave when form data changes
|
||||
$effect(() => {
|
||||
title; status; content; featuredImage; tags
|
||||
title
|
||||
status
|
||||
content
|
||||
featuredImage
|
||||
tags
|
||||
if (hasLoaded && autoSave) {
|
||||
autoSave.schedule()
|
||||
}
|
||||
|
|
@ -117,13 +124,13 @@ let autoSave = mode === 'edit' && postId
|
|||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
}
|
||||
})
|
||||
|
||||
function restoreDraft() {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
|
|
@ -330,11 +337,11 @@ $effect(() => {
|
|||
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.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Photo post ${status === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
|
||||
// Redirect to posts list or edit page
|
||||
if (mode === 'create') {
|
||||
|
|
@ -397,7 +404,8 @@ $effect(() => {
|
|||
<div class="draft-banner">
|
||||
<div class="draft-banner-content">
|
||||
<span class="draft-banner-text">
|
||||
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
|
||||
Unsaved draft found{#if draftTimeText}
|
||||
(saved {draftTimeText}){/if}.
|
||||
</span>
|
||||
<div class="draft-banner-actions">
|
||||
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="dropdown-container" use:clickOutside={{ enabled: isOpen }} onclickoutside={handleClickOutside}>
|
||||
<div
|
||||
class="dropdown-container"
|
||||
use:clickOutside={{ enabled: isOpen }}
|
||||
onclickoutside={handleClickOutside}
|
||||
>
|
||||
<Button
|
||||
bind:this={buttonRef}
|
||||
variant="primary"
|
||||
|
|
|
|||
|
|
@ -165,9 +165,7 @@
|
|||
|
||||
{#if isDropdownOpen}
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" type="button" onclick={handleEdit}>
|
||||
Edit post
|
||||
</button>
|
||||
<button class="dropdown-item" type="button" onclick={handleEdit}> Edit post </button>
|
||||
<button class="dropdown-item" type="button" onclick={handleTogglePublish}>
|
||||
{post.status === 'published' ? 'Unpublish' : 'Publish'} post
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@
|
|||
const hasFeaturedImage = $derived(
|
||||
!!(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)
|
||||
|
||||
// Auto-disable toggles when content is removed
|
||||
|
|
|
|||
|
|
@ -43,21 +43,22 @@
|
|||
const draftKey = $derived(mode === 'edit' && project ? makeDraftKey('project', project.id) : null)
|
||||
|
||||
// Autosave (edit mode only)
|
||||
const autoSave = mode === 'edit'
|
||||
? createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? formStore.buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
|
||||
},
|
||||
onSaved: (savedProject: any, { prime }) => {
|
||||
project = savedProject
|
||||
formStore.populateFromProject(savedProject)
|
||||
prime(formStore.buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
})
|
||||
: null
|
||||
const autoSave =
|
||||
mode === 'edit'
|
||||
? createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? formStore.buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
|
||||
},
|
||||
onSaved: (savedProject: any, { prime }) => {
|
||||
project = savedProject
|
||||
formStore.populateFromProject(savedProject)
|
||||
prime(formStore.buildPayload())
|
||||
if (draftKey) clearDraft(draftKey)
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
// Draft recovery helper
|
||||
const draftRecovery = useDraftRecovery<Partial<ProjectFormData>>({
|
||||
|
|
@ -89,7 +90,8 @@
|
|||
// Trigger autosave when formData changes (edit mode)
|
||||
$effect(() => {
|
||||
// Establish dependencies on fields
|
||||
formStore.fields; activeTab
|
||||
formStore.fields
|
||||
activeTab
|
||||
if (mode === 'edit' && hasLoaded && autoSave) {
|
||||
autoSave.schedule()
|
||||
}
|
||||
|
|
@ -143,9 +145,9 @@
|
|||
|
||||
let savedProject: Project
|
||||
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 {
|
||||
savedProject = await api.post('/api/projects', payload) as Project
|
||||
savedProject = (await api.post('/api/projects', payload)) as Project
|
||||
}
|
||||
|
||||
toast.dismiss(loadingToastId)
|
||||
|
|
@ -168,8 +170,6 @@
|
|||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
|
|
@ -225,7 +225,11 @@
|
|||
handleSave()
|
||||
}}
|
||||
>
|
||||
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
|
||||
<ProjectMetadataForm
|
||||
bind:formData={formStore.fields}
|
||||
validationErrors={formStore.validationErrors}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -239,7 +243,11 @@
|
|||
handleSave()
|
||||
}}
|
||||
>
|
||||
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
|
||||
<ProjectBrandingForm
|
||||
bind:formData={formStore.fields}
|
||||
validationErrors={formStore.validationErrors}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -131,9 +131,7 @@
|
|||
|
||||
{#if isDropdownOpen}
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" type="button" onclick={handleEdit}>
|
||||
Edit project
|
||||
</button>
|
||||
<button class="dropdown-item" type="button" onclick={handleEdit}> Edit project </button>
|
||||
<button class="dropdown-item" type="button" onclick={handleTogglePublish}>
|
||||
{project.status === 'published' ? 'Unpublish' : 'Publish'} project
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
mode: 'create' | 'edit'
|
||||
}
|
||||
|
||||
let { postType, postId, initialData, mode }: Props = $props()
|
||||
let { postType, postId, initialData, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
let isSaving = $state(false)
|
||||
|
|
@ -38,7 +38,7 @@ let { postType, postId, initialData, mode }: Props = $props()
|
|||
let linkDescription = $state(initialData?.linkDescription || '')
|
||||
let title = $state(initialData?.title || '')
|
||||
|
||||
// Character count for posts
|
||||
// Character count for posts
|
||||
const maxLength = 280
|
||||
const textContent = $derived.by(() => {
|
||||
if (!content.content) return ''
|
||||
|
|
@ -50,178 +50,185 @@ let { postType, postId, initialData, mode }: Props = $props()
|
|||
const isOverLimit = $derived(charCount > maxLength)
|
||||
|
||||
// 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
|
||||
const hasTextContent = textContent.trim().length > 0
|
||||
const hasLinkContent = linkUrl && linkUrl.trim().length > 0
|
||||
return hasTextContent || hasLinkContent
|
||||
})
|
||||
})
|
||||
|
||||
// Draft backup
|
||||
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
|
||||
let showDraftPrompt = $state(false)
|
||||
let draftTimestamp = $state<number | null>(null)
|
||||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
||||
// Draft backup
|
||||
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
|
||||
let showDraftPrompt = $state(false)
|
||||
let draftTimestamp = $state<number | null>(null)
|
||||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived.by(() =>
|
||||
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
|
||||
)
|
||||
|
||||
function buildPayload() {
|
||||
const payload: any = {
|
||||
type: 'post',
|
||||
status,
|
||||
content,
|
||||
updatedAt
|
||||
}
|
||||
if (linkUrl && linkUrl.trim()) {
|
||||
payload.title = title || linkUrl
|
||||
payload.link_url = linkUrl
|
||||
payload.linkDescription = linkDescription
|
||||
} else if (title) {
|
||||
payload.title = title
|
||||
}
|
||||
return payload
|
||||
}
|
||||
function buildPayload() {
|
||||
const payload: any = {
|
||||
type: 'post',
|
||||
status,
|
||||
content,
|
||||
updatedAt
|
||||
}
|
||||
if (linkUrl && linkUrl.trim()) {
|
||||
payload.title = title || linkUrl
|
||||
payload.link_url = linkUrl
|
||||
payload.linkDescription = linkDescription
|
||||
} else if (title) {
|
||||
payload.title = title
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// Autosave store (edit mode only)
|
||||
let autoSave = mode === 'edit' && postId
|
||||
? createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
const response = await fetch(`/api/posts/${postId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'same-origin',
|
||||
signal
|
||||
// Autosave store (edit mode only)
|
||||
let autoSave =
|
||||
mode === 'edit' && postId
|
||||
? createAutoSaveStore({
|
||||
debounceMs: 2000,
|
||||
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||
save: async (payload, { signal }) => {
|
||||
const response = await fetch(`/api/posts/${postId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
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')
|
||||
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)
|
||||
$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())
|
||||
}
|
||||
})
|
||||
: 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(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
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
|
||||
$effect(() => {
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
}
|
||||
// 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
|
||||
$effect(() => {
|
||||
if (!hasLoaded || !autoSave) return
|
||||
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (autoSave!.status !== 'saved') {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
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)
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
function dismissDraft() {
|
||||
showDraftPrompt = false
|
||||
clearDraft(draftKey)
|
||||
}
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
|
||||
$effect(() => {
|
||||
if (!hasLoaded || !autoSave) return
|
||||
// Auto-update draft time text every minute when prompt visible
|
||||
$effect(() => {
|
||||
if (showDraftPrompt) {
|
||||
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
|
||||
return () => clearInterval(id)
|
||||
}
|
||||
})
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
autoSave!.flush().catch((error) => {
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
// Warn before closing browser tab/window if there are unsaved changes
|
||||
$effect(() => {
|
||||
if (!hasLoaded || !autoSave) return
|
||||
|
||||
// Cleanup autosave on unmount
|
||||
$effect(() => {
|
||||
if (autoSave) {
|
||||
return () => autoSave.destroy()
|
||||
}
|
||||
})
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (autoSave!.status !== 'saved') {
|
||||
event.preventDefault()
|
||||
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') {
|
||||
if (isOverLimit) {
|
||||
|
|
@ -275,11 +282,11 @@ $effect(() => {
|
|||
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
|
||||
}
|
||||
|
||||
const savedPost = await response.json()
|
||||
const savedPost = await response.json()
|
||||
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`Post ${publishStatus === 'published' ? 'published' : 'saved'} successfully!`)
|
||||
clearDraft(draftKey)
|
||||
|
||||
// Redirect back to posts list after creation
|
||||
goto('/admin/posts')
|
||||
|
|
@ -336,7 +343,8 @@ $effect(() => {
|
|||
<div class="draft-banner">
|
||||
<div class="draft-banner-content">
|
||||
<span class="draft-banner-text">
|
||||
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
|
||||
Unsaved draft found{#if draftTimeText}
|
||||
(saved {draftTimeText}){/if}.
|
||||
</span>
|
||||
<div class="draft-banner-actions">
|
||||
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
|
||||
|
|
|
|||
|
|
@ -66,14 +66,7 @@
|
|||
>
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-label">{currentConfig.label}</span>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:open={isOpen}
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
>
|
||||
<svg class="chevron" class:open={isOpen} width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
|
|
@ -96,12 +89,7 @@
|
|||
|
||||
{#if viewUrl && currentStatus === 'published'}
|
||||
<div class="dropdown-divider"></div>
|
||||
<a
|
||||
href={viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="dropdown-item view-link"
|
||||
>
|
||||
<a href={viewUrl} target="_blank" rel="noopener noreferrer" class="dropdown-item view-link">
|
||||
View on site
|
||||
</a>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,6 @@
|
|||
// Short delay to prevent flicker
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
|
||||
let url = `/api/media?page=${page}&limit=24`
|
||||
|
||||
if (filterType !== 'all') {
|
||||
|
|
|
|||
|
|
@ -375,10 +375,7 @@
|
|||
const afterPos = nodePos + actualNode.nodeSize
|
||||
|
||||
// Insert the duplicated node
|
||||
editor.chain()
|
||||
.focus()
|
||||
.insertContentAt(afterPos, nodeCopy)
|
||||
.run()
|
||||
editor.chain().focus().insertContentAt(afterPos, nodeCopy).run()
|
||||
|
||||
isMenuOpen = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ export async function adminFetch(
|
|||
let detail: string | undefined
|
||||
try {
|
||||
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 {
|
||||
try {
|
||||
detail = await response.clone().text()
|
||||
|
|
|
|||
|
|
@ -340,11 +340,14 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
|
|||
|
||||
// Log all songs for debugging
|
||||
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
|
||||
const matchingSong = songs.find(s => {
|
||||
const matchingSong = songs.find((s) => {
|
||||
const songName = s.attributes?.name || ''
|
||||
const artistName = s.attributes?.artistName || ''
|
||||
const albumName = s.attributes?.albumName || ''
|
||||
|
|
@ -357,7 +360,8 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
|
|||
const artistSearchLower = artist.toLowerCase()
|
||||
|
||||
// Check if the song name matches what we're looking for
|
||||
const songMatches = songNameLower === albumSearchLower ||
|
||||
const songMatches =
|
||||
songNameLower === albumSearchLower ||
|
||||
songNameLower.includes(albumSearchLower) ||
|
||||
albumSearchLower.includes(songNameLower)
|
||||
|
||||
|
|
@ -365,7 +369,8 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
|
|||
const artistNameNormalized = artistNameLower.replace(/\s+/g, '')
|
||||
const artistSearchNormalized = artistSearchLower.replace(/\s+/g, '')
|
||||
|
||||
const artistMatches = artistNameLower === artistSearchLower ||
|
||||
const artistMatches =
|
||||
artistNameLower === artistSearchLower ||
|
||||
artistNameNormalized === artistSearchNormalized ||
|
||||
artistNameLower.includes(artistSearchLower) ||
|
||||
artistSearchLower.includes(artistNameLower) ||
|
||||
|
|
@ -373,7 +378,10 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
|
|||
artistSearchNormalized.includes(artistNameNormalized)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
logger.music('debug', `Creating synthetic album from single: "${matchingSong.attributes?.name}"`)
|
||||
logger.music(
|
||||
'debug',
|
||||
`Creating synthetic album from single: "${matchingSong.attributes?.name}"`
|
||||
)
|
||||
return {
|
||||
id: `single-${matchingSong.id}`,
|
||||
type: 'albums' as const,
|
||||
|
|
@ -449,11 +460,13 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
|||
if ((attributes as any).isSingle && (attributes as any)._singleSongPreview) {
|
||||
logger.music('debug', 'Processing synthetic single album')
|
||||
previewUrl = (attributes as any)._singleSongPreview
|
||||
tracks = [{
|
||||
name: attributes.name,
|
||||
previewUrl: (attributes as any)._singleSongPreview,
|
||||
durationMs: undefined // We'd need to fetch the song details for duration
|
||||
}]
|
||||
tracks = [
|
||||
{
|
||||
name: attributes.name,
|
||||
previewUrl: (attributes as any)._singleSongPreview,
|
||||
durationMs: undefined // We'd need to fetch the song details for duration
|
||||
}
|
||||
]
|
||||
}
|
||||
// Always fetch tracks to get preview URLs
|
||||
else if (appleMusicAlbum.id) {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,38 @@ export interface CacheConfig {
|
|||
|
||||
export class CacheManager {
|
||||
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' }],
|
||||
['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' }]
|
||||
[
|
||||
'lastfm-recent',
|
||||
{ prefix: 'lastfm:recent:', defaultTTL: 30, description: 'Last.fm recent tracks' }
|
||||
],
|
||||
[
|
||||
'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
|
||||
}
|
||||
|
||||
|
|
@ -152,14 +181,21 @@ export class CacheManager {
|
|||
export const cache = {
|
||||
lastfm: {
|
||||
getRecent: (username: string) => CacheManager.get('lastfm-recent', username),
|
||||
setRecent: (username: string, data: string) => CacheManager.set('lastfm-recent', username, 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)
|
||||
setRecent: (username: string, data: string) =>
|
||||
CacheManager.set('lastfm-recent', username, 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: {
|
||||
getAlbum: (artist: string, album: string) => CacheManager.get('apple-album', `${artist}:${album}`),
|
||||
setAlbum: (artist: string, album: string, data: string, ttl?: number) => 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)
|
||||
getAlbum: (artist: string, album: string) =>
|
||||
CacheManager.get('apple-album', `${artist}:${album}`),
|
||||
setAlbum: (artist: string, album: string, data: string, ttl?: number) =>
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -167,17 +167,17 @@ export async function uploadFile(
|
|||
const isVideo = file.type.startsWith('video/')
|
||||
const thumbnailUrl = isVideo
|
||||
? cloudinary.url(result.public_id + '.jpg', {
|
||||
resource_type: 'video',
|
||||
transformation: [
|
||||
{ width: 1920, crop: 'scale', quality: 'auto:good' }, // 'scale' maintains aspect ratio
|
||||
{ start_offset: 'auto' } // Let Cloudinary pick the most interesting frame
|
||||
],
|
||||
secure: true
|
||||
})
|
||||
resource_type: 'video',
|
||||
transformation: [
|
||||
{ width: 1920, crop: 'scale', quality: 'auto:good' }, // 'scale' maintains aspect ratio
|
||||
{ start_offset: 'auto' } // Let Cloudinary pick the most interesting frame
|
||||
],
|
||||
secure: true
|
||||
})
|
||||
: cloudinary.url(result.public_id, {
|
||||
...imageSizes.thumbnail,
|
||||
secure: true
|
||||
})
|
||||
...imageSizes.thumbnail,
|
||||
secure: true
|
||||
})
|
||||
|
||||
// Extract dominant color using smart selection
|
||||
let dominantColor: string | undefined
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ export async function uploadFileLocally(
|
|||
}
|
||||
|
||||
// Extract video metadata
|
||||
const videoStream = metadata.streams.find(s => s.codec_type === 'video')
|
||||
const audioStream = metadata.streams.find(s => s.codec_type === 'audio')
|
||||
const videoStream = metadata.streams.find((s) => s.codec_type === 'video')
|
||||
const audioStream = metadata.streams.find((s) => s.codec_type === 'audio')
|
||||
|
||||
if (videoStream) {
|
||||
width = videoStream.width || 0
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function createMusicStream() {
|
|||
nowPlaying: nowPlayingAlbum
|
||||
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
|
||||
: 'none',
|
||||
albums: albums.map(a => ({
|
||||
albums: albums.map((a) => ({
|
||||
name: a.name,
|
||||
artist: a.artist.name,
|
||||
isNowPlaying: a.isNowPlaying,
|
||||
|
|
@ -144,11 +144,13 @@ function createMusicStream() {
|
|||
albums: derived({ subscribe }, ($state) => $state.albums) as Readable<Album[]>,
|
||||
// Helper to check if any album is playing
|
||||
nowPlaying: derived({ subscribe }, ($state) => {
|
||||
const playing = $state.albums.find(a => a.isNowPlaying)
|
||||
return playing ? {
|
||||
album: playing,
|
||||
track: playing.nowPlayingTrack
|
||||
} : null
|
||||
const playing = $state.albums.find((a) => a.isNowPlaying)
|
||||
return playing
|
||||
? {
|
||||
album: playing,
|
||||
track: playing.nowPlayingTrack
|
||||
}
|
||||
: null
|
||||
}) as Readable<{ album: Album; track?: string } | null>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ export function createProjectFormStore(initialProject?: Project | null) {
|
|||
let original = $state<ProjectFormData | null>(null)
|
||||
|
||||
// Derived state using $derived rune
|
||||
const isDirty = $derived(
|
||||
original ? JSON.stringify(fields) !== JSON.stringify(original) : false
|
||||
)
|
||||
const isDirty = $derived(original ? JSON.stringify(fields) !== JSON.stringify(original) : false)
|
||||
|
||||
// Initialize from project if provided
|
||||
if (initialProject) {
|
||||
|
|
@ -96,7 +94,8 @@ export function createProjectFormStore(initialProject?: Project | null) {
|
|||
role: fields.role,
|
||||
projectType: fields.projectType,
|
||||
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,
|
||||
backgroundColor: fields.backgroundColor,
|
||||
highlightColor: fields.highlightColor,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,10 @@ export class LastfmStreamManager {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
update.nowPlayingUpdates = nowPlayingUpdates
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,13 +163,19 @@ export class NowPlayingDetector {
|
|||
for (const track of tracks) {
|
||||
if (track.nowPlaying) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -230,8 +236,11 @@ export class NowPlayingDetector {
|
|||
this.updateRecentTracks(newRecentTracks)
|
||||
|
||||
// Log summary
|
||||
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`)
|
||||
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`
|
||||
)
|
||||
|
||||
// Ensure only one album is marked as now playing
|
||||
return this.ensureSingleNowPlaying(albums, newRecentTracks)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ export class SimpleLastfmStreamManager {
|
|||
limit: 50,
|
||||
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
|
||||
await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse)
|
||||
|
|
@ -64,7 +67,7 @@ export class SimpleLastfmStreamManager {
|
|||
|
||||
// Check if anything changed
|
||||
const currentState = JSON.stringify(
|
||||
enrichedAlbums.map(a => ({
|
||||
enrichedAlbums.map((a) => ({
|
||||
key: `${a.artist.name}:${a.name}`,
|
||||
isNowPlaying: a.isNowPlaying,
|
||||
track: a.nowPlayingTrack
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ export class SimpleNowPlayingDetector {
|
|||
|
||||
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
|
||||
return isPlaying
|
||||
|
|
@ -30,16 +33,22 @@ export class SimpleNowPlayingDetector {
|
|||
recentTracks: any[],
|
||||
appleMusicDataLookup: (artistName: string, albumName: string) => Promise<any>
|
||||
): 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
|
||||
const officialNowPlaying = recentTracks.find(track => track.nowPlaying)
|
||||
const officialNowPlaying = recentTracks.find((track) => track.nowPlaying)
|
||||
|
||||
if (officialNowPlaying) {
|
||||
// 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,
|
||||
isNowPlaying:
|
||||
album.name === officialNowPlaying.album.name &&
|
||||
|
|
@ -74,14 +83,17 @@ export class SimpleNowPlayingDetector {
|
|||
if (!mostRecentTrack) {
|
||||
// No recent tracks, nothing is playing
|
||||
logger.music('debug', '❌ No recent tracks found, nothing is playing')
|
||||
return albums.map(album => ({
|
||||
return albums.map((album) => ({
|
||||
...album,
|
||||
isNowPlaying: false,
|
||||
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}`)
|
||||
|
||||
// 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}"`)
|
||||
// Fallback: assume track is playing if scrobbled within last 5 minutes
|
||||
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
|
||||
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes
|
||||
if (timeSinceScrobble < 5 * 60 * 1000) {
|
||||
// 5 minutes
|
||||
isPlaying = true
|
||||
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) {
|
||||
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
|
||||
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
|
||||
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes
|
||||
if (timeSinceScrobble < 5 * 60 * 1000) {
|
||||
// 5 minutes
|
||||
isPlaying = true
|
||||
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
|
||||
return albums.map(album => {
|
||||
return albums.map((album) => {
|
||||
const key = `${album.artist.name}:${album.name}`
|
||||
const isThisAlbumPlaying = isPlaying && key === albumKey
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/stores'
|
||||
</script>
|
||||
|
||||
<div class="error-container">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { fail, redirect } from '@sveltejs/kit'
|
||||
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 }) => {
|
||||
// Ensure we start with a clean session when hitting the login page
|
||||
|
|
|
|||
|
|
@ -361,213 +361,207 @@
|
|||
</AdminHeader>
|
||||
|
||||
<!-- Filters -->
|
||||
<AdminFilters>
|
||||
{#snippet left()}
|
||||
<Select
|
||||
value={filterType}
|
||||
options={typeFilterOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => handleTypeFilterChange((e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
<Select
|
||||
value={publishedFilter}
|
||||
options={publishedFilterOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => handlePublishedFilterChange((e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<Select
|
||||
value={sortBy}
|
||||
options={sortOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => handleSortChange((e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search files..."
|
||||
buttonSize="small"
|
||||
fullWidth={false}
|
||||
pill={true}
|
||||
prefixIcon
|
||||
<AdminFilters>
|
||||
{#snippet left()}
|
||||
<Select
|
||||
value={filterType}
|
||||
options={typeFilterOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => handleTypeFilterChange((e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
<Select
|
||||
value={publishedFilter}
|
||||
options={publishedFilterOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => handlePublishedFilterChange((e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<Select
|
||||
value={sortBy}
|
||||
options={sortOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => handleSortChange((e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search files..."
|
||||
buttonSize="small"
|
||||
fullWidth={false}
|
||||
pill={true}
|
||||
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
|
||||
slot="prefix"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
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>
|
||||
<path
|
||||
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}
|
||||
<div class="bulk-actions">
|
||||
<div class="bulk-actions-left">
|
||||
<button
|
||||
onclick={selectAllMedia}
|
||||
class="btn btn-secondary btn-small"
|
||||
disabled={selectedMediaIds.size === media.length}
|
||||
>
|
||||
Select All ({media.length})
|
||||
</button>
|
||||
<button
|
||||
onclick={clearSelection}
|
||||
class="btn btn-secondary btn-small"
|
||||
disabled={selectedMediaIds.size === 0}
|
||||
>
|
||||
Clear Selection
|
||||
</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>
|
||||
{#if isMultiSelectMode && media.length > 0}
|
||||
<div class="bulk-actions">
|
||||
<div class="bulk-actions-left">
|
||||
<button
|
||||
onclick={selectAllMedia}
|
||||
class="btn btn-secondary btn-small"
|
||||
disabled={selectedMediaIds.size === media.length}
|
||||
>
|
||||
Select All ({media.length})
|
||||
</button>
|
||||
<button
|
||||
onclick={clearSelection}
|
||||
class="btn btn-secondary btn-small"
|
||||
disabled={selectedMediaIds.size === 0}
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
</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}
|
||||
<EmptyState title="No media files found" message="Upload your first file to get started.">
|
||||
{#snippet action()}
|
||||
<Button variant="primary" onclick={openUploadModal}>Upload your first file</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="media-grid">
|
||||
{#each media as item}
|
||||
<div class="media-item-wrapper" class:multiselect={isMultiSelectMode}>
|
||||
{#if isMultiSelectMode}
|
||||
<div class="selection-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMediaIds.has(item.id)}
|
||||
onchange={() => toggleMediaSelection(item.id)}
|
||||
id="media-{item.id}"
|
||||
/>
|
||||
<label for="media-{item.id}" class="checkbox-label"></label>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="media-item"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
|
||||
title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
|
||||
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
|
||||
>
|
||||
{#if item.mimeType.startsWith('image/')}
|
||||
<img
|
||||
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
|
||||
alt={item.description || item.filename}
|
||||
/>
|
||||
{:else if isVideoFile(item.mimeType)}
|
||||
{#if item.thumbnailUrl}
|
||||
<div class="video-thumbnail-wrapper">
|
||||
<img src={item.thumbnailUrl} alt={item.description || item.filename} />
|
||||
<div class="video-overlay">
|
||||
<PlayIcon class="play-icon" />
|
||||
</div>
|
||||
{#if media.length === 0}
|
||||
<EmptyState title="No media files found" message="Upload your first file to get started.">
|
||||
{#snippet action()}
|
||||
<Button variant="primary" onclick={openUploadModal}>Upload your first file</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="media-grid">
|
||||
{#each media as item}
|
||||
<div class="media-item-wrapper" class:multiselect={isMultiSelectMode}>
|
||||
{#if isMultiSelectMode}
|
||||
<div class="selection-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMediaIds.has(item.id)}
|
||||
onchange={() => toggleMediaSelection(item.id)}
|
||||
id="media-{item.id}"
|
||||
/>
|
||||
<label for="media-{item.id}" class="checkbox-label"></label>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="media-item"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
|
||||
title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
|
||||
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
|
||||
>
|
||||
{#if item.mimeType.startsWith('image/')}
|
||||
<img
|
||||
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
|
||||
alt={item.description || item.filename}
|
||||
/>
|
||||
{:else if isVideoFile(item.mimeType)}
|
||||
{#if item.thumbnailUrl}
|
||||
<div class="video-thumbnail-wrapper">
|
||||
<img src={item.thumbnailUrl} alt={item.description || item.filename} />
|
||||
<div class="video-overlay">
|
||||
<PlayIcon class="play-icon" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="file-placeholder video-placeholder">
|
||||
<PlayIcon class="video-icon" />
|
||||
<span class="file-type">Video</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
<span class="file-type">{getFileType(item.mimeType)}</span>
|
||||
<div class="file-placeholder video-placeholder">
|
||||
<PlayIcon class="video-icon" />
|
||||
<span class="file-type">Video</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="media-info">
|
||||
<span class="filename">{item.filename}</span>
|
||||
<div class="media-info-bottom">
|
||||
<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>
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
<span class="file-type">{getFileType(item.mimeType)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="media-info">
|
||||
<span class="filename">{item.filename}</span>
|
||||
<div class="media-info-bottom">
|
||||
<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}
|
||||
<div class="pagination">
|
||||
<button
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
class="pagination-btn"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="pagination-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
class="pagination-btn"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if totalPages > 1}
|
||||
<div class="pagination">
|
||||
<button
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
class="pagination-btn"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="pagination-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
class="pagination-btn"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</AdminPage>
|
||||
|
||||
<!-- Media Details Modal -->
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@
|
|||
let showCleanupModal = $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 selectedSize = $derived(
|
||||
auditData?.orphanedFiles
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@
|
|||
|
||||
function addFiles(newFiles: File[]) {
|
||||
// Filter for supported file types (images and videos)
|
||||
const supportedFiles = newFiles.filter((file) =>
|
||||
file.type.startsWith('image/') || file.type.startsWith('video/')
|
||||
const supportedFiles = newFiles.filter(
|
||||
(file) => file.type.startsWith('image/') || file.type.startsWith('video/')
|
||||
)
|
||||
|
||||
if (supportedFiles.length !== newFiles.length) {
|
||||
|
|
@ -305,7 +305,9 @@
|
|||
</div>
|
||||
<h3>Drop media files here</h3>
|
||||
<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}
|
||||
<div class="compact-content">
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -14,31 +14,31 @@
|
|||
import type { PageData } from './$types'
|
||||
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 showDeleteConfirmation = false
|
||||
let postToDelete: AdminPost | null = null
|
||||
let showInlineComposer = true
|
||||
let showDeleteConfirmation = false
|
||||
let postToDelete: AdminPost | null = null
|
||||
|
||||
const actionError = form?.message ?? ''
|
||||
const posts = data.items ?? []
|
||||
const actionError = form?.message ?? ''
|
||||
const posts = data.items ?? []
|
||||
|
||||
// Create reactive filters
|
||||
const filters = createListFilters(posts, {
|
||||
filters: {
|
||||
type: { field: 'postType', default: 'all' },
|
||||
status: { field: 'status', default: 'all' }
|
||||
},
|
||||
sorts: {
|
||||
newest: commonSorts.dateDesc<AdminPost>('createdAt'),
|
||||
oldest: commonSorts.dateAsc<AdminPost>('createdAt'),
|
||||
'title-asc': commonSorts.stringAsc<AdminPost>('title'),
|
||||
'title-desc': commonSorts.stringDesc<AdminPost>('title'),
|
||||
'status-published': commonSorts.statusPublishedFirst<AdminPost>('status'),
|
||||
'status-draft': commonSorts.statusDraftFirst<AdminPost>('status')
|
||||
},
|
||||
defaultSort: 'newest'
|
||||
})
|
||||
// Create reactive filters
|
||||
const filters = createListFilters(posts, {
|
||||
filters: {
|
||||
type: { field: 'postType', default: 'all' },
|
||||
status: { field: 'status', default: 'all' }
|
||||
},
|
||||
sorts: {
|
||||
newest: commonSorts.dateDesc<AdminPost>('createdAt'),
|
||||
oldest: commonSorts.dateAsc<AdminPost>('createdAt'),
|
||||
'title-asc': commonSorts.stringAsc<AdminPost>('title'),
|
||||
'title-desc': commonSorts.stringDesc<AdminPost>('title'),
|
||||
'status-published': commonSorts.statusPublishedFirst<AdminPost>('status'),
|
||||
'status-draft': commonSorts.statusDraftFirst<AdminPost>('status')
|
||||
},
|
||||
defaultSort: 'newest'
|
||||
})
|
||||
|
||||
let toggleForm: HTMLFormElement | null = null
|
||||
let toggleIdField: HTMLInputElement | null = null
|
||||
|
|
@ -48,17 +48,17 @@ const filters = createListFilters(posts, {
|
|||
let deleteForm: HTMLFormElement | null = null
|
||||
let deleteIdField: HTMLInputElement | null = null
|
||||
|
||||
const typeFilterOptions = [
|
||||
{ value: 'all', label: 'All posts' },
|
||||
{ value: 'post', label: 'Posts' },
|
||||
{ value: 'essay', label: 'Essays' }
|
||||
]
|
||||
const typeFilterOptions = [
|
||||
{ value: 'all', label: 'All posts' },
|
||||
{ value: 'post', label: 'Posts' },
|
||||
{ value: 'essay', label: 'Essays' }
|
||||
]
|
||||
|
||||
const statusFilterOptions = [
|
||||
const statusFilterOptions = [
|
||||
{ value: 'all', label: 'All statuses' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'draft', label: 'Draft' }
|
||||
]
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'newest', label: 'Newest first' },
|
||||
|
|
@ -120,9 +120,7 @@ const statusFilterOptions = [
|
|||
<AdminPage>
|
||||
<AdminHeader title="Universe" slot="header">
|
||||
{#snippet actions()}
|
||||
<Button variant="primary" buttonSize="medium" onclick={handleNewEssay}>
|
||||
New Essay
|
||||
</Button>
|
||||
<Button variant="primary" buttonSize="medium" onclick={handleNewEssay}>New Essay</Button>
|
||||
{/snippet}
|
||||
</AdminHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { goto, beforeNavigate } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '$lib/admin/api'
|
||||
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '$lib/admin/api'
|
||||
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Composer from '$lib/components/admin/composer'
|
||||
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 showMetadata = $state(false)
|
||||
let metadataButtonRef: HTMLButtonElement
|
||||
let showDeleteConfirmation = $state(false)
|
||||
let showDeleteConfirmation = $state(false)
|
||||
|
||||
// Draft backup
|
||||
const draftKey = $derived(makeDraftKey('post', $page.params.id))
|
||||
let showDraftPrompt = $state(false)
|
||||
let draftTimestamp = $state<number | null>(null)
|
||||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
||||
// Draft backup
|
||||
const draftKey = $derived(makeDraftKey('post', $page.params.id))
|
||||
let showDraftPrompt = $state(false)
|
||||
let draftTimestamp = $state<number | null>(null)
|
||||
let timeTicker = $state(0)
|
||||
const draftTimeText = $derived.by(() =>
|
||||
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
|
||||
)
|
||||
|
||||
const postTypeConfig = {
|
||||
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
|
||||
|
|
@ -183,16 +185,16 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
|
|||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Wait a tick to ensure page params are loaded
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await loadPost()
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
}
|
||||
})
|
||||
onMount(async () => {
|
||||
// Wait a tick to ensure page params are loaded
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await loadPost()
|
||||
const draft = loadDraft<any>(draftKey)
|
||||
if (draft) {
|
||||
showDraftPrompt = true
|
||||
draftTimestamp = draft.ts
|
||||
}
|
||||
})
|
||||
|
||||
async function loadPost() {
|
||||
const postId = $page.params.id
|
||||
|
|
@ -243,7 +245,7 @@ onMount(async () => {
|
|||
hasLoaded = true
|
||||
} else {
|
||||
// Fallback error messaging
|
||||
loadError = 'Post not found'
|
||||
loadError = 'Post not found'
|
||||
}
|
||||
} catch (error) {
|
||||
loadError = 'Network error occurred while loading post'
|
||||
|
|
@ -353,7 +355,13 @@ onMount(async () => {
|
|||
// Trigger autosave when form data changes
|
||||
$effect(() => {
|
||||
// Establish dependencies
|
||||
title; slug; status; content; tags; excerpt; postType
|
||||
title
|
||||
slug
|
||||
status
|
||||
content
|
||||
tags
|
||||
excerpt
|
||||
postType
|
||||
if (hasLoaded) {
|
||||
autoSave.schedule()
|
||||
}
|
||||
|
|
@ -433,13 +441,13 @@ onMount(async () => {
|
|||
return () => autoSave.destroy()
|
||||
})
|
||||
|
||||
// Auto-update draft time text every minute when prompt visible
|
||||
$effect(() => {
|
||||
if (showDraftPrompt) {
|
||||
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
|
||||
return () => clearInterval(id)
|
||||
}
|
||||
})
|
||||
// Auto-update draft time text every minute when prompt visible
|
||||
$effect(() => {
|
||||
if (showDraftPrompt) {
|
||||
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
|
||||
return () => clearInterval(id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -521,7 +529,8 @@ $effect(() => {
|
|||
<div class="draft-banner">
|
||||
<div class="draft-banner-content">
|
||||
<span class="draft-banner-text">
|
||||
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
|
||||
Unsaved draft found{#if draftTimeText}
|
||||
(saved {draftTimeText}){/if}.
|
||||
</span>
|
||||
<div class="draft-banner-actions">
|
||||
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { api } from '$lib/admin/api'
|
||||
import { goto } from '$app/navigation'
|
||||
import { api } from '$lib/admin/api'
|
||||
import { onMount } from 'svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Composer from '$lib/components/admin/composer'
|
||||
|
|
|
|||
|
|
@ -114,11 +114,7 @@
|
|||
<AdminPage>
|
||||
<AdminHeader title="Work" slot="header">
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
variant="primary"
|
||||
buttonSize="medium"
|
||||
onclick={() => goto('/admin/projects/new')}
|
||||
>
|
||||
<Button variant="primary" buttonSize="medium" onclick={() => goto('/admin/projects/new')}>
|
||||
New project
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
|
@ -126,20 +122,20 @@
|
|||
|
||||
<AdminFilters>
|
||||
{#snippet left()}
|
||||
<Select
|
||||
value={filters.values.type}
|
||||
options={typeFilterOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => filters.set('type', (e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
<Select
|
||||
value={filters.values.status}
|
||||
options={statusFilterOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => filters.set('status', (e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
<Select
|
||||
value={filters.values.type}
|
||||
options={typeFilterOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => filters.set('type', (e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
<Select
|
||||
value={filters.values.status}
|
||||
options={statusFilterOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={(e) => filters.set('status', (e.target as HTMLSelectElement).value)}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
|
||||
import type { Project } from '$lib/types/project'
|
||||
import { api } from '$lib/admin/api'
|
||||
import { page } from '$app/stores'
|
||||
import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
|
||||
import type { Project } from '$lib/types/project'
|
||||
import { api } from '$lib/admin/api'
|
||||
|
||||
let project = $state<Project | null>(null)
|
||||
let isLoading = $state(true)
|
||||
|
|
|
|||
|
|
@ -23,11 +23,14 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
})
|
||||
} catch (error) {
|
||||
console.error('Apple Music search error:', error)
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -36,13 +36,16 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
deleted = await redis.del(key)
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
deleted,
|
||||
key: key || pattern
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
deleted,
|
||||
key: key || pattern
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear cache:', error as Error)
|
||||
return new Response('Internal server error', { status: 500 })
|
||||
|
|
|
|||
|
|
@ -27,13 +27,16 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||
})
|
||||
)
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
total: keys.length,
|
||||
showing: keysWithValues.length,
|
||||
keys: keysWithValues
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
total: keys.length,
|
||||
showing: keysWithValues.length,
|
||||
keys: keysWithValues
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to get Redis keys:', error)
|
||||
return new Response('Internal server error', { status: 500 })
|
||||
|
|
|
|||
|
|
@ -14,20 +14,26 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||
|
||||
try {
|
||||
const result = await findAlbum(artist, album)
|
||||
return new Response(JSON.stringify({
|
||||
artist,
|
||||
album,
|
||||
found: !!result,
|
||||
result
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
artist,
|
||||
album,
|
||||
found: !!result,
|
||||
result
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,36 +20,45 @@ export const GET: RequestHandler = async () => {
|
|||
const jpSongs = jpResults.results?.songs?.data || []
|
||||
const usSongs = usResults.results?.songs?.data || []
|
||||
|
||||
const hachiko = [...jpSongs, ...usSongs].find(s =>
|
||||
s.attributes?.name?.toLowerCase() === 'hachikō' &&
|
||||
s.attributes?.artistName?.includes('藤井')
|
||||
const hachiko = [...jpSongs, ...usSongs].find(
|
||||
(s) =>
|
||||
s.attributes?.name?.toLowerCase() === 'hachikō' &&
|
||||
s.attributes?.artistName?.includes('藤井')
|
||||
)
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
searchQuery,
|
||||
jpSongsFound: jpSongs.length,
|
||||
usSongsFound: usSongs.length,
|
||||
hachikoFound: !!hachiko,
|
||||
hachikoDetails: hachiko ? {
|
||||
name: hachiko.attributes?.name,
|
||||
artist: hachiko.attributes?.artistName,
|
||||
album: hachiko.attributes?.albumName,
|
||||
preview: hachiko.attributes?.previews?.[0]?.url
|
||||
} : null,
|
||||
allSongs: [...jpSongs, ...usSongs].map(s => ({
|
||||
name: s.attributes?.name,
|
||||
artist: s.attributes?.artistName,
|
||||
album: s.attributes?.albumName
|
||||
}))
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
searchQuery,
|
||||
jpSongsFound: jpSongs.length,
|
||||
usSongsFound: usSongs.length,
|
||||
hachikoFound: !!hachiko,
|
||||
hachikoDetails: hachiko
|
||||
? {
|
||||
name: hachiko.attributes?.name,
|
||||
artist: hachiko.attributes?.artistName,
|
||||
album: hachiko.attributes?.albumName,
|
||||
preview: hachiko.attributes?.previews?.[0]?.url
|
||||
}
|
||||
: null,
|
||||
allSongs: [...jpSongs, ...usSongs].map((s) => ({
|
||||
name: s.attributes?.name,
|
||||
artist: s.attributes?.artistName,
|
||||
album: s.attributes?.albumName
|
||||
}))
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -265,7 +265,12 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
|
|||
searchMetadata,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const GET: RequestHandler = async ({ request }) => {
|
|||
let remainingMs = 0
|
||||
if (nowPlayingAlbum?.nowPlayingTrack && nowPlayingAlbum.appleMusicData?.tracks) {
|
||||
const track = nowPlayingAlbum.appleMusicData.tracks.find(
|
||||
t => t.name === nowPlayingAlbum.nowPlayingTrack
|
||||
(t) => t.name === nowPlayingAlbum.nowPlayingTrack
|
||||
)
|
||||
|
||||
if (track?.durationMs && nowPlayingAlbum.lastScrobbleTime) {
|
||||
|
|
@ -68,7 +68,7 @@ export const GET: RequestHandler = async ({ request }) => {
|
|||
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
|
||||
: 'none',
|
||||
remainingMs: remainingMs,
|
||||
albumsWithStatus: update.albums.map(a => ({
|
||||
albumsWithStatus: update.albums.map((a) => ({
|
||||
name: a.name,
|
||||
artist: a.artist.name,
|
||||
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)
|
||||
if (Math.abs(targetInterval - currentInterval) > 1000) {
|
||||
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
|
||||
if (intervalId) {
|
||||
|
|
|
|||
|
|
@ -139,7 +139,10 @@ export const POST: RequestHandler = async (event) => {
|
|||
const allowedTypes = [...allowedImageTypes, ...allowedVideoTypes]
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -153,56 +153,57 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// PATCH /api/posts/[id] - Partially update a post
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid post ID', 400)
|
||||
}
|
||||
try {
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid post ID', 400)
|
||||
}
|
||||
|
||||
const data = await event.request.json()
|
||||
const data = await event.request.json()
|
||||
|
||||
// Check for existence and concurrency
|
||||
const existing = await prisma.post.findUnique({ where: { id } })
|
||||
if (!existing) return errorResponse('Post not found', 404)
|
||||
if (data.updatedAt) {
|
||||
const incoming = new Date(data.updatedAt)
|
||||
if (existing.updatedAt.getTime() !== incoming.getTime()) {
|
||||
return errorResponse('Conflict: post has changed', 409)
|
||||
}
|
||||
}
|
||||
// Check for existence and concurrency
|
||||
const existing = await prisma.post.findUnique({ where: { id } })
|
||||
if (!existing) return errorResponse('Post not found', 404)
|
||||
if (data.updatedAt) {
|
||||
const incoming = new Date(data.updatedAt)
|
||||
if (existing.updatedAt.getTime() !== incoming.getTime()) {
|
||||
return errorResponse('Conflict: post has changed', 409)
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
const updateData: any = {}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
updateData.status = data.status
|
||||
if (data.status === 'published' && !existing.publishedAt) {
|
||||
updateData.publishedAt = new Date()
|
||||
} else if (data.status === 'draft') {
|
||||
updateData.publishedAt = null
|
||||
}
|
||||
}
|
||||
if (data.title !== undefined) updateData.title = data.title
|
||||
if (data.slug !== undefined) updateData.slug = data.slug
|
||||
if (data.type !== undefined) updateData.postType = data.type
|
||||
if (data.content !== undefined) updateData.content = data.content
|
||||
if (data.featuredImage !== undefined) updateData.featuredImage = data.featuredImage
|
||||
if (data.attachedPhotos !== undefined)
|
||||
updateData.attachments = data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null
|
||||
if (data.tags !== undefined) updateData.tags = data.tags
|
||||
if (data.publishedAt !== undefined) updateData.publishedAt = data.publishedAt
|
||||
if (data.status !== undefined) {
|
||||
updateData.status = data.status
|
||||
if (data.status === 'published' && !existing.publishedAt) {
|
||||
updateData.publishedAt = new Date()
|
||||
} else if (data.status === 'draft') {
|
||||
updateData.publishedAt = null
|
||||
}
|
||||
}
|
||||
if (data.title !== undefined) updateData.title = data.title
|
||||
if (data.slug !== undefined) updateData.slug = data.slug
|
||||
if (data.type !== undefined) updateData.postType = data.type
|
||||
if (data.content !== undefined) updateData.content = data.content
|
||||
if (data.featuredImage !== undefined) updateData.featuredImage = data.featuredImage
|
||||
if (data.attachedPhotos !== undefined)
|
||||
updateData.attachments =
|
||||
data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null
|
||||
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) })
|
||||
return jsonResponse(post)
|
||||
} catch (error) {
|
||||
logger.error('Failed to partially update post', error as Error)
|
||||
return errorResponse('Failed to update post', 500)
|
||||
}
|
||||
logger.info('Post partially updated', { id: post.id, fields: Object.keys(updateData) })
|
||||
return jsonResponse(post)
|
||||
} catch (error) {
|
||||
logger.error('Failed to partially update post', error as Error)
|
||||
return errorResponse('Failed to update post', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/posts/[id] - Delete a post
|
||||
|
|
|
|||
|
|
@ -320,7 +320,10 @@
|
|||
if (isMobile) {
|
||||
const viewport = document.querySelector('meta[name="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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,8 @@ export const GET: RequestHandler = async (event) => {
|
|||
section: 'universe',
|
||||
id: post.id.toString(),
|
||||
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',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
|
|
@ -177,13 +178,13 @@ ${
|
|||
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}"/>
|
||||
<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"/>`
|
||||
: item.type === 'post' && item.featuredImage
|
||||
? `
|
||||
? `
|
||||
<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"/>`
|
||||
: ''
|
||||
: ''
|
||||
}
|
||||
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
|
||||
<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',
|
||||
'Cache-Control': 'public, max-age=300, s-maxage=600, stale-while-revalidate=86400',
|
||||
'Last-Modified': lastBuildDate,
|
||||
'ETag': etag,
|
||||
ETag: etag,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Vary': 'Accept-Encoding',
|
||||
Vary: 'Accept-Encoding',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
|
|
|
|||
Loading…
Reference in a new issue