From 4895d2ef2e2196792684e2d1f0826dbc74a0366f Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 09:27:53 -0600 Subject: [PATCH 1/7] feat: overhaul GIF picker with masonry layout, lazy-loading, and share URLs - Replace CSS grid with dynamic masonry columns (2-3 based on width) - Add IntersectionObserver to auto-play visible GIFs and show stills off-screen - Use WEBP for grid previews (smaller/faster), full GIF for comment sharing - Add search icon to search field for better visual affordance - Increase GIF fetch limit from 20 to 30 - Reduce displayed GIF sizes in comments for better readability --- src/lib/components/CommentInput.svelte | 7 +- src/lib/components/CommentsSheet.svelte | 13 +- src/lib/components/GifPicker.svelte | 154 ++++++++++++++++++------ src/routes/api/gifs/search/+server.ts | 8 +- 4 files changed, 132 insertions(+), 50 deletions(-) diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index 2d3c095..7727a46 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -17,7 +17,7 @@ replyingTo: { id: string; username: string } | null; submitting: boolean; gifEnabled?: boolean; - attachedGif: { url: string; stillUrl: string } | null; + attachedGif: { url: string; stillUrl: string; shareUrl?: string } | null; members?: GroupMember[]; onsubmit: (text: string, gifUrl?: string) => void; oncancelreply: () => void; @@ -42,7 +42,7 @@ function handleSubmit(e: SubmitEvent) { e.preventDefault(); if (!canSubmit || submitting) return; - onsubmit(text.trim(), attachedGif?.url); + onsubmit(text.trim(), attachedGif?.shareUrl || attachedGif?.url); } @@ -83,7 +83,8 @@ text = t; }} onsubmit={() => { - if (canSubmit && !submitting) onsubmit(text.trim(), attachedGif?.url); + 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 706a686..7c39a4d 100644 --- a/src/lib/components/CommentsSheet.svelte +++ b/src/lib/components/CommentsSheet.svelte @@ -45,6 +45,7 @@ id: string; url: string; stillUrl: string; + shareUrl: string; width: number; height: number; } | null>(null); @@ -373,16 +374,18 @@ .comment-gif, .reply-gif { display: block; - border-radius: var(--radius-sm); - margin-top: var(--space-xs); + border-radius: var(--radius-md); + margin-top: var(--space-sm); object-fit: contain; + background: var(--bg-surface); + padding: 2px; } .comment-gif { - max-width: 200px; - max-height: 160px; + max-width: 150px; + max-height: 150px; } .reply-gif { - max-width: 160px; + max-width: 120px; max-height: 120px; } .comment-actions { diff --git a/src/lib/components/GifPicker.svelte b/src/lib/components/GifPicker.svelte index eb5dc27..e44d89a 100644 --- a/src/lib/components/GifPicker.svelte +++ b/src/lib/components/GifPicker.svelte @@ -1,9 +1,12 @@ - - diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index b46321d..87762e3 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -43,9 +43,10 @@ const isHost = $derived(group?.createdBy === user?.id); let activeTab = $state<'me' | 'group'>('me'); - let showAvatarCrop = $state(false); + let avatarCropImage = $state(null); let avatarOverride = $state(undefined); let avatarCacheBust = $state(0); + let avatarFileInput = $state(null); const avatarPath = $derived( avatarOverride !== undefined ? avatarOverride : (user?.avatarPath ?? null) ); @@ -53,10 +54,20 @@ avatarPath ? `/api/profile/avatar/${avatarPath}?v=${avatarCacheBust}` : null ); + function handleAvatarFileSelect(e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (file) { + avatarCropImage = URL.createObjectURL(file); + } + // Reset so the same file can be re-selected + input.value = ''; + } + function handleAvatarUploaded(path: string) { avatarOverride = path; avatarCacheBust = Date.now(); - showAvatarCrop = false; + avatarCropImage = null; } async function handleRemoveAvatar() { @@ -98,8 +109,8 @@ }); onMount(async () => { - const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); - showShareCta = isMobile || import.meta.env.DEV; + const isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent); + showShareCta = isIos; pushSupported = isPushSupported(); if (pushSupported) { @@ -176,7 +187,14 @@ {#if activeTab === 'me'}
-
{/if} - {#if showAvatarCrop} - (showAvatarCrop = false)} onuploaded={handleAvatarUploaded} /> + {#if avatarCropImage} + { + if (avatarCropImage) { + URL.revokeObjectURL(avatarCropImage); + avatarCropImage = null; + } + }} + onuploaded={handleAvatarUploaded} + /> {/if}
From efb896d9c6c71aed8c4f351788f5b0f50a2bceef Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 09:28:53 -0600 Subject: [PATCH 3/7] feat: prevent users from liking or reacting to their own clips Disable the favorite button and block reaction gestures (double-tap, long-press, picker) when viewing your own clip. --- src/lib/components/ActionSidebar.svelte | 27 ++++++++++++++++++++++--- src/lib/components/ReelItem.svelte | 5 +++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/lib/components/ActionSidebar.svelte b/src/lib/components/ActionSidebar.svelte index 5341e34..97be29a 100644 --- a/src/lib/components/ActionSidebar.svelte +++ b/src/lib/components/ActionSidebar.svelte @@ -14,6 +14,7 @@ originalUrl, muted = true, uiHidden = false, + isOwn = false, onsave, oncomment, onreactionhold, @@ -26,12 +27,17 @@ originalUrl: string; muted?: boolean; uiHidden?: boolean; + isOwn?: boolean; onsave: () => void; oncomment: () => void; onreactionhold?: (x: number, y: number) => void; onmute?: () => void; } = $props(); + const saveLabel = $derived.by(() => { + if (isOwn) return 'Cannot like own clip'; + return favorited ? 'Unsave' : 'Save'; + }); let saveBtnEl: HTMLButtonElement | null = $state(null); let holdTimer: ReturnType | null = null; let holdFired = false; @@ -106,10 +112,12 @@ @@ -251,7 +248,7 @@ .caption-edit { margin-bottom: var(--space-sm); } - .caption-edit input { + .caption-edit textarea { width: 100%; padding: 8px 12px; background: var(--reel-input-bg); @@ -262,12 +259,14 @@ color: var(--reel-text); font-size: 0.875rem; font-family: var(--font-body); + line-height: 1.4; outline: none; + resize: none; } - .caption-edit input:focus { + .caption-edit textarea:focus { border-color: var(--accent-primary); } - .caption-edit input::placeholder { + .caption-edit textarea::placeholder { color: var(--reel-text-placeholder); } .edit-actions { From 552f1d8cb326fe29701a7ec64dad4dc7bf79030b Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 09:29:03 -0600 Subject: [PATCH 5/7] fix: add giphy.com to CSP img-src for GIF rendering in comments --- src/hooks.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index ed70dab..eb0085e 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -57,7 +57,7 @@ function setSecurityHeaders(response: Response): void { "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com", - "img-src 'self' blob: data: https://i.scdn.co", + "img-src 'self' blob: data: https://i.scdn.co https://*.giphy.com", "media-src 'self' blob:", "connect-src 'self'", "frame-ancestors 'none'" From a79a0215db8c5efca8b34f6f4581d0d4e3428dcd Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 09:29:08 -0600 Subject: [PATCH 6/7] docs: document ORIGIN env var for SvelteKit CSRF protection behind proxies --- .env.example | 10 +++++++++- Dockerfile | 2 ++ docs/architecture.md | 15 ++++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 57a39d8..0eba10f 100644 --- a/.env.example +++ b/.env.example @@ -25,10 +25,18 @@ VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:you@example.com -# App URL (REQUIRED — used for CSRF protection, Twilio webhooks, and invite links) +# App URL (REQUIRED — used for invite links, Twilio webhooks, and internal references) # Must match the public URL users access (e.g. https://scrolly.example.com) PUBLIC_APP_URL=http://localhost:3000 +# SvelteKit origin (REQUIRED behind a reverse proxy) +# SvelteKit checks the Origin header on form submissions for CSRF protection. +# Behind a reverse proxy, SvelteKit can't determine the correct origin on its own +# and will reject requests with a silent 403 (before app-level logging). +# docker-compose.yml sets this automatically from PUBLIC_APP_URL. +# For manual deployments, set this to your public URL. +# ORIGIN=https://scrolly.example.com + # Data directory (optional — defaults to ./data) # DATA_DIR=./data diff --git a/Dockerfile b/Dockerfile index 245a3f4..12e9e5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,8 @@ RUN mkdir -p /app/data && chown -R scrolly:scrolly /app ENV APP_VERSION=${APP_VERSION} ENV NODE_ENV=production ENV PORT=3000 +# SvelteKit CSRF protection — must match the public URL when behind a reverse proxy. +# docker-compose.yml overrides this with PUBLIC_APP_URL or DOMAIN. ENV ORIGIN=http://localhost:3000 VOLUME /app/data diff --git a/docs/architecture.md b/docs/architecture.md index be3faf2..0b682f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -180,11 +180,24 @@ VPS (Ubuntu, e.g., DigitalOcean or Hetzner) 2. Clone repo, `npm install`, `npm run build` 3. Create `data/videos/` directory 4. Configure environment variables (see `.env` template in repo) -5. Start app: `pm2 start build/index.js --name scrolly` +5. Start app: `ORIGIN=https://your-domain.com pm2 start build/index.js --name scrolly` 6. Generate VAPID keys: `npx web-push generate-vapid-keys` 7. Configure Twilio for SMS verification codes (see deployment docs) 8. Set up a reverse proxy (Caddy, nginx, etc.) for HTTPS +### ORIGIN and CSRF Protection + +SvelteKit has built-in CSRF protection that checks the `Origin` header on form submissions. Behind a reverse proxy, SvelteKit can't determine the correct origin on its own and will reject requests with a **silent 403** — the rejection happens before the request reaches app-level logging, so nothing appears in `docker logs` or application output. + +**Set the `ORIGIN` environment variable** to your public URL (e.g., `ORIGIN=https://scrolly.example.com`): + +- **Docker:** `docker-compose.yml` sets this automatically from `PUBLIC_APP_URL`. The Caddy overlay sets it from `DOMAIN`. +- **Manual:** Set `ORIGIN` in your shell environment or process manager config. + +This only affects form submissions (SvelteKit form actions). API endpoints (`/api/*` routes via `+server.ts`) are not subject to CSRF origin checks. + +**Troubleshooting:** If you see unexplained 403 errors on POST requests that don't appear in your app logs, check that `ORIGIN` matches the URL users access in their browser (protocol + domain, no trailing slash). + ## PWA Configuration **manifest.json:** From 6c1909a2c410fa5cb87cabf0da6c0501bfa177e7 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Sun, 1 Mar 2026 09:29:28 -0600 Subject: [PATCH 7/7] fix: misc UI improvements - Hide speed pill on mobile (touch-only devices) - Disable video right-click context menu - Only show iOS Shortcut nudge on iOS devices - Add explicit 'Open Scrolly' button on share success page - Prevent horizontal swipe from hijacking progress bar interaction --- src/lib/components/ReelVideo.svelte | 1 + src/lib/components/SpeedPill.svelte | 5 +++++ src/lib/stores/shortcutNudge.ts | 9 +++++++-- src/routes/(app)/+page.svelte | 13 +++++++++++-- src/routes/share/+page.svelte | 23 +++++++++++++++++++++-- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/lib/components/ReelVideo.svelte b/src/lib/components/ReelVideo.svelte index b0b57c0..00551a3 100644 --- a/src/lib/components/ReelVideo.svelte +++ b/src/lib/components/ReelVideo.svelte @@ -81,6 +81,7 @@ loop={!autoScroll} muted class="reel-video" + oncontextmenu={(e) => e.preventDefault()} onended={() => { if (autoScroll) onended(); }} diff --git a/src/lib/components/SpeedPill.svelte b/src/lib/components/SpeedPill.svelte index 41d1cc2..1593c58 100644 --- a/src/lib/components/SpeedPill.svelte +++ b/src/lib/components/SpeedPill.svelte @@ -70,4 +70,9 @@ .speed-pill:active { transform: scale(0.93); } + @media (max-width: 768px) { + .speed-pill { + display: none; + } + } diff --git a/src/lib/stores/shortcutNudge.ts b/src/lib/stores/shortcutNudge.ts index 47cf2a0..bdd39f1 100644 --- a/src/lib/stores/shortcutNudge.ts +++ b/src/lib/stores/shortcutNudge.ts @@ -9,12 +9,17 @@ function getInitial(): boolean { return localStorage.getItem(STORAGE_KEY) === 'true'; } +function isIosDevice(): boolean { + if (!browser) return false; + return /iPhone|iPad|iPod/.test(navigator.userAgent); +} + export const shortcutNudgeDismissed = writable(getInitial()); -/** Show the shortcut nudge only when install banner is not visible and nudge hasn't been dismissed */ +/** Show the shortcut nudge only on iOS, when install banner is not visible and nudge hasn't been dismissed */ export const showShortcutNudge = derived( [shortcutNudgeDismissed, showInstallBanner], - ([$dismissed, $showInstall]) => !$dismissed && !$showInstall + ([$dismissed, $showInstall]) => isIosDevice() && !$dismissed && !$showInstall ); export function dismissShortcutNudge(): void { diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 023d588..894ffa4 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -227,11 +227,18 @@ const el = feedWrapper; let startX = 0; let startY = 0; + let tracking = false; let decided = false; let isHorizontal = false; function onTouchStart(e: TouchEvent) { if (swipeAnimating) return; + const target = e.target as HTMLElement; + if (target.closest('.progress-bar')) { + tracking = false; + return; + } + tracking = true; startX = e.touches[0].clientX; startY = e.touches[0].clientY; decided = false; @@ -240,7 +247,7 @@ } function onTouchMove(e: TouchEvent) { - if (swipeAnimating) return; + if (!tracking || swipeAnimating) return; const dx = e.touches[0].clientX - startX; const dy = e.touches[0].clientY - startY; @@ -261,13 +268,15 @@ } function onTouchEnd() { - if (!isHorizontal || swipeX === 0) { + if (!tracking || !isHorizontal || swipeX === 0) { + tracking = false; decided = false; isHorizontal = false; isHorizontalSwiping = false; return; } + tracking = false; decided = false; isHorizontal = false; isHorizontalSwiping = false; diff --git a/src/routes/share/+page.svelte b/src/routes/share/+page.svelte index ad3ff7f..4f5f7ab 100644 --- a/src/routes/share/+page.svelte +++ b/src/routes/share/+page.svelte @@ -8,6 +8,7 @@ detectPlatform, isPlatformAllowed } from '$lib/url-validation'; + import { addToast } from '$lib/stores/toasts'; import XCircleIcon from 'phosphor-svelte/lib/XCircleIcon'; import ProhibitIcon from 'phosphor-svelte/lib/ProhibitIcon'; import CheckIcon from 'phosphor-svelte/lib/CheckIcon'; @@ -32,6 +33,8 @@ let loading = $state(false); let error = $state(''); let success = $state(false); + let clipId = $state(''); + let contentType = $state(''); async function handleSubmit() { error = ''; @@ -47,14 +50,29 @@ error = data.error || 'Failed to add clip'; return; } + clipId = data.clip.id; + contentType = data.clip.contentType ?? 'video'; success = true; - setTimeout(() => goto(resolve('/')), 1500); } catch { error = 'Something went wrong'; } finally { loading = false; } } + + function openFeed() { + if (clipId) { + const label = contentType === 'music' ? 'song' : 'video'; + addToast({ + type: 'processing', + message: `Adding ${label} to feed...`, + clipId, + contentType, + autoDismiss: 0 + }); + } + goto(resolve('/')); + } @@ -84,7 +102,8 @@

Added!

- + + {:else}