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.
12 kB · 409 lines
Vue
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410<script setup lang="ts">import type { StrongRef, TalkGroupRecord, TalkRecord } from '#shared/cms/records'import { community } from '#shared/lex'import { ref } from 'vue'import { COMMUNITY_IMAGE_MAX_BYTES, pdsBlobUrl } from '#shared/cms/blob'
type TalkValue = Omit<TalkRecord, '$type'>
interface TalkGroupEntry { rkey: string uri: string cid: string value: TalkGroupRecord}
interface GitHubRelease { tag: string name: string publishedAt: string | null}
const props = defineProps<{ initial?: Partial<TalkValue> submitLabel?: string}>()
const emit = defineEmits<{ (e: 'submit', value: TalkValue): void}>()
const { data: groups } = useAdminFetch<TalkGroupEntry[]>('/api/admin/talk-groups', { default: () => [] })const { data: releases } = useAdminFetch<GitHubRelease[]>('/api/admin/github-releases', { default: () => [] })
const { public: publicConfig } = useRuntimeConfig()const pdsService = publicConfig.atproto?.service || nullconst pdsDid = publicConfig.atproto?.did || null
const TYPES = ['conference', 'meetup', 'podcast', 'workshop', 'stream', 'talk'] as const
const form = reactive({ title: props.initial?.title ?? '', description: props.initial?.description ?? '', date: toDateInput(props.initial?.date) ?? '', endDate: toDateInput(props.initial?.endDate) ?? '', source: props.initial?.source ?? '', location: props.initial?.location ?? '', type: (props.initial?.type as typeof TYPES[number]) ?? 'conference', tags: (props.initial?.tags ?? []).join(', '), link: props.initial?.link ?? '', video: props.initial?.video ?? '', slides: props.initial?.slides ?? '', demo: props.initial?.demo ?? '', repo: props.initial?.repo ?? '', groupUri: props.initial?.group?.uri ?? '', image: props.initial?.image?.image, imageAlt: props.initial?.image?.alt ?? '', aspectRatio: props.initial?.image?.aspectRatio,})
/** * Local object URL for a just-uploaded image, so the user sees a preview * before the record is saved. Cleared when the underlying image changes or * the component unmounts. */const localPreviewUrl = ref<string | null>(null)onBeforeUnmount(() => { if (localPreviewUrl.value) URL.revokeObjectURL(localPreviewUrl.value)})
const submitting = ref(false)const error = ref<string | null>(null)
function toDateInput (iso?: string): string | undefined { if (!iso) return undefined return iso.slice(0, 10)}
const imageUrl = computed(() => { if (localPreviewUrl.value) return localPreviewUrl.value return pdsBlobUrl(pdsService, pdsDid, form.image)})
async function onImageChange (e: Event) { const file = (e.target as HTMLInputElement).files?.[0] if (!file) return
if (file.size > COMMUNITY_IMAGE_MAX_BYTES) { error.value = `That image is ${(file.size / 1_000_000).toFixed(1)} MB; the record format allows 2 MB.` return }
if (localPreviewUrl.value) URL.revokeObjectURL(localPreviewUrl.value) localPreviewUrl.value = URL.createObjectURL(file)
const buf = new Uint8Array(await file.arrayBuffer()) try { const uploaded = await $fetch<{ blob: NonNullable<NonNullable<TalkValue['image']>['image']> aspectRatio?: { width: number, height: number } }>('/api/admin/blobs', { method: 'POST', body: buf, headers: { 'content-type': file.type || 'application/octet-stream' }, }) form.image = uploaded.blob form.aspectRatio = uploaded.aspectRatio if (!form.imageAlt && form.source) form.imageAlt = `Logo for ${form.source}` } catch (err) { error.value = err instanceof Error ? err.message : String(err) if (localPreviewUrl.value) { URL.revokeObjectURL(localPreviewUrl.value) localPreviewUrl.value = null } }}
function clearImage () { form.image = undefined form.aspectRatio = undefined form.imageAlt = '' if (localPreviewUrl.value) { URL.revokeObjectURL(localPreviewUrl.value) localPreviewUrl.value = null }}
async function onSubmit () { error.value = null submitting.value = true try { if (form.image && !form.imageAlt) { error.value = 'Please describe the image for screen reader users.' submitting.value = false return }
const group = (groups.value ?? []).find(g => g.uri === form.groupUri) const groupRef: StrongRef | undefined = group ? { uri: group.uri, cid: group.cid } : undefined
const value: TalkValue = { ...(form.title ? { title: form.title } : {}), ...(form.description ? { description: form.description } : {}), date: new Date(form.date).toISOString(), ...(form.endDate ? { endDate: new Date(form.endDate).toISOString() } : {}), source: form.source, ...(form.location ? { location: form.location } : {}), type: form.type, ...(form.tags.trim() ? { tags: form.tags.split(',').map(t => t.trim()).filter(Boolean) } : {}), ...(form.link ? { link: form.link } : {}), ...(form.video ? { video: form.video } : {}), ...(form.slides ? { slides: form.slides } : {}), ...(form.demo ? { demo: form.demo } : {}), ...(form.repo ? { repo: form.repo } : {}), ...(groupRef ? { group: groupRef } : {}), ...(form.image ? { image: { $type: 'community.lexicon.app.defs#image' as const, purpose: community.lexicon.app.defs.purposeLogo.value, image: form.image, alt: form.imageAlt, ...(form.aspectRatio ? { aspectRatio: form.aspectRatio } : {}), }, } : {}), ...(props.initial?.createdAt ? { createdAt: props.initial.createdAt } : { createdAt: new Date().toISOString() }), }
emit('submit', value) } catch (err) { error.value = err instanceof Error ? err.message : String(err) } finally { submitting.value = false }}</script>
<template> <form class="flex flex-col gap-4" @submit.prevent="onSubmit" > <p v-if="error" class="text-sm text-red-500" > {{ error }} </p>
<label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Title</span> <input v-model="form.title" type="text" class="bg-accent px-3 py-2" > </label>
<label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Description</span> <textarea v-model="form.description" rows="3" class="bg-accent px-3 py-2" /> </label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Date <span class="text-red-500">*</span></span> <input v-model="form.date" required type="date" class="bg-accent px-3 py-2" > </label> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">End date (multi-day)</span> <input v-model="form.endDate" type="date" class="bg-accent px-3 py-2" > </label> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Source / event <span class="text-red-500">*</span></span> <input v-model="form.source" required type="text" class="bg-accent px-3 py-2" > </label> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Location</span> <input v-model="form.location" type="text" class="bg-accent px-3 py-2" > </label> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Type <span class="text-red-500">*</span></span> <select v-model="form.type" required class="bg-accent px-3 py-2" > <option v-for="t in TYPES" :key="t" :value="t" > {{ t }} </option> </select> </label> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Group</span> <select v-model="form.groupUri" class="bg-accent px-3 py-2" > <option value=""> - none - </option> <option v-for="g in groups ?? []" :key="g.uri" :value="g.uri" > {{ g.value.title }} </option> </select> </label> </div>
<label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Tags (comma-separated)</span> <input v-model="form.tags" type="text" class="bg-accent px-3 py-2" > </label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Event link</span> <input v-model="form.link" type="url" class="bg-accent px-3 py-2" > </label> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Video link</span> <input v-model="form.video" type="url" class="bg-accent px-3 py-2" > </label> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Slides (GitHub release tag)</span> <input v-model="form.slides" type="text" list="admin-slides-releases" class="bg-accent px-3 py-2" > <datalist id="admin-slides-releases"> <option v-for="r in releases ?? []" :key="r.tag" :value="r.tag" > {{ r.name }} </option> </datalist> </label> <label class="flex flex-col gap-1 text-sm"> <span class="text-muted">Demo link</span> <input v-model="form.demo" type="url" class="bg-accent px-3 py-2" > </label> <label class="flex flex-col gap-1 text-sm md:col-span-2"> <span class="text-muted">Repo link</span> <input v-model="form.repo" type="url" class="bg-accent px-3 py-2" > </label> </div>
<div class="flex flex-col gap-2 text-sm"> <label class="flex flex-col gap-2"> <span class="text-muted">Image</span> <input type="file" accept="image/*" class="text-sm" @change="onImageChange" > </label> <div v-if="imageUrl" class="flex items-start gap-3" > <img :src="imageUrl" alt="Talk image preview" class="max-h-40 bg-accent object-contain" > <button type="button" class="text-xs text-muted hover:text-red-500 transition-colors" @click="clearImage" > Remove </button> </div> <label v-if="form.image" class="flex flex-col gap-1" > <span class="text-muted">Alt text <span class="text-red-500">*</span></span> <input v-model="form.imageAlt" type="text" required class="bg-accent px-3 py-2" > </label> </div>
<div class="flex gap-3"> <button type="submit" :disabled="submitting" class="bg-primary text-background px-4 py-2 hover:bg-primary/90 transition-colors disabled:opacity-60" > {{ submitting ? 'Saving…' : (submitLabel ?? 'Save') }} </button> <NuxtLink to="/admin/talks" class="text-muted hover:text-primary self-center text-sm" > Cancel </NuxtLink> </div> </form></template>