From 6289a00e34b84405ef019a000313d76519dd1e6a Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 00:32:11 -0600 Subject: [PATCH 1/7] fix: allow re-adding clips that previously failed to download When a clip download fails, the unique (group_id, original_url) constraint prevented re-submitting the same URL. Now both the clips API and share endpoint detect failed clips and auto-retry the download instead of returning a 409 duplicate error. --- src/routes/api/clips/+server.ts | 101 +++++++++++++++++--------- src/routes/api/clips/share/+server.ts | 40 ++++++---- 2 files changed, 92 insertions(+), 49 deletions(-) diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts index 101fb2d..d78427c 100644 --- a/src/routes/api/clips/+server.ts +++ b/src/routes/api/clips/+server.ts @@ -36,6 +36,54 @@ import { createLogger } from '$lib/server/logger'; const log = createLogger('clips'); +/** Set clip status to 'downloading' and trigger the download pipeline. Marks as 'failed' on error. */ +async function startDownload(clipId: string, url: string, contentType: string, label: string) { + await db.update(clips).set({ status: 'downloading' }).where(eq(clips.id, clipId)); + + const onError = async (err: unknown) => { + log.error({ err, clipId }, `download failed (${label})`); + await db + .update(clips) + .set({ status: 'failed' }) + .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); + }; + + if (contentType === 'music') { + downloadMusic(clipId, url).catch(onError); + } else { + downloadVideo(clipId, url).catch(onError); + } +} + +/** Validate a clip URL and return an error response, or null if valid. */ +function validateClipUrl( + videoUrl: string | undefined, + group: { platformFilterMode: string | null; platformFilterList: string | null } +): Response | null { + if (!videoUrl) return json({ error: 'URL required' }, { status: 400 }); + + if (!isSupportedUrl(videoUrl)) { + return json( + { + error: + 'Unsupported URL. Try a link from TikTok, YouTube, Instagram, X, Reddit, Spotify, or other supported platforms.' + }, + { status: 400 } + ); + } + + const platform = detectPlatform(videoUrl)!; + const filterList = group.platformFilterList ? JSON.parse(group.platformFilterList) : null; + if (!isPlatformAllowed(platform, group.platformFilterMode ?? 'all', filterList)) { + return json( + { error: `${platformLabel(videoUrl) || platform} links are not allowed in this group` }, + { status: 400 } + ); + } + + return null; +} + const VALID_FILTERS = ['unwatched', 'watched', 'favorites'] as const; const VALID_SORTS = ['oldest', 'round-robin'] as const; @@ -236,36 +284,29 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } const message = typeof body.message === 'string' ? body.message.trim().slice(0, 500) || null : null; - if (!videoUrl) return json({ error: 'URL required' }, { status: 400 }); - - if (!isSupportedUrl(videoUrl)) { - return json( - { - error: - 'Unsupported URL. Try a link from TikTok, YouTube, Instagram, X, Reddit, Spotify, or other supported platforms.' - }, - { status: 400 } - ); - } - - const platform = detectPlatform(videoUrl)!; - - // Enforce group platform filter - const filterList = group.platformFilterList ? JSON.parse(group.platformFilterList) : null; - if (!isPlatformAllowed(platform, group.platformFilterMode ?? 'all', filterList)) { - return json( - { error: `${platformLabel(videoUrl) || platform} links are not allowed in this group` }, - { status: 400 } - ); - } + const urlError = validateClipUrl(videoUrl, group); + if (urlError) return urlError; + const validUrl = videoUrl!; + const platform = detectPlatform(validUrl)!; const contentType = getContentType(platform); - const normalizedUrl = normalizeUrl(videoUrl); + const normalizedUrl = normalizeUrl(validUrl); // Check if this URL already exists in the group's feed const existing = await db.query.clips.findFirst({ where: and(eq(clips.groupId, user.groupId), eq(clips.originalUrl, normalizedUrl)) }); + if (existing && existing.status === 'failed') { + // Previous attempt failed — retry the download instead of rejecting + if (title) { + await db.update(clips).set({ title }).where(eq(clips.id, existing.id)); + } + await startDownload(existing.id, validUrl, existing.contentType, 're-add retry'); + return json( + { clip: { id: existing.id, status: 'downloading', contentType: existing.contentType } }, + { status: 201 } + ); + } if (existing) { return json({ error: 'This link has already been added to the feed.' }, { status: 409 }); } @@ -301,19 +342,7 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } }); // Route to appropriate download pipeline - const markFailedOnError = async (err: unknown) => { - log.error({ err, clipId }, 'download failed'); - await db - .update(clips) - .set({ status: 'failed' }) - .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); - }; - - if (contentType === 'music') { - downloadMusic(clipId, videoUrl).catch(markFailedOnError); - } else { - downloadVideo(clipId, videoUrl).catch(markFailedOnError); - } + await startDownload(clipId, validUrl, contentType, 'new clip'); // Push notification is sent after download succeeds (see video/download.ts, music/download.ts) diff --git a/src/routes/api/clips/share/+server.ts b/src/routes/api/clips/share/+server.ts index 5494d4b..1b92bc6 100644 --- a/src/routes/api/clips/share/+server.ts +++ b/src/routes/api/clips/share/+server.ts @@ -20,6 +20,25 @@ import { createLogger } from '$lib/server/logger'; const log = createLogger('share'); +/** Set clip status to 'downloading' and trigger the download pipeline. Marks as 'failed' on error. */ +async function startDownload(clipId: string, url: string, contentType: string, label: string) { + await db.update(clips).set({ status: 'downloading' }).where(eq(clips.id, clipId)); + + const onError = async (err: unknown) => { + log.error({ err, clipId }, `download failed (${label})`); + await db + .update(clips) + .set({ status: 'failed' }) + .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); + }; + + if (contentType === 'music') { + downloadMusic(clipId, url).catch(onError); + } else { + downloadVideo(clipId, url).catch(onError); + } +} + /** Shortcut-friendly JSON response. Every response includes `success` (1|0) and `message`. */ function shareResponse( success: boolean, @@ -169,6 +188,13 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { const existing = await db.query.clips.findFirst({ where: and(eq(clips.groupId, group.id), eq(clips.originalUrl, normalizedVideoUrl)) }); + if (existing && existing.status === 'failed') { + // Previous attempt failed — retry the download instead of rejecting + await startDownload(existing.id, videoUrl, existing.contentType, 're-share retry'); + return shareResponse(true, '✅ Clip shared! (retrying download)', 201, { + clipId: existing.id + }); + } if (existing) { return shareResponse(false, '❌ This clip has already been shared!', 409); } @@ -196,19 +222,7 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { }); // 10. Async download - const markFailedOnError = async (err: unknown) => { - log.error({ err, clipId }, 'download failed'); - await db - .update(clips) - .set({ status: 'failed' }) - .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); - }; - - if (contentType === 'music') { - downloadMusic(clipId, videoUrl).catch(markFailedOnError); - } else { - downloadVideo(clipId, videoUrl).catch(markFailedOnError); - } + await startDownload(clipId, videoUrl, contentType, 'new clip'); // Push notification is sent after download succeeds (see video/download.ts, music/download.ts) From 2483398eeeab21d967ec0b2b3f71c623787950c5 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:05:05 -0600 Subject: [PATCH 2/7] fix: enrich GET /api/clips/[id] with full FeedClip data The endpoint returned minimal fields missing addedByUsername, reactions, watched status, etc. This caused a crash in ReelOverlay when rendering a single clip via "View in feed". Now returns the same shape as the feed list endpoint using parallel queries. --- src/routes/api/clips/[id]/+server.ts | 69 +++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/src/routes/api/clips/[id]/+server.ts b/src/routes/api/clips/[id]/+server.ts index b4fecc6..68d0c27 100644 --- a/src/routes/api/clips/[id]/+server.ts +++ b/src/routes/api/clips/[id]/+server.ts @@ -12,31 +12,76 @@ import { notifications } from '$lib/server/db/schema'; import { eq, and, ne, count, inArray } from 'drizzle-orm'; -import { withClipAuth, parseBody, isResponse } from '$lib/server/api-utils'; +import { + withClipAuth, + parseBody, + isResponse, + mapUsersByIds, + groupReactions +} from '$lib/server/api-utils'; import { cleanupClipFiles } from '$lib/server/download-utils'; export const GET: RequestHandler = withClipAuth(async ({ params }, { user, clip }) => { - // Check if anyone other than the uploader has watched this clip - let canEditCaption = false; - if (clip.addedBy === user.id) { - const [watchResult] = await db - .select({ count: count() }) - .from(watched) - .where(and(eq(watched.clipId, params.id), ne(watched.userId, clip.addedBy))); - canEditCaption = watchResult.count === 0; - } + const clipId = params.id; + const userId = user.id; + + // Fetch all related data in parallel + const [watchedRows, favRow, clipReactions, clipComments, userCommentView, uploaderInfo] = + await Promise.all([ + db.query.watched.findMany({ where: eq(watched.clipId, clipId) }), + db.query.favorites.findFirst({ + where: and(eq(favorites.clipId, clipId), eq(favorites.userId, userId)) + }), + db.query.reactions.findMany({ where: eq(reactions.clipId, clipId) }), + db.query.comments.findMany({ where: eq(comments.clipId, clipId) }), + db.query.commentViews.findFirst({ + where: and(eq(commentViews.clipId, clipId), eq(commentViews.userId, userId)) + }), + mapUsersByIds([clip.addedBy]) + ]); + + const isWatched = watchedRows.some((w) => w.userId === userId); + const isFavorited = !!favRow; + const viewCount = watchedRows.length; + const seenByOthers = watchedRows.some((w) => w.userId !== clip.addedBy); + const canEditCaption = clip.addedBy === userId && !seenByOthers; + + const reactionData = groupReactions(clipReactions, userId); + + const commentCount = clipComments.length; + const unreadCommentCount = userCommentView + ? clipComments.filter((c) => c.createdAt > userCommentView.viewedAt).length + : commentCount; + + const uploaderUser = uploaderInfo.get(clip.addedBy); return json({ id: clip.id, - status: clip.status, + originalUrl: clip.originalUrl, videoPath: clip.videoPath, audioPath: clip.audioPath, thumbnailPath: clip.thumbnailPath, title: clip.title, artist: clip.artist, albumArt: clip.albumArt, - contentType: clip.contentType, + spotifyUrl: clip.spotifyUrl, + appleMusicUrl: clip.appleMusicUrl, + youtubeMusicUrl: clip.youtubeMusicUrl, + addedBy: clip.addedBy, + addedByUsername: uploaderUser?.username || 'Unknown', + addedByAvatar: uploaderUser?.avatarPath || null, platform: clip.platform, + status: clip.status, + contentType: clip.contentType, + durationSeconds: clip.durationSeconds, + watched: isWatched, + favorited: isFavorited, + reactions: reactionData, + commentCount, + unreadCommentCount, + viewCount, + seenByOthers, + createdAt: clip.createdAt, canEditCaption }); }); From ea112036dae0df694e4cfbde747571f17ec426e3 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:05:12 -0600 Subject: [PATCH 3/7] fix: enforce single reaction per user per clip Previously users could add multiple different emoji reactions to the same clip. Now changing your reaction removes the old one first, matching the expected one-reaction-per-user behavior. --- .../api/clips/[id]/reactions/+server.ts | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/routes/api/clips/[id]/reactions/+server.ts b/src/routes/api/clips/[id]/reactions/+server.ts index b3985ae..f64910a 100644 --- a/src/routes/api/clips/[id]/reactions/+server.ts +++ b/src/routes/api/clips/[id]/reactions/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; -import { reactions } from '$lib/server/db/schema'; +import { reactions, notifications } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { @@ -37,18 +37,32 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u return badRequest('Invalid emoji'); } - // Toggle: if exists, delete; if not, insert + // Find any existing reaction by this user on this clip (one reaction per user per clip) const existing = await db.query.reactions.findFirst({ - where: and( - eq(reactions.clipId, clipId), - eq(reactions.userId, userId), - eq(reactions.emoji, emoji) - ) + where: and(eq(reactions.clipId, clipId), eq(reactions.userId, userId)) }); + const sameEmoji = existing?.emoji === emoji; + if (existing) { + // Remove the old reaction await db.delete(reactions).where(eq(reactions.id, existing.id)); - } else { + + // Remove the old notification entry for this reaction + await db + .delete(notifications) + .where( + and( + eq(notifications.clipId, clipId), + eq(notifications.actorId, userId), + eq(notifications.type, 'reaction'), + eq(notifications.emoji, existing.emoji) + ) + ); + } + + if (!sameEmoji) { + // Add the new reaction (either fresh or replacing a different emoji) await db.insert(reactions).values({ id: uuid(), clipId, @@ -77,5 +91,8 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u where: eq(reactions.clipId, clipId) }); - return json({ reactions: groupReactions(allReactions, userId), toggled: !existing }); + return json({ + reactions: groupReactions(allReactions, userId), + toggled: !sameEmoji || !existing + }); }); From 46302db711f7215454ba45fdc51468b95c662e80 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:05:21 -0600 Subject: [PATCH 4/7] feat: reel layout polish and remove speed controls - Remove playback speed controls (SpeedPill, keyboard shortcuts) - Add periodic watch percent updates while viewing - Redesign music disc popout as a dropdown menu with labels - Adjust bottom positions for overlay, sidebar, and progress bar - Move ActionSidebar and MusicDisc outside overlay-content div - Add hideViewBadge prop to ReelItem for overlay use - Send watch percent before opening viewers sheet --- src/lib/components/ActionSidebar.svelte | 2 +- src/lib/components/MusicDisc.svelte | 95 +++++++++++-------- src/lib/components/ProgressBar.svelte | 2 +- src/lib/components/ReelItem.svelte | 118 +++++++++++------------- src/lib/components/ReelMusic.svelte | 1 + src/lib/components/ReelOverlay.svelte | 2 +- src/lib/reelInteractions.ts | 16 +--- src/lib/reelPlayback.ts | 12 +++ 8 files changed, 131 insertions(+), 117 deletions(-) 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/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} -
diff --git a/src/lib/components/ToastStack.svelte b/src/lib/components/ToastStack.svelte index 79efc99..05433d9 100644 --- a/src/lib/components/ToastStack.svelte +++ b/src/lib/components/ToastStack.svelte @@ -4,7 +4,7 @@ removeToast, replaceToast, clipReadySignal, - viewClipSignal, + clipOverlaySignal, type Toast } from '$lib/stores/toasts'; import { onDestroy } from 'svelte'; @@ -93,7 +93,7 @@ function handleView(toast: Toast) { if (toast.clipId) { - viewClipSignal.set(toast.clipId); + clipOverlaySignal.set(toast.clipId); } handleDismiss(toast.id); } diff --git a/src/lib/components/UploadStatus.svelte b/src/lib/components/UploadStatus.svelte index 8e7dd35..6b6dbac 100644 --- a/src/lib/components/UploadStatus.svelte +++ b/src/lib/components/UploadStatus.svelte @@ -2,30 +2,25 @@ import { showShortcutNudge, dismissShortcutNudge } from '$lib/stores/shortcutNudge'; import XIcon from 'phosphor-svelte/lib/XIcon'; import CheckIcon from 'phosphor-svelte/lib/CheckIcon'; + import LightbulbIcon from 'phosphor-svelte/lib/LightbulbIcon'; const { phase, clipContentType, - displayTitle, serverArtist, serverAlbumArt, - savingCaption, ondismiss, onretry, onsaveandview, - oncaptioninput, ondismissnudge }: { phase: 'uploading' | 'done' | 'failed'; clipContentType: string; - displayTitle: string; serverArtist: string | null; serverAlbumArt: string | null; - savingCaption: boolean; ondismiss: () => void; onretry: () => void; onsaveandview: () => void; - oncaptioninput: (e: Event) => void; ondismissnudge: () => void; } = $props(); @@ -78,27 +73,15 @@

