Skip to content
Open
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
186 changes: 119 additions & 67 deletions src/components/OnboardingTour.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
"use client";

import { useEffect, useRef, useState, useCallback } from "react";
import {
useEffect,
useRef,
useState,
useCallback,
} from "react";
import { createPortal } from "react-dom";

const TOUR_KEY = "reframe_onboarding_complete";
Expand Down Expand Up @@ -40,7 +45,7 @@ const TOUR_STEPS: TourStep[] = [
title: "Export your video",
description:
"Click Export (or press ⌘↵) to process your video locally — nothing ever leaves your device.",
position: "top",
position: "bottom",
},
];

Expand Down Expand Up @@ -69,30 +74,48 @@ function getTooltipStyle(
width: rect.width + PADDING * 2,
height: rect.height + PADDING * 2,
};
const viewportWidth =
typeof window !== "undefined" ? window.innerWidth : 1024;
const isMobile = viewportWidth < 1024;

switch (position) {
case "top":
return {
top: sr.top - th - TOOLTIP_OFFSET,
left: sr.left + sr.width / 2 - tw / 2,
};
case "left":
return {
top: sr.top + sr.height / 2 - th / 2,
left: sr.left - tw - TOOLTIP_OFFSET,
};
case "right":
return {
top: sr.top + sr.height / 2 - th / 2,
left: sr.left + sr.width + TOOLTIP_OFFSET,
};
case "bottom":
default:
return {
top: sr.top + sr.height + TOOLTIP_OFFSET,
left: sr.left + sr.width / 2 - tw / 2,
};
}
const viewportHeight =
typeof window !== "undefined" ? window.innerHeight : 768;

const resolvedPosition = isMobile ? "bottom" : position;

let top = 0;
let left = 0;

switch (resolvedPosition) {
case "top":
top = sr.top - th - TOOLTIP_OFFSET;
left = sr.left + sr.width / 2 - tw / 2;
break;

case "left":
top = sr.top + sr.height / 2 - th / 2;
left = sr.left - tw - TOOLTIP_OFFSET;
break;

case "right":
top = sr.top + sr.height / 2 - th / 2;
left = sr.left + sr.width + TOOLTIP_OFFSET;
break;

case "top":
default:
top = sr.top + sr.height + TOOLTIP_OFFSET;
left = sr.left + sr.width / 2 - tw / 2;
break;
}

top = Math.max(
16,
Math.min(top, viewportHeight - th - 16)
);
left = Math.max(16, Math.min(left, viewportWidth - tw - 16));

return { top, left };
}

