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
20 changes: 20 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,24 @@ detail > summary {
}
details > summary::-webkit-details-marker {
display: none;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

.animate-toast-in {
animation: toast-in 0.2s ease-out both;
}

@media (prefers-reduced-motion: reduce) {
.animate-toast-in {
animation: none;
}
}
81 changes: 81 additions & 0 deletions src/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { CheckCircle, XCircle, Loader2, X } from "lucide-react";
import type { Toast } from "@/hooks/useToast";

interface ToastItemProps {
toast: Toast;
onDismiss: (id: string) => void;
}

function ToastItem({ toast, onDismiss }: ToastItemProps) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (toast.duration !== Infinity) {
timerRef.current = setTimeout(() => {
onDismiss(toast.id);
}, toast.duration ?? 5000);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [toast.id, toast.duration, onDismiss]);

const icons = {
success: <CheckCircle size={16} className="shrink-0 mt-0.5 text-[var(--accent)]" aria-hidden="true" />,
error: <XCircle size={16} className="shrink-0 mt-0.5 text-[var(--error,#ef4444)]" aria-hidden="true" />,
loading: <Loader2 size={16} className="shrink-0 mt-0.5 text-[var(--muted)] animate-spin" aria-hidden="true" />,
};

return (
<div
role={toast.type === "error" ? "alert" : "status"}
aria-live={toast.type === "error" ? "assertive" : "polite"}
aria-atomic="true"
className={cn(
"flex items-start gap-3 px-4 py-3 rounded-xl border shadow-lg",
"bg-[var(--surface)] border-[var(--border)]",
"animate-toast-in",
"min-w-[260px] max-w-[360px]",
)}
>
{icons[toast.type]}
<p className="flex-1 text-sm font-heading text-[var(--text)] leading-snug">
{toast.message}
</p>
{toast.duration !== Infinity && (
<button
type="button"
onClick={() => onDismiss(toast.id)}
aria-label="Dismiss notification"
className="shrink-0 mt-0.5 text-[var(--muted)] hover:text-[var(--text)] transition-colors"
>
<X size={14} aria-hidden="true" />
</button>
)}
</div>
);
}

interface ToastContainerProps {
toasts: Toast[];
onDismiss: (id: string) => void;
}

export default function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
if (toasts.length === 0) return null;

return (
<div
aria-label="Notifications"
className="fixed bottom-6 right-6 z-50 flex flex-col gap-2 items-end"
>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
);
}
26 changes: 25 additions & 1 deletion src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import {
import OnboardingTour from "./OnboardingTour";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { loadOverlayState, persistOverlayState } from "@/lib/editorPersistence";

import ToastContainer from "./Toast";
import { useToast } from "@/hooks/useToast";
interface SectionProps {
icon: React.ReactNode;
title: string;
Expand Down Expand Up @@ -224,6 +225,8 @@ export default function VideoEditor() {
onToggleShortcutsModal: () => {},
});

const { toasts, addToast, dismissToast, dismissAll } = useToast();
const prevStatusRef = useRef<string | null>(null);
const [copied, setCopied] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const initialOverlayState = useRef({
Expand Down Expand Up @@ -284,9 +287,29 @@ export default function VideoEditor() {
navigator.clipboard.writeText(url.toString()).then(() => {
setShareCopied(true);
setTimeout(() => setShareCopied(false), 2000);
addToast({ type: "success", message: "Settings link copied to clipboard!" });
});
};

useEffect(() => {
if (status === prevStatusRef.current) return;

if (status === "exporting" && prevStatusRef.current !== "exporting") {
addToast({ type: "loading", message: "Export started…", duration: Infinity });
} else if (status === "done") {
dismissAll();
addToast({ type: "success", message: "Export complete — ready to download!" });
} else if (status === "error" && error) {
dismissAll();
addToast({ type: "error", message: `Export failed: ${error}` });
} else if (status === "idle" && prevStatusRef.current === "exporting") {
dismissAll();
addToast({ type: "error", message: "Export cancelled." });
}

prevStatusRef.current = status;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, error]);
useEffect(() => {
if (status === "done" && downloadRef.current) {

Expand Down Expand Up @@ -357,6 +380,7 @@ return () => {
exportStartedAt={exportStartedAt}
onCancel={cancelExport}
/>
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
<OnboardingTour />

<div aria-live="polite" aria-atomic="true" className="sr-only">
Expand Down
44 changes: 44 additions & 0 deletions src/hooks/useToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { useState, useCallback } from "react";

export type ToastType = "success" | "error" | "loading";

export interface Toast {
id: string;
message: string;
type: ToastType;
/** Auto-dismiss delay in ms. Pass `Infinity` to persist until manually dismissed. Defaults to 5000. */
duration?: number;
}

type ToastInput = Omit<Toast, "id">;

let counter = 0;

export function useToast() {
const [toasts, setToasts] = useState<Toast[]>([]);

const addToast = useCallback((input: ToastInput): string => {
const id = `toast-${++counter}`;
setToasts((prev) => [...prev, { ...input, id }]);
return id;
}, []);

const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);

const dismissAll = useCallback(() => {
setToasts([]);
}, []);

/** Replace a toast (e.g. swap a loading toast for a success/error one). */
const updateToast = useCallback((id: string, input: Partial<ToastInput>) => {
setToasts((prev) =>
prev.map((t) => (t.id === id ? { ...t, ...input } : t))
);
}, []);

return { toasts, addToast, dismissToast, dismissAll, updateToast };
}