Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/lib/components/ActionSidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@
holdTimer = setTimeout(() => {
holdFired = true;
if (onreactionhold && saveBtnEl) {
const rect = saveBtnEl.getBoundingClientRect();
onreactionhold(rect.left + rect.width / 2, rect.top);
const circle = saveBtnEl.querySelector('.icon-circle');
const rect = (circle ?? saveBtnEl).getBoundingClientRect();
onreactionhold(rect.left + rect.width / 2, rect.top + rect.height / 2);
}
}, 350);
}
Expand Down
45 changes: 20 additions & 25 deletions src/lib/components/EmojiShower.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,22 @@
let particles = $state<Particle[]>([]);

onMount(() => {
const count = 10 + Math.floor(Math.random() * 6);
const count = 8 + Math.floor(Math.random() * 5); // 8–12 particles
let maxEnd = 0;

particles = Array.from({ length: count }, (_, i) => {
const dur = 1.0 + Math.random() * 0.8;
const del = Math.random() * 250;
const dur = 0.8 + Math.random() * 0.6; // 0.8–1.4s (snappy)
const del = Math.random() * 200; // tight stagger
maxEnd = Math.max(maxEnd, dur * 1000 + del);

return {
id: i,
emoji,
x: x + (Math.random() - 0.5) * 30,
y: y + (Math.random() - 0.5) * 20,
scale: 0.5 + Math.random() * 0.9,
travel: 200 + Math.random() * 200,
wobble: 20 + Math.random() * 30,
x: x + (Math.random() - 0.5) * 80, // moderate spread
y: y + (Math.random() - 0.5) * 40,
scale: 0.6 + Math.random() * 0.8, // 0.6–1.4x
travel: 120 + Math.random() * 160, // 120–280px upward
wobble: 15 + Math.random() * 30, // gentle lateral drift
rotation: (Math.random() - 0.5) * 30,
duration: dur,
delay: del
Expand Down Expand Up @@ -95,32 +95,27 @@

@keyframes float-up {
0% {
opacity: 1;
opacity: 0.9;
transform: translate(-50%, -50%) scale(0) rotate(0deg);
}
8% {
opacity: 1;
10% {
opacity: 0.9;
transform: translate(-50%, -50%) scale(var(--scale)) rotate(0deg);
}
25% {
opacity: 1;
transform: translate(calc(-50% + var(--wobble)), calc(-50% - var(--travel) * 0.25))
40% {
opacity: 0.7;
transform: translate(calc(-50% + var(--wobble)), calc(-50% - var(--travel) * 0.4))
scale(var(--scale)) rotate(var(--rotation));
}
50% {
opacity: 1;
transform: translate(calc(-50% - var(--wobble) * 0.5), calc(-50% - var(--travel) * 0.5))
scale(var(--scale)) rotate(calc(var(--rotation) * -0.5));
}
75% {
opacity: 0.7;
transform: translate(calc(-50% + var(--wobble) * 0.3), calc(-50% - var(--travel) * 0.75))
scale(calc(var(--scale) * 0.8)) rotate(var(--rotation));
70% {
opacity: 0.3;
transform: translate(calc(-50% - var(--wobble) * 0.4), calc(-50% - var(--travel) * 0.7))
scale(calc(var(--scale) * 0.85)) rotate(calc(var(--rotation) * -0.5));
}
100% {
opacity: 0;
transform: translate(calc(-50% - var(--wobble) * 0.2), calc(-50% - var(--travel)))
scale(calc(var(--scale) * 0.5)) rotate(calc(var(--rotation) * -1));
transform: translate(calc(-50% + var(--wobble) * 0.2), calc(-50% - var(--travel)))
scale(calc(var(--scale) * 0.6)) rotate(calc(var(--rotation) * -1));
}
}
</style>
87 changes: 52 additions & 35 deletions src/lib/components/ReactionPicker.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script lang="ts">
import { REACTIONS } from '$lib/icons';
import { openSheet, closeSheet } from '$lib/stores/sheetOpen';
import { onDestroy } from 'svelte';

const RADIUS = 140;
const START_DEG = 120;
const END_DEG = 240;
// Reorder: heart last (rightmost, anchoring the bar)
const BAR_EMOJIS = [...REACTIONS.filter((e) => e !== '❤️'), '❤️'];

const {
x,
Expand All @@ -19,20 +20,14 @@
dragMode?: boolean;
} = $props();

let pickerEl: HTMLDivElement | null = $state(null);
let barEl: HTMLDivElement | null = $state(null);
let visible = $state(false);
let hoveredIndex = $state(-1);
const btnEls: HTMLButtonElement[] = $state([]);

// Pre-compute arc positions (math coords, screen-y inverted)
const positions = REACTIONS.map((_, i) => {
const deg = START_DEG + (END_DEG - START_DEG) * (i / (REACTIONS.length - 1));
const rad = (deg * Math.PI) / 180;
return {
x: RADIUS * Math.cos(rad),
y: -RADIUS * Math.sin(rad)
};
});
// Block feed swipe while picker is open
openSheet();
onDestroy(closeSheet);

// Animate in
$effect(() => {
Expand All @@ -54,7 +49,7 @@
if (dragMode) return;

function handleOutsideClick(e: PointerEvent) {
if (pickerEl && !pickerEl.contains(e.target as Node)) {
if (barEl && !barEl.contains(e.target as Node)) {
ondismiss();
}
}
Expand Down Expand Up @@ -97,7 +92,7 @@
function handleUp(e: PointerEvent) {
const idx = hitTestEmoji(e.clientX, e.clientY);
if (idx >= 0) {
onpick(REACTIONS[idx]);
onpick(BAR_EMOJIS[idx]);
} else {
ondismiss();
}
Expand All @@ -113,21 +108,27 @@
});
</script>

<!-- x,y = center of the heart icon-circle (44x44) -->
<!-- Bar right edge should align so the last emoji (❤️) sits exactly over the heart -->
<div
class="picker-anchor"
class="reaction-bar"
class:visible
style="left:{x}px;top:{y}px"
bind:this={pickerEl}
bind:this={barEl}
role="listbox"
aria-label="Reaction picker"
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
onpointerdown={(e) => e.stopPropagation()}
>
{#each REACTIONS as emoji, i (emoji)}
{#each BAR_EMOJIS as emoji, i (emoji)}
{@const delayIndex = BAR_EMOJIS.length - 1 - i}
<button
class="reaction-btn"
class:visible
class:hovered={hoveredIndex === i}
style="--tx:{positions[i].x}px;--ty:{positions[i].y}px;transition-delay:{visible
? i * 30
: 0}ms"
style="transition-delay:{visible ? delayIndex * 25 : 0}ms"
bind:this={btnEls[i]}
onclick={() => {
if (!dragMode) onpick(emoji);
Expand All @@ -140,14 +141,33 @@
</div>

<style>
.picker-anchor {
.reaction-bar {
position: fixed;
z-index: 200;
pointer-events: none;
display: flex;
align-items: center;
gap: 0;
padding: 4px;
border-radius: var(--radius-full);
background: var(--reel-icon-circle-bg);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
/* Right-align: shift left by full width, then back by half the last emoji (22px = 44/2) */
transform-origin: right center;
transform: translate(calc(-100% + 22px), -50%) scaleX(0);
opacity: 0;
transition:
transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 150ms ease;
}

.reaction-bar.visible {
transform: translate(calc(-100% + 22px), -50%) scaleX(1);
opacity: 1;
}

.reaction-btn {
position: absolute;
width: 44px;
height: 44px;
display: flex;
Expand All @@ -158,36 +178,33 @@
padding: 0;
border-radius: var(--radius-full);
background: none;
color: var(--reel-text);
transform: translate(-50%, -50%) scale(0);
transform: scale(0);
opacity: 0;
transition:
transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 150ms ease,
color 120ms ease;
pointer-events: none;
transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 150ms ease;
-webkit-tap-highlight-color: transparent;
pointer-events: none;
}

.reaction-btn.visible {
transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) scale(1);
transform: scale(1);
opacity: 1;
pointer-events: auto;
}

.reaction-btn.hovered {
transform: translate(calc(-50% + var(--tx)), calc(-50% + var(--ty))) scale(1.45);
color: var(--accent-magenta);
transform: scale(1.5) translateY(-16px);
}

.reaction-btn:hover:not(.hovered) .emoji {
.reaction-btn:not(.hovered):hover .emoji {
transform: scale(1.15);
}

.emoji {
font-size: 24px;
line-height: 1;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 8px rgba(0, 0, 0, 0.4));
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4));
transition: transform 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
pointer-events: none;
user-select: none;
Expand Down
20 changes: 7 additions & 13 deletions src/lib/reelPlayback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,13 @@ export function sendWatchPercent(clipId: string, maxPercent: number): void {
if (maxPercent <= 0) return;
const pct = Math.round(maxPercent);
const body = JSON.stringify({ watchPercent: pct });
if (navigator.sendBeacon) {
navigator.sendBeacon(
`/api/clips/${clipId}/watched`,
new Blob([body], { type: 'application/json' })
);
} else {
fetch(`/api/clips/${clipId}/watched`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true
}).catch((err) => console.warn('[watch-beacon]', err));
}
// Use PATCH to update percent without marking as watched (only swipe-past marks watched)
fetch(`/api/clips/${clipId}/watched`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true
}).catch((err) => console.warn('[watch-percent]', err));
}

export function startPeriodicWatchUpdate(
Expand Down
4 changes: 2 additions & 2 deletions src/routes/(app)/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@
}

function onPointerDown(e: PointerEvent) {
if (swipeAnimating) return;
if (swipeAnimating || get(anySheetOpen)) return;
const target = e.target as HTMLElement;
if (target.closest('.progress-bar')) {
tracking = false;
Expand All @@ -395,7 +395,7 @@
}

function onPointerMove(e: PointerEvent) {
if (!tracking || swipeAnimating) return;
if (!tracking || swipeAnimating || get(anySheetOpen)) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;

Expand Down
28 changes: 28 additions & 0 deletions src/routes/api/clips/[id]/watched/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u
return json({ watched: true });
});

// PATCH: update watchPercent only — creates the row if needed but does NOT count as "watched"
// Used for periodic progress tracking while user is still viewing
export const PATCH: RequestHandler = withClipAuth(async ({ params, request }, { user }) => {
let watchPercent: number | null = null;
try {
const body = await request.json();
if (typeof body.watchPercent === 'number') {
watchPercent = Math.max(0, Math.min(100, Math.round(body.watchPercent)));
}
} catch {
// No body or invalid JSON
}

if (watchPercent === null) {
return json({ updated: false });
}

// Only update if the row already exists — don't create a watched record
await db
.update(watched)
.set({
watchPercent: sql`MAX(COALESCE(${watched.watchPercent}, 0), ${watchPercent})`
})
.where(and(eq(watched.clipId, params.id), eq(watched.userId, user.id)));

return json({ updated: true });
});

export const DELETE: RequestHandler = withClipAuth(async ({ params }, { user }) => {
await db.delete(watched).where(and(eq(watched.clipId, params.id), eq(watched.userId, user.id)));

Expand Down
Loading