Files
Recordly/src/components/video-editor/TutorialHelp.tsx
T

409 lines
15 KiB
TypeScript

import {
ArrowRight,
ArrowSquareOut as ExternalLink,
Question as HelpCircle,
Keyboard,
ChatDots as MessageSquareMore,
Scissors,
GearSix as Settings2,
XLogo as Twitter,
} from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
import { formatShortcut } from "@/utils/platformUtils";
import { toast } from "@/components/ui/toast";
export const RECORDLY_ISSUES_URL = "https://github.com/webadderallorg/Recordly/issues";
const RECORDLY_DISCORD_URL = "https://discord.gg/sdv2FBVNgE";
const RECORDLY_X_URL = "https://x.com/webadderall";
const CONTACT_EMAIL = "youngchen3442@gmail.com";
export const APP_HEADER_ACTION_BUTTON_CLASS =
"h-7 px-2 text-xs text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all gap-1.5";
export const APP_HEADER_ICON_BUTTON_CLASS =
"h-9 w-9 min-w-9 p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all";
interface KeyboardShortcutsDialogProps {
triggerLabel?: string;
triggerClassName?: string;
}
function DiscordIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
<path d="M20.317 4.369A19.791 19.791 0 0 0 15.418 3a13.255 13.255 0 0 0-.615 1.263 18.27 18.27 0 0 0-5.606 0A13.25 13.25 0 0 0 8.582 3a19.736 19.736 0 0 0-4.9 1.369C.533 9.091-.32 13.697.099 18.237a19.917 19.917 0 0 0 5.993 3.024 14.32 14.32 0 0 0 1.284-2.108 12.804 12.804 0 0 1-2.021-.972c.17-.126.335-.258.495-.395a14.135 14.135 0 0 0 12.3 0c.16.137.325.269.495.395a12.736 12.736 0 0 1-2.026.974 14.103 14.103 0 0 0 1.284 2.106 19.883 19.883 0 0 0 5.996-3.024c.489-5.258-.836-9.822-3.682-13.868ZM8.02 15.331c-1.182 0-2.154-1.085-2.154-2.419 0-1.334.953-2.419 2.154-2.419 1.211 0 2.173 1.095 2.154 2.419 0 1.334-.953 2.419-2.154 2.419Zm7.96 0c-1.182 0-2.154-1.085-2.154-2.419 0-1.334.953-2.419 2.154-2.419 1.211 0 2.173 1.095 2.154 2.419 0 1.334-.943 2.419-2.154 2.419Z" />
</svg>
);
}
export async function openExternalLink(url: string, errorMessage: string) {
try {
const result = await window.electronAPI.openExternalUrl(url);
if (!result.success) {
toast.error(result.error || errorMessage);
}
} catch (error) {
toast.error(`${errorMessage} ${String(error)}`);
}
}
export function DiscordLinkButton() {
const t = useScopedT("editor");
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
void openExternalLink(
RECORDLY_DISCORD_URL,
t("feedback.openFailed", "Failed to open link."),
)
}
className={APP_HEADER_ICON_BUTTON_CLASS}
title={t("common.app.discord", "Join Discord")}
aria-label={t("common.app.discord", "Join Discord")}
>
<DiscordIcon className="h-3.5 w-3.5" />
</Button>
);
}
export function FeedbackDialog() {
const t = useScopedT("editor");
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className={APP_HEADER_ICON_BUTTON_CLASS}
title={t("feedback.trigger", "Feedback")}
aria-label={t("feedback.trigger", "Feedback")}
>
<MessageSquareMore className="h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-foreground flex items-center gap-2">
<MessageSquareMore className="h-5 w-5 text-[#2563EB]" />{" "}
{t("feedback.title", "Feedback & contact")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t(
"feedback.description",
"Reach out directly or open an issue if something is broken or missing.",
)}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="rounded-xl border border-foreground/10 bg-foreground/[0.03] p-4 space-y-3">
<div className="flex items-center justify-between gap-3 rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-3">
<div>
<p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70">
{t("feedback.emailLabel", "Email")}
</p>
<p className="mt-1 text-sm font-medium text-foreground">
{CONTACT_EMAIL}
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void openExternalLink(
`mailto:${CONTACT_EMAIL}`,
t("feedback.openFailed", "Failed to open link."),
)
}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-3">
<div>
<p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70">
{t("feedback.xLabel", "X")}
</p>
<p className="mt-1 text-sm font-medium text-foreground">
@webadderall
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void openExternalLink(
RECORDLY_X_URL,
t("feedback.openFailed", "Failed to open link."),
)
}
>
<Twitter className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void openExternalLink(
RECORDLY_ISSUES_URL,
t("feedback.openFailed", "Failed to open link."),
)
}
className="h-10 w-full justify-between px-4"
>
<span className="flex items-center gap-2 text-sm font-medium">
<MessageSquareMore className="h-4 w-4" />
{t("feedback.reportIssue", "Report issue / send feedback")}
</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/70" />
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export function KeyboardShortcutsDialog({
triggerLabel,
triggerClassName,
}: KeyboardShortcutsDialogProps) {
const { shortcuts, isMac, openConfig } = useShortcuts();
const t = useScopedT("editor");
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Scroll",
zoom: "Ctrl + Scroll",
});
useEffect(() => {
Promise.all([formatShortcut(["shift", "Scroll"]), formatShortcut(["mod", "Scroll"])]).then(
([pan, zoom]) => setScrollLabels({ pan, zoom }),
);
}, []);
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className={triggerClassName ?? APP_HEADER_ICON_BUTTON_CLASS}
title={t("keyboardShortcuts.trigger", "Shortcuts")}
aria-label={t("keyboardShortcuts.trigger", "Shortcuts")}
>
<Keyboard className="h-3.5 w-3.5" />
{triggerLabel ? <span className="font-medium">{triggerLabel}</span> : null}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-foreground flex items-center gap-2">
<Keyboard className="h-5 w-5 text-[#2563EB]" />{" "}
{t("keyboardShortcuts.title")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t(
"keyboardShortcuts.description",
"Quick reference for the timeline and editor controls.",
)}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="rounded-xl border border-foreground/10 bg-foreground/[0.03] p-4 space-y-2 text-xs">
{SHORTCUT_ACTIONS.map((action) => (
<div
key={action}
className="flex items-center justify-between gap-3 rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-2.5"
>
<span className="text-muted-foreground">
{SHORTCUT_LABELS[action]}
</span>
<kbd className="rounded border border-foreground/10 bg-foreground/10 px-2 py-1 font-mono text-[#2563EB]">
{formatBinding(shortcuts[action], isMac)}
</kbd>
</div>
))}
<div className="grid grid-cols-1 gap-2 pt-2 sm:grid-cols-3">
<div className="rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-2.5">
<p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70">
{t("keyboardShortcuts.panTimeline")}
</p>
<kbd className="mt-2 inline-flex rounded border border-foreground/10 bg-foreground/10 px-2 py-1 font-mono text-[#2563EB]">
{scrollLabels.pan}
</kbd>
</div>
<div className="rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-2.5">
<p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70">
{t("keyboardShortcuts.zoomTimeline")}
</p>
<kbd className="mt-2 inline-flex rounded border border-foreground/10 bg-foreground/10 px-2 py-1 font-mono text-[#2563EB]">
{scrollLabels.zoom}
</kbd>
</div>
<div className="rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-2.5">
<p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70">
{t("keyboardShortcuts.cycleAnnotations")}
</p>
<kbd className="mt-2 inline-flex rounded border border-foreground/10 bg-foreground/10 px-2 py-1 font-mono text-[#2563EB]">
{t("keyboardShortcuts.tab")}
</kbd>
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={openConfig}>
<Settings2 className="h-4 w-4" />
{t("keyboardShortcuts.customize")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function TutorialHelp() {
const t = useScopedT("editor");
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className={APP_HEADER_ACTION_BUTTON_CLASS}>
<HelpCircle className="w-3.5 h-3.5" />
<span className="font-medium">{t("tutorial.howTrimmingWorks")}</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-foreground flex items-center gap-2">
<Scissors className="w-5 h-5 text-[#ef4444]" /> {t("tutorial.title")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("tutorial.understanding")}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-8">
{/* Explanation */}
<div className="bg-foreground/5 rounded-lg p-4 border border-foreground/5">
<p className="text-muted-foreground leading-relaxed">
{t("tutorial.descriptionP1")}
<span className="text-[#ef4444] font-bold">
{" "}
{t("tutorial.descriptionRemove")}
</span>
. {t("tutorial.descriptionP3")}
</p>
</div>
{/* Visual Illustration */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
{t("tutorial.visualExample")}
</h3>
<div className="relative h-24 bg-[#000] rounded-lg border border-foreground/10 flex items-center px-4 overflow-hidden select-none">
{/* Background track (Kept parts) */}
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
{/* Solid line representing video */}
</div>
{/* Removed Segment 1 */}
<div
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "20%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
{t("tutorial.removed")}
</span>
</div>
{/* Removed Segment 2 */}
<div
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "15%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
{t("tutorial.removed")}
</span>
</div>
{/* Labels for kept parts */}
<div className="absolute left-[5%] text-[10px] text-muted-foreground font-medium">
{t("tutorial.kept")}
</div>
<div className="absolute left-[50%] text-[10px] text-muted-foreground font-medium">
{t("tutorial.kept")}
</div>
<div className="absolute left-[90%] text-[10px] text-muted-foreground font-medium">
{t("tutorial.kept")}
</div>
</div>
<div className="flex justify-center mt-2">
<ArrowRight className="w-4 h-4 text-muted-foreground rotate-90" />
</div>
{/* Result */}
<div className="relative h-12 bg-[#000] rounded-lg border border-foreground/10 flex items-center justify-center gap-1 px-4 select-none">
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
{t("tutorial.part", undefined, { number: "1" })}
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
{t("tutorial.part", undefined, { number: "2" })}
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
{t("tutorial.part", undefined, { number: "3" })}
</span>
</div>
<span className="absolute right-4 text-xs text-muted-foreground">
{t("tutorial.finalVideo")}
</span>
</div>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded bg-foreground/5 border border-foreground/5">
<div className="text-[#ef4444] font-bold mb-1">
{t("tutorial.addTrimStep")}
</div>
<p className="text-xs text-muted-foreground">
{t("tutorial.addTrimDesc")}
</p>
</div>
<div className="p-3 rounded bg-foreground/5 border border-foreground/5">
<div className="text-[#ef4444] font-bold mb-1">
{t("tutorial.adjustStep")}
</div>
<p className="text-xs text-muted-foreground">
{t("tutorial.adjustDesc")}
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}