diff --git a/src/app.d.ts b/src/app.d.ts index 78130c5..77f3d3d 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -11,6 +11,7 @@ declare global { } interface PageState { sheet?: string; + clipOverlay?: string; } } } diff --git a/src/lib/components/ActionSidebar.svelte b/src/lib/components/ActionSidebar.svelte index 0db02fe..b0a7232 100644 --- a/src/lib/components/ActionSidebar.svelte +++ b/src/lib/components/ActionSidebar.svelte @@ -167,7 +167,7 @@ .action-sidebar { position: absolute; right: var(--space-lg); - bottom: calc(var(--bottom-nav-height, 64px) + 68px); + bottom: calc(var(--bottom-nav-height, 64px) + 88px); display: flex; flex-direction: column; align-items: center; diff --git a/src/lib/components/ActivitySheet.svelte b/src/lib/components/ActivitySheet.svelte index 3106380..81cd236 100644 --- a/src/lib/components/ActivitySheet.svelte +++ b/src/lib/components/ActivitySheet.svelte @@ -3,7 +3,7 @@ import { resolve } from '$app/paths'; import { relativeTime } from '$lib/utils'; import { fetchUnreadCount } from '$lib/stores/notifications'; - import { viewClipSignal, openCommentsSignal } from '$lib/stores/toasts'; + import { clipOverlaySignal, openCommentsSignal } from '$lib/stores/toasts'; import XIcon from 'phosphor-svelte/lib/XIcon'; import BellIcon from 'phosphor-svelte/lib/BellIcon'; @@ -126,7 +126,7 @@ visible = false; setTimeout(() => { ondismiss(); - viewClipSignal.set(n.clipId); + clipOverlaySignal.set(n.clipId); if (n.type !== 'reaction') { openCommentsSignal.set(n.clipId); } diff --git a/src/lib/components/AddVideo.svelte b/src/lib/components/AddVideo.svelte index 8b169a3..aa1ed33 100644 --- a/src/lib/components/AddVideo.svelte +++ b/src/lib/components/AddVideo.svelte @@ -8,22 +8,21 @@ isPlatformAllowed } from '$lib/url-validation'; import { addToast } from '$lib/stores/toasts'; + import { showShortcutNudge } from '$lib/stores/shortcutNudge'; import { page } from '$app/stores'; import type { GroupMember } from '$lib/types'; import DownloadSimpleIcon from 'phosphor-svelte/lib/DownloadSimpleIcon'; import ClipboardIcon from 'phosphor-svelte/lib/ClipboardIcon'; import XIcon from 'phosphor-svelte/lib/XIcon'; import ArrowRightIcon from 'phosphor-svelte/lib/ArrowRightIcon'; + import LightbulbIcon from 'phosphor-svelte/lib/LightbulbIcon'; const { onsubmitted, initialUrl, members = [] }: { - onsubmitted?: ( - clip: { id: string; status: string; contentType: string }, - caption: string - ) => void; + onsubmitted?: (clip: { id: string; status: string; contentType: string }) => void; initialUrl?: string; members?: GroupMember[]; } = $props(); @@ -132,7 +131,7 @@ contentType: data.clip.contentType, autoDismiss: 0 }); - onsubmitted?.(data.clip, ''); + onsubmitted?.(data.clip); } catch { error = 'Something went wrong'; } finally { @@ -229,6 +228,14 @@

{platformLabel(url.trim())} links aren't allowed in this group

{/if} + + {#if $showShortcutNudge} + + + + Share clips faster from other apps + + {/if} {/if} @@ -240,7 +247,6 @@ gap: var(--space-md); padding: var(--space-sm) var(--space-lg) var(--space-lg); } - .clipboard-suggestion { display: flex; align-items: center; @@ -326,8 +332,6 @@ transform: translateY(0); } } - - /* iMessage-style compose fields */ .compose-fields { width: 100%; background: var(--bg-elevated); @@ -338,25 +342,21 @@ border-color 0.2s ease, box-shadow 0.2s ease; } - .compose-fields:focus-within { border-color: var(--accent-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-primary) 15%, transparent); } - .field-row { display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-xs) var(--space-md); } - .message-row { align-items: flex-start; padding-top: var(--space-sm); padding-bottom: var(--space-sm); } - .field-label { font-size: 0.8125rem; font-weight: 600; @@ -364,28 +364,23 @@ flex-shrink: 0; min-width: 56px; } - .message-row .field-label { padding-top: var(--space-xs); } - .field-divider { height: 1px; background: var(--border); margin: 0 var(--space-md); } - .field-input-wrap { display: flex; align-items: center; flex: 1; min-width: 0; } - .field-input-wrap.has-error input { color: var(--error); } - .field-input-wrap input { flex: 1; padding: 10px 0; @@ -439,16 +434,13 @@ width: 18px; height: 18px; } - .submit-btn:active { transform: scale(0.92); } - .submit-btn:disabled { opacity: 0.35; cursor: not-allowed; } - .spinner { width: 16px; height: 16px; @@ -457,13 +449,11 @@ border-radius: var(--radius-full); animation: spin 0.6s linear infinite; } - @keyframes spin { to { transform: rotate(360deg); } } - .platform-blocked { margin: 0; font-size: 0.8125rem; @@ -476,7 +466,6 @@ text-align: center; padding: var(--space-2xl) var(--space-lg); } - .no-provider-state :global(.no-provider-icon) { width: 40px; height: 40px; @@ -484,7 +473,6 @@ opacity: 0.4; margin-bottom: var(--space-md); } - .no-provider-title { font-family: var(--font-display); font-size: 1rem; @@ -497,4 +485,16 @@ color: var(--text-muted); margin: 0; } + .share-hint { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.75rem; + color: var(--text-muted); + text-decoration: underline; + text-underline-offset: 2px; + } + .share-hint :global(svg) { + flex-shrink: 0; + } diff --git a/src/lib/components/AddVideoModal.svelte b/src/lib/components/AddVideoModal.svelte index f2a409c..cc9a991 100644 --- a/src/lib/components/AddVideoModal.svelte +++ b/src/lib/components/AddVideoModal.svelte @@ -4,8 +4,7 @@ import AddVideo from './AddVideo.svelte'; import UploadStatus from './UploadStatus.svelte'; import BaseSheet from './BaseSheet.svelte'; - import { addToast, toast, toasts } from '$lib/stores/toasts'; - import { clipReadySignal, viewClipSignal } from '$lib/stores/toasts'; + import { addToast, toasts, clipReadySignal, clipOverlaySignal } from '$lib/stores/toasts'; import { dismissShortcutNudge } from '$lib/stores/shortcutNudge'; import { groupMembers } from '$lib/stores/members'; @@ -14,13 +13,9 @@ let phase = $state<'form' | 'uploading' | 'done' | 'failed'>('form'); let clipId = $state(''); let clipContentType = $state(''); - let caption = $state(''); - let captionDirty = $state(false); - let serverTitle = $state(null); let serverArtist = $state(null); let serverAlbumArt = $state(null); let pollTimer: ReturnType | null = null; - let savingCaption = $state(false); let addVideoRef = $state | null>(null); let sheetRef = $state | null>(null); @@ -45,13 +40,9 @@ timers.forEach(clearTimeout); }); - function handleSubmitted( - clip: { id: string; status: string; contentType: string }, - submittedCaption: string - ) { + function handleSubmitted(clip: { id: string; status: string; contentType: string }) { clipId = clip.id; clipContentType = clip.contentType; - caption = submittedCaption; // Remove the processing toast AddVideo created — UploadStatus screen takes over toasts.update((t) => t.filter((item) => item.clipId !== clip.id)); phase = 'uploading'; @@ -65,10 +56,6 @@ if (!res.ok) return; const data = await res.json(); - // Update metadata from server (e.g. music title/artist from Odesli) - if (data.title && !captionDirty) { - serverTitle = data.title; - } if (data.artist) serverArtist = data.artist; if (data.albumArt) serverAlbumArt = data.albumArt; @@ -101,24 +88,6 @@ } } - async function saveCaption() { - if (!captionDirty || !caption.trim()) return; - savingCaption = true; - try { - const res = await fetch(`/api/clips/${clipId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: caption.trim() }) - }); - if (!res.ok) { - toast.error('Failed to save caption'); - } - } catch { - toast.error('Failed to save caption'); - } - savingCaption = false; - } - function dismiss() { // If still uploading, push a background toast if (phase === 'uploading') { @@ -134,25 +103,15 @@ } async function handleSaveAndView() { - if (captionDirty && caption.trim()) { - await saveCaption(); - } clipReadySignal.set(clipId); - viewClipSignal.set(clipId); + clipOverlaySignal.set(clipId); sheetRef?.dismiss(); } - function handleCaptionInput(e: Event) { - caption = (e.target as HTMLInputElement).value; - captionDirty = true; - } - function handleDismissNudge() { dismissShortcutNudge(); sheetRef?.dismiss(); } - - const displayTitle = $derived(captionDirty ? caption : serverTitle || caption || '');
@@ -178,14 +137,11 @@ {/if} diff --git a/src/lib/components/ClipOverlay.svelte b/src/lib/components/ClipOverlay.svelte new file mode 100644 index 0000000..098c31d --- /dev/null +++ b/src/lib/components/ClipOverlay.svelte @@ -0,0 +1,356 @@ + + +
+ +
+ + {#if clip && clip.viewCount > 0} + { + showViewers = true; + }} + /> + {/if} +
+ + {#if loading} + + {:else if error || !clip} +
+ +

Clip not found

+ +
+ {:else} +
+ {}} + oncaptionedit={handleCaptionEdit} + ondelete={handleDelete} + /> +
+ {/if} +
+ +{#if showViewers} + (showViewers = false)} /> +{/if} + + diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index 7727a46..36ddb8f 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -1,5 +1,6 @@ {#if replyingTo} @@ -64,13 +84,17 @@ { text = t; }} + onfocus={handleInputFocus} onsubmit={() => { if (canSubmit && !submitting) onsubmit(text.trim(), attachedGif?.shareUrl || attachedGif?.url); diff --git a/src/lib/components/CommentsSheet.svelte b/src/lib/components/CommentsSheet.svelte index 42cfb04..c459abe 100644 --- a/src/lib/components/CommentsSheet.svelte +++ b/src/lib/components/CommentsSheet.svelte @@ -76,6 +76,20 @@ loadComments(); }); + $effect(() => { + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + if (showGifPicker) { + showGifPicker = false; + } else if (!attachedGif && commentInput?.isEmpty()) { + sheetRef?.dismiss(); + } + } + } + document.addEventListener('keydown', handleKeydown); + return () => document.removeEventListener('keydown', handleKeydown); + }); + async function loadComments() { loading = true; const result = await fetchComments(clipId); @@ -234,86 +248,91 @@ sheetId="comments" {ondismiss} > - -
- {#if loading} -

Loading...

- {:else if feedItems.length === 0} -

No comments yet

+
+ {#if showGifPicker} + { + attachedGif = gif; + showGifPicker = false; + }} + onclose={() => { + showGifPicker = false; + requestAnimationFrame(() => commentInput?.focus()); + }} + autoFocus + /> {:else} - {#each feedItems as item (item.type === 'comment' ? item.data.id : `reaction-${item.data.emoji}-${item.data.username}-${item.data.createdAt}`)} - {#if item.type === 'reaction'} - {@const r = item.data} -
- {r.emoji} - {r.username} reacted - {relativeTime(r.createdAt)} -
+ +
+ {#if loading} +

Loading...

+ {:else if feedItems.length === 0} +

No comments yet

{:else} - {@const comment = item.data} - startReply(comment)} - ontoggleheart={() => toggleHeart(comment)} - onstartedit={() => startEdit(comment)} - onsaveedit={() => handleEdit(comment.id)} - oncanceledit={cancelEdit} - ondelete={() => handleDelete(comment.id)} - onstartlongpress={() => startHeartLongPress(comment.id)} - oncancellongpress={cancelHeartLongPress} - /> - {#if comment.replies && comment.replies.length > 0} -
- {#each comment.replies as reply (reply.id)} - toggleHeart(reply)} - onstartedit={() => startEdit(reply)} - onsaveedit={() => handleEdit(reply.id)} - oncanceledit={cancelEdit} - ondelete={() => handleDelete(reply.id)} - onstartlongpress={() => startHeartLongPress(reply.id)} - oncancellongpress={cancelHeartLongPress} - /> - {/each} -
- {/if} + {#each feedItems as item (item.type === 'comment' ? item.data.id : `reaction-${item.data.emoji}-${item.data.username}-${item.data.createdAt}`)} + {#if item.type === 'reaction'} + {@const r = item.data} +
+ {r.emoji} + {r.username} reacted + {relativeTime(r.createdAt)} +
+ {:else} + {@const comment = item.data} + startReply(comment)} + ontoggleheart={() => toggleHeart(comment)} + onstartedit={() => startEdit(comment)} + onsaveedit={() => handleEdit(comment.id)} + oncanceledit={cancelEdit} + ondelete={() => handleDelete(comment.id)} + onstartlongpress={() => startHeartLongPress(comment.id)} + oncancellongpress={cancelHeartLongPress} + /> + {#if comment.replies && comment.replies.length > 0} +
+ {#each comment.replies as reply (reply.id)} + toggleHeart(reply)} + onstartedit={() => startEdit(reply)} + onsaveedit={() => handleEdit(reply.id)} + oncanceledit={cancelEdit} + ondelete={() => handleDelete(reply.id)} + onstartlongpress={() => startHeartLongPress(reply.id)} + oncancellongpress={cancelHeartLongPress} + /> + {/each} +
+ {/if} + {/if} + {/each} {/if} - {/each} +
{/if}
- {#if showGifPicker} - { - attachedGif = gif; - showGifPicker = false; - }} - ondismiss={() => { - showGifPicker = false; - }} - /> - {/if} - { showGifPicker = !showGifPicker; + if (!showGifPicker) { + requestAnimationFrame(() => commentInput?.focus()); + } }} onremovegif={() => { attachedGif = null; @@ -336,8 +358,15 @@ } .comments-sheet-wrapper :global(.base-sheet) { height: 70vh; + height: 70dvh; background: rgba(0, 0, 0, 0.93); } + .content-area { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } .comments-list { flex: 1; overflow-y: auto; diff --git a/src/lib/components/GifPicker.svelte b/src/lib/components/GifPicker.svelte index b74516a..4ffca56 100644 --- a/src/lib/components/GifPicker.svelte +++ b/src/lib/components/GifPicker.svelte @@ -13,12 +13,28 @@ const { onselect, - ondismiss + onclose, + autoFocus = false }: { onselect: (gif: GifResult) => void; - ondismiss: () => void; + onclose?: () => void; + autoFocus?: boolean; } = $props(); + function handleSearchKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + searchInputEl?.blur(); + onclose?.(); + } + } + + let searchInputEl = $state(null); + + export function focusSearch() { + searchInputEl?.focus(); + } + let query = $state(''); let gifs = $state([]); let loading = $state(false); @@ -41,6 +57,9 @@ $effect(() => { loadGifs(); + if (autoFocus) { + requestAnimationFrame(() => searchInputEl?.focus()); + } return () => { if (debounceTimer) clearTimeout(debounceTimer); }; @@ -108,9 +127,15 @@
- +
-
@@ -149,8 +174,8 @@ .gif-picker { display: flex; flex-direction: column; - height: 40vh; - border-top: 1px solid var(--border); + flex: 1; + min-height: 0; background: var(--bg-elevated); } @@ -190,16 +215,6 @@ color: var(--text-muted); } - .close-btn { - background: none; - border: none; - color: var(--text-muted); - font-size: 1.25rem; - cursor: pointer; - padding: var(--space-xs); - line-height: 1; - } - .gif-grid { flex: 1; overflow-y: auto; diff --git a/src/lib/components/MentionInput.svelte b/src/lib/components/MentionInput.svelte index 9852806..ee4e2fc 100644 --- a/src/lib/components/MentionInput.svelte +++ b/src/lib/components/MentionInput.svelte @@ -8,6 +8,7 @@ members = [], singleLine = false, onchange, + onfocus, onsubmit }: { placeholder?: string; @@ -16,6 +17,7 @@ members?: GroupMember[]; singleLine?: boolean; onchange?: (text: string) => void; + onfocus?: () => void; onsubmit?: () => void; } = $props(); @@ -198,7 +200,10 @@ oninput={handleInput} onkeydown={handleKeydown} onclick={handleClick} - onfocus={() => (isFocused = true)} + onfocus={() => { + isFocused = true; + onfocus?.(); + }} onblur={() => (isFocused = false)} onscroll={syncScroll} /> @@ -215,7 +220,10 @@ oninput={handleInput} onkeydown={handleKeydown} onclick={handleClick} - onfocus={() => (isFocused = true)} + onfocus={() => { + isFocused = true; + onfocus?.(); + }} onblur={() => (isFocused = false)} onscroll={syncScroll} > diff --git a/src/lib/components/MusicDisc.svelte b/src/lib/components/MusicDisc.svelte index 75cccac..7958d43 100644 --- a/src/lib/components/MusicDisc.svelte +++ b/src/lib/components/MusicDisc.svelte @@ -23,7 +23,29 @@ const hasMusicLinks = $derived(!!(spotifyUrl || appleMusicUrl || youtubeMusicUrl)); +{#if showMusicLinks} + + +{/if} +
+ + {#if showMusicLinks && hasMusicLinks} {/if} -