ReviewOS

also looking at this

stacks/postline

UI polish, real queue + scheduling, Bluesky threads, live analytics, blog target

#8
Merged glennmichael123 wants to merge feat/ui-polish into main
54 files +2036 -589
app/Actions/Postline/AnalyticsAction.tsadded+113-0
Changes to app/Actions/Postline/AnalyticsAction.ts
@@ -0,0 +1,113 @@
1import { Action } from '@stacksjs/actions'
2import { db } from '@stacksjs/database'
3import { response } from '@stacksjs/router'
4
5const database = db as any
6
7const DAY_MS = 24 * 60 * 60 * 1000
8
9function dayKey(date: Date): string {
10 return date.toISOString().slice(0, 10)
11}
12
13function 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
18export 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})
app/Actions/Postline/BlogListAction.tsadded+22-0
Changes to app/Actions/Postline/BlogListAction.ts
@@ -0,0 +1,22 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { blog } from '../../Services/Social/BlogService'
4
5export 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})
app/Actions/Postline/CrosspostPublishAction.tsmodified+12-1
Changes to app/Actions/Postline/CrosspostPublishAction.ts
@@ -35,8 +35,19 @@ export default new Action({
3535 ? [{ url: imageUrl, altText: imageAlt || undefined }]
3636 : undefined
3737
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[] = []
3841 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 })
4051 // Surface a partial-failure (some targets failed) as a 207-style payload
4152 // while still returning 200 so the client can report per-provider state.
4253 const allFailed = data.results.length > 0 && data.results.every(result => !result.ok)
app/Actions/Postline/ProvidersStatusAction.tsmodified+2-0
Changes to app/Actions/Postline/ProvidersStatusAction.ts
@@ -1,5 +1,6 @@
11import { Action } from '@stacksjs/actions'
22import { response } from '@stacksjs/router'
3import { blog } from '../../Services/Social/BlogService'
34import { bluesky } from '../../Services/Social/BlueskyService'
45import { instagram } from '../../Services/Social/InstagramService'
56import { linkedin } from '../../Services/Social/LinkedInService'
@@ -17,6 +18,7 @@ export default new Action({
1718 linkedin.status(),
1819 instagram.status(),
1920 threads.status(),
21 blog.status(),
2022 ])
2123
2224 return response.json({ ok: true, data: { providers } })
app/Actions/Postline/QueueDeleteAction.tsadded+27-0
Changes to app/Actions/Postline/QueueDeleteAction.ts
@@ -0,0 +1,27 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { postQueue } from '../../Services/Social/QueueService'
5
6export 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})
app/Actions/Postline/QueueListAction.tsadded+22-0
Changes to app/Actions/Postline/QueueListAction.ts
@@ -0,0 +1,22 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { postQueue } from '../../Services/Social/QueueService'
4
5export 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})
app/Actions/Postline/QueuePublishNowAction.tsadded+28-0
Changes to app/Actions/Postline/QueuePublishNowAction.ts
@@ -0,0 +1,28 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { postQueue } from '../../Services/Social/QueueService'
5
6export 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})
app/Actions/Postline/QueueSaveAction.tsadded+35-0
Changes to app/Actions/Postline/QueueSaveAction.ts
@@ -0,0 +1,35 @@
1import type { SocialProvider } from '../../Support/Social/types'
2import type { RequestInstance } from '@stacksjs/types'
3import { Action } from '@stacksjs/actions'
4import { response } from '@stacksjs/router'
5import { crosspostProviders } from '../../Services/Social/CrosspostService'
6import { postQueue } from '../../Services/Social/QueueService'
7
8export 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})
app/Jobs/PublishScheduledPosts.tsadded+19-0
Changes to app/Jobs/PublishScheduledPosts.ts
@@ -0,0 +1,19 @@
1import { Job } from '@stacksjs/queue'
2import { Every } from '@stacksjs/types'
3import { postQueue } from '../Services/Social/QueueService'
4
5export 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})
app/Models/BlogPost.tsadded+65-0
Changes to app/Models/BlogPost.ts
@@ -0,0 +1,65 @@
1import { defineModel } from '@stacksjs/orm'
2import { schema } from '@stacksjs/validation'
3
4export 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)
app/Models/PostTarget.tsmodified+1-1
Changes to app/Models/PostTarget.ts
@@ -27,7 +27,7 @@ export default defineModel({
2727 provider: {
2828 required: true,
2929 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() },
3131 factory: faker => faker.helpers.arrayElement(['bluesky', 'twitter', 'mastodon']),
3232 },
3333 status: {
app/Scheduler.tsmodified+5-0
Changes to app/Scheduler.ts
@@ -16,6 +16,11 @@ export default function () {
1616 .hourly()
1717 .setTimeZone('America/Los_Angeles')
1818
19 // Publish scheduled Postline posts as they come due
20 schedule
21 .job('PublishScheduledPosts')
22 .everyMinute()
23
1924 // Run a custom action every five minutes
2025 // schedule.action('CleanupTempFiles').everyFiveMinutes()
2126
app/Services/Social/BlogService.tsadded+162-0
Changes to app/Services/Social/BlogService.ts
@@ -0,0 +1,162 @@
1import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types'
2import { mkdir } from 'node:fs/promises'
3import { join } from 'node:path'
4import process from 'node:process'
5import { db } from '@stacksjs/database'
6import { ensureAccount, now, uuid } from './support'
7
8const 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.
12const CONTENT_DIR = join(process.cwd(), 'content/blog')
13
14/** Postline's own blog has no external character ceiling worth enforcing. */
15const BLOG_CHARACTER_LIMIT = 10000
16
17function 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
29async 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 */
48export 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
162export const blog = new BlogService()
app/Services/Social/BlueskyService.tsmodified+3-0
Changes to app/Services/Social/BlueskyService.ts
@@ -186,8 +186,10 @@ export class BlueskyService {
186186 const createdAt = now()
187187 const publishInput = { text: post.body } as Parameters<BlueskyDriver['publish']>[1] & {
188188 external?: { uri: string, title: string, description?: string }
189 reply?: { root: { uri: string, cid: string }, parent: { uri: string, cid: string } }
189190 }
190191 if (content?.external) publishInput.external = content.external
192 if (content?.reply) publishInput.reply = content.reply
191193
192194 await database.insertInto('post_targets').values({
193195 uuid: targetUuid,
@@ -230,6 +232,7 @@ export class BlueskyService {
230232 ok: true,
231233 url: published.url,
232234 uri: published.uri,
235 cid: published.cid,
233236 targetId: Number(target.id),
234237 }
235238 }
app/Services/Social/CrosspostService.tsmodified+107-5
Changes to app/Services/Social/CrosspostService.ts
@@ -1,6 +1,7 @@
11import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types'
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
4import { blog } from './BlogService'
45import { bluesky } from './BlueskyService'
56import { instagram } from './InstagramService'
67import { linkedin } from './LinkedInService'
@@ -19,6 +20,7 @@ const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = {
1920 linkedin,
2021 instagram,
2122 threads,
23 blog,
2224}
2325
2426export function crosspostProviders(): SocialProvider[] {
@@ -67,11 +69,7 @@ export class CrosspostService {
6769 .where('uuid', '=', postUuid)
6870 .executeTakeFirstOrThrow()
6971
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)
7573
7674 const anyOk = results.some(result => result.ok)
7775 const finishedAt = now()
@@ -83,6 +81,110 @@ export class CrosspostService {
8381
8482 return { postId: Number(post.id), results }
8583 }
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 }
86188}
87189
88190export const crosspost = new CrosspostService()
app/Services/Social/QueueService.tsadded+235-0
Changes to app/Services/Social/QueueService.ts
@@ -0,0 +1,235 @@
1import type { CrosspostTargetResult, SocialProvider } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { crosspost, crosspostProviders } from './CrosspostService'
5import { ensureAccount, now, uuid } from './support'
6
7const database = db as any
8
9/** Post statuses a user may still act on (publish now, delete). */
10const ACTIONABLE = new Set(['draft', 'scheduled', 'failed'])
11
12export interface QueueTargetView {
13 provider: SocialProvider
14 status: string
15 remoteUri: string | null
16 failureReason: string | null
17}
18
19export 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
30export 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
37export 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
235export const postQueue = new QueueService()
app/Support/Social/types.tsmodified+7-0
Changes to app/Support/Social/types.ts
@@ -7,6 +7,7 @@ export type SocialProvider =
77 | 'tiktok'
88 | 'linkedin'
99 | 'threads'
10 | 'blog'
1011
1112export interface SocialIdentityCredentials {
1213 handle: string
@@ -60,6 +61,11 @@ export interface PublishContent {
6061 url: string
6162 altText?: string
6263 }>
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 }
6369}
6470
6571/** Outcome of publishing one post to a single provider during a crosspost. */
@@ -68,6 +74,7 @@ export interface CrosspostTargetResult {
6874 ok: boolean
6975 url?: string
7076 uri?: string
77 cid?: string
7178 targetId?: number
7279 error?: string
7380}
bun.lockmodified+4-7
Changes to bun.lock
@@ -6,7 +6,6 @@
66 "name": "stacks",
77 "dependencies": {
88 "@stacksjs/bun-router": "^0.0.16",
9 "@ts-charts/charts": "link:@ts-charts/charts",
109 "craft-native": "^0.0.37",
1110 "stacks": "workspace:*",
1211 },
@@ -1123,7 +1122,7 @@
11231122
11241123 "@stacksjs/docs": ["@stacksjs/docs@workspace:storage/framework/core/docs"],
11251124
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=="],
11271126
11281127 "@stacksjs/email": ["@stacksjs/email@workspace:storage/framework/core/email"],
11291128
@@ -1269,8 +1268,6 @@
12691268
12701269 "@ts-charts/array": ["@ts-charts/array@0.2.0", "", { "dependencies": { "@ts-charts/internmap": "0.2.0" } }, "sha512-rcYZWzYx8pjqpcJOLyNWcxeCwEGFte1HANtMxTiiXswQ990VkWYK1iVY13W4EwUwJU0TGXdg1e3DGjVXtrwc5w=="],
12711270
1272 "@ts-charts/charts": ["@ts-charts/charts@link:@ts-charts/charts", {}],
1273
12741271 "@ts-charts/color": ["@ts-charts/color@0.2.0", "", {}, "sha512-Oe/gbCmTUhlkqQk1xFg+MSObDm5qEAlvBxmGiCDRsPZaPNrTlGlAiuiynPUfb9nl5ByZ39y3aPJFQjmq/RG+WA=="],
12751272
12761273 "@ts-charts/format": ["@ts-charts/format@0.2.0", "", {}, "sha512-sHCLKTsjUEai3v+MZEr9t/Gb/tbmGfPENji+mqxElJ4/ji80drIcqFvhvIGMgh68l7weFsJ3uBcNtO9/tGwRzQ=="],
@@ -1343,7 +1340,7 @@
13431340
13441341 "bun-plugin-auto-imports": ["bun-plugin-auto-imports@0.4.1", "", {}, "sha512-S/+M76UtZc+1SBpqLKOmFXuEVBDnEBqjwnD3dsbHQao8vV+C46uN0JMQNJUZLdhR35KChZh1mHVJuiNqBrOSXQ=="],
13451342
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=="],
13471344
13481345 "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=="],
13491346
@@ -1579,8 +1576,6 @@
15791576
15801577 "@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=="],
15811578
1582 "@stacksjs/dtsx/@stacksjs/clapp": ["@stacksjs/clapp@0.2.9", "", {}, "sha512-tY/Cl7ANduISwYA2flAthZMBh7VnLho1op2wjbGDwugl995+s4sfEvkTMoBgwdniKmG9HBQmNE1dRwVH+9l55g=="],
1583
15841579 "@stacksjs/logsmith/@stacksjs/clapp": ["@stacksjs/clapp@0.2.6", "", {}, "sha512-W5A5C4hn21jurKd6ntCTGMDPEu/2zUn5FWjweWtwkPZ6yP8/q0B/2kecTClAG2UoUPREEmlr2RlGi6eKux1Jpg=="],
15851580
15861581 "@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 @@
16151610
16161611 "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=="],
16171612
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
16181615 "bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
16191616
16201617 "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
components/PostlineSidebar.stxmodified+49-4
Changes to components/PostlineSidebar.stx
@@ -28,12 +28,48 @@ const settingsActive = currentPath === '/settings' || currentPath.startsWith('/s
2828</script>
2929
3030<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>
3268 <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>
3369 <div class="flex absolute right-5 top-3.5 z-20 items-center min-h-7" data-shell-controls-row>
3470 <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>
3773 </button>
3874 <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>
3975 <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
71107 <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>
72108 </a>
73109 @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>
74118 </nav>
75119
76120 <a
@@ -84,7 +128,8 @@ const settingsActive = currentPath === '/settings' || currentPath.startsWith('/s
84128 >
85129 <span class="h-[18px] w-[18px] i-hugeicons-settings-02 text-zinc-600" aria-hidden="true"></span>
86130 <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>
88132 </a>
89133 </aside>
134 </div>
90135</template>
database/migrations/0000000028-add-threads-blog-to-post_targets-provider.sqladded+23-0
Changes to database/migrations/0000000028-add-threads-blog-to-post_targets-provider.sql
@@ -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.
4CREATE 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);
20INSERT INTO "post_targets_new" SELECT * FROM "post_targets";
21DROP TABLE "post_targets";
22ALTER TABLE "post_targets_new" RENAME TO "post_targets";
23CREATE INDEX IF NOT EXISTS "post_targets_provider_status_index" ON "post_targets" ("provider", "status");
database/migrations/0000000029-create-blog_posts-table.sqladded+15-0
Changes to database/migrations/0000000029-create-blog_posts-table.sql
@@ -0,0 +1,15 @@
1CREATE 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);
15CREATE INDEX IF NOT EXISTS "blog_posts_status_published_at_index" ON "blog_posts" ("status", "published_at");
package.jsonmodified+0-1
Changes to package.json
@@ -70,7 +70,6 @@
7070 },
7171 "dependencies": {
7272 "@stacksjs/bun-router": "^0.0.16",
73 "@ts-charts/charts": "link:@ts-charts/charts",
7473 "craft-native": "^0.0.37",
7574 "stacks": "workspace:*"
7675 },
public/favicon.svgadded+4-0
Changes to public/favicon.svg
@@ -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>
resources/assets/scripts/postline-analytics.tsmodified+149-36
Changes to resources/assets/scripts/postline-analytics.ts
@@ -1,21 +1,50 @@
1import { line, scaleLinear, ticks } from '@ts-charts/charts'
2
31type Point = {
42 label: string
53 value: number
6 posts: number
74}
85
9const 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.
10function 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
17function linePath(coordinates: Array<[number, number]>): string {
18 return coordinates.map(([px, py], index) => `${index === 0 ? 'M' : 'L'}${px},${py}`).join('')
19}
20
21function 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
33interface 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.
46let points: Point[] = []
47let analyticsPromise: Promise<AnalyticsPayload | null> | null = null
1948
2049const width = 640
2150const height = 280
@@ -41,26 +70,21 @@ function renderAnalyticsChart(): void {
4170 const chart = document.querySelector<SVGSVGElement>('[data-analytics-chart]')
4271 const existingPath = chart?.querySelector<SVGPathElement>('[data-chart-line]')?.getAttribute('d')
4372 if (!chart || (chart.dataset.rendered === 'true' && existingPath)) return
73 if (points.length < 2) return
4474
4575 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)]))
5881 const baseline = height - inset.bottom
5982 const firstX = x(0)
6083 const lastX = x(points.length - 1)
6184 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))
6488
6589 chart.querySelector<SVGPathElement>('[data-chart-line]')?.setAttribute('d', path)
6690 chart.querySelector<SVGPathElement>('[data-chart-area]')?.setAttribute('d', areaPath)
@@ -99,8 +123,8 @@ function renderAnalyticsChart(): void {
99123 const labels = chart.querySelector<SVGGElement>('[data-chart-labels]')
100124 if (labels) {
101125 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>`,
104128 ].join('')
105129 }
106130
@@ -108,7 +132,7 @@ function renderAnalyticsChart(): void {
108132 if (legend) {
109133 legend.innerHTML = [
110134 `<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>`,
112136 `<rect x="${width - 82}" y="16" width="22" height="8" rx="4" fill="rgba(37, 99, 235, 0.12)"></rect>`,
113137 `<text x="${width - 28}" y="26" fill="${muted}" font-size="11" text-anchor="end">30 days</text>`,
114138 ].join('')
@@ -117,7 +141,7 @@ function renderAnalyticsChart(): void {
117141 const pointLayer = chart.querySelector<SVGGElement>('[data-chart-points]')
118142 if (pointLayer) {
119143 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>`)
121145 .join('')
122146 }
123147
@@ -132,7 +156,7 @@ function renderAnalyticsChart(): void {
132156 const xx = x(activeIndex)
133157 const yy = y(point.value)
134158 const tooltipWidth = 154
135 const tooltipHeight = 62
159 const tooltipHeight = 48
136160 const tooltipX = Math.min(Math.max(xx + 14, inset.left), width - inset.right - tooltipWidth)
137161 const tooltipY = Math.max(inset.top + 8, yy - tooltipHeight - 14)
138162
@@ -143,15 +167,14 @@ function renderAnalyticsChart(): void {
143167 `<g transform="translate(${tooltipX} ${tooltipY})" data-chart-tooltip>`,
144168 `<rect width="${tooltipWidth}" height="${tooltipHeight}" rx="12" fill="#fff" stroke="#d4d4d8" stroke-width="1.2"></rect>`,
145169 `<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>`,
148171 '</g>',
149172 ].join('')
150173 hoverLayer.removeAttribute('aria-hidden')
151174 }
152175
153176 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`)
155178 }
156179
157180 const hideHover = (): void => {
@@ -200,6 +223,93 @@ function renderAnalyticsChart(): void {
200223 chart.dataset.rendered = 'true'
201224}
202225
226const 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
235function 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
241function 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
292async 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
203313let renderFrame = 0
204314
205315function scheduleAnalyticsRender(): void {
@@ -212,14 +322,17 @@ function scheduleAnalyticsRender(): void {
212322}
213323
214324function watchAnalyticsChartMounts(): void {
325 if (document.querySelector('[data-analytics-chart]')) void loadAnalytics()
215326 scheduleAnalyticsRender()
216327
217328 const target = document.body || document.documentElement
218329 if (!target) return
219330
220331 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()
222334 scheduleAnalyticsRender()
335 }
223336 })
224337
225338 observer.observe(target, { childList: true, subtree: true })
resources/assets/scripts/postline-social.tsmodified+5-0
Changes to resources/assets/scripts/postline-social.ts
@@ -24,6 +24,8 @@ export interface CrosspostInput {
2424 providers: string[]
2525 external?: { uri: string, title: string, description?: string }
2626 image?: { url: string, altText?: string }
27 /** Multi-segment thread; reply-chained on providers that support it. */
28 thread?: string[]
2729}
2830
2931const PROVIDER_LABELS: Record<string, string> = {
@@ -35,6 +37,7 @@ const PROVIDER_LABELS: Record<string, string> = {
3537 instagram: 'Instagram',
3638 threads: 'Threads',
3739 tiktok: 'TikTok',
40 blog: 'Blog',
3841}
3942
4043export function providerLabel(provider: string): string {
@@ -54,6 +57,8 @@ export async function publishCrosspost(input: CrosspostInput): Promise<{ postId:
5457 const body = new FormData()
5558 body.set('text', input.text)
5659 body.set('providers', input.providers.join(','))
60 if (input.thread && input.thread.length > 1)
61 body.set('thread', JSON.stringify(input.thread))
5762 if (input.external?.uri && input.external?.title) {
5863 body.set('external_uri', input.external.uri)
5964 body.set('external_title', input.external.title)
resources/assets/scripts/postline-toast.tsmodified+2-2
Changes to resources/assets/scripts/postline-toast.ts
@@ -36,7 +36,7 @@ function host(): HTMLElement {
3636 if (!element) {
3737 element = document.createElement('div')
3838 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'
4040 element.setAttribute('aria-live', 'polite')
4141 element.setAttribute('aria-relevant', 'additions')
4242 document.body.append(element)
formatting only · 8 hidden lines
Changes to resources/assets/scripts/postline-toast.ts
@@ -95,7 +95,7 @@ export function showPostlineToast(options: PostlineToastOptions): void { formatting only
9595
9696 const bodyMarkup = options.url
9797 ? `<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>`
9999 : `<p class="postline-toast__message">${escapeHtml(options.message)}</p>`
100100
101101 toast.innerHTML = `
resources/views/accounts.stxmodified+7-7
Changes to resources/views/accounts.stx
@@ -26,7 +26,7 @@
2626 <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>
2727 </label>
2828 <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>
3030 </div>
3131 <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>
3232 </form>
@@ -53,8 +53,8 @@
5353 </div>
5454 <div class="flex flex-col gap-2 items-end">
5555 <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>
5858 </div>
5959 </article>
6060 <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 @@
6565 </div>
6666 <div class="flex flex-col gap-2 items-end">
6767 <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>
7070 </div>
7171 </article>
7272 <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 @@
7777 </div>
7878 <div class="flex flex-col gap-2 items-end">
7979 <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>
8282 </div>
8383 </article>
8484 <article class="flex gap-5 items-center justify-between p-3.5 bg-zinc-50/80 border border-zinc-200 rounded-xl opacity-80">
resources/views/analytics.stxmodified+33-54
Changes to resources/views/analytics.stx
@@ -3,44 +3,43 @@
33@section('title', 'Postline - Analytics')
44@section('description', 'Review lightweight publishing analytics in Postline.')
55
6<script server>
7const 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
13const 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
206@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
2216 <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>
3032 </div>
3133
3234 <div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_320px]">
3335 <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm">
3436 <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>
4039 </header>
4140 <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>
4443 <rect x="0" y="0" width="640" height="280" rx="16" fill="#fafafa"></rect>
4544 <g data-chart-legend></g>
4645 <g data-chart-grid></g>
@@ -51,7 +50,7 @@ const channelSplits = [
5150 <path data-chart-line fill="none" stroke="#2563eb" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></path>
5251 <g data-chart-points></g>
5352 <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>
5554 </svg>
5655 </div>
5756 </section>
@@ -61,18 +60,8 @@ const channelSplits = [
6160 <header class="p-3.5 border-b border-zinc-200">
6261 <strong class="text-[13px]">Channel mix</strong>
6362 </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>
7665 </div>
7766 </section>
7867
@@ -80,21 +69,11 @@ const channelSplits = [
8069 <header class="p-3.5 border-b border-zinc-200">
8170 <strong class="text-[13px]">Best windows</strong>
8271 </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>
8774 </div>
8875 </section>
8976 </aside>
9077 </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>
9978 </section>
10079@endsection
resources/views/index.stxmodified+140-19
Changes to resources/views/index.stx
@@ -4,11 +4,18 @@
44@section('description', 'Write, schedule, publish, and review social posts from a clean personal workspace.')
55
66@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">
88 <!-- Crosswind safelist: utilities applied dynamically from the script below
99 (provider toggles, active-post highlight, connection summary, char
1010 counter) must appear in static markup so the CSS scanner emits them. -->
1111 <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>
1219 <div class="grid">
1320 <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">
1421 <header class="flex flex-wrap gap-3 items-center justify-between p-3 border-b border-zinc-200">
@@ -20,9 +27,9 @@
2027 </a>
2128 </div>
2229 <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>
2633 </div>
2734 </header>
2835
@@ -41,7 +48,7 @@
4148 <span class="h-3.5 w-3.5 i-hugeicons-edit-02" aria-hidden="true"></span>
4249 <span data-post-label>Post 1</span>
4350 </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>
4552 <span class="h-3.5 w-3.5 i-hugeicons-cancel-01" aria-hidden="true"></span>
4653 </button>
4754 </header>
@@ -52,15 +59,19 @@
5259 </div>
5360
5461 <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>
5869 <span class="h-4 w-4 i-hugeicons-plus-sign" aria-hidden="true"></span>
5970 </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>
6172 <span class="h-4 w-4 i-hugeicons-view" aria-hidden="true"></span>
6273 </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>
6475 <span class="h-4 w-4 i-hugeicons-sent" aria-hidden="true"></span>
6576 <span data-bluesky-publish-label>Publish</span>
6677 </button>
@@ -88,6 +99,8 @@
8899 const providerToggles = root.querySelector('[data-provider-toggles]')
89100 const composeImageRow = root.querySelector('[data-compose-image-row]')
90101 const composeImage = root.querySelector('[data-compose-image]')
102 const scheduleToggle = root.querySelector('[data-schedule-toggle]')
103 const scheduleInput = root.querySelector('[data-compose-schedule]')
91104 const postsRoot = root.querySelector('[data-composer-posts]')
92105 const addPostButton = root.querySelector('[data-add-post]')
93106 const previewButton = root.querySelector('[data-preview-post]')
@@ -251,7 +264,7 @@
251264 <span class="h-3.5 w-3.5 i-hugeicons-edit-02" aria-hidden="true"></span>
252265 <span data-post-label>Post ${number}</span>
253266 </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>
255268 <span class="h-3.5 w-3.5 i-hugeicons-cancel-01" aria-hidden="true"></span>
256269 </button>
257270 </header>
@@ -452,6 +465,75 @@
452465 saveDraft()
453466 }
454467
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
455537 const setMode = (mode, options = {}) => {
456538 const nextMode = ['single', 'thread', 'draft'].includes(mode) ? mode : 'single'
457539 activeMode = nextMode
@@ -465,6 +547,7 @@
465547 if (nextMode === 'thread' && allPosts().length === 1 && !options.skipThreadPost) {
466548 addPost({ announce: false, focus: false, save: false })
467549 }
550 updatePublishLabel()
468551 if (!options.silent) setStatus(`${nextMode[0].toUpperCase()}${nextMode.slice(1)} mode selected.`, 'success')
469552 if (!options.skipSave) saveDraft()
470553 }
@@ -489,6 +572,11 @@
489572 }
490573
491574 const publish = async () => {
575 if (activeMode === 'draft' || scheduleInput?.value) {
576 await saveToQueue(scheduleInput?.value || null)
577 return
578 }
579
492580 const targetProviders = selectedConnected().map(provider => provider.provider)
493581 if (targetProviders.length === 0) {
494582 setStatus('Select a connected account on the Accounts page before publishing.', 'error', { title: 'Not connected' })
@@ -518,8 +606,7 @@
518606 let lastUrl
519607 const failures = []
520608 let successCount = 0
521 for (const [index, post] of targets.entries()) {
522 const data = await publishOne(post, targetProviders)
609 const collect = (data) => {
523610 for (const result of data.results || []) {
524611 if (result.ok) {
525612 successCount += 1
@@ -529,11 +616,29 @@
529616 failures.push(`${providerLabel(result.provider)}: ${result.error || 'failed'}`)
530617 }
531618 }
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))
537642 }
538643
539644 if (activeMode === 'thread') {
@@ -573,7 +678,7 @@
573678 setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Publish failed' })
574679 }
575680 finally {
576 if (publishLabel) publishLabel.textContent = 'Publish'
681 updatePublishLabel()
577682 buttons.forEach(button => {
578683 if (canPublish) button.removeAttribute('disabled')
579684 })
@@ -602,6 +707,22 @@
602707 selectPost(post)
603708 updateCount()
604709 })
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)
605726 addPostButton?.addEventListener('click', addPost)
606727 previewButton?.addEventListener('click', () => {
607728 const text = activeTextarea()?.value.trim()
resources/views/layouts/postline.stxmodified+33-4
Changes to resources/views/layouts/postline.stx
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light">
77 <meta name="description" content="@yield('description', 'Postline is a clean personal workspace for social publishing.')">
88 <title>@yield('title', 'Postline')</title>
9 <link rel="icon" href="/favicon.svg" type="image/svg+xml">
910 <script>
1011 ;(() => {
1112 document.documentElement.setAttribute('data-theme', 'light')
@@ -84,17 +85,28 @@
8485 transform: translate3d(0, 0, 0) scale(1);
8586 pointer-events: auto;
8687 }
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 }
8799 </style>
88100 <link rel="stylesheet" href="/assets/styles/postline-toast.css">
89101 <link rel="stylesheet" href="/assets/styles/postline-timeline.css">
90102 </head>
91103 <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>
93105 <PostlineSidebar />
94106
95107 <div class="relative min-w-0 bg-[#f3f3f1]" data-postline-content-shell>
96108 <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"
98110 type="button"
99111 aria-label="Expand sidebar"
100112 title="Expand sidebar"
@@ -103,14 +115,14 @@
103115 >
104116 <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>
105117 </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>
107119 <main class="p-3.5 sm:p-4 min-w-0" data-postline-router>
108120 @yield('content')
109121 </main>
110122 </section>
111123 </div>
112124 </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>
114126 <script type="module" src="/assets/scripts/postline-toast.ts"></script>
115127 <script type="module" src="/assets/scripts/postline-bluesky.ts"></script>
116128 <script type="module" src="/assets/scripts/postline-linkedin.ts"></script>
@@ -302,9 +314,26 @@
302314 })
303315 }
304316
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
305333 bindSidebarCollapse()
306334 bindShellHistory()
307335 bindWindowDragRegions()
336 bindKeyboardShortcuts()
308337 syncNativeShell()
309338 window.addEventListener('stx:load', bindShellHistory)
310339 window.addEventListener('stx:load', bindSidebarCollapse)
resources/views/queue.stxmodified+186-52
Changes to resources/views/queue.stx
@@ -3,66 +3,200 @@
33@section('title', 'Postline - Queue')
44@section('description', 'Review scheduled and drafted social posts in Postline.')
55
6<script server>
7const 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
326@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 &amp; 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
3432 <section class="bg-white border border-zinc-200 rounded-2xl shadow-sm">
3533 <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>
4736 </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('&', '&amp;')
67 .replaceAll('<', '&lt;')
68 .replaceAll('>', '&gt;')
69 .replaceAll('"', '&quot;')
70 .replaceAll("'", '&#039;')
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}">
51103 <header class="flex gap-3 items-center justify-between">
52104 <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>
55107 </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>
57109 </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>` : ''}
62116 </footer>
63117 </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>
68202@endsection
resources/views/settings.stxmodified+6-6
Changes to resources/views/settings.stx
@@ -9,7 +9,7 @@
99 <div>
1010 <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Settings</p>
1111 <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>
1313 </div>
1414 </header>
1515
@@ -18,12 +18,12 @@
1818 <header class="flex gap-3 items-center justify-between pb-3 border-b border-zinc-200">
1919 <div>
2020 <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>
2222 </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>
2424 </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>
2727 </article>
2828
2929 <article class="grid gap-3 p-4 bg-white border border-zinc-200 rounded-2xl shadow-sm">
@@ -41,7 +41,7 @@
4141 </label>
4242 <label class="flex gap-4 items-center justify-between text-[13px] text-slate-700">
4343 <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>
4545 </label>
4646 </article>
4747 </section>
resources/views/timeline.stxmodified+5-5
Changes to resources/views/timeline.stx
@@ -9,11 +9,11 @@
99 <div>
1010 <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Timeline</p>
1111 <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>
1313 </div>
1414 <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>
1717 <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>
1818 <span data-bluesky-timeline-refresh-label>Refresh</span>
1919 </button>
@@ -23,10 +23,10 @@
2323 <div class="overflow-hidden bg-white border border-zinc-200 rounded-2xl shadow-sm postline-feed" aria-label="Bluesky timeline">
2424 <div class="postline-feed__header">
2525 <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>
2727 <p class="m-0 mt-0.5 text-xs text-zinc-500" data-bluesky-timeline-meta>Loading feed…</p>
2828 </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>
3030 </div>
3131 <div class="postline-feed__list" data-bluesky-timeline-list>
3232 <p class="postline-feed__loading" data-bluesky-timeline-empty>Loading your timeline…</p>
routes/api.tsmodified+6-0
Changes to routes/api.ts
@@ -48,4 +48,10 @@ route.group({ prefix: '/postline/threads' }, () => {
4848route.group({ prefix: '/postline' }, () => {
4949 route.get('/providers', 'Actions/Postline/ProvidersStatusAction').skipCsrf()
5050 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()
5157})
storage/framework/core/actions/src/blog.tsmodifiedformatting only+6-6
Changes to storage/framework/core/actions/src/blog.ts
@@ -276,9 +276,9 @@ async function postHtml(bp: BunPress, slug: string, origin: string): Promise<str
276276
277277 const authorCard = fm.authorBio
278278 ? `<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>`
282282 : ''
283283
284284 const postUrl = `${origin}/blog/${slug}`
@@ -292,9 +292,9 @@ async function postHtml(bp: BunPress, slug: string, origin: string): Promise<str
292292 const others = listPosts().filter(p => p.slug !== slug).slice(0, 3)
293293 const more = others.length
294294 ? `<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>`
298298 : ''
299299
300300 return bp.wrapInLayout(blogChrome() + header + html + authorCard + share + more + blogFooter(), blogConfig(bp, fm), `/blog/${slug}`, 'page')
storage/framework/core/database/src/fk-audit.tsmodifiedformatting only+2-2
Changes to storage/framework/core/database/src/fk-audit.ts
@@ -332,10 +332,10 @@ async function getPostgresLiveFKs(db: any): Promise<LiveFK[]> {
332332 FROM information_schema.referential_constraints rc
333333 JOIN information_schema.key_column_usage kcu
334334 ON kcu.constraint_name = rc.constraint_name
335 AND kcu.constraint_schema = rc.constraint_schema
335 AND kcu.constraint_schema = rc.constraint_schema
336336 JOIN information_schema.constraint_column_usage ccu
337337 ON ccu.constraint_name = rc.constraint_name
338 AND ccu.constraint_schema = rc.constraint_schema
338 AND ccu.constraint_schema = rc.constraint_schema
339339 WHERE rc.constraint_schema = 'public'
340340 `)
341341 return (Array.isArray(rows) ? rows : []).map((row: any) => ({
storage/framework/core/email/README.mdmodified+2-2
Changes to storage/framework/core/email/README.md
@@ -111,7 +111,7 @@ EMAILJS*HOST=example.com
111111EMAILJS*USERNAME=username
112112EMAILJS*PASSWORD=password
113113EMAILJS*PORT=25
114EMAILJS_SECURE=true
114EMAILJS*SECURE=true
115115```
116116
117117Learn more in the docs.
@@ -163,7 +163,7 @@ or ignore `<style>` blocks, so styles have to ride on each element via
163163`style="…"`. Stacks ships a pass-through inliner that runs after
164164`renderEmail()` and before the result hands back:
165165
166- **On by default in production** (`APP_ENV` or `NODE_ENV` is
166- **On by default in production** (`APP*ENV` or `NODE_ENV` is
167167 `production`). Off in dev so previews show the un-mutated stx
168168 output.
169169- **Per-call opt-out** via `template(name, { inline: false })`.
storage/framework/core/email/src/webhook-signatures.tsmodified+1-1
Changes to storage/framework/core/email/src/webhook-signatures.ts
@@ -20,7 +20,7 @@
2020 * - SendGrid: ECDSA over `timestamp + body` with the public key
2121 * from their dashboard. P-256 curve, DER signature.
2222 *
23 * Each function takes the bits it needs (request body, headers,
23 * Each function takes the bits it needs (request body, _headers,
2424 * configured secret) and returns a discriminated `{ ok, reason? }`
2525 * — the route handler maps `ok: false` to a 401/403 without
2626 * touching the rest of the pipeline.
storage/framework/core/router/build.tsmodified+13-0
Changes to storage/framework/core/router/build.ts
@@ -22,6 +22,19 @@ const result = await Bun.build({
2222 ],
2323})
2424
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.
29const augmentationDts = `${import.meta.dir}/dist/request-augmentation.d.ts`
30const dtsFile = Bun.file(augmentationDts)
31if (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
2538await outro({
2639 dir: import.meta.dir,
2740 startTime,
storage/framework/core/socials/src/drivers/bluesky.tsmodified+1-0
Changes to storage/framework/core/socials/src/drivers/bluesky.ts
@@ -110,6 +110,7 @@ export class BlueskyPublishingDriver implements SocialPublishingDriver {
110110 }
111111
112112 if (post.langs?.length) record.langs = post.langs
113 if (post.reply) record.reply = post.reply
113114 if (post.external) {
114115 record.embed = {
115116 $type: 'app.bsky.embed.external',
storage/framework/core/socials/src/types.tsmodified+8-0
Changes to storage/framework/core/socials/src/types.ts
@@ -103,6 +103,14 @@ export interface PublishPostInput {
103103 title: string
104104 description?: string
105105 }
106 /**
107 * Reply references for thread chaining (ATProto shape). `root` is the
108 * first post of the thread, `parent` the one directly above.
109 */
110 reply?: {
111 root: { uri: string, cid: string }
112 parent: { uri: string, cid: string }
113 }
106114 /**
107115 * Attached media. Required by media-only providers such as Instagram, and
108116 * ignored by text-first providers (Bluesky, LinkedIn) for now.
storage/framework/defaults/resources/components/Pagination.stxmodified+5-5
Changes to storage/framework/defaults/resources/components/Pagination.stx
@@ -74,10 +74,10 @@ const inactiveLink = `${baseLink} border-gray-300 dark:border-neutral-600 text-g
7474const disabledLink = `${baseLink} border-gray-200 dark:border-neutral-700 text-gray-400 dark:text-neutral-500 cursor-not-allowed`
7575</script>
7676
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">
7878 @if(isFull)
7979 {{-- 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">
8181 @if(paginator.prev_page_url)
8282 <a href="" class=" rounded-md">Previous</a>
8383 @else
@@ -92,7 +92,7 @@ const disabledLink = `${baseLink} border-gray-200 dark:border-neutral-700 text-g
9292
9393 {{-- Desktop: prev / page numbers / next + "Showing X to Y of Z" --}}
9494 <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">
9696 @if(paginator.from && paginator.to)
9797 Showing <span class="font-medium">{{ paginator.from }}</span>
9898 to <span class="font-medium">{{ paginator.to }}</span>
@@ -102,7 +102,7 @@ const disabledLink = `${baseLink} border-gray-200 dark:border-neutral-700 text-g
102102 @endif
103103 </p>
104104
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">
106106 @if(paginator.prev_page_url)
107107 <a href="" rel="prev" class=" rounded-l-md" aria-label="Previous page">←</a>
108108 @else
formatting only · 8 hidden lines
Changes to storage/framework/defaults/resources/components/Pagination.stx
@@ -132,7 +132,7 @@ const disabledLink = `${baseLink} border-gray-200 dark:border-neutral-700 text-g formatting only
132132
133133 @if(isSimple || isCursor)
134134 {{-- 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). --}}
136136 <div class="flex flex-1 justify-between">
137137 @if(paginator.prev_page_url)
138138 <a href="" rel="prev" class=" rounded-md">← Previous</a>
storage/framework/defaults/views/dashboard/AUDIT.mdmodified+3-3
Changes to storage/framework/defaults/views/dashboard/AUDIT.md
@@ -36,8 +36,8 @@ reassign `const` vars (always threw in strict-mode JS) and called
3636`useRouter()` (a Nuxt-ism, no-op in stx).
3737
3838Remaining 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
4141`@stacksjs/components`) are the open targets.
4242
4343Three new pages landed since the original audit (kanban index, kanban
@@ -47,7 +47,7 @@ pages outpaced the new arrivals.
4747
4848The remaining 78 DOM-access lines cluster in two patterns: pre-existing
4949`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
5151import-map / role-filter installer where direct DOM is appropriate. The
5252in-page DOM references are the next sweep target.
5353
storage/framework/defaults/views/dashboard/[...all].stxmodified+5-5
Changes to storage/framework/defaults/views/dashboard/[...all].stx
@@ -2,11 +2,11 @@
22
33@section('content')
44{{-- 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">
1010 <div class="text-center">
1111 <div class="flex justify-center">
1212 <div class="mb-6 h-24 w-24 text-gray-400 dark:text-neutral-500 i-hugeicons-alert-circle"></div>
storage/framework/defaults/views/dashboard/ci/index.stxmodified+35-35
Changes to storage/framework/defaults/views/dashboard/ci/index.stx
@@ -181,10 +181,10 @@
181181
182182<div class="mx-auto px-6 py-8 max-w-7xl">
183183 <!-- 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">
188188 <p class="text-sm text-zinc-600 dark:text-zinc-400">This surface requires the dev role.</p>
189189 </div>
190190
@@ -195,7 +195,7 @@
195195 </header>
196196
197197 <!-- 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">
199199 <p class="text-sm text-zinc-600 dark:text-zinc-400">CI tracking is disabled.</p>
200200 <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>
201201 </div>
@@ -211,9 +211,9 @@
211211 </div>
212212
213213 <!-- 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>
217217 <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>
218218 </div>
219219
@@ -246,11 +246,11 @@
246246 <span class="text-zinc-500 dark:text-zinc-400" :text="runnersText"></span>
247247 </div>
248248 <!-- 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>
254254 <svg width="200" height="24" viewBox="0 0 200 24" class="text-violet-400">
255255 <path :d="sparklinePath()" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
256256 </svg>
formatting only · 10 hidden lines
Changes to storage/framework/defaults/views/dashboard/ci/index.stx
@@ -281,8 +281,8 @@ formatting only
281281 <div class="flex-1 min-w-0">
282282 <div class="flex gap-2 items-center">
283283 <!-- 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. -->
286286 <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>
287287 <span :if="repo.commitSha" class="font-mono text-xs text-zinc-400" :text="repo.commitSha"></span>
288288 <span :if="repo.updatedAt" class="text-xs text-zinc-400" :text="timeAgo(repo.updatedAt)"></span>
Changes to storage/framework/defaults/views/dashboard/ci/index.stx
@@ -337,25 +337,25 @@
337337
338338 <!-- ────────── Drilldown drawer (#1848) ────────── -->
339339 <!-- 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. -->
343343 <div
344344 :if="drilldownTarget()"
345 class="fixed inset-0 z-50 flex justify-end"
345 class="flex fixed inset-0 z-50 justify-end"
346346 @click="ci.closeDrilldown()"
347347 @keydown.escape.window="ci.closeDrilldown()"
348348 >
349349 <div class="absolute inset-0 bg-black/40"></div>
350350 <aside
351351 @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"
353353 >
354354 <!-- 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">
356356 <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>
359359 </div>
360360 <button
361361 @click="ci.closeDrilldown()"
@@ -369,8 +369,8 @@
369369 </div>
370370
371371 <!-- 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>
374374 </div>
375375
376376 <!-- Loading -->
@@ -391,17 +391,17 @@
391391 @click="ci.toggleRunJobs(run.id)"
392392 :class="runRowClass(run.conclusion, run.status)"
393393 >
394 <div class="flex items-start justify-between gap-3">
394 <div class="flex gap-3 items-start justify-between">
395395 <div class="flex-1 min-w-0">
396 <div class="flex items-center gap-2">
396 <div class="flex gap-2 items-center">
397397 <span
398398 :class="`px-1.5 py-0.5 text-[10px] font-medium rounded ${runStatusBadge(run.conclusion, run.status).cls}`"
399399 :text="runStatusBadge(run.conclusion, run.status).label"
400400 ></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>
402402 </div>
403403 <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">
405405 <span class="font-mono" :text="run.headShaShort"></span>
406406 <span :if="run.headBranch" :text="run.headBranch"></span>
407407 <span :if="run.commitAuthor">·</span>
@@ -416,27 +416,27 @@
416416 :href="run.url"
417417 target="_blank"
418418 @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"
420420 >view ↗</a>
421421 </div>
422422 </div>
423423
424424 <!-- Expanded: failed job list -->
425425 <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">
427427 Loading jobs…
428428 </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">
430430 No job detail.
431431 </div>
432432 <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>
434434 <a
435435 :for="job in failedJobs(run.id)"
436436 :href="job.url"
437437 target="_blank"
438438 @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"
440440 >
441441 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
442442 <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 @@
445445 <span :if="job.durationMs" class="text-zinc-400" :text="'(' + formatDuration(job.durationMs) + ')'"></span>
446446 </a>
447447 </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">
449449 All <span :text="jobsForRun(run.id).length"></span> jobs passed.
450450 </div>
451451 </div>
storage/framework/defaults/views/dashboard/content/authors/index.stxmodified+33-33
Changes to storage/framework/defaults/views/dashboard/content/authors/index.stx
@@ -1,42 +1,42 @@
11@extends('layouts/default')
22
33@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">
77 <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">Authors</h1>
10 <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage the authors available to posts and pages.</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">Authors</h1>
10 <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Manage the authors available to posts and pages.</p>
1111 </div>
12 <button id="new-author-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-author-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>
1414 New Author
1515 </button>
1616 </header>
1717
1818 <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 Authors</p>
21 <p id="author-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 Authors</p>
21 <p id="author-total" class="mt-2 font-semibold text-3xl">0</p>
2222 </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">Assigned Posts</p>
25 <p id="author-posts" class="mt-2 text-3xl font-semibold text-green-600 dark:text-green-400">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">Assigned Posts</p>
25 <p id="author-posts" class="mt-2 font-semibold text-3xl text-green-600 dark:text-green-400">0</p>
2626 </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="author-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="author-newest" class="mt-2 font-semibold text-2xl truncate">-</p>
3030 </div>
3131 </section>
3232
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="author-search" type="search" placeholder="Search authors..." 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="author-search" type="search" placeholder="Search authors..." 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" />
3636 </div>
3737 <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">
4040 <tr>
4141 <th class="px-4 py-3">Name</th>
4242 <th class="px-4 py-3">Email</th>
@@ -45,26 +45,26 @@
4545 <th class="px-4 py-3 text-right">Actions</th>
4646 </tr>
4747 </thead>
48 <tbody id="authors-table" class="divide-y divide-neutral-200 dark:divide-neutral-800"></tbody>
48 <tbody id="authors-table" class="divide-neutral-200 divide-y dark:divide-neutral-800"></tbody>
4949 </table>
5050 </div>
51 <div id="authors-empty" class="hidden p-10 text-center text-sm text-neutral-500 dark:text-neutral-400">No authors found.</div>
51 <div id="authors-empty" class="hidden p-10 text-center text-neutral-500 text-sm dark:text-neutral-400">No authors found.</div>
5252 </section>
5353 </div>
5454</div>
5555
56<dialog id="author-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="author-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">
5757 <form id="author-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 Author</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 Author</h2>
6060 </div>
6161 <div class="grid gap-4 p-5">
62 <label class="grid gap-1 text-sm font-medium">Name <input id="author-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>
63 <label class="grid gap-1 text-sm font-medium">Email <input id="author-email" type="email" 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 font-medium text-sm">Name <input id="author-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>
63 <label class="grid gap-1 font-medium text-sm">Email <input id="author-email" type="email" 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>
6464 </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="author-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 Author</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="author-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 Author</button>
6868 </div>
6969 </form>
7070</dialog>
@@ -107,7 +107,7 @@
107107 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(author.email) + '</td>' +
108108 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + postCount(author) + '</td>' +
109109 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + formatDate(author.created_at) + '</td>' +
110 '<td class="px-4 py-3 text-right"><button type="button" data-id="' + author.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>' +
110 '<td class="px-4 py-3 text-right"><button type="button" data-id="' + author.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>' +
111111 '</tr>'
112112 }).join('')
113113 }
storage/framework/defaults/views/dashboard/content/categories/index.stxmodified+34-34
Changes to storage/framework/defaults/views/dashboard/content/categories/index.stx
@@ -1,42 +1,42 @@
11@extends('layouts/default')
22
33@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">
77 <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>
1111 </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>
1414 New Category
1515 </button>
1616 </header>
1717
1818 <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>
2222 </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>
2626 </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>
3030 </div>
3131 </section>
3232
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" />
3636 </div>
3737 <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">
4040 <tr>
4141 <th class="px-4 py-3">Name</th>
4242 <th class="px-4 py-3">Slug</th>
@@ -44,27 +44,27 @@
4444 <th class="px-4 py-3 text-right">Actions</th>
4545 </tr>
4646 </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>
4848 </table>
4949 </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>
5151 </section>
5252 </div>
5353</div>
5454
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">
5656 <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>
5959 </div>
6060 <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>
6464 </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>
6868 </div>
6969 </form>
7070</dialog>
@@ -100,7 +100,7 @@
100100 '<td class="px-4 py-3 font-medium">' + escapeHtml(category.name) + '</td>' +
101101 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(category.slug) + '</td>' +
102102 '<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>' +
104104 '</tr>'
105105 }).join('')
106106 }
storage/framework/defaults/views/dashboard/content/comments/index.stxmodified+32-32
Changes to storage/framework/defaults/views/dashboard/content/comments/index.stx
@@ -1,16 +1,16 @@
11@extends('layouts/default')
22
33@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">
77 <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>
1111 </div>
1212 <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">
1414 <option value="">All statuses</option>
1515 <option value="approved">Approved</option>
1616 <option value="pending">Pending</option>
@@ -21,31 +21,31 @@
2121 </header>
2222
2323 <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>
2727 </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>
3131 </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>
3535 </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>
3939 </div>
4040 </section>
4141
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" />
4545 </div>
4646 <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">
4949 <tr>
5050 <th class="px-4 py-3">Author</th>
5151 <th class="px-4 py-3">Comment</th>
@@ -55,10 +55,10 @@
5555 <th class="px-4 py-3 text-right">Actions</th>
5656 </tr>
5757 </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>
5959 </table>
6060 </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>
6262 </section>
6363 </div>
6464</div>
@@ -116,15 +116,15 @@
116116 empty.classList.toggle('hidden', rows.length > 0)
117117 table.innerHTML = rows.map(function (comment) {
118118 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>' +
120120 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(truncate(comment.content, 120)) + '</td>' +
121121 '<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>' +
123123 '<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>' +
128128 '</div></td>' +
129129 '</tr>'
130130 }).join('')
storage/framework/defaults/views/dashboard/content/pages/index.stxmodified+34-34
Changes to storage/framework/defaults/views/dashboard/content/pages/index.stx
@@ -1,42 +1,42 @@
11@extends('layouts/default')
22
33@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">
77 <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>
1111 </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>
1414 New Page
1515 </button>
1616 </header>
1717
1818 <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>
2222 </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>
2626 </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>
3030 </div>
3131 </section>
3232
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" />
3636 </div>
3737 <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">
4040 <tr>
4141 <th class="px-4 py-3">Title</th>
4242 <th class="px-4 py-3">Template</th>
@@ -45,23 +45,23 @@
4545 <th class="px-4 py-3 text-right">Actions</th>
4646 </tr>
4747 </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>
4949 </table>
5050 </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>
5252 </section>
5353 </div>
5454</div>
5555
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">
5757 <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>
6060 </div>
6161 <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">
6565 <option value="default">Default</option>
6666 <option value="landing">Landing</option>
6767 <option value="blog">Blog</option>
@@ -69,9 +69,9 @@
6969 </select>
7070 </label>
7171 </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>
7575 </div>
7676 </form>
7777</dialog>
@@ -110,7 +110,7 @@
110110 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(page.template) + '</td>' +
111111 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + Number(page.views || 0).toLocaleString() + '</td>' +
112112 '<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>' +
114114 '</tr>'
115115 }).join('')
116116 }
storage/framework/defaults/views/dashboard/content/posts/index.stxmodified+55-55
Changes to storage/framework/defaults/views/dashboard/content/posts/index.stx
@@ -1,52 +1,52 @@
11@extends('layouts/default')
22
33@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">
77 <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>
1111 </div>
1212 <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>
1515 View Blog
1616 </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>
1919 New Post
2020 </button>
2121 </div>
2222 </header>
2323
2424 <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>
2828 </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>
3232 </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>
3636 </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>
4040 </div>
4141 </section>
4242
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" />
4848 </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">
5050 <option value="">All statuses</option>
5151 <option value="published">Published</option>
5252 <option value="draft">Draft</option>
@@ -55,8 +55,8 @@
5555 </div>
5656
5757 <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">
6060 <tr>
6161 <th class="px-4 py-3">Title</th>
6262 <th class="px-4 py-3">Status</th>
@@ -65,57 +65,57 @@
6565 <th class="px-4 py-3 text-right">Actions</th>
6666 </tr>
6767 </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>
6969 </table>
7070 </div>
7171
7272 <div id="posts-empty" class="hidden p-10 text-center">
7373 <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>
7676 </div>
7777 </section>
7878 </div>
7979</div>
8080
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">
8282 <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>
8686 </div>
8787 <div class="grid gap-4 p-5">
8888 <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">
9090 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" />
9292 </label>
93 <label class="grid gap-1 text-sm font-medium">
93 <label class="grid gap-1 font-medium text-sm">
9494 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>
9696 </label>
9797 <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">
9999 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">
101101 <option value="draft">Draft</option>
102102 <option value="published">Published</option>
103103 <option value="archived">Archived</option>
104104 </select>
105105 </label>
106 <label class="grid gap-1 text-sm font-medium">
106 <label class="grid gap-1 font-medium text-sm">
107107 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" />
109109 </label>
110110 </div>
111 <label class="grid gap-1 text-sm font-medium">
111 <label class="grid gap-1 font-medium text-sm">
112112 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>
114114 </label>
115115 </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>
119119 </div>
120120 </form>
121121</dialog>
@@ -172,13 +172,13 @@
172172 empty.classList.toggle('hidden', rows.length > 0)
173173 table.innerHTML = rows.map(function (post) {
174174 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>' +
177177 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + Number(post.views || 0).toLocaleString() + '</td>' +
178178 '<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>' +
182182 '</div></td>' +
183183 '</tr>'
184184 }).join('')
storage/framework/defaults/views/dashboard/content/tags/index.stxmodified+34-34
Changes to storage/framework/defaults/views/dashboard/content/tags/index.stx
@@ -1,42 +1,42 @@
11@extends('layouts/default')
22
33@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">
77 <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">Tags</h1>
10 <p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage the tags used by blog posts and markdown 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">Tags</h1>
10 <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Manage the tags used by blog posts and markdown content.</p>
1111 </div>
12 <button id="new-tag-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-tag-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>
1414 New Tag
1515 </button>
1616 </header>
1717
1818 <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 Tags</p>
21 <p id="tag-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 Tags</p>
21 <p id="tag-total" class="mt-2 font-semibold text-3xl">0</p>
2222 </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="tag-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="tag-newest" class="mt-2 font-semibold text-2xl truncate">-</p>
2626 </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="tag-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="tag-active" class="mt-2 font-semibold text-3xl text-green-600 dark:text-green-400">0</p>
3030 </div>
3131 </section>
3232
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="tag-search" type="search" placeholder="Search tags..." 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="tag-search" type="search" placeholder="Search tags..." 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" />
3636 </div>
3737 <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">
4040 <tr>
4141 <th class="px-4 py-3">Name</th>
4242 <th class="px-4 py-3">Slug</th>
@@ -44,27 +44,27 @@
4444 <th class="px-4 py-3 text-right">Actions</th>
4545 </tr>
4646 </thead>
47 <tbody id="tags-table" class="divide-y divide-neutral-200 dark:divide-neutral-800"></tbody>
47 <tbody id="tags-table" class="divide-neutral-200 divide-y dark:divide-neutral-800"></tbody>
4848 </table>
4949 </div>
50 <div id="tags-empty" class="hidden p-10 text-center text-sm text-neutral-500 dark:text-neutral-400">No tags found.</div>
50 <div id="tags-empty" class="hidden p-10 text-center text-neutral-500 text-sm dark:text-neutral-400">No tags found.</div>
5151 </section>
5252 </div>
5353</div>
5454
55<dialog id="tag-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="tag-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">
5656 <form id="tag-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 Tag</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 Tag</h2>
5959 </div>
6060 <div class="grid gap-4 p-5">
61 <label class="grid gap-1 text-sm font-medium">Name <input id="tag-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="tag-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="tag-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="tag-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="tag-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="tag-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>
6464 </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="tag-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 Tag</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="tag-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 Tag</button>
6868 </div>
6969 </form>
7070</dialog>
@@ -100,7 +100,7 @@
100100 '<td class="px-4 py-3 font-medium">' + escapeHtml(tag.name) + '</td>' +
101101 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(tag.slug) + '</td>' +
102102 '<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(tag.description) + '</td>' +
103 '<td class="px-4 py-3 text-right"><button type="button" data-id="' + tag.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="' + tag.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>' +
104104 '</tr>'
105105 }).join('')
106106 }
storage/framework/defaults/views/dashboard/kanban/[id].stxmodified+74-74
Changes to storage/framework/defaults/views/dashboard/kanban/[id].stx
@@ -443,12 +443,12 @@
443443</script>
444444
445445<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">
447447 <p class="text-sm text-zinc-600 dark:text-zinc-400">Kanban boards require the dev role.</p>
448448 </div>
449449
450450 <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">
452452 <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>
453453 All boards
454454 </a>
@@ -461,9 +461,9 @@
461461 </div>
462462
463463 <!-- 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>
467467 </div>
468468
469469 <!-- Loaded -->
@@ -481,23 +481,23 @@
481481 </header>
482482
483483 <!-- 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">
486486 <div
487487 :for="col in columns"
488488 :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"
490490 style="max-height: calc(100vh - 220px)"
491491 >
492492 <!-- Column header -->
493493 <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>
498498 <button
499499 @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"
501501 title="Delete column"
502502 >
503503 <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 @@
510510 </div>
511511
512512 <!-- 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">
514514 <div
515515 :for="card in col.cards"
516516 :data-card-id="card.id"
517517 :data-card-column-id="col.id"
518518 @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"
520520 >
521521 <!-- Label chips strip -->
522522 <div :if="card.labels && card.labels.length > 0" class="flex flex-wrap gap-1 mb-1">
@@ -527,8 +527,8 @@
527527 ></span>
528528 </div>
529529
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>
532532 <button
533533 @click.stop="deleteCardClicked(card.id)"
534534 class="text-zinc-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
@@ -542,8 +542,8 @@
542542 </div>
543543
544544 <!-- 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">
547547 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
548548 <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" />
549549 </svg>
@@ -553,7 +553,7 @@
553553 <div :if="card.assignees && card.assignees.length > 0" class="flex -space-x-1">
554554 <span
555555 :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"
557557 :title="asg.name || asg.email || 'Assignee'"
558558 :text="initialsOf(asg.name, asg.email)"
559559 ></span>
@@ -572,18 +572,18 @@
572572 @keydown.escape="closeCardComposer()"
573573 placeholder="Card title…"
574574 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"
576576 ></textarea>
577577 <div class="flex gap-1">
578578 <button
579579 @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"
581581 >
582582 Add
583583 </button>
584584 <button
585585 @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"
587587 >
588588 Cancel
589589 </button>
@@ -592,7 +592,7 @@
592592 <button
593593 :if="!isComposingFor(col.id)"
594594 @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"
596596 >
597597 + Add card
598598 </button>
@@ -601,26 +601,26 @@
601601
602602 <!-- Add column trailing slot -->
603603 <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">
605605 <input
606606 :value="draftColumnName()"
607607 @input="(e) => draftColumnName.set(e.target.value)"
608608 @keydown.enter="submitColumn()"
609609 @keydown.escape="closeColumnComposer()"
610610 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"
612612 autofocus
613613 />
614614 <div class="flex gap-1">
615615 <button
616616 @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"
618618 >
619619 Add column
620620 </button>
621621 <button
622622 @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"
624624 >
625625 Cancel
626626 </button>
@@ -629,7 +629,7 @@
629629 <button
630630 :if="!composingNewColumn()"
631631 @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"
633633 >
634634 + Add column
635635 </button>
@@ -640,31 +640,31 @@
640640
641641 <!-- ───────────── Card detail modal (Phase 3) ───────────── -->
642642 <!-- 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. -->
650650 <div
651651 :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"
653653 @click="closeDetail()"
654654 @keydown.escape.window="closeDetail()"
655655 >
656656 <div
657657 @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"
659659 >
660660 <!-- 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">
662662 <input
663663 :value="titleDraft()"
664664 @input="(e) => titleDraft.set(e.target.value)"
665665 @blur="commitTitle()"
666666 @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"
668668 />
669669 <button
670670 @click="closeDetail()"
@@ -682,28 +682,28 @@
682682 </div>
683683
684684 <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>
686686 </div>
687687
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">
689689 <!-- Main column: description + comments -->
690690 <div class="md:col-span-2 space-y-6">
691691 <!-- Description -->
692692 <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>
694694 <textarea
695695 :value="descDraft()"
696696 @input="(e) => descDraft.set(e.target.value)"
697697 @blur="commitDescription()"
698698 rows="4"
699699 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"
701701 ></textarea>
702702 </section>
703703
704704 <!-- Comments -->
705705 <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>
707707
708708 <!-- Composer -->
709709 <div class="mb-4">
@@ -713,13 +713,13 @@
713713 @keydown.metaCmd.enter.prevent="submitComment()"
714714 rows="2"
715715 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"
717717 ></textarea>
718718 <div class="flex justify-end mt-2">
719719 <button
720720 @click="submitComment()"
721721 :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"
723723 >
724724 Comment
725725 </button>
@@ -727,7 +727,7 @@
727727 </div>
728728
729729 <!-- 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">
731731 No comments yet.
732732 </div>
733733 <ul class="space-y-3">
@@ -735,17 +735,17 @@
735735 :for="cm in openCard().comments"
736736 class="flex gap-3"
737737 >
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>
739739 <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>
742742 <span class="text-[10px] text-zinc-400" :text="formatTimestamp(cm.createdAt)"></span>
743743 <button
744744 @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"
746746 >Delete</button>
747747 </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>
749749 </div>
750750 </li>
751751 </ul>
@@ -757,10 +757,10 @@
757757 <!-- Labels -->
758758 <section>
759759 <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>
761761 <button
762762 @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"
764764 >
765765 <span :if="!showLabelPicker()">Edit</span>
766766 <span :if="showLabelPicker()">Done</span>
@@ -774,7 +774,7 @@
774774 :class="`px-2 py-0.5 text-xs font-medium rounded ${labelChipClass(lbl.color)}`"
775775 :text="lbl.name"
776776 ></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>
778778 </div>
779779
780780 <!-- Picker -->
@@ -785,7 +785,7 @@
785785 :class="`w-full flex items-center justify-between px-2 py-1 rounded text-xs ${labelChipClass(lbl.color)} hover:opacity-90`"
786786 >
787787 <span :text="lbl.name"></span>
788 <span class="flex items-center gap-1">
788 <span class="flex gap-1 items-center">
789789 <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">
790790 <polyline points="20 6 9 17 4 12" />
791791 </svg>
@@ -800,15 +800,15 @@
800800 </button>
801801
802802 <!-- 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">
804804 <input
805805 :value="newLabelName()"
806806 @input="(e) => newLabelName.set(e.target.value)"
807807 @keydown.enter.prevent="submitNewLabel()"
808808 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"
810810 />
811 <div class="flex items-center gap-1">
811 <div class="flex gap-1 items-center">
812812 <button
813813 :for="c in labelColors"
814814 @click="newLabelColor.set(c)"
@@ -817,14 +817,14 @@
817817 ></button>
818818 </div>
819819 <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>
821821 <button @click="showNewLabelComposer.set(false)" class="px-2 py-0.5 text-xs text-zinc-500 hover:text-zinc-700">Cancel</button>
822822 </div>
823823 </div>
824824 <button
825825 :if="!showNewLabelComposer()"
826826 @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"
828828 >
829829 + New label
830830 </button>
@@ -834,10 +834,10 @@
834834 <!-- Assignees -->
835835 <section>
836836 <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>
838838 <button
839839 @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"
841841 >
842842 <span :if="!showAssigneePicker()">Edit</span>
843843 <span :if="showAssigneePicker()">Done</span>
@@ -847,23 +847,23 @@
847847 <div :if="!showAssigneePicker()" class="flex flex-wrap gap-2">
848848 <div
849849 :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"
851851 >
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>
853853 <span :text="asg.name || asg.email || 'Unknown'"></span>
854854 </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>
856856 </div>
857857
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>
860860 <button
861861 :for="u in kanban.users()"
862862 @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"
864864 >
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>
867867 <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">
868868 <polyline points="20 6 9 17 4 12" />
869869 </svg>
@@ -873,12 +873,12 @@
873873
874874 <!-- Due date -->
875875 <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>
877877 <input
878878 type="date"
879879 :value="openCard().dueDate ? openCard().dueDate.slice(0, 10) : ''"
880880 @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"
882882 />
883883 </section>
884884
storage/framework/defaults/views/dashboard/kanban/index.stxmodified+30-30
Changes to storage/framework/defaults/views/dashboard/kanban/index.stx
@@ -111,9 +111,9 @@
111111
112112<div class="mx-auto px-6 py-8 max-w-7xl">
113113 <!-- 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">
117117 <p class="text-sm text-zinc-600 dark:text-zinc-400">Kanban boards require the dev role.</p>
118118 </div>
119119
@@ -125,7 +125,7 @@
125125 </div>
126126 <button
127127 @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"
129129 >
130130 New board
131131 </button>
@@ -139,20 +139,20 @@
139139 </div>
140140
141141 <!-- 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>
145145 <p class="mt-2 text-xs text-zinc-500">Most common: migrations haven't run yet. Try <code>./buddy migrate</code>.</p>
146146 </div>
147147
148148 <!-- 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">
151151 <rect x="3" y="4" width="6" height="16" rx="1" />
152152 <rect x="11" y="4" width="6" height="10" rx="1" />
153153 <rect x="19" y="4" width="2" height="6" rx="1" />
154154 </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>
156156 <p class="mt-1 text-xs text-zinc-500">Click "New board" to create one.</p>
157157 </div>
158158
@@ -161,17 +161,17 @@
161161 <a
162162 :for="board in boards"
163163 :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"
165165 data-mpa="true"
166166 >
167167 <div :class="`h-2 ${accentClass(board.color)}`"></div>
168168 <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>
171171 </div>
172172 <p
173173 :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"
175175 :text="board.description"
176176 ></p>
177177 <p class="mt-3 text-xs text-zinc-400" :text="cardCountText(board.cardCount)"></p>
@@ -181,49 +181,49 @@
181181 </div>
182182
183183 <!-- 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). -->
187187 <div
188188 :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"
190190 @click="closeCreate()"
191191 @keydown.escape.window="closeCreate()"
192192 >
193193 <div
194194 @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"
196196 >
197197 <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>
199199 </div>
200200 <div class="px-5 py-4 space-y-4">
201201 <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>
203203 <input
204204 :value="draftName()"
205205 @input="(e) => draftName.set(e.target.value)"
206206 @keydown.enter="submitCreate()"
207207 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"
209209 autofocus
210210 />
211211 </div>
212212
213213 <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>
215215 <textarea
216216 :value="draftDescription()"
217217 @input="(e) => draftDescription.set(e.target.value)"
218218 rows="3"
219219 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"
221221 ></textarea>
222222 </div>
223223
224224 <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">
227227 <button
228228 :for="c in colorChoices"
229229 @click="draftColor.set(c)"
@@ -234,20 +234,20 @@
234234 </div>
235235 </div>
236236
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>
238238 </div>
239239
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">
241241 <button
242242 @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"
244244 :disabled="submitting()"
245245 >
246246 Cancel
247247 </button>
248248 <button
249249 @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"
251251 :disabled="submitting()"
252252 >
253253 <span :if="!submitting()">Create</span>
tests/unit/social-publish.test.tsadded+97-0
Changes to tests/unit/social-publish.test.ts
@@ -0,0 +1,97 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { BlueskyPublishingDriver } from '../../storage/framework/core/socials/src/drivers/bluesky'
3
4const realFetch = globalThis.fetch
5
6afterEach(() => {
7 globalThis.fetch = realFetch
8})
9
10interface CapturedRequest {
11 url: string
12 body: any
13}
14
15function mockBlueskyApi(captured: CapturedRequest[]): void {
16 globalThis.fetch = (async (input: any, init?: any) => {
17 const url = String(input)
18 const body = init?.body ? JSON.parse(String(init.body)) : undefined
19 captured.push({ url, body })
20 return new Response(JSON.stringify({
21 uri: `at://did:plc:test/app.bsky.feed.post/rec${captured.length}`,
22 cid: `cid${captured.length}`,
23 }), { status: 200, headers: { 'content-type': 'application/json' } })
24 }) as typeof fetch
25}
26
27const identity = {
28 handle: 'tester.bsky.social',
29 did: 'did:plc:test',
30 accessToken: 'jwt-token',
31}
32
33describe('BlueskyPublishingDriver.publish', () => {
34 test('creates a plain post record without reply refs', async () => {
35 const captured: CapturedRequest[] = []
36 mockBlueskyApi(captured)
37
38 const driver = new BlueskyPublishingDriver()
39 const result = await driver.publish(identity, { text: 'hello world' })
40
41 expect(captured).toHaveLength(1)
42 expect(captured[0].url).toContain('/xrpc/com.atproto.repo.createRecord')
43 expect(captured[0].body.record.text).toBe('hello world')
44 expect(captured[0].body.record.reply).toBeUndefined()
45 expect(result.uri).toBe('at://did:plc:test/app.bsky.feed.post/rec1')
46 expect(result.cid).toBe('cid1')
47 })
48
49 test('threads a reply via root/parent refs on the record', async () => {
50 const captured: CapturedRequest[] = []
51 mockBlueskyApi(captured)
52
53 const reply = {
54 root: { uri: 'at://did:plc:test/app.bsky.feed.post/root', cid: 'cid-root' },
55 parent: { uri: 'at://did:plc:test/app.bsky.feed.post/parent', cid: 'cid-parent' },
56 }
57
58 const driver = new BlueskyPublishingDriver()
59 await driver.publish(identity, { text: 'second segment', reply })
60
61 expect(captured[0].body.record.reply).toEqual(reply)
62 })
63
64 test('attaches an external link card when provided', async () => {
65 const captured: CapturedRequest[] = []
66 mockBlueskyApi(captured)
67
68 const driver = new BlueskyPublishingDriver()
69 await driver.publish(identity, {
70 text: 'with card',
71 external: { uri: 'https://example.com', title: 'Example', description: 'Desc' },
72 })
73
74 expect(captured[0].body.record.embed).toEqual({
75 $type: 'app.bsky.embed.external',
76 external: { uri: 'https://example.com', title: 'Example', description: 'Desc' },
77 })
78 })
79
80 test('rejects posts over the character limit without hitting the API', async () => {
81 const captured: CapturedRequest[] = []
82 mockBlueskyApi(captured)
83
84 const driver = new BlueskyPublishingDriver()
85 await expect(driver.publish(identity, { text: 'x'.repeat(301) })).rejects.toThrow(/300 characters/)
86 expect(captured).toHaveLength(0)
87 })
88
89 test('rejects when the identity has no access token', async () => {
90 const captured: CapturedRequest[] = []
91 mockBlueskyApi(captured)
92
93 const driver = new BlueskyPublishingDriver()
94 await expect(driver.publish({ handle: 'tester.bsky.social' }, { text: 'hi' })).rejects.toThrow(/access token/)
95 expect(captured).toHaveLength(0)
96 })
97})