Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt. roe.dev
Something went wrong. Try again.
17 kB · 526 lines
Vue
at feat/nuxt-v5
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527<script setup lang="ts">import type { AmaPlatforms, AmaPost, AmaPublishedLinks, EntityRecord, StrongRef } from '#shared/cms/records'
interface EntityEntry { rkey: string uri: string cid: string value: EntityRecord}
type AmaPlatform = 'bluesky' | 'mastodon' | 'linkedin' | 'youtubeShorts'const ALL_PLATFORMS: AmaPlatform[] = ['bluesky', 'mastodon', 'linkedin', 'youtubeShorts']
interface PlatformState { status: 'idle' | 'publishing' | 'success' | 'failed' url?: string error?: string}
const props = defineProps<{ rkey: string question: string createdAt: string initialPosts?: AmaPost[] initialPlatforms?: AmaPlatforms initialPublishedLinks?: AmaPublishedLinks /** Public URL of any already-published image, for the generator's initial preview. */ initialImagePreviewUrl?: string | null initialImageDimensions?: { width: number, height: number } initialBackgroundStyleId?: string}>()
const FOOTER_PREVIEW_LENGTH = '\n\nroe.dev/ama\n\n#ama'.lengthconst BLUESKY_LIMIT = 300
const { data: entities } = useAdminFetch<EntityEntry[]>('/api/admin/entities', { default: () => [] })
interface DraftPost { text: string mentions: StrongRef[]}
const posts = ref<DraftPost[]>( (props.initialPosts ?? [{ text: '' }]).map(p => ({ text: p.text, mentions: (p.mentions ?? []) as StrongRef[], })),)if (!posts.value.length) posts.value = [{ text: '', mentions: [] }]
const platforms = reactive({ bluesky: props.initialPlatforms?.bluesky ?? true, mastodon: props.initialPlatforms?.mastodon ?? true, linkedin: props.initialPlatforms?.linkedin ?? true, youtubeShorts: props.initialPlatforms?.youtubeShorts ?? false,})
// Seed per-platform state from the existing record so a returning editor// sees "already published" rather than being prompted to re-post.const platformState = reactive<Record<AmaPlatform, PlatformState>>( Object.fromEntries(ALL_PLATFORMS.map(p => { const url = props.initialPublishedLinks?.[p] return [p, url ? { status: 'success', url } : { status: 'idle' }] })) as Record<AmaPlatform, PlatformState>,)
// Generated media, held in memory until publish. Images upload to the PDS// at publish time (unreferenced PDS blobs are GC'd); video goes straight// to YouTube and isn't persisted on the PDS.interface PendingImage { blob: Blob width: number height: number backgroundStyleId: string}interface PendingVideo { blob: Blob width: number height: number durationSeconds: number}const pendingImage = ref<PendingImage | null>(null)const pendingVideo = ref<PendingVideo | null>(null)const imageRemoved = ref(false)
function onImageGenerated (image: PendingImage) { pendingImage.value = image imageRemoved.value = false}
function onImageRemoved () { pendingImage.value = null imageRemoved.value = true}
// Plain-text answer for the video generator + YouTube description, with// entity mention placeholders swapped for bare names.const plainAnswerForVideo = computed(() => posts.value .map(p => p.text .replace(/@([a-z0-9]{13})\b/g, (_match, rkey) => { const ent = (entities.value ?? []).find(e => e.rkey === rkey) return ent ? `@${ent.value.name}` : '' }) .replace(/ +/g, ' ')) .filter(Boolean) .join('\n') .trim(),)
const error = ref<string | null>(null)const submitting = computed(() => ALL_PLATFORMS.some(p => platformState[p].status === 'publishing'))
const saving = ref(false)const savedAt = ref<string | null>(null)const saveError = ref<string | null>(null)
function addPost () { posts.value.push({ text: '', mentions: [] })}
function removePost (i: number) { if (posts.value.length === 1) return posts.value.splice(i, 1)}
// Recompute the strong-ref `mentions` array from the `@<rkey>`// placeholders in `text`. Skipped entries silently drop mentions of// entities that have since been deleted.function syncMentions (post: DraftPost) { const ids = new Set<string>() for (const match of post.text.matchAll(/@([a-z0-9]{13})\b/g)) { ids.add(match[1]!) } post.mentions = Array.from(ids).flatMap(rkey => { const ent = (entities.value ?? []).find(e => e.rkey === rkey) return ent ? [{ uri: ent.uri, cid: ent.cid }] : [] })}
type PostEditorRef = { insertMention: (entity: EntityEntry) => void insertLink: (label: string, url: string) => void}const editorRefs = ref<Array<PostEditorRef | null>>([])function setEditorRef (i: number, el: unknown) { editorRefs.value[i] = el as PostEditorRef | null}
function promptInsertLink (i: number) { const editor = editorRefs.value[i] if (!editor) return const label = window.prompt('Link text:')?.trim() if (!label) return const url = window.prompt('URL:')?.trim() if (!url) return editor.insertLink(label, url)}
function onPickMention (i: number, target: HTMLSelectElement) { const ent = (entities.value ?? []).find(e => e.rkey === target.value) if (ent) editorRefs.value[i]?.insertMention(ent) target.value = ''}
// Live Bluesky-equivalent character count, swapping each `@<rkey>`// placeholder for a `@<handle>` estimate.function blueskyLength (post: DraftPost, includeFooter: boolean): number { let len = post.text.length for (const match of post.text.matchAll(/@([a-z0-9]{13})\b/g)) { const ent = (entities.value ?? []).find(e => e.rkey === match[1]) const handle = ent?.value.socialHandles?.bluesky ?? '' len = len - match[0].length + (handle ? handle.length + 1 : 0) } if (includeFooter) len += FOOTER_PREVIEW_LENGTH return len}
// Upload the pending image to the PDS lazily, caching both the BlobRef// and the in-flight promise so parallel publishes share a single upload.const uploadedImage = ref<unknown>(null)let uploadingPromise: Promise<unknown> | null = nullasync function ensureImageUploaded (): Promise<unknown> { if (uploadedImage.value) return uploadedImage.value if (!pendingImage.value) return undefined if (uploadingPromise) return uploadingPromise
const blob = pendingImage.value.blob uploadingPromise = (async () => { const buf = new Uint8Array(await blob.arrayBuffer()) const result = await $fetch<{ blob: unknown }>('/api/admin/blobs', { method: 'POST', body: buf, headers: { 'content-type': blob.type || 'image/png' }, }) uploadedImage.value = result.blob return result.blob })() try { return await uploadingPromise } finally { uploadingPromise = null }}
// Reset the cached upload when the user regenerates the image, so the// next publish doesn't reuse a stale BlobRef.watch(pendingImage, () => { uploadedImage.value = null uploadingPromise = null})
function requirePosts (): boolean { if (posts.value.some(p => p.text.trim())) return true error.value = 'At least one post must have content.' return false}
async function buildPayload (): Promise<Record<string, unknown>> { const image = await ensureImageUploaded() const payload: Record<string, unknown> = { question: props.question, posts: posts.value .filter(p => p.text.trim()) .map(p => ({ text: p.text, mentions: p.mentions })), platforms: { ...platforms }, } if (image && pendingImage.value) { payload.image = image payload.imageDimensions = { width: pendingImage.value.width, height: pendingImage.value.height } payload.backgroundStyle = pendingImage.value.backgroundStyleId } else if (imageRemoved.value) { payload.image = null payload.imageDimensions = null payload.backgroundStyle = null } return payload}
async function saveDraft () { if (saving.value) return saving.value = true saveError.value = null try { await $fetch(`/api/admin/ama/${props.rkey}`, { method: 'PATCH', body: await buildPayload() }) savedAt.value = new Date().toISOString() imageRemoved.value = false } catch (err) { const data = (err as { data?: { statusMessage?: string } }).data saveError.value = data?.statusMessage || (err instanceof Error ? err.message : String(err)) } finally { saving.value = false }}
async function publishToPlatform (platform: AmaPlatform, force = false) { if (platformState[platform].status === 'publishing') return if (!requirePosts()) return error.value = null platformState[platform] = { status: 'publishing' }
try { const baseBody = { ...(await buildPayload()), ...(force ? { force: true } : {}), }
const res = platform === 'youtubeShorts' ? await publishYouTube(baseBody) : await $fetch<{ success: boolean, url: string }>( `/api/admin/ama/${props.rkey}/publish/${platform}`, { method: 'POST', body: baseBody }, ) platformState[platform] = { status: 'success', url: res.url } } catch (err) { const data = (err as { data?: { statusMessage?: string } }).data platformState[platform] = { status: 'failed', error: data?.statusMessage || (err instanceof Error ? err.message : String(err)), } }}
async function publishYouTube (baseBody: object): Promise<{ success: boolean, url: string }> { if (!pendingVideo.value) throw new Error('Generate a video before publishing to YouTube Shorts.') const form = new FormData() form.append('payload', new Blob([JSON.stringify({ ...baseBody, answer: plainAnswerForVideo.value })], { type: 'application/json' })) form.append('video', pendingVideo.value.blob, 'ama.webm') return $fetch<{ success: boolean, url: string }>( `/api/admin/ama/${props.rkey}/publish/youtube-shorts`, { method: 'POST', body: form }, )}
async function publishAll () { if (!requirePosts()) return error.value = null // Skip platforms that are unchecked, already published this session, or // (YouTube only) lack a generated video. const todo = ALL_PLATFORMS.filter(p => platforms[p] && platformState[p].status !== 'success' && !(p === 'youtubeShorts' && !pendingVideo.value), ) await Promise.all(todo.map(p => publishToPlatform(p)))}</script>
<template> <div class="flex flex-col gap-4"> <section class="bg-accent p-3"> <h2 class="text-sm text-muted mb-1"> Question </h2> <p class="whitespace-pre-wrap"> {{ question }} </p> </section>
<p v-if="error" class="text-sm text-red-500" > {{ error }} </p>
<section class="flex flex-col gap-4"> <div v-for="(post, i) in posts" :key="i" class="flex flex-col gap-2" > <header class="flex items-center justify-between text-xs text-muted"> <span>Post {{ i + 1 }}{{ i === posts.length - 1 ? ' (+ footer)' : '' }}</span> <button v-if="posts.length > 1" type="button" class="hover:text-red-500" @click="removePost(i)" > Remove </button> </header> <AdminPostEditor :ref="(el) => setEditorRef(i, el)" :model-value="post.text" :entities="entities ?? []" :placeholder="i === 0 ? 'Your response…' : 'Continued…'" @update:model-value="(v) => { post.text = v; syncMentions(post) }" /> <div class="flex items-center gap-3 text-xs"> <select class="bg-accent px-2 py-1" @change="(e) => onPickMention(i, e.target as HTMLSelectElement)" > <option value=""> Insert mention… </option> <option v-for="ent in entities ?? []" :key="ent.rkey" :value="ent.rkey" > @{{ ent.value.name }} </option> </select> <button type="button" class="bg-accent px-2 py-1 hover:bg-accent/70 text-muted" @click="promptInsertLink(i)" > Insert link </button> <span :class="{ 'text-red-500': blueskyLength(post, i === posts.length - 1) > BLUESKY_LIMIT, 'text-yellow-500': blueskyLength(post, i === posts.length - 1) > BLUESKY_LIMIT - 30 && blueskyLength(post, i === posts.length - 1) <= BLUESKY_LIMIT, }" class="text-muted" > {{ blueskyLength(post, i === posts.length - 1) }} / {{ BLUESKY_LIMIT }} (bluesky) </span> </div> </div>
<button type="button" class="text-sm text-muted hover:text-primary self-start" @click="addPost" > + Add post </button> </section>
<AdminAmaImageGenerator :question="question" :created-at="createdAt" :initial-preview-url="initialImagePreviewUrl" :initial-background-style-id="initialBackgroundStyleId" @generated="onImageGenerated" @removed="onImageRemoved" @cleared="pendingImage = null" />
<AdminAmaVideoGenerator v-if="platforms.youtubeShorts" :question="question" :answer="plainAnswerForVideo" :background-style-id="pendingImage?.backgroundStyleId ?? initialBackgroundStyleId" @generated="pendingVideo = $event" @cleared="pendingVideo = null" />
<section class="flex flex-col gap-3 text-sm"> <h3 class="text-muted"> Publish </h3> <ul class="flex flex-col divide-y divide-accent"> <li v-for="platform in ALL_PLATFORMS" :key="platform" class="flex items-center gap-3 py-2" > <label class="flex items-center gap-2 min-w-32"> <input v-model="platforms[platform]" type="checkbox" > <span class="capitalize">{{ platform === 'youtubeShorts' ? 'YouTube Shorts' : platform }}</span> </label>
<div class="flex-grow min-w-0 text-xs"> <a v-if="platformState[platform].url" :href="platformState[platform].url" target="_blank" rel="noopener" class="text-muted hover:text-primary underline-offset-4 hover:underline truncate block" >{{ platformState[platform].url }}</a> <span v-if="platformState[platform].error" class="text-red-500 block truncate" >{{ platformState[platform].error }}</span> <span v-else-if="platform === 'youtubeShorts' && platforms.youtubeShorts && !pendingVideo && platformState[platform].status !== 'success'" class="text-muted block" >Generate a video first.</span> </div>
<button v-if="platformState[platform].status === 'success'" type="button" :disabled="submitting || (platform === 'youtubeShorts' && !pendingVideo)" class="text-xs text-muted hover:text-red-500 transition-colors disabled:opacity-60" @click="publishToPlatform(platform, true)" > Re-publish </button> <button v-else-if="platformState[platform].status === 'failed'" type="button" :disabled="submitting || !platforms[platform] || (platform === 'youtubeShorts' && !pendingVideo)" class="text-xs bg-accent text-primary px-3 py-1 hover:bg-accent/70 transition-colors disabled:opacity-60" @click="publishToPlatform(platform)" > Retry </button> <button v-else-if="platformState[platform].status === 'publishing'" type="button" disabled class="text-xs bg-accent text-muted px-3 py-1 opacity-60" > Publishing… </button> <button v-else type="button" :disabled="submitting || !platforms[platform] || (platform === 'youtubeShorts' && !pendingVideo)" class="text-xs bg-primary text-background px-3 py-1 hover:bg-primary/90 transition-colors disabled:opacity-60" @click="publishToPlatform(platform)" > Publish </button> </li> </ul> </section>
<div class="flex items-center gap-3"> <button type="button" :disabled="submitting" class="bg-primary text-background px-4 py-2 hover:bg-primary/90 transition-colors disabled:opacity-60" @click="publishAll" > {{ submitting ? 'Publishing…' : 'Publish all enabled' }} </button> <button type="button" :disabled="saving" class="bg-accent text-primary px-4 py-2 hover:bg-accent/70 transition-colors disabled:opacity-60" @click="saveDraft" > {{ saving ? 'Saving…' : 'Save draft' }} </button> <span v-if="saveError" class="text-sm text-red-500" >{{ saveError }}</span> <span v-else-if="savedAt" class="text-sm text-muted" >Draft saved</span> <NuxtLink to="/admin/ama" class="text-muted hover:text-primary self-center text-sm" > Back </NuxtLink> </div> </div></template>