your personal website on atproto - mirror blento.app

blog creation pt1

Florian 23e1a609 ce94823f

+380 -63
+11 -2
src/routes/[[actor=actor]]/blog/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { getCDNImageBlobUrl } from '$lib/atproto'; 3 - import { Avatar as FoxAvatar } from '@foxui/core'; 3 + import { user } from '$lib/atproto/auth.svelte'; 4 + import { Avatar as FoxAvatar, Button } from '@foxui/core'; 4 5 5 6 let { data } = $props(); 6 7 ··· 23 24 return date.toLocaleDateString('en-US', options); 24 25 } 25 26 27 + let actorPrefix = $derived(hostProfile?.handle ? `/${hostProfile.handle}` : `/${did}`); 28 + let isOwner = $derived(user.isLoggedIn && user.did === did); 29 + 26 30 function getCoverUrl( 27 31 coverImage: { $type: 'blob'; ref: { $link: string } } | undefined 28 32 ): string | undefined { ··· 45 49 <div class="mx-auto max-w-4xl"> 46 50 <!-- Header --> 47 51 <div class="mb-8"> 48 - <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl">Blog</h1> 52 + <div class="flex items-center justify-between gap-4"> 53 + <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl">Blog</h1> 54 + {#if isOwner} 55 + <Button href="{actorPrefix}/blog/new">New post</Button> 56 + {/if} 57 + </div> 49 58 <div class="mt-4 flex items-center gap-2"> 50 59 <span class="text-base-500 dark:text-base-400 text-sm">Written by</span> 51 60 <a
+263
src/routes/[[actor=actor]]/blog/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { user } from '$lib/atproto/auth.svelte'; 3 + import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 4 + import { uploadBlob, createTID } from '$lib/atproto/methods'; 5 + import { compressImage } from '$lib/atproto/image-helper'; 6 + import { Button } from '@foxui/core'; 7 + import { goto } from '$app/navigation'; 8 + 9 + let title = $state(''); 10 + let description = $state(''); 11 + let content = $state(''); 12 + let coverFile: File | null = $state(null); 13 + let coverPreview: string | null = $state(null); 14 + let submitting = $state(false); 15 + let error: string | null = $state(null); 16 + 17 + let fileInput: HTMLInputElement | undefined = $state(); 18 + 19 + function onFileChange(e: Event) { 20 + const input = e.target as HTMLInputElement; 21 + const file = input.files?.[0]; 22 + if (!file) return; 23 + coverFile = file; 24 + if (coverPreview) URL.revokeObjectURL(coverPreview); 25 + coverPreview = URL.createObjectURL(file); 26 + } 27 + 28 + function removeCover() { 29 + coverFile = null; 30 + if (coverPreview) { 31 + URL.revokeObjectURL(coverPreview); 32 + coverPreview = null; 33 + } 34 + if (fileInput) fileInput.value = ''; 35 + } 36 + 37 + async function handleSubmit() { 38 + error = null; 39 + 40 + if (!title.trim()) { 41 + error = 'Title is required.'; 42 + return; 43 + } 44 + if (!user.client || !user.did) { 45 + error = 'You must be logged in.'; 46 + return; 47 + } 48 + 49 + submitting = true; 50 + 51 + try { 52 + let coverImage: unknown | undefined; 53 + 54 + if (coverFile) { 55 + const compressed = await compressImage(coverFile); 56 + const blobRef = await uploadBlob({ blob: compressed.blob }); 57 + if (blobRef) { 58 + coverImage = blobRef; 59 + } 60 + } 61 + 62 + const rkey = createTID(); 63 + 64 + const record: Record<string, unknown> = { 65 + $type: 'site.standard.document', 66 + title: title.trim(), 67 + content: { $type: 'app.blento.markdown', value: content }, 68 + site: `at://${user.did}/site.standard.publication/blento.self`, 69 + path: `/blog/${rkey}`, 70 + publishedAt: new Date().toISOString() 71 + }; 72 + 73 + if (description.trim()) { 74 + record.description = description.trim(); 75 + } 76 + if (coverImage) { 77 + record.coverImage = coverImage; 78 + } 79 + 80 + const response = await user.client.post('com.atproto.repo.createRecord', { 81 + input: { 82 + collection: 'site.standard.document', 83 + repo: user.did, 84 + rkey, 85 + record 86 + } 87 + }); 88 + 89 + if (response.ok) { 90 + const handle = 91 + user.profile?.handle && user.profile.handle !== 'handle.invalid' 92 + ? user.profile.handle 93 + : user.did; 94 + goto(`/${handle}/blog/${rkey}`); 95 + } else { 96 + error = 'Failed to create post. Please try again.'; 97 + } 98 + } catch (e) { 99 + console.error('Failed to create post:', e); 100 + error = 'Failed to create post. Please try again.'; 101 + } finally { 102 + submitting = false; 103 + } 104 + } 105 + </script> 106 + 107 + <svelte:head> 108 + <title>Create Blog Post</title> 109 + </svelte:head> 110 + 111 + <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12"> 112 + <div class="mx-auto max-w-2xl"> 113 + <h1 class="text-base-900 dark:text-base-50 mb-8 text-3xl font-bold">Create Blog Post</h1> 114 + 115 + {#if user.isInitializing} 116 + <div class="flex items-center gap-3"> 117 + <div class="bg-base-300 dark:bg-base-700 size-5 animate-pulse rounded-full"></div> 118 + <div class="bg-base-300 dark:bg-base-700 h-4 w-48 animate-pulse rounded"></div> 119 + </div> 120 + {:else if !user.isLoggedIn} 121 + <div 122 + class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center" 123 + > 124 + <p class="text-base-600 dark:text-base-400 mb-4">Log in to create a blog post.</p> 125 + <Button onclick={() => loginModalState.show()}>Log in</Button> 126 + </div> 127 + {:else} 128 + <form 129 + onsubmit={(e) => { 130 + e.preventDefault(); 131 + handleSubmit(); 132 + }} 133 + class="space-y-6" 134 + > 135 + <!-- Cover image --> 136 + <div> 137 + <label 138 + class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 139 + for="cover" 140 + > 141 + Cover image 142 + </label> 143 + <input 144 + bind:this={fileInput} 145 + type="file" 146 + id="cover" 147 + accept="image/*" 148 + onchange={onFileChange} 149 + class="hidden" 150 + /> 151 + {#if coverPreview} 152 + <div class="relative inline-block"> 153 + <img 154 + src={coverPreview} 155 + alt="Cover preview" 156 + class="border-base-200 dark:border-base-700 h-40 w-40 rounded-xl border object-cover" 157 + /> 158 + <button 159 + type="button" 160 + onclick={removeCover} 161 + aria-label="Remove cover image" 162 + class="bg-base-900/70 absolute -top-2 -right-2 flex size-6 items-center justify-center rounded-full text-white hover:bg-red-600" 163 + > 164 + <svg 165 + xmlns="http://www.w3.org/2000/svg" 166 + viewBox="0 0 20 20" 167 + fill="currentColor" 168 + class="size-3.5" 169 + > 170 + <path 171 + d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" 172 + /> 173 + </svg> 174 + </button> 175 + </div> 176 + {:else} 177 + <button 178 + type="button" 179 + onclick={() => fileInput?.click()} 180 + class="border-base-300 dark:border-base-700 hover:border-base-400 dark:hover:border-base-600 text-base-500 dark:text-base-400 flex h-40 w-40 flex-col items-center justify-center rounded-xl border-2 border-dashed transition-colors" 181 + > 182 + <svg 183 + xmlns="http://www.w3.org/2000/svg" 184 + fill="none" 185 + viewBox="0 0 24 24" 186 + stroke-width="1.5" 187 + stroke="currentColor" 188 + class="mb-1 size-6" 189 + > 190 + <path 191 + stroke-linecap="round" 192 + stroke-linejoin="round" 193 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" 194 + /> 195 + </svg> 196 + <span class="text-xs">Upload image</span> 197 + </button> 198 + {/if} 199 + </div> 200 + 201 + <!-- Title --> 202 + <div> 203 + <label 204 + class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 205 + for="title" 206 + > 207 + Title <span class="text-red-500">*</span> 208 + </label> 209 + <input 210 + type="text" 211 + id="title" 212 + bind:value={title} 213 + required 214 + placeholder="Post title" 215 + class="border-base-300 dark:border-base-700 bg-base-50 dark:bg-base-900 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" 216 + /> 217 + </div> 218 + 219 + <!-- Description --> 220 + <div> 221 + <label 222 + class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 223 + for="description" 224 + > 225 + Description 226 + </label> 227 + <textarea 228 + id="description" 229 + bind:value={description} 230 + rows={2} 231 + placeholder="A short summary of the post" 232 + class="border-base-300 dark:border-base-700 bg-base-50 dark:bg-base-900 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" 233 + ></textarea> 234 + </div> 235 + 236 + <!-- Content --> 237 + <div> 238 + <label 239 + class="text-base-700 dark:text-base-300 mb-1.5 block text-sm font-medium" 240 + for="content" 241 + > 242 + Content 243 + </label> 244 + <textarea 245 + id="content" 246 + bind:value={content} 247 + rows={12} 248 + placeholder="Write your post content in markdown..." 249 + class="border-base-300 dark:border-base-700 bg-base-50 dark:bg-base-900 text-base-900 dark:text-base-100 placeholder:text-base-400 dark:placeholder:text-base-600 w-full rounded-lg border px-3 py-2 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" 250 + ></textarea> 251 + </div> 252 + 253 + {#if error} 254 + <p class="text-sm text-red-600 dark:text-red-400">{error}</p> 255 + {/if} 256 + 257 + <Button type="submit" disabled={submitting} class="w-full"> 258 + {submitting ? 'Publishing...' : 'Publish Post'} 259 + </Button> 260 + </form> 261 + {/if} 262 + </div> 263 + </div>
+45 -36
src/routes/[[actor=actor]]/events/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 3 import { getCDNImageBlobUrl } from '$lib/atproto'; 4 - import { Avatar as FoxAvatar, Badge } from '@foxui/core'; 4 + import { user } from '$lib/atproto/auth.svelte'; 5 + import { Avatar as FoxAvatar, Badge, Button } from '@foxui/core'; 5 6 import Avatar from 'svelte-boring-avatars'; 6 7 7 8 let { data } = $props(); ··· 75 76 } 76 77 77 78 let actorPrefix = $derived(data.hostProfile?.handle ? `/${data.hostProfile.handle}` : `/${did}`); 79 + let isOwner = $derived(user.isLoggedIn && user.did === did); 78 80 </script> 79 81 80 82 <svelte:head> ··· 90 92 <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 91 93 <div class="mx-auto max-w-4xl"> 92 94 <!-- Header --> 93 - <div class="mb-8"> 94 - <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl"> 95 - Upcoming events 96 - </h1> 97 - <div class="mt-4 flex items-center gap-2"> 98 - <span class="text-base-500 dark:text-base-400 text-sm">Hosted by</span> 99 - <a 100 - href={hostUrl} 101 - target={hostProfile?.hasBlento ? undefined : '_blank'} 102 - rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 103 - class="flex items-center gap-1.5 hover:underline" 104 - > 105 - <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" /> 106 - <span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span> 107 - </a> 95 + <div class="mb-8 flex items-start justify-between"> 96 + <div> 97 + <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl"> 98 + Upcoming events 99 + </h1> 100 + <div class="mt-4 flex items-center gap-2"> 101 + <span class="text-base-500 dark:text-base-400 text-sm">Hosted by</span> 102 + <a 103 + href={hostUrl} 104 + target={hostProfile?.hasBlento ? undefined : '_blank'} 105 + rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 106 + class="flex items-center gap-1.5 hover:underline" 107 + > 108 + <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" /> 109 + <span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span> 110 + </a> 111 + </div> 108 112 </div> 113 + {#if isOwner} 114 + <Button href="{actorPrefix}/events/new" variant="primary">New event</Button> 115 + {/if} 109 116 </div> 110 117 111 118 {#if events.length === 0} ··· 117 124 {@const location = getLocationString(event.locations)} 118 125 {@const rkey = event.rkey} 119 126 <a 120 - href="{actorPrefix}/e/{rkey}" 121 - class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group block overflow-hidden rounded-xl border transition-colors" 127 + href="{actorPrefix}/events/{rkey}" 128 + class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group block overflow-hidden rounded-2xl border transition-colors" 122 129 > 123 130 <!-- Thumbnail --> 124 - {#if thumbnail} 125 - <img 126 - src={thumbnail.url} 127 - alt={thumbnail.alt} 128 - class="aspect-square w-full object-cover" 129 - /> 130 - {:else} 131 - <div 132 - class="bg-base-100 dark:bg-base-900 aspect-square w-full [&>svg]:h-full [&>svg]:w-full" 133 - > 134 - <Avatar 135 - size={400} 136 - name={rkey} 137 - variant="marble" 138 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 139 - square 131 + <div class="p-4"> 132 + {#if thumbnail} 133 + <img 134 + src={thumbnail.url} 135 + alt={thumbnail.alt} 136 + class="aspect-square w-full rounded-2xl object-cover" 140 137 /> 141 - </div> 142 - {/if} 138 + {:else} 139 + <div 140 + class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full" 141 + > 142 + <Avatar 143 + size={400} 144 + name={rkey} 145 + variant="marble" 146 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 147 + square 148 + /> 149 + </div> 150 + {/if} 151 + </div> 143 152 144 153 <!-- Content --> 145 154 <div class="p-4">
+1
src/routes/[[actor=actor]]/events/[rkey]/+page.server.ts
··· 44 44 } 45 45 46 46 const eventData: EventData = eventRecord.value as EventData; 47 + console.log(eventData); 47 48 48 49 return { 49 50 eventData,
+59 -24
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 79 79 80 80 let location = $derived(getLocationString(eventData.locations)); 81 81 82 - let headerImage = $derived.by(() => { 82 + let thumbnailImage = $derived.by(() => { 83 83 if (!eventData.media || eventData.media.length === 0) return null; 84 84 const media = eventData.media.find((m) => m.role === 'thumbnail'); 85 85 if (!media?.content) return null; ··· 88 88 return { url, alt: media.alt || eventData.name }; 89 89 }); 90 90 91 + let bannerImage = $derived.by(() => { 92 + if (!eventData.media || eventData.media.length === 0) return null; 93 + const media = eventData.media.find((m) => m.role === 'header'); 94 + if (!media?.content) return null; 95 + const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }); 96 + if (!url) return null; 97 + return { url, alt: media.alt || eventData.name }; 98 + }); 99 + 100 + // Prefer thumbnail; fall back to header/banner image 101 + let displayImage = $derived(thumbnailImage ?? bannerImage); 102 + let isBannerOnly = $derived(!thumbnailImage && !!bannerImage); 103 + 104 + let isSameDay = $derived( 105 + endDate && 106 + startDate.getFullYear() === endDate.getFullYear() && 107 + startDate.getMonth() === endDate.getMonth() && 108 + startDate.getDate() === endDate.getDate() 109 + ); 110 + 91 111 let smokesignalUrl = $derived(`https://smokesignal.events/${did}/${rkey}`); 92 112 let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`); 93 113 ··· 108 128 109 129 <div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 110 130 <div class="mx-auto max-w-4xl"> 131 + <!-- Banner image (full width, only when no thumbnail) --> 132 + {#if isBannerOnly && displayImage} 133 + <img 134 + src={displayImage.url} 135 + alt={displayImage.alt} 136 + class="border-base-200 dark:border-base-800 mb-8 aspect-[3/1] w-full rounded-2xl border object-cover" 137 + /> 138 + {/if} 139 + 111 140 <!-- Two-column layout: image left, details right --> 112 141 <div 113 142 class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]" 114 143 > 115 - <!-- Image --> 116 - <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"> 117 - {#if headerImage} 118 - <img 119 - src={headerImage.url} 120 - alt={headerImage.alt} 121 - class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 122 - /> 123 - {:else} 124 - <div 125 - class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full" 126 - > 127 - <Avatar 128 - size={256} 129 - name={data.rkey} 130 - variant="marble" 131 - colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 132 - square 144 + <!-- Thumbnail image (left column) --> 145 + {#if !isBannerOnly} 146 + <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"> 147 + {#if displayImage} 148 + <img 149 + src={displayImage.url} 150 + alt={displayImage.alt} 151 + class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover" 133 152 /> 134 - </div> 135 - {/if} 136 - </div> 153 + {:else} 154 + <div 155 + class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full" 156 + > 157 + <Avatar 158 + size={256} 159 + name={data.rkey} 160 + variant="marble" 161 + colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 162 + square 163 + /> 164 + </div> 165 + {/if} 166 + </div> 167 + {/if} 137 168 138 169 <!-- Right column: event details --> 139 170 <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1"> ··· 167 198 <div> 168 199 <p class="text-base-900 dark:text-base-50 font-semibold"> 169 200 {formatWeekday(startDate)}, {formatFullDate(startDate)} 201 + {#if endDate && !isSameDay} 202 + - {formatWeekday(endDate)}, {formatFullDate(endDate)} 203 + {/if} 170 204 </p> 171 205 <p class="text-base-500 dark:text-base-400 text-sm"> 172 206 {formatTime(startDate)} 173 - {#if endDate} 174 - - {formatTime(endDate)}{/if} 207 + {#if endDate && isSameDay} 208 + - {formatTime(endDate)} 209 + {/if} 175 210 </p> 176 211 </div> 177 212 </div>
+1 -1
src/routes/[[actor=actor]]/events/[rkey]/EventRsvp.svelte
··· 120 120 </script> 121 121 122 122 <div 123 - class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 mt-8 mb-2 rounded-2xl border p-4" 123 + class="border-base-200 dark:border-base-800 bg-base-100 items-between dark:bg-base-900/50 mt-8 mb-2 flex h-25 flex-col justify-center rounded-2xl border p-4" 124 124 > 125 125 {#if user.isInitializing || rsvpLoading} 126 126 <div class="flex items-center gap-3">