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>