diff --git a/src/lib/components/ActivitySheet.svelte b/src/lib/components/ActivitySheet.svelte index 8f9e3e1..c645620 100644 --- a/src/lib/components/ActivitySheet.svelte +++ b/src/lib/components/ActivitySheet.svelte @@ -8,15 +8,18 @@ import { clipOverlaySignal, openCommentsSignal } from '$lib/stores/toasts'; import { createSafeTimeout } from '$lib/safeTimeout'; import BellIcon from 'phosphor-svelte/lib/BellIcon'; + import XIcon from 'phosphor-svelte/lib/XIcon'; + import { fetchUnwatchedCount } from '$lib/stores/notifications'; const { ondismiss }: { ondismiss: () => void } = $props(); interface Notification { id: string; - type: 'reaction' | 'comment' | 'reply' | 'mention'; + type: 'reaction' | 'comment' | 'reply' | 'mention' | 'new_clip'; clipId: string; emoji: string | null; commentPreview: string | null; + clipContentType: string; actorUsername: string; actorAvatar: string | null; clipThumbnail: string | null; @@ -129,13 +132,17 @@ safeTimeout(() => { ondismiss(); clipOverlaySignal.set(n.clipId); - if (n.type !== 'reaction') { + if (n.type !== 'reaction' && n.type !== 'new_clip') { openCommentsSignal.set(n.clipId); } }, 300); } function description(n: Notification): string { + if (n.type === 'new_clip') { + const label = n.clipContentType === 'music' ? 'a song' : 'a video'; + return `added ${label}`; + } if (n.type === 'reaction') { return `reacted ${n.emoji} to your clip`; } @@ -148,6 +155,15 @@ return 'commented on your clip'; } + async function dismissNotification(e: Event, n: Notification) { + e.preventDefault(); + e.stopPropagation(); + items = items.filter((item) => item.id !== n.id); + await fetch(`/api/notifications/${n.id}`, { method: 'DELETE' }); + fetchUnreadCount(); + fetchUnwatchedCount(); + } + onDestroy(clearAll); @@ -165,7 +181,7 @@

No activity yet

-

Reactions and comments on your clips will show up here

+

New clips, reactions, and comments will show up here

{:else} {#each grouped as section (section.label)} @@ -204,6 +220,13 @@ {/if} + {/each} @@ -425,6 +448,31 @@ object-fit: cover; } + .dismiss-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: transparent; + color: var(--text-muted); + border-radius: var(--radius-full); + cursor: pointer; + flex-shrink: 0; + padding: 0; + transition: all 0.15s ease; + } + + .dismiss-btn:hover { + background: var(--bg-surface); + color: var(--text-secondary); + } + + .dismiss-btn:active { + transform: scale(0.9); + } + .spinner { display: inline-block; width: 32px; diff --git a/src/lib/components/ClipOverlay.svelte b/src/lib/components/ClipOverlay.svelte index 9517a09..a36542e 100644 --- a/src/lib/components/ClipOverlay.svelte +++ b/src/lib/components/ClipOverlay.svelte @@ -20,6 +20,7 @@ const { clipId, currentUserId, + isHost = false, autoScroll, gifEnabled = false, openComments = false, @@ -27,6 +28,7 @@ }: { clipId: string; currentUserId: string; + isHost?: boolean; autoScroll: boolean; gifEnabled?: boolean; openComments?: boolean; @@ -266,6 +268,7 @@ | null>(null); - let inputWrapperHeight = $state(0); const canSubmit = $derived(text.trim().length > 0 || !!attachedGif); - // Switch from centered to bottom-anchored once the input grows past 1 line. - // Single-line height is ~36px; threshold of 50px gives plenty of buffer. - const isMultiLine = $derived(inputWrapperHeight > 50); export function focus() { mentionInputRef?.focus(); @@ -91,12 +87,7 @@ {/if}
-
+
(undefined); + const localCaption = $derived(captionOverride !== undefined ? captionOverride : clip.title); + let captionExpanded = $state(false); const isOwn = $derived(clip.addedBy === currentUserId); const reactedEmoji = $derived( Object.entries(clip.reactions).find(([, v]) => v.reacted)?.[0] ?? null @@ -174,6 +179,7 @@ if (!active) { // Reel is no longer visible — cancel everything pendingAutoScroll = false; + captionExpanded = false; if (postEngagementTimer) { clearTimeout(postEngagementTimer); postEngagementTimer = null; @@ -199,20 +205,6 @@ } }); - function checkPillOverlap() { - if (!pillEl) return; - const filterBar = document.querySelector('.filter-tabs'); - if (!filterBar) return; - const pillRect = pillEl.getBoundingClientRect(); - const barRect = filterBar.getBoundingClientRect(); - const overlaps = - pillRect.right > barRect.left && - pillRect.left < barRect.right && - pillRect.bottom > barRect.top && - pillRect.top < barRect.bottom; - filterBarDimmed.set(overlaps); - } - // Contributor pill: expand when a different contributor's clip becomes active $effect(() => { if (!active) { @@ -229,8 +221,7 @@ lastActiveContributor = contributor; pillTimer = setTimeout(() => { pillExpanded = true; - // Check overlap after transition completes - setTimeout(checkPillOverlap, 1050); + filterBarDimmed.set(true); pillTimer = setTimeout(() => { pillExpanded = false; filterBarDimmed.set(false); @@ -525,6 +516,7 @@ pillTimer = null; } pillExpanded = !pillExpanded; + filterBarDimmed.set(pillExpanded); }} />
@@ -588,14 +580,31 @@ /> {/if} + +
(captionExpanded = false)} + onpointerdown={(e) => e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} + >
+ { + captionOverride = newCaption; + }} {ondelete} {uiHidden} /> @@ -699,13 +708,13 @@ } .top-left-row { position: absolute; - top: max(var(--space-md), env(safe-area-inset-top)); + top: calc(max(var(--space-md), env(safe-area-inset-top)) - 1px); left: var(--space-lg); z-index: 6; display: flex; align-items: center; gap: var(--space-sm); - min-height: 40px; + height: 34px; transition: opacity 0.3s ease; } .top-left-row.ui-hidden { @@ -714,18 +723,31 @@ } .top-right-row { position: absolute; - top: max(var(--space-md), env(safe-area-inset-top)); + top: calc(max(var(--space-md), env(safe-area-inset-top)) - 1px); right: calc(var(--space-sm) + 44px); z-index: 6; display: flex; align-items: center; - min-height: 44px; + height: 34px; transition: opacity 0.3s ease; } .top-right-row.ui-hidden { opacity: 0; pointer-events: none; } + .caption-backdrop { + position: absolute; + inset: 0; + z-index: 11; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + } + .caption-backdrop.visible { + opacity: 1; + pointer-events: auto; + } .bottom-row { position: absolute; bottom: 14px; diff --git a/src/lib/components/ReelOverlay.svelte b/src/lib/components/ReelOverlay.svelte index 6427433..5c37689 100644 --- a/src/lib/components/ReelOverlay.svelte +++ b/src/lib/components/ReelOverlay.svelte @@ -2,16 +2,23 @@ import PlatformIcon from './PlatformIcon.svelte'; import ReelOverlayActions from './ReelOverlayActions.svelte'; import TrashIcon from 'phosphor-svelte/lib/TrashIcon'; + import ArrowsClockwiseIcon from 'phosphor-svelte/lib/ArrowsClockwiseIcon'; + import PencilSimpleIcon from 'phosphor-svelte/lib/PencilSimpleIcon'; + import { toast } from '$lib/stores/toasts'; - const { + let { platform, creatorName = null, creatorUrl = null, contentType = 'video', caption, canDelete = false, + canRefetch = false, + canEditCaption = false, clipId = '', uiHidden = false, + expanded = $bindable(false), + oncaptionedit, ondelete }: { platform: string; @@ -20,13 +27,36 @@ contentType?: string; caption: string | null; canDelete?: boolean; + canRefetch?: boolean; + canEditCaption?: boolean; clipId?: string; uiHidden?: boolean; + expanded?: boolean; + oncaptionedit?: (clipId: string, newCaption: string | null) => void; ondelete?: (clipId: string) => void; } = $props(); - - let expanded = $state(false); let confirmingDelete = $state(false); + let editing = $state(false); + let refetching = $state(false); + + async function handleRefetch() { + if (refetching) return; + refetching = true; + try { + const res = await fetch(`/api/clips/${clipId}/refetch`, { method: 'POST' }); + if (res.ok) { + toast.success('Caption refreshed'); + // Reload the page to show updated caption + window.location.reload(); + } else { + const data = await res.json().catch(() => null); + toast.error(data?.error ?? 'Failed to refresh caption'); + } + } catch { + toast.error('Failed to refresh caption'); + } + refetching = false; + } @@ -34,6 +64,7 @@
e.stopPropagation()} ontouchstart={(e) => e.stopPropagation()} ontouchmove={(e) => e.stopPropagation()} @@ -56,20 +87,42 @@ {/if} {/if} - {#if canDelete && !confirmingDelete} + {#if (canDelete || canRefetch || canEditCaption) && !confirmingDelete && !editing} e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} > - + {#if canEditCaption} + + {/if} + {#if canRefetch} + + {/if} + {#if canDelete} + + {/if} {/if}
@@ -78,9 +131,12 @@ {clipId} {caption} {canDelete} + {canEditCaption} {expanded} onexpandtoggle={() => (expanded = !expanded)} + {oncaptionedit} {ondelete} + bind:editing bind:confirmingDelete />
@@ -99,6 +155,9 @@ opacity: 0; pointer-events: none; } + .reel-overlay.caption-expanded { + z-index: 12; + } .overlay-content { margin-right: 64px; } @@ -164,4 +223,17 @@ .host-icon-btn.delete:active { color: var(--error); } + .host-icon-btn.refetching { + animation: spin 0.8s linear infinite; + opacity: 0.6; + pointer-events: none; + } + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } diff --git a/src/lib/components/ReelOverlayActions.svelte b/src/lib/components/ReelOverlayActions.svelte index 3ec9da4..c5ac0c8 100644 --- a/src/lib/components/ReelOverlayActions.svelte +++ b/src/lib/components/ReelOverlayActions.svelte @@ -5,21 +5,78 @@ clipId, caption, canDelete, + canEditCaption = false, expanded, onexpandtoggle, + oncaptionedit, ondelete, + editing = $bindable(false), confirmingDelete = $bindable(false) }: { clipId: string; caption: string | null; canDelete: boolean; + canEditCaption?: boolean; expanded: boolean; onexpandtoggle: () => void; + oncaptionedit?: (clipId: string, newCaption: string | null) => void; ondelete?: (clipId: string) => void; + editing?: boolean; confirmingDelete?: boolean; } = $props(); + let editValue = $state(''); + let saving = $state(false); let deleting = $state(false); + let inputEl: HTMLTextAreaElement | null = $state(null); + + $effect(() => { + if (editing) { + editValue = caption || ''; + confirmingDelete = false; + requestAnimationFrame(() => { + inputEl?.focus(); + }); + } + }); + + function cancelEdit() { + editing = false; + } + + async function saveEdit() { + if (!editing) return; + editing = false; + const newCaption = editValue.trim(); + if (newCaption === (caption || '').trim()) return; + + saving = true; + try { + const res = await fetch(`/api/clips/${clipId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: newCaption }) + }); + if (res.ok) { + oncaptionedit?.(clipId, newCaption || null); + } else { + toast.error('Failed to save caption'); + } + } catch { + toast.error('Failed to save caption'); + } + saving = false; + } + + function handleKeydown(e: KeyboardEvent) { + e.stopPropagation(); + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + saveEdit(); + } else if (e.key === 'Escape') { + cancelEdit(); + } + } async function handleDelete() { if (deleting) return; @@ -39,18 +96,85 @@ } -{#if caption} +{#if editing} + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > + +
+ + · + +
+
+{:else if caption} + +
{ + if (expanded) e.stopPropagation(); + }} + ontouchmove={(e) => { + if (expanded) e.stopPropagation(); + }} + ontouchend={(e) => { + if (expanded) e.stopPropagation(); + }} + > + {#if expanded} + + +
{ + e.stopPropagation(); + onexpandtoggle(); + }} + > + {caption} +
+ {:else} + + {/if} +
+{:else if canEditCaption}
{/if} @@ -71,10 +195,25 @@ {/if} diff --git a/src/lib/components/settings/ClipsManager.svelte b/src/lib/components/settings/ClipsManager.svelte index 41745cc..bbbafb3 100644 --- a/src/lib/components/settings/ClipsManager.svelte +++ b/src/lib/components/settings/ClipsManager.svelte @@ -114,6 +114,10 @@ } } + function updateCaption(id: string, title: string | null) { + clips = clips.map((c) => (c.id === id ? { ...c, title } : c)); + } + function deleteSingle(clip: ClipSummary) { handleDelete( [clip.id], @@ -202,6 +206,7 @@ selected={selected.has(clip.id)} ontoggle={toggleSelect} ondelete={deleteSingle} + oncaptionupdate={updateCaption} /> {/each} diff --git a/src/lib/gestures.ts b/src/lib/gestures.ts index 579745d..388408a 100644 --- a/src/lib/gestures.ts +++ b/src/lib/gestures.ts @@ -148,12 +148,14 @@ export function onTapHold(element: HTMLElement, handlers: TapHoldHandlers): () = element.addEventListener('pointermove', handlePointerMove); element.addEventListener('pointerup', handlePointerUp); element.addEventListener('pointercancel', handlePointerCancel); + element.addEventListener('pointerleave', handlePointerCancel); return () => { element.removeEventListener('pointerdown', handlePointerDown); element.removeEventListener('pointermove', handlePointerMove); element.removeEventListener('pointerup', handlePointerUp); element.removeEventListener('pointercancel', handlePointerCancel); + element.removeEventListener('pointerleave', handlePointerCancel); cancelHold(); if (tapTimer) clearTimeout(tapTimer); }; diff --git a/src/lib/server/mentions.ts b/src/lib/server/mentions.ts index 2d579eb..706daaf 100644 --- a/src/lib/server/mentions.ts +++ b/src/lib/server/mentions.ts @@ -68,7 +68,7 @@ export async function notifyMentions(opts: { title: `${opts.actorUsername} mentioned you`, body: opts.commentPreview, url: `/?clip=${opts.clipId}&comments=true`, - tag: `mention-${opts.clipId}`, + tag: `mention-${opts.clipId}-${opts.actorId}`, ...(image ? { image } : {}) }).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 d5f5eb1..4250e90 100644 --- a/src/lib/server/music/download.ts +++ b/src/lib/server/music/download.ts @@ -71,7 +71,6 @@ async function resolveOdesli(url: string): Promise { async function finalizeMusicClip( clipId: string, result: AudioDownloadResult, - metadata: MusicMetadata, maxFileSizeBytes: number | null, existingTitle: string | null ): Promise { @@ -104,8 +103,8 @@ async function finalizeMusicClip( return; } - // Keep existing title (caption from SMS or metadata update) if present - const title = existingTitle || metadata.title || null; + // Only keep user-provided caption — don't auto-set from song metadata + const title = existingTitle || null; await db .update(clips) @@ -148,12 +147,11 @@ async function downloadMusicInner(clipId: string, url: string): Promise { const metadata = await resolveOdesli(url); // Step 2: Update clip immediately with metadata (UI can show song info while downloading) - // Preserve user-provided caption if present (e.g. from SMS share) - const resolvedTitle = clip.title || metadata.title; + // Only keep user-provided caption — don't auto-set from song metadata + const resolvedTitle = clip.title || null; await db .update(clips) .set({ - title: resolvedTitle, artist: metadata.artist, albumArt: metadata.albumArt, spotifyUrl: metadata.spotifyUrl, @@ -191,7 +189,7 @@ async function downloadMusicInner(clipId: string, url: string): Promise { } if (result) { - await finalizeMusicClip(clipId, result, metadata, maxFileSizeBytes, resolvedTitle ?? null); + await finalizeMusicClip(clipId, result, maxFileSizeBytes, resolvedTitle ?? null); } else { // Failed to download audio, but metadata + platform links are still visible await cleanupClipFiles(clipId); diff --git a/src/lib/server/providers/ytdlp/index.ts b/src/lib/server/providers/ytdlp/index.ts index b30952c..c5fabea 100644 --- a/src/lib/server/providers/ytdlp/index.ts +++ b/src/lib/server/providers/ytdlp/index.ts @@ -1,5 +1,5 @@ import { spawn } from 'child_process'; -import { readdir, readFile } from 'fs/promises'; +import { readdir, readFile, unlink } from 'fs/promises'; import type { DownloadProvider, VideoDownloadResult, @@ -23,6 +23,24 @@ const AUDIO_BYTES_PER_SEC = 100 * 1024; const MAX_RETRIES = 3; const RETRY_DELAY_MS = 2000; +const MAX_CAPTION_LENGTH = 500; + +/** + * Extract the best caption/description from yt-dlp info.json metadata. + * Prefers `description` (actual post caption on TikTok/IG/etc.) over `title` + * (which is often generic like "Video by @username"). + */ +export function extractCaption(info: Record): string | null { + const desc = typeof info.description === 'string' ? info.description.trim() : ''; + const title = typeof info.title === 'string' ? info.title.trim() : ''; + const fulltitle = typeof info.fulltitle === 'string' ? (info.fulltitle as string).trim() : ''; + const caption = desc || title || fulltitle || null; + if (!caption) return null; + return caption.length > MAX_CAPTION_LENGTH + ? caption.slice(0, MAX_CAPTION_LENGTH).trimEnd() + '…' + : caption; +} + export class YtDlpProvider implements DownloadProvider { readonly id = PROVIDER_ID; readonly name = 'yt-dlp'; @@ -150,7 +168,7 @@ export class YtDlpProvider implements DownloadProvider { try { // eslint-disable-next-line security/detect-non-literal-fs-filename const info = JSON.parse(await readFile(`${outputDir}/${infoFile}`, 'utf-8')); - title = info.title || info.fulltitle || null; + title = extractCaption(info); duration = typeof info.duration === 'number' ? Math.round(info.duration) : null; const rawName = info.uploader || info.channel || info.uploader_id || null; @@ -193,6 +211,64 @@ export class YtDlpProvider implements DownloadProvider { throw lastError ?? new Error('yt-dlp download failed'); } + /** + * Fetch metadata only (no video/audio download). + * Returns title, creator info extracted from yt-dlp's info.json. + */ + async fetchMetadata( + url: string, + outputDir: string, + clipId: string + ): Promise<{ title: string | null; creatorName: string | null; creatorUrl: string | null }> { + const binary = this.getBinaryCommand(); + const outputTemplate = `${outputDir}/${clipId}_meta.%(ext)s`; + const args = [ + '--no-playlist', + '--skip-download', + '--write-info-json', + '--js-runtimes', + 'node', + '-o', + outputTemplate, + url + ]; + + await new Promise((resolve, reject) => { + const proc = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + proc.stderr.on('data', (d: Buffer) => { + stderr += d.toString(); + }); + proc.on('close', (code) => { + if (code !== 0) reject(new Error(`yt-dlp metadata fetch failed: ${stderr}`)); + else resolve(); + }); + proc.on('error', (err) => reject(err)); + }); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + const files = await readdir(outputDir); + const infoFile = files.find((f) => f.startsWith(`${clipId}_meta`) && f.endsWith('.info.json')); + if (!infoFile) throw new Error('No info.json produced by metadata fetch'); + + // eslint-disable-next-line security/detect-non-literal-fs-filename + const info = JSON.parse(await readFile(`${outputDir}/${infoFile}`, 'utf-8')); + const title = extractCaption(info); + const rawName = info.uploader || info.channel || info.uploader_id || null; + const creatorName = rawName ? String(rawName).replace(/^@/, '') : null; + const creatorUrl = info.uploader_url || info.channel_url || null; + + // Clean up the temp info.json + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + await unlink(`${outputDir}/${infoFile}`); + } catch { + // best-effort cleanup + } + + return { title, creatorName, creatorUrl }; + } + private runAudioDownload( searchQuery: string, options: DownloadOptions diff --git a/src/lib/server/push.ts b/src/lib/server/push.ts index 6ba30b0..9f8fcd2 100644 --- a/src/lib/server/push.ts +++ b/src/lib/server/push.ts @@ -3,12 +3,14 @@ import { env } from '$env/dynamic/private'; import { db } from '$lib/server/db'; import { clips, + notifications, pushSubscriptions, notificationPreferences, users, watched } from '$lib/server/db/schema'; import { eq, and, inArray, sql } from 'drizzle-orm'; +import { v4 as uuid } from 'uuid'; import { createLogger } from '$lib/server/logger'; const log = createLogger('push'); @@ -102,6 +104,7 @@ export async function sendNotification( /** * Send push notification to the group after a clip is published (ready or failed). * Called from the download pipeline — NOT from the API endpoint. + * Also creates in-app notification records for each recipient. */ export async function notifyNewClip(clipId: string): Promise { const clip = await db.query.clips.findFirst({ @@ -120,17 +123,51 @@ export async function notifyNewClip(clipId: string): Promise { ? `${env.ORIGIN}/api/thumbnails/${clip.thumbnailPath}` : undefined; - await sendGroupNotification( - clip.groupId, - { - title: `${uploader.username} added a ${label}`, - body: clip.title || 'Tap to watch', - url: `/?clip=${clipId}`, - tag: 'new-clip', - ...(image ? { image } : {}) - }, - 'newAdds', - uploader.id + const payload: NotificationPayload = { + title: `${uploader.username} added a ${label}`, + body: clip.title || 'Tap to watch', + url: `/?clip=${clipId}`, + tag: `new-clip-${uploader.id}`, + ...(image ? { image } : {}) + }; + + // Fetch group members, exclude uploader and removed users + const groupUsers = await db.query.users.findMany({ + where: eq(users.groupId, clip.groupId), + columns: { id: true, removedAt: true } + }); + const targets = groupUsers.filter((u) => u.id !== uploader.id && !u.removedAt); + if (targets.length === 0) return; + + const targetIds = targets.map((u) => u.id); + + // Batch-fetch notification preferences + const allPrefs = await db.query.notificationPreferences.findMany({ + where: inArray(notificationPreferences.userId, targetIds) + }); + const prefsMap = new Map(allPrefs.map((p) => [p.userId, p])); + + const now = new Date(); + + await Promise.allSettled( + targets.map(async (user) => { + const prefs = prefsMap.get(user.id); + if (prefs && !prefs.newAdds) return; + + // Insert in-app notification record + await db.insert(notifications).values({ + id: uuid(), + userId: user.id, + type: 'new_clip', + clipId, + actorId: uploader.id, + createdAt: now + }); + + // Send push notification + const badgeCount = await getUnwatchedCount(user.id, clip.groupId); + await sendNotification(user.id, { ...payload, badgeCount }); + }) ); } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 424f56c..11c2d22 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -188,14 +188,14 @@ /* Feed: floating notification bell (top-right) */ .feed-notif-btn { position: fixed; - top: max(var(--space-md), env(safe-area-inset-top)); + top: calc(max(var(--space-md), env(safe-area-inset-top)) - 1px); right: var(--space-sm); z-index: 20; display: flex; align-items: center; justify-content: center; width: 44px; - height: 44px; + height: 34px; border-radius: var(--radius-full); color: var(--reel-text); background: none; diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 6c788d4..1250a5b 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -106,6 +106,7 @@ } const pullY = $derived(computePullY(pullDistance, isRefreshing)); const currentUserId = $derived(page.data.user?.id ?? ''); + const isHost = $derived(page.data.group?.createdBy === page.data.user?.id); const autoScroll = $derived(page.data.user?.autoScroll ?? false); const gifEnabled = $derived(!!page.data.gifEnabled); const vapidPublicKey = $derived(page.data.vapidPublicKey as string); @@ -406,11 +407,13 @@ el.addEventListener('pointermove', onPointerMove); el.addEventListener('pointerup', onPointerUp); el.addEventListener('pointercancel', onPointerUp); + el.addEventListener('pointerleave', onPointerUp); return () => { el.removeEventListener('pointerdown', onPointerDown); el.removeEventListener('pointermove', onPointerMove); el.removeEventListener('pointerup', onPointerUp); el.removeEventListener('pointercancel', onPointerUp); + el.removeEventListener('pointerleave', onPointerUp); }; }); @@ -715,7 +718,7 @@ - scrolly + {page.data.group?.name ?? 'scrolly'} · scrolly @@ -793,6 +796,7 @@ + + Faves · {page.data.group?.name ?? 'scrolly'} · scrolly + +
{#if loading}
@@ -230,6 +235,7 @@ - Settings — scrolly + Settings · {page.data.group?.name ?? 'scrolly'} · scrolly
@@ -221,7 +222,7 @@ >Me Group
{/if} @@ -509,6 +510,10 @@ transition: color 0.2s ease; position: relative; z-index: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; } .tab.active { diff --git a/src/routes/api/clips/[id]/+server.ts b/src/routes/api/clips/[id]/+server.ts index d28e602..ffdc016 100644 --- a/src/routes/api/clips/[id]/+server.ts +++ b/src/routes/api/clips/[id]/+server.ts @@ -12,7 +12,13 @@ import { notifications } from '$lib/server/db/schema'; import { eq, and, ne, count, inArray } from 'drizzle-orm'; -import { withClipAuth, mapUsersByIds, groupReactions } from '$lib/server/api-utils'; +import { + withClipAuth, + mapUsersByIds, + groupReactions, + parseBody, + isResponse +} from '$lib/server/api-utils'; import { cleanupClipFiles } from '$lib/server/download-utils'; export const GET: RequestHandler = withClipAuth(async ({ params }, { user, clip }) => { @@ -91,18 +97,39 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user, clip }); }); -export const DELETE: RequestHandler = withClipAuth(async ({ params }, { user, clip }) => { - if (clip.addedBy !== user.id) - return json({ error: 'Only the uploader can delete' }, { status: 403 }); +export const PATCH: RequestHandler = withClipAuth(async ({ params, request }, { user, group }) => { + // Only the host can edit captions + if (group.createdBy !== user.id) { + return json({ error: 'Only the host can edit captions' }, { status: 403 }); + } + + const body = await parseBody<{ title?: string }>(request); + if (isResponse(body)) return body; + + const title = typeof body.title === 'string' ? body.title.trim().slice(0, 500) || null : null; + + await db.update(clips).set({ title }).where(eq(clips.id, params.id)); + + return json({ title }); +}); + +export const DELETE: RequestHandler = withClipAuth(async ({ params }, { user, group, clip }) => { + const isHost = group.createdBy === user.id; + + if (!isHost && clip.addedBy !== user.id) { + return json({ error: 'Only the uploader or host can delete' }, { status: 403 }); + } - // Only allow deletion if no one else has watched - const [watchResult] = await db - .select({ count: count() }) - .from(watched) - .where(and(eq(watched.clipId, params.id), ne(watched.userId, clip.addedBy))); + // Non-host uploaders can only delete if no one else has watched + if (!isHost) { + const [watchResult] = await db + .select({ count: count() }) + .from(watched) + .where(and(eq(watched.clipId, params.id), ne(watched.userId, clip.addedBy))); - if (watchResult.count > 0) { - return json({ error: 'Clip can no longer be deleted' }, { status: 403 }); + if (watchResult.count > 0) { + return json({ error: 'Clip can no longer be deleted' }, { status: 403 }); + } } // Fetch comment IDs before the transaction so we can cascade to comment_hearts diff --git a/src/routes/api/clips/[id]/comments/+server.ts b/src/routes/api/clips/[id]/comments/+server.ts index 6bcb7d8..5c980e4 100644 --- a/src/routes/api/clips/[id]/comments/+server.ts +++ b/src/routes/api/clips/[id]/comments/+server.ts @@ -145,7 +145,7 @@ async function dispatchCommentNotification( preferenceKey: 'comments', pushTitle: `${actor.username} replied to you`, pushBody: preview, - pushTag: `reply-${clipId}`, + pushTag: `reply-${clipId}-${actor.id}`, commentPreview: preview }); return parentComment.userId; @@ -163,7 +163,7 @@ async function dispatchCommentNotification( preferenceKey: 'comments', pushTitle: `${actor.username} commented on your clip`, pushBody: preview, - pushTag: `comment-${clipId}`, + pushTag: `comment-${clipId}-${actor.id}`, commentPreview: preview }); return clip.addedBy; diff --git a/src/routes/api/clips/[id]/comments/viewed/+server.ts b/src/routes/api/clips/[id]/comments/viewed/+server.ts index 11b72f2..6f6e63f 100644 --- a/src/routes/api/clips/[id]/comments/viewed/+server.ts +++ b/src/routes/api/clips/[id]/comments/viewed/+server.ts @@ -1,8 +1,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; -import { commentViews } from '$lib/server/db/schema'; -import { eq, and } from 'drizzle-orm'; +import { commentViews, notifications } from '$lib/server/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; import { withClipAuth } from '$lib/server/api-utils'; export const POST: RequestHandler = withClipAuth(async ({ params }, { user }) => { @@ -24,5 +24,16 @@ export const POST: RequestHandler = withClipAuth(async ({ params }, { user }) => await db.insert(commentViews).values({ clipId, userId, viewedAt: now }); } + // Auto-clear comment/reply/mention notifications now that the user has viewed comments + await db + .delete(notifications) + .where( + and( + eq(notifications.userId, userId), + eq(notifications.clipId, clipId), + inArray(notifications.type, ['comment', 'reply', 'mention']) + ) + ); + return json({ ok: true }); }); diff --git a/src/routes/api/clips/[id]/favorite/+server.ts b/src/routes/api/clips/[id]/favorite/+server.ts index 9cd078c..c7ef89e 100644 --- a/src/routes/api/clips/[id]/favorite/+server.ts +++ b/src/routes/api/clips/[id]/favorite/+server.ts @@ -101,7 +101,7 @@ export const POST: RequestHandler = withClipAuth(async ({ params }, { user, clip preferenceKey: 'reactions', pushTitle: `${user.username} reacted ❤️`, pushBody: 'on your clip', - pushTag: `reaction-${clipId}`, + pushTag: `reaction-${clipId}-${user.id}`, emoji: '❤️' }); } diff --git a/src/routes/api/clips/[id]/reactions/+server.ts b/src/routes/api/clips/[id]/reactions/+server.ts index 6e0a886..1fdbe95 100644 --- a/src/routes/api/clips/[id]/reactions/+server.ts +++ b/src/routes/api/clips/[id]/reactions/+server.ts @@ -82,7 +82,7 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u preferenceKey: 'reactions', pushTitle: `${user.username} reacted ${emoji}`, pushBody: 'on your clip', - pushTag: `reaction-${clipId}`, + pushTag: `reaction-${clipId}-${user.id}`, emoji }); } diff --git a/src/routes/api/clips/[id]/refetch/+server.ts b/src/routes/api/clips/[id]/refetch/+server.ts new file mode 100644 index 0000000..990adac --- /dev/null +++ b/src/routes/api/clips/[id]/refetch/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { clips } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { requireHost, requireClipInGroup, isResponse } from '$lib/server/api-utils'; +import { getActiveProvider } from '$lib/server/providers/registry'; +import { DATA_DIR } from '$lib/server/download-utils'; +import { YtDlpProvider } from '$lib/server/providers/ytdlp'; +import { createLogger } from '$lib/server/logger'; + +const log = createLogger('refetch'); + +export const POST: RequestHandler = async ({ params, locals }) => { + const hostError = requireHost(locals); + if (hostError) return hostError; + + const clipOrError = await requireClipInGroup(params.id, locals.user!.groupId); + if (isResponse(clipOrError)) return clipOrError; + + const clip = clipOrError; + + // Only refetch for clips that have a source URL + if (!clip.originalUrl) { + return json({ error: 'No source URL to refetch from' }, { status: 400 }); + } + + // Must have a provider installed + const provider = await getActiveProvider(locals.user!.groupId); + if (!provider || !(provider instanceof YtDlpProvider)) { + return json({ error: 'No compatible download provider configured' }, { status: 400 }); + } + + try { + const metadata = await provider.fetchMetadata(clip.originalUrl, DATA_DIR, clip.id); + + const updates: Record = {}; + if (metadata.title) updates.title = metadata.title; + if (metadata.creatorName) updates.creatorName = metadata.creatorName; + if (metadata.creatorUrl) updates.creatorUrl = metadata.creatorUrl; + + if (Object.keys(updates).length > 0) { + await db.update(clips).set(updates).where(eq(clips.id, clip.id)); + } + + return json({ + title: metadata.title ?? clip.title, + creatorName: metadata.creatorName ?? clip.creatorName, + creatorUrl: metadata.creatorUrl ?? clip.creatorUrl + }); + } catch (err) { + log.error({ err, clipId: clip.id }, 'metadata refetch failed'); + return json({ error: 'Failed to refetch metadata' }, { status: 500 }); + } +}; diff --git a/src/routes/api/clips/[id]/watched/+server.ts b/src/routes/api/clips/[id]/watched/+server.ts index 92d4151..6656634 100644 --- a/src/routes/api/clips/[id]/watched/+server.ts +++ b/src/routes/api/clips/[id]/watched/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; -import { watched } from '$lib/server/db/schema'; +import { notifications, watched } from '$lib/server/db/schema'; import { and, eq, sql } from 'drizzle-orm'; import { withClipAuth } from '$lib/server/api-utils'; @@ -36,6 +36,17 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u } }); + // Auto-clear new_clip notification now that the user has watched it + await db + .delete(notifications) + .where( + and( + eq(notifications.userId, user.id), + eq(notifications.clipId, params.id), + eq(notifications.type, 'new_clip') + ) + ); + return json({ watched: true }); }); diff --git a/src/routes/api/notifications/+server.ts b/src/routes/api/notifications/+server.ts index 9619e7c..4e76cd2 100644 --- a/src/routes/api/notifications/+server.ts +++ b/src/routes/api/notifications/+server.ts @@ -26,7 +26,10 @@ export const GET: RequestHandler = withAuth(async ({ url }, { user }) => { where: (c, { inArray }) => inArray(c.id, clipIds) }); const clipMap = new Map( - clipRows.map((c) => [c.id, { thumbnailPath: c.thumbnailPath, title: c.title }]) + clipRows.map((c) => [ + c.id, + { thumbnailPath: c.thumbnailPath, title: c.title, contentType: c.contentType } + ]) ); const result = rows.map((n) => ({ @@ -39,6 +42,7 @@ export const GET: RequestHandler = withAuth(async ({ url }, { user }) => { actorAvatar: actorMap.get(n.actorId)?.avatarPath || null, clipThumbnail: clipMap.get(n.clipId)?.thumbnailPath || null, clipTitle: clipMap.get(n.clipId)?.title || null, + clipContentType: clipMap.get(n.clipId)?.contentType || 'video', read: !!n.readAt, createdAt: n.createdAt.toISOString() })); diff --git a/src/routes/api/notifications/[id]/+server.ts b/src/routes/api/notifications/[id]/+server.ts new file mode 100644 index 0000000..08eea79 --- /dev/null +++ b/src/routes/api/notifications/[id]/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { notifications } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { withAuth } from '$lib/server/api-utils'; + +export const DELETE: RequestHandler = withAuth(async ({ params }, { user }) => { + await db + .delete(notifications) + .where(and(eq(notifications.id, params.id), eq(notifications.userId, user.id))); + + return json({ ok: true }); +});