diff --git a/src/app.html b/src/app.html
index 745dc8e..e74f1f3 100644
--- a/src/app.html
+++ b/src/app.html
@@ -16,11 +16,11 @@
+
-
-
+
%sveltekit.head%
@@ -170,7 +181,7 @@
class="notification-item"
class:unread={!n.read}
style="animation-delay: {Math.min(i, 15) * 30}ms"
- onclick={(e) => handleNotificationClick(e, n.clipId)}
+ onclick={(e) => handleNotificationClick(e, n)}
>
{#if n.actorAvatar}
diff --git a/src/lib/components/InstallBanner.svelte b/src/lib/components/InstallBanner.svelte
index fd05d91..184d2c4 100644
--- a/src/lib/components/InstallBanner.svelte
+++ b/src/lib/components/InstallBanner.svelte
@@ -7,7 +7,6 @@
} from '$lib/stores/pwa';
import XIcon from 'phosphor-svelte/lib/XIcon';
import ExportIcon from 'phosphor-svelte/lib/ExportIcon';
- import DownloadSimpleIcon from 'phosphor-svelte/lib/DownloadSimpleIcon';
let showingInstructions = $state(false);
@@ -64,23 +63,17 @@
{/if}
{:else}
-
+
-
-
-
-
-
- Install scrolly
- Add to your home screen for the best experience
-
-
-
-
-
+
+