{serverArtist}

{/if} - -
- -
- {#if phase === 'done'} - + {#if $showShortcutNudge}
+ Share clips faster from other apps
+ {:else if !hasMore} +
+
+ + + +

+ {filter === 'unwatched' ? "You're all caught up!" : "That's all, folks!"} +

+

+ {#if filter === 'unwatched'} + Check back later for new clips + {:else if filter === 'watched'} + All your watched clips are above + {:else} + Your favorite clips are above + {/if} +

+
+
{/if} {/if} +{#if overlayClipId} + +{/if} + {#if $addVideoModalOpen} addVideoModalOpen.set(false)} /> {/if} @@ -967,6 +979,35 @@ color: var(--text-primary); margin: 0; } + .end-slide { + background: var(--bg-primary); + } + .end-slide-content { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-sm); + animation: empty-in 400ms cubic-bezier(0.32, 0.72, 0, 1); + } + .end-slide-icon { + color: var(--accent-primary); + opacity: 0.7; + margin-bottom: var(--space-xs); + } + .end-slide-title { + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + margin: 0; + } + .end-slide-sub { + color: var(--text-muted); + font-size: 0.875rem; + margin: 0; + } @keyframes fade-in { from { opacity: 0; From 2dd2e437641eb935c7c4e1869a3982d1c24aef26 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:08:39 -0600 Subject: [PATCH 6/7] feat: improved comments UX with inline GIF picker - Move GIF picker into the content area above the comment input instead of a separate overlay - Toggle between keyboard and GIF icons on the input button - Add Escape key to dismiss GIF picker or close comments sheet - Auto-focus GIF search when picker opens - Close GIF picker when text input is focused --- src/lib/components/CommentInput.svelte | 31 ++++- src/lib/components/CommentsSheet.svelte | 167 ++++++++++++++---------- src/lib/components/GifPicker.svelte | 47 ++++--- src/lib/components/MentionInput.svelte | 12 +- 4 files changed, 167 insertions(+), 90 deletions(-) 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} > From a315a9c943f789f0046d280c753382460fbf0362 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:10:22 -0600 Subject: [PATCH 7/7] feat: share shortcut nudge and dev mode GIF placeholders - Add "Share clips faster from other apps" hint link in AddVideo form and upload success screen when shortcut nudge is active - Enable GIF picker in dev mode with placeholder images - Generate dev placeholder GIFs when no GIPHY API key is configured --- src/lib/components/AddVideo.svelte | 48 +++++++++++++-------------- src/routes/(app)/+layout.server.ts | 3 +- src/routes/api/gifs/search/+server.ts | 39 +++++++++++++++++++++- 3 files changed, 64 insertions(+), 26 deletions(-) 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} + + + {/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/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index e614671..306b107 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -1,4 +1,5 @@ import { redirect } from '@sveltejs/kit'; +import { dev } from '$app/environment'; import type { LayoutServerLoad } from './$types'; import { env } from '$env/dynamic/private'; @@ -14,6 +15,6 @@ export const load: LayoutServerLoad = async ({ locals, url }) => { user: locals.user, group: locals.group, vapidPublicKey: env.VAPID_PUBLIC_KEY || '', - gifEnabled: !!env.GIPHY_API_KEY + gifEnabled: !!env.GIPHY_API_KEY || dev }; }; diff --git a/src/routes/api/gifs/search/+server.ts b/src/routes/api/gifs/search/+server.ts index b7586c2..46c333b 100644 --- a/src/routes/api/gifs/search/+server.ts +++ b/src/routes/api/gifs/search/+server.ts @@ -1,13 +1,50 @@ import { json } from '@sveltejs/kit'; +import { dev } from '$app/environment'; import type { RequestHandler } from './$types'; import { env } from '$env/dynamic/private'; import { withAuth, safeInt } from '$lib/server/api-utils'; const GIPHY_BASE = 'https://api.giphy.com/v1/gifs'; +// Placeholder GIFs for dev mode when no GIPHY API key is configured +function generateDevGifs(count: number) { + const colors = [ + 'E74C3C', + '3498DB', + '2ECC71', + 'F39C12', + '9B59B6', + '1ABC9C', + 'E91E63', + '00BCD4', + 'FF5722', + '795548' + ]; + const widths = [200, 200, 200, 200, 200]; + const heights = [200, 150, 250, 180, 220]; + return Array.from({ length: count }, (_, i) => { + const w = widths[i % widths.length]; + const h = heights[i % heights.length]; + const color = colors[i % colors.length]; + const url = `https://placehold.co/${w}x${h}/${color}/white?text=GIF+${i + 1}`; + return { + id: `dev-${i}`, + title: `Dev GIF ${i + 1}`, + url, + stillUrl: url, + shareUrl: url, + width: w, + height: h + }; + }); +} + export const GET: RequestHandler = withAuth(async ({ url }, _auth) => { const apiKey = env.GIPHY_API_KEY; - if (!apiKey) return json({ error: 'GIF search not configured' }, { status: 503 }); + if (!apiKey) { + if (dev) return json({ gifs: generateDevGifs(20) }); + return json({ error: 'GIF search not configured' }, { status: 503 }); + } const q = url.searchParams.get('q')?.trim(); const limit = safeInt(url.searchParams.get('limit'), 20, 30);