also looking at this
UI polish, real queue + scheduling, Bluesky threads, live analytics, blog target
#8
54 files
+2036
-589
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -36,7 +36,7 @@ function host(): HTMLElement { | ||
| 36 | 36 | if (!element) { |
| 37 | 37 | element = document.createElement('div') |
| 38 | 38 | element.id = 'postline-toast-host' |
| 39 | element.className = 'pointer-events-none fixed bottom-6 right-6 z-[100] flex w-[calc(100%-2rem)] max-w-[22rem] flex-col-reverse gap-2.5 sm:right-6' | |
| 39 | element.className = 'flex bottom-6 fixed right-6 z-[100] sm:right-6 flex-col-reverse gap-2.5 max-w-[22rem] w-[calc(100%-2rem)] pointer-events-none' | |
| 40 | 40 | element.setAttribute('aria-live', 'polite') |
| 41 | 41 | element.setAttribute('aria-relevant', 'additions') |
| 42 | 42 | document.body.append(element) |
| @@ -95,7 +95,7 @@ export function showPostlineToast(options: PostlineToastOptions): void { formatting only | ||
| 95 | 95 | |
| 96 | 96 | const bodyMarkup = options.url |
| 97 | 97 | ? `<p class="postline-toast__message">${escapeHtml(options.message)}</p> |
| 98 | <a class="postline-toast__url" href="${escapeHtml(options.url)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(options.url)}">${escapeHtml(formatUrlLabel(options.url))}</a>` | |
| 98 | <a class="postline-toast__url" href="${escapeHtml(options.url)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(options.url)}">${escapeHtml(formatUrlLabel(options.url))}</a>` | |
| 99 | 99 | : `<p class="postline-toast__message">${escapeHtml(options.message)}</p>` |
| 100 | 100 | |
| 101 | 101 | toast.innerHTML = ` |
| @@ -276,9 +276,9 @@ async function postHtml(bp: BunPress, slug: string, origin: string): Promise<str | ||
| 276 | 276 | |
| 277 | 277 | const authorCard = fm.authorBio |
| 278 | 278 | ? `<aside class="blog-author-card"> |
| 279 | <div class="avatar">${initial}</div> | |
| 280 | <div><span class="name">${escapeHtml(fm.author || 'The Stacks Team')}</span><p class="bio">${escapeHtml(fm.authorBio)}</p></div> | |
| 281 | </aside>` | |
| 279 | <div class="avatar">${initial}</div> | |
| 280 | <div><span class="name">${escapeHtml(fm.author || 'The Stacks Team')}</span><p class="bio">${escapeHtml(fm.authorBio)}</p></div> | |
| 281 | </aside>` | |
| 282 | 282 | : '' |
| 283 | 283 | |
| 284 | 284 | const postUrl = `${origin}/blog/${slug}` |
| @@ -292,9 +292,9 @@ async function postHtml(bp: BunPress, slug: string, origin: string): Promise<str | ||
| 292 | 292 | const others = listPosts().filter(p => p.slug !== slug).slice(0, 3) |
| 293 | 293 | const more = others.length |
| 294 | 294 | ? `<nav class="blog-more"> |
| 295 | <h2>More from the blog</h2> | |
| 296 | <ul>${others.map(p => `<li><a href="/blog/${escapeHtml(p.slug)}">${escapeHtml(p.fm.title || p.slug)}</a></li>`).join('')}</ul> | |
| 297 | </nav>` | |
| 295 | <h2>More from the blog</h2> | |
| 296 | <ul>${others.map(p => `<li><a href="/blog/${escapeHtml(p.slug)}">${escapeHtml(p.fm.title || p.slug)}</a></li>`).join('')}</ul> | |
| 297 | </nav>` | |
| 298 | 298 | : '' |
| 299 | 299 | |
| 300 | 300 | return bp.wrapInLayout(blogChrome() + header + html + authorCard + share + more + blogFooter(), blogConfig(bp, fm), `/blog/${slug}`, 'page') |
| @@ -332,10 +332,10 @@ async function getPostgresLiveFKs(db: any): Promise<LiveFK[]> { | ||
| 332 | 332 | FROM information_schema.referential_constraints rc |
| 333 | 333 | JOIN information_schema.key_column_usage kcu |
| 334 | 334 | ON kcu.constraint_name = rc.constraint_name |
| 335 | AND kcu.constraint_schema = rc.constraint_schema | |
| 335 | AND kcu.constraint_schema = rc.constraint_schema | |
| 336 | 336 | JOIN information_schema.constraint_column_usage ccu |
| 337 | 337 | ON ccu.constraint_name = rc.constraint_name |
| 338 | AND ccu.constraint_schema = rc.constraint_schema | |
| 338 | AND ccu.constraint_schema = rc.constraint_schema | |
| 339 | 339 | WHERE rc.constraint_schema = 'public' |
| 340 | 340 | `) |
| 341 | 341 | return (Array.isArray(rows) ? rows : []).map((row: any) => ({ |
| @@ -111,7 +111,7 @@ EMAILJS*HOST=example.com | ||
| 111 | 111 | EMAILJS*USERNAME=username |
| 112 | 112 | EMAILJS*PASSWORD=password |
| 113 | 113 | EMAILJS*PORT=25 |
| 114 | EMAILJS_SECURE=true | |
| 114 | EMAILJS*SECURE=true | |
| 115 | 115 | ``` |
| 116 | 116 | |
| 117 | 117 | Learn more in the docs. |
| @@ -163,7 +163,7 @@ or ignore `<style>` blocks, so styles have to ride on each element via | ||
| 163 | 163 | `style="…"`. Stacks ships a pass-through inliner that runs after |
| 164 | 164 | `renderEmail()` and before the result hands back: |
| 165 | 165 | |
| 166 | - **On by default in production** (`APP_ENV` or `NODE_ENV` is | |
| 166 | - **On by default in production** (`APP*ENV` or `NODE_ENV` is | |
| 167 | 167 | `production`). Off in dev so previews show the un-mutated stx |
| 168 | 168 | output. |
| 169 | 169 | - **Per-call opt-out** via `template(name, { inline: false })`. |
| @@ -20,7 +20,7 @@ | ||
| 20 | 20 | * - SendGrid: ECDSA over `timestamp + body` with the public key |
| 21 | 21 | * from their dashboard. P-256 curve, DER signature. |
| 22 | 22 | * |
| 23 | * Each function takes the bits it needs (request body, headers, | |
| 23 | * Each function takes the bits it needs (request body, _headers, | |
| 24 | 24 | * configured secret) and returns a discriminated `{ ok, reason? }` |
| 25 | 25 | * — the route handler maps `ok: false` to a 401/403 without |
| 26 | 26 | * touching the rest of the pipeline. |
| @@ -74,10 +74,10 @@ const inactiveLink = `${baseLink} border-gray-300 dark:border-neutral-600 text-g | ||
| 74 | 74 | const disabledLink = `${baseLink} border-gray-200 dark:border-neutral-700 text-gray-400 dark:text-neutral-500 cursor-not-allowed` |
| 75 | 75 | </script> |
| 76 | 76 | |
| 77 | <nav aria-label="Pagination" class="flex items-center justify-between gap-2 py-3 select-none"> | |
| 77 | <nav aria-label="Pagination" class="flex gap-2 items-center justify-between py-3 select-none"> | |
| 78 | 78 | @if(isFull) |
| 79 | 79 | {{-- Mobile: prev / next only (page numbers hidden) --}} |
| 80 | <div class="flex flex-1 justify-between sm:hidden"> | |
| 80 | <div class="flex sm:hidden flex-1 justify-between"> | |
| 81 | 81 | @if(paginator.prev_page_url) |
| 82 | 82 | <a href="" class=" rounded-md">Previous</a> |
| 83 | 83 | @else |
| @@ -92,7 +92,7 @@ const disabledLink = `${baseLink} border-gray-200 dark:border-neutral-700 text-g | ||
| 92 | 92 | |
| 93 | 93 | {{-- Desktop: prev / page numbers / next + "Showing X to Y of Z" --}} |
| 94 | 94 | <div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between"> |
| 95 | <p class="text-sm text-gray-700 dark:text-neutral-300"> | |
| 95 | <p class="text-gray-700 text-sm dark:text-neutral-300"> | |
| 96 | 96 | @if(paginator.from && paginator.to) |
| 97 | 97 | Showing <span class="font-medium">{{ paginator.from }}</span> |
| 98 | 98 | to <span class="font-medium">{{ paginator.to }}</span> |
| @@ -102,7 +102,7 @@ const disabledLink = `${baseLink} border-gray-200 dark:border-neutral-700 text-g | ||
| 102 | 102 | @endif |
| 103 | 103 | </p> |
| 104 | 104 | |
| 105 | <div class="isolate inline-flex -space-x-px rounded-md shadow-sm"> | |
| 105 | <div class="inline-flex -space-x-px rounded-md shadow-sm isolate"> | |
| 106 | 106 | @if(paginator.prev_page_url) |
| 107 | 107 | <a href="" rel="prev" class=" rounded-l-md" aria-label="Previous page">←</a> |
| 108 | 108 | @else |
| @@ -132,7 +132,7 @@ const disabledLink = `${baseLink} border-gray-200 dark:border-neutral-700 text-g formatting only | ||
| 132 | 132 | |
| 133 | 133 | @if(isSimple || isCursor) |
| 134 | 134 | {{-- Simple / cursor: just prev / next. No page numbers because we |
| 135 | don't know last_page (simple) or it doesn't apply (cursor). --}} | |
| 135 | don't know last_page (simple) or it doesn't apply (cursor). --}} | |
| 136 | 136 | <div class="flex flex-1 justify-between"> |
| 137 | 137 | @if(paginator.prev_page_url) |
| 138 | 138 | <a href="" rel="prev" class=" rounded-md">← Previous</a> |
| @@ -36,8 +36,8 @@ reassign `const` vars (always threw in strict-mode JS) and called | ||
| 36 | 36 | `useRouter()` (a Nuxt-ism, no-op in stx). |
| 37 | 37 | |
| 38 | 38 | Remaining work for #1838: the 78 in-page `window.`/`document.` lines |
| 39 | (mostly pre-existing vanilla event wiring under `content/*` and | |
| 40 | `commerce/*`) and deliverable #4 (component lift to | |
| 39 | (mostly pre-existing vanilla event wiring under `content/_` and | |
| 40 | `commerce/_`) and deliverable #4 (component lift to | |
| 41 | 41 | `@stacksjs/components`) are the open targets. |
| 42 | 42 | |
| 43 | 43 | Three new pages landed since the original audit (kanban index, kanban |
| @@ -47,7 +47,7 @@ pages outpaced the new arrivals. | ||
| 47 | 47 | |
| 48 | 48 | The remaining 78 DOM-access lines cluster in two patterns: pre-existing |
| 49 | 49 | `document.getElementById(...)` event wiring in vanilla `<script>` blocks |
| 50 | (largely under `content/*`, `commerce/*`), and the layout's own | |
| 50 | (largely under `content/_`, `commerce/_`), and the layout's own | |
| 51 | 51 | import-map / role-filter installer where direct DOM is appropriate. The |
| 52 | 52 | in-page DOM references are the next sweep target. |
| 53 | 53 | |
| @@ -2,11 +2,11 @@ | ||
| 2 | 2 | |
| 3 | 3 | @section('content') |
| 4 | 4 | {{-- Catch-all 404. Inlined instead of using <PageLayout> because the |
| 5 | filename's route-param `...all` leaks into component prop scopes | |
| 6 | and stx's @if evaluator parses it as a rest parameter, breaking | |
| 7 | every @if in components that consume it. --}} | |
| 8 | <div class="flex items-center justify-center min-h-[80vh] px-6 pt-6 pb-12"> | |
| 9 | <div class="mx-auto w-full max-w-7xl space-y-6"> | |
| 5 | filename's route-param `...all` leaks into component prop scopes | |
| 6 | and stx's @if evaluator parses it as a rest parameter, breaking | |
| 7 | every @if in components that consume it. --}} | |
| 8 | <div class="flex items-center justify-center pb-12 pt-6 px-6 min-h-[80vh]"> | |
| 9 | <div class="mx-auto space-y-6 max-w-7xl w-full"> | |
| 10 | 10 | <div class="text-center"> |
| 11 | 11 | <div class="flex justify-center"> |
| 12 | 12 | <div class="mb-6 h-24 w-24 text-gray-400 dark:text-neutral-500 i-hugeicons-alert-circle"></div> |
| @@ -181,10 +181,10 @@ | ||
| 181 | 181 | |
| 182 | 182 | <div class="mx-auto px-6 py-8 max-w-7xl"> |
| 183 | 183 | <!-- Role-gated wrapper. Collapses to the "requires dev role" message |
| 184 | when an authenticated client-role user lands here. Unauthenticated | |
| 185 | viewers (dev dashboard on localhost) see the surface as if they | |
| 186 | were a dev. --> | |
| 187 | <div :if="!canView" class="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-5 py-8 text-center"> | |
| 184 | when an authenticated client-role user lands here. Unauthenticated | |
| 185 | viewers (dev dashboard on localhost) see the surface as if they | |
| 186 | were a dev. --> | |
| 187 | <div :if="!canView" class="px-5 py-8 text-center bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800"> | |
| 188 | 188 | <p class="text-sm text-zinc-600 dark:text-zinc-400">This surface requires the dev role.</p> |
| 189 | 189 | </div> |
| 190 | 190 | |
| @@ -195,7 +195,7 @@ | ||
| 195 | 195 | </header> |
| 196 | 196 | |
| 197 | 197 | <!-- Disabled state — the surface is off in config/dashboard.ts --> |
| 198 | <div :if="disabled" class="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-5 py-8 text-center"> | |
| 198 | <div :if="disabled" class="px-5 py-8 text-center bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800"> | |
| 199 | 199 | <p class="text-sm text-zinc-600 dark:text-zinc-400">CI tracking is disabled.</p> |
| 200 | 200 | <p class="mt-1 text-xs text-zinc-500">Enable it in <code>config/dashboard.ts</code> by setting <code>ci.enabled = true</code> and listing one or more orgs.</p> |
| 201 | 201 | </div> |
| @@ -211,9 +211,9 @@ | ||
| 211 | 211 | </div> |
| 212 | 212 | |
| 213 | 213 | <!-- Error state --> |
| 214 | <div :if="error && !loading && !disabled" class="rounded-lg border border-red-200 dark:border-red-900/40 bg-red-50 dark:bg-red-950/30 px-5 py-4"> | |
| 215 | <p class="text-sm font-medium text-red-700 dark:text-red-300">Snapshot failed</p> | |
| 216 | <p class="mt-1 text-xs text-red-600 dark:text-red-400" :text="error"></p> | |
| 214 | <div :if="error && !loading && !disabled" class="px-5 py-4 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900/40"> | |
| 215 | <p class="font-medium text-red-700 text-sm dark:text-red-300">Snapshot failed</p> | |
| 216 | <p class="mt-1 text-red-600 text-xs dark:text-red-400" :text="error"></p> | |
| 217 | 217 | <p class="mt-2 text-xs text-zinc-500">Most often: <code>GITHUB_TOKEN</code> is missing or lacks <code>public_repo</code> scope.</p> |
| 218 | 218 | </div> |
| 219 | 219 | |
| @@ -246,11 +246,11 @@ | ||
| 246 | 246 | <span class="text-zinc-500 dark:text-zinc-400" :text="runnersText"></span> |
| 247 | 247 | </div> |
| 248 | 248 | <!-- Sparkline showing recent queue depth for the active org — |
| 249 | appears only when alerts are on (history collection is gated | |
| 250 | on `ci.alerts.enabled`). Drawn inline as a single SVG path | |
| 251 | so we don't pull a chart library for 20 datapoints. --> | |
| 252 | <div :if="hasHistory()" class="mb-5 flex items-center gap-2"> | |
| 253 | <span class="text-[10px] uppercase tracking-wide text-zinc-400">Queue depth (last hour)</span> | |
| 249 | appears only when alerts are on (history collection is gated | |
| 250 | on `ci.alerts.enabled`). Drawn inline as a single SVG path | |
| 251 | so we don't pull a chart library for 20 datapoints. --> | |
| 252 | <div :if="hasHistory()" class="flex gap-2 items-center mb-5"> | |
| 253 | <span class="text-[10px] text-zinc-400 tracking-wide uppercase">Queue depth (last hour)</span> | |
| 254 | 254 | <svg width="200" height="24" viewBox="0 0 200 24" class="text-violet-400"> |
| 255 | 255 | <path :d="sparklinePath()" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" /> |
| 256 | 256 | </svg> |
| @@ -281,8 +281,8 @@ formatting only | ||
| 281 | 281 | <div class="flex-1 min-w-0"> |
| 282 | 282 | <div class="flex gap-2 items-center"> |
| 283 | 283 | <!-- Inner links stop propagation so they remain "open repo |
| 284 | in a new tab" rather than triggering the drilldown | |
| 285 | drawer on the parent card. --> | |
| 284 | in a new tab" rather than triggering the drilldown | |
| 285 | drawer on the parent card. --> | |
| 286 | 286 | <a :href="repo.url" target="_blank" @click.stop class="font-medium no-underline text-sm text-zinc-900 dark:text-zinc-100 hover:underline" :text="repo.name"></a> |
| 287 | 287 | <span :if="repo.commitSha" class="font-mono text-xs text-zinc-400" :text="repo.commitSha"></span> |
| 288 | 288 | <span :if="repo.updatedAt" class="text-xs text-zinc-400" :text="timeAgo(repo.updatedAt)"></span> |
| @@ -337,25 +337,25 @@ | ||
| 337 | 337 | |
| 338 | 338 | <!-- ────────── Drilldown drawer (#1848) ────────── --> |
| 339 | 339 | <!-- Slide-in side panel triggered by a repo-card click. Lists the |
| 340 | last 20 workflow runs as a vertical timeline; failing runs | |
| 341 | expand inline to their failed-jobs list. Closes on backdrop | |
| 342 | click + Escape. --> | |
| 340 | last 20 workflow runs as a vertical timeline; failing runs | |
| 341 | expand inline to their failed-jobs list. Closes on backdrop | |
| 342 | click + Escape. --> | |
| 343 | 343 | <div |
| 344 | 344 | :if="drilldownTarget()" |
| 345 | class="fixed inset-0 z-50 flex justify-end" | |
| 345 | class="flex fixed inset-0 z-50 justify-end" | |
| 346 | 346 | @click="ci.closeDrilldown()" |
| 347 | 347 | @keydown.escape.window="ci.closeDrilldown()" |
| 348 | 348 | > |
| 349 | 349 | <div class="absolute inset-0 bg-black/40"></div> |
| 350 | 350 | <aside |
| 351 | 351 | @click.stop |
| 352 | class="relative w-full max-w-2xl h-full bg-white dark:bg-zinc-950 border-l border-zinc-200 dark:border-zinc-800 overflow-y-auto shadow-2xl" | |
| 352 | class="overflow-y-auto relative h-full max-w-2xl w-full bg-white dark:bg-zinc-950 border-l border-zinc-200 dark:border-zinc-800 shadow-2xl" | |
| 353 | 353 | > |
| 354 | 354 | <!-- Header --> |
| 355 | <div class="px-5 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-start gap-3 sticky top-0 bg-white dark:bg-zinc-950 z-10"> | |
| 355 | <div class="flex sticky top-0 z-10 gap-3 items-start px-5 py-4 bg-white dark:bg-zinc-950 border-b border-zinc-200 dark:border-zinc-800"> | |
| 356 | 356 | <div class="flex-1 min-w-0"> |
| 357 | <p class="text-xs uppercase tracking-wide text-zinc-500">Run history</p> | |
| 358 | <h3 class="font-semibold text-lg text-zinc-900 dark:text-zinc-100 truncate" :text="drilldownTarget().fullName"></h3> | |
| 357 | <p class="text-xs text-zinc-500 tracking-wide uppercase">Run history</p> | |
| 358 | <h3 class="font-semibold text-lg text-zinc-900 truncate dark:text-zinc-100" :text="drilldownTarget().fullName"></h3> | |
| 359 | 359 | </div> |
| 360 | 360 | <button |
| 361 | 361 | @click="ci.closeDrilldown()" |
| @@ -369,8 +369,8 @@ | ||
| 369 | 369 | </div> |
| 370 | 370 | |
| 371 | 371 | <!-- Error --> |
| 372 | <div :if="drilldownError()" class="mx-5 mt-4 rounded-lg border border-red-200 dark:border-red-900/40 bg-red-50 dark:bg-red-950/30 px-4 py-3"> | |
| 373 | <p class="text-xs text-red-700 dark:text-red-300" :text="drilldownError()"></p> | |
| 372 | <div :if="drilldownError()" class="mt-4 mx-5 px-4 py-3 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900/40"> | |
| 373 | <p class="text-red-700 text-xs dark:text-red-300" :text="drilldownError()"></p> | |
| 374 | 374 | </div> |
| 375 | 375 | |
| 376 | 376 | <!-- Loading --> |
| @@ -391,17 +391,17 @@ | ||
| 391 | 391 | @click="ci.toggleRunJobs(run.id)" |
| 392 | 392 | :class="runRowClass(run.conclusion, run.status)" |
| 393 | 393 | > |
| 394 | <div class="flex items-start justify-between gap-3"> | |
| 394 | <div class="flex gap-3 items-start justify-between"> | |
| 395 | 395 | <div class="flex-1 min-w-0"> |
| 396 | <div class="flex items-center gap-2"> | |
| 396 | <div class="flex gap-2 items-center"> | |
| 397 | 397 | <span |
| 398 | 398 | :class="`px-1.5 py-0.5 text-[10px] font-medium rounded ${runStatusBadge(run.conclusion, run.status).cls}`" |
| 399 | 399 | :text="runStatusBadge(run.conclusion, run.status).label" |
| 400 | 400 | ></span> |
| 401 | <span class="text-sm font-medium text-zinc-800 dark:text-zinc-200 truncate" :text="run.name"></span> | |
| 401 | <span class="font-medium text-sm text-zinc-800 truncate dark:text-zinc-200" :text="run.name"></span> | |
| 402 | 402 | </div> |
| 403 | 403 | <p :if="run.commitMessage" class="mt-1 text-xs text-zinc-500 truncate" :text="truncate(run.commitMessage, 80)"></p> |
| 404 | <div class="mt-1 flex items-center gap-2 text-[10px] text-zinc-400"> | |
| 404 | <div class="flex gap-2 items-center mt-1 text-[10px] text-zinc-400"> | |
| 405 | 405 | <span class="font-mono" :text="run.headShaShort"></span> |
| 406 | 406 | <span :if="run.headBranch" :text="run.headBranch"></span> |
| 407 | 407 | <span :if="run.commitAuthor">·</span> |
| @@ -416,27 +416,27 @@ | ||
| 416 | 416 | :href="run.url" |
| 417 | 417 | target="_blank" |
| 418 | 418 | @click.stop |
| 419 | class="text-xs text-violet-600 dark:text-violet-400 hover:underline no-underline flex-shrink-0" | |
| 419 | class="flex-shrink-0 no-underline text-violet-600 text-xs dark:text-violet-400 hover:underline" | |
| 420 | 420 | >view ↗</a> |
| 421 | 421 | </div> |
| 422 | 422 | </div> |
| 423 | 423 | |
| 424 | 424 | <!-- Expanded: failed job list --> |
| 425 | 425 | <div :if="expandedRunId() === run.id" class="px-3 py-2 bg-zinc-50 dark:bg-zinc-900/50 border-t border-zinc-100 dark:border-zinc-900"> |
| 426 | <div :if="loadingJobsRunId() === run.id" class="text-xs text-zinc-500 py-2"> | |
| 426 | <div :if="loadingJobsRunId() === run.id" class="py-2 text-xs text-zinc-500"> | |
| 427 | 427 | Loading jobs… |
| 428 | 428 | </div> |
| 429 | <div :if="loadingJobsRunId() !== run.id && jobsForRun(run.id).length === 0" class="text-xs text-zinc-400 italic py-1"> | |
| 429 | <div :if="loadingJobsRunId() !== run.id && jobsForRun(run.id).length === 0" class="py-1 italic text-xs text-zinc-400"> | |
| 430 | 430 | No job detail. |
| 431 | 431 | </div> |
| 432 | 432 | <div :if="loadingJobsRunId() !== run.id && failedJobs(run.id).length > 0" class="space-y-1.5"> |
| 433 | <p class="text-[10px] uppercase tracking-wide text-zinc-500">Failed jobs</p> | |
| 433 | <p class="text-[10px] text-zinc-500 tracking-wide uppercase">Failed jobs</p> | |
| 434 | 434 | <a |
| 435 | 435 | :for="job in failedJobs(run.id)" |
| 436 | 436 | :href="job.url" |
| 437 | 437 | target="_blank" |
| 438 | 438 | @click.stop |
| 439 | class="flex items-center gap-1.5 text-xs no-underline hover:underline text-red-700 dark:text-red-400" | |
| 439 | class="flex gap-1.5 items-center no-underline text-red-700 text-xs dark:text-red-400 hover:underline" | |
| 440 | 440 | > |
| 441 | 441 | <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| 442 | 442 | <circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /> |
| @@ -445,7 +445,7 @@ | ||
| 445 | 445 | <span :if="job.durationMs" class="text-zinc-400" :text="'(' + formatDuration(job.durationMs) + ')'"></span> |
| 446 | 446 | </a> |
| 447 | 447 | </div> |
| 448 | <div :if="loadingJobsRunId() !== run.id && jobsForRun(run.id).length > 0 && failedJobs(run.id).length === 0" class="text-xs text-zinc-500 py-1"> | |
| 448 | <div :if="loadingJobsRunId() !== run.id && jobsForRun(run.id).length > 0 && failedJobs(run.id).length === 0" class="py-1 text-xs text-zinc-500"> | |
| 449 | 449 | All <span :text="jobsForRun(run.id).length"></span> jobs passed. |
| 450 | 450 | </div> |
| 451 | 451 | </div> |
| @@ -1,42 +1,42 @@ | ||
| 1 | 1 | @extends('layouts/default') |
| 2 | 2 | |
| 3 | 3 | @section('content') |
| 4 | <div class="min-h-screen bg-neutral-50 p-6 text-neutral-950 dark:bg-neutral-950 dark:text-neutral-100"> | |
| 5 | <div class="mx-auto flex max-w-6xl flex-col gap-6"> | |
| 6 | <header class="flex flex-col gap-4 border-b border-neutral-200 pb-5 dark:border-neutral-800 md:flex-row md:items-end md:justify-between"> | |
| 4 | <div class="p-6 min-h-screen text-neutral-950 dark:text-neutral-100 bg-neutral-50 dark:bg-neutral-950"> | |
| 5 | <div class="flex flex-col gap-6 mx-auto max-w-6xl"> | |
| 6 | <header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between pb-5 border-b border-neutral-200 dark:border-neutral-800"> | |
| 7 | 7 | <div> |
| 8 | <p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">CMS</p> | |
| 9 | <h1 class="mt-1 text-2xl font-semibold tracking-tight">Categories</h1> | |
| 10 | <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Organize posts and markdown content into public blog categories.</p> | |
| 8 | <p class="font-semibold text-blue-600 text-xs tracking-wide uppercase dark:text-blue-400">CMS</p> | |
| 9 | <h1 class="mt-1 font-semibold text-2xl tracking-tight">Categories</h1> | |
| 10 | <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Organize posts and markdown content into public blog categories.</p> | |
| 11 | 11 | </div> |
| 12 | <button id="new-category-button" type="button" class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"> | |
| 13 | <span class="i-hugeicons-plus-sign h-4 w-4"></span> | |
| 12 | <button id="new-category-button" type="button" class="inline-flex gap-2 items-center px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md shadow-sm"> | |
| 13 | <span class="h-4 w-4 i-hugeicons-plus-sign"></span> | |
| 14 | 14 | New Category |
| 15 | 15 | </button> |
| 16 | 16 | </header> |
| 17 | 17 | |
| 18 | 18 | <section class="grid gap-4 md:grid-cols-3"> |
| 19 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 20 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Total Categories</p> | |
| 21 | <p id="category-total" class="mt-2 text-3xl font-semibold">0</p> | |
| 19 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 20 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Total Categories</p> | |
| 21 | <p id="category-total" class="mt-2 font-semibold text-3xl">0</p> | |
| 22 | 22 | </div> |
| 23 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 24 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Newest</p> | |
| 25 | <p id="category-newest" class="mt-2 truncate text-2xl font-semibold">-</p> | |
| 23 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 24 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Newest</p> | |
| 25 | <p id="category-newest" class="mt-2 font-semibold text-2xl truncate">-</p> | |
| 26 | 26 | </div> |
| 27 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 28 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Active</p> | |
| 29 | <p id="category-active" class="mt-2 text-3xl font-semibold text-green-600 dark:text-green-400">0</p> | |
| 27 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 28 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Active</p> | |
| 29 | <p id="category-active" class="mt-2 font-semibold text-3xl text-green-600 dark:text-green-400">0</p> | |
| 30 | 30 | </div> |
| 31 | 31 | </section> |
| 32 | 32 | |
| 33 | <section class="rounded-lg border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 34 | <div class="border-b border-neutral-200 p-4 dark:border-neutral-800"> | |
| 35 | <input id="category-search" type="search" placeholder="Search categories..." class="w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-neutral-700 dark:bg-neutral-950" /> | |
| 33 | <section class="bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 34 | <div class="p-4 border-b border-neutral-200 dark:border-neutral-800"> | |
| 35 | <input id="category-search" type="search" placeholder="Search categories..." class="px-3 py-2 max-w-md w-full text-sm bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" /> | |
| 36 | 36 | </div> |
| 37 | 37 | <div class="overflow-x-auto"> |
| 38 | <table class="min-w-full divide-y divide-neutral-200 text-sm dark:divide-neutral-800"> | |
| 39 | <thead class="bg-neutral-50 text-left text-xs font-semibold uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400"> | |
| 38 | <table class="min-w-full text-sm divide-neutral-200 divide-y dark:divide-neutral-800"> | |
| 39 | <thead class="font-semibold text-left text-neutral-500 text-xs uppercase dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-900"> | |
| 40 | 40 | <tr> |
| 41 | 41 | <th class="px-4 py-3">Name</th> |
| 42 | 42 | <th class="px-4 py-3">Slug</th> |
| @@ -44,27 +44,27 @@ | ||
| 44 | 44 | <th class="px-4 py-3 text-right">Actions</th> |
| 45 | 45 | </tr> |
| 46 | 46 | </thead> |
| 47 | <tbody id="categories-table" class="divide-y divide-neutral-200 dark:divide-neutral-800"></tbody> | |
| 47 | <tbody id="categories-table" class="divide-neutral-200 divide-y dark:divide-neutral-800"></tbody> | |
| 48 | 48 | </table> |
| 49 | 49 | </div> |
| 50 | <div id="categories-empty" class="hidden p-10 text-center text-sm text-neutral-500 dark:text-neutral-400">No categories found.</div> | |
| 50 | <div id="categories-empty" class="hidden p-10 text-center text-neutral-500 text-sm dark:text-neutral-400">No categories found.</div> | |
| 51 | 51 | </section> |
| 52 | 52 | </div> |
| 53 | 53 | </div> |
| 54 | 54 | |
| 55 | <dialog id="category-dialog" class="w-full max-w-lg rounded-lg border border-neutral-200 bg-white p-0 text-neutral-950 shadow-xl backdrop:bg-black/40 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"> | |
| 55 | <dialog id="category-dialog" class="p-0 max-w-lg w-full text-neutral-950 dark:text-neutral-100 bg-white backdrop:bg-black/40 dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl"> | |
| 56 | 56 | <form id="category-form" method="dialog"> |
| 57 | <div class="border-b border-neutral-200 px-5 py-4 dark:border-neutral-800"> | |
| 58 | <h2 class="text-lg font-semibold">New Category</h2> | |
| 57 | <div class="px-5 py-4 border-b border-neutral-200 dark:border-neutral-800"> | |
| 58 | <h2 class="font-semibold text-lg">New Category</h2> | |
| 59 | 59 | </div> |
| 60 | 60 | <div class="grid gap-4 p-5"> |
| 61 | <label class="grid gap-1 text-sm font-medium">Name <input id="category-name" required class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950" /></label> | |
| 62 | <label class="grid gap-1 text-sm font-medium">Slug <input id="category-slug" class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950" /></label> | |
| 63 | <label class="grid gap-1 text-sm font-medium">Description <textarea id="category-description" rows="3" class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"></textarea></label> | |
| 61 | <label class="grid gap-1 font-medium text-sm">Name <input id="category-name" required class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700" /></label> | |
| 62 | <label class="grid gap-1 font-medium text-sm">Slug <input id="category-slug" class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700" /></label> | |
| 63 | <label class="grid gap-1 font-medium text-sm">Description <textarea id="category-description" rows="3" class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700"></textarea></label> | |
| 64 | 64 | </div> |
| 65 | <div class="flex justify-end gap-2 border-t border-neutral-200 px-5 py-4 dark:border-neutral-800"> | |
| 66 | <button id="category-cancel" type="button" class="rounded-md border border-neutral-300 px-3 py-2 text-sm font-medium dark:border-neutral-700">Cancel</button> | |
| 67 | <button type="submit" class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500">Create Category</button> | |
| 65 | <div class="flex gap-2 justify-end px-5 py-4 border-neutral-200 border-t dark:border-neutral-800"> | |
| 66 | <button id="category-cancel" type="button" class="px-3 py-2 font-medium text-sm border border-neutral-300 rounded-md dark:border-neutral-700">Cancel</button> | |
| 67 | <button type="submit" class="px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md">Create Category</button> | |
| 68 | 68 | </div> |
| 69 | 69 | </form> |
| 70 | 70 | </dialog> |
| @@ -100,7 +100,7 @@ | ||
| 100 | 100 | '<td class="px-4 py-3 font-medium">' + escapeHtml(category.name) + '</td>' + |
| 101 | 101 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(category.slug) + '</td>' + |
| 102 | 102 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(category.description) + '</td>' + |
| 103 | '<td class="px-4 py-3 text-right"><button type="button" data-id="' + category.id + '" class="rounded p-2 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><span class="block h-4 w-4 i-hugeicons-waste"></span></button></td>' + | |
| 103 | '<td class="px-4 py-3 text-right"><button type="button" data-id="' + category.id + '" class="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/40 rounded"><span class="block h-4 w-4 i-hugeicons-waste"></span></button></td>' + | |
| 104 | 104 | '</tr>' |
| 105 | 105 | }).join('') |
| 106 | 106 | } |
| @@ -1,16 +1,16 @@ | ||
| 1 | 1 | @extends('layouts/default') |
| 2 | 2 | |
| 3 | 3 | @section('content') |
| 4 | <div class="min-h-screen bg-neutral-50 p-6 text-neutral-950 dark:bg-neutral-950 dark:text-neutral-100"> | |
| 5 | <div class="mx-auto flex max-w-7xl flex-col gap-6"> | |
| 6 | <header class="flex flex-col gap-4 border-b border-neutral-200 pb-5 dark:border-neutral-800 md:flex-row md:items-end md:justify-between"> | |
| 4 | <div class="p-6 min-h-screen text-neutral-950 dark:text-neutral-100 bg-neutral-50 dark:bg-neutral-950"> | |
| 5 | <div class="flex flex-col gap-6 mx-auto max-w-7xl"> | |
| 6 | <header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between pb-5 border-b border-neutral-200 dark:border-neutral-800"> | |
| 7 | 7 | <div> |
| 8 | <p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">CMS</p> | |
| 9 | <h1 class="mt-1 text-2xl font-semibold tracking-tight">Comments</h1> | |
| 10 | <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Moderate reader comments from the CMS comments table.</p> | |
| 8 | <p class="font-semibold text-blue-600 text-xs tracking-wide uppercase dark:text-blue-400">CMS</p> | |
| 9 | <h1 class="mt-1 font-semibold text-2xl tracking-tight">Comments</h1> | |
| 10 | <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Moderate reader comments from the CMS comments table.</p> | |
| 11 | 11 | </div> |
| 12 | 12 | <div class="flex flex-wrap gap-2"> |
| 13 | <select id="comment-status-filter" class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"> | |
| 13 | <select id="comment-status-filter" class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700"> | |
| 14 | 14 | <option value="">All statuses</option> |
| 15 | 15 | <option value="approved">Approved</option> |
| 16 | 16 | <option value="pending">Pending</option> |
| @@ -21,31 +21,31 @@ | ||
| 21 | 21 | </header> |
| 22 | 22 | |
| 23 | 23 | <section class="grid gap-4 md:grid-cols-4"> |
| 24 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 25 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Total Comments</p> | |
| 26 | <p id="comment-total" class="mt-2 text-3xl font-semibold">0</p> | |
| 24 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 25 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Total Comments</p> | |
| 26 | <p id="comment-total" class="mt-2 font-semibold text-3xl">0</p> | |
| 27 | 27 | </div> |
| 28 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 29 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Approved</p> | |
| 30 | <p id="comment-approved" class="mt-2 text-3xl font-semibold text-green-600 dark:text-green-400">0</p> | |
| 28 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 29 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Approved</p> | |
| 30 | <p id="comment-approved" class="mt-2 font-semibold text-3xl text-green-600 dark:text-green-400">0</p> | |
| 31 | 31 | </div> |
| 32 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 33 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Pending</p> | |
| 34 | <p id="comment-pending" class="mt-2 text-3xl font-semibold text-amber-600 dark:text-amber-400">0</p> | |
| 32 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 33 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Pending</p> | |
| 34 | <p id="comment-pending" class="mt-2 font-semibold text-3xl text-amber-600 dark:text-amber-400">0</p> | |
| 35 | 35 | </div> |
| 36 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 37 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Spam</p> | |
| 38 | <p id="comment-spam" class="mt-2 text-3xl font-semibold text-red-600 dark:text-red-400">0</p> | |
| 36 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 37 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Spam</p> | |
| 38 | <p id="comment-spam" class="mt-2 font-semibold text-3xl text-red-600 dark:text-red-400">0</p> | |
| 39 | 39 | </div> |
| 40 | 40 | </section> |
| 41 | 41 | |
| 42 | <section class="rounded-lg border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 43 | <div class="border-b border-neutral-200 p-4 dark:border-neutral-800"> | |
| 44 | <input id="comment-search" type="search" placeholder="Search comments..." class="w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-neutral-700 dark:bg-neutral-950" /> | |
| 42 | <section class="bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 43 | <div class="p-4 border-b border-neutral-200 dark:border-neutral-800"> | |
| 44 | <input id="comment-search" type="search" placeholder="Search comments..." class="px-3 py-2 max-w-md w-full text-sm bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" /> | |
| 45 | 45 | </div> |
| 46 | 46 | <div class="overflow-x-auto"> |
| 47 | <table class="min-w-full divide-y divide-neutral-200 text-sm dark:divide-neutral-800"> | |
| 48 | <thead class="bg-neutral-50 text-left text-xs font-semibold uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400"> | |
| 47 | <table class="min-w-full text-sm divide-neutral-200 divide-y dark:divide-neutral-800"> | |
| 48 | <thead class="font-semibold text-left text-neutral-500 text-xs uppercase dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-900"> | |
| 49 | 49 | <tr> |
| 50 | 50 | <th class="px-4 py-3">Author</th> |
| 51 | 51 | <th class="px-4 py-3">Comment</th> |
| @@ -55,10 +55,10 @@ | ||
| 55 | 55 | <th class="px-4 py-3 text-right">Actions</th> |
| 56 | 56 | </tr> |
| 57 | 57 | </thead> |
| 58 | <tbody id="comments-table" class="divide-y divide-neutral-200 dark:divide-neutral-800"></tbody> | |
| 58 | <tbody id="comments-table" class="divide-neutral-200 divide-y dark:divide-neutral-800"></tbody> | |
| 59 | 59 | </table> |
| 60 | 60 | </div> |
| 61 | <div id="comments-empty" class="hidden p-10 text-center text-sm text-neutral-500 dark:text-neutral-400">No comments found.</div> | |
| 61 | <div id="comments-empty" class="hidden p-10 text-center text-neutral-500 text-sm dark:text-neutral-400">No comments found.</div> | |
| 62 | 62 | </section> |
| 63 | 63 | </div> |
| 64 | 64 | </div> |
| @@ -116,15 +116,15 @@ | ||
| 116 | 116 | empty.classList.toggle('hidden', rows.length > 0) |
| 117 | 117 | table.innerHTML = rows.map(function (comment) { |
| 118 | 118 | return '<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-950/70">' + |
| 119 | '<td class="px-4 py-3"><div class="font-medium">' + escapeHtml(comment.authorName) + '</div><div class="text-xs text-neutral-500">' + escapeHtml(comment.authorEmail) + '</div></td>' + | |
| 119 | '<td class="px-4 py-3"><div class="font-medium">' + escapeHtml(comment.authorName) + '</div><div class="text-neutral-500 text-xs">' + escapeHtml(comment.authorEmail) + '</div></td>' + | |
| 120 | 120 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(truncate(comment.content, 120)) + '</td>' + |
| 121 | 121 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(comment.postTitle || '-') + '</td>' + |
| 122 | '<td class="px-4 py-3"><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium ' + statusClass(comment.status) + '">' + escapeHtml(comment.status) + '</span></td>' + | |
| 122 | '<td class="px-4 py-3"><span class="inline-flex px-2 py-0.5 font-medium text-xs rounded-full ' ' + + statusClass(comment.status)">' + escapeHtml(comment.status) + '</span></td>' + | |
| 123 | 123 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + formatDate(comment.createdAt) + '</td>' + |
| 124 | '<td class="px-4 py-3 text-right"><div class="flex justify-end gap-1">' + | |
| 125 | '<button type="button" data-action="approved" data-id="' + comment.id + '" class="rounded p-2 text-green-600 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-950/40"><span class="block h-4 w-4 i-hugeicons-checkmark-circle-02"></span></button>' + | |
| 126 | '<button type="button" data-action="spam" data-id="' + comment.id + '" class="rounded p-2 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/40"><span class="block h-4 w-4 i-hugeicons-alert-02"></span></button>' + | |
| 127 | '<button type="button" data-action="delete" data-id="' + comment.id + '" class="rounded p-2 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><span class="block h-4 w-4 i-hugeicons-waste"></span></button>' + | |
| 124 | '<td class="px-4 py-3 text-right"><div class="flex gap-1 justify-end">' + | |
| 125 | '<button type="button" data-action="approved" data-id="' + comment.id + '" class="p-2 text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-950/40 rounded"><span class="block h-4 w-4 i-hugeicons-checkmark-circle-02"></span></button>' + | |
| 126 | '<button type="button" data-action="spam" data-id="' + comment.id + '" class="p-2 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950/40 rounded"><span class="block h-4 w-4 i-hugeicons-alert-02"></span></button>' + | |
| 127 | '<button type="button" data-action="delete" data-id="' + comment.id + '" class="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/40 rounded"><span class="block h-4 w-4 i-hugeicons-waste"></span></button>' + | |
| 128 | 128 | '</div></td>' + |
| 129 | 129 | '</tr>' |
| 130 | 130 | }).join('') |
| @@ -1,42 +1,42 @@ | ||
| 1 | 1 | @extends('layouts/default') |
| 2 | 2 | |
| 3 | 3 | @section('content') |
| 4 | <div class="min-h-screen bg-neutral-50 p-6 text-neutral-950 dark:bg-neutral-950 dark:text-neutral-100"> | |
| 5 | <div class="mx-auto flex max-w-6xl flex-col gap-6"> | |
| 6 | <header class="flex flex-col gap-4 border-b border-neutral-200 pb-5 dark:border-neutral-800 md:flex-row md:items-end md:justify-between"> | |
| 4 | <div class="p-6 min-h-screen text-neutral-950 dark:text-neutral-100 bg-neutral-50 dark:bg-neutral-950"> | |
| 5 | <div class="flex flex-col gap-6 mx-auto max-w-6xl"> | |
| 6 | <header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between pb-5 border-b border-neutral-200 dark:border-neutral-800"> | |
| 7 | 7 | <div> |
| 8 | <p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">CMS</p> | |
| 9 | <h1 class="mt-1 text-2xl font-semibold tracking-tight">Pages</h1> | |
| 10 | <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage static CMS pages alongside your blog content.</p> | |
| 8 | <p class="font-semibold text-blue-600 text-xs tracking-wide uppercase dark:text-blue-400">CMS</p> | |
| 9 | <h1 class="mt-1 font-semibold text-2xl tracking-tight">Pages</h1> | |
| 10 | <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Manage static CMS pages alongside your blog content.</p> | |
| 11 | 11 | </div> |
| 12 | <button id="new-page-button" type="button" class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"> | |
| 13 | <span class="i-hugeicons-plus-sign h-4 w-4"></span> | |
| 12 | <button id="new-page-button" type="button" class="inline-flex gap-2 items-center px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md shadow-sm"> | |
| 13 | <span class="h-4 w-4 i-hugeicons-plus-sign"></span> | |
| 14 | 14 | New Page |
| 15 | 15 | </button> |
| 16 | 16 | </header> |
| 17 | 17 | |
| 18 | 18 | <section class="grid gap-4 md:grid-cols-3"> |
| 19 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 20 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Total Pages</p> | |
| 21 | <p id="page-total" class="mt-2 text-3xl font-semibold">0</p> | |
| 19 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 20 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Total Pages</p> | |
| 21 | <p id="page-total" class="mt-2 font-semibold text-3xl">0</p> | |
| 22 | 22 | </div> |
| 23 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 24 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Views</p> | |
| 25 | <p id="page-views" class="mt-2 text-3xl font-semibold">0</p> | |
| 23 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 24 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Views</p> | |
| 25 | <p id="page-views" class="mt-2 font-semibold text-3xl">0</p> | |
| 26 | 26 | </div> |
| 27 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 28 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Newest</p> | |
| 29 | <p id="page-newest" class="mt-2 truncate text-2xl font-semibold">-</p> | |
| 27 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 28 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Newest</p> | |
| 29 | <p id="page-newest" class="mt-2 font-semibold text-2xl truncate">-</p> | |
| 30 | 30 | </div> |
| 31 | 31 | </section> |
| 32 | 32 | |
| 33 | <section class="rounded-lg border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 34 | <div class="border-b border-neutral-200 p-4 dark:border-neutral-800"> | |
| 35 | <input id="page-search" type="search" placeholder="Search pages..." class="w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-neutral-700 dark:bg-neutral-950" /> | |
| 33 | <section class="bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 34 | <div class="p-4 border-b border-neutral-200 dark:border-neutral-800"> | |
| 35 | <input id="page-search" type="search" placeholder="Search pages..." class="px-3 py-2 max-w-md w-full text-sm bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" /> | |
| 36 | 36 | </div> |
| 37 | 37 | <div class="overflow-x-auto"> |
| 38 | <table class="min-w-full divide-y divide-neutral-200 text-sm dark:divide-neutral-800"> | |
| 39 | <thead class="bg-neutral-50 text-left text-xs font-semibold uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400"> | |
| 38 | <table class="min-w-full text-sm divide-neutral-200 divide-y dark:divide-neutral-800"> | |
| 39 | <thead class="font-semibold text-left text-neutral-500 text-xs uppercase dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-900"> | |
| 40 | 40 | <tr> |
| 41 | 41 | <th class="px-4 py-3">Title</th> |
| 42 | 42 | <th class="px-4 py-3">Template</th> |
| @@ -45,23 +45,23 @@ | ||
| 45 | 45 | <th class="px-4 py-3 text-right">Actions</th> |
| 46 | 46 | </tr> |
| 47 | 47 | </thead> |
| 48 | <tbody id="pages-table" class="divide-y divide-neutral-200 dark:divide-neutral-800"></tbody> | |
| 48 | <tbody id="pages-table" class="divide-neutral-200 divide-y dark:divide-neutral-800"></tbody> | |
| 49 | 49 | </table> |
| 50 | 50 | </div> |
| 51 | <div id="pages-empty" class="hidden p-10 text-center text-sm text-neutral-500 dark:text-neutral-400">No pages found.</div> | |
| 51 | <div id="pages-empty" class="hidden p-10 text-center text-neutral-500 text-sm dark:text-neutral-400">No pages found.</div> | |
| 52 | 52 | </section> |
| 53 | 53 | </div> |
| 54 | 54 | </div> |
| 55 | 55 | |
| 56 | <dialog id="page-dialog" class="w-full max-w-lg rounded-lg border border-neutral-200 bg-white p-0 text-neutral-950 shadow-xl backdrop:bg-black/40 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"> | |
| 56 | <dialog id="page-dialog" class="p-0 max-w-lg w-full text-neutral-950 dark:text-neutral-100 bg-white backdrop:bg-black/40 dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl"> | |
| 57 | 57 | <form id="page-form" method="dialog"> |
| 58 | <div class="border-b border-neutral-200 px-5 py-4 dark:border-neutral-800"> | |
| 59 | <h2 class="text-lg font-semibold">New Page</h2> | |
| 58 | <div class="px-5 py-4 border-b border-neutral-200 dark:border-neutral-800"> | |
| 59 | <h2 class="font-semibold text-lg">New Page</h2> | |
| 60 | 60 | </div> |
| 61 | 61 | <div class="grid gap-4 p-5"> |
| 62 | <label class="grid gap-1 text-sm font-medium">Title <input id="page-title" required class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950" /></label> | |
| 63 | <label class="grid gap-1 text-sm font-medium">Template | |
| 64 | <select id="page-template" class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"> | |
| 62 | <label class="grid gap-1 font-medium text-sm">Title <input id="page-title" required class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700" /></label> | |
| 63 | <label class="grid gap-1 font-medium text-sm">Template | |
| 64 | <select id="page-template" class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700"> | |
| 65 | 65 | <option value="default">Default</option> |
| 66 | 66 | <option value="landing">Landing</option> |
| 67 | 67 | <option value="blog">Blog</option> |
| @@ -69,9 +69,9 @@ | ||
| 69 | 69 | </select> |
| 70 | 70 | </label> |
| 71 | 71 | </div> |
| 72 | <div class="flex justify-end gap-2 border-t border-neutral-200 px-5 py-4 dark:border-neutral-800"> | |
| 73 | <button id="page-cancel" type="button" class="rounded-md border border-neutral-300 px-3 py-2 text-sm font-medium dark:border-neutral-700">Cancel</button> | |
| 74 | <button type="submit" class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500">Create Page</button> | |
| 72 | <div class="flex gap-2 justify-end px-5 py-4 border-neutral-200 border-t dark:border-neutral-800"> | |
| 73 | <button id="page-cancel" type="button" class="px-3 py-2 font-medium text-sm border border-neutral-300 rounded-md dark:border-neutral-700">Cancel</button> | |
| 74 | <button type="submit" class="px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md">Create Page</button> | |
| 75 | 75 | </div> |
| 76 | 76 | </form> |
| 77 | 77 | </dialog> |
| @@ -110,7 +110,7 @@ | ||
| 110 | 110 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(page.template) + '</td>' + |
| 111 | 111 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + Number(page.views || 0).toLocaleString() + '</td>' + |
| 112 | 112 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + fmtDate(page.published_at || page.created_at) + '</td>' + |
| 113 | '<td class="px-4 py-3 text-right"><button type="button" data-id="' + page.id + '" class="rounded p-2 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><span class="block h-4 w-4 i-hugeicons-waste"></span></button></td>' + | |
| 113 | '<td class="px-4 py-3 text-right"><button type="button" data-id="' + page.id + '" class="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/40 rounded"><span class="block h-4 w-4 i-hugeicons-waste"></span></button></td>' + | |
| 114 | 114 | '</tr>' |
| 115 | 115 | }).join('') |
| 116 | 116 | } |
| @@ -1,52 +1,52 @@ | ||
| 1 | 1 | @extends('layouts/default') |
| 2 | 2 | |
| 3 | 3 | @section('content') |
| 4 | <div class="min-h-screen bg-neutral-50 p-6 text-neutral-950 dark:bg-neutral-950 dark:text-neutral-100"> | |
| 5 | <div class="mx-auto flex max-w-7xl flex-col gap-6"> | |
| 6 | <header class="flex flex-col gap-4 border-b border-neutral-200 pb-5 dark:border-neutral-800 md:flex-row md:items-end md:justify-between"> | |
| 4 | <div class="p-6 min-h-screen text-neutral-950 dark:text-neutral-100 bg-neutral-50 dark:bg-neutral-950"> | |
| 5 | <div class="flex flex-col gap-6 mx-auto max-w-7xl"> | |
| 6 | <header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between pb-5 border-b border-neutral-200 dark:border-neutral-800"> | |
| 7 | 7 | <div> |
| 8 | <p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">CMS</p> | |
| 9 | <h1 class="mt-1 text-2xl font-semibold tracking-tight">Posts</h1> | |
| 10 | <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Create, edit, publish, and remove content backed by the Stacks CMS tables.</p> | |
| 8 | <p class="font-semibold text-blue-600 text-xs tracking-wide uppercase dark:text-blue-400">CMS</p> | |
| 9 | <h1 class="mt-1 font-semibold text-2xl tracking-tight">Posts</h1> | |
| 10 | <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Create, edit, publish, and remove content backed by the Stacks CMS tables.</p> | |
| 11 | 11 | </div> |
| 12 | 12 | <div class="flex flex-wrap gap-2"> |
| 13 | <a href="/blog" target="_blank" class="inline-flex items-center gap-2 rounded-md border border-neutral-300 px-3 py-2 text-sm font-medium hover:bg-white dark:border-neutral-700 dark:hover:bg-neutral-900"> | |
| 14 | <span class="i-hugeicons-view h-4 w-4"></span> | |
| 13 | <a href="/blog" target="_blank" class="inline-flex gap-2 items-center px-3 py-2 font-medium text-sm hover:bg-white dark:hover:bg-neutral-900 border border-neutral-300 rounded-md dark:border-neutral-700"> | |
| 14 | <span class="h-4 w-4 i-hugeicons-view"></span> | |
| 15 | 15 | View Blog |
| 16 | 16 | </a> |
| 17 | <button id="new-post-button" type="button" class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"> | |
| 18 | <span class="i-hugeicons-plus-sign h-4 w-4"></span> | |
| 17 | <button id="new-post-button" type="button" class="inline-flex gap-2 items-center px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md shadow-sm"> | |
| 18 | <span class="h-4 w-4 i-hugeicons-plus-sign"></span> | |
| 19 | 19 | New Post |
| 20 | 20 | </button> |
| 21 | 21 | </div> |
| 22 | 22 | </header> |
| 23 | 23 | |
| 24 | 24 | <section class="grid gap-4 md:grid-cols-4"> |
| 25 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 26 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Total Posts</p> | |
| 27 | <p id="stat-total" class="mt-2 text-3xl font-semibold">0</p> | |
| 25 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 26 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Total Posts</p> | |
| 27 | <p id="stat-total" class="mt-2 font-semibold text-3xl">0</p> | |
| 28 | 28 | </div> |
| 29 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 30 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Published</p> | |
| 31 | <p id="stat-published" class="mt-2 text-3xl font-semibold text-green-600 dark:text-green-400">0</p> | |
| 29 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 30 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Published</p> | |
| 31 | <p id="stat-published" class="mt-2 font-semibold text-3xl text-green-600 dark:text-green-400">0</p> | |
| 32 | 32 | </div> |
| 33 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 34 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Drafts</p> | |
| 35 | <p id="stat-drafts" class="mt-2 text-3xl font-semibold text-amber-600 dark:text-amber-400">0</p> | |
| 33 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 34 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Drafts</p> | |
| 35 | <p id="stat-drafts" class="mt-2 font-semibold text-3xl text-amber-600 dark:text-amber-400">0</p> | |
| 36 | 36 | </div> |
| 37 | <div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 38 | <p class="text-sm text-neutral-500 dark:text-neutral-400">Views</p> | |
| 39 | <p id="stat-views" class="mt-2 text-3xl font-semibold">0</p> | |
| 37 | <div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 38 | <p class="text-neutral-500 text-sm dark:text-neutral-400">Views</p> | |
| 39 | <p id="stat-views" class="mt-2 font-semibold text-3xl">0</p> | |
| 40 | 40 | </div> |
| 41 | 41 | </section> |
| 42 | 42 | |
| 43 | <section class="rounded-lg border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900"> | |
| 44 | <div class="flex flex-col gap-3 border-b border-neutral-200 p-4 dark:border-neutral-800 md:flex-row md:items-center md:justify-between"> | |
| 45 | <div class="relative max-w-md flex-1"> | |
| 46 | <span class="i-hugeicons-search-01 pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400"></span> | |
| 47 | <input id="post-search" type="search" placeholder="Search posts..." class="w-full rounded-md border border-neutral-300 bg-white py-2 pl-9 pr-3 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-neutral-700 dark:bg-neutral-950" /> | |
| 43 | <section class="bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm"> | |
| 44 | <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between p-4 border-b border-neutral-200 dark:border-neutral-800"> | |
| 45 | <div class="relative flex-1 max-w-md"> | |
| 46 | <span class="absolute left-3 top-1/2 h-4 w-4 text-neutral-400 pointer-events-none -translate-y-1/2 i-hugeicons-search-01"></span> | |
| 47 | <input id="post-search" type="search" placeholder="Search posts..." class="pl-9 pr-3 py-2 w-full text-sm bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" /> | |
| 48 | 48 | </div> |
| 49 | <select id="post-status-filter" class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"> | |
| 49 | <select id="post-status-filter" class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700"> | |
| 50 | 50 | <option value="">All statuses</option> |
| 51 | 51 | <option value="published">Published</option> |
| 52 | 52 | <option value="draft">Draft</option> |
| @@ -55,8 +55,8 @@ | ||
| 55 | 55 | </div> |
| 56 | 56 | |
| 57 | 57 | <div class="overflow-x-auto"> |
| 58 | <table class="min-w-full divide-y divide-neutral-200 text-sm dark:divide-neutral-800"> | |
| 59 | <thead class="bg-neutral-50 text-left text-xs font-semibold uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400"> | |
| 58 | <table class="min-w-full text-sm divide-neutral-200 divide-y dark:divide-neutral-800"> | |
| 59 | <thead class="font-semibold text-left text-neutral-500 text-xs uppercase dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-900"> | |
| 60 | 60 | <tr> |
| 61 | 61 | <th class="px-4 py-3">Title</th> |
| 62 | 62 | <th class="px-4 py-3">Status</th> |
| @@ -65,57 +65,57 @@ | ||
| 65 | 65 | <th class="px-4 py-3 text-right">Actions</th> |
| 66 | 66 | </tr> |
| 67 | 67 | </thead> |
| 68 | <tbody id="posts-table" class="divide-y divide-neutral-200 dark:divide-neutral-800"></tbody> | |
| 68 | <tbody id="posts-table" class="divide-neutral-200 divide-y dark:divide-neutral-800"></tbody> | |
| 69 | 69 | </table> |
| 70 | 70 | </div> |
| 71 | 71 | |
| 72 | 72 | <div id="posts-empty" class="hidden p-10 text-center"> |
| 73 | 73 | <div class="mx-auto h-10 w-10 text-neutral-400 i-hugeicons-quill-write-02"></div> |
| 74 | <h2 class="mt-3 text-sm font-semibold">No posts found</h2> | |
| 75 | <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Create a draft to get the CMS moving.</p> | |
| 74 | <h2 class="mt-3 font-semibold text-sm">No posts found</h2> | |
| 75 | <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Create a draft to get the CMS moving.</p> | |
| 76 | 76 | </div> |
| 77 | 77 | </section> |
| 78 | 78 | </div> |
| 79 | 79 | </div> |
| 80 | 80 | |
| 81 | <dialog id="post-dialog" class="w-full max-w-3xl rounded-lg border border-neutral-200 bg-white p-0 text-neutral-950 shadow-xl backdrop:bg-black/40 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100"> | |
| 81 | <dialog id="post-dialog" class="p-0 max-w-3xl w-full text-neutral-950 dark:text-neutral-100 bg-white backdrop:bg-black/40 dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl"> | |
| 82 | 82 | <form id="post-form" method="dialog" class="flex flex-col"> |
| 83 | <div class="border-b border-neutral-200 px-5 py-4 dark:border-neutral-800"> | |
| 84 | <h2 id="post-dialog-title" class="text-lg font-semibold">New Post</h2> | |
| 85 | <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Markdown works well here; the public blog reads from the same content field.</p> | |
| 83 | <div class="px-5 py-4 border-b border-neutral-200 dark:border-neutral-800"> | |
| 84 | <h2 id="post-dialog-title" class="font-semibold text-lg">New Post</h2> | |
| 85 | <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Markdown works well here; the public blog reads from the same content field.</p> | |
| 86 | 86 | </div> |
| 87 | 87 | <div class="grid gap-4 p-5"> |
| 88 | 88 | <input id="post-id" type="hidden" /> |
| 89 | <label class="grid gap-1 text-sm font-medium"> | |
| 89 | <label class="grid gap-1 font-medium text-sm"> | |
| 90 | 90 | Title |
| 91 | <input id="post-title" required class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-neutral-700 dark:bg-neutral-950" /> | |
| 91 | <input id="post-title" required class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" /> | |
| 92 | 92 | </label> |
| 93 | <label class="grid gap-1 text-sm font-medium"> | |
| 93 | <label class="grid gap-1 font-medium text-sm"> | |
| 94 | 94 | Excerpt |
| 95 | <textarea id="post-excerpt" rows="2" class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-neutral-700 dark:bg-neutral-950"></textarea> | |
| 95 | <textarea id="post-excerpt" rows="2" class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"></textarea> | |
| 96 | 96 | </label> |
| 97 | 97 | <div class="grid gap-4 md:grid-cols-2"> |
| 98 | <label class="grid gap-1 text-sm font-medium"> | |
| 98 | <label class="grid gap-1 font-medium text-sm"> | |
| 99 | 99 | Status |
| 100 | <select id="post-status" class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"> | |
| 100 | <select id="post-status" class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700"> | |
| 101 | 101 | <option value="draft">Draft</option> |
| 102 | 102 | <option value="published">Published</option> |
| 103 | 103 | <option value="archived">Archived</option> |
| 104 | 104 | </select> |
| 105 | 105 | </label> |
| 106 | <label class="grid gap-1 text-sm font-medium"> | |
| 106 | <label class="grid gap-1 font-medium text-sm"> | |
| 107 | 107 | Poster URL |
| 108 | <input id="post-poster" class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-neutral-700 dark:bg-neutral-950" /> | |
| 108 | <input id="post-poster" class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" /> | |
| 109 | 109 | </label> |
| 110 | 110 | </div> |
| 111 | <label class="grid gap-1 text-sm font-medium"> | |
| 111 | <label class="grid gap-1 font-medium text-sm"> | |
| 112 | 112 | Content |
| 113 | <textarea id="post-content" rows="12" class="rounded-md border border-neutral-300 bg-white px-3 py-2 font-mono text-sm outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-neutral-700 dark:bg-neutral-950"></textarea> | |
| 113 | <textarea id="post-content" rows="12" class="px-3 py-2 font-mono text-sm bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"></textarea> | |
| 114 | 114 | </label> |
| 115 | 115 | </div> |
| 116 | <div class="flex justify-end gap-2 border-t border-neutral-200 px-5 py-4 dark:border-neutral-800"> | |
| 117 | <button id="post-cancel" type="button" class="rounded-md border border-neutral-300 px-3 py-2 text-sm font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">Cancel</button> | |
| 118 | <button type="submit" class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500">Save Post</button> | |
| 116 | <div class="flex gap-2 justify-end px-5 py-4 border-neutral-200 border-t dark:border-neutral-800"> | |
| 117 | <button id="post-cancel" type="button" class="px-3 py-2 font-medium text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800 border border-neutral-300 rounded-md dark:border-neutral-700">Cancel</button> | |
| 118 | <button type="submit" class="px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md">Save Post</button> | |
| 119 | 119 | </div> |
| 120 | 120 | </form> |
| 121 | 121 | </dialog> |
| @@ -172,13 +172,13 @@ | ||
| 172 | 172 | empty.classList.toggle('hidden', rows.length > 0) |
| 173 | 173 | table.innerHTML = rows.map(function (post) { |
| 174 | 174 | return '<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-950/70">' + |
| 175 | '<td class="px-4 py-3"><div class="font-medium">' + escapeHtml(post.title) + '</div><div class="mt-1 max-w-xl truncate text-xs text-neutral-500 dark:text-neutral-400">' + escapeHtml(post.excerpt || post.content || '') + '</div></td>' + | |
| 176 | '<td class="px-4 py-3"><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium ' + statusClass(post.status) + '">' + escapeHtml(post.status || 'draft') + '</span></td>' + | |
| 175 | '<td class="px-4 py-3"><div class="font-medium">' + escapeHtml(post.title) + '</div><div class="mt-1 max-w-xl text-neutral-500 text-xs truncate dark:text-neutral-400">' + escapeHtml(post.excerpt || post.content || '') + '</div></td>' + | |
| 176 | '<td class="px-4 py-3"><span class="inline-flex px-2 py-0.5 font-medium text-xs rounded-full ' ' + + statusClass(post.status)">' + escapeHtml(post.status || 'draft') + '</span></td>' + | |
| 177 | 177 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + Number(post.views || 0).toLocaleString() + '</td>' + |
| 178 | 178 | '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + fmtDate(post.published_at || post.created_at) + '</td>' + |
| 179 | '<td class="px-4 py-3"><div class="flex justify-end gap-1">' + | |
| 180 | '<button type="button" class="rounded p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800" data-action="edit" data-id="' + post.id + '" title="Edit"><span class="block h-4 w-4 i-hugeicons-edit-01"></span></button>' + | |
| 181 | '<button type="button" class="rounded p-2 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40" data-action="delete" data-id="' + post.id + '" title="Delete"><span class="block h-4 w-4 i-hugeicons-waste"></span></button>' + | |
| 179 | '<td class="px-4 py-3"><div class="flex gap-1 justify-end">' + | |
| 180 | '<button type="button" class="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded" data-action="edit" data-id="' + post.id + '" title="Edit"><span class="block h-4 w-4 i-hugeicons-edit-01"></span></button>' + | |
| 181 | '<button type="button" class="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/40 rounded" data-action="delete" data-id="' + post.id + '" title="Delete"><span class="block h-4 w-4 i-hugeicons-waste"></span></button>' + | |
| 182 | 182 | '</div></td>' + |
| 183 | 183 | '</tr>' |
| 184 | 184 | }).join('') |
| @@ -443,12 +443,12 @@ | ||
| 443 | 443 | </script> |
| 444 | 444 | |
| 445 | 445 | <div class="mx-auto px-6 py-6 max-w-full"> |
| 446 | <div :if="!canView" class="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-5 py-8 text-center mx-auto max-w-2xl"> | |
| 446 | <div :if="!canView" class="mx-auto px-5 py-8 max-w-2xl text-center bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800"> | |
| 447 | 447 | <p class="text-sm text-zinc-600 dark:text-zinc-400">Kanban boards require the dev role.</p> |
| 448 | 448 | </div> |
| 449 | 449 | |
| 450 | 450 | <div :if="canView"> |
| 451 | <a href="/kanban" class="inline-flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 no-underline mb-4" data-mpa="true"> | |
| 451 | <a href="/kanban" class="inline-flex gap-1 items-center mb-4 no-underline text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" data-mpa="true"> | |
| 452 | 452 | <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6" /></svg> |
| 453 | 453 | All boards |
| 454 | 454 | </a> |
| @@ -461,9 +461,9 @@ | ||
| 461 | 461 | </div> |
| 462 | 462 | |
| 463 | 463 | <!-- Error --> |
| 464 | <div :if="error && !loading" class="rounded-lg border border-red-200 dark:border-red-900/40 bg-red-50 dark:bg-red-950/30 px-5 py-4"> | |
| 465 | <p class="text-sm font-medium text-red-700 dark:text-red-300">Board operation failed</p> | |
| 466 | <p class="mt-1 text-xs text-red-600 dark:text-red-400" :text="error"></p> | |
| 464 | <div :if="error && !loading" class="px-5 py-4 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900/40"> | |
| 465 | <p class="font-medium text-red-700 text-sm dark:text-red-300">Board operation failed</p> | |
| 466 | <p class="mt-1 text-red-600 text-xs dark:text-red-400" :text="error"></p> | |
| 467 | 467 | </div> |
| 468 | 468 | |
| 469 | 469 | <!-- Loaded --> |
| @@ -481,23 +481,23 @@ | ||
| 481 | 481 | </header> |
| 482 | 482 | |
| 483 | 483 | <!-- The board itself. Horizontal scroll for wide boards; |
| 484 | each column is a vertical card stack. --> | |
| 485 | <div data-kanban-root class="flex gap-3 overflow-x-auto pb-4" style="min-height: 60vh"> | |
| 484 | each column is a vertical card stack. --> | |
| 485 | <div data-kanban-root class="flex overflow-x-auto gap-3 pb-4" style="min-height: 60vh"> | |
| 486 | 486 | <div |
| 487 | 487 | :for="col in columns" |
| 488 | 488 | :data-column-id="col.id" |
| 489 | class="flex-shrink-0 w-72 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 flex flex-col" | |
| 489 | class="flex flex-col flex-shrink-0 w-72 bg-white dark:bg-zinc-950 border border-zinc-200 rounded-lg dark:border-zinc-800" | |
| 490 | 490 | style="max-height: calc(100vh - 220px)" |
| 491 | 491 | > |
| 492 | 492 | <!-- Column header --> |
| 493 | 493 | <div :class="`px-3 py-2 border-b border-zinc-100 dark:border-zinc-900 ${columnAccentClass(col.color)}`"> |
| 494 | <div class="flex items-center justify-between gap-2"> | |
| 495 | <p class="text-sm font-medium text-zinc-700 dark:text-zinc-200 truncate" :text="col.name"></p> | |
| 496 | <div class="flex items-center gap-1"> | |
| 497 | <span class="text-xs text-zinc-500 font-mono" :text="col.cards.length"></span> | |
| 494 | <div class="flex gap-2 items-center justify-between"> | |
| 495 | <p class="font-medium text-sm text-zinc-700 truncate dark:text-zinc-200" :text="col.name"></p> | |
| 496 | <div class="flex gap-1 items-center"> | |
| 497 | <span class="font-mono text-xs text-zinc-500" :text="col.cards.length"></span> | |
| 498 | 498 | <button |
| 499 | 499 | @click="deleteColumnClicked(col.id, col.cards.length > 0)" |
| 500 | class="text-zinc-400 hover:text-red-500 p-1 -m-1" | |
| 500 | class="-m-1 p-1 text-zinc-400 hover:text-red-500" | |
| 501 | 501 | title="Delete column" |
| 502 | 502 | > |
| 503 | 503 | <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| @@ -510,13 +510,13 @@ | ||
| 510 | 510 | </div> |
| 511 | 511 | |
| 512 | 512 | <!-- Cards (scrollable) --> |
| 513 | <div class="p-2 space-y-1 overflow-y-auto flex-1" style="min-height: 40px"> | |
| 513 | <div class="overflow-y-auto flex-1 p-2 space-y-1" style="min-height: 40px"> | |
| 514 | 514 | <div |
| 515 | 515 | :for="card in col.cards" |
| 516 | 516 | :data-card-id="card.id" |
| 517 | 517 | :data-card-column-id="col.id" |
| 518 | 518 | @click="openDetail(card.id)" |
| 519 | class="rounded border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-3 py-2 cursor-grab hover:border-violet-300 dark:hover:border-violet-700 transition-colors group" | |
| 519 | class="px-3 py-2 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 rounded dark:border-zinc-800 hover:border-violet-300 dark:hover:border-violet-700 transition-colors cursor-grab group" | |
| 520 | 520 | > |
| 521 | 521 | <!-- Label chips strip --> |
| 522 | 522 | <div :if="card.labels && card.labels.length > 0" class="flex flex-wrap gap-1 mb-1"> |
| @@ -527,8 +527,8 @@ | ||
| 527 | 527 | ></span> |
| 528 | 528 | </div> |
| 529 | 529 | |
| 530 | <div class="flex items-start justify-between gap-2"> | |
| 531 | <p class="text-xs text-zinc-800 dark:text-zinc-200 leading-snug flex-1" :text="card.title"></p> | |
| 530 | <div class="flex gap-2 items-start justify-between"> | |
| 531 | <p class="flex-1 leading-snug text-xs text-zinc-800 dark:text-zinc-200" :text="card.title"></p> | |
| 532 | 532 | <button |
| 533 | 533 | @click.stop="deleteCardClicked(card.id)" |
| 534 | 534 | class="text-zinc-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity" |
| @@ -542,8 +542,8 @@ | ||
| 542 | 542 | </div> |
| 543 | 543 | |
| 544 | 544 | <!-- Footer: due date + assignee avatars --> |
| 545 | <div :if="card.dueDate || (card.assignees && card.assignees.length > 0)" class="flex items-center justify-between gap-2 mt-1.5 pt-1.5 border-t border-zinc-200/50 dark:border-zinc-800/50"> | |
| 546 | <div :if="card.dueDate" class="flex items-center gap-1 text-[10px] text-zinc-500"> | |
| 545 | <div :if="card.dueDate || (card.assignees && card.assignees.length > 0)" class="flex gap-2 items-center justify-between mt-1.5 pt-1.5 border-t border-zinc-200/50 dark:border-zinc-800/50"> | |
| 546 | <div :if="card.dueDate" class="flex gap-1 items-center text-[10px] text-zinc-500"> | |
| 547 | 547 | <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| 548 | 548 | <rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" /> |
| 549 | 549 | </svg> |
| @@ -553,7 +553,7 @@ | ||
| 553 | 553 | <div :if="card.assignees && card.assignees.length > 0" class="flex -space-x-1"> |
| 554 | 554 | <span |
| 555 | 555 | :for="asg in card.assignees" |
| 556 | class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-violet-500 text-white text-[9px] font-medium ring-1 ring-white dark:ring-zinc-950" | |
| 556 | class="inline-flex items-center justify-center h-5 w-5 font-medium text-[9px] text-white bg-violet-500 ring-1 ring-white rounded-full dark:ring-zinc-950" | |
| 557 | 557 | :title="asg.name || asg.email || 'Assignee'" |
| 558 | 558 | :text="initialsOf(asg.name, asg.email)" |
| 559 | 559 | ></span> |
| @@ -572,18 +572,18 @@ | ||
| 572 | 572 | @keydown.escape="closeCardComposer()" |
| 573 | 573 | placeholder="Card title…" |
| 574 | 574 | rows="2" |
| 575 | class="w-full px-2 py-1 text-xs rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-800 dark:text-zinc-100 resize-none focus:outline-none focus:border-violet-500" | |
| 575 | class="px-2 py-1 w-full text-xs text-zinc-800 dark:text-zinc-100 bg-white dark:bg-zinc-900 border border-zinc-300 rounded dark:border-zinc-700 focus:border-violet-500 focus:outline-none resize-none" | |
| 576 | 576 | ></textarea> |
| 577 | 577 | <div class="flex gap-1"> |
| 578 | 578 | <button |
| 579 | 579 | @click="submitCard(col.id)" |
| 580 | class="px-2 py-1 text-xs rounded bg-violet-600 hover:bg-violet-700 text-white" | |
| 580 | class="px-2 py-1 text-white text-xs bg-violet-600 hover:bg-violet-700 rounded" | |
| 581 | 581 | > |
| 582 | 582 | Add |
| 583 | 583 | </button> |
| 584 | 584 | <button |
| 585 | 585 | @click="closeCardComposer()" |
| 586 | class="px-2 py-1 text-xs rounded text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" | |
| 586 | class="px-2 py-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 rounded" | |
| 587 | 587 | > |
| 588 | 588 | Cancel |
| 589 | 589 | </button> |
| @@ -592,7 +592,7 @@ | ||
| 592 | 592 | <button |
| 593 | 593 | :if="!isComposingFor(col.id)" |
| 594 | 594 | @click="openCardComposer(col.id)" |
| 595 | class="w-full px-2 py-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-900 rounded text-left" | |
| 595 | class="px-2 py-1 w-full text-left text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-900 rounded" | |
| 596 | 596 | > |
| 597 | 597 | + Add card |
| 598 | 598 | </button> |
| @@ -601,26 +601,26 @@ | ||
| 601 | 601 | |
| 602 | 602 | <!-- Add column trailing slot --> |
| 603 | 603 | <div class="flex-shrink-0 w-72"> |
| 604 | <div :if="composingNewColumn()" class="rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 p-3 space-y-2"> | |
| 604 | <div :if="composingNewColumn()" class="p-3 space-y-2 bg-white dark:bg-zinc-950 border border-zinc-300 rounded-lg dark:border-zinc-700"> | |
| 605 | 605 | <input |
| 606 | 606 | :value="draftColumnName()" |
| 607 | 607 | @input="(e) => draftColumnName.set(e.target.value)" |
| 608 | 608 | @keydown.enter="submitColumn()" |
| 609 | 609 | @keydown.escape="closeColumnComposer()" |
| 610 | 610 | placeholder="Column name…" |
| 611 | class="w-full px-2 py-1 text-sm rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-800 dark:text-zinc-100 focus:outline-none focus:border-violet-500" | |
| 611 | class="px-2 py-1 w-full text-sm text-zinc-800 dark:text-zinc-100 bg-white dark:bg-zinc-900 border border-zinc-300 rounded dark:border-zinc-700 focus:border-violet-500 focus:outline-none" | |
| 612 | 612 | autofocus |
| 613 | 613 | /> |
| 614 | 614 | <div class="flex gap-1"> |
| 615 | 615 | <button |
| 616 | 616 | @click="submitColumn()" |
| 617 | class="px-2 py-1 text-xs rounded bg-violet-600 hover:bg-violet-700 text-white" | |
| 617 | class="px-2 py-1 text-white text-xs bg-violet-600 hover:bg-violet-700 rounded" | |
| 618 | 618 | > |
| 619 | 619 | Add column |
| 620 | 620 | </button> |
| 621 | 621 | <button |
| 622 | 622 | @click="closeColumnComposer()" |
| 623 | class="px-2 py-1 text-xs rounded text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300" | |
| 623 | class="px-2 py-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 rounded" | |
| 624 | 624 | > |
| 625 | 625 | Cancel |
| 626 | 626 | </button> |
| @@ -629,7 +629,7 @@ | ||
| 629 | 629 | <button |
| 630 | 630 | :if="!composingNewColumn()" |
| 631 | 631 | @click="openColumnComposer()" |
| 632 | class="w-full h-12 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-700 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 hover:border-zinc-400 dark:hover:border-zinc-600 text-sm" | |
| 632 | class="h-12 w-full text-sm text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 border border-dashed border-zinc-300 rounded-lg dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600" | |
| 633 | 633 | > |
| 634 | 634 | + Add column |
| 635 | 635 | </button> |
| @@ -640,31 +640,31 @@ | ||
| 640 | 640 | |
| 641 | 641 | <!-- ───────────── Card detail modal (Phase 3) ───────────── --> |
| 642 | 642 | <!-- Opens on card click. Fixed positioned with a backdrop; closes |
| 643 | on backdrop click + Escape. Sections (top → bottom): | |
| 644 | · Title (inline-editable, blurs to commit) | |
| 645 | · Description (textarea, blurs to commit) | |
| 646 | · Sidebar: labels picker, assignees picker, due date, archive | |
| 647 | · Comments thread + composer | |
| 648 | All mutations route through the store, which fires optimistic | |
| 649 | updates with rollback on failure. --> | |
| 643 | on backdrop click + Escape. Sections (top → bottom): | |
| 644 | · Title (inline-editable, blurs to commit) | |
| 645 | · Description (textarea, blurs to commit) | |
| 646 | · Sidebar: labels picker, assignees picker, due date, archive | |
| 647 | · Comments thread + composer | |
| 648 | All mutations route through the store, which fires optimistic | |
| 649 | updates with rollback on failure. --> | |
| 650 | 650 | <div |
| 651 | 651 | :if="openCard()" |
| 652 | class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 py-8 overflow-y-auto" | |
| 652 | class="flex fixed inset-0 overflow-y-auto z-50 items-start justify-center px-4 py-8 bg-black/50" | |
| 653 | 653 | @click="closeDetail()" |
| 654 | 654 | @keydown.escape.window="closeDetail()" |
| 655 | 655 | > |
| 656 | 656 | <div |
| 657 | 657 | @click.stop |
| 658 | class="w-full max-w-3xl rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xl my-8" | |
| 658 | class="my-8 max-w-3xl w-full bg-white dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800 shadow-xl" | |
| 659 | 659 | > |
| 660 | 660 | <!-- Header: title + close --> |
| 661 | <div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-start gap-3"> | |
| 661 | <div class="flex gap-3 items-start px-6 py-4 border-b border-zinc-200 dark:border-zinc-800"> | |
| 662 | 662 | <input |
| 663 | 663 | :value="titleDraft()" |
| 664 | 664 | @input="(e) => titleDraft.set(e.target.value)" |
| 665 | 665 | @blur="commitTitle()" |
| 666 | 666 | @keydown.enter.prevent="(e) => e.target.blur()" |
| 667 | class="flex-1 text-lg font-semibold text-zinc-900 dark:text-zinc-100 bg-transparent focus:outline-none focus:bg-zinc-50 dark:focus:bg-zinc-800 rounded px-2 -mx-2 py-1 -my-1" | |
| 667 | class="flex-1 -mx-2 -my-1 px-2 py-1 font-semibold text-lg text-zinc-900 dark:text-zinc-100 bg-transparent focus:bg-zinc-50 dark:focus:bg-zinc-800 rounded focus:outline-none" | |
| 668 | 668 | /> |
| 669 | 669 | <button |
| 670 | 670 | @click="closeDetail()" |
| @@ -682,28 +682,28 @@ | ||
| 682 | 682 | </div> |
| 683 | 683 | |
| 684 | 684 | <div :if="errorCard()" class="px-6 py-3 bg-red-50 dark:bg-red-950/30 border-b border-red-200 dark:border-red-900/40"> |
| 685 | <p class="text-xs text-red-700 dark:text-red-300" :text="errorCard()"></p> | |
| 685 | <p class="text-red-700 text-xs dark:text-red-300" :text="errorCard()"></p> | |
| 686 | 686 | </div> |
| 687 | 687 | |
| 688 | <div class="grid grid-cols-1 md:grid-cols-3 gap-6 px-6 py-5"> | |
| 688 | <div class="grid gap-6 grid-cols-1 md:grid-cols-3 px-6 py-5"> | |
| 689 | 689 | <!-- Main column: description + comments --> |
| 690 | 690 | <div class="md:col-span-2 space-y-6"> |
| 691 | 691 | <!-- Description --> |
| 692 | 692 | <section> |
| 693 | <h4 class="text-xs font-medium uppercase tracking-wide text-zinc-500 mb-2">Description</h4> | |
| 693 | <h4 class="mb-2 font-medium text-xs text-zinc-500 tracking-wide uppercase">Description</h4> | |
| 694 | 694 | <textarea |
| 695 | 695 | :value="descDraft()" |
| 696 | 696 | @input="(e) => descDraft.set(e.target.value)" |
| 697 | 697 | @blur="commitDescription()" |
| 698 | 698 | rows="4" |
| 699 | 699 | placeholder="Add a more detailed description…" |
| 700 | class="w-full px-3 py-2 text-sm rounded border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-zinc-800 dark:text-zinc-200 resize-y focus:outline-none focus:border-violet-400 dark:focus:border-violet-600" | |
| 700 | class="px-3 py-2 w-full text-sm text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-y" | |
| 701 | 701 | ></textarea> |
| 702 | 702 | </section> |
| 703 | 703 | |
| 704 | 704 | <!-- Comments --> |
| 705 | 705 | <section> |
| 706 | <h4 class="text-xs font-medium uppercase tracking-wide text-zinc-500 mb-2">Activity</h4> | |
| 706 | <h4 class="mb-2 font-medium text-xs text-zinc-500 tracking-wide uppercase">Activity</h4> | |
| 707 | 707 | |
| 708 | 708 | <!-- Composer --> |
| 709 | 709 | <div class="mb-4"> |
| @@ -713,13 +713,13 @@ | ||
| 713 | 713 | @keydown.metaCmd.enter.prevent="submitComment()" |
| 714 | 714 | rows="2" |
| 715 | 715 | placeholder="Add a comment… (⌘+Enter to send)" |
| 716 | class="w-full px-3 py-2 text-sm rounded border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-zinc-800 dark:text-zinc-200 resize-none focus:outline-none focus:border-violet-400 dark:focus:border-violet-600" | |
| 716 | class="px-3 py-2 w-full text-sm text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-none" | |
| 717 | 717 | ></textarea> |
| 718 | 718 | <div class="flex justify-end mt-2"> |
| 719 | 719 | <button |
| 720 | 720 | @click="submitComment()" |
| 721 | 721 | :disabled="!commentDraft().trim()" |
| 722 | class="px-3 py-1 text-xs rounded bg-violet-600 hover:bg-violet-700 text-white disabled:opacity-40 disabled:cursor-not-allowed" | |
| 722 | class="px-3 py-1 text-white text-xs bg-violet-600 hover:bg-violet-700 rounded disabled:opacity-40 disabled:cursor-not-allowed" | |
| 723 | 723 | > |
| 724 | 724 | Comment |
| 725 | 725 | </button> |
| @@ -727,7 +727,7 @@ | ||
| 727 | 727 | </div> |
| 728 | 728 | |
| 729 | 729 | <!-- Thread --> |
| 730 | <div :if="openCard().comments.length === 0 && !loadingCard()" class="text-xs text-zinc-400 italic"> | |
| 730 | <div :if="openCard().comments.length === 0 && !loadingCard()" class="italic text-xs text-zinc-400"> | |
| 731 | 731 | No comments yet. |
| 732 | 732 | </div> |
| 733 | 733 | <ul class="space-y-3"> |
| @@ -735,17 +735,17 @@ | ||
| 735 | 735 | :for="cm in openCard().comments" |
| 736 | 736 | class="flex gap-3" |
| 737 | 737 | > |
| 738 | <span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-violet-500 text-white text-[10px] font-medium flex-shrink-0" :text="initialsOf(cm.authorName, cm.authorEmail)"></span> | |
| 738 | <span class="inline-flex flex-shrink-0 items-center justify-center h-7 w-7 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(cm.authorName, cm.authorEmail)"></span> | |
| 739 | 739 | <div class="flex-1 min-w-0"> |
| 740 | <div class="flex items-baseline gap-2"> | |
| 741 | <span class="text-xs font-medium text-zinc-700 dark:text-zinc-200" :text="cm.authorName || cm.authorEmail || 'Unknown'"></span> | |
| 740 | <div class="flex gap-2 items-baseline"> | |
| 741 | <span class="font-medium text-xs text-zinc-700 dark:text-zinc-200" :text="cm.authorName || cm.authorEmail || 'Unknown'"></span> | |
| 742 | 742 | <span class="text-[10px] text-zinc-400" :text="formatTimestamp(cm.createdAt)"></span> |
| 743 | 743 | <button |
| 744 | 744 | @click="deleteCommentClicked(cm.id)" |
| 745 | class="text-[10px] text-zinc-400 hover:text-red-500 ml-auto" | |
| 745 | class="ml-auto text-[10px] text-zinc-400 hover:text-red-500" | |
| 746 | 746 | >Delete</button> |
| 747 | 747 | </div> |
| 748 | <p class="mt-0.5 text-xs text-zinc-700 dark:text-zinc-300 whitespace-pre-wrap" :text="cm.body"></p> | |
| 748 | <p class="mt-0.5 text-xs text-zinc-700 whitespace-pre-wrap dark:text-zinc-300" :text="cm.body"></p> | |
| 749 | 749 | </div> |
| 750 | 750 | </li> |
| 751 | 751 | </ul> |
| @@ -757,10 +757,10 @@ | ||
| 757 | 757 | <!-- Labels --> |
| 758 | 758 | <section> |
| 759 | 759 | <div class="flex items-center justify-between mb-2"> |
| 760 | <h4 class="text-xs font-medium uppercase tracking-wide text-zinc-500">Labels</h4> | |
| 760 | <h4 class="font-medium text-xs text-zinc-500 tracking-wide uppercase">Labels</h4> | |
| 761 | 761 | <button |
| 762 | 762 | @click="showLabelPicker.set(!showLabelPicker())" |
| 763 | class="text-xs text-violet-600 dark:text-violet-400 hover:underline" | |
| 763 | class="text-violet-600 text-xs dark:text-violet-400 hover:underline" | |
| 764 | 764 | > |
| 765 | 765 | <span :if="!showLabelPicker()">Edit</span> |
| 766 | 766 | <span :if="showLabelPicker()">Done</span> |
| @@ -774,7 +774,7 @@ | ||
| 774 | 774 | :class="`px-2 py-0.5 text-xs font-medium rounded ${labelChipClass(lbl.color)}`" |
| 775 | 775 | :text="lbl.name" |
| 776 | 776 | ></span> |
| 777 | <span :if="openCard().labels.length === 0" class="text-xs text-zinc-400 italic">None</span> | |
| 777 | <span :if="openCard().labels.length === 0" class="italic text-xs text-zinc-400">None</span> | |
| 778 | 778 | </div> |
| 779 | 779 | |
| 780 | 780 | <!-- Picker --> |
| @@ -785,7 +785,7 @@ | ||
| 785 | 785 | :class="`w-full flex items-center justify-between px-2 py-1 rounded text-xs ${labelChipClass(lbl.color)} hover:opacity-90`" |
| 786 | 786 | > |
| 787 | 787 | <span :text="lbl.name"></span> |
| 788 | <span class="flex items-center gap-1"> | |
| 788 | <span class="flex gap-1 items-center"> | |
| 789 | 789 | <svg :if="openCard().labels.some(l => l.id === lbl.id)" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> |
| 790 | 790 | <polyline points="20 6 9 17 4 12" /> |
| 791 | 791 | </svg> |
| @@ -800,15 +800,15 @@ | ||
| 800 | 800 | </button> |
| 801 | 801 | |
| 802 | 802 | <!-- New label composer --> |
| 803 | <div :if="showNewLabelComposer()" class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800"> | |
| 803 | <div :if="showNewLabelComposer()" class="pt-2 space-y-2 border-t border-zinc-200 dark:border-zinc-800"> | |
| 804 | 804 | <input |
| 805 | 805 | :value="newLabelName()" |
| 806 | 806 | @input="(e) => newLabelName.set(e.target.value)" |
| 807 | 807 | @keydown.enter.prevent="submitNewLabel()" |
| 808 | 808 | placeholder="Label name" |
| 809 | class="w-full px-2 py-1 text-xs rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 focus:outline-none focus:border-violet-500" | |
| 809 | class="px-2 py-1 w-full text-xs bg-white dark:bg-zinc-950 border border-zinc-300 rounded dark:border-zinc-700 focus:border-violet-500 focus:outline-none" | |
| 810 | 810 | /> |
| 811 | <div class="flex items-center gap-1"> | |
| 811 | <div class="flex gap-1 items-center"> | |
| 812 | 812 | <button |
| 813 | 813 | :for="c in labelColors" |
| 814 | 814 | @click="newLabelColor.set(c)" |
| @@ -817,14 +817,14 @@ | ||
| 817 | 817 | ></button> |
| 818 | 818 | </div> |
| 819 | 819 | <div class="flex gap-1"> |
| 820 | <button @click="submitNewLabel()" class="px-2 py-0.5 text-xs rounded bg-violet-600 hover:bg-violet-700 text-white">Create</button> | |
| 820 | <button @click="submitNewLabel()" class="px-2 py-0.5 text-white text-xs bg-violet-600 hover:bg-violet-700 rounded">Create</button> | |
| 821 | 821 | <button @click="showNewLabelComposer.set(false)" class="px-2 py-0.5 text-xs text-zinc-500 hover:text-zinc-700">Cancel</button> |
| 822 | 822 | </div> |
| 823 | 823 | </div> |
| 824 | 824 | <button |
| 825 | 825 | :if="!showNewLabelComposer()" |
| 826 | 826 | @click="showNewLabelComposer.set(true)" |
| 827 | class="w-full px-2 py-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded border border-dashed border-zinc-300 dark:border-zinc-700" | |
| 827 | class="px-2 py-1 w-full text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 border border-dashed border-zinc-300 rounded dark:border-zinc-700" | |
| 828 | 828 | > |
| 829 | 829 | + New label |
| 830 | 830 | </button> |
| @@ -834,10 +834,10 @@ | ||
| 834 | 834 | <!-- Assignees --> |
| 835 | 835 | <section> |
| 836 | 836 | <div class="flex items-center justify-between mb-2"> |
| 837 | <h4 class="text-xs font-medium uppercase tracking-wide text-zinc-500">Assignees</h4> | |
| 837 | <h4 class="font-medium text-xs text-zinc-500 tracking-wide uppercase">Assignees</h4> | |
| 838 | 838 | <button |
| 839 | 839 | @click="showAssigneePicker.set(!showAssigneePicker())" |
| 840 | class="text-xs text-violet-600 dark:text-violet-400 hover:underline" | |
| 840 | class="text-violet-600 text-xs dark:text-violet-400 hover:underline" | |
| 841 | 841 | > |
| 842 | 842 | <span :if="!showAssigneePicker()">Edit</span> |
| 843 | 843 | <span :if="showAssigneePicker()">Done</span> |
| @@ -847,23 +847,23 @@ | ||
| 847 | 847 | <div :if="!showAssigneePicker()" class="flex flex-wrap gap-2"> |
| 848 | 848 | <div |
| 849 | 849 | :for="asg in openCard().assignees" |
| 850 | class="flex items-center gap-1.5 text-xs text-zinc-700 dark:text-zinc-200" | |
| 850 | class="flex gap-1.5 items-center text-xs text-zinc-700 dark:text-zinc-200" | |
| 851 | 851 | > |
| 852 | <span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-violet-500 text-white text-[10px] font-medium" :text="initialsOf(asg.name, asg.email)"></span> | |
| 852 | <span class="inline-flex items-center justify-center h-6 w-6 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(asg.name, asg.email)"></span> | |
| 853 | 853 | <span :text="asg.name || asg.email || 'Unknown'"></span> |
| 854 | 854 | </div> |
| 855 | <span :if="openCard().assignees.length === 0" class="text-xs text-zinc-400 italic">Unassigned</span> | |
| 855 | <span :if="openCard().assignees.length === 0" class="italic text-xs text-zinc-400">Unassigned</span> | |
| 856 | 856 | </div> |
| 857 | 857 | |
| 858 | <div :if="showAssigneePicker()" class="space-y-1 max-h-48 overflow-y-auto"> | |
| 859 | <p :if="kanban.users().length === 0" class="text-xs text-zinc-400 italic">No users to assign.</p> | |
| 858 | <div :if="showAssigneePicker()" class="overflow-y-auto space-y-1 max-h-48"> | |
| 859 | <p :if="kanban.users().length === 0" class="italic text-xs text-zinc-400">No users to assign.</p> | |
| 860 | 860 | <button |
| 861 | 861 | :for="u in kanban.users()" |
| 862 | 862 | @click="toggleAssignee(u.id)" |
| 863 | class="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 text-left" | |
| 863 | class="flex gap-2 items-center px-2 py-1.5 w-full text-left text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded" | |
| 864 | 864 | > |
| 865 | <span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-violet-500 text-white text-[10px] font-medium flex-shrink-0" :text="initialsOf(u.name, u.email)"></span> | |
| 866 | <span class="flex-1 truncate text-zinc-700 dark:text-zinc-200" :text="u.name || u.email"></span> | |
| 865 | <span class="inline-flex flex-shrink-0 items-center justify-center h-6 w-6 font-medium text-[10px] text-white bg-violet-500 rounded-full" :text="initialsOf(u.name, u.email)"></span> | |
| 866 | <span class="flex-1 text-zinc-700 truncate dark:text-zinc-200" :text="u.name || u.email"></span> | |
| 867 | 867 | <svg :if="openCard().assignees.some(a => a.userId === u.id)" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-violet-500"> |
| 868 | 868 | <polyline points="20 6 9 17 4 12" /> |
| 869 | 869 | </svg> |
| @@ -873,12 +873,12 @@ | ||
| 873 | 873 | |
| 874 | 874 | <!-- Due date --> |
| 875 | 875 | <section> |
| 876 | <h4 class="text-xs font-medium uppercase tracking-wide text-zinc-500 mb-2">Due date</h4> | |
| 876 | <h4 class="mb-2 font-medium text-xs text-zinc-500 tracking-wide uppercase">Due date</h4> | |
| 877 | 877 | <input |
| 878 | 878 | type="date" |
| 879 | 879 | :value="openCard().dueDate ? openCard().dueDate.slice(0, 10) : ''" |
| 880 | 880 | @change="(e) => commitDueDate(e.target.value)" |
| 881 | class="w-full px-2 py-1 text-xs rounded border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-zinc-800 dark:text-zinc-200 focus:outline-none focus:border-violet-400" | |
| 881 | class="px-2 py-1 w-full text-xs text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none" | |
| 882 | 882 | /> |
| 883 | 883 | </section> |
| 884 | 884 | |
| @@ -111,9 +111,9 @@ | ||
| 111 | 111 | |
| 112 | 112 | <div class="mx-auto px-6 py-8 max-w-7xl"> |
| 113 | 113 | <!-- Role gate. The full RBAC check via useRole(); unauthenticated |
| 114 | dev-dashboard viewers see the surface as if they were a dev per | |
| 115 | the permissive default. --> | |
| 116 | <div :if="!canView" class="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-5 py-8 text-center"> | |
| 114 | dev-dashboard viewers see the surface as if they were a dev per | |
| 115 | the permissive default. --> | |
| 116 | <div :if="!canView" class="px-5 py-8 text-center bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800"> | |
| 117 | 117 | <p class="text-sm text-zinc-600 dark:text-zinc-400">Kanban boards require the dev role.</p> |
| 118 | 118 | </div> |
| 119 | 119 | |
| @@ -125,7 +125,7 @@ | ||
| 125 | 125 | </div> |
| 126 | 126 | <button |
| 127 | 127 | @click="openCreate()" |
| 128 | class="px-3 py-1.5 text-sm rounded-md bg-violet-600 hover:bg-violet-700 text-white" | |
| 128 | class="px-3 py-1.5 text-sm text-white bg-violet-600 hover:bg-violet-700 rounded-md" | |
| 129 | 129 | > |
| 130 | 130 | New board |
| 131 | 131 | </button> |
| @@ -139,20 +139,20 @@ | ||
| 139 | 139 | </div> |
| 140 | 140 | |
| 141 | 141 | <!-- Error --> |
| 142 | <div :if="error && !loading" class="rounded-lg border border-red-200 dark:border-red-900/40 bg-red-50 dark:bg-red-950/30 px-5 py-4"> | |
| 143 | <p class="text-sm font-medium text-red-700 dark:text-red-300">Couldn't load boards</p> | |
| 144 | <p class="mt-1 text-xs text-red-600 dark:text-red-400" :text="error"></p> | |
| 142 | <div :if="error && !loading" class="px-5 py-4 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900/40"> | |
| 143 | <p class="font-medium text-red-700 text-sm dark:text-red-300">Couldn't load boards</p> | |
| 144 | <p class="mt-1 text-red-600 text-xs dark:text-red-400" :text="error"></p> | |
| 145 | 145 | <p class="mt-2 text-xs text-zinc-500">Most common: migrations haven't run yet. Try <code>./buddy migrate</code>.</p> |
| 146 | 146 | </div> |
| 147 | 147 | |
| 148 | 148 | <!-- Empty state — DB reachable but no boards yet --> |
| 149 | <div :if="!loading && !error && !hasBoards" class="rounded-lg border border-dashed border-zinc-300 dark:border-zinc-700 px-6 py-12 text-center"> | |
| 150 | <svg class="mx-auto text-zinc-400 mb-3" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> | |
| 149 | <div :if="!loading && !error && !hasBoards" class="px-6 py-12 text-center border border-dashed border-zinc-300 rounded-lg dark:border-zinc-700"> | |
| 150 | <svg class="mb-3 mx-auto text-zinc-400" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> | |
| 151 | 151 | <rect x="3" y="4" width="6" height="16" rx="1" /> |
| 152 | 152 | <rect x="11" y="4" width="6" height="10" rx="1" /> |
| 153 | 153 | <rect x="19" y="4" width="2" height="6" rx="1" /> |
| 154 | 154 | </svg> |
| 155 | <p class="text-sm font-medium text-zinc-700 dark:text-zinc-300">No boards yet</p> | |
| 155 | <p class="font-medium text-sm text-zinc-700 dark:text-zinc-300">No boards yet</p> | |
| 156 | 156 | <p class="mt-1 text-xs text-zinc-500">Click "New board" to create one.</p> |
| 157 | 157 | </div> |
| 158 | 158 | |
| @@ -161,17 +161,17 @@ | ||
| 161 | 161 | <a |
| 162 | 162 | :for="board in boards" |
| 163 | 163 | :href="`/kanban/${board.id}`" |
| 164 | class="block rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors overflow-hidden no-underline group" | |
| 164 | class="block overflow-hidden no-underline bg-white dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors group" | |
| 165 | 165 | data-mpa="true" |
| 166 | 166 | > |
| 167 | 167 | <div :class="`h-2 ${accentClass(board.color)}`"></div> |
| 168 | 168 | <div class="px-4 py-3"> |
| 169 | <div class="flex items-start gap-2"> | |
| 170 | <h2 class="flex-1 font-medium text-sm text-zinc-900 dark:text-zinc-100 group-hover:text-violet-600 dark:group-hover:text-violet-400 truncate" :text="board.name"></h2> | |
| 169 | <div class="flex gap-2 items-start"> | |
| 170 | <h2 class="flex-1 font-medium text-sm text-zinc-900 truncate dark:text-zinc-100 group-hover:text-violet-600 dark:group-hover:text-violet-400" :text="board.name"></h2> | |
| 171 | 171 | </div> |
| 172 | 172 | <p |
| 173 | 173 | :if="board.description" |
| 174 | class="mt-1 text-xs text-zinc-500 line-clamp-2" | |
| 174 | class="mt-1 line-clamp-2 text-xs text-zinc-500" | |
| 175 | 175 | :text="board.description" |
| 176 | 176 | ></p> |
| 177 | 177 | <p class="mt-3 text-xs text-zinc-400" :text="cardCountText(board.cardCount)"></p> |
| @@ -181,49 +181,49 @@ | ||
| 181 | 181 | </div> |
| 182 | 182 | |
| 183 | 183 | <!-- Create-board modal. Fixed positioned with a backdrop; closes on |
| 184 | backdrop click + Escape key. Optimistic insert: kanban.createBoard | |
| 185 | updates the boards list immediately on success and rolls back on | |
| 186 | failure (with the error surfaced inline below the form). --> | |
| 184 | backdrop click + Escape key. Optimistic insert: kanban.createBoard | |
| 185 | updates the boards list immediately on success and rolls back on | |
| 186 | failure (with the error surfaced inline below the form). --> | |
| 187 | 187 | <div |
| 188 | 188 | :if="isCreating()" |
| 189 | class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4" | |
| 189 | class="flex fixed inset-0 z-50 items-center justify-center px-4 bg-black/50" | |
| 190 | 190 | @click="closeCreate()" |
| 191 | 191 | @keydown.escape.window="closeCreate()" |
| 192 | 192 | > |
| 193 | 193 | <div |
| 194 | 194 | @click.stop |
| 195 | class="w-full max-w-md rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xl" | |
| 195 | class="max-w-md w-full bg-white dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800 shadow-xl" | |
| 196 | 196 | > |
| 197 | 197 | <div class="px-5 py-4 border-b border-zinc-200 dark:border-zinc-800"> |
| 198 | <h3 class="text-base font-semibold text-zinc-900 dark:text-zinc-100">New board</h3> | |
| 198 | <h3 class="font-semibold text-base text-zinc-900 dark:text-zinc-100">New board</h3> | |
| 199 | 199 | </div> |
| 200 | 200 | <div class="px-5 py-4 space-y-4"> |
| 201 | 201 | <div> |
| 202 | <label class="block text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">Name</label> | |
| 202 | <label class="block mb-1 font-medium text-xs text-zinc-700 dark:text-zinc-300">Name</label> | |
| 203 | 203 | <input |
| 204 | 204 | :value="draftName()" |
| 205 | 205 | @input="(e) => draftName.set(e.target.value)" |
| 206 | 206 | @keydown.enter="submitCreate()" |
| 207 | 207 | placeholder="e.g. Q3 Roadmap" |
| 208 | class="w-full px-3 py-2 text-sm rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-violet-500" | |
| 208 | class="px-3 py-2 w-full text-sm text-zinc-900 dark:text-zinc-100 bg-white dark:bg-zinc-950 border border-zinc-300 rounded dark:border-zinc-700 focus:border-violet-500 focus:outline-none" | |
| 209 | 209 | autofocus |
| 210 | 210 | /> |
| 211 | 211 | </div> |
| 212 | 212 | |
| 213 | 213 | <div> |
| 214 | <label class="block text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">Description <span class="text-zinc-400">(optional)</span></label> | |
| 214 | <label class="block mb-1 font-medium text-xs text-zinc-700 dark:text-zinc-300">Description <span class="text-zinc-400">(optional)</span></label> | |
| 215 | 215 | <textarea |
| 216 | 216 | :value="draftDescription()" |
| 217 | 217 | @input="(e) => draftDescription.set(e.target.value)" |
| 218 | 218 | rows="3" |
| 219 | 219 | placeholder="A short description of what this board tracks." |
| 220 | class="w-full px-3 py-2 text-sm rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 resize-none focus:outline-none focus:border-violet-500" | |
| 220 | class="px-3 py-2 w-full text-sm text-zinc-900 dark:text-zinc-100 bg-white dark:bg-zinc-950 border border-zinc-300 rounded dark:border-zinc-700 focus:border-violet-500 focus:outline-none resize-none" | |
| 221 | 221 | ></textarea> |
| 222 | 222 | </div> |
| 223 | 223 | |
| 224 | 224 | <div> |
| 225 | <label class="block text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-2">Accent color</label> | |
| 226 | <div class="flex items-center gap-2"> | |
| 225 | <label class="block mb-2 font-medium text-xs text-zinc-700 dark:text-zinc-300">Accent color</label> | |
| 226 | <div class="flex gap-2 items-center"> | |
| 227 | 227 | <button |
| 228 | 228 | :for="c in colorChoices" |
| 229 | 229 | @click="draftColor.set(c)" |
| @@ -234,20 +234,20 @@ | ||
| 234 | 234 | </div> |
| 235 | 235 | </div> |
| 236 | 236 | |
| 237 | <p :if="submitError()" class="text-xs text-red-600 dark:text-red-400" :text="submitError()"></p> | |
| 237 | <p :if="submitError()" class="text-red-600 text-xs dark:text-red-400" :text="submitError()"></p> | |
| 238 | 238 | </div> |
| 239 | 239 | |
| 240 | <div class="px-5 py-3 border-t border-zinc-200 dark:border-zinc-800 flex justify-end gap-2"> | |
| 240 | <div class="flex gap-2 justify-end px-5 py-3 border-t border-zinc-200 dark:border-zinc-800"> | |
| 241 | 241 | <button |
| 242 | 242 | @click="closeCreate()" |
| 243 | class="px-3 py-1.5 text-sm rounded-md text-zinc-600 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200" | |
| 243 | class="px-3 py-1.5 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 rounded-md" | |
| 244 | 244 | :disabled="submitting()" |
| 245 | 245 | > |
| 246 | 246 | Cancel |
| 247 | 247 | </button> |
| 248 | 248 | <button |
| 249 | 249 | @click="submitCreate()" |
| 250 | class="px-3 py-1.5 text-sm rounded-md bg-violet-600 hover:bg-violet-700 text-white" | |
| 250 | class="px-3 py-1.5 text-sm text-white bg-violet-600 hover:bg-violet-700 rounded-md" | |
| 251 | 251 | :disabled="submitting()" |
| 252 | 252 | > |
| 253 | 253 | <span :if="!submitting()">Create</span> |