+
+ scrolly
+ Your crew's private feed
+
{/if}
{/if}
@@ -237,110 +230,87 @@
opacity: 0.6;
}
- /* ========== Android install banner ========== */
+ /* ========== Android smart app banner ========== */
.install-banner {
position: fixed;
- bottom: calc(80px + env(safe-area-inset-bottom) + var(--space-sm));
- left: 50%;
- transform: translateX(-50%);
+ bottom: 0;
+ left: 0;
+ right: 0;
z-index: 200;
- width: calc(100% - var(--space-lg) * 2);
- max-width: 400px;
display: flex;
align-items: center;
- justify-content: space-between;
- gap: var(--space-sm);
- padding: var(--space-md);
+ gap: var(--space-md);
+ padding: var(--space-md) var(--space-lg);
+ padding-bottom: max(var(--space-md), env(safe-area-inset-bottom));
background: var(--bg-elevated);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
- animation: slide-up 0.3s cubic-bezier(0.32, 0.72, 0, 1);
- }
-
- .install-content {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
- flex: 1;
- min-width: 0;
+ border-top: 1px solid var(--border);
+ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15);
+ animation: slide-up 0.3s cubic-bezier(0.2, 0, 0, 1);
}
- .install-icon {
+ .banner-close {
flex-shrink: 0;
- width: 32px;
- height: 32px;
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 4px;
+ margin: -4px;
display: flex;
align-items: center;
justify-content: center;
- background: color-mix(in srgb, var(--accent-primary) 15%, transparent);
- border-radius: var(--radius-sm);
- color: var(--accent-primary);
}
- .install-icon :global(svg) {
- width: 18px;
- height: 18px;
+ .banner-close :global(svg) {
+ width: 14px;
+ height: 14px;
}
- .install-text {
+ .banner-icon {
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-sm);
+ object-fit: cover;
+ }
+
+ .banner-text {
+ flex: 1;
+ min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
- min-width: 0;
}
- .install-text strong {
- font-family: var(--font-display);
- font-size: 0.8125rem;
- font-weight: 700;
+ .banner-text strong {
+ font-size: 0.875rem;
+ font-weight: 600;
color: var(--text-primary);
+ line-height: 1.3;
}
- .install-text span {
+ .banner-text span {
font-size: 0.6875rem;
color: var(--text-muted);
line-height: 1.3;
}
- .install-actions {
- display: flex;
- align-items: center;
- gap: var(--space-xs);
+ .banner-action {
flex-shrink: 0;
- }
-
- .install-btn {
- padding: var(--space-xs) var(--space-md);
- background: var(--accent-primary);
- color: var(--bg-primary);
+ background: none;
border: none;
- border-radius: var(--radius-full);
- font-size: 0.75rem;
+ color: var(--accent-blue);
+ font-size: 0.8125rem;
font-weight: 700;
+ letter-spacing: 0.04em;
cursor: pointer;
+ padding: var(--space-xs) var(--space-sm);
font-family: var(--font-body);
}
- .install-btn:active {
- transform: scale(0.97);
- }
-
- .dismiss-btn {
- background: none;
- border: none;
- color: var(--text-muted);
- cursor: pointer;
- padding: 4px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
-
- .dismiss-btn :global(svg) {
- width: 16px;
- height: 16px;
+ .banner-action:active {
+ opacity: 0.6;
}
/* ========== Animations ========== */
@@ -359,11 +329,11 @@
@keyframes slide-up {
from {
opacity: 0;
- transform: translateX(-50%) translateY(12px);
+ transform: translateY(100%);
}
to {
opacity: 1;
- transform: translateX(-50%) translateY(0);
+ transform: translateY(0);
}
}
diff --git a/src/lib/components/ReelItem.svelte b/src/lib/components/ReelItem.svelte
index 2f3074f..769034c 100644
--- a/src/lib/components/ReelItem.svelte
+++ b/src/lib/components/ReelItem.svelte
@@ -1,8 +1,8 @@
diff --git a/src/lib/components/settings/UsernameEdit.svelte b/src/lib/components/settings/UsernameEdit.svelte
new file mode 100644
index 0000000..d8dadd8
--- /dev/null
+++ b/src/lib/components/settings/UsernameEdit.svelte
@@ -0,0 +1,139 @@
+
+
+
+
+ {#if saved}
+
+
+
+ {/if}
+
+
+
diff --git a/src/lib/iconSvg.ts b/src/lib/iconSvg.ts
new file mode 100644
index 0000000..c1d2936
--- /dev/null
+++ b/src/lib/iconSvg.ts
@@ -0,0 +1,73 @@
+import { getAccentColor } from '$lib/colors';
+
+// SVG path data for the scrolly glyph (extracted from src/lib/assets/favicon.svg)
+const GLYPH_PATHS =
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+
+/**
+ * Generate an SVG icon with the scrolly glyph in the given accent color.
+ */
+export function generateIconSvg(
+ accentHex: string,
+ options: { maskable?: boolean; monochrome?: boolean } = {}
+): string {
+ const { maskable = false, monochrome = false } = options;
+
+ if (monochrome) {
+ return [
+ '
'
+ ].join('');
+ }
+
+ const rx = maskable ? '' : ' rx="96"';
+
+ // For maskable, scale the glyph to 70% centered for the safe zone
+ const glyphContent = maskable
+ ? '
' +
+ `` +
+ GLYPH_PATHS +
+ ''
+ : `
` +
+ GLYPH_PATHS +
+ '';
+
+ return (
+ '
'
+ );
+}
+
+/**
+ * Generate a data URL for the favicon SVG with the given accent color.
+ */
+export function generateFaviconDataUrl(accentHex: string): string {
+ return `data:image/svg+xml,${encodeURIComponent(generateIconSvg(accentHex))}`;
+}
+
+/**
+ * Resolve the accent hex from a color key string.
+ */
+export function resolveAccentHex(accentColorKey: string | null | undefined): string {
+ return getAccentColor(accentColorKey).hex;
+}
+
+/**
+ * Update the dynamic favicon link element with a new accent color.
+ * Call this client-side when the accent color changes.
+ */
+export function updateFavicon(accentHex: string): void {
+ if (typeof document === 'undefined') return;
+ const link = document.getElementById('dynamic-favicon') as HTMLLinkElement | null;
+ if (link) {
+ link.href = generateFaviconDataUrl(accentHex);
+ }
+}
diff --git a/src/lib/phone.ts b/src/lib/phone.ts
new file mode 100644
index 0000000..2ccb1a6
--- /dev/null
+++ b/src/lib/phone.ts
@@ -0,0 +1,18 @@
+/** Extract raw digits from a formatted phone display string. */
+export function rawDigits(formatted: string): string {
+ return formatted.replace(/\D/g, '');
+}
+
+/** Format digits as (XXX) XXX-XXXX for display. */
+export function formatPhone(digits: string): string {
+ if (digits.length === 0) return '';
+ if (digits.length <= 3) return `(${digits}`;
+ if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
+}
+
+/** Convert a formatted display string to E.164 format (+1XXXXXXXXXX). */
+export function toE164(formatted: string): string {
+ const digits = rawDigits(formatted);
+ return `+1${digits}`;
+}
diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts
index 2bfe398..b9c06d3 100644
--- a/src/lib/server/api-utils.ts
+++ b/src/lib/server/api-utils.ts
@@ -211,10 +211,14 @@ export async function notifyClipOwner(opts: {
where: eq(notificationPreferences.userId, opts.recipientId)
});
if (!prefs || prefs[opts.preferenceKey]) {
+ const url =
+ opts.type === 'comment' || opts.type === 'reply'
+ ? `/?clip=${opts.clipId}&comments=true`
+ : `/?clip=${opts.clipId}`;
sendNotification(opts.recipientId, {
title: opts.pushTitle,
body: opts.pushBody,
- url: '/',
+ url,
tag: opts.pushTag
}).catch((err) => log.error({ err }, 'push notification failed'));
}
diff --git a/src/lib/server/mentions.ts b/src/lib/server/mentions.ts
index dfad705..9c7b3aa 100644
--- a/src/lib/server/mentions.ts
+++ b/src/lib/server/mentions.ts
@@ -61,7 +61,7 @@ export async function notifyMentions(opts: {
sendNotification(recipient.id, {
title: `${opts.actorUsername} mentioned you`,
body: opts.commentPreview,
- url: '/',
+ url: `/?clip=${opts.clipId}&comments=true`,
tag: `mention-${opts.clipId}`
}).catch((err) => log.error({ err }, 'mention push notification failed'));
}
diff --git a/src/lib/server/music/download.ts b/src/lib/server/music/download.ts
index 4c56172..58a9da9 100644
--- a/src/lib/server/music/download.ts
+++ b/src/lib/server/music/download.ts
@@ -41,7 +41,7 @@ interface MusicMetadata {
async function resolveOdesli(url: string): Promise
{
const apiUrl = `https://api.song.link/v1-alpha.1/links?url=${encodeURIComponent(url)}`;
- const res = await fetch(apiUrl);
+ const res = await fetch(apiUrl, { signal: AbortSignal.timeout(15000) });
if (!res.ok) {
log.error({ status: res.status, url }, 'odesli API error');
@@ -97,6 +97,9 @@ async function finalizeMusicClip(
durationSeconds: result.duration
})
.where(eq(clips.id, clipId));
+ await notifyNewClip(clipId).catch((err) =>
+ log.error({ err, clipId }, 'push notification failed')
+ );
return;
}
@@ -133,6 +136,9 @@ async function downloadMusicInner(clipId: string, url: string): Promise {
.update(clips)
.set({ status: 'failed', title: 'No download provider configured' })
.where(eq(clips.id, clipId));
+ await notifyNewClip(clipId).catch((err) =>
+ log.error({ err, clipId }, 'push notification failed')
+ );
return;
}
@@ -187,11 +193,17 @@ async function downloadMusicInner(clipId: string, url: string): Promise {
// Failed to download audio, but metadata + platform links are still visible
await cleanupClipFiles(clipId);
await db.update(clips).set({ status: 'failed' }).where(eq(clips.id, clipId));
+ await notifyNewClip(clipId).catch((err) =>
+ log.error({ err, clipId }, 'push notification failed')
+ );
}
} catch (err) {
log.error({ err, clipId }, 'music download failed');
await cleanupClipFiles(clipId);
await db.update(clips).set({ status: 'failed' }).where(eq(clips.id, clipId));
+ await notifyNewClip(clipId).catch((err2) =>
+ log.error({ err: err2, clipId }, 'push notification failed')
+ );
}
}
diff --git a/src/lib/server/push.ts b/src/lib/server/push.ts
index 3cd8ec0..bb24870 100644
--- a/src/lib/server/push.ts
+++ b/src/lib/server/push.ts
@@ -51,8 +51,11 @@ export async function sendNotification(
payloadStr
);
} catch (err: unknown) {
- const pushErr = err as { statusCode?: number };
- if (pushErr.statusCode === 410 || pushErr.statusCode === 404) {
+ const statusCode =
+ typeof err === 'object' && err !== null && 'statusCode' in err
+ ? (err as { statusCode: number }).statusCode
+ : undefined;
+ if (statusCode === 410 || statusCode === 404) {
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id));
} else {
log.error({ err, subscriptionId: sub.id }, 'push failed for subscription');
@@ -63,7 +66,7 @@ export async function sendNotification(
}
/**
- * Send push notification to the group after a clip download succeeds.
+ * Send push notification to the group after a clip is published (ready or failed).
* Called from the download pipeline — NOT from the API endpoint.
*/
export async function notifyNewClip(clipId: string): Promise {
@@ -82,9 +85,9 @@ export async function notifyNewClip(clipId: string): Promise {
await sendGroupNotification(
clip.groupId,
{
- title: 'New clip added',
- body: `${uploader.username} shared a new ${label}`,
- url: '/',
+ title: `${uploader.username} added a ${label}`,
+ body: clip.title || 'Tap to watch',
+ url: `/?clip=${clipId}`,
tag: 'new-clip'
},
'newAdds',
diff --git a/src/lib/server/scheduler.ts b/src/lib/server/scheduler.ts
index 49ba784..cbd824e 100644
--- a/src/lib/server/scheduler.ts
+++ b/src/lib/server/scheduler.ts
@@ -18,6 +18,13 @@ let lastBackupDate: string | null = null;
const CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour
const REMINDER_HOUR = 9; // 9 AM server time
const BACKUP_HOUR = 2; // 2 AM server time
+const REMINDER_BODIES = [
+ 'Your group has been sharing — catch up!',
+ 'New stuff from your crew awaits.',
+ 'See what your friends have been posting.',
+ "You're falling behind — time to scroll.",
+ 'Fresh clips in the feed. Take a look!'
+];
export function startScheduler(): void {
checkAndSendReminders();
@@ -96,7 +103,7 @@ async function sendDailyReminders(): Promise {
and(
eq(clips.groupId, user.groupId),
eq(clips.status, 'ready'),
- sql`${clips.id} NOT IN (SELECT ${watched.clipId} FROM ${watched} WHERE ${watched.userId} = ${user.id})`
+ sql`NOT EXISTS (SELECT 1 FROM ${watched} WHERE ${watched.clipId} = ${clips.id} AND ${watched.userId} = ${user.id})`
)
);
@@ -105,7 +112,7 @@ async function sendDailyReminders(): Promise {
await sendNotification(user.id, {
title: `${unwatchedCount} unwatched ${unwatchedCount === 1 ? 'clip' : 'clips'}`,
- body: 'Check out what your group has been sharing!',
+ body: REMINDER_BODIES[Math.floor(Math.random() * REMINDER_BODIES.length)],
url: '/',
tag: 'daily-reminder'
});
diff --git a/src/lib/server/video/download.ts b/src/lib/server/video/download.ts
index 0cedaa2..fbd03af 100644
--- a/src/lib/server/video/download.ts
+++ b/src/lib/server/video/download.ts
@@ -31,12 +31,20 @@ async function handleDownloadError(
.update(clips)
.set({ status: 'failed', title: `Exceeds ${sizeMb} MB limit` })
.where(eq(clips.id, clipId));
+ // Still notify — clip is viewable via external link
+ await notifyNewClip(clipId).catch((err2) =>
+ log.error({ err: err2, clipId }, 'push notification failed')
+ );
return;
}
log.error({ err, clipId }, 'download failed');
await cleanupClipFiles(clipId);
await db.update(clips).set({ status: 'failed' }).where(eq(clips.id, clipId));
+ // Still notify — clip is viewable via external link
+ await notifyNewClip(clipId).catch((err2) =>
+ log.error({ err: err2, clipId }, 'push notification failed')
+ );
}
async function downloadVideoInner(clipId: string, url: string): Promise {
@@ -51,6 +59,10 @@ async function downloadVideoInner(clipId: string, url: string): Promise {
.update(clips)
.set({ status: 'failed', title: 'No download provider configured' })
.where(eq(clips.id, clipId));
+ // Still notify — clip is viewable via external link
+ await notifyNewClip(clipId).catch((err) =>
+ log.error({ err, clipId }, 'push notification failed')
+ );
return;
}
@@ -75,6 +87,10 @@ async function downloadVideoInner(clipId: string, url: string): Promise {
durationSeconds: result.duration
})
.where(eq(clips.id, clipId));
+ // Still notify — clip is viewable via external link
+ await notifyNewClip(clipId).catch((err) =>
+ log.error({ err, clipId }, 'push notification failed')
+ );
return;
}
diff --git a/src/lib/settingsApi.ts b/src/lib/settingsApi.ts
index bfa2aef..bcc57ce 100644
--- a/src/lib/settingsApi.ts
+++ b/src/lib/settingsApi.ts
@@ -1,4 +1,5 @@
import { ACCENT_COLORS, type AccentColorKey } from '$lib/colors';
+import { updateFavicon } from '$lib/iconSvg';
export type NotificationPrefs = {
newAdds: boolean;
@@ -8,6 +9,20 @@ export type NotificationPrefs = {
dailyReminder: boolean;
};
+// --- Helpers ---
+
+async function savePreference(data: Record): Promise {
+ try {
+ await fetch('/api/profile/preferences', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ } catch (err) {
+ console.warn('failed to save preference', err);
+ }
+}
+
// --- Theme ---
export function applyTheme(value: 'system' | 'light' | 'dark'): void {
@@ -20,29 +35,33 @@ export function applyTheme(value: 'system' | 'light' | 'dark'): void {
}
export async function saveThemePreference(value: 'system' | 'light' | 'dark'): Promise {
- await fetch('/api/profile/preferences', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ themePreference: value })
- });
+ await savePreference({ themePreference: value });
+}
+
+// --- Username ---
+
+export async function saveUsername(username: string): Promise<{ username: string } | null> {
+ try {
+ const res = await fetch('/api/profile/preferences', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username })
+ });
+ if (res.ok) return res.json();
+ } catch (err) {
+ console.warn('failed to save username', err);
+ }
+ return null;
}
// --- Playback ---
export async function saveAutoScroll(value: boolean): Promise {
- await fetch('/api/profile/preferences', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ autoScroll: value })
- });
+ await savePreference({ autoScroll: value });
}
export async function saveMutedByDefault(value: boolean): Promise {
- await fetch('/api/profile/preferences', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ mutedByDefault: value })
- });
+ await savePreference({ mutedByDefault: value });
}
// --- Accent color ---
@@ -52,28 +71,44 @@ export function applyAccentColor(key: AccentColorKey): void {
document.documentElement.style.setProperty('--accent-primary', color.hex);
document.documentElement.style.setProperty('--accent-primary-dark', color.dark);
document.cookie = `scrolly_accent=${encodeURIComponent(JSON.stringify({ hex: color.hex, dark: color.dark }))};path=/;max-age=31536000;SameSite=Lax`;
+ updateFavicon(color.hex);
}
export async function saveAccentColor(key: AccentColorKey): Promise {
- await fetch('/api/group/accent', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ accentColor: key })
- });
+ try {
+ await fetch('/api/group/accent', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ accentColor: key })
+ });
+ } catch (err) {
+ console.warn('failed to save accent color', err);
+ }
}
// --- Notification preferences ---
export async function fetchNotificationPrefs(): Promise {
- const res = await fetch('/api/notifications/preferences');
- if (res.ok) return res.json();
+ try {
+ const res = await fetch('/api/notifications/preferences');
+ if (res.ok) return res.json();
+ } catch (err) {
+ console.warn('failed to fetch notification prefs', err);
+ }
return { newAdds: true, reactions: true, comments: true, mentions: true, dailyReminder: false };
}
-export async function updateNotificationPref(key: string, value: boolean): Promise {
- await fetch('/api/notifications/preferences', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ [key]: value })
- });
+export async function updateNotificationPref(
+ key: keyof NotificationPrefs,
+ value: boolean
+): Promise {
+ try {
+ await fetch('/api/notifications/preferences', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ [key]: value })
+ });
+ } catch (err) {
+ console.warn('failed to update notification pref', err);
+ }
}
diff --git a/src/lib/stores/toasts.ts b/src/lib/stores/toasts.ts
index 5e62a29..965bad1 100644
--- a/src/lib/stores/toasts.ts
+++ b/src/lib/stores/toasts.ts
@@ -15,6 +15,7 @@ export interface Toast {
export const toasts = writable([]);
export const clipReadySignal = writable(null);
export const viewClipSignal = writable(null);
+export const openCommentsSignal = writable(null);
let toastCounter = 0;
diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts
index 4eea6e8..e614671 100644
--- a/src/routes/(app)/+layout.server.ts
+++ b/src/routes/(app)/+layout.server.ts
@@ -2,12 +2,13 @@ import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
-export const load: LayoutServerLoad = async ({ locals }) => {
+export const load: LayoutServerLoad = async ({ locals, url }) => {
+ const returnTo = url.search ? url.pathname + url.search : '';
if (!locals.user) {
- redirect(302, '/join');
+ redirect(302, returnTo ? `/join?returnTo=${encodeURIComponent(returnTo)}` : '/join');
}
if (!locals.user.username) {
- redirect(302, '/onboard');
+ redirect(302, returnTo ? `/onboard?returnTo=${encodeURIComponent(returnTo)}` : '/onboard');
}
return {
user: locals.user,
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 89bf61a..72d05d9 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -28,6 +28,9 @@
onMount(() => {
startPolling();
fetchGroupMembers();
+
+ // Tell fixed-position components (e.g. InstallBanner) about the bottom nav height
+ document.documentElement.style.setProperty('--bottom-nav-height', '80px');
// Initialize mute state from user preference
const user = page.data?.user;
if (user) {
@@ -56,6 +59,7 @@
stopPolling();
themeObserver.disconnect();
darkMq.removeEventListener('change', syncThemeColor);
+ document.documentElement.style.removeProperty('--bottom-nav-height');
};
});
@@ -306,7 +310,7 @@
}
.overlay-mode .tab {
- color: rgba(255, 255, 255, 0.5);
+ color: var(--reel-text-subtle);
}
.tab.active {
diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte
index f400db2..023d588 100644
--- a/src/routes/(app)/+page.svelte
+++ b/src/routes/(app)/+page.svelte
@@ -8,11 +8,17 @@
import ArrowDownIcon from 'phosphor-svelte/lib/ArrowDownIcon';
import FilmSlateIcon from 'phosphor-svelte/lib/FilmSlateIcon';
import { addVideoModalOpen } from '$lib/stores/addVideoModal';
- import { addToast, toast, clipReadySignal, viewClipSignal } from '$lib/stores/toasts';
+ import {
+ addToast,
+ toast,
+ clipReadySignal,
+ viewClipSignal,
+ openCommentsSignal
+ } from '$lib/stores/toasts';
import { homeTapSignal } from '$lib/stores/homeTap';
import { feedUiHidden } from '$lib/stores/uiHidden';
import { onMount, onDestroy } from 'svelte';
- import { page } from '$app/stores';
+ import { page } from '$app/state';
import type { FeedClip } from '$lib/types';
import type { FeedFilter } from '$lib/feed';
import {
@@ -63,9 +69,9 @@
swipeX !== 0 && typeof window !== 'undefined' ? -swipeX / window.innerWidth : 0
);
- const currentUserId = $derived($page.data.user?.id ?? '');
- const autoScroll = $derived($page.data.user?.autoScroll ?? false);
- const gifEnabled = $derived(!!$page.data.gifEnabled);
+ const currentUserId = $derived(page.data.user?.id ?? '');
+ const autoScroll = $derived(page.data.user?.autoScroll ?? false);
+ const gifEnabled = $derived(!!page.data.gifEnabled);
async function loadInitialClips() {
loading = true;
@@ -395,7 +401,7 @@
if (!targetClipId) return;
viewClipSignal.set(null);
(async () => {
- filter = 'unwatched';
+ filter = 'all' as FeedFilter;
currentOffset = 0;
hasMore = true;
const data = await fetchClips('all', PAGE_SIZE);
@@ -506,6 +512,21 @@
}
})();
}
+
+ // Deep-link: jump to a specific clip (and optionally open comments)
+ const params = new URLSearchParams(window.location.search);
+ const deepClipId = params.get('clip');
+ const deepComments = params.get('comments') === 'true';
+ if (deepClipId) {
+ viewClipSignal.set(deepClipId);
+ if (deepComments) openCommentsSignal.set(deepClipId);
+ // Clean URL without triggering navigation
+ const clean = new URL(window.location.href);
+ clean.searchParams.delete('clip');
+ clean.searchParams.delete('comments');
+ history.replaceState(null, '', clean.pathname + clean.search || '/');
+ }
+
loadInitialClips();
});
@@ -650,7 +671,7 @@
}
.pull-arrow {
display: inline-flex;
- color: rgba(255, 255, 255, 0.7);
+ color: var(--reel-text-dim);
transform: rotate(180deg);
transition:
transform 0.2s ease,
@@ -664,7 +685,7 @@
display: inline-block;
width: 22px;
height: 22px;
- border: 2.5px solid rgba(255, 255, 255, 0.2);
+ border: 2.5px solid var(--reel-spinner-track);
border-top-color: var(--accent-primary);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
@@ -784,7 +805,7 @@
position: fixed;
inset: 0;
z-index: 90;
- background: rgba(0, 0, 0, 0.75);
+ background: var(--reel-gradient-heavy);
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte
index c19f815..b46321d 100644
--- a/src/routes/(app)/settings/+page.svelte
+++ b/src/routes/(app)/settings/+page.svelte
@@ -1,7 +1,7 @@
-
-
-
-
{@render children()}
diff --git a/src/routes/api/auth/+server.ts b/src/routes/api/auth/+server.ts
index 6da0b0c..9be5936 100644
--- a/src/routes/api/auth/+server.ts
+++ b/src/routes/api/auth/+server.ts
@@ -13,6 +13,7 @@ import { eq, and, desc } from 'drizzle-orm';
import { sendVerification, checkVerification } from '$lib/server/sms/verify';
import { dev } from '$app/environment';
import { createLogger } from '$lib/server/logger';
+import { checkRateLimit, rateLimitResponse } from '$lib/server/rate-limit';
const log = createLogger('auth');
@@ -224,12 +225,20 @@ async function handleLoginVerifyCode(body: Record) {
}
// POST /api/auth - Login, join, send/verify phone code, or onboard
-export const POST: RequestHandler = async ({ request }) => {
+export const POST: RequestHandler = async ({ request, getClientAddress }) => {
const body = await request.json();
const { action } = body;
// Unauthenticated actions
- if (action === 'join') return handleJoin(body);
+ if (action === 'join') {
+ const ip = getClientAddress();
+ const result = checkRateLimit(`join:${ip}`, { windowMs: 15 * 60 * 1000, maxRequests: 5 });
+ if (!result.allowed) {
+ log.warn({ ip }, 'join rate limit exceeded');
+ return rateLimitResponse(result.resetAt);
+ }
+ return handleJoin(body);
+ }
if (action === 'login-send-code') return handleLoginSendCode(body);
if (action === 'login-verify-code') return handleLoginVerifyCode(body);
diff --git a/src/routes/api/clips/[id]/comments/+server.ts b/src/routes/api/clips/[id]/comments/+server.ts
index e4a9792..3984114 100644
--- a/src/routes/api/clips/[id]/comments/+server.ts
+++ b/src/routes/api/clips/[id]/comments/+server.ts
@@ -107,8 +107,8 @@ async function dispatchCommentNotification(
clipId,
type: 'reply',
preferenceKey: 'comments',
- pushTitle: 'New reply',
- pushBody: `${actor.username} replied: ${preview}`,
+ pushTitle: `${actor.username} replied to you`,
+ pushBody: preview,
pushTag: `reply-${clipId}`,
commentPreview: preview
});
@@ -124,8 +124,8 @@ async function dispatchCommentNotification(
clipId,
type: 'comment',
preferenceKey: 'comments',
- pushTitle: 'New comment',
- pushBody: `${actor.username}: ${preview}`,
+ pushTitle: `${actor.username} commented on your clip`,
+ pushBody: preview,
pushTag: `comment-${clipId}`,
commentPreview: preview
});
diff --git a/src/routes/api/clips/[id]/reactions/+server.ts b/src/routes/api/clips/[id]/reactions/+server.ts
index 11642ea..b3985ae 100644
--- a/src/routes/api/clips/[id]/reactions/+server.ts
+++ b/src/routes/api/clips/[id]/reactions/+server.ts
@@ -65,8 +65,8 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u
clipId,
type: 'reaction',
preferenceKey: 'reactions',
- pushTitle: 'New reaction',
- pushBody: `${user.username} reacted ${emoji} to your clip`,
+ pushTitle: `${user.username} reacted ${emoji}`,
+ pushBody: 'on your clip',
pushTag: `reaction-${clipId}`,
emoji
});
diff --git a/src/routes/api/profile/preferences/+server.ts b/src/routes/api/profile/preferences/+server.ts
index ca8b6f1..99f6f54 100644
--- a/src/routes/api/profile/preferences/+server.ts
+++ b/src/routes/api/profile/preferences/+server.ts
@@ -5,30 +5,38 @@ import { users } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { withAuth, parseBody, isResponse } from '$lib/server/api-utils';
+function validateUsername(body: Record): { value: string } | { error: string } {
+ const username = typeof body.username === 'string' ? body.username.trim() : '';
+ if (!username || username.length > 30) return { error: 'Username must be 1–30 characters' };
+ return { value: username };
+}
+
+const VALID_THEMES = ['system', 'light', 'dark'];
+
export const POST: RequestHandler = withAuth(async ({ request }, { user }) => {
const body = await parseBody>(request);
if (isResponse(body)) return body;
const updates: Record = {};
+ if ('username' in body) {
+ const result = validateUsername(body);
+ if ('error' in result) return json({ error: result.error }, { status: 400 });
+ updates.username = result.value;
+ }
+
if ('themePreference' in body) {
- if (!['system', 'light', 'dark'].includes(body.themePreference as string)) {
+ if (!VALID_THEMES.includes(body.themePreference as string)) {
return json({ error: 'Invalid theme preference' }, { status: 400 });
}
updates.themePreference = body.themePreference;
}
- if ('autoScroll' in body) {
- if (typeof body.autoScroll !== 'boolean') {
- return json({ error: 'Invalid autoScroll value' }, { status: 400 });
- }
+ if ('autoScroll' in body && typeof body.autoScroll === 'boolean') {
updates.autoScroll = body.autoScroll;
}
- if ('mutedByDefault' in body) {
- if (typeof body.mutedByDefault !== 'boolean') {
- return json({ error: 'Invalid mutedByDefault value' }, { status: 400 });
- }
+ if ('mutedByDefault' in body && typeof body.mutedByDefault === 'boolean') {
updates.mutedByDefault = body.mutedByDefault;
}
diff --git a/src/routes/icon/[name]/+server.ts b/src/routes/icon/[name]/+server.ts
new file mode 100644
index 0000000..135628a
--- /dev/null
+++ b/src/routes/icon/[name]/+server.ts
@@ -0,0 +1,30 @@
+import type { RequestHandler } from './$types';
+import { generateIconSvg, resolveAccentHex } from '$lib/iconSvg';
+
+const ICON_CONFIG: Record = {
+ 'favicon.svg': {},
+ 'icon-192.svg': {},
+ 'icon-512.svg': {},
+ 'icon-maskable-192.svg': { maskable: true },
+ 'icon-maskable-512.svg': { maskable: true },
+ 'apple-touch-icon.svg': {},
+ 'badge-72.svg': { monochrome: true }
+};
+
+export const GET: RequestHandler = ({ params, locals }) => {
+ const config = ICON_CONFIG[params.name];
+ if (!config) {
+ return new Response('Not found', { status: 404 });
+ }
+
+ const accentHex = resolveAccentHex(locals.group?.accentColor);
+ const svg = generateIconSvg(accentHex, config);
+
+ return new Response(svg, {
+ headers: {
+ 'Content-Type': 'image/svg+xml',
+ 'Cache-Control': 'private, no-cache',
+ Vary: 'Cookie'
+ }
+ });
+};
diff --git a/src/routes/join/+page.svelte b/src/routes/join/+page.svelte
index 1d96c6f..c0a211b 100644
--- a/src/routes/join/+page.svelte
+++ b/src/routes/join/+page.svelte
@@ -4,6 +4,7 @@
import iconUrl from '$lib/assets/icon.svg?url';
import InlineError from '$lib/components/InlineError.svelte';
import ArrowRightIcon from 'phosphor-svelte/lib/ArrowRightIcon';
+ import { rawDigits, formatPhone, toE164 } from '$lib/phone';
let view = $state<'login' | 'verify'>('login');
let phoneDisplay = $state('');
@@ -21,25 +22,6 @@
};
});
- // Extract raw digits from formatted display
- function rawDigits(formatted: string): string {
- return formatted.replace(/\D/g, '');
- }
-
- // Format digits as (XXX) XXX-XXXX for display
- function formatPhone(digits: string): string {
- if (digits.length === 0) return '';
- if (digits.length <= 3) return `(${digits}`;
- if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
- return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
- }
-
- // Convert display format to E.164 for Twilio
- function toE164(formatted: string): string {
- const digits = rawDigits(formatted);
- return `+1${digits}`;
- }
-
function handlePhoneInput(e: Event) {
const input = e.target as HTMLInputElement;
const digits = rawDigits(input.value).slice(0, 10);
@@ -113,7 +95,8 @@
}
const params = new URLSearchParams(window.location.search);
const returnTo = params.get('returnTo');
- window.location.href = returnTo && returnTo.startsWith('/') ? returnTo : '/';
+ window.location.href =
+ returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//') ? returnTo : '/';
} catch {
error = 'Something went wrong';
} finally {
@@ -152,6 +135,17 @@
const input = e.target as HTMLInputElement;
const value = input.value.replace(/\D/g, '');
+ if (value.length > 1) {
+ // Multi-digit paste via input event (common on Android IME)
+ for (let i = 0; i < 6; i++) {
+ codeDigits[i] = value[i] || '';
+ }
+ code = codeDigits.join('');
+ const nextEmpty = codeDigits.findIndex((d) => d === '');
+ codeInputs[nextEmpty === -1 ? 5 : nextEmpty]?.focus();
+ return;
+ }
+
if (value.length > 0) {
codeDigits[index] = value[0];
// Auto-advance to next input
@@ -192,6 +186,12 @@
scrolly — Sign In
+
+
+
@@ -269,7 +269,7 @@
handleVerifyLogin();
}}
>
-
+
{#each codeDigits as digit, i (i)}
handleCodeInput(i, e)}
onkeydown={(e) => handleCodeKeydown(i, e)}
+ onpaste={handleCodePaste}
disabled={loading}
class="code-box"
class:filled={digit !== ''}
@@ -337,6 +338,7 @@
justify-content: center;
min-height: 100dvh;
padding: var(--space-xl);
+ padding-bottom: calc(var(--space-xl) + 72px);
background: var(--bg-primary);
overflow: hidden;
}
diff --git a/src/routes/manifest.json/+server.ts b/src/routes/manifest.json/+server.ts
new file mode 100644
index 0000000..1d3e3a3
--- /dev/null
+++ b/src/routes/manifest.json/+server.ts
@@ -0,0 +1,93 @@
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = () => {
+ const manifest = {
+ name: 'scrolly',
+ short_name: 'scrolly',
+ description: 'Private video sharing for your friend group',
+ id: '/',
+ start_url: '/',
+ scope: '/',
+ display: 'standalone',
+ orientation: 'portrait',
+ lang: 'en',
+ categories: ['social', 'entertainment'],
+ background_color: '#000000',
+ theme_color: '#000000',
+ icons: [
+ {
+ src: '/icon/icon-192.svg',
+ sizes: 'any',
+ type: 'image/svg+xml',
+ purpose: 'any'
+ },
+ {
+ src: '/icon/icon-512.svg',
+ sizes: 'any',
+ type: 'image/svg+xml',
+ purpose: 'any'
+ },
+ {
+ src: '/icon/icon-maskable-192.svg',
+ sizes: 'any',
+ type: 'image/svg+xml',
+ purpose: 'maskable'
+ },
+ {
+ src: '/icon/icon-maskable-512.svg',
+ sizes: 'any',
+ type: 'image/svg+xml',
+ purpose: 'maskable'
+ },
+ // PNG fallbacks for platforms that don't support SVG manifest icons
+ {
+ src: '/icons/icon-192.png',
+ sizes: '192x192',
+ type: 'image/png',
+ purpose: 'any'
+ },
+ {
+ src: '/icons/icon-512.png',
+ sizes: '512x512',
+ type: 'image/png',
+ purpose: 'any'
+ },
+ {
+ src: '/icons/icon-maskable-192.png',
+ sizes: '192x192',
+ type: 'image/png',
+ purpose: 'maskable'
+ },
+ {
+ src: '/icons/icon-maskable-512.png',
+ sizes: '512x512',
+ type: 'image/png',
+ purpose: 'maskable'
+ },
+ {
+ src: '/icon/badge-72.svg',
+ sizes: 'any',
+ type: 'image/svg+xml',
+ purpose: 'monochrome'
+ }
+ ],
+ share_target: {
+ action: '/share',
+ method: 'GET',
+ enctype: 'application/x-www-form-urlencoded',
+ params: {
+ url: 'url',
+ text: 'text',
+ title: 'title'
+ }
+ }
+ };
+
+ return new Response(JSON.stringify(manifest, null, '\t'), {
+ headers: {
+ 'Content-Type': 'application/manifest+json',
+ 'Cache-Control': 'private, no-cache',
+ Vary: 'Cookie'
+ }
+ });
+};
diff --git a/src/routes/onboard/+page.svelte b/src/routes/onboard/+page.svelte
index 3def2c1..89b5d1a 100644
--- a/src/routes/onboard/+page.svelte
+++ b/src/routes/onboard/+page.svelte
@@ -1,16 +1,48 @@