replies timeline only, appview-less bluesky client
at main 23 kB view raw
1<script lang="ts"> 2 import type { AtpClient } from '$lib/at/client.svelte'; 3 import { ok, err, type Result, expect } from '$lib/result'; 4 import type { AppBskyEmbedRecordWithMedia, AppBskyFeedPost } from '@atcute/bluesky'; 5 import { generateColorForDid } from '$lib/accounts'; 6 import type { PostWithUri } from '$lib/at/fetch'; 7 import BskyPost from './BskyPost.svelte'; 8 import { parseCanonicalResourceUri, type Blob as AtpBlob } from '@atcute/lexicons'; 9 import type { ComAtprotoRepoStrongRef } from '@atcute/atproto'; 10 import { parseToRichText } from '$lib/richtext'; 11 import { tokenize } from '$lib/richtext/parser'; 12 import Icon from '@iconify/svelte'; 13 import ProfilePicture from './ProfilePicture.svelte'; 14 import type { AppBskyEmbedMedia } from '$lib/at/types'; 15 import { SvelteMap } from 'svelte/reactivity'; 16 import { handles } from '$lib/state.svelte'; 17 18 type UploadState = 19 | { state: 'uploading'; progress: number } 20 | { state: 'uploaded'; blob: AtpBlob<string> } 21 | { state: 'error'; message: string }; 22 export type FocusState = 'null' | 'focused'; 23 export type State = { 24 focus: FocusState; 25 text: string; 26 quoting?: PostWithUri; 27 replying?: PostWithUri; 28 attachedMedia?: AppBskyEmbedMedia; 29 blobsState: SvelteMap<string, UploadState>; 30 }; 31 32 interface Props { 33 client: AtpClient; 34 onPostSent: (post: PostWithUri) => void; 35 _state: State; 36 } 37 38 let { client, onPostSent, _state = $bindable() }: Props = $props(); 39 40 const isFocused = $derived(_state.focus === 'focused'); 41 42 const color = $derived( 43 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' 44 ); 45 46 const getVideoDimensions = ( 47 blobUrl: string 48 ): Promise<Result<{ width: number; height: number }, string>> => 49 new Promise((resolve) => { 50 const video = document.createElement('video'); 51 video.onloadedmetadata = () => { 52 resolve(ok({ width: video.videoWidth, height: video.videoHeight })); 53 }; 54 video.onerror = (e) => resolve(err(String(e))); 55 video.src = blobUrl; 56 }); 57 58 const uploadVideo = async (blobUrl: string, mimeType: string) => { 59 const file = await (await fetch(blobUrl)).blob(); 60 return await client.uploadVideo(file, mimeType, (status) => { 61 if (status.stage === 'uploading' && status.progress !== undefined) { 62 _state.blobsState.set(blobUrl, { state: 'uploading', progress: status.progress * 0.5 }); 63 } else if (status.stage === 'processing' && status.progress !== undefined) { 64 _state.blobsState.set(blobUrl, { 65 state: 'uploading', 66 progress: 0.5 + status.progress * 0.5 67 }); 68 } 69 }); 70 }; 71 72 const getImageDimensions = ( 73 blobUrl: string 74 ): Promise<Result<{ width: number; height: number }, string>> => 75 new Promise((resolve) => { 76 const img = new Image(); 77 img.onload = () => resolve(ok({ width: img.width, height: img.height })); 78 img.onerror = (e) => resolve(err(String(e))); 79 img.src = blobUrl; 80 }); 81 82 const uploadImage = async (blobUrl: string) => { 83 const file = await (await fetch(blobUrl)).blob(); 84 return await client.uploadBlob(file, (progress) => { 85 _state.blobsState.set(blobUrl, { state: 'uploading', progress }); 86 }); 87 }; 88 89 const post = async (text: string): Promise<Result<PostWithUri, string>> => { 90 const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ 91 $type: 'com.atproto.repo.strongRef', 92 cid: p.cid!, 93 uri: p.uri 94 }); 95 96 const rt = await parseToRichText(text); 97 98 let media: AppBskyEmbedMedia | undefined = _state.attachedMedia; 99 if (_state.attachedMedia?.$type === 'app.bsky.embed.images') { 100 const images = _state.attachedMedia.images; 101 let uploadedImages: typeof images = []; 102 for (const image of images) { 103 const blobUrl = (image.image as AtpBlob<string>).ref.$link; 104 const upload = _state.blobsState.get(blobUrl); 105 if (!upload || upload.state !== 'uploaded') continue; 106 const size = await getImageDimensions(blobUrl); 107 if (size.ok) image.aspectRatio = size.value; 108 uploadedImages.push({ 109 ...image, 110 image: upload.blob 111 }); 112 } 113 if (uploadedImages.length > 0) 114 media = { 115 ..._state.attachedMedia, 116 $type: 'app.bsky.embed.images', 117 images: uploadedImages 118 }; 119 } else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { 120 const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link; 121 const upload = _state.blobsState.get(blobUrl); 122 if (upload && upload.state === 'uploaded') { 123 const size = await getVideoDimensions(blobUrl); 124 if (size.ok) _state.attachedMedia.aspectRatio = size.value; 125 media = { 126 ..._state.attachedMedia, 127 $type: 'app.bsky.embed.video', 128 video: upload.blob 129 }; 130 } 131 } 132 // console.log('media', media); 133 134 const record: AppBskyFeedPost.Main = { 135 $type: 'app.bsky.feed.post', 136 text: rt.text, 137 facets: rt.facets, 138 reply: 139 _state.focus === 'focused' && _state.replying 140 ? { 141 root: _state.replying.record.reply?.root ?? strongRef(_state.replying), 142 parent: strongRef(_state.replying) 143 } 144 : undefined, 145 embed: 146 _state.focus === 'focused' && _state.quoting 147 ? media 148 ? { 149 $type: 'app.bsky.embed.recordWithMedia', 150 record: { record: strongRef(_state.quoting) }, 151 media: media as AppBskyEmbedRecordWithMedia.Main['media'] 152 } 153 : { 154 $type: 'app.bsky.embed.record', 155 record: strongRef(_state.quoting) 156 } 157 : (media as AppBskyFeedPost.Main['embed']), 158 createdAt: new Date().toISOString() 159 }; 160 161 const res = await client.user?.atcute.post('com.atproto.repo.createRecord', { 162 input: { 163 collection: 'app.bsky.feed.post', 164 repo: client.user!.did, 165 record 166 } 167 }); 168 169 if (!res) return err('failed to post: not logged in'); 170 171 if (!res.ok) 172 return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`); 173 174 return ok({ 175 uri: res.data.uri, 176 cid: res.data.cid, 177 record 178 }); 179 }; 180 181 let posting = $state(false); 182 let postError = $state(''); 183 let textareaEl: HTMLTextAreaElement | undefined = $state(); 184 let fileInputEl: HTMLInputElement | undefined = $state(); 185 let selectingFile = $state(false); 186 187 const canUpload = $derived( 188 !( 189 _state.attachedMedia?.$type === 'app.bsky.embed.video' || 190 (_state.attachedMedia?.$type === 'app.bsky.embed.images' && 191 _state.attachedMedia.images.length >= 4) 192 ) 193 ); 194 195 const unfocus = () => (_state.focus = 'null'); 196 197 const handleFiles = (files: File[]) => { 198 if (!canUpload || !files || files.length === 0) return; 199 200 const existingImages = 201 _state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : []; 202 203 let newImages = [...existingImages]; 204 let hasVideo = false; 205 206 for (let i = 0; i < files.length; i++) { 207 const file = files[i]; 208 const isVideo = file.type.startsWith('video/'); 209 const isImage = file.type.startsWith('image/'); 210 211 if (!isVideo && !isImage) { 212 postError = 'unsupported file type'; 213 continue; 214 } 215 216 if (isVideo) { 217 if (existingImages.length > 0 || newImages.length > 0) { 218 postError = 'cannot mix images and video'; 219 continue; 220 } 221 const blobUrl = URL.createObjectURL(file); 222 _state.attachedMedia = { 223 $type: 'app.bsky.embed.video', 224 video: { 225 $type: 'blob', 226 ref: { $link: blobUrl }, 227 mimeType: file.type, 228 size: file.size 229 } 230 }; 231 hasVideo = true; 232 break; 233 } else if (isImage) { 234 if (newImages.length >= 4) { 235 postError = 'max 4 images allowed'; 236 break; 237 } 238 const blobUrl = URL.createObjectURL(file); 239 newImages.push({ 240 image: { 241 $type: 'blob', 242 ref: { $link: blobUrl }, 243 mimeType: file.type, 244 size: file.size 245 }, 246 alt: '', 247 aspectRatio: undefined 248 }); 249 } 250 } 251 252 if (!hasVideo && newImages.length > 0) { 253 _state.attachedMedia = { 254 $type: 'app.bsky.embed.images', 255 images: newImages 256 }; 257 } 258 259 const handleUpload = (blobUrl: string, res: Result<AtpBlob<string>, string>) => { 260 if (res.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: res.value }); 261 else _state.blobsState.set(blobUrl, { state: 'error', message: res.error }); 262 }; 263 264 const media = _state.attachedMedia; 265 if (media?.$type == 'app.bsky.embed.images') { 266 for (const image of media.images) { 267 const blobUrl = (image.image as AtpBlob<string>).ref.$link; 268 uploadImage(blobUrl).then((r) => handleUpload(blobUrl, r)); 269 } 270 } else if (media?.$type === 'app.bsky.embed.video') { 271 const blobUrl = (media.video as AtpBlob<string>).ref.$link; 272 uploadVideo(blobUrl, media.video.mimeType).then((r) => handleUpload(blobUrl, r)); 273 } 274 }; 275 276 const handlePaste = (e: ClipboardEvent) => { 277 const files = Array.from(e.clipboardData?.items ?? []) 278 .filter((item) => item.kind === 'file') 279 .map((item) => item.getAsFile()) 280 .filter((file): file is File => file !== null); 281 282 if (files.length > 0) { 283 e.preventDefault(); 284 handleFiles(files); 285 } 286 }; 287 288 const handleDrop = (e: DragEvent) => { 289 e.preventDefault(); 290 const files = Array.from(e.dataTransfer?.files ?? []); 291 if (files.length > 0) handleFiles(files); 292 }; 293 294 const handleFileSelect = (e: Event) => { 295 e.preventDefault(); 296 selectingFile = false; 297 298 const input = e.target as HTMLInputElement; 299 if (input.files) handleFiles(Array.from(input.files)); 300 301 input.value = ''; 302 }; 303 304 const removeMedia = () => { 305 if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { 306 const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link; 307 _state.blobsState.delete(blobUrl); 308 queueMicrotask(() => URL.revokeObjectURL(blobUrl)); 309 } 310 _state.attachedMedia = undefined; 311 }; 312 313 const removeMediaAtIndex = (index: number) => { 314 if (_state.attachedMedia?.$type !== 'app.bsky.embed.images') return; 315 const imageToRemove = _state.attachedMedia.images[index]; 316 const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link; 317 _state.blobsState.delete(blobUrl); 318 queueMicrotask(() => URL.revokeObjectURL(blobUrl)); 319 320 const images = _state.attachedMedia.images.filter((_, i) => i !== index); 321 _state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined; 322 }; 323 324 const doPost = () => { 325 if (_state.text.length === 0 || _state.text.length > 300) return; 326 327 postError = ''; 328 posting = true; 329 post(_state.text) 330 .then((res) => { 331 if (res.ok) { 332 onPostSent(res.value); 333 _state.text = ''; 334 _state.quoting = undefined; 335 _state.replying = undefined; 336 if (_state.attachedMedia?.$type === 'app.bsky.embed.video') 337 URL.revokeObjectURL((_state.attachedMedia.video as AtpBlob<string>).ref.$link); 338 else if (_state.attachedMedia?.$type === 'app.bsky.embed.images') 339 _state.attachedMedia.images.forEach((image) => 340 URL.revokeObjectURL((image.image as AtpBlob<string>).ref.$link) 341 ); 342 _state.attachedMedia = undefined; 343 _state.blobsState.clear(); 344 unfocus(); 345 } else { 346 postError = res.error; 347 } 348 }) 349 .finally(() => { 350 posting = false; 351 }); 352 }; 353 354 $effect(() => { 355 document.documentElement.style.setProperty('--acc-color', color); 356 if (isFocused && textareaEl) textareaEl.focus(); 357 }); 358</script> 359 360{#snippet attachedPost(post: PostWithUri, type: 'quoting' | 'replying')} 361 {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 362 <BskyPost {client} did={parsedUri.repo} rkey={parsedUri.rkey} data={post} isOnPostComposer={true}> 363 {#snippet cornerFragment()} 364 <button 365 class="transition-transform hover:scale-150" 366 onclick={() => { 367 _state[type] = undefined; 368 }}><Icon width={24} icon="heroicons:x-mark-16-solid" /></button 369 > 370 {/snippet} 371 </BskyPost> 372{/snippet} 373 374{#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')} 375 {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 376 {@const color = generateColorForDid(parsedUri.repo)} 377 {@const id = handles.get(parsedUri.repo) ?? parsedUri.repo} 378 <div 379 class="flex shrink-0 items-center gap-1.5 rounded-sm border py-0.5 pr-0.5 pl-1 text-xs font-bold transition-all" 380 style=" 381 background: color-mix(in srgb, {color} 10%, transparent); 382 border-color: {color}; 383 color: {color}; 384 " 385 title={type === 'replying' ? `replying to ${id}` : `quoting ${id}`} 386 > 387 <span class="truncate text-sm font-normal opacity-90"> 388 {type === 'replying' ? 'replying to' : 'quoting'} 389 </span> 390 <div class="shrink-0"> 391 <ProfilePicture {client} did={parsedUri.repo} size={5} /> 392 </div> 393 </div> 394{/snippet} 395 396{#snippet highlighter(text: string)} 397 {#each tokenize(text) as token, idx (idx)} 398 {@const highlighted = 399 token.type === 'mention' || 400 token.type === 'topic' || 401 token.type === 'link' || 402 token.type === 'autolink'} 403 <span class={highlighted ? 'text-(--nucleus-accent2)' : ''}>{token.raw}</span> 404 {/each} 405 {#if text.endsWith('\n')} 406 <br /> 407 {/if} 408{/snippet} 409 410{#snippet uploadControls(blobUrl: string, remove: () => void)} 411 {@const upload = _state.blobsState.get(blobUrl)} 412 {#if upload !== undefined && upload.state === 'uploading'} 413 <div 414 class="absolute top-2 right-2 z-10 flex items-center gap-2 rounded-sm bg-black/70 p-1.5 text-sm backdrop-blur-sm" 415 > 416 <div class="flex justify-center"> 417 <div 418 class="h-5 w-5 animate-spin rounded-full border-4 border-t-transparent" 419 style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 420 ></div> 421 </div> 422 <span class="font-medium">{Math.round(upload.progress * 100)}%</span> 423 </div> 424 {:else} 425 <div class="absolute top-2 right-2 z-10 flex items-center gap-1"> 426 {#if upload !== undefined && upload.state === 'error'} 427 <span 428 class="rounded-sm bg-black/70 p-1.5 px-1 text-sm font-bold text-red-500 backdrop-blur-sm" 429 >{upload.message}</span 430 > 431 {/if} 432 <button 433 onclick={(e) => { 434 e.preventDefault(); 435 e.stopPropagation(); 436 remove(); 437 }} 438 onmousedown={(e) => e.preventDefault()} 439 class="rounded-sm bg-black/70 p-1.5 backdrop-blur-sm {upload?.state !== 'error' 440 ? 'opacity-0 transition-opacity group-hover:opacity-100' 441 : ''}" 442 > 443 {#if upload?.state === 'error'} 444 <Icon 445 class="text-red-500 group-hover:hidden" 446 icon="heroicons:exclamation-circle-16-solid" 447 width={20} 448 /> 449 {/if} 450 <Icon 451 class={upload?.state === 'error' ? 'hidden group-hover:block' : ''} 452 icon="heroicons:x-mark-16-solid" 453 width={20} 454 /> 455 </button> 456 </div> 457 {/if} 458{/snippet} 459 460{#snippet mediaPreview(embed: AppBskyEmbedMedia)} 461 {#if embed.$type === 'app.bsky.embed.images'} 462 <div class="image-preview-grid" data-total={embed.images.length}> 463 {#each embed.images as image, idx (idx)} 464 {@const blobUrl = (image.image as AtpBlob<string>).ref.$link} 465 <div class="image-preview-item group"> 466 <img src={blobUrl} alt="" /> 467 {@render uploadControls(blobUrl, () => removeMediaAtIndex(idx))} 468 </div> 469 {/each} 470 </div> 471 {:else if embed.$type === 'app.bsky.embed.video'} 472 {@const blobUrl = (embed.video as AtpBlob<string>).ref.$link} 473 <div 474 class="group relative max-h-[30vh] overflow-hidden rounded-sm" 475 style="aspect-ratio: 16/10;" 476 > 477 <!-- svelte-ignore a11y_media_has_caption --> 478 <video src={blobUrl} controls class="h-full w-full"></video> 479 {@render uploadControls(blobUrl, removeMedia)} 480 </div> 481 {/if} 482{/snippet} 483 484{#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)} 485 {@const hasIncompleteUpload = _state.blobsState 486 .values() 487 .some((s) => s.state === 'uploading' || s.state === 'error')} 488 <div class="flex items-center gap-2"> 489 <input 490 bind:this={fileInputEl} 491 type="file" 492 accept="image/*,video/*" 493 multiple 494 onchange={handleFileSelect} 495 oncancel={() => (selectingFile = false)} 496 class="hidden" 497 /> 498 <button 499 onclick={(e) => { 500 e.preventDefault(); 501 e.stopPropagation(); 502 selectingFile = true; 503 fileInputEl?.click(); 504 }} 505 onmousedown={(e) => e.preventDefault()} 506 disabled={!canUpload} 507 class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50" 508 style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};" 509 title="attach media" 510 > 511 <Icon icon="heroicons:photo-16-solid" width={20} /> 512 </button> 513 {#if postError.length > 0} 514 <div class="group flex items-center gap-2 truncate rounded-sm bg-red-500 p-1.5"> 515 <button onclick={() => (postError = '')}> 516 <Icon 517 class="group-hover:hidden" 518 icon="heroicons:exclamation-circle-16-solid" 519 width={20} 520 /> 521 <Icon class="hidden group-hover:block" icon="heroicons:x-mark-16-solid" width={20} /> 522 </button> 523 <span title={postError} class="truncate text-sm font-bold">{postError}</span> 524 </div> 525 {/if} 526 <div class="grow"></div> 527 {#if posting} 528 <div 529 class="h-6 w-6 animate-spin rounded-full border-4 border-t-transparent" 530 style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 531 ></div> 532 {/if} 533 <span 534 class="text-sm font-medium text-nowrap" 535 style="color: color-mix(in srgb, {_state.text.length > 300 536 ? '#ef4444' 537 : 'var(--nucleus-fg)'} 53%, transparent);" 538 > 539 {_state.text.length} / 300 540 </span> 541 <button 542 onmousedown={(e) => e.preventDefault()} 543 onclick={doPost} 544 disabled={(!_state.attachedMedia && _state.text.length === 0) || 545 _state.text.length > 300 || 546 hasIncompleteUpload} 547 class="action-button border-none px-4 py-1.5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100" 548 style="background: color-mix(in srgb, {color} 87%, transparent);" 549 > 550 post 551 </button> 552 </div> 553 {#if replying} 554 {@render attachedPost(replying, 'replying')} 555 {/if} 556 <!-- svelte-ignore a11y_no_static_element_interactions --> 557 <div 558 class="composer space-y-2" 559 onpaste={handlePaste} 560 ondrop={handleDrop} 561 ondragover={(e) => e.preventDefault()} 562 > 563 <div class="relative grid"> 564 <!-- todo: replace this with a proper rich text editor --> 565 <div 566 class="pointer-events-none col-start-1 row-start-1 min-h-[5lh] w-full bg-transparent text-wrap break-all whitespace-pre-wrap text-(--nucleus-fg)" 567 aria-hidden="true" 568 > 569 {@render highlighter(_state.text)} 570 </div> 571 572 <textarea 573 bind:this={textareaEl} 574 bind:value={_state.text} 575 onfocus={() => (_state.focus = 'focused')} 576 onblur={() => (!selectingFile ? unfocus() : null)} 577 onkeydown={(event) => { 578 if (event.key === 'Escape') unfocus(); 579 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); 580 }} 581 placeholder="what's on your mind?" 582 rows="4" 583 class="col-start-1 row-start-1 field-sizing-content min-h-[5lh] w-full resize-none overflow-hidden bg-transparent text-wrap break-all whitespace-pre-wrap text-transparent caret-(--nucleus-fg) placeholder:text-(--nucleus-fg)/45" 584 ></textarea> 585 </div> 586 {#if _state.attachedMedia} 587 {@render mediaPreview(_state.attachedMedia)} 588 {/if} 589 {#if quoting} 590 {@render attachedPost(quoting, 'quoting')} 591 {/if} 592 </div> 593{/snippet} 594 595<div class="relative min-h-13"> 596 <!-- Spacer to maintain layout when focused --> 597 {#if isFocused} 598 <div class="min-h-13"></div> 599 {/if} 600 601 <!-- svelte-ignore a11y_no_static_element_interactions --> 602 <div 603 onmousedown={(e) => { 604 if (isFocused) e.preventDefault(); 605 }} 606 class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300 607 {!isFocused ? 'min-h-13 items-center' : ''} 608 {isFocused ? 'absolute right-0 bottom-0 left-0 z-50 shadow-2xl' : ''}" 609 style="background: {isFocused 610 ? `color-mix(in srgb, var(--nucleus-bg) 75%, ${color})` 611 : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; 612 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 613 > 614 <div class="w-full p-1"> 615 {#if !client.user} 616 <div 617 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 618 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 619 > 620 not logged in 621 </div> 622 {:else} 623 <div class="flex flex-col gap-1"> 624 {#if _state.focus === 'focused'} 625 {@render composer(_state.replying, _state.quoting)} 626 {:else} 627 <!-- svelte-ignore a11y_no_static_element_interactions --> 628 <div 629 class="composer relative flex cursor-text items-center gap-0 py-0! transition-all hover:brightness-110" 630 onmousedown={(e) => { 631 if (e.defaultPrevented) return; 632 _state.focus = 'focused'; 633 }} 634 > 635 {#if _state.replying} 636 {@render attachmentIndicator(_state.replying, 'replying')} 637 {/if} 638 <input 639 bind:value={_state.text} 640 onfocus={() => (_state.focus = 'focused')} 641 type="text" 642 placeholder="what's on your mind?" 643 class="min-w-0 flex-1 border-none bg-transparent outline-none placeholder:text-(--nucleus-fg)/45 focus:ring-0" 644 /> 645 {#if _state.quoting} 646 {@render attachmentIndicator(_state.quoting, 'quoting')} 647 {/if} 648 </div> 649 {/if} 650 </div> 651 {/if} 652 </div> 653 </div> 654</div> 655 656<style> 657 @reference "../app.css"; 658 659 input, 660 .composer { 661 @apply single-line-input rounded-xs bg-(--nucleus-bg)/35; 662 border-color: color-mix(in srgb, var(--acc-color) 30%, transparent); 663 } 664 665 .composer { 666 @apply p-1; 667 } 668 669 textarea { 670 @apply w-full p-0; 671 } 672 673 input { 674 @apply p-1.5; 675 } 676 677 .composer { 678 @apply focus:scale-100; 679 } 680 681 input::placeholder { 682 color: color-mix(in srgb, var(--acc-color) 45%, var(--nucleus-bg)); 683 } 684 685 textarea:focus { 686 @apply border-none! [box-shadow:none]! outline-none!; 687 } 688 689 /* Image preview grid - based on PhotoSwipeGallery */ 690 .image-preview-grid { 691 display: grid; 692 gap: 2px; 693 border-radius: 4px; 694 overflow: hidden; 695 width: 100%; 696 max-height: 30vh; 697 } 698 699 .image-preview-item { 700 width: 100%; 701 height: 100%; 702 display: block; 703 position: relative; 704 overflow: hidden; 705 border-radius: 4px; 706 } 707 708 .image-preview-item > img { 709 width: 100%; 710 height: 100%; 711 object-fit: cover; 712 } 713 714 /* Single image: natural aspect ratio */ 715 .image-preview-grid[data-total='1'] { 716 display: block; 717 height: auto; 718 width: 100%; 719 border-radius: 0; 720 } 721 722 .image-preview-grid[data-total='1'] .image-preview-item { 723 width: 100%; 724 height: auto; 725 display: block; 726 border-radius: 4px; 727 } 728 729 .image-preview-grid[data-total='1'] .image-preview-item > img { 730 width: 100%; 731 height: auto; 732 max-height: 60vh; 733 object-fit: contain; 734 } 735 736 /* 2 Images: Split vertically */ 737 .image-preview-grid[data-total='2'] { 738 grid-template-columns: 1fr 1fr; 739 grid-template-rows: 1fr; 740 aspect-ratio: 16/9; 741 } 742 743 /* 3 Images: 1 Big (left), 2 Small (stacked right) */ 744 .image-preview-grid[data-total='3'] { 745 grid-template-columns: 1fr 1fr; 746 grid-template-rows: 1fr 1fr; 747 aspect-ratio: 16/9; 748 } 749 .image-preview-grid[data-total='3'] .image-preview-item:first-child { 750 grid-row: span 2; 751 } 752 753 /* 4 Images: 2x2 Grid */ 754 .image-preview-grid[data-total='4'] { 755 grid-template-columns: 1fr 1fr; 756 grid-template-rows: 1fr 1fr; 757 aspect-ratio: 16/9; 758 } 759</style>