interface SpotlightProps {
Expand Down Expand Up @@ -166,7 +189,15 @@ function Tooltip({
onSkip,
tooltipRef,
}: TooltipProps) {
const style = getTooltipStyle(rect, step.position, tooltipRef);
const [ready, setReady] = useState(false);

useEffect(() => {
setReady(true);
}, []);

const style = ready
? getTooltipStyle(rect, step.position, tooltipRef)
: { opacity: 0 };
const isLast = stepIndex === totalSteps - 1;

return (
Expand All @@ -175,7 +206,7 @@ function Tooltip({
role="dialog"
aria-modal="true"
aria-label={`Onboarding step ${stepIndex + 1} of ${totalSteps}: ${step.title}`}
className="fixed z-[9999] w-80 rounded-xl shadow-2xl border
className="fixed z-[9999] w-[min(20rem,calc(100vw-2rem))] rounded-xl shadow-2xl border
bg-[var(--surface)]
border-[var(--border)]
text-[var(--text)]
Expand Down Expand Up @@ -209,16 +240,13 @@ function Tooltip({
>
Skip tour
</button>
<button
onClick={onNext}
ref={(el) => {
el?.focus();
}}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700
text-white transition-colors focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
>
<button
onClick={onNext}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700
text-white transition-colors focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
>
{isLast ? "Done" : "Next →"}
</button>
</div>
Expand All @@ -243,20 +271,29 @@ export default function OnboardingTour() {
const measureTarget = useCallback((id: string): Promise<Rect | null> => {
return new Promise((resolve) => {
const attempt = (tries: number) => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => {
const r = el.getBoundingClientRect();
resolve({
top: r.top,
left: r.left,
width: r.width,
height: r.height,
});
}, 400); // wait for scroll to finish
return;
}
const el = document.getElementById(id);

if (el) {
if (tries === 5) {
el.scrollIntoView({
block: "center",
behavior: "smooth",
});
}

setTimeout(() => {
const r = el.getBoundingClientRect();

resolve({
top: r.top,
left: r.left,
width: r.width,
height: r.height,
});
}, 400);

return;
}
if (tries <= 0) {
resolve(null);
return;
Expand Down Expand Up @@ -303,7 +340,6 @@ export default function OnboardingTour() {
if (cancelled) return;
if (rect) {
setTargetRect(rect);
setTimeout(() => tooltipRef.current?.focus(), 50);
retryCount = 0;
} else if (retryCount < maxRetries) {
retryCount++;
Expand Down Expand Up @@ -331,22 +367,38 @@ export default function OnboardingTour() {
// Re-measure on resize or scroll so spotlight stays anchored to target.
// requestAnimationFrame prevents layout thrashing on rapid scroll/resize events.
useEffect(() => {
if (!visible) return;
let rafId: number;
const remeasure = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
measureTarget(TOUR_STEPS[stepIndex]?.targetId ?? "").then(setTargetRect);
if (!visible) return;

let resizeTimer: number;

const remeasure = () => {
clearTimeout(resizeTimer);

resizeTimer = window.setTimeout(() => {
const el = document.getElementById(
TOUR_STEPS[stepIndex]?.targetId ?? ""
);

if (!el) return;

const r = el.getBoundingClientRect();

setTargetRect({
top: r.top,
left: r.left,
width: r.width,
height: r.height,
});
};
window.addEventListener("resize", remeasure);
window.addEventListener("scroll", remeasure, true);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", remeasure);
window.removeEventListener("scroll", remeasure, true);
};
}, [visible, stepIndex, measureTarget]);
}, 100);
};

window.addEventListener("resize", remeasure);

return () => {
clearTimeout(resizeTimer);
window.removeEventListener("resize", remeasure);
};
}, [visible, stepIndex]);

// Keyboard support
useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/PresetSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export default function PresetSelector({ recipe, onChange }: Props) {
);

return (
<div className="space-y-3">
<div id="preset-selector" className="space-y-3">
{/* Quick-action row */}
<div className="grid grid-cols-5 gap-1.5">
{QUICK_ACTIONS.map(({ preset, label, platform, icon }) => {
Expand Down
17 changes: 9 additions & 8 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function AccordionSection({
aria-expanded={isOpen}
aria-controls={`${id}-panel`}
onClick={onToggle}
className="w-full flex items-center justify-between px-3 py-2 text-left hover:bg-[var(--border)] transition-colors duration-150"
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-[var(--border)] transition-colors duration-150"
>
<div className="flex items-center gap-2">
<span className="text-film-500 opacity-80">{icon}</span>
Expand Down Expand Up @@ -228,7 +228,7 @@ export default function VideoEditor() {
const [selectedTextId, setSelectedTextId] = useState<string | null>(null);
const [openSections, setOpenSections] = useState({
resize: true,
trim: false,
trim: true,
rotation: false,
text: false,
audio: false,
Expand Down Expand Up @@ -324,8 +324,8 @@ export default function VideoEditor() {
{status === "error" && `Export failed: ${error}`}
</div>

<div className="max-w-6xl mx-auto px-4 py-8 pb-6 flex-1 w-full">
<header className="mb-10 flex flex-col items-center justify-center gap-4 animate-fade-in">
<div className="max-w-6xl mx-auto px-4 py-5 md:py-8 pb-6 flex-1 w-full">
<header className="mb-6 md:mb-10 flex flex-col items-center justify-center gap-4 animate-fade-in">
<div
className="inline-block rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-sm border-l-4 border-l-film-600 mx-auto w-fit min-w-min"
style={{ padding: 'clamp(0.75rem,3vw,1.25rem) clamp(1rem,5vw,2rem)', boxSizing: 'border-box' }}
Expand Down Expand Up @@ -415,7 +415,7 @@ export default function VideoEditor() {
"grid grid-cols-1 gap-4",
isProcessing && "pointer-events-none opacity-50"
)}>
<div className="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5 space-y-6">
<div className="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-4 md:p-5 space-y-5 md:space-y-6">
<AccordionSection
id="trim"
icon={<Scissors size={12} />}
Expand Down Expand Up @@ -687,7 +687,7 @@ export default function VideoEditor() {
</div>

<div className={cn(
"space-y-5 transition-opacity duration-300 sticky top-8 self-start",
"space-y-5 transition-opacity duration-300 lg:sticky lg:top-8 self-start",
(isProcessing || !file) && "pointer-events-none opacity-50"
)}>
{!file && (
Expand Down Expand Up @@ -759,13 +759,14 @@ export default function VideoEditor() {
title={!file ? "Upload a video to enable export" : undefined}
className={cn(
"w-full flex items-center justify-center gap-3 py-5 min-h-[44px] rounded-xl",
"font-display text-2xl tracking-widest transition-all duration-200",
"font-display text-xl md:text-2xl tracking-widest transition-all duration-200",
file && status === "idle" && "motion-safe:animate-pulse",
file && !isProcessing
? "bg-[var(--accent)] hover:bg-[var(--accent-hover)] hover:scale-[1.02] text-white shadow-[var(--shadow)] active:scale-[0.98] cursor-pointer"
: "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed"
)}
>
<Zap size={20} className={cn(file && !isProcessing && "animate-pulse")} />
<Zap size={20} />
{isProcessing ? "PROCESSING" : "EXPORT"}
</button>

Expand Down