also looking at this
UI polish, real queue + scheduling, Bluesky threads, live analytics, blog target
#8
54 files
+2036
-589
| @@ -0,0 +1,113 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | ||
| 5 | const database = db as any | |
| 6 | ||
| 7 | const DAY_MS = 24 * 60 * 60 * 1000 | |
| 8 | ||
| 9 | function dayKey(date: Date): string { | |
| 10 | return date.toISOString().slice(0, 10) | |
| 11 | } | |
| 12 | ||
| 13 | function parseUtc(value: string): Date | null { | |
| 14 | const date = new Date(`${String(value).replace(' ', 'T')}Z`) | |
| 15 | return Number.isNaN(date.getTime()) ? null : date | |
| 16 | } | |
| 17 | ||
| 18 | export default new Action({ | |
| 19 | name: 'Postline Analytics', | |
| 20 | description: 'Publishing analytics computed from real posts and targets.', | |
| 21 | method: 'GET', | |
| 22 | ||
| 23 | async handle() { | |
| 24 | try { | |
| 25 | const since = new Date(Date.now() - 30 * DAY_MS) | |
| 26 | const sinceKey = `${dayKey(since)} 00:00:00` | |
| 27 | ||
| 28 | const posts = await database | |
| 29 | .selectFrom('posts') | |
| 30 | .select(['id', 'published_at']) | |
| 31 | .where('status', '=', 'published') | |
| 32 | .where('published_at', '>=', sinceKey) | |
| 33 | .execute() | |
| 34 | ||
| 35 | const targets = await database | |
| 36 | .selectFrom('post_targets') | |
| 37 | .select(['provider', 'status', 'updated_at']) | |
| 38 | .where('status', '=', 'published') | |
| 39 | .execute() | |
| 40 | ||
| 41 | // Posts published per day over the last 30 days (gaps filled with 0). | |
| 42 | const byDay = new Map<string, number>() | |
| 43 | for (const post of posts) { | |
| 44 | const date = parseUtc(post.published_at) | |
| 45 | if (date) byDay.set(dayKey(date), (byDay.get(dayKey(date)) || 0) + 1) | |
| 46 | } | |
| 47 | const series: Array<{ label: string, value: number }> = [] | |
| 48 | for (let offset = 29; offset >= 0; offset--) { | |
| 49 | const date = new Date(Date.now() - offset * DAY_MS) | |
| 50 | series.push({ | |
| 51 | label: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }), | |
| 52 | value: byDay.get(dayKey(date)) || 0, | |
| 53 | }) | |
| 54 | } | |
| 55 | ||
| 56 | const weekAgo = Date.now() - 7 * DAY_MS | |
| 57 | const twoWeeksAgo = Date.now() - 14 * DAY_MS | |
| 58 | let postsThisWeek = 0 | |
| 59 | let postsLastWeek = 0 | |
| 60 | for (const post of posts) { | |
| 61 | const time = parseUtc(post.published_at)?.getTime() | |
| 62 | if (!time) continue | |
| 63 | if (time >= weekAgo) postsThisWeek += 1 | |
| 64 | else if (time >= twoWeeksAgo) postsLastWeek += 1 | |
| 65 | } | |
| 66 | ||
| 67 | // Channel mix across all published targets. | |
| 68 | const byProvider = new Map<string, number>() | |
| 69 | for (const target of targets) | |
| 70 | byProvider.set(target.provider, (byProvider.get(target.provider) || 0) + 1) | |
| 71 | const totalTargets = targets.length | |
| 72 | const channelMix = [...byProvider.entries()] | |
| 73 | .sort((a, b) => b[1] - a[1]) | |
| 74 | .map(([provider, count]) => ({ | |
| 75 | provider, | |
| 76 | published: count, | |
| 77 | share: totalTargets ? Math.round((count / totalTargets) * 100) : 0, | |
| 78 | })) | |
| 79 | ||
| 80 | // Publish-hour histogram (UTC) → best posting windows. | |
| 81 | const byHour = new Map<number, number>() | |
| 82 | for (const target of targets) { | |
| 83 | const date = parseUtc(target.updated_at) | |
| 84 | if (date) byHour.set(date.getUTCHours(), (byHour.get(date.getUTCHours()) || 0) + 1) | |
| 85 | } | |
| 86 | const windows = [...byHour.entries()] | |
| 87 | .sort((a, b) => b[1] - a[1]) | |
| 88 | .slice(0, 3) | |
| 89 | .map(([hour, count]) => ({ hour, count })) | |
| 90 | ||
| 91 | return response.json({ | |
| 92 | ok: true, | |
| 93 | data: { | |
| 94 | stats: { | |
| 95 | postsThisWeek, | |
| 96 | postsLastWeek, | |
| 97 | bestChannel: channelMix[0] || null, | |
| 98 | bestHour: windows[0] || null, | |
| 99 | }, | |
| 100 | series, | |
| 101 | channelMix, | |
| 102 | windows, | |
| 103 | }, | |
| 104 | }) | |
| 105 | } | |
| 106 | catch (error) { | |
| 107 | return response.json({ | |
| 108 | ok: false, | |
| 109 | error: error instanceof Error ? error.message : String(error), | |
| 110 | }, { status: 500 }) | |
| 111 | } | |
| 112 | }, | |
| 113 | }) | |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { blog } from '../../Services/Social/BlogService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Blog List', | |
| 7 | description: 'List published blog posts.', | |
| 8 | method: 'GET', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | const items = await blog.list() | |
| 13 | return response.json({ ok: true, data: { items } }) | |
| 14 | } | |
| 15 | catch (error) { | |
| 16 | return response.json({ | |
| 17 | ok: false, | |
| 18 | error: error instanceof Error ? error.message : String(error), | |
| 19 | }, { status: 500 }) | |
| 20 | } | |
| 21 | }, | |
| 22 | }) | |
| @@ -35,8 +35,19 @@ export default new Action({ | ||
| 35 | 35 | ? [{ url: imageUrl, altText: imageAlt || undefined }] |
| 36 | 36 | : undefined |
| 37 | 37 | |
| 38 | // Optional multi-segment thread: JSON array of post texts. Segments are | |
| 39 | // reply-chained on providers that support it (Bluesky). | |
| 40 | let thread: string[] = [] | |
| 38 | 41 | try { |
| 39 | const data = await crosspost.publish(text, providers, { external, media }) | |
| 42 | const parsed = JSON.parse(String(request.get('thread') || '[]')) | |
| 43 | if (Array.isArray(parsed)) thread = parsed.map(value => String(value).trim()).filter(Boolean) | |
| 44 | } | |
| 45 | catch {} | |
| 46 | ||
| 47 | try { | |
| 48 | const data = thread.length > 1 | |
| 49 | ? await crosspost.publishThread(thread, providers, { external, media }) | |
| 50 | : await crosspost.publish(thread[0] || text, providers, { external, media }) | |
| 40 | 51 | // Surface a partial-failure (some targets failed) as a 207-style payload |
| 41 | 52 | // while still returning 200 so the client can report per-provider state. |
| 42 | 53 | const allFailed = data.results.length > 0 && data.results.every(result => !result.ok) |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { Action } from '@stacksjs/actions' |
| 2 | 2 | import { response } from '@stacksjs/router' |
| 3 | import { blog } from '../../Services/Social/BlogService' | |
| 3 | 4 | import { bluesky } from '../../Services/Social/BlueskyService' |
| 4 | 5 | import { instagram } from '../../Services/Social/InstagramService' |
| 5 | 6 | import { linkedin } from '../../Services/Social/LinkedInService' |
| @@ -17,6 +18,7 @@ export default new Action({ | ||
| 17 | 18 | linkedin.status(), |
| 18 | 19 | instagram.status(), |
| 19 | 20 | threads.status(), |
| 21 | blog.status(), | |
| 20 | 22 | ]) |
| 21 | 23 | |
| 22 | 24 | return response.json({ ok: true, data: { providers } }) |
| @@ -0,0 +1,27 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | import { postQueue } from '../../Services/Social/QueueService' | |
| 5 | ||
| 6 | export default new Action({ | |
| 7 | name: 'Postline Queue Delete', | |
| 8 | description: 'Remove a draft, scheduled, or failed post from the queue.', | |
| 9 | method: 'POST', | |
| 10 | ||
| 11 | async handle(request: RequestInstance) { | |
| 12 | const id = Number(request.get('id') || 0) | |
| 13 | if (!id) | |
| 14 | return response.json({ ok: false, error: 'Post id is required.' }, { status: 422 }) | |
| 15 | ||
| 16 | try { | |
| 17 | await postQueue.remove(id) | |
| 18 | return response.json({ ok: true, data: { id } }) | |
| 19 | } | |
| 20 | catch (error) { | |
| 21 | return response.json({ | |
| 22 | ok: false, | |
| 23 | error: error instanceof Error ? error.message : String(error), | |
| 24 | }, { status: 422 }) | |
| 25 | } | |
| 26 | }, | |
| 27 | }) | |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { postQueue } from '../../Services/Social/QueueService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Queue List', | |
| 7 | description: 'List recent posts with their per-provider targets.', | |
| 8 | method: 'GET', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | const items = await postQueue.list() | |
| 13 | return response.json({ ok: true, data: { items } }) | |
| 14 | } | |
| 15 | catch (error) { | |
| 16 | return response.json({ | |
| 17 | ok: false, | |
| 18 | error: error instanceof Error ? error.message : String(error), | |
| 19 | }, { status: 500 }) | |
| 20 | } | |
| 21 | }, | |
| 22 | }) | |
| @@ -0,0 +1,28 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | import { postQueue } from '../../Services/Social/QueueService' | |
| 5 | ||
| 6 | export default new Action({ | |
| 7 | name: 'Postline Queue Publish Now', | |
| 8 | description: 'Publish a draft, scheduled, or failed post immediately.', | |
| 9 | method: 'POST', | |
| 10 | ||
| 11 | async handle(request: RequestInstance) { | |
| 12 | const id = Number(request.get('id') || 0) | |
| 13 | if (!id) | |
| 14 | return response.json({ ok: false, error: 'Post id is required.' }, { status: 422 }) | |
| 15 | ||
| 16 | try { | |
| 17 | const data = await postQueue.publishNow(id) | |
| 18 | const allFailed = data.results.length > 0 && data.results.every(result => !result.ok) | |
| 19 | return response.json({ ok: !allFailed, data }, { status: allFailed ? 422 : 200 }) | |
| 20 | } | |
| 21 | catch (error) { | |
| 22 | return response.json({ | |
| 23 | ok: false, | |
| 24 | error: error instanceof Error ? error.message : String(error), | |
| 25 | }, { status: 422 }) | |
| 26 | } | |
| 27 | }, | |
| 28 | }) | |
| @@ -0,0 +1,35 @@ | ||
| 1 | import type { SocialProvider } from '../../Support/Social/types' | |
| 2 | import type { RequestInstance } from '@stacksjs/types' | |
| 3 | import { Action } from '@stacksjs/actions' | |
| 4 | import { response } from '@stacksjs/router' | |
| 5 | import { crosspostProviders } from '../../Services/Social/CrosspostService' | |
| 6 | import { postQueue } from '../../Services/Social/QueueService' | |
| 7 | ||
| 8 | export default new Action({ | |
| 9 | name: 'Postline Queue Save', | |
| 10 | description: 'Save a post as a draft or schedule it for later publishing.', | |
| 11 | method: 'POST', | |
| 12 | ||
| 13 | async handle(request: RequestInstance) { | |
| 14 | const text = String(request.get('text') || request.get('body') || '').trim() | |
| 15 | ||
| 16 | const available = new Set<string>(crosspostProviders()) | |
| 17 | const providers = String(request.get('providers') || request.get('provider') || '') | |
| 18 | .split(',') | |
| 19 | .map(value => value.trim().toLowerCase()) | |
| 20 | .filter(value => available.has(value)) as SocialProvider[] | |
| 21 | ||
| 22 | const scheduledAt = String(request.get('scheduled_at') || '').trim() || null | |
| 23 | ||
| 24 | try { | |
| 25 | const data = await postQueue.save({ text, providers, scheduledAt }) | |
| 26 | return response.json({ ok: true, data }) | |
| 27 | } | |
| 28 | catch (error) { | |
| 29 | return response.json({ | |
| 30 | ok: false, | |
| 31 | error: error instanceof Error ? error.message : String(error), | |
| 32 | }, { status: 422 }) | |
| 33 | } | |
| 34 | }, | |
| 35 | }) | |
| @@ -0,0 +1,19 @@ | ||
| 1 | import { Job } from '@stacksjs/queue' | |
| 2 | import { Every } from '@stacksjs/types' | |
| 3 | import { postQueue } from '../Services/Social/QueueService' | |
| 4 | ||
| 5 | export default new Job({ | |
| 6 | name: 'PublishScheduledPosts', | |
| 7 | description: 'Publish scheduled Postline posts whose time has come', | |
| 8 | queue: 'default', | |
| 9 | tries: 1, | |
| 10 | backoff: 3, | |
| 11 | rate: Every.Minute, | |
| 12 | ||
| 13 | handle: async () => { | |
| 14 | const { published, failed } = await postQueue.publishDue() | |
| 15 | if (published || failed) | |
| 16 | console.log(`[postline] scheduled publish run: ${published} published, ${failed} failed`) | |
| 17 | return { published, failed } | |
| 18 | }, | |
| 19 | }) | |
| @@ -0,0 +1,65 @@ | ||
| 1 | import { defineModel } from '@stacksjs/orm' | |
| 2 | import { schema } from '@stacksjs/validation' | |
| 3 | ||
| 4 | export default defineModel({ | |
| 5 | name: 'BlogPost', | |
| 6 | table: 'blog_posts', | |
| 7 | primaryKey: 'id', | |
| 8 | autoIncrement: true, | |
| 9 | ||
| 10 | indexes: [ | |
| 11 | { name: 'blog_posts_status_published_at_index', columns: ['status', 'published_at'] }, | |
| 12 | ], | |
| 13 | ||
| 14 | traits: { | |
| 15 | useUuid: true, | |
| 16 | useTimestamps: true, | |
| 17 | useApi: { | |
| 18 | uri: 'blog-posts', | |
| 19 | routes: ['index', 'show'], | |
| 20 | }, | |
| 21 | }, | |
| 22 | ||
| 23 | belongsTo: ['Post', 'Account'], | |
| 24 | ||
| 25 | attributes: { | |
| 26 | title: { | |
| 27 | required: true, | |
| 28 | fillable: true, | |
| 29 | validation: { rule: schema.string().required().max(200) }, | |
| 30 | factory: faker => faker.lorem.sentence({ min: 3, max: 8 }), | |
| 31 | }, | |
| 32 | slug: { | |
| 33 | required: true, | |
| 34 | fillable: true, | |
| 35 | unique: true, | |
| 36 | validation: { rule: schema.string().required().max(220) }, | |
| 37 | factory: faker => faker.lorem.slug(), | |
| 38 | }, | |
| 39 | body: { | |
| 40 | required: true, | |
| 41 | fillable: true, | |
| 42 | validation: { rule: schema.string().required().min(1) }, | |
| 43 | factory: faker => faker.lorem.paragraphs({ min: 2, max: 5 }), | |
| 44 | }, | |
| 45 | excerpt: { | |
| 46 | required: false, | |
| 47 | fillable: true, | |
| 48 | validation: { rule: schema.string().max(500).optional() }, | |
| 49 | factory: faker => faker.lorem.sentences(2), | |
| 50 | }, | |
| 51 | status: { | |
| 52 | required: true, | |
| 53 | fillable: true, | |
| 54 | default: 'draft', | |
| 55 | validation: { rule: schema.enum(['draft', 'published', 'archived']).required() }, | |
| 56 | factory: () => 'published', | |
| 57 | }, | |
| 58 | publishedAt: { | |
| 59 | required: false, | |
| 60 | fillable: true, | |
| 61 | validation: { rule: schema.timestamp().optional() }, | |
| 62 | factory: faker => faker.date.recent({ days: 30 }).toISOString().slice(0, 19).replace('T', ' '), | |
| 63 | }, | |
| 64 | }, | |
| 65 | } as const) | |
| @@ -27,7 +27,7 @@ export default defineModel({ | ||
| 27 | 27 | provider: { |
| 28 | 28 | required: true, |
| 29 | 29 | fillable: true, |
| 30 | validation: { rule: schema.enum(['bluesky', 'twitter', 'mastodon', 'facebook', 'instagram', 'tiktok', 'linkedin']).required() }, | |
| 30 | validation: { rule: schema.enum(['bluesky', 'twitter', 'mastodon', 'facebook', 'instagram', 'tiktok', 'linkedin', 'threads', 'blog']).required() }, | |
| 31 | 31 | factory: faker => faker.helpers.arrayElement(['bluesky', 'twitter', 'mastodon']), |
| 32 | 32 | }, |
| 33 | 33 | status: { |
| @@ -16,6 +16,11 @@ export default function () { | ||
| 16 | 16 | .hourly() |
| 17 | 17 | .setTimeZone('America/Los_Angeles') |
| 18 | 18 | |
| 19 | // Publish scheduled Postline posts as they come due | |
| 20 | schedule | |
| 21 | .job('PublishScheduledPosts') | |
| 22 | .everyMinute() | |
| 23 | ||
| 19 | 24 | // Run a custom action every five minutes |
| 20 | 25 | // schedule.action('CleanupTempFiles').everyFiveMinutes() |
| 21 | 26 | |
| @@ -0,0 +1,162 @@ | ||
| 1 | import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types' | |
| 2 | import { mkdir } from 'node:fs/promises' | |
| 3 | import { join } from 'node:path' | |
| 4 | import process from 'node:process' | |
| 5 | import { db } from '@stacksjs/database' | |
| 6 | import { ensureAccount, now, uuid } from './support' | |
| 7 | ||
| 8 | const database = db as any | |
| 9 | ||
| 10 | // The framework's BunPress blog renders markdown from content/blog/*.md at | |
| 11 | // /blog/<slug> — publishing a file there makes the post immediately live. | |
| 12 | const CONTENT_DIR = join(process.cwd(), 'content/blog') | |
| 13 | ||
| 14 | /** Postline's own blog has no external character ceiling worth enforcing. */ | |
| 15 | const BLOG_CHARACTER_LIMIT = 10000 | |
| 16 | ||
| 17 | function slugify(value: string): string { | |
| 18 | return value | |
| 19 | .toLowerCase() | |
| 20 | .normalize('NFKD') | |
| 21 | .replace(/[̀-ͯ]/g, '') | |
| 22 | .replace(/[^a-z0-9\s-]/g, '') | |
| 23 | .trim() | |
| 24 | .replace(/[\s-]+/g, '-') | |
| 25 | .slice(0, 80) | |
| 26 | .replace(/^-+|-+$/g, '') || 'post' | |
| 27 | } | |
| 28 | ||
| 29 | async function uniqueSlug(base: string): Promise<string> { | |
| 30 | let candidate = base | |
| 31 | for (let attempt = 2; attempt < 50; attempt++) { | |
| 32 | const existing = await database | |
| 33 | .selectFrom('blog_posts') | |
| 34 | .select(['id']) | |
| 35 | .where('slug', '=', candidate) | |
| 36 | .executeTakeFirst() | |
| 37 | if (!existing) return candidate | |
| 38 | candidate = `${base}-${attempt}` | |
| 39 | } | |
| 40 | return `${base}-${uuid().slice(0, 8)}` | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * The blog is "just another crosspost target": publishing writes a | |
| 45 | * `blog_posts` row instead of calling an external API. The first line of | |
| 46 | * the text becomes the title, the rest the body. | |
| 47 | */ | |
| 48 | export class BlogService { | |
| 49 | async status(): Promise<{ provider: 'blog', handle: string, canPublish: boolean, characterLimit: number, configured: boolean }> { | |
| 50 | return { | |
| 51 | provider: 'blog', | |
| 52 | handle: 'Postline Blog', | |
| 53 | canPublish: true, | |
| 54 | characterLimit: BLOG_CHARACTER_LIMIT, | |
| 55 | configured: true, | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | async publishToPost( | |
| 60 | post: { id: number, body: string }, | |
| 61 | _content?: PublishContent, | |
| 62 | ): Promise<CrosspostTargetResult> { | |
| 63 | const targetUuid = uuid() | |
| 64 | const createdAt = now() | |
| 65 | ||
| 66 | await database.insertInto('post_targets').values({ | |
| 67 | uuid: targetUuid, | |
| 68 | provider: 'blog', | |
| 69 | status: 'publishing', | |
| 70 | post_id: post.id, | |
| 71 | created_at: createdAt, | |
| 72 | updated_at: createdAt, | |
| 73 | }).execute() | |
| 74 | ||
| 75 | const target = await database | |
| 76 | .selectFrom('post_targets') | |
| 77 | .selectAll() | |
| 78 | .where('uuid', '=', targetUuid) | |
| 79 | .executeTakeFirstOrThrow() | |
| 80 | ||
| 81 | try { | |
| 82 | const lines = post.body.split('\n').map(line => line.trim()) | |
| 83 | const title = (lines.find(Boolean) || 'Untitled post').slice(0, 200) | |
| 84 | const body = post.body.trim() | |
| 85 | const slug = await uniqueSlug(slugify(title)) | |
| 86 | const accountId = await ensureAccount() | |
| 87 | const publishedAt = now() | |
| 88 | ||
| 89 | // Body minus the title line — the blog theme already renders the title. | |
| 90 | const firstContentIndex = post.body.split('\n').findIndex(line => line.trim()) | |
| 91 | const markdownBody = post.body.split('\n').slice(firstContentIndex + 1).join('\n').trim() || body | |
| 92 | ||
| 93 | await mkdir(CONTENT_DIR, { recursive: true }) | |
| 94 | await Bun.write(join(CONTENT_DIR, `${slug}.md`), [ | |
| 95 | '---', | |
| 96 | `title: ${JSON.stringify(title)}`, | |
| 97 | `date: ${publishedAt.slice(0, 10)}`, | |
| 98 | 'author: Postline', | |
| 99 | '---', | |
| 100 | '', | |
| 101 | markdownBody, | |
| 102 | '', | |
| 103 | ].join('\n')) | |
| 104 | ||
| 105 | await database.insertInto('blog_posts').values({ | |
| 106 | uuid: uuid(), | |
| 107 | title, | |
| 108 | slug, | |
| 109 | body, | |
| 110 | excerpt: body.slice(0, 280), | |
| 111 | status: 'published', | |
| 112 | published_at: publishedAt, | |
| 113 | post_id: post.id, | |
| 114 | account_id: accountId, | |
| 115 | created_at: publishedAt, | |
| 116 | updated_at: publishedAt, | |
| 117 | }).execute() | |
| 118 | ||
| 119 | const url = `/blog/${slug}` | |
| 120 | await database.updateTable('post_targets').set({ | |
| 121 | status: 'published', | |
| 122 | remote_uri: url, | |
| 123 | failure_reason: null, | |
| 124 | updated_at: publishedAt, | |
| 125 | }).where('id', '=', target.id).execute() | |
| 126 | ||
| 127 | return { provider: 'blog', ok: true, url, uri: url, targetId: Number(target.id) } | |
| 128 | } | |
| 129 | catch (error) { | |
| 130 | const message = error instanceof Error ? error.message : String(error) | |
| 131 | await database.updateTable('post_targets').set({ | |
| 132 | status: 'failed', | |
| 133 | failure_reason: message.slice(0, 1000), | |
| 134 | updated_at: now(), | |
| 135 | }).where('id', '=', target.id).execute() | |
| 136 | ||
| 137 | return { provider: 'blog', ok: false, error: message, targetId: Number(target.id) } | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | /** Published blog posts, newest first. */ | |
| 142 | async list(limit = 50): Promise<Array<{ id: number, title: string, slug: string, excerpt: string | null, body: string, publishedAt: string | null }>> { | |
| 143 | const rows = await database | |
| 144 | .selectFrom('blog_posts') | |
| 145 | .selectAll() | |
| 146 | .where('status', '=', 'published') | |
| 147 | .orderBy('published_at', 'desc') | |
| 148 | .limit(limit) | |
| 149 | .execute() | |
| 150 | ||
| 151 | return rows.map((row: any) => ({ | |
| 152 | id: Number(row.id), | |
| 153 | title: String(row.title), | |
| 154 | slug: String(row.slug), | |
| 155 | excerpt: row.excerpt || null, | |
| 156 | body: String(row.body), | |
| 157 | publishedAt: row.published_at || null, | |
| 158 | })) | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | export const blog = new BlogService() | |
| @@ -186,8 +186,10 @@ export class BlueskyService { | ||
| 186 | 186 | const createdAt = now() |
| 187 | 187 | const publishInput = { text: post.body } as Parameters<BlueskyDriver['publish']>[1] & { |
| 188 | 188 | external?: { uri: string, title: string, description?: string } |
| 189 | reply?: { root: { uri: string, cid: string }, parent: { uri: string, cid: string } } | |
| 189 | 190 | } |
| 190 | 191 | if (content?.external) publishInput.external = content.external |
| 192 | if (content?.reply) publishInput.reply = content.reply | |
| 191 | 193 | |
| 192 | 194 | await database.insertInto('post_targets').values({ |
| 193 | 195 | uuid: targetUuid, |
| @@ -230,6 +232,7 @@ export class BlueskyService { | ||
| 230 | 232 | ok: true, |
| 231 | 233 | url: published.url, |
| 232 | 234 | uri: published.uri, |
| 235 | cid: published.cid, | |
| 233 | 236 | targetId: Number(target.id), |
| 234 | 237 | } |
| 235 | 238 | } |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types' |
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | import { blog } from './BlogService' | |
| 4 | 5 | import { bluesky } from './BlueskyService' |
| 5 | 6 | import { instagram } from './InstagramService' |
| 6 | 7 | import { linkedin } from './LinkedInService' |
| @@ -19,6 +20,7 @@ const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = { | ||
| 19 | 20 | linkedin, |
| 20 | 21 | instagram, |
| 21 | 22 | threads, |
| 23 | blog, | |
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 26 | export function crosspostProviders(): SocialProvider[] { |
| @@ -67,11 +69,7 @@ export class CrosspostService { | ||
| 67 | 69 | .where('uuid', '=', postUuid) |
| 68 | 70 | .executeTakeFirstOrThrow() |
| 69 | 71 | |
| 70 | const results: CrosspostTargetResult[] = [] | |
| 71 | for (const provider of selected) { | |
| 72 | const publisher = publishers[provider]! | |
| 73 | results.push(await publisher.publishToPost({ id: Number(post.id), body }, content)) | |
| 74 | } | |
| 72 | const results = await this.publishExisting({ id: Number(post.id), body }, selected, content) | |
| 75 | 73 | |
| 76 | 74 | const anyOk = results.some(result => result.ok) |
| 77 | 75 | const finishedAt = now() |
| @@ -83,6 +81,110 @@ export class CrosspostService { | ||
| 83 | 81 | |
| 84 | 82 | return { postId: Number(post.id), results } |
| 85 | 83 | } |
| 84 | ||
| 85 | /** | |
| 86 | * Publish a multi-post thread. Each segment gets its own `posts` row | |
| 87 | * (grouped by `thread_key`). Providers that support reply chains | |
| 88 | * (Bluesky) receive root/parent refs so segments render as an actual | |
| 89 | * thread; other providers publish each segment independently. | |
| 90 | */ | |
| 91 | async publishThread( | |
| 92 | texts: string[], | |
| 93 | providers: SocialProvider[], | |
| 94 | content?: PublishContent, | |
| 95 | ): Promise<{ postIds: number[], results: CrosspostTargetResult[] }> { | |
| 96 | const segments = texts.map(text => text.trim()).filter(Boolean) | |
| 97 | if (segments.length === 0) throw new Error('Post text is required.') | |
| 98 | ||
| 99 | const selected = providers.filter(provider => publishers[provider]) | |
| 100 | if (selected.length === 0) { | |
| 101 | throw new Error('Select at least one connected provider to publish.') | |
| 102 | } | |
| 103 | ||
| 104 | const accountId = await ensureAccount() | |
| 105 | const threadKey = uuid() | |
| 106 | const postIds: number[] = [] | |
| 107 | const results: CrosspostTargetResult[] = [] | |
| 108 | // Per-provider chain refs; only populated for providers whose results | |
| 109 | // carry a cid (Bluesky). | |
| 110 | const chains = new Map<SocialProvider, { root: { uri: string, cid: string }, parent: { uri: string, cid: string } }>() | |
| 111 | ||
| 112 | for (const [index, body] of segments.entries()) { | |
| 113 | const postUuid = uuid() | |
| 114 | const createdAt = now() | |
| 115 | ||
| 116 | await database.insertInto('posts').values({ | |
| 117 | uuid: postUuid, | |
| 118 | title: body.slice(0, 80), | |
| 119 | body, | |
| 120 | status: 'publishing', | |
| 121 | timezone: env.TZ || 'America/Los_Angeles', | |
| 122 | source: 'composer', | |
| 123 | thread_key: threadKey, | |
| 124 | account_id: accountId, | |
| 125 | created_at: createdAt, | |
| 126 | updated_at: createdAt, | |
| 127 | }).execute() | |
| 128 | ||
| 129 | const post = await database | |
| 130 | .selectFrom('posts') | |
| 131 | .selectAll() | |
| 132 | .where('uuid', '=', postUuid) | |
| 133 | .executeTakeFirstOrThrow() | |
| 134 | postIds.push(Number(post.id)) | |
| 135 | ||
| 136 | const segmentResults: CrosspostTargetResult[] = [] | |
| 137 | for (const provider of selected) { | |
| 138 | const publisher = publishers[provider]! | |
| 139 | const chain = chains.get(provider) | |
| 140 | // Link previews/media only make sense on the first segment. | |
| 141 | const segmentContent: PublishContent | undefined = index === 0 | |
| 142 | ? content | |
| 143 | : chain ? { reply: chain } : undefined | |
| 144 | ||
| 145 | const result = await publisher.publishToPost({ id: Number(post.id), body }, segmentContent) | |
| 146 | segmentResults.push(result) | |
| 147 | ||
| 148 | if (result.ok && result.uri && result.cid) { | |
| 149 | const existing = chains.get(provider) | |
| 150 | chains.set(provider, { | |
| 151 | root: existing?.root || { uri: result.uri, cid: result.cid }, | |
| 152 | parent: { uri: result.uri, cid: result.cid }, | |
| 153 | }) | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | results.push(...segmentResults) | |
| 158 | const anyOk = segmentResults.some(result => result.ok) | |
| 159 | const finishedAt = now() | |
| 160 | await database.updateTable('posts').set({ | |
| 161 | status: anyOk ? 'published' : 'failed', | |
| 162 | ...(anyOk ? { published_at: finishedAt } : {}), | |
| 163 | updated_at: finishedAt, | |
| 164 | }).where('id', '=', post.id).execute() | |
| 165 | } | |
| 166 | ||
| 167 | return { postIds, results } | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * Publish an existing `posts` row to the given providers. Used by the | |
| 172 | * fresh-publish path above and by the queue when a scheduled or drafted | |
| 173 | * post is (re)published. | |
| 174 | */ | |
| 175 | async publishExisting( | |
| 176 | post: { id: number, body: string }, | |
| 177 | providers: SocialProvider[], | |
| 178 | content?: PublishContent, | |
| 179 | ): Promise<CrosspostTargetResult[]> { | |
| 180 | const results: CrosspostTargetResult[] = [] | |
| 181 | for (const provider of providers) { | |
| 182 | const publisher = publishers[provider] | |
| 183 | if (!publisher) continue | |
| 184 | results.push(await publisher.publishToPost({ id: post.id, body: post.body }, content)) | |
| 185 | } | |
| 186 | return results | |
| 187 | } | |
| 86 | 188 | } |
| 87 | 189 | |
| 88 | 190 | export const crosspost = new CrosspostService() |
| @@ -0,0 +1,235 @@ | ||
| 1 | import type { CrosspostTargetResult, SocialProvider } from '../../Support/Social/types' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { env } from '@stacksjs/env' | |
| 4 | import { crosspost, crosspostProviders } from './CrosspostService' | |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 6 | ||
| 7 | const database = db as any | |
| 8 | ||
| 9 | /** Post statuses a user may still act on (publish now, delete). */ | |
| 10 | const ACTIONABLE = new Set(['draft', 'scheduled', 'failed']) | |
| 11 | ||
| 12 | export interface QueueTargetView { | |
| 13 | provider: SocialProvider | |
| 14 | status: string | |
| 15 | remoteUri: string | null | |
| 16 | failureReason: string | null | |
| 17 | } | |
| 18 | ||
| 19 | export interface QueueItemView { | |
| 20 | id: number | |
| 21 | title: string | null | |
| 22 | body: string | |
| 23 | status: string | |
| 24 | scheduledAt: string | null | |
| 25 | publishedAt: string | null | |
| 26 | createdAt: string | |
| 27 | providers: QueueTargetView[] | |
| 28 | } | |
| 29 | ||
| 30 | export interface SaveQueueInput { | |
| 31 | text: string | |
| 32 | providers: SocialProvider[] | |
| 33 | /** UTC `YYYY-MM-DD HH:MM:SS`; omitted → saved as a draft. */ | |
| 34 | scheduledAt?: string | null | |
| 35 | } | |
| 36 | ||
| 37 | export class QueueService { | |
| 38 | /** | |
| 39 | * Persist a post without publishing it. With `scheduledAt` the post is | |
| 40 | * queued (`scheduled`) and picked up by the PublishScheduledPosts job when | |
| 41 | * due; without it the post is stored as a `draft`. One placeholder | |
| 42 | * `post_targets` row per provider records where the post should go — the | |
| 43 | * real result rows replace them at publish time. | |
| 44 | */ | |
| 45 | async save(input: SaveQueueInput): Promise<{ postId: number, status: 'draft' | 'scheduled', scheduledAt: string | null }> { | |
| 46 | const body = input.text.trim() | |
| 47 | if (!body) | |
| 48 | throw new Error('Post text is required.') | |
| 49 | ||
| 50 | const available = new Set<string>(crosspostProviders()) | |
| 51 | const providers = input.providers.filter(provider => available.has(provider)) | |
| 52 | if (providers.length === 0) | |
| 53 | throw new Error('Select at least one connected provider.') | |
| 54 | ||
| 55 | const scheduledAt = input.scheduledAt?.trim() || null | |
| 56 | if (scheduledAt) { | |
| 57 | if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(scheduledAt)) | |
| 58 | throw new Error('Scheduled time must be a UTC YYYY-MM-DD HH:MM:SS timestamp.') | |
| 59 | if (scheduledAt <= now()) | |
| 60 | throw new Error('Scheduled time must be in the future.') | |
| 61 | } | |
| 62 | ||
| 63 | const status = scheduledAt ? 'scheduled' as const : 'draft' as const | |
| 64 | const accountId = await ensureAccount() | |
| 65 | const postUuid = uuid() | |
| 66 | const createdAt = now() | |
| 67 | ||
| 68 | await database.insertInto('posts').values({ | |
| 69 | uuid: postUuid, | |
| 70 | title: body.slice(0, 80), | |
| 71 | body, | |
| 72 | status, | |
| 73 | scheduled_at: scheduledAt, | |
| 74 | timezone: env.TZ || 'America/Los_Angeles', | |
| 75 | source: 'composer', | |
| 76 | account_id: accountId, | |
| 77 | created_at: createdAt, | |
| 78 | updated_at: createdAt, | |
| 79 | }).execute() | |
| 80 | ||
| 81 | const post = await database | |
| 82 | .selectFrom('posts') | |
| 83 | .select(['id']) | |
| 84 | .where('uuid', '=', postUuid) | |
| 85 | .executeTakeFirstOrThrow() | |
| 86 | ||
| 87 | for (const provider of providers) { | |
| 88 | await database.insertInto('post_targets').values({ | |
| 89 | uuid: uuid(), | |
| 90 | provider, | |
| 91 | status, | |
| 92 | scheduled_at: scheduledAt, | |
| 93 | post_id: post.id, | |
| 94 | created_at: createdAt, | |
| 95 | updated_at: createdAt, | |
| 96 | }).execute() | |
| 97 | } | |
| 98 | ||
| 99 | return { postId: Number(post.id), status, scheduledAt } | |
| 100 | } | |
| 101 | ||
| 102 | /** Recent posts with their per-provider targets, newest first. */ | |
| 103 | async list(limit = 50): Promise<QueueItemView[]> { | |
| 104 | const posts = await database | |
| 105 | .selectFrom('posts') | |
| 106 | .selectAll() | |
| 107 | .where('status', '!=', 'archived') | |
| 108 | .orderBy('created_at', 'desc') | |
| 109 | .limit(limit) | |
| 110 | .execute() | |
| 111 | ||
| 112 | if (posts.length === 0) | |
| 113 | return [] | |
| 114 | ||
| 115 | const targets = await database | |
| 116 | .selectFrom('post_targets') | |
| 117 | .selectAll() | |
| 118 | .where('post_id', 'in', posts.map((post: any) => post.id)) | |
| 119 | .execute() | |
| 120 | ||
| 121 | return posts.map((post: any): QueueItemView => ({ | |
| 122 | id: Number(post.id), | |
| 123 | title: post.title || null, | |
| 124 | body: String(post.body || ''), | |
| 125 | status: String(post.status), | |
| 126 | scheduledAt: post.scheduled_at || null, | |
| 127 | publishedAt: post.published_at || null, | |
| 128 | createdAt: String(post.created_at), | |
| 129 | providers: targets | |
| 130 | .filter((target: any) => Number(target.post_id) === Number(post.id)) | |
| 131 | .map((target: any): QueueTargetView => ({ | |
| 132 | provider: target.provider, | |
| 133 | status: String(target.status), | |
| 134 | remoteUri: target.remote_uri || null, | |
| 135 | failureReason: target.failure_reason || null, | |
| 136 | })), | |
| 137 | })) | |
| 138 | } | |
| 139 | ||
| 140 | /** Delete a draft, scheduled, or failed post along with its targets. */ | |
| 141 | async remove(id: number): Promise<void> { | |
| 142 | const post = await this.findActionable(id) | |
| 143 | await database.deleteFrom('post_targets').where('post_id', '=', post.id).execute() | |
| 144 | await database.deleteFrom('posts').where('id', '=', post.id).execute() | |
| 145 | } | |
| 146 | ||
| 147 | /** Publish a draft/scheduled/failed post immediately. */ | |
| 148 | async publishNow(id: number): Promise<{ postId: number, results: CrosspostTargetResult[] }> { | |
| 149 | const post = await this.findActionable(id) | |
| 150 | ||
| 151 | const placeholders = await database | |
| 152 | .selectFrom('post_targets') | |
| 153 | .selectAll() | |
| 154 | .where('post_id', '=', post.id) | |
| 155 | .where('status', 'in', ['draft', 'scheduled', 'failed']) | |
| 156 | .execute() | |
| 157 | ||
| 158 | const providers = [...new Set(placeholders.map((target: any) => target.provider))] as SocialProvider[] | |
| 159 | if (providers.length === 0) | |
| 160 | throw new Error('This post has no pending targets to publish.') | |
| 161 | ||
| 162 | await database.updateTable('posts').set({ | |
| 163 | status: 'publishing', | |
| 164 | updated_at: now(), | |
| 165 | }).where('id', '=', post.id).execute() | |
| 166 | ||
| 167 | // The provider services insert fresh result rows; drop the placeholders | |
| 168 | // so the queue doesn't show both. (Deleted one-by-one — the query | |
| 169 | // builder mis-compiles `where in` on DELETE statements.) | |
| 170 | for (const target of placeholders) { | |
| 171 | await database.deleteFrom('post_targets').where('id', '=', target.id).execute() | |
| 172 | } | |
| 173 | ||
| 174 | const results = await crosspost.publishExisting( | |
| 175 | { id: Number(post.id), body: String(post.body) }, | |
| 176 | providers, | |
| 177 | ) | |
| 178 | ||
| 179 | const anyOk = results.some(result => result.ok) | |
| 180 | const finishedAt = now() | |
| 181 | await database.updateTable('posts').set({ | |
| 182 | status: anyOk ? 'published' : 'failed', | |
| 183 | ...(anyOk ? { published_at: finishedAt } : {}), | |
| 184 | updated_at: finishedAt, | |
| 185 | }).where('id', '=', post.id).execute() | |
| 186 | ||
| 187 | return { postId: Number(post.id), results } | |
| 188 | } | |
| 189 | ||
| 190 | /** | |
| 191 | * Publish every scheduled post whose time has come. Called by the | |
| 192 | * PublishScheduledPosts job every minute; failures on one post never block | |
| 193 | * the rest. | |
| 194 | */ | |
| 195 | async publishDue(): Promise<{ published: number, failed: number }> { | |
| 196 | const due = await database | |
| 197 | .selectFrom('posts') | |
| 198 | .select(['id']) | |
| 199 | .where('status', '=', 'scheduled') | |
| 200 | .where('scheduled_at', '<=', now()) | |
| 201 | .execute() | |
| 202 | ||
| 203 | let published = 0 | |
| 204 | let failed = 0 | |
| 205 | for (const post of due) { | |
| 206 | try { | |
| 207 | const { results } = await this.publishNow(Number(post.id)) | |
| 208 | if (results.some(result => result.ok)) published += 1 | |
| 209 | else failed += 1 | |
| 210 | } | |
| 211 | catch { | |
| 212 | failed += 1 | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 216 | return { published, failed } | |
| 217 | } | |
| 218 | ||
| 219 | private async findActionable(id: number): Promise<any> { | |
| 220 | const post = await database | |
| 221 | .selectFrom('posts') | |
| 222 | .selectAll() | |
| 223 | .where('id', '=', id) | |
| 224 | .executeTakeFirst() | |
| 225 | ||
| 226 | if (!post) | |
| 227 | throw new Error('Post not found.') | |
| 228 | if (!ACTIONABLE.has(String(post.status))) | |
| 229 | throw new Error('Only draft, scheduled, or failed posts can be changed.') | |
| 230 | ||
| 231 | return post | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | export const postQueue = new QueueService() | |
| @@ -7,6 +7,7 @@ export type SocialProvider = | ||
| 7 | 7 | | 'tiktok' |
| 8 | 8 | | 'linkedin' |
| 9 | 9 | | 'threads' |
| 10 | | 'blog' | |
| 10 | 11 | |
| 11 | 12 | export interface SocialIdentityCredentials { |
| 12 | 13 | handle: string |
| @@ -60,6 +61,11 @@ export interface PublishContent { | ||
| 60 | 61 | url: string |
| 61 | 62 | altText?: string |
| 62 | 63 | }> |
| 64 | /** Thread chaining refs — only honored by providers that support replies. */ | |
| 65 | reply?: { | |
| 66 | root: { uri: string, cid: string } | |
| 67 | parent: { uri: string, cid: string } | |
| 68 | } | |
| 63 | 69 | } |
| 64 | 70 | |
| 65 | 71 | /** Outcome of publishing one post to a single provider during a crosspost. */ |
| @@ -68,6 +74,7 @@ export interface CrosspostTargetResult { | ||
| 68 | 74 | ok: boolean |
| 69 | 75 | url?: string |
| 70 | 76 | uri?: string |
| 77 | cid?: string | |
| 71 | 78 | targetId?: number |
| 72 | 79 | error?: string |
| 73 | 80 | } |
bun.lockmodified+4-7
| @@ -6,7 +6,6 @@ | ||
| 6 | 6 | "name": "stacks", |
| 7 | 7 | "dependencies": { |
| 8 | 8 | "@stacksjs/bun-router": "^0.0.16", |
| 9 | "@ts-charts/charts": "link:@ts-charts/charts", | |
| 10 | 9 | "craft-native": "^0.0.37", |
| 11 | 10 | "stacks": "workspace:*", |
| 12 | 11 | }, |
| @@ -1123,7 +1122,7 @@ | ||
| 1123 | 1122 | |
| 1124 | 1123 | "@stacksjs/docs": ["@stacksjs/docs@workspace:storage/framework/core/docs"], |
| 1125 | 1124 | |
| 1126 | "@stacksjs/dtsx": ["@stacksjs/dtsx@0.9.23", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0" }, "peerDependencies": { "typescript": ">=5.9.3" }, "bin": { "dtsx": "./dist/bin/cli.js" } }, "sha512-VH4RGQlO3wqbsZybz3laPOrpDfNNEjE69iq6gPccIBvinTU5XPlbx/T1hOShl2FGHqPaIa3glgNK0pVA9eEVcA=="], | |
| 1125 | "@stacksjs/dtsx": ["@stacksjs/dtsx@0.9.35", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0" }, "bin": { "dtsx": "./dist/bin/cli.js" } }, "sha512-R1OOEO/lOSQJe9v6Nu1JQMesy8h+9qnD8WgHssYpCc447bl9K5F+pWnDK8Vdz2crljeTIiXmhgIptWmM/Ep8HA=="], | |
| 1127 | 1126 | |
| 1128 | 1127 | "@stacksjs/email": ["@stacksjs/email@workspace:storage/framework/core/email"], |
| 1129 | 1128 | |
| @@ -1269,8 +1268,6 @@ | ||
| 1269 | 1268 | |
| 1270 | 1269 | "@ts-charts/array": ["@ts-charts/array@0.2.0", "", { "dependencies": { "@ts-charts/internmap": "0.2.0" } }, "sha512-rcYZWzYx8pjqpcJOLyNWcxeCwEGFte1HANtMxTiiXswQ990VkWYK1iVY13W4EwUwJU0TGXdg1e3DGjVXtrwc5w=="], |
| 1271 | 1270 | |
| 1272 | "@ts-charts/charts": ["@ts-charts/charts@link:@ts-charts/charts", {}], | |
| 1273 | ||
| 1274 | 1271 | "@ts-charts/color": ["@ts-charts/color@0.2.0", "", {}, "sha512-Oe/gbCmTUhlkqQk1xFg+MSObDm5qEAlvBxmGiCDRsPZaPNrTlGlAiuiynPUfb9nl5ByZ39y3aPJFQjmq/RG+WA=="], |
| 1275 | 1272 | |
| 1276 | 1273 | "@ts-charts/format": ["@ts-charts/format@0.2.0", "", {}, "sha512-sHCLKTsjUEai3v+MZEr9t/Gb/tbmGfPENji+mqxElJ4/ji80drIcqFvhvIGMgh68l7weFsJ3uBcNtO9/tGwRzQ=="], |
| @@ -1343,7 +1340,7 @@ | ||
| 1343 | 1340 | |
| 1344 | 1341 | "bun-plugin-auto-imports": ["bun-plugin-auto-imports@0.4.1", "", {}, "sha512-S/+M76UtZc+1SBpqLKOmFXuEVBDnEBqjwnD3dsbHQao8vV+C46uN0JMQNJUZLdhR35KChZh1mHVJuiNqBrOSXQ=="], |
| 1345 | 1342 | |
| 1346 | "bun-plugin-dtsx": ["bun-plugin-dtsx@0.9.23", "", { "dependencies": { "@stacksjs/dtsx": "0.9.23" } }, "sha512-RK7uvwwfMc7FP4ONZWBMgG1Midfc5QtIlMDdCxrRoT3hDTGrn91JI1vgOau2ZGHTt/RyzsSmrQDe3DkanUH20A=="], | |
| 1343 | "bun-plugin-dtsx": ["bun-plugin-dtsx@0.9.26", "", { "dependencies": { "@stacksjs/dtsx": "0.9.26" } }, "sha512-dOlA+gx3mju+Be0uoZqsuQ3U+GPu08wv7QQwA1k569tw8Bv9GwVWJz0jjQw6tzJi9Rvwfl31MVXu9rHYQXSKow=="], | |
| 1347 | 1344 | |
| 1348 | 1345 | "bun-plugin-stx": ["bun-plugin-stx@0.2.84", "", { "dependencies": { "@stacksjs/stx": "^0.2.72", "stx-router": "^0.2.72" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-J3ZOCxuMOmjziSJsMToymsvkenlI4ELymwnuRWfq3j35k9YDzowGr5ORZQ0SpdngrBw1bSb+tj27L7FfYqyvEg=="], |
| 1349 | 1346 | |
| @@ -1579,8 +1576,6 @@ | ||
| 1579 | 1576 | |
| 1580 | 1577 | "@stacksjs/clarity/bunfig": ["bunfig@0.15.11", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./bin/cli.js" } }, "sha512-yJmUTNoHJdJB3r4on6kHT1zqqKEhA+5GpYHI2cGQC5l7ayTYYdOzd6gUwYFMg3KD8gmwJS6b7YzQMwVZiiqz4w=="], |
| 1581 | 1578 | |
| 1582 | "@stacksjs/dtsx/@stacksjs/clapp": ["@stacksjs/clapp@0.2.9", "", {}, "sha512-tY/Cl7ANduISwYA2flAthZMBh7VnLho1op2wjbGDwugl995+s4sfEvkTMoBgwdniKmG9HBQmNE1dRwVH+9l55g=="], | |
| 1583 | ||
| 1584 | 1579 | "@stacksjs/logsmith/@stacksjs/clapp": ["@stacksjs/clapp@0.2.6", "", {}, "sha512-W5A5C4hn21jurKd6ntCTGMDPEu/2zUn5FWjweWtwkPZ6yP8/q0B/2kecTClAG2UoUPREEmlr2RlGi6eKux1Jpg=="], |
| 1585 | 1580 | |
| 1586 | 1581 | "@stacksjs/logsmith/bunfig": ["bunfig@0.15.11", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./bin/cli.js" } }, "sha512-yJmUTNoHJdJB3r4on6kHT1zqqKEhA+5GpYHI2cGQC5l7ayTYYdOzd6gUwYFMg3KD8gmwJS6b7YzQMwVZiiqz4w=="], |
| @@ -1615,6 +1610,8 @@ | ||
| 1615 | 1610 | |
| 1616 | 1611 | "buddy-bot/bunfig": ["bunfig@0.15.11", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./bin/cli.js" } }, "sha512-yJmUTNoHJdJB3r4on6kHT1zqqKEhA+5GpYHI2cGQC5l7ayTYYdOzd6gUwYFMg3KD8gmwJS6b7YzQMwVZiiqz4w=="], |
| 1617 | 1612 | |
| 1613 | "bun-plugin-dtsx/@stacksjs/dtsx": ["@stacksjs/dtsx@0.9.26", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0" }, "peerDependencies": { "typescript": ">=5.9.3" }, "bin": { "dtsx": "./dist/bin/cli.js" } }, "sha512-MOUxZn8BhXpFYM+vh7cnQLUOLjZ4G6q76V+4OypkRiu/Gvpek9ywBQLbmv8iWLjT6gXlFMpEdw33o3GKKLpMCw=="], | |
| 1614 | ||
| 1618 | 1615 | "bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], |
| 1619 | 1616 | |
| 1620 | 1617 | "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], |
| @@ -28,12 +28,48 @@ const settingsActive = currentPath === '/settings' || currentPath.startsWith('/s | ||
| 28 | 28 | </script> |
| 29 | 29 | |
| 30 | 30 | <template> |
| 31 | <aside class="flex overflow-hidden relative sticky top-0 flex-col pb-5 pt-[68px] px-5 h-screen min-w-0 bg-[#f3f3f1]/48 backdrop-blur-3xl backdrop-saturate-150 duration-200 ease-out transition-[width,padding]" aria-label="Postline" data-postline-sidebar data-window-drag-region> | |
| 31 | <div class="contents"> | |
| 32 | <nav class="flex lg:hidden overflow-x-auto gap-1.5 items-center px-3 py-2 bg-[#f3f3f1]" aria-label="Primary" data-postline-mobile-nav> | |
| 33 | <span class="grid place-items-center shrink-0 mr-1 h-8 w-8 font-bold text-[13px] text-white bg-neutral-950 rounded-lg" aria-hidden="true">P</span> | |
| 34 | @foreach (navLinks as link) | |
| 35 | <a | |
| 36 | href="" | |
| 37 | data-stx-link | |
| 38 | data-stx-active-class="text-zinc-950 bg-white shadow-sm" | |
| 39 | data-stx-exact-active-class="text-zinc-950 bg-white shadow-sm" | |
| 40 | class="inline-flex shrink-0 gap-1.5 items-center px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-full transition-colors hover:bg-white/60 " | |
| 41 | aria-current="" | |
| 42 | > | |
| 43 | <span class="h-4 w-4 " aria-hidden="true"></span> | |
| 44 | <span>{{ link.label }}</span> | |
| 45 | </a> | |
| 46 | @endforeach | |
| 47 | <a | |
| 48 | href="/blog" | |
| 49 | class="inline-flex gap-1.5 items-center shrink-0 px-3 py-1.5 font-medium text-xs text-zinc-600 whitespace-nowrap hover:bg-white/60 rounded-full transition-colors" | |
| 50 | > | |
| 51 | <span class="h-4 w-4 i-hugeicons-quill-write-02" aria-hidden="true"></span> | |
| 52 | <span>Blog</span> | |
| 53 | </a> | |
| 54 | <a | |
| 55 | href="/settings" | |
| 56 | data-stx-link | |
| 57 | data-stx-active-class="text-zinc-950 bg-white shadow-sm" | |
| 58 | data-stx-exact-active-class="text-zinc-950 bg-white shadow-sm" | |
| 59 | class="inline-flex shrink-0 gap-1.5 items-center px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-full transition-colors hover:bg-white/60 text-zinc-600" | |
| 60 | aria-current="false" | |
| 61 | > | |
| 62 | <span class="h-4 w-4 i-hugeicons-settings-02" aria-hidden="true"></span> | |
| 63 | <span>Settings</span> | |
| 64 | </a> | |
| 65 | </nav> | |
| 66 | ||
| 67 | <aside class="hidden lg:flex overflow-hidden relative sticky top-0 flex-col pb-5 pt-[68px] px-5 h-screen min-w-0 bg-[#f3f3f1]/48 backdrop-blur-3xl backdrop-saturate-150 duration-200 ease-out transition-[width,padding]" aria-label="Postline" data-postline-sidebar data-window-drag-region> | |
| 32 | 68 | <div class="absolute inset-0 bg-[linear-gradient(112deg,rgba(255,255,255,0.20)_0%,rgba(255,255,255,0.04)_45%,rgba(226,226,222,0.16)_100%)] pointer-events-none"></div> |
| 33 | 69 | <div class="flex absolute right-5 top-3.5 z-20 items-center min-h-7" data-shell-controls-row> |
| 34 | 70 | <div class="flex gap-1.5 items-center"> |
| 35 | <button class="inline-flex items-center justify-center h-8 w-8 text-zinc-600 hover:text-zinc-900 bg-white/60 hover:bg-white border border-zinc-200/70 rounded-xl shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/50 transition-all duration-200" type="button" aria-label="Collapse sidebar" aria-expanded="true" data-sidebar-collapse data-no-window-drag> | |
| 36 | <svg class="transition-transform duration-200" data-sidebar-collapse-icon width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M15.78 4.22a.75.75 0 0 1 0 1.06L10.06 11l5.72 5.72a.75.75 0 1 1-1.06 1.06l-6.25-6.25a.75.75 0 0 1 0-1.06l6.25-6.25a.75.75 0 0 1 1.06 0ZM4.75 4a.75.75 0 0 0-.75.75v10.5c0 .414.336.75.75.75h2.5a.75.75 0 0 0 0-1.5H5.5V5.5h1.75a.75.75 0 0 0 0-1.5h-2.5Z" clip-rule="evenodd"/></svg> | |
| 71 | <button class="inline-flex items-center justify-center h-8 w-8 text-zinc-600 hover:text-zinc-900 bg-white/60 hover:bg-white border border-zinc-200/70 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/50 shadow-sm duration-200 transition-all" type="button" aria-label="Collapse sidebar" aria-expanded="true" data-sidebar-collapse data-no-window-drag> | |
| 72 | <svg class="duration-200 transition-transform" data-sidebar-collapse-icon width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M15.78 4.22a.75.75 0 0 1 0 1.06L10.06 11l5.72 5.72a.75.75 0 1 1-1.06 1.06l-6.25-6.25a.75.75 0 0 1 0-1.06l6.25-6.25a.75.75 0 0 1 1.06 0ZM4.75 4a.75.75 0 0 0-.75.75v10.5c0 .414.336.75.75.75h2.5a.75.75 0 0 0 0-1.5H5.5V5.5h1.75a.75.75 0 0 0 0-1.5h-2.5Z" clip-rule="evenodd"/></svg> | |
| 37 | 73 | </button> |
| 38 | 74 | <button class="hidden inline-flex items-center justify-center h-7 w-7 text-zinc-500/80 hover:text-zinc-900 hover:bg-white/35 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/50 transition-colors" type="button" aria-label="Go back" data-shell-back data-native-shell-control> |
| 39 | 75 | <span class="h-5 w-5 i-hugeicons-arrow-left-02" aria-hidden="true"></span> |
| @@ -71,6 +107,14 @@ const settingsActive = currentPath === '/settings' || currentPath.startsWith('/s | ||
| 71 | 107 | <span class="grid place-items-center px-1.5 h-5 min-w-5 text-[11px] text-zinc-500 bg-white/72 rounded-full shadow-sm" data-sidebar-expanded>{{ link.shortcut }}</span> |
| 72 | 108 | </a> |
| 73 | 109 | @endforeach |
| 110 | <a | |
| 111 | href="/blog" | |
| 112 | class="flex gap-3 items-center px-3 py-1.5 w-full text-[13.5px] text-left text-zinc-700 focus-visible:bg-white/38 hover:bg-white/38 rounded-[14px] focus-visible:outline-none transition-colors" | |
| 113 | > | |
| 114 | <span class="h-[18px] w-[18px] text-zinc-600 i-hugeicons-quill-write-02" aria-hidden="true"></span> | |
| 115 | <span class="flex-1 font-medium truncate" data-sidebar-expanded>Blog</span> | |
| 116 | <span class="grid place-items-center px-1.5 h-5 min-w-5 text-[11px] text-zinc-500 bg-white/72 rounded-full shadow-sm" data-sidebar-expanded>6</span> | |
| 117 | </a> | |
| 74 | 118 | </nav> |
| 75 | 119 | |
| 76 | 120 | <a |
| @@ -84,7 +128,8 @@ const settingsActive = currentPath === '/settings' || currentPath.startsWith('/s | ||
| 84 | 128 | > |
| 85 | 129 | <span class="h-[18px] w-[18px] i-hugeicons-settings-02 text-zinc-600" aria-hidden="true"></span> |
| 86 | 130 | <span class="flex-1 font-medium" data-sidebar-expanded>Settings</span> |
| 87 | <span class="grid place-items-center px-1.5 h-5 min-w-5 text-[11px] text-zinc-500 bg-white/72 rounded-full shadow-sm" data-sidebar-expanded>6</span> | |
| 131 | <span class="grid place-items-center px-1.5 h-5 min-w-5 text-[11px] text-zinc-500 bg-white/72 rounded-full shadow-sm" data-sidebar-expanded>7</span> | |
| 88 | 132 | </a> |
| 89 | 133 | </aside> |
| 134 | </div> | |
| 90 | 135 | </template> |
| @@ -0,0 +1,23 @@ | ||
| 1 | -- The provider CHECK constraint predates the Threads driver and the blog | |
| 2 | -- target, so inserts for either provider fail. SQLite can't alter a CHECK | |
| 3 | -- in place — rebuild the table with the widened constraint. | |
| 4 | CREATE TABLE IF NOT EXISTS "post_targets_new" ( | |
| 5 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 6 | "provider" TEXT CHECK ("provider" IN ('bluesky', 'twitter', 'mastodon', 'facebook', 'instagram', 'tiktok', 'linkedin', 'threads', 'blog')), | |
| 7 | "status" TEXT CHECK ("status" IN ('draft', 'scheduled', 'publishing', 'published', 'failed', 'skipped')) default 'draft', | |
| 8 | "scheduled_at" TEXT, | |
| 9 | "remote_uri" TEXT, | |
| 10 | "remote_cid" TEXT, | |
| 11 | "failure_reason" TEXT, | |
| 12 | "metrics" TEXT, | |
| 13 | "post_id" INTEGER, | |
| 14 | "social_driver_id" INTEGER, | |
| 15 | "social_identity_id" INTEGER, | |
| 16 | "created_at" TEXT not null default CURRENT_TIMESTAMP, | |
| 17 | "updated_at" TEXT, | |
| 18 | "uuid" TEXT | |
| 19 | ); | |
| 20 | INSERT INTO "post_targets_new" SELECT * FROM "post_targets"; | |
| 21 | DROP TABLE "post_targets"; | |
| 22 | ALTER TABLE "post_targets_new" RENAME TO "post_targets"; | |
| 23 | CREATE INDEX IF NOT EXISTS "post_targets_provider_status_index" ON "post_targets" ("provider", "status"); | |
| @@ -0,0 +1,15 @@ | ||
| 1 | CREATE TABLE IF NOT EXISTS "blog_posts" ( | |
| 2 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 3 | "title" TEXT not null, | |
| 4 | "slug" TEXT not null UNIQUE, | |
| 5 | "body" TEXT not null, | |
| 6 | "excerpt" TEXT, | |
| 7 | "status" TEXT CHECK ("status" IN ('draft', 'published', 'archived')) default 'draft', | |
| 8 | "published_at" TEXT, | |
| 9 | "post_id" INTEGER, | |
| 10 | "account_id" INTEGER, | |
| 11 | "created_at" TEXT not null default CURRENT_TIMESTAMP, | |
| 12 | "updated_at" TEXT, | |
| 13 | "uuid" TEXT | |
| 14 | ); | |
| 15 | CREATE INDEX IF NOT EXISTS "blog_posts_status_published_at_index" ON "blog_posts" ("status", "published_at"); | |
| @@ -0,0 +1,4 @@ | ||
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> | |
| 2 | <rect width="64" height="64" rx="14" fill="#0a0a0a"/> | |
| 3 | <text x="32" y="45" font-family="-apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="38" font-weight="700" fill="#ffffff" text-anchor="middle">P</text> | |
| 4 | </svg> | |
| @@ -1,21 +1,50 @@ | ||
| 1 | import { line, scaleLinear, ticks } from '@ts-charts/charts' | |
| 2 | ||
| 3 | 1 | type Point = { |
| 4 | 2 | label: string |
| 5 | 3 | value: number |
| 6 | posts: number | |
| 7 | 4 | } |
| 8 | 5 | |
| 9 | const points: Point[] = [ | |
| 10 | { label: 'Apr 16', value: 18, posts: 1 }, | |
| 11 | { label: 'Apr 20', value: 24, posts: 2 }, | |
| 12 | { label: 'Apr 24', value: 21, posts: 1 }, | |
| 13 | { label: 'Apr 28', value: 35, posts: 3 }, | |
| 14 | { label: 'May 2', value: 30, posts: 2 }, | |
| 15 | { label: 'May 6', value: 42, posts: 3 }, | |
| 16 | { label: 'May 10', value: 48, posts: 4 }, | |
| 17 | { label: 'May 14', value: 57, posts: 4 }, | |
| 18 | ] | |
| 6 | // Minimal local versions of d3-style helpers. The published @ts-charts | |
| 7 | // packages declare a `bun` export condition pointing at an unshipped src/ | |
| 8 | // directory, so they can never bundle under Bun's dev server — and this | |
| 9 | // chart only needs a linear scale, a line path, and nice ticks. | |
| 10 | function makeLinearScale(domain: [number, number], range: [number, number]): (value: number) => number { | |
| 11 | const [d0, d1] = domain | |
| 12 | const [r0, r1] = range | |
| 13 | const slope = d1 - d0 === 0 ? 0 : (r1 - r0) / (d1 - d0) | |
| 14 | return value => r0 + (value - d0) * slope | |
| 15 | } | |
| 16 | ||
| 17 | function linePath(coordinates: Array<[number, number]>): string { | |
| 18 | return coordinates.map(([px, py], index) => `${index === 0 ? 'M' : 'L'}${px},${py}`).join('') | |
| 19 | } | |
| 20 | ||
| 21 | function niceTicks(min: number, max: number, count: number): number[] { | |
| 22 | if (max <= min) return [min] | |
| 23 | const step0 = (max - min) / Math.max(1, count) | |
| 24 | const magnitude = 10 ** Math.floor(Math.log10(step0)) | |
| 25 | const normalized = step0 / magnitude | |
| 26 | const step = (normalized >= 5 ? 10 : normalized >= 2 ? 5 : normalized >= 1 ? 2 : 1) * magnitude | |
| 27 | const result: number[] = [] | |
| 28 | for (let value = Math.ceil(min / step) * step; value <= max + 1e-9; value += step) | |
| 29 | result.push(Number(value.toFixed(10))) | |
| 30 | return result | |
| 31 | } | |
| 32 | ||
| 33 | interface AnalyticsPayload { | |
| 34 | stats: { | |
| 35 | postsThisWeek: number | |
| 36 | postsLastWeek: number | |
| 37 | bestChannel: { provider: string, published: number, share: number } | null | |
| 38 | bestHour: { hour: number, count: number } | null | |
| 39 | } | |
| 40 | series: Point[] | |
| 41 | channelMix: Array<{ provider: string, published: number, share: number }> | |
| 42 | windows: Array<{ hour: number, count: number }> | |
| 43 | } | |
| 44 | ||
| 45 | // Populated from /api/postline/analytics before the chart renders. | |
| 46 | let points: Point[] = [] | |
| 47 | let analyticsPromise: Promise<AnalyticsPayload | null> | null = null | |
| 19 | 48 | |
| 20 | 49 | const width = 640 |
| 21 | 50 | const height = 280 |
| @@ -41,26 +70,21 @@ function renderAnalyticsChart(): void { | ||
| 41 | 70 | const chart = document.querySelector<SVGSVGElement>('[data-analytics-chart]') |
| 42 | 71 | const existingPath = chart?.querySelector<SVGPathElement>('[data-chart-line]')?.getAttribute('d') |
| 43 | 72 | if (!chart || (chart.dataset.rendered === 'true' && existingPath)) return |
| 73 | if (points.length < 2) return | |
| 44 | 74 | |
| 45 | 75 | const maxValue = Math.max(...points.map(point => point.value)) |
| 46 | const x = scaleLinear() | |
| 47 | .domain([0, points.length - 1]) | |
| 48 | .range([inset.left, width - inset.right]) | |
| 49 | const y = scaleLinear() | |
| 50 | .domain([0, Math.ceil((maxValue + 8) / 10) * 10]) | |
| 51 | .range([height - inset.bottom, inset.top]) | |
| 52 | ||
| 53 | const generator = line<Point>() | |
| 54 | .x((_point: Point, index: number) => x(index)) | |
| 55 | .y((point: Point) => y(point.value)) | |
| 56 | ||
| 57 | const path = generator(points) || '' | |
| 76 | const yMax = Math.max(4, Math.ceil((maxValue + 1) / 2) * 2) | |
| 77 | const x = makeLinearScale([0, points.length - 1], [inset.left, width - inset.right]) | |
| 78 | const y = makeLinearScale([0, yMax], [height - inset.bottom, inset.top]) | |
| 79 | ||
| 80 | const path = linePath(points.map((point, index) => [x(index), y(point.value)])) | |
| 58 | 81 | const baseline = height - inset.bottom |
| 59 | 82 | const firstX = x(0) |
| 60 | 83 | const lastX = x(points.length - 1) |
| 61 | 84 | const areaPath = `${path}L${lastX},${baseline}L${firstX},${baseline}Z` |
| 62 | const yTicks = ticks(0, Math.ceil((maxValue + 8) / 10) * 10, 4) | |
| 63 | const xTickIndexes = points.map((_point, index) => index).filter(index => index === 0 || index === points.length - 1 || index % 2 === 1) | |
| 85 | const yTicks = niceTicks(0, yMax, 4) | |
| 86 | const xTickIndexes = points.map((_point, index) => index) | |
| 87 | .filter(index => index === 0 || index === points.length - 1 || (index % 5 === 0 && index < points.length - 3)) | |
| 64 | 88 | |
| 65 | 89 | chart.querySelector<SVGPathElement>('[data-chart-line]')?.setAttribute('d', path) |
| 66 | 90 | chart.querySelector<SVGPathElement>('[data-chart-area]')?.setAttribute('d', areaPath) |
| @@ -99,8 +123,8 @@ function renderAnalyticsChart(): void { | ||
| 99 | 123 | const labels = chart.querySelector<SVGGElement>('[data-chart-labels]') |
| 100 | 124 | if (labels) { |
| 101 | 125 | labels.innerHTML = [ |
| 102 | `<text x="${inset.left}" y="24" fill="${muted}" font-size="11" font-weight="700">Responses</text>`, | |
| 103 | `<text x="${width - inset.right}" y="${height - 14}" text-anchor="end" fill="${muted}" font-size="11" font-weight="700">Publishing date</text>`, | |
| 126 | `<text x="${inset.left}" y="24" fill="${muted}" font-size="11" font-weight="700">Posts published</text>`, | |
| 127 | `<text x="${width - inset.right}" y="${height - 14}" text-anchor="end" fill="${muted}" font-size="11" font-weight="700">Date</text>`, | |
| 104 | 128 | ].join('') |
| 105 | 129 | } |
| 106 | 130 | |
| @@ -108,7 +132,7 @@ function renderAnalyticsChart(): void { | ||
| 108 | 132 | if (legend) { |
| 109 | 133 | legend.innerHTML = [ |
| 110 | 134 | `<circle cx="${width - 212}" cy="22" r="4" fill="${blue}"></circle>`, |
| 111 | `<text x="${width - 202}" y="26" fill="${text}" font-size="11" font-weight="700">Response trend</text>`, | |
| 135 | `<text x="${width - 202}" y="26" fill="${text}" font-size="11" font-weight="700">Publishing activity</text>`, | |
| 112 | 136 | `<rect x="${width - 82}" y="16" width="22" height="8" rx="4" fill="rgba(37, 99, 235, 0.12)"></rect>`, |
| 113 | 137 | `<text x="${width - 28}" y="26" fill="${muted}" font-size="11" text-anchor="end">30 days</text>`, |
| 114 | 138 | ].join('') |
| @@ -117,7 +141,7 @@ function renderAnalyticsChart(): void { | ||
| 117 | 141 | const pointLayer = chart.querySelector<SVGGElement>('[data-chart-points]') |
| 118 | 142 | if (pointLayer) { |
| 119 | 143 | pointLayer.innerHTML = points |
| 120 | .map((point, index) => `<circle cx="${x(index)}" cy="${y(point.value)}" r="4" fill="#fff" stroke="${blue}" stroke-width="3"><title>${escapeHtml(point.label)}: ${point.value} responses from ${point.posts} posts</title></circle>`) | |
| 144 | .map((point, index) => `<circle cx="${x(index)}" cy="${y(point.value)}" r="4" fill="#fff" stroke="${blue}" stroke-width="3"><title>${escapeHtml(point.label)}: ${point.value} post${point.value === 1 ? '' : 's'} published</title></circle>`) | |
| 121 | 145 | .join('') |
| 122 | 146 | } |
| 123 | 147 | |
| @@ -132,7 +156,7 @@ function renderAnalyticsChart(): void { | ||
| 132 | 156 | const xx = x(activeIndex) |
| 133 | 157 | const yy = y(point.value) |
| 134 | 158 | const tooltipWidth = 154 |
| 135 | const tooltipHeight = 62 | |
| 159 | const tooltipHeight = 48 | |
| 136 | 160 | const tooltipX = Math.min(Math.max(xx + 14, inset.left), width - inset.right - tooltipWidth) |
| 137 | 161 | const tooltipY = Math.max(inset.top + 8, yy - tooltipHeight - 14) |
| 138 | 162 | |
| @@ -143,15 +167,14 @@ function renderAnalyticsChart(): void { | ||
| 143 | 167 | `<g transform="translate(${tooltipX} ${tooltipY})" data-chart-tooltip>`, |
| 144 | 168 | `<rect width="${tooltipWidth}" height="${tooltipHeight}" rx="12" fill="#fff" stroke="#d4d4d8" stroke-width="1.2"></rect>`, |
| 145 | 169 | `<text x="12" y="22" fill="${text}" font-size="12" font-weight="800">${escapeHtml(point.label)}</text>`, |
| 146 | `<text x="12" y="40" fill="${muted}" font-size="11">${point.value} responses</text>`, | |
| 147 | `<text x="12" y="54" fill="${muted}" font-size="10">${point.posts} scheduled posts</text>`, | |
| 170 | `<text x="12" y="38" fill="${muted}" font-size="11">${point.value} post${point.value === 1 ? '' : 's'} published</text>`, | |
| 148 | 171 | '</g>', |
| 149 | 172 | ].join('') |
| 150 | 173 | hoverLayer.removeAttribute('aria-hidden') |
| 151 | 174 | } |
| 152 | 175 | |
| 153 | 176 | if (hitArea) |
| 154 | hitArea.setAttribute('aria-label', `${point.label}: ${point.value} responses from ${point.posts} scheduled posts`) | |
| 177 | hitArea.setAttribute('aria-label', `${point.label}: ${point.value} post${point.value === 1 ? '' : 's'} published`) | |
| 155 | 178 | } |
| 156 | 179 | |
| 157 | 180 | const hideHover = (): void => { |
| @@ -200,6 +223,93 @@ function renderAnalyticsChart(): void { | ||
| 200 | 223 | chart.dataset.rendered = 'true' |
| 201 | 224 | } |
| 202 | 225 | |
| 226 | const providerNames: Record<string, string> = { | |
| 227 | bluesky: 'Bluesky', | |
| 228 | linkedin: 'LinkedIn', | |
| 229 | instagram: 'Instagram', | |
| 230 | threads: 'Threads', | |
| 231 | twitter: 'Twitter/X', | |
| 232 | mastodon: 'Mastodon', | |
| 233 | } | |
| 234 | ||
| 235 | function formatHour(hour: number): string { | |
| 236 | const local = new Date() | |
| 237 | local.setUTCHours(hour, 0, 0, 0) | |
| 238 | return local.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) | |
| 239 | } | |
| 240 | ||
| 241 | function fillStats(payload: AnalyticsPayload): void { | |
| 242 | const page = document.querySelector<HTMLElement>('[data-testid="analytics-page"]') | |
| 243 | // The mount observer refires on any DOM change; fill once per page mount | |
| 244 | // or the fill's own mutations retrigger it in a microtask loop. | |
| 245 | if (!page || page.dataset.analyticsFilled === 'true') return | |
| 246 | page.dataset.analyticsFilled = 'true' | |
| 247 | ||
| 248 | const { stats } = payload | |
| 249 | const delta = stats.postsThisWeek - stats.postsLastWeek | |
| 250 | const set = (selector: string, value: string): void => { | |
| 251 | const element = page.querySelector(selector) | |
| 252 | if (element) element.textContent = value | |
| 253 | } | |
| 254 | ||
| 255 | set('[data-analytics-posts-week]', String(stats.postsThisWeek)) | |
| 256 | set('[data-analytics-posts-trend]', `${delta >= 0 ? '+' : ''}${delta} from last week`) | |
| 257 | set('[data-analytics-best-channel]', stats.bestChannel ? (providerNames[stats.bestChannel.provider] || stats.bestChannel.provider) : '—') | |
| 258 | set('[data-analytics-best-channel-trend]', stats.bestChannel ? `${stats.bestChannel.published} published posts` : 'No published posts yet') | |
| 259 | set('[data-analytics-best-window]', stats.bestHour ? formatHour(stats.bestHour.hour) : '—') | |
| 260 | set('[data-analytics-best-window-trend]', stats.bestHour ? `${stats.bestHour.count} posts published` : 'No published posts yet') | |
| 261 | ||
| 262 | const mix = page.querySelector('[data-analytics-channel-mix]') | |
| 263 | if (mix) { | |
| 264 | mix.innerHTML = payload.channelMix.length | |
| 265 | ? payload.channelMix.map(channel => ` | |
| 266 | <div class="grid gap-1.5"> | |
| 267 | <div class="flex items-center justify-between text-xs"> | |
| 268 | <span class="font-bold">${providerNames[channel.provider] || channel.provider}</span> | |
| 269 | <span class="text-zinc-500">${channel.share}%</span> | |
| 270 | </div> | |
| 271 | <div class="overflow-hidden h-2 bg-zinc-100 rounded-full"> | |
| 272 | <div class="h-full bg-blue-600 rounded-full" style="width: ${channel.share}%"></div> | |
| 273 | </div> | |
| 274 | </div> | |
| 275 | `).join('') | |
| 276 | : '<p class="m-0 text-xs text-zinc-500">Publish a post to see the channel mix.</p>' | |
| 277 | } | |
| 278 | ||
| 279 | const windows = page.querySelector('[data-analytics-windows]') | |
| 280 | if (windows) { | |
| 281 | windows.innerHTML = payload.windows.length | |
| 282 | ? payload.windows.map(window_ => ` | |
| 283 | <div class="flex items-center justify-between"> | |
| 284 | <span>${window_.count} post${window_.count === 1 ? '' : 's'}</span> | |
| 285 | <span class="font-bold text-neutral-950">${formatHour(window_.hour)}</span> | |
| 286 | </div> | |
| 287 | `).join('') | |
| 288 | : '<p class="m-0 text-xs">No publishing history yet.</p>' | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 292 | async function loadAnalytics(): Promise<void> { | |
| 293 | if (!analyticsPromise) { | |
| 294 | analyticsPromise = fetch('/api/postline/analytics') | |
| 295 | .then(async (response) => { | |
| 296 | const payload = await response.json() | |
| 297 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not load analytics.') | |
| 298 | return payload.data as AnalyticsPayload | |
| 299 | }) | |
| 300 | .catch(() => { | |
| 301 | analyticsPromise = null | |
| 302 | return null | |
| 303 | }) | |
| 304 | } | |
| 305 | ||
| 306 | const data = await analyticsPromise | |
| 307 | if (!data) return | |
| 308 | points = data.series || [] | |
| 309 | fillStats(data) | |
| 310 | scheduleAnalyticsRender() | |
| 311 | } | |
| 312 | ||
| 203 | 313 | let renderFrame = 0 |
| 204 | 314 | |
| 205 | 315 | function scheduleAnalyticsRender(): void { |
| @@ -212,14 +322,17 @@ function scheduleAnalyticsRender(): void { | ||
| 212 | 322 | } |
| 213 | 323 | |
| 214 | 324 | function watchAnalyticsChartMounts(): void { |
| 325 | if (document.querySelector('[data-analytics-chart]')) void loadAnalytics() | |
| 215 | 326 | scheduleAnalyticsRender() |
| 216 | 327 | |
| 217 | 328 | const target = document.body || document.documentElement |
| 218 | 329 | if (!target) return |
| 219 | 330 | |
| 220 | 331 | const observer = new MutationObserver(() => { |
| 221 | if (document.querySelector('[data-analytics-chart]:not([data-rendered="true"])')) | |
| 332 | if (document.querySelector('[data-analytics-chart]:not([data-rendered="true"])')) { | |
| 333 | void loadAnalytics() | |
| 222 | 334 | scheduleAnalyticsRender() |
| 335 | } | |
| 223 | 336 | }) |
| 224 | 337 | |
| 225 | 338 | observer.observe(target, { childList: true, subtree: true }) |
| @@ -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) |
formatting only · 8 hidden lines
| @@ -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 = ` |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | <input class="px-3 h-10 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="password" type="password" autocomplete="current-password" placeholder="xxxx-xxxx-xxxx-xxxx" data-bluesky-password required> |
| 27 | 27 | </label> |
| 28 | 28 | <div class="flex items-end"> |
| 29 | <button class="inline-flex items-center justify-center px-4 h-10 w-full sm:w-auto font-bold text-sm text-white bg-neutral-950 rounded-xl disabled:opacity-50" type="submit" data-bluesky-connect-submit>Connect Bluesky</button> | |
| 29 | <button class="inline-flex items-center justify-center px-4 h-10 w-full sm:w-auto font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl disabled:opacity-50 transition-colors" type="submit" data-bluesky-connect-submit>Connect Bluesky</button> | |
| 30 | 30 | </div> |
| 31 | 31 | <p class="sm:col-span-3 m-0 text-xs text-zinc-500">Create an app password in Bluesky Settings → Privacy and security → App passwords.</p> |
| 32 | 32 | </form> |
| @@ -53,8 +53,8 @@ | ||
| 53 | 53 | </div> |
| 54 | 54 | <div class="flex flex-col gap-2 items-end"> |
| 55 | 55 | <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-linkedin-channel-status>Setup</span> |
| 56 | <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-[#0a66c2] rounded-xl disabled:opacity-50" type="button" data-linkedin-connect>Connect with LinkedIn</button> | |
| 57 | <button class="hidden text-xs text-zinc-500 underline" type="button" data-linkedin-connect-env>Use token from .env</button> | |
| 56 | <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-[#0a66c2] hover:bg-[#0857a6] rounded-xl disabled:opacity-50 transition-colors" type="button" data-linkedin-connect>Connect with LinkedIn</button> | |
| 57 | <button class="hidden text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-linkedin-connect-env>Use token from .env</button> | |
| 58 | 58 | </div> |
| 59 | 59 | </article> |
| 60 | 60 | <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="instagram"> |
| @@ -65,8 +65,8 @@ | ||
| 65 | 65 | </div> |
| 66 | 66 | <div class="flex flex-col gap-2 items-end"> |
| 67 | 67 | <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-instagram-channel-status>Setup</span> |
| 68 | <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-gradient-to-tr from-amber-500 to-purple-600 via-pink-600 rounded-xl disabled:opacity-50" type="button" data-instagram-connect>Connect with Instagram</button> | |
| 69 | <button class="hidden text-xs text-zinc-500 underline" type="button" data-instagram-connect-env>Use token from .env</button> | |
| 68 | <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-gradient-to-tr from-amber-500 to-purple-600 via-pink-600 rounded-xl disabled:opacity-50 hover:opacity-90 transition-opacity" type="button" data-instagram-connect>Connect with Instagram</button> | |
| 69 | <button class="hidden text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-instagram-connect-env>Use token from .env</button> | |
| 70 | 70 | </div> |
| 71 | 71 | </article> |
| 72 | 72 | <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="threads"> |
| @@ -77,8 +77,8 @@ | ||
| 77 | 77 | </div> |
| 78 | 78 | <div class="flex flex-col gap-2 items-end"> |
| 79 | 79 | <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-threads-channel-status>Setup</span> |
| 80 | <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-neutral-950 rounded-xl disabled:opacity-50" type="button" data-threads-connect>Connect with Threads</button> | |
| 81 | <button class="hidden text-xs text-zinc-500 underline" type="button" data-threads-connect-env>Use token from .env</button> | |
| 80 | <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl disabled:opacity-50 transition-colors" type="button" data-threads-connect>Connect with Threads</button> | |
| 81 | <button class="hidden text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-threads-connect-env>Use token from .env</button> | |
| 82 | 82 | </div> |
| 83 | 83 | </article> |
| 84 | 84 | <article class="flex gap-5 items-center justify-between p-3.5 bg-zinc-50/80 border border-zinc-200 rounded-xl opacity-80"> |
| @@ -3,44 +3,43 @@ | ||
| 3 | 3 | @section('title', 'Postline - Analytics') |
| 4 | 4 | @section('description', 'Review lightweight publishing analytics in Postline.') |
| 5 | 5 | |
| 6 | <script server> | |
| 7 | const stats = [ | |
| 8 | { label: 'Posts this week', value: '12', trend: '+4 from last week' }, | |
| 9 | { label: 'Best channel', value: 'Bluesky', trend: '2.8x engagement' }, | |
| 10 | { label: 'Best window', value: '9 AM', trend: 'Weekdays' }, | |
| 11 | ] | |
| 12 | ||
| 13 | const channelSplits = [ | |
| 14 | { label: 'Bluesky', value: '68%', width: '68%' }, | |
| 15 | { label: 'Mastodon', value: '21%', width: '21%' }, | |
| 16 | { label: 'Twitter/X', value: '11%', width: '11%' }, | |
| 17 | ] | |
| 18 | </script> | |
| 19 | ||
| 20 | 6 | @section('content') |
| 21 | <section class="grid gap-3.5" data-postline-page="analytics" data-testid="analytics-page"> | |
| 7 | <section class="grid gap-5" data-postline-page="analytics" data-testid="analytics-page"> | |
| 8 | <header class="flex gap-4 items-center justify-between min-h-11"> | |
| 9 | <div> | |
| 10 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Analytics</p> | |
| 11 | <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Publishing insights</h1> | |
| 12 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">How your posting has performed over the last 30 days.</p> | |
| 13 | </div> | |
| 14 | </header> | |
| 15 | ||
| 22 | 16 | <div class="grid gap-3.5 lg:grid-cols-3"> |
| 23 | @foreach (stats as stat) | |
| 24 | <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 25 | <span class="text-xs text-zinc-500">{{ stat.label }}</span> | |
| 26 | <strong class="text-2xl">{{ stat.value }}</strong> | |
| 27 | <span class="text-xs text-zinc-500">{{ stat.trend }}</span> | |
| 28 | </article> | |
| 29 | @endforeach | |
| 17 | <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 18 | <span class="text-xs text-zinc-500">Posts this week</span> | |
| 19 | <strong class="text-2xl" data-analytics-posts-week>—</strong> | |
| 20 | <span class="text-xs text-zinc-500" data-analytics-posts-trend>Loading…</span> | |
| 21 | </article> | |
| 22 | <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 23 | <span class="text-xs text-zinc-500">Best channel</span> | |
| 24 | <strong class="text-2xl" data-analytics-best-channel>—</strong> | |
| 25 | <span class="text-xs text-zinc-500" data-analytics-best-channel-trend>Loading…</span> | |
| 26 | </article> | |
| 27 | <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 28 | <span class="text-xs text-zinc-500">Best window</span> | |
| 29 | <strong class="text-2xl" data-analytics-best-window>—</strong> | |
| 30 | <span class="text-xs text-zinc-500" data-analytics-best-window-trend>Loading…</span> | |
| 31 | </article> | |
| 30 | 32 | </div> |
| 31 | 33 | |
| 32 | 34 | <div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_320px]"> |
| 33 | 35 | <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm"> |
| 34 | 36 | <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200"> |
| 35 | <strong class="text-[13px]">Response trend</strong> | |
| 36 | <button class="inline-flex gap-1.5 items-center justify-center px-3 h-8 font-bold text-xs text-white bg-neutral-950 rounded-lg" type="button"> | |
| 37 | <span class="h-3.5 w-3.5 i-hugeicons-download-04" aria-hidden="true"></span> | |
| 38 | <span>Export</span> | |
| 39 | </button> | |
| 37 | <strong class="text-[13px]">Publishing activity</strong> | |
| 38 | <span class="text-xs text-zinc-500">Last 30 days</span> | |
| 40 | 39 | </header> |
| 41 | 40 | <div class="p-3.5"> |
| 42 | <svg class="block w-full h-auto" viewBox="0 0 640 280" role="img" aria-label="Postline response trend chart" data-analytics-chart> | |
| 43 | <desc>Responses over the last 30 days with publishing dates on the x axis and response counts on the y axis.</desc> | |
| 41 | <svg class="block h-auto w-full" viewBox="0 0 640 280" role="img" aria-label="Posts published over the last 30 days" data-analytics-chart> | |
| 42 | <desc>Posts published over the last 30 days with dates on the x axis and post counts on the y axis.</desc> | |
| 44 | 43 | <rect x="0" y="0" width="640" height="280" rx="16" fill="#fafafa"></rect> |
| 45 | 44 | <g data-chart-legend></g> |
| 46 | 45 | <g data-chart-grid></g> |
| @@ -51,7 +50,7 @@ const channelSplits = [ | ||
| 51 | 50 | <path data-chart-line fill="none" stroke="#2563eb" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></path> |
| 52 | 51 | <g data-chart-points></g> |
| 53 | 52 | <g data-chart-hover aria-hidden="true"></g> |
| 54 | <rect data-chart-hit-area x="0" y="0" width="640" height="280" fill="transparent" tabindex="0" role="application" aria-label="Hover or focus the response trend chart for point details."></rect> | |
| 53 | <rect data-chart-hit-area x="0" y="0" width="640" height="280" fill="transparent" tabindex="0" role="application" aria-label="Hover or focus the publishing activity chart for point details."></rect> | |
| 55 | 54 | </svg> |
| 56 | 55 | </div> |
| 57 | 56 | </section> |
| @@ -61,18 +60,8 @@ const channelSplits = [ | ||
| 61 | 60 | <header class="p-3.5 border-b border-zinc-200"> |
| 62 | 61 | <strong class="text-[13px]">Channel mix</strong> |
| 63 | 62 | </header> |
| 64 | <div class="grid gap-3 p-3.5"> | |
| 65 | @foreach (channelSplits as channel) | |
| 66 | <div class="grid gap-1.5"> | |
| 67 | <div class="flex items-center justify-between text-xs"> | |
| 68 | <span class="font-bold">{{ channel.label }}</span> | |
| 69 | <span class="text-zinc-500">{{ channel.value }}</span> | |
| 70 | </div> | |
| 71 | <div class="h-2 overflow-hidden bg-zinc-100 rounded-full"> | |
| 72 | <div class="h-full bg-blue-600 rounded-full" style="width: "></div> | |
| 73 | </div> | |
| 74 | </div> | |
| 75 | @endforeach | |
| 63 | <div class="grid gap-3 p-3.5" data-analytics-channel-mix> | |
| 64 | <p class="m-0 text-xs text-zinc-500">Loading…</p> | |
| 76 | 65 | </div> |
| 77 | 66 | </section> |
| 78 | 67 | |
| @@ -80,21 +69,11 @@ const channelSplits = [ | ||
| 80 | 69 | <header class="p-3.5 border-b border-zinc-200"> |
| 81 | 70 | <strong class="text-[13px]">Best windows</strong> |
| 82 | 71 | </header> |
| 83 | <div class="grid gap-2 p-3 text-xs text-zinc-500"> | |
| 84 | <div class="flex items-center justify-between"><span>Weekdays</span><span class="font-bold text-neutral-950">9:00 AM</span></div> | |
| 85 | <div class="flex items-center justify-between"><span>Launch notes</span><span class="font-bold text-neutral-950">3:30 PM</span></div> | |
| 86 | <div class="flex items-center justify-between"><span>Replies</span><span class="font-bold text-neutral-950">11:15 AM</span></div> | |
| 72 | <div class="grid gap-2 p-3 text-xs text-zinc-500" data-analytics-windows> | |
| 73 | <p class="m-0 text-xs">Loading…</p> | |
| 87 | 74 | </div> |
| 88 | 75 | </section> |
| 89 | 76 | </aside> |
| 90 | 77 | </div> |
| 91 | ||
| 92 | <section class="pb-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 93 | <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200"> | |
| 94 | <strong class="text-[13px]">What is working</strong> | |
| 95 | <span class="text-xs text-zinc-500">Bluesky</span> | |
| 96 | </header> | |
| 97 | <p class="m-0 p-3.5 leading-normal text-[13px] text-slate-700">Short, concrete posts are outperforming general updates. Bluesky is carrying the current response rate, so the next scheduling pass should keep that channel as the primary publishing target.</p> | |
| 98 | </section> | |
| 99 | 78 | </section> |
| 100 | 79 | @endsection |
| @@ -4,11 +4,18 @@ | ||
| 4 | 4 | @section('description', 'Write, schedule, publish, and review social posts from a clean personal workspace.') |
| 5 | 5 | |
| 6 | 6 | @section('content') |
| 7 | <section class="grid gap-3.5" data-postline-page="composer" data-testid="composer-page"> | |
| 7 | <section class="grid gap-5" data-postline-page="composer" data-testid="composer-page"> | |
| 8 | 8 | <!-- Crosswind safelist: utilities applied dynamically from the script below |
| 9 | 9 | (provider toggles, active-post highlight, connection summary, char |
| 10 | 10 | counter) must appear in static markup so the CSS scanner emits them. --> |
| 11 | 11 | <div class="hidden text-amber-700 text-emerald-800 text-neutral-950 text-red-700 text-white text-zinc-400 text-zinc-500 text-zinc-600 bg-emerald-400 bg-emerald-50 bg-emerald-500 bg-neutral-950 bg-white bg-zinc-100 bg-zinc-300 bg-zinc-50 hover:bg-white border-blue-300 border-emerald-200 border-neutral-950 border-zinc-200 shadow-sm cursor-not-allowed" aria-hidden="true"></div> |
| 12 | <header class="flex gap-4 items-center justify-between min-h-11"> | |
| 13 | <div> | |
| 14 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Composer</p> | |
| 15 | <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Write a post</h1> | |
| 16 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Draft once, then publish to every connected account.</p> | |
| 17 | </div> | |
| 18 | </header> | |
| 12 | 19 | <div class="grid"> |
| 13 | 20 | <section class="grid content-start grid-rows-[auto_auto_auto] self-start bg-white border border-zinc-200 rounded-2xl shadow-sm" aria-label="Post composer"> |
| 14 | 21 | <header class="flex flex-wrap gap-3 items-center justify-between p-3 border-b border-zinc-200"> |
| @@ -20,9 +27,9 @@ | ||
| 20 | 27 | </a> |
| 21 | 28 | </div> |
| 22 | 29 | <div class="grid grid-cols-3 p-0.5 bg-zinc-100 border border-zinc-200 rounded-xl" role="group" aria-label="Post mode"> |
| 23 | <button class="px-2.5 py-1.5 min-w-19 text-neutral-950 text-xs bg-white rounded-lg shadow-sm" type="button" data-composer-mode="single">Single</button> | |
| 24 | <button class="px-2.5 py-1.5 min-w-19 text-xs text-zinc-500 rounded-lg" type="button" data-composer-mode="thread">Thread</button> | |
| 25 | <button class="px-2.5 py-1.5 min-w-19 text-xs text-zinc-500 rounded-lg" type="button" data-composer-mode="draft">Draft</button> | |
| 30 | <button class="px-2.5 py-1.5 min-w-19 text-neutral-950 text-xs bg-white rounded-lg shadow-sm transition-colors" type="button" data-composer-mode="single">Single</button> | |
| 31 | <button class="px-2.5 py-1.5 min-w-19 text-xs text-zinc-500 rounded-lg transition-colors" type="button" data-composer-mode="thread">Thread</button> | |
| 32 | <button class="px-2.5 py-1.5 min-w-19 text-xs text-zinc-500 rounded-lg transition-colors" type="button" data-composer-mode="draft">Draft</button> | |
| 26 | 33 | </div> |
| 27 | 34 | </header> |
| 28 | 35 | |
| @@ -41,7 +48,7 @@ | ||
| 41 | 48 | <span class="h-3.5 w-3.5 i-hugeicons-edit-02" aria-hidden="true"></span> |
| 42 | 49 | <span data-post-label>Post 1</span> |
| 43 | 50 | </button> |
| 44 | <button class="hidden items-center justify-center h-7 w-7 text-zinc-500 bg-white border border-zinc-200 rounded-lg shadow-sm" type="button" aria-label="Remove post" title="Remove post" data-remove-post> | |
| 51 | <button class="hidden items-center justify-center h-7 w-7 text-zinc-500 hover:text-zinc-700 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" type="button" aria-label="Remove post" title="Remove post" data-remove-post> | |
| 45 | 52 | <span class="h-3.5 w-3.5 i-hugeicons-cancel-01" aria-hidden="true"></span> |
| 46 | 53 | </button> |
| 47 | 54 | </header> |
| @@ -52,15 +59,19 @@ | ||
| 52 | 59 | </div> |
| 53 | 60 | |
| 54 | 61 | <footer class="flex gap-3 items-center justify-between p-3 border-t border-zinc-200"> |
| 55 | <span class="text-xs text-zinc-500" data-bluesky-count>118 / 300 characters</span> | |
| 56 | <div class="flex gap-2 items-center"> | |
| 57 | <button class="inline-flex items-center justify-center h-9 w-9 text-neutral-950 bg-white border border-zinc-200 rounded-xl shadow-sm" type="button" aria-label="Add post" title="Add post" data-add-post> | |
| 62 | <span class="text-xs text-zinc-500" data-bluesky-count>0 / 300 characters</span> | |
| 63 | <div class="flex flex-wrap gap-2 items-center justify-end"> | |
| 64 | <input class="hidden px-2 h-9 text-neutral-950 text-xs bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" type="datetime-local" aria-label="Schedule time" data-compose-schedule> | |
| 65 | <button class="inline-flex items-center justify-center h-9 w-9 text-neutral-950 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-xl shadow-sm transition-colors" type="button" aria-label="Schedule post" title="Schedule post" data-schedule-toggle> | |
| 66 | <span class="h-4 w-4 i-hugeicons-calendar-03" aria-hidden="true"></span> | |
| 67 | </button> | |
| 68 | <button class="inline-flex items-center justify-center h-9 w-9 text-neutral-950 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-xl shadow-sm transition-colors" type="button" aria-label="Add post" title="Add post" data-add-post> | |
| 58 | 69 | <span class="h-4 w-4 i-hugeicons-plus-sign" aria-hidden="true"></span> |
| 59 | 70 | </button> |
| 60 | <button class="inline-flex items-center justify-center h-9 w-9 text-neutral-950 bg-white border border-zinc-200 rounded-xl shadow-sm" type="button" aria-label="Preview post" title="Preview post" data-preview-post> | |
| 71 | <button class="inline-flex items-center justify-center h-9 w-9 text-neutral-950 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-xl shadow-sm transition-colors" type="button" aria-label="Preview post" title="Preview post" data-preview-post> | |
| 61 | 72 | <span class="h-4 w-4 i-hugeicons-view" aria-hidden="true"></span> |
| 62 | 73 | </button> |
| 63 | <button class="inline-flex gap-2 items-center justify-center px-4 h-9 font-bold text-white bg-neutral-950 rounded-xl disabled:opacity-50 disabled:cursor-not-allowed" type="button" data-bluesky-publish> | |
| 74 | <button class="inline-flex gap-2 items-center justify-center px-4 h-9 font-bold text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl disabled:opacity-50 transition-colors disabled:cursor-not-allowed" type="button" data-bluesky-publish> | |
| 64 | 75 | <span class="h-4 w-4 i-hugeicons-sent" aria-hidden="true"></span> |
| 65 | 76 | <span data-bluesky-publish-label>Publish</span> |
| 66 | 77 | </button> |
| @@ -88,6 +99,8 @@ | ||
| 88 | 99 | const providerToggles = root.querySelector('[data-provider-toggles]') |
| 89 | 100 | const composeImageRow = root.querySelector('[data-compose-image-row]') |
| 90 | 101 | const composeImage = root.querySelector('[data-compose-image]') |
| 102 | const scheduleToggle = root.querySelector('[data-schedule-toggle]') | |
| 103 | const scheduleInput = root.querySelector('[data-compose-schedule]') | |
| 91 | 104 | const postsRoot = root.querySelector('[data-composer-posts]') |
| 92 | 105 | const addPostButton = root.querySelector('[data-add-post]') |
| 93 | 106 | const previewButton = root.querySelector('[data-preview-post]') |
| @@ -251,7 +264,7 @@ | ||
| 251 | 264 | <span class="h-3.5 w-3.5 i-hugeicons-edit-02" aria-hidden="true"></span> |
| 252 | 265 | <span data-post-label>Post ${number}</span> |
| 253 | 266 | </button> |
| 254 | <button class="inline-flex items-center justify-center h-7 w-7 text-zinc-500 bg-white border border-zinc-200 rounded-lg shadow-sm" type="button" aria-label="Remove post" title="Remove post" data-remove-post> | |
| 267 | <button class="inline-flex items-center justify-center h-7 w-7 text-zinc-500 hover:text-zinc-700 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" type="button" aria-label="Remove post" title="Remove post" data-remove-post> | |
| 255 | 268 | <span class="h-3.5 w-3.5 i-hugeicons-cancel-01" aria-hidden="true"></span> |
| 256 | 269 | </button> |
| 257 | 270 | </header> |
| @@ -452,6 +465,75 @@ | ||
| 452 | 465 | saveDraft() |
| 453 | 466 | } |
| 454 | 467 | |
| 468 | const updatePublishLabel = () => { | |
| 469 | if (!publishLabel) return | |
| 470 | if (activeMode === 'draft') publishLabel.textContent = 'Save draft' | |
| 471 | else if (scheduleInput?.value) publishLabel.textContent = 'Schedule' | |
| 472 | else publishLabel.textContent = 'Publish' | |
| 473 | } | |
| 474 | ||
| 475 | const toUtcTimestamp = (localValue) => { | |
| 476 | const date = new Date(localValue) | |
| 477 | if (Number.isNaN(date.getTime())) throw new Error('Pick a valid schedule time.') | |
| 478 | return date.toISOString().slice(0, 19).replace('T', ' ') | |
| 479 | } | |
| 480 | ||
| 481 | // Draft mode and schedule-time both save to the server-side queue | |
| 482 | // instead of publishing immediately. | |
| 483 | const saveToQueue = async (scheduleValue) => { | |
| 484 | const text = activeTextarea()?.value.trim() | |
| 485 | if (!text) { | |
| 486 | setStatus('Write something before saving.', 'error') | |
| 487 | activeTextarea()?.focus() | |
| 488 | return | |
| 489 | } | |
| 490 | const targetProviders = selectedConnected().map(provider => provider.provider) | |
| 491 | if (targetProviders.length === 0) { | |
| 492 | setStatus('Select a connected account before saving to the queue.', 'error', { title: 'Not connected' }) | |
| 493 | return | |
| 494 | } | |
| 495 | if (activeMode === 'thread' && allPosts().filter(post => textareaFor(post)?.value.trim()).length > 1) { | |
| 496 | setStatus('Scheduling threads is not supported yet — schedule posts one at a time.', 'error', { title: 'Queue' }) | |
| 497 | return | |
| 498 | } | |
| 499 | ||
| 500 | const form = new FormData() | |
| 501 | form.set('text', text) | |
| 502 | form.set('providers', targetProviders.join(',')) | |
| 503 | if (scheduleValue) form.set('scheduled_at', toUtcTimestamp(scheduleValue)) | |
| 504 | ||
| 505 | buttons.forEach(button => button.setAttribute('disabled', 'disabled')) | |
| 506 | try { | |
| 507 | const response = await fetch('/api/postline/queue', { method: 'POST', body: form }) | |
| 508 | const payload = await response.json() | |
| 509 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not save to the queue.') | |
| 510 | ||
| 511 | const textarea = activeTextarea() | |
| 512 | if (textarea) textarea.value = '' | |
| 513 | if (scheduleInput) scheduleInput.value = '' | |
| 514 | scheduleInput?.classList.add('hidden') | |
| 515 | renderLinkPreview(activePost, null) | |
| 516 | updateCount() | |
| 517 | updatePublishLabel() | |
| 518 | saveDraft() | |
| 519 | setStatus( | |
| 520 | payload.data.status === 'scheduled' | |
| 521 | ? `Scheduled — check the Queue page to manage it.` | |
| 522 | : 'Draft saved to the queue.', | |
| 523 | 'success', | |
| 524 | { title: payload.data.status === 'scheduled' ? 'Scheduled' : 'Draft saved' }, | |
| 525 | ) | |
| 526 | } | |
| 527 | catch (error) { | |
| 528 | setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Queue' }) | |
| 529 | } | |
| 530 | finally { | |
| 531 | buttons.forEach((button) => { | |
| 532 | if (canPublish) button.removeAttribute('disabled') | |
| 533 | }) | |
| 534 | } | |
| 535 | } | |
| 536 | ||
| 455 | 537 | const setMode = (mode, options = {}) => { |
| 456 | 538 | const nextMode = ['single', 'thread', 'draft'].includes(mode) ? mode : 'single' |
| 457 | 539 | activeMode = nextMode |
| @@ -465,6 +547,7 @@ | ||
| 465 | 547 | if (nextMode === 'thread' && allPosts().length === 1 && !options.skipThreadPost) { |
| 466 | 548 | addPost({ announce: false, focus: false, save: false }) |
| 467 | 549 | } |
| 550 | updatePublishLabel() | |
| 468 | 551 | if (!options.silent) setStatus(`${nextMode[0].toUpperCase()}${nextMode.slice(1)} mode selected.`, 'success') |
| 469 | 552 | if (!options.skipSave) saveDraft() |
| 470 | 553 | } |
| @@ -489,6 +572,11 @@ | ||
| 489 | 572 | } |
| 490 | 573 | |
| 491 | 574 | const publish = async () => { |
| 575 | if (activeMode === 'draft' || scheduleInput?.value) { | |
| 576 | await saveToQueue(scheduleInput?.value || null) | |
| 577 | return | |
| 578 | } | |
| 579 | ||
| 492 | 580 | const targetProviders = selectedConnected().map(provider => provider.provider) |
| 493 | 581 | if (targetProviders.length === 0) { |
| 494 | 582 | setStatus('Select a connected account on the Accounts page before publishing.', 'error', { title: 'Not connected' }) |
| @@ -518,8 +606,7 @@ | ||
| 518 | 606 | let lastUrl |
| 519 | 607 | const failures = [] |
| 520 | 608 | let successCount = 0 |
| 521 | for (const [index, post] of targets.entries()) { | |
| 522 | const data = await publishOne(post, targetProviders) | |
| 609 | const collect = (data) => { | |
| 523 | 610 | for (const result of data.results || []) { |
| 524 | 611 | if (result.ok) { |
| 525 | 612 | successCount += 1 |
| @@ -529,11 +616,29 @@ | ||
| 529 | 616 | failures.push(`${providerLabel(result.provider)}: ${result.error || 'failed'}`) |
| 530 | 617 | } |
| 531 | 618 | } |
| 532 | if (targets.length > 1) { | |
| 533 | textareaFor(post).value = '' | |
| 534 | renderLinkPreview(post, null) | |
| 535 | } | |
| 536 | if (index < targets.length - 1) await new Promise(resolve => setTimeout(resolve, 400)) | |
| 619 | } | |
| 620 | ||
| 621 | if (targets.length > 1) { | |
| 622 | // Threads go up in one request so the server can reply-chain the | |
| 623 | // segments on providers that support it (Bluesky). | |
| 624 | const social = window.postlineSocial | |
| 625 | if (!social) throw new Error('Publishing is still loading. Try again in a moment.') | |
| 626 | const first = targets[0] | |
| 627 | const external = (first?.dataset.externalUri && first.dataset.externalTitle) | |
| 628 | ? { uri: first.dataset.externalUri, title: first.dataset.externalTitle, description: first.dataset.externalDescription || '' } | |
| 629 | : undefined | |
| 630 | const imageUrl = composeImage?.value.trim() | |
| 631 | const thread = targets.map(post => textareaFor(post)?.value.trim() || '') | |
| 632 | collect(await social.publishCrosspost({ | |
| 633 | text: thread[0], | |
| 634 | thread, | |
| 635 | providers: targetProviders, | |
| 636 | external, | |
| 637 | image: imageUrl ? { url: imageUrl } : undefined, | |
| 638 | })) | |
| 639 | } | |
| 640 | else { | |
| 641 | collect(await publishOne(targets[0], targetProviders)) | |
| 537 | 642 | } |
| 538 | 643 | |
| 539 | 644 | if (activeMode === 'thread') { |
| @@ -573,7 +678,7 @@ | ||
| 573 | 678 | setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Publish failed' }) |
| 574 | 679 | } |
| 575 | 680 | finally { |
| 576 | if (publishLabel) publishLabel.textContent = 'Publish' | |
| 681 | updatePublishLabel() | |
| 577 | 682 | buttons.forEach(button => { |
| 578 | 683 | if (canPublish) button.removeAttribute('disabled') |
| 579 | 684 | }) |
| @@ -602,6 +707,22 @@ | ||
| 602 | 707 | selectPost(post) |
| 603 | 708 | updateCount() |
| 604 | 709 | }) |
| 710 | scheduleToggle?.addEventListener('click', () => { | |
| 711 | if (!scheduleInput) return | |
| 712 | const showing = scheduleInput.classList.toggle('hidden') === false | |
| 713 | if (showing) { | |
| 714 | // Local-time floor so the picker can't select the past. | |
| 715 | const soon = new Date(Date.now() + 5 * 60 * 1000) | |
| 716 | soon.setSeconds(0, 0) | |
| 717 | scheduleInput.min = new Date(soon.getTime() - soon.getTimezoneOffset() * 60 * 1000).toISOString().slice(0, 16) | |
| 718 | scheduleInput.focus() | |
| 719 | } | |
| 720 | else { | |
| 721 | scheduleInput.value = '' | |
| 722 | } | |
| 723 | updatePublishLabel() | |
| 724 | }) | |
| 725 | scheduleInput?.addEventListener('input', updatePublishLabel) | |
| 605 | 726 | addPostButton?.addEventListener('click', addPost) |
| 606 | 727 | previewButton?.addEventListener('click', () => { |
| 607 | 728 | const text = activeTextarea()?.value.trim() |
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | <meta name="color-scheme" content="light"> |
| 7 | 7 | <meta name="description" content="@yield('description', 'Postline is a clean personal workspace for social publishing.')"> |
| 8 | 8 | <title>@yield('title', 'Postline')</title> |
| 9 | <link rel="icon" href="/favicon.svg" type="image/svg+xml"> | |
| 9 | 10 | <script> |
| 10 | 11 | ;(() => { |
| 11 | 12 | document.documentElement.setAttribute('data-theme', 'light') |
| @@ -84,17 +85,28 @@ | ||
| 84 | 85 | transform: translate3d(0, 0, 0) scale(1); |
| 85 | 86 | pointer-events: auto; |
| 86 | 87 | } |
| 88 | ||
| 89 | @media (max-width: 1023px) { | |
| 90 | [data-sidebar-expand-fab] { | |
| 91 | display: none; | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | [data-postline-app] :is(a, button, [role='button']):focus-visible { | |
| 96 | outline: 2px solid rgb(82 82 91 / 0.6); | |
| 97 | outline-offset: 2px; | |
| 98 | } | |
| 87 | 99 | </style> |
| 88 | 100 | <link rel="stylesheet" href="/assets/styles/postline-toast.css"> |
| 89 | 101 | <link rel="stylesheet" href="/assets/styles/postline-timeline.css"> |
| 90 | 102 | </head> |
| 91 | 103 | <body class="min-h-screen min-w-80 antialiased font-sans text-neutral-950 bg-[#f3f3f1]"> |
| 92 | <div class="grid grid-cols-1 lg:grid-cols-[var(--postline-sidebar-width,286px)_minmax(0,1fr)] min-h-screen duration-200 ease-out transition-[grid-template-columns]" data-postline-app data-postline-shell> | |
| 104 | <div class="grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)] lg:grid-cols-[var(--postline-sidebar-width,286px)_minmax(0,1fr)] lg:grid-rows-1 min-h-screen duration-200 ease-out transition-[grid-template-columns]" data-postline-app data-postline-shell> | |
| 93 | 105 | <PostlineSidebar /> |
| 94 | 106 | |
| 95 | 107 | <div class="relative min-w-0 bg-[#f3f3f1]" data-postline-content-shell> |
| 96 | 108 | <button |
| 97 | class="fixed top-3.5 left-3.5 z-[90] inline-flex items-center justify-center h-9 w-9 text-zinc-700 bg-white/95 border border-zinc-200/90 rounded-xl shadow-[0_8px_28px_rgba(24,31,42,0.12)] backdrop-blur-sm hover:bg-white hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/60" | |
| 109 | class="inline-flex fixed left-3.5 top-3.5 z-[90] items-center justify-center h-9 w-9 text-zinc-700 hover:text-zinc-900 bg-white/95 hover:bg-white border border-zinc-200/90 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/60 shadow-[0_8px_28px_rgba(24,31,42,0.12)] backdrop-blur-sm" | |
| 98 | 110 | type="button" |
| 99 | 111 | aria-label="Expand sidebar" |
| 100 | 112 | title="Expand sidebar" |
| @@ -103,14 +115,14 @@ | ||
| 103 | 115 | > |
| 104 | 116 | <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18" aria-hidden="true"><path fill-rule="evenodd" d="M4.75 4a.75.75 0 0 0-.75.75v10.5c0 .414.336.75.75.75h2.5a.75.75 0 0 0 0-1.5H5.5V5.5h7.69l-1.22-1.22a.75.75 0 1 1 1.06-1.06l2.5 2.5a.75.75 0 0 1 0 1.06l-2.5 2.5a.75.75 0 1 1-1.06-1.06L13.19 11H5.5v5.25h1.75a.75.75 0 0 0 0 1.5h-2.5A.75.75 0 0 0 4.75 15V4.75Z" clip-rule="evenodd"/></svg> |
| 105 | 117 | </button> |
| 106 | <section class="overflow-hidden min-h-screen min-w-0 bg-[#f3f3f1] border-l border-t border-zinc-200/80 rounded-tl-[28px] shadow-[-10px_0_36px_rgba(24,31,42,0.08)]" aria-label="Postline workspace" data-postline-workspace data-window-drag-region> | |
| 118 | <section class="overflow-hidden min-h-screen min-w-0 bg-[#f3f3f1] border-t border-zinc-200/80 lg:border-l lg:rounded-tl-[28px] lg:shadow-[-10px_0_36px_rgba(24,31,42,0.08)]" aria-label="Postline workspace" data-postline-workspace data-window-drag-region> | |
| 107 | 119 | <main class="p-3.5 sm:p-4 min-w-0" data-postline-router> |
| 108 | 120 | @yield('content') |
| 109 | 121 | </main> |
| 110 | 122 | </section> |
| 111 | 123 | </div> |
| 112 | 124 | </div> |
| 113 | <div id="postline-toast-host" class="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" aria-live="polite" aria-relevant="additions"></div> | |
| 125 | <div id="postline-toast-host" class="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" aria-live="polite" aria-relevant="additions"></div> | |
| 114 | 126 | <script type="module" src="/assets/scripts/postline-toast.ts"></script> |
| 115 | 127 | <script type="module" src="/assets/scripts/postline-bluesky.ts"></script> |
| 116 | 128 | <script type="module" src="/assets/scripts/postline-linkedin.ts"></script> |
| @@ -302,9 +314,26 @@ | ||
| 302 | 314 | }) |
| 303 | 315 | } |
| 304 | 316 | |
| 317 | // Number keys 1-6 jump between pages (the sidebar shows the badges). | |
| 318 | const bindKeyboardShortcuts = () => { | |
| 319 | const routes = { 1: '/', 2: '/queue', 3: '/timeline', 4: '/analytics', 5: '/accounts', 6: '/blog', 7: '/settings' } | |
| 320 | document.addEventListener('keydown', (event) => { | |
| 321 | if (event.metaKey || event.ctrlKey || event.altKey) return | |
| 322 | const target = event.target | |
| 323 | if (target instanceof HTMLElement && target.closest('input, textarea, select, [contenteditable]')) return | |
| 324 | const href = routes[event.key] | |
| 325 | if (!href || window.location.pathname === href) return | |
| 326 | event.preventDefault() | |
| 327 | const link = document.querySelector(`[data-stx-link][href="${href}"]`) | |
| 328 | if (link instanceof HTMLElement) link.click() | |
| 329 | else window.location.assign(href) | |
| 330 | }) | |
| 331 | } | |
| 332 | ||
| 305 | 333 | bindSidebarCollapse() |
| 306 | 334 | bindShellHistory() |
| 307 | 335 | bindWindowDragRegions() |
| 336 | bindKeyboardShortcuts() | |
| 308 | 337 | syncNativeShell() |
| 309 | 338 | window.addEventListener('stx:load', bindShellHistory) |
| 310 | 339 | window.addEventListener('stx:load', bindSidebarCollapse) |
| @@ -3,66 +3,200 @@ | ||
| 3 | 3 | @section('title', 'Postline - Queue') |
| 4 | 4 | @section('description', 'Review scheduled and drafted social posts in Postline.') |
| 5 | 5 | |
| 6 | <script server> | |
| 7 | const scheduledPosts = [ | |
| 8 | { | |
| 9 | time: 'Today 3:30 PM', | |
| 10 | channel: 'Bluesky', | |
| 11 | status: 'Ready', | |
| 12 | text: 'Working on a calmer way to plan posts: drafts, timelines, scheduling, and publishing in one native-feeling place.', | |
| 13 | note: 'Auto-publish when the Bluesky driver token is present.', | |
| 14 | }, | |
| 15 | { | |
| 16 | time: 'Tomorrow 9:00 AM', | |
| 17 | channel: 'Bluesky', | |
| 18 | status: 'Draft', | |
| 19 | text: 'Tiny systems compound. A clean publishing queue might be the difference between ideas fading and ideas shipping.', | |
| 20 | note: 'Needs final copy pass.', | |
| 21 | }, | |
| 22 | { | |
| 23 | time: 'Friday 11:15 AM', | |
| 24 | channel: 'Mastodon', | |
| 25 | status: 'Planned', | |
| 26 | text: 'Cross-posting should feel intentional, not like copy/paste chores across six tabs.', | |
| 27 | note: 'Driver support planned after Bluesky settles.', | |
| 28 | }, | |
| 29 | ] | |
| 30 | </script> | |
| 31 | ||
| 32 | 6 | @section('content') |
| 33 | <section class="grid gap-3.5" data-postline-page="queue" data-testid="queue-page"> | |
| 7 | <section class="grid gap-5" data-postline-page="queue" data-testid="queue-page"> | |
| 8 | <!-- Crosswind safelist: status pill + action classes are set from JS. --> | |
| 9 | <div class="hidden text-amber-800 text-blue-700 text-emerald-700 text-red-700 text-zinc-600 bg-amber-50 bg-blue-50 bg-emerald-50 bg-red-50 bg-zinc-100 hover:bg-neutral-800 hover:bg-zinc-50 border-amber-200 border-blue-200 border-emerald-200 border-red-200 border-zinc-200 transition-colors" aria-hidden="true"></div> | |
| 10 | <header class="flex gap-4 items-center justify-between min-h-11"> | |
| 11 | <div> | |
| 12 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Queue</p> | |
| 13 | <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Scheduled posts</h1> | |
| 14 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">What's drafted, scheduled, and recently published.</p> | |
| 15 | </div> | |
| 16 | <a href="/" data-stx-link class="inline-flex gap-2 items-center justify-center px-4 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl transition-colors"> | |
| 17 | <span class="h-4 w-4 i-hugeicons-plus-sign" aria-hidden="true"></span> | |
| 18 | <span>New post</span> | |
| 19 | </a> | |
| 20 | </header> | |
| 21 | ||
| 22 | <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm"> | |
| 23 | <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200"> | |
| 24 | <strong class="text-[13px]">Upcoming & drafts</strong> | |
| 25 | <span class="text-xs text-zinc-500" data-queue-meta>Loading…</span> | |
| 26 | </header> | |
| 27 | <div class="grid gap-2.5 p-3" data-queue-pending> | |
| 28 | <p class="m-0 p-3 text-[13px] text-zinc-500" data-queue-pending-empty>Loading queue…</p> | |
| 29 | </div> | |
| 30 | </section> | |
| 31 | ||
| 34 | 32 | <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm"> |
| 35 | 33 | <header class="flex gap-3 items-center justify-between p-3.5 border-b border-zinc-200"> |
| 36 | <strong class="text-[13px]">Queue</strong> | |
| 37 | <div class="flex gap-2 items-center"> | |
| 38 | <a href="/" data-stx-link class="inline-flex gap-1.5 items-center justify-center px-3 h-8 font-bold text-xs text-neutral-950 bg-white border border-zinc-200 rounded-lg shadow-sm"> | |
| 39 | <span class="h-3.5 w-3.5 i-hugeicons-plus-sign" aria-hidden="true"></span> | |
| 40 | <span>New</span> | |
| 41 | </a> | |
| 42 | <button class="inline-flex gap-1.5 items-center justify-center px-3 h-8 font-bold text-xs text-white bg-neutral-950 rounded-lg" type="button"> | |
| 43 | <span class="h-3.5 w-3.5 i-hugeicons-magic-wand-01" aria-hidden="true"></span> | |
| 44 | <span>Optimize</span> | |
| 45 | </button> | |
| 46 | </div> | |
| 34 | <strong class="text-[13px]">Recent</strong> | |
| 35 | <span class="text-xs text-zinc-500" data-queue-recent-meta></span> | |
| 47 | 36 | </header> |
| 48 | <div class="grid gap-2.5 p-3"> | |
| 49 | @foreach (scheduledPosts as post) | |
| 50 | <article class="grid gap-2.5 p-3.5 bg-white border border-zinc-200 rounded-2xl"> | |
| 37 | <div class="grid gap-2.5 p-3" data-queue-recent> | |
| 38 | <p class="m-0 p-3 text-[13px] text-zinc-500" data-queue-recent-empty>Loading…</p> | |
| 39 | </div> | |
| 40 | </section> | |
| 41 | </section> | |
| 42 | ||
| 43 | <script> | |
| 44 | ;(() => { | |
| 45 | const root = document.querySelector('[data-testid="queue-page"]') | |
| 46 | if (!root) return | |
| 47 | ||
| 48 | const pendingList = root.querySelector('[data-queue-pending]') | |
| 49 | const recentList = root.querySelector('[data-queue-recent]') | |
| 50 | const meta = root.querySelector('[data-queue-meta]') | |
| 51 | const recentMeta = root.querySelector('[data-queue-recent-meta]') | |
| 52 | const toast = window.postlineToast?.show | |
| 53 | ||
| 54 | const PENDING = new Set(['draft', 'scheduled', 'publishing', 'failed']) | |
| 55 | ||
| 56 | const pillClass = (status) => { | |
| 57 | const base = 'px-2 py-1 font-bold text-[11px] rounded-full border whitespace-nowrap' | |
| 58 | if (status === 'published') return `${base} text-emerald-700 bg-emerald-50 border-emerald-200` | |
| 59 | if (status === 'scheduled') return `${base} text-blue-700 bg-blue-50 border-blue-200` | |
| 60 | if (status === 'publishing') return `${base} text-amber-800 bg-amber-50 border-amber-200` | |
| 61 | if (status === 'failed') return `${base} text-red-700 bg-red-50 border-red-200` | |
| 62 | return `${base} text-zinc-600 bg-zinc-100 border-zinc-200` | |
| 63 | } | |
| 64 | ||
| 65 | const escapeHtml = (value) => String(value || '') | |
| 66 | .replaceAll('&', '&') | |
| 67 | .replaceAll('<', '<') | |
| 68 | .replaceAll('>', '>') | |
| 69 | .replaceAll('"', '"') | |
| 70 | .replaceAll("'", ''') | |
| 71 | ||
| 72 | const providerLabel = (provider) => window.postlineSocial?.providerLabel(provider) || provider | |
| 73 | ||
| 74 | // Server timestamps are UTC "YYYY-MM-DD HH:MM:SS" — render in local time. | |
| 75 | const formatTime = (value) => { | |
| 76 | if (!value) return '' | |
| 77 | const date = new Date(`${value.replace(' ', 'T')}Z`) | |
| 78 | if (Number.isNaN(date.getTime())) return value | |
| 79 | return date.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) | |
| 80 | } | |
| 81 | ||
| 82 | const liveUrlFor = (uri) => { | |
| 83 | const value = String(uri || '') | |
| 84 | if (value.startsWith('/')) return value | |
| 85 | const match = value.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/) | |
| 86 | return match ? `https://bsky.app/profile/${encodeURIComponent(match[1])}/post/${encodeURIComponent(match[2])}` : null | |
| 87 | } | |
| 88 | ||
| 89 | const itemHtml = (item) => { | |
| 90 | const when = item.status === 'scheduled' | |
| 91 | ? `Scheduled · ${formatTime(item.scheduledAt)}` | |
| 92 | : item.status === 'published' | |
| 93 | ? `Published · ${formatTime(item.publishedAt || item.createdAt)}` | |
| 94 | : `${item.status[0].toUpperCase()}${item.status.slice(1)} · ${formatTime(item.createdAt)}` | |
| 95 | const channels = item.providers.map(target => providerLabel(target.provider)).join(', ') || '—' | |
| 96 | const failure = item.providers.find(target => target.failureReason)?.failureReason | |
| 97 | const liveUri = item.providers.find(target => target.remoteUri)?.remoteUri | |
| 98 | const liveUrl = liveUrlFor(liveUri) | |
| 99 | const actionable = ['draft', 'scheduled', 'failed'].includes(item.status) | |
| 100 | ||
| 101 | return ` | |
| 102 | <article class="grid gap-2.5 p-3.5 bg-white border border-zinc-200 rounded-2xl" data-queue-item="${item.id}"> | |
| 51 | 103 | <header class="flex gap-3 items-center justify-between"> |
| 52 | 104 | <div class="grid gap-1"> |
| 53 | <span class="font-bold text-xs">{{ post.time }}</span> | |
| 54 | <span class="text-xs text-zinc-500">{{ post.channel }}</span> | |
| 105 | <span class="font-bold text-xs">${escapeHtml(when)}</span> | |
| 106 | <span class="text-xs text-zinc-500">${escapeHtml(channels)}</span> | |
| 55 | 107 | </div> |
| 56 | <span class="px-2 py-1 font-bold text-[11px] text-blue-700 bg-blue-50 rounded-full">{{ post.status }}</span> | |
| 108 | <span class="${pillClass(item.status)}">${escapeHtml(item.status)}</span> | |
| 57 | 109 | </header> |
| 58 | <p class="m-0 leading-normal text-[13px] text-slate-700">{{ post.text }}</p> | |
| 59 | <footer class="flex items-center justify-between text-xs text-zinc-500"> | |
| 60 | <span>{{ post.note }}</span> | |
| 61 | <button class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-neutral-950 bg-white border border-zinc-200 rounded-lg shadow-sm" type="button">Edit</button> | |
| 110 | <p class="m-0 leading-normal text-[13px] text-slate-700">${escapeHtml(item.body)}</p> | |
| 111 | ${failure ? `<p class="m-0 text-red-700 text-xs">${escapeHtml(failure)}</p>` : ''} | |
| 112 | <footer class="flex gap-2 items-center justify-end"> | |
| 113 | ${liveUrl ? `<a class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-neutral-950 text-xs bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" href="${escapeHtml(liveUrl)}" target="_blank" rel="noopener noreferrer">View</a>` : ''} | |
| 114 | ${actionable ? `<button class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-white text-xs bg-neutral-950 hover:bg-neutral-800 rounded-lg transition-colors" type="button" data-queue-publish="${item.id}">Publish now</button>` : ''} | |
| 115 | ${actionable ? `<button class="inline-flex items-center justify-center px-2.5 h-7 font-bold text-red-700 text-xs bg-white hover:bg-zinc-50 border border-zinc-200 rounded-lg shadow-sm transition-colors" type="button" data-queue-delete="${item.id}">Delete</button>` : ''} | |
| 62 | 116 | </footer> |
| 63 | 117 | </article> |
| 64 | @endforeach | |
| 65 | </div> | |
| 66 | </section> | |
| 67 | </section> | |
| 118 | ` | |
| 119 | } | |
| 120 | ||
| 121 | const render = (items) => { | |
| 122 | const pending = items.filter(item => PENDING.has(item.status)) | |
| 123 | const recent = items.filter(item => !PENDING.has(item.status)) | |
| 124 | ||
| 125 | if (pendingList) { | |
| 126 | pendingList.innerHTML = pending.length | |
| 127 | ? pending.map(itemHtml).join('') | |
| 128 | : '<p class="m-0 p-3 text-[13px] text-zinc-500">Nothing queued yet. Schedule a post from the composer and it will show up here.</p>' | |
| 129 | } | |
| 130 | if (recentList) { | |
| 131 | recentList.innerHTML = recent.length | |
| 132 | ? recent.slice(0, 10).map(itemHtml).join('') | |
| 133 | : '<p class="m-0 p-3 text-[13px] text-zinc-500">No published posts yet.</p>' | |
| 134 | } | |
| 135 | if (meta) { | |
| 136 | const scheduled = pending.filter(item => item.status === 'scheduled').length | |
| 137 | const drafts = pending.filter(item => item.status === 'draft').length | |
| 138 | meta.textContent = `${scheduled} scheduled · ${drafts} draft${drafts === 1 ? '' : 's'}` | |
| 139 | } | |
| 140 | if (recentMeta) recentMeta.textContent = `${recent.length} post${recent.length === 1 ? '' : 's'}` | |
| 141 | } | |
| 142 | ||
| 143 | const refresh = async () => { | |
| 144 | try { | |
| 145 | const response = await fetch('/api/postline/queue') | |
| 146 | const payload = await response.json() | |
| 147 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not load the queue.') | |
| 148 | render(payload.data.items || []) | |
| 149 | } | |
| 150 | catch (error) { | |
| 151 | const message = error instanceof Error ? error.message : String(error) | |
| 152 | if (pendingList) pendingList.innerHTML = `<p class="m-0 p-3 text-[13px] text-zinc-500">${escapeHtml(message)}</p>` | |
| 153 | if (meta) meta.textContent = 'Could not load' | |
| 154 | if (toast) toast({ title: 'Queue', message, tone: 'error' }) | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | const post = async (path, id) => { | |
| 159 | const form = new FormData() | |
| 160 | form.set('id', String(id)) | |
| 161 | const response = await fetch(path, { method: 'POST', body: form }) | |
| 162 | const payload = await response.json() | |
| 163 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Request failed.') | |
| 164 | return payload.data | |
| 165 | } | |
| 166 | ||
| 167 | root.addEventListener('click', async (event) => { | |
| 168 | const target = event.target | |
| 169 | if (!(target instanceof HTMLElement)) return | |
| 170 | ||
| 171 | const publishButton = target.closest('[data-queue-publish]') | |
| 172 | if (publishButton) { | |
| 173 | publishButton.setAttribute('disabled', 'disabled') | |
| 174 | try { | |
| 175 | await post('/api/postline/queue/publish-now', publishButton.dataset.queuePublish) | |
| 176 | if (toast) toast({ title: 'Published', message: 'Post published.', tone: 'success' }) | |
| 177 | } | |
| 178 | catch (error) { | |
| 179 | if (toast) toast({ title: 'Publish failed', message: error instanceof Error ? error.message : String(error), tone: 'error' }) | |
| 180 | } | |
| 181 | await refresh() | |
| 182 | return | |
| 183 | } | |
| 184 | ||
| 185 | const deleteButton = target.closest('[data-queue-delete]') | |
| 186 | if (deleteButton) { | |
| 187 | deleteButton.setAttribute('disabled', 'disabled') | |
| 188 | try { | |
| 189 | await post('/api/postline/queue/delete', deleteButton.dataset.queueDelete) | |
| 190 | if (toast) toast({ title: 'Removed', message: 'Post removed from the queue.', tone: 'success' }) | |
| 191 | } | |
| 192 | catch (error) { | |
| 193 | if (toast) toast({ title: 'Delete failed', message: error instanceof Error ? error.message : String(error), tone: 'error' }) | |
| 194 | } | |
| 195 | await refresh() | |
| 196 | } | |
| 197 | }) | |
| 198 | ||
| 199 | void refresh() | |
| 200 | })() | |
| 201 | </script> | |
| 68 | 202 | @endsection |
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | <div> |
| 10 | 10 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Settings</p> |
| 11 | 11 | <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Publishing</h1> |
| 12 | <p class="mt-1.5 mb-0 text-[13px] text-zinc-600">Defaults for Postline. Bluesky credentials live on Accounts.</p> | |
| 12 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Defaults for Postline. Bluesky credentials live on Accounts.</p> | |
| 13 | 13 | </div> |
| 14 | 14 | </header> |
| 15 | 15 | |
| @@ -18,12 +18,12 @@ | ||
| 18 | 18 | <header class="flex gap-3 items-center justify-between pb-3 border-b border-zinc-200"> |
| 19 | 19 | <div> |
| 20 | 20 | <strong class="text-[13px]">Bluesky connection</strong> |
| 21 | <p class="mt-1 mb-0 text-xs text-zinc-500" data-settings-bluesky-summary>Checking…</p> | |
| 21 | <p class="mb-0 mt-1 text-xs text-zinc-500" data-settings-bluesky-summary>Checking…</p> | |
| 22 | 22 | </div> |
| 23 | <span class="rounded-full border px-2.5 py-1 text-[11px] font-bold border-zinc-200 text-zinc-500" data-settings-bluesky-pill>—</span> | |
| 23 | <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 border border-zinc-200 rounded-full" data-settings-bluesky-pill>—</span> | |
| 24 | 24 | </header> |
| 25 | <p class="m-0 text-[13px] leading-relaxed text-slate-700">Postline uses a Bluesky app password (not your main login). You can also set <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">BLUESKY_IDENTIFIER</code> and <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">BLUESKY_APP_PASSWORD</code> in <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">.env</code> for local dev.</p> | |
| 26 | <a class="inline-flex items-center justify-center px-4 h-9 w-fit font-bold text-sm text-white bg-neutral-950 rounded-xl" href="/accounts" data-stx-link>Manage on Accounts</a> | |
| 25 | <p class="m-0 leading-relaxed text-[13px] text-slate-700">Postline uses a Bluesky app password (not your main login). You can also set <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">BLUESKY_IDENTIFIER</code> and <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">BLUESKY_APP_PASSWORD</code> in <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">.env</code> for local dev.</p> | |
| 26 | <a class="inline-flex items-center justify-center px-4 h-9 w-fit font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl transition-colors" href="/accounts" data-stx-link>Manage on Accounts</a> | |
| 27 | 27 | </article> |
| 28 | 28 | |
| 29 | 29 | <article class="grid gap-3 p-4 bg-white border border-zinc-200 rounded-2xl shadow-sm"> |
| @@ -41,7 +41,7 @@ | ||
| 41 | 41 | </label> |
| 42 | 42 | <label class="flex gap-4 items-center justify-between text-[13px] text-slate-700"> |
| 43 | 43 | <span>Auto-save drafts</span> |
| 44 | <span class="text-emerald-700 font-bold">On</span> | |
| 44 | <span class="font-bold text-emerald-700">On</span> | |
| 45 | 45 | </label> |
| 46 | 46 | </article> |
| 47 | 47 | </section> |
| @@ -9,11 +9,11 @@ | ||
| 9 | 9 | <div> |
| 10 | 10 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Timeline</p> |
| 11 | 11 | <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Home feed</h1> |
| 12 | <p class="mt-1.5 mb-0 text-[13px] text-zinc-600">Posts from your connected Bluesky account.</p> | |
| 12 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Posts from your connected Bluesky account.</p> | |
| 13 | 13 | </div> |
| 14 | 14 | <div class="flex gap-2 items-center"> |
| 15 | <a class="inline-flex items-center justify-center px-4 h-9 font-bold text-sm text-neutral-950 bg-white border border-zinc-200 rounded-xl shadow-sm hover:bg-zinc-50" href="/" data-stx-link>Compose</a> | |
| 16 | <button class="inline-flex gap-2 items-center justify-center px-4 h-9 font-bold text-sm text-white bg-neutral-950 rounded-xl disabled:opacity-50" type="button" data-bluesky-timeline-refresh> | |
| 15 | <a class="inline-flex items-center justify-center px-4 h-9 font-bold text-neutral-950 text-sm bg-white hover:bg-zinc-50 border border-zinc-200 rounded-xl shadow-sm transition-colors" href="/" data-stx-link>Compose</a> | |
| 16 | <button class="inline-flex gap-2 items-center justify-center px-4 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl disabled:opacity-50 transition-colors" type="button" data-bluesky-timeline-refresh> | |
| 17 | 17 | <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M15.312 4.818A.75.75 0 0 0 14 4H6.5a.75.75 0 0 0 0 1.5h5.992l-4.43 4.431a.75.75 0 1 0 1.06 1.06l5.784-5.784a.75.75 0 0 0 0-1.06l-5.784-5.784a.75.75 0 0 0-1.06 1.06l4.43 4.43H6a2.75 2.75 0 0 0 0 5.5h8.312a.75.75 0 0 0 .53-.22l2.25-2.25a.75.75 0 0 0-1.06-1.06l-1.72 1.72H6a1.25 1.25 0 0 1 0-2.5h7.312a.75.75 0 0 0 .53-.22l2.25-2.25a.75.75 0 0 0 0-1.06l-2.25-2.25a.75.75 0 0 0-.53-.22Z" clip-rule="evenodd"/></svg> |
| 18 | 18 | <span data-bluesky-timeline-refresh-label>Refresh</span> |
| 19 | 19 | </button> |
| @@ -23,10 +23,10 @@ | ||
| 23 | 23 | <div class="overflow-hidden bg-white border border-zinc-200 rounded-2xl shadow-sm postline-feed" aria-label="Bluesky timeline"> |
| 24 | 24 | <div class="postline-feed__header"> |
| 25 | 25 | <div> |
| 26 | <strong class="text-sm font-bold text-zinc-900">Following</strong> | |
| 26 | <strong class="font-bold text-sm text-zinc-900">Following</strong> | |
| 27 | 27 | <p class="m-0 mt-0.5 text-xs text-zinc-500" data-bluesky-timeline-meta>Loading feed…</p> |
| 28 | 28 | </div> |
| 29 | <span class="rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-700">Bluesky</span> | |
| 29 | <span class="px-2.5 py-1 font-bold text-[11px] text-blue-700 bg-blue-50 border border-blue-200 rounded-full">Bluesky</span> | |
| 30 | 30 | </div> |
| 31 | 31 | <div class="postline-feed__list" data-bluesky-timeline-list> |
| 32 | 32 | <p class="postline-feed__loading" data-bluesky-timeline-empty>Loading your timeline…</p> |
| @@ -48,4 +48,10 @@ route.group({ prefix: '/postline/threads' }, () => { | ||
| 48 | 48 | route.group({ prefix: '/postline' }, () => { |
| 49 | 49 | route.get('/providers', 'Actions/Postline/ProvidersStatusAction').skipCsrf() |
| 50 | 50 | route.post('/publish', 'Actions/Postline/CrosspostPublishAction').skipCsrf() |
| 51 | route.get('/analytics', 'Actions/Postline/AnalyticsAction').skipCsrf() | |
| 52 | route.get('/blog', 'Actions/Postline/BlogListAction').skipCsrf() | |
| 53 | route.get('/queue', 'Actions/Postline/QueueListAction').skipCsrf() | |
| 54 | route.post('/queue', 'Actions/Postline/QueueSaveAction').skipCsrf() | |
| 55 | route.post('/queue/delete', 'Actions/Postline/QueueDeleteAction').skipCsrf() | |
| 56 | route.post('/queue/publish-now', 'Actions/Postline/QueuePublishNowAction').skipCsrf() | |
| 51 | 57 | }) |
| @@ -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. |
| @@ -22,6 +22,19 @@ const result = await Bun.build({ | ||
| 22 | 22 | ], |
| 23 | 23 | }) |
| 24 | 24 | |
| 25 | // dtsx (<= 0.10.8) drops the space between an interface name and its | |
| 26 | // `extends` clause inside `declare module` blocks, emitting a d.ts that | |
| 27 | // fails to parse (TS1005). Repair the known augmentation until the | |
| 28 | // upstream emitter is fixed. | |
| 29 | const augmentationDts = `${import.meta.dir}/dist/request-augmentation.d.ts` | |
| 30 | const dtsFile = Bun.file(augmentationDts) | |
| 31 | if (await dtsFile.exists()) { | |
| 32 | const contents = await dtsFile.text() | |
| 33 | const repaired = contents.replace(/\binterface (\w+)extends /g, 'interface $1 extends ') | |
| 34 | if (repaired !== contents) | |
| 35 | await Bun.write(augmentationDts, repaired) | |
| 36 | } | |
| 37 | ||
| 25 | 38 | await outro({ |
| 26 | 39 | dir: import.meta.dir, |
| 27 | 40 | startTime, |
| @@ -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 |
formatting only · 8 hidden lines
| @@ -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> |
formatting only · 10 hidden lines
| @@ -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> |