feat: AI SEO generator, full admin panel, i18n sweep, new logo + auth/RTL fixes
Build backend images / build content-svc (push) Failing after 3m39s
Build backend images / build file-svc (push) Failing after 52s
Build backend images / build gateway (push) Failing after 58s
Build backend images / build identity-svc (push) Failing after 1m21s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 55s
Build backend images / build content-svc (push) Failing after 3m39s
Build backend images / build file-svc (push) Failing after 52s
Build backend images / build gateway (push) Failing after 58s
Build backend images / build identity-svc (push) Failing after 1m21s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 55s
AI SEO content generator - content-svc: per-tenant OpenAI config (ai_settings) + /v1/ai endpoints (settings GET/PUT, seo-post) with SEO-expert prompt → structured article - admin UI to configure token/base-url/model and generate + save as blog - configurable base URL for restricted networks Full data-driven admin panel - generic /api/admin/resource proxy + reusable AdminResource component - categories/tags/fonts/blogs (CRUD), users (list + ban), plans/slides - AI content section; nav + i18n i18n localization sweep - localized 116 user-facing + studio/editor components to next-intl (fa+en) under the auto.* namespace; merge tooling in scripts/merge-i18n.js Branding + assets - Monoline F logo (LogoMark + favicon) - offline SVG placeholder generator (/api/placeholder), dropped picsum.photos Fixes - JWT issuer mismatch on content/studio (flatrender → flatrender-identity) - missing role claim → [Authorize(Roles="Admin")] now works (RBAC) - Secure cookies broke HTTP sessions → gated behind AUTH_COOKIE_SECURE - Radix RTL via DirectionProvider (right-aligned menus in fa) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: "text" | "textarea" | "number" | "checkbox" | "select";
|
||||
options?: { value: string; label: string }[];
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
render?: (row: Record<string, unknown>) => ReactNode;
|
||||
}
|
||||
|
||||
export interface ResourceConfig {
|
||||
title: string;
|
||||
description?: string;
|
||||
basePath: string; // e.g. "categories"
|
||||
idKey?: string; // default "id"
|
||||
listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array
|
||||
columns: ColumnDef[];
|
||||
fields?: FieldDef[];
|
||||
canCreate?: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
rowActions?: (row: Record<string, unknown>, reload: () => void) => ReactNode;
|
||||
}
|
||||
|
||||
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
||||
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||
const btnGhost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]";
|
||||
const inputCls = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||
|
||||
export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
const idKey = config.idKey ?? "id";
|
||||
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Record<string, unknown> | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<Record<string, unknown>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const url = (suffix = "") => `/api/admin/resource/${config.basePath}${suffix}`;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(url(), { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? "Failed to load");
|
||||
const list = config.listKey ? data?.[config.listKey] : data;
|
||||
setRows(Array.isArray(list) ? list : (data?.items ?? []));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.basePath, config.listKey]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const openCreate = () => {
|
||||
const init: Record<string, unknown> = {};
|
||||
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
|
||||
setForm(init);
|
||||
setCreating(true);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const openEdit = (row: Record<string, unknown>) => {
|
||||
const init: Record<string, unknown> = {};
|
||||
config.fields?.forEach((f) => (init[f.key] = row[f.key] ?? (f.type === "checkbox" ? false : "")));
|
||||
setForm(init);
|
||||
setEditing(row);
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
setForm({});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const isEdit = !!editing;
|
||||
const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), {
|
||||
method: isEdit ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error(data?.error ?? "Save failed");
|
||||
closeForm();
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (row: Record<string, unknown>) => {
|
||||
if (!confirm(`Delete this ${config.title.replace(/s$/, "").toLowerCase()}?`)) return;
|
||||
const res = await fetch(url(`/${row[idKey]}`), { method: "DELETE" });
|
||||
if (res.ok) reload();
|
||||
else {
|
||||
const d = await res.json().catch(() => null);
|
||||
setError(d?.error ?? "Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">{config.title}</h1>
|
||||
{config.description && <p className="mt-1 text-sm text-gray-400">{config.description}</p>}
|
||||
</div>
|
||||
{config.canCreate && config.fields && (
|
||||
<button className={btn} onClick={openCreate}>+ New</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
|
||||
|
||||
<div className={`${card} overflow-hidden`}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e2235] text-left text-xs text-gray-500">
|
||||
{config.columns.map((c) => (
|
||||
<th key={c.key} className="px-4 py-3 font-medium">{c.label}</th>
|
||||
))}
|
||||
{(config.canEdit || config.canDelete || config.rowActions) && (
|
||||
<th className="px-4 py-3 text-right font-medium">Actions</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>Loading…</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>No records.</td></tr>
|
||||
) : (
|
||||
rows.map((row, i) => (
|
||||
<tr key={String(row[idKey] ?? i)} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
||||
{config.columns.map((c) => (
|
||||
<td key={c.key} className="px-4 py-3 text-gray-200">
|
||||
{c.render ? c.render(row) : formatCell(row[c.key])}
|
||||
</td>
|
||||
))}
|
||||
{(config.canEdit || config.canDelete || config.rowActions) && (
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{config.rowActions?.(row, reload)}
|
||||
{config.canEdit && config.fields && (
|
||||
<button className={btnGhost} onClick={() => openEdit(row)}>Edit</button>
|
||||
)}
|
||||
{config.canDelete && (
|
||||
<button
|
||||
className="rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => remove(row)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{(creating || editing) && config.fields && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={closeForm}>
|
||||
<div className={`${card} w-full max-w-lg p-5`} onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
{editing ? "Edit" : "New"} {config.title.replace(/s$/, "")}
|
||||
</h2>
|
||||
<div className="mt-4 grid max-h-[60vh] gap-3 overflow-y-auto pr-1">
|
||||
{config.fields.map((f) => (
|
||||
<div key={f.key}>
|
||||
{f.type !== "checkbox" && (
|
||||
<label className="mb-1 block text-xs font-medium text-gray-400">
|
||||
{f.label}{f.required && <span className="text-red-400"> *</span>}
|
||||
</label>
|
||||
)}
|
||||
{f.type === "textarea" ? (
|
||||
<textarea className={`${inputCls} min-h-[80px]`} placeholder={f.placeholder}
|
||||
value={String(form[f.key] ?? "")} onChange={(e) => setForm({ ...form, [f.key]: e.target.value })} />
|
||||
) : f.type === "select" ? (
|
||||
<select className={inputCls} value={String(form[f.key] ?? "")}
|
||||
onChange={(e) => setForm({ ...form, [f.key]: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{f.options?.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : f.type === "checkbox" ? (
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" checked={!!form[f.key]} onChange={(e) => setForm({ ...form, [f.key]: e.target.checked })} />
|
||||
{f.label}
|
||||
</label>
|
||||
) : (
|
||||
<input type={f.type === "number" ? "number" : "text"} className={inputCls} placeholder={f.placeholder}
|
||||
value={String(form[f.key] ?? "")}
|
||||
onChange={(e) => setForm({ ...form, [f.key]: f.type === "number" ? Number(e.target.value) : e.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<button className={btnGhost} onClick={closeForm}>Cancel</button>
|
||||
<button className={btn} onClick={submit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCell(v: unknown): ReactNode {
|
||||
if (v === null || v === undefined || v === "") return <span className="text-gray-600">—</span>;
|
||||
if (typeof v === "boolean") return v ? "✓" : "✗";
|
||||
if (Array.isArray(v)) return v.join(", ");
|
||||
if (typeof v === "object") return JSON.stringify(v).slice(0, 40);
|
||||
const s = String(v);
|
||||
return s.length > 60 ? s.slice(0, 60) + "…" : s;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AiSettings {
|
||||
provider: string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
enabled: boolean;
|
||||
has_api_key: boolean;
|
||||
api_key_masked: string | null;
|
||||
}
|
||||
|
||||
interface SeoPost {
|
||||
title: string;
|
||||
slug: string;
|
||||
meta_title: string;
|
||||
meta_description: string;
|
||||
keywords: string[];
|
||||
short_description: string;
|
||||
content_html: string;
|
||||
}
|
||||
|
||||
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5";
|
||||
const label = "block text-xs font-medium text-gray-400 mb-1";
|
||||
const input =
|
||||
"w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||
const btn =
|
||||
"rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||
const btnGhost =
|
||||
"rounded-lg border border-[#262b40] px-4 py-2 text-sm text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
|
||||
|
||||
export function AiContentStudio() {
|
||||
const t = useTranslations("auto.adminAi");
|
||||
|
||||
const [settings, setSettings] = useState<AiSettings | null>(null);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [settingsMsg, setSettingsMsg] = useState<string | null>(null);
|
||||
|
||||
// Generation form
|
||||
const [description, setDescription] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [audience, setAudience] = useState("");
|
||||
const [locale, setLocale] = useState("fa");
|
||||
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [genError, setGenError] = useState<string | null>(null);
|
||||
const [post, setPost] = useState<SeoPost | null>(null);
|
||||
const [publishNow, setPublishNow] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
const res = await fetch("/api/admin/ai/settings", { cache: "no-store" });
|
||||
if (res.ok) setSettings(await res.json());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!settings) return;
|
||||
setSavingSettings(true);
|
||||
setSettingsMsg(null);
|
||||
const res = await fetch("/api/admin/ai/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: settings.provider,
|
||||
base_url: settings.base_url,
|
||||
model: settings.model,
|
||||
enabled: settings.enabled,
|
||||
api_key: apiKey.trim() === "" ? null : apiKey.trim(),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings(await res.json());
|
||||
setApiKey("");
|
||||
setSettingsMsg(t("settingsSaved"));
|
||||
} else {
|
||||
setSettingsMsg(t("settingsError"));
|
||||
}
|
||||
setSavingSettings(false);
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
setGenerating(true);
|
||||
setGenError(null);
|
||||
setSaveMsg(null);
|
||||
const res = await fetch("/api/admin/ai/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
description,
|
||||
title: title || null,
|
||||
type: type || null,
|
||||
tags: tags ? tags.split(",").map((s) => s.trim()).filter(Boolean) : null,
|
||||
keyword: keyword || null,
|
||||
audience: audience || null,
|
||||
locale,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok) setPost(data);
|
||||
else setGenError(data?.error ?? t("generateError"));
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const saveAsBlog = async () => {
|
||||
if (!post) return;
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
const res = await fetch("/api/admin/ai/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
short_description: post.short_description,
|
||||
content: post.content_html,
|
||||
meta_title: post.meta_title,
|
||||
meta_description: post.meta_description,
|
||||
meta_keywords: post.keywords.join(", "),
|
||||
include_in_site_map: true,
|
||||
is_published: publishNow,
|
||||
}),
|
||||
});
|
||||
setSaveMsg(res.ok ? t("savedAsBlog") : t("saveError"));
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const setPostField = (k: keyof SeoPost, v: string) =>
|
||||
setPost((p) => (p ? { ...p, [k]: v } : p));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">{t("pageTitle")}</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">{t("pageDesc")}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Settings ─────────────────────────────────────────── */}
|
||||
<section className={card}>
|
||||
<h2 className="text-sm font-semibold text-white">{t("settingsTitle")}</h2>
|
||||
<p className="mt-1 text-xs text-gray-500">{t("settingsDesc")}</p>
|
||||
{settings && (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className={label}>{t("apiKeyLabel")}</label>
|
||||
<input
|
||||
className={input}
|
||||
type="password"
|
||||
placeholder={settings.api_key_masked ?? t("apiKeyPlaceholder")}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-gray-500">
|
||||
{settings.has_api_key ? `✓ ${t("keyConfigured")}` : t("noKey")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("baseUrlLabel")}</label>
|
||||
<input
|
||||
className={input}
|
||||
value={settings.base_url}
|
||||
onChange={(e) => setSettings({ ...settings, base_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("modelLabel")}</label>
|
||||
<input
|
||||
className={input}
|
||||
value={settings.model}
|
||||
onChange={(e) => setSettings({ ...settings, model: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
{t("enabledLabel")}
|
||||
</label>
|
||||
<div className="flex items-center gap-3 sm:col-span-2">
|
||||
<button className={btn} onClick={saveSettings} disabled={savingSettings}>
|
||||
{savingSettings ? t("saving") : t("saveSettings")}
|
||||
</button>
|
||||
{settingsMsg && <span className="text-xs text-gray-400">{settingsMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Generator ────────────────────────────────────────── */}
|
||||
<section className={card}>
|
||||
<h2 className="text-sm font-semibold text-white">{t("generateTitle")}</h2>
|
||||
<p className="mt-1 text-xs text-gray-500">{t("generateDesc")}</p>
|
||||
{settings && !settings.enabled && (
|
||||
<p className="mt-3 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
|
||||
{t("mustConfigure")}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 grid gap-4">
|
||||
<div>
|
||||
<label className={label}>{t("descriptionLabel")}</label>
|
||||
<textarea
|
||||
className={`${input} min-h-[100px]`}
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={label}>{t("titleLabel")}</label>
|
||||
<input className={input} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("typeLabel")}</label>
|
||||
<input className={input} placeholder={t("typePlaceholder")} value={type} onChange={(e) => setType(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("keywordLabel")}</label>
|
||||
<input className={input} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("audienceLabel")}</label>
|
||||
<input className={input} value={audience} onChange={(e) => setAudience(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("tagsLabel")}</label>
|
||||
<input className={input} value={tags} onChange={(e) => setTags(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("localeLabel")}</label>
|
||||
<select className={input} value={locale} onChange={(e) => setLocale(e.target.value)}>
|
||||
<option value="fa">{t("localeFa")}</option>
|
||||
<option value="en">{t("localeEn")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className={btn} onClick={generate} disabled={generating || !description.trim()}>
|
||||
{generating ? t("generating") : t("generate")}
|
||||
</button>
|
||||
{genError && <span className="text-xs text-red-400">{genError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Result ───────────────────────────────────────────── */}
|
||||
{post && (
|
||||
<section className={card}>
|
||||
<h2 className="text-sm font-semibold text-white">{t("resultTitle")}</h2>
|
||||
<div className="mt-4 grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={label}>{t("fTitle")}</label>
|
||||
<input className={input} value={post.title} onChange={(e) => setPostField("title", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("fSlug")}</label>
|
||||
<input className={input} value={post.slug} onChange={(e) => setPostField("slug", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("fMetaTitle")}</label>
|
||||
<input className={input} value={post.meta_title} onChange={(e) => setPostField("meta_title", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("fKeywords")}</label>
|
||||
<input className={input} value={post.keywords.join(", ")} onChange={(e) => setPost({ ...post, keywords: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("fMetaDesc")}</label>
|
||||
<textarea className={`${input} min-h-[60px]`} value={post.meta_description} onChange={(e) => setPostField("meta_description", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("fShortDesc")}</label>
|
||||
<textarea className={`${input} min-h-[60px]`} value={post.short_description} onChange={(e) => setPostField("short_description", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("fContent")}</label>
|
||||
<textarea className={`${input} min-h-[200px] font-mono text-xs`} value={post.content_html} onChange={(e) => setPostField("content_html", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>{t("preview")}</label>
|
||||
<div
|
||||
className="prose prose-invert max-w-none rounded-lg border border-[#262b40] bg-white p-4 text-black"
|
||||
dangerouslySetInnerHTML={{ __html: post.content_html }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" checked={publishNow} onChange={(e) => setPublishNow(e.target.checked)} />
|
||||
{t("publishNow")}
|
||||
</label>
|
||||
<button className={btnGhost} onClick={saveAsBlog} disabled={saving}>
|
||||
{saving ? t("saving") : t("saveAsBlog")}
|
||||
</button>
|
||||
{saveMsg && <span className="text-xs text-gray-400">{saveMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiFetch } from "@/lib/api/fetch";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -31,6 +32,7 @@ function heartbeatAge(iso: string): string {
|
||||
}
|
||||
|
||||
export function NodesTable({ nodes }: { nodes: V2Node[] }) {
|
||||
const t = useTranslations("auto.componentsAdminNodesTable");
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -47,7 +49,7 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
|
||||
No nodes registered. Start the node agent on a render machine to see it here.
|
||||
{t("emptyState")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,13 +59,13 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">Node</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Slots</th>
|
||||
<th className="px-4 py-3">Heartbeat</th>
|
||||
<th className="px-4 py-3">Active Job</th>
|
||||
<th className="px-4 py-3">Tags</th>
|
||||
<th className="px-4 py-3">Actions</th>
|
||||
<th className="px-4 py-3">{t("colNode")}</th>
|
||||
<th className="px-4 py-3">{t("colStatus")}</th>
|
||||
<th className="px-4 py-3">{t("colSlots")}</th>
|
||||
<th className="px-4 py-3">{t("colHeartbeat")}</th>
|
||||
<th className="px-4 py-3">{t("colActiveJob")}</th>
|
||||
<th className="px-4 py-3">{t("colTags")}</th>
|
||||
<th className="px-4 py-3">{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
|
||||
@@ -103,14 +105,14 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
|
||||
disabled={loading[node.id] || node.status === "Offline"}
|
||||
className="rounded px-2.5 py-1 text-xs text-yellow-300 border border-yellow-500/30 hover:bg-yellow-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Drain
|
||||
{t("actionDrain")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action(node.id, "release")}
|
||||
disabled={loading[node.id]}
|
||||
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Release
|
||||
{t("actionRelease")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api/fetch";
|
||||
import type { V2RenderJob } from "@/app/[locale]/admin/renders/page";
|
||||
@@ -33,6 +34,7 @@ function relativeTime(iso: string): string {
|
||||
}
|
||||
|
||||
export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
||||
const t = useTranslations("auto.componentsAdminRenderQueueTable");
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -59,7 +61,7 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
||||
if (jobs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
|
||||
No render jobs found for the selected filter.
|
||||
{t("emptyState")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,14 +71,14 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">Job ID</th>
|
||||
<th className="px-4 py-3">Project</th>
|
||||
<th className="px-4 py-3">Step</th>
|
||||
<th className="px-4 py-3">Progress</th>
|
||||
<th className="px-4 py-3">Quality</th>
|
||||
<th className="px-4 py-3">Node</th>
|
||||
<th className="px-4 py-3">Created</th>
|
||||
<th className="px-4 py-3">Actions</th>
|
||||
<th className="px-4 py-3">{t("colJobId")}</th>
|
||||
<th className="px-4 py-3">{t("colProject")}</th>
|
||||
<th className="px-4 py-3">{t("colStep")}</th>
|
||||
<th className="px-4 py-3">{t("colProgress")}</th>
|
||||
<th className="px-4 py-3">{t("colQuality")}</th>
|
||||
<th className="px-4 py-3">{t("colNode")}</th>
|
||||
<th className="px-4 py-3">{t("colCreated")}</th>
|
||||
<th className="px-4 py-3">{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
|
||||
@@ -133,7 +135,7 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
||||
disabled={loading[job.id]}
|
||||
className="rounded px-2.5 py-1 text-xs text-emerald-300 border border-emerald-500/30 hover:bg-emerald-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Retry
|
||||
{t("actionRetry")}
|
||||
</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
@@ -142,7 +144,7 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
||||
disabled={loading[job.id]}
|
||||
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("actionCancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import type { ResourceConfig } from "@/components/admin/AdminResource";
|
||||
|
||||
const badge = (ok: boolean, yes: string, no: string) =>
|
||||
ok ? (
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[11px] text-emerald-300">{yes}</span>
|
||||
) : (
|
||||
<span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[11px] text-gray-400">{no}</span>
|
||||
);
|
||||
|
||||
const banAction = (row: Record<string, unknown>, reload: () => void) => {
|
||||
const banned = !!row.ban_account;
|
||||
return (
|
||||
<button
|
||||
className={
|
||||
banned
|
||||
? "rounded-lg border border-emerald-500/30 px-3 py-1.5 text-xs text-emerald-300 hover:bg-emerald-500/10"
|
||||
: "rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
|
||||
}
|
||||
onClick={async () => {
|
||||
const reason = banned ? "" : prompt("Ban reason?") ?? "";
|
||||
if (!banned && !reason) return;
|
||||
const res = await fetch(`/api/admin/resource/users/${row.id}/ban`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: banned ? "unban" : reason, unbanned: banned }),
|
||||
});
|
||||
if (res.ok) reload();
|
||||
}}
|
||||
>
|
||||
{banned ? "Unban" : "Ban"}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const categoriesConfig: ResourceConfig = {
|
||||
title: "Categories",
|
||||
description: "Taxonomy used across templates and the public site.",
|
||||
basePath: "categories",
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "slug", label: "Slug" },
|
||||
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
|
||||
{ key: "sort", label: "Sort" },
|
||||
],
|
||||
fields: [
|
||||
{ key: "name", label: "Name", required: true },
|
||||
{ key: "slug", label: "Slug", required: true },
|
||||
{ key: "description", label: "Description", type: "textarea" },
|
||||
{ key: "image_url", label: "Image URL" },
|
||||
{ key: "icon", label: "Icon" },
|
||||
],
|
||||
};
|
||||
|
||||
export const tagsConfig: ResourceConfig = {
|
||||
title: "Tags",
|
||||
description: "Keyword tags for templates and content.",
|
||||
basePath: "tags",
|
||||
listKey: "items",
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "slug", label: "Slug" },
|
||||
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
|
||||
],
|
||||
fields: [
|
||||
{ key: "name", label: "Name", required: true },
|
||||
{ key: "latin_name", label: "Latin name" },
|
||||
{ key: "slug", label: "Slug", required: true },
|
||||
{ key: "is_active", label: "Active", type: "checkbox", defaultValue: true },
|
||||
],
|
||||
};
|
||||
|
||||
export const fontsConfig: ResourceConfig = {
|
||||
title: "Fonts",
|
||||
description: "Fonts available in the studio editors.",
|
||||
basePath: "fonts",
|
||||
listKey: "items",
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "family", label: "Family" },
|
||||
{ key: "weight", label: "Weight" },
|
||||
{ key: "style", label: "Style" },
|
||||
],
|
||||
fields: [
|
||||
{ key: "name", label: "Name", required: true },
|
||||
{ key: "original_name", label: "Original name" },
|
||||
{ key: "system_name", label: "System name" },
|
||||
{ key: "family", label: "Family" },
|
||||
{ key: "weight", label: "Weight", type: "number" },
|
||||
{ key: "style", label: "Style" },
|
||||
],
|
||||
};
|
||||
|
||||
export const blogsConfig: ResourceConfig = {
|
||||
title: "Blog Posts",
|
||||
description: "CMS articles (also created by the AI SEO generator).",
|
||||
basePath: "blogs",
|
||||
listKey: "items",
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "slug", label: "Slug" },
|
||||
{ key: "is_published", label: "Published", render: (r) => badge(!!r.is_published, "live", "draft") },
|
||||
{ key: "view_count", label: "Views" },
|
||||
],
|
||||
fields: [
|
||||
{ key: "title", label: "Title", required: true },
|
||||
{ key: "slug", label: "Slug", required: true },
|
||||
{ key: "short_description", label: "Short description", type: "textarea" },
|
||||
{ key: "content", label: "Content (HTML)", type: "textarea", required: true },
|
||||
{ key: "meta_title", label: "Meta title" },
|
||||
{ key: "meta_description", label: "Meta description", type: "textarea" },
|
||||
{ key: "meta_keywords", label: "Meta keywords" },
|
||||
{ key: "is_published", label: "Published", type: "checkbox" },
|
||||
{ key: "include_in_site_map", label: "Include in sitemap", type: "checkbox", defaultValue: true },
|
||||
],
|
||||
};
|
||||
|
||||
export const slidesConfig: ResourceConfig = {
|
||||
title: "Home Slides",
|
||||
description: "Hero/promo slides on the homepage.",
|
||||
basePath: "slides",
|
||||
canDelete: true,
|
||||
columns: [
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "slide_type", label: "Type" },
|
||||
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "hidden") },
|
||||
],
|
||||
};
|
||||
|
||||
export const usersConfig: ResourceConfig = {
|
||||
title: "Users",
|
||||
description: "Accounts in this tenant. Ban or unban below.",
|
||||
basePath: "users",
|
||||
listKey: "data",
|
||||
columns: [
|
||||
{ key: "email", label: "Email" },
|
||||
{ key: "full_name", label: "Name" },
|
||||
{ key: "is_admin", label: "Admin", render: (r) => badge(!!r.is_admin, "admin", "—") },
|
||||
{ key: "register_mode", label: "Source" },
|
||||
{ key: "ban_account", label: "Status", render: (r) => badge(!r.ban_account, "active", "banned") },
|
||||
],
|
||||
rowActions: banAction,
|
||||
};
|
||||
|
||||
export const plansConfig: ResourceConfig = {
|
||||
title: "Plans",
|
||||
description: "Subscription plans (read-only view).",
|
||||
basePath: "plans",
|
||||
listKey: "data",
|
||||
columns: [
|
||||
{ key: "code", label: "Code" },
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "price_minor", label: "Price (minor)" },
|
||||
{ key: "billing_period", label: "Period" },
|
||||
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "off") },
|
||||
],
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { AuthLoadingSpinner } from "@/components/auth/AuthLoadingSpinner";
|
||||
import { authFormSchema, type AuthFormValues } from "@/components/auth/auth-schemas";
|
||||
@@ -22,6 +23,7 @@ function safeNext(next: string | null): string {
|
||||
}
|
||||
|
||||
export function AuthPageContent() {
|
||||
const t = useTranslations("auto.componentsAuthAuthPageContent");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = safeNext(searchParams.get("next"));
|
||||
@@ -107,7 +109,7 @@ export function AuthPageContent() {
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
setFormError(data?.error ?? "Something went wrong. Please try again.");
|
||||
setFormError(data?.error ?? t("genericError"));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -115,8 +117,8 @@ export function AuthPageContent() {
|
||||
if (data?.registered && !data?.user) {
|
||||
setFormMessage(
|
||||
data.verificationRequired
|
||||
? "Account created. Check your email to verify, then sign in."
|
||||
: "Account created. Please sign in."
|
||||
? t("accountCreatedVerify")
|
||||
: t("accountCreatedSignIn")
|
||||
);
|
||||
setActiveTab("sign-in");
|
||||
reset();
|
||||
@@ -127,7 +129,7 @@ export function AuthPageContent() {
|
||||
router.replace(nextPath);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setFormError("Network error. Please try again.");
|
||||
setFormError(t("networkError"));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
@@ -146,9 +148,9 @@ export function AuthPageContent() {
|
||||
});
|
||||
// Always succeed (anti-enumeration)
|
||||
setView("reset-confirm");
|
||||
setFormMessage("If that email is registered, we sent a reset code.");
|
||||
setFormMessage(t("resetCodeSent"));
|
||||
} catch {
|
||||
setFormError("Network error. Please try again.");
|
||||
setFormError(t("networkError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -168,13 +170,13 @@ export function AuthPageContent() {
|
||||
});
|
||||
const data = await res.json().catch(() => null) as { error?: string } | null;
|
||||
if (!res.ok) {
|
||||
setFormError(data?.error ?? "Invalid or expired code.");
|
||||
setFormError(data?.error ?? t("invalidCode"));
|
||||
} else {
|
||||
setFormMessage("Password updated. You can now sign in.");
|
||||
setFormMessage(t("passwordUpdated"));
|
||||
goBack();
|
||||
}
|
||||
} catch {
|
||||
setFormError("Network error. Please try again.");
|
||||
setFormError(t("networkError"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -183,7 +185,7 @@ export function AuthPageContent() {
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center py-20">
|
||||
<AuthLoadingSpinner label="Checking authentication..." />
|
||||
<AuthLoadingSpinner label={t("checkingAuth")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -194,12 +196,12 @@ export function AuthPageContent() {
|
||||
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
|
||||
<div className="text-center">
|
||||
<h1 className="font-heading text-2xl font-bold text-neutral-900">
|
||||
{view === "forgot-password" ? "Reset your password" : "Enter reset code"}
|
||||
{view === "forgot-password" ? t("resetTitle") : t("enterCodeTitle")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
{view === "forgot-password"
|
||||
? "We'll send a one-time code to your email."
|
||||
: `Check your email for the code sent to ${resetEmail}`}
|
||||
? t("resetSubtitle")
|
||||
: t("enterCodeSubtitle", { email: resetEmail })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -208,7 +210,7 @@ export function AuthPageContent() {
|
||||
<form onSubmit={handleForgotRequest} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<label htmlFor="reset-email" className="block text-sm font-medium text-neutral-700">
|
||||
Email address
|
||||
{t("emailAddressLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="reset-email"
|
||||
@@ -223,14 +225,14 @@ export function AuthPageContent() {
|
||||
{formError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>}
|
||||
<Button type="submit" className="w-full" disabled={submitting || !resetEmail}>
|
||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Send reset code
|
||||
{t("sendResetCode")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleResetConfirm} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<label htmlFor="reset-otp" className="block text-sm font-medium text-neutral-700">
|
||||
Reset code
|
||||
{t("resetCodeLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="reset-otp"
|
||||
@@ -240,13 +242,13 @@ export function AuthPageContent() {
|
||||
onChange={(e) => setResetOtp(e.target.value)}
|
||||
disabled={submitting}
|
||||
required
|
||||
placeholder="6-digit code"
|
||||
placeholder={t("resetCodePlaceholder")}
|
||||
className="mt-1.5 w-full rounded-lg border border-gray-100 bg-white px-3 py-2.5 text-sm text-neutral-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new-password" className="block text-sm font-medium text-neutral-700">
|
||||
New password
|
||||
{t("newPasswordLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
@@ -264,14 +266,14 @@ export function AuthPageContent() {
|
||||
{formMessage && <p className="rounded-lg bg-primary-50 px-3 py-2 text-sm text-primary-700">{formMessage}</p>}
|
||||
<Button type="submit" className="w-full" disabled={submitting || !resetOtp || !resetNewPassword}>
|
||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Set new password
|
||||
{t("setNewPassword")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={goBack} className="mt-4 block w-full text-center text-sm text-neutral-500 hover:text-neutral-700 transition-colors">
|
||||
← Back to sign in
|
||||
← {t("backToSignIn")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -282,12 +284,12 @@ export function AuthPageContent() {
|
||||
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
|
||||
<div className="text-center">
|
||||
<h1 className="font-heading text-3xl font-bold text-neutral-900">
|
||||
Welcome to FlatRender
|
||||
{t("welcomeTitle")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
{activeTab === "sign-in"
|
||||
? "Sign in to continue to your dashboard"
|
||||
: "Create a free account to get started"}
|
||||
? t("signInSubtitle")
|
||||
: t("signUpSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -304,7 +306,7 @@ export function AuthPageContent() {
|
||||
: "text-neutral-600 hover:text-neutral-900"
|
||||
)}
|
||||
>
|
||||
{tab === "sign-in" ? "Sign In" : "Sign Up"}
|
||||
{tab === "sign-in" ? t("signInTab") : t("signUpTab")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -313,7 +315,7 @@ export function AuthPageContent() {
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-neutral-700">
|
||||
Email
|
||||
{t("emailLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
@@ -334,7 +336,7 @@ export function AuthPageContent() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700">
|
||||
Password
|
||||
{t("passwordLabel")}
|
||||
</label>
|
||||
{activeTab === "sign-in" && (
|
||||
<button
|
||||
@@ -342,7 +344,7 @@ export function AuthPageContent() {
|
||||
onClick={() => { setView("forgot-password"); setFormError(null); setFormMessage(null); }}
|
||||
className="text-xs text-primary-600 hover:underline focus-visible:outline-none"
|
||||
>
|
||||
Forgot password?
|
||||
{t("forgotPassword")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -371,16 +373,24 @@ export function AuthPageContent() {
|
||||
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||
{activeTab === "sign-in" ? "Sign In" : "Create Account"}
|
||||
{activeTab === "sign-in" ? t("signInTab") : t("createAccount")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-neutral-500">
|
||||
By continuing, you agree to our{" "}
|
||||
<Link href="/terms" className="text-primary-600 hover:underline">Terms</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/privacy" className="text-primary-600 hover:underline">Privacy Policy</Link>.
|
||||
{t.rich("legalNotice", {
|
||||
terms: (chunks) => (
|
||||
<Link href="/terms" className="text-primary-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
privacy: (chunks) => (
|
||||
<Link href="/privacy" className="text-primary-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -10,6 +11,7 @@ interface SupabaseSetupNoticeProps {
|
||||
}
|
||||
|
||||
export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
|
||||
const t = useTranslations("auto.componentsAuthSupabaseSetupNotice");
|
||||
const router = useRouter();
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const continueHref = nextPath?.startsWith("/") ? nextPath : "/dashboard";
|
||||
@@ -18,15 +20,23 @@ export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
|
||||
<div className="mx-auto w-full max-w-md px-4 py-12 sm:py-16">
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-6 text-center shadow-sm">
|
||||
<h1 className="font-heading text-xl font-bold text-neutral-900">
|
||||
Supabase not configured
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-neutral-600">
|
||||
Copy <code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.example</code>{" "}
|
||||
to <code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.local</code> and set{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_URL</code>{" "}
|
||||
and{" "}
|
||||
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_ANON_KEY</code>
|
||||
, then restart the dev server.
|
||||
{t.rich("instructions", {
|
||||
envExample: () => (
|
||||
<code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.example</code>
|
||||
),
|
||||
envLocal: () => (
|
||||
<code className="rounded bg-white px-1.5 py-0.5 text-xs">.env.local</code>
|
||||
),
|
||||
supabaseUrl: () => (
|
||||
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_URL</code>
|
||||
),
|
||||
supabaseAnonKey: () => (
|
||||
<code className="rounded bg-white px-1.5 py-0.5 text-xs">NEXT_PUBLIC_SUPABASE_ANON_KEY</code>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
{isDev ? (
|
||||
<Button
|
||||
@@ -34,11 +44,11 @@ export function SupabaseSetupNotice({ nextPath }: SupabaseSetupNoticeProps) {
|
||||
className="mt-6 w-full"
|
||||
onClick={() => router.push(continueHref)}
|
||||
>
|
||||
Continue without signing in (dev only)
|
||||
{t("continueDev")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" className="mt-6 w-full" asChild>
|
||||
<Link href="/">Back to home</Link>
|
||||
<Link href="/">{t("backToHome")}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
|
||||
|
||||
export function DashboardEmptyState() {
|
||||
const t = useTranslations("auto.componentsDashboardDashboardEmptyState");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-gray-200 bg-neutral-50 px-6 py-20 text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary-50 text-primary-600">
|
||||
<FolderOpen className="h-10 w-10" aria-hidden />
|
||||
</div>
|
||||
<h3 className="mt-6 font-heading text-xl font-semibold text-neutral-900">
|
||||
No projects yet
|
||||
{t("title")}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-sm text-sm text-neutral-600">
|
||||
Create a video, image, or trim project to see it here. Everything you
|
||||
save appears in this workspace.
|
||||
{t("description")}
|
||||
</p>
|
||||
<NewProjectMenu
|
||||
triggerLabel="Create your first project"
|
||||
triggerLabel={t("createFirstProject")}
|
||||
triggerClassName="mt-8 gap-2"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getUserProfile } from "@/lib/profiles";
|
||||
@@ -16,6 +17,7 @@ interface DashboardPlanBadgeProps {
|
||||
}
|
||||
|
||||
export async function DashboardPlanBadge({ userId }: DashboardPlanBadgeProps) {
|
||||
const t = await getTranslations("auto.componentsDashboardDashboardPlanBadge");
|
||||
const profile = await getUserProfile(userId);
|
||||
|
||||
return (
|
||||
@@ -30,7 +32,7 @@ export async function DashboardPlanBadge({ userId }: DashboardPlanBadgeProps) {
|
||||
</p>
|
||||
{profile.plan !== "business" ? (
|
||||
<Button size="sm" className="mt-3 w-full" asChild>
|
||||
<Link href="/#pricing">Upgrade plan</Link>
|
||||
<Link href="/#pricing">{t("upgradePlan")}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { DashboardEmptyState } from "@/components/dashboard/DashboardEmptyState";
|
||||
import { DashboardTopBar } from "@/components/dashboard/DashboardTopBar";
|
||||
@@ -19,6 +20,7 @@ export function DashboardProjectsSection({
|
||||
projects = [],
|
||||
isLoading = false,
|
||||
}: DashboardProjectsSectionProps) {
|
||||
const t = useTranslations("auto.componentsDashboardDashboardProjectsSection");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
@@ -42,7 +44,7 @@ export function DashboardProjectsSection({
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<h2 className="font-heading text-xl font-bold text-neutral-900">
|
||||
Recent Projects
|
||||
{t("recentProjects")}
|
||||
</h2>
|
||||
|
||||
{showEmpty && (
|
||||
@@ -62,10 +64,10 @@ export function DashboardProjectsSection({
|
||||
{showNoResults && (
|
||||
<div className="mt-8 rounded-xl border border-dashed border-gray-200 bg-neutral-50 px-6 py-12 text-center">
|
||||
<p className="font-heading text-lg font-semibold text-neutral-900">
|
||||
No projects match your search
|
||||
{t("noResultsTitle")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
Try a different keyword or clear the search bar.
|
||||
{t("noResultsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { LogoMark } from "@/components/ui/LogoMark";
|
||||
|
||||
import {
|
||||
@@ -26,11 +27,12 @@ function getInitials(email: string, name?: string | null): string {
|
||||
return email.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function DashboardSidebar({
|
||||
export async function DashboardSidebar({
|
||||
userEmail,
|
||||
userName,
|
||||
userId,
|
||||
}: DashboardSidebarProps) {
|
||||
const t = await getTranslations("auto.componentsDashboardDashboardSidebar");
|
||||
const initials = getInitials(userEmail, userName);
|
||||
|
||||
return (
|
||||
@@ -52,7 +54,7 @@ export function DashboardSidebar({
|
||||
<div className="border-t border-gray-100 p-4">
|
||||
<div className="mb-3 rounded-lg border border-gray-100 bg-neutral-50 p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Current plan
|
||||
{t("currentPlan")}
|
||||
</p>
|
||||
<Suspense fallback={<DashboardPlanBadgeSkeleton />}>
|
||||
<DashboardPlanBadge userId={userId} />
|
||||
@@ -78,7 +80,7 @@ export function DashboardSidebar({
|
||||
type="submit"
|
||||
className="w-full rounded-lg px-3 py-2 text-left text-sm text-neutral-600 transition-colors hover:bg-neutral-50 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
Sign out
|
||||
{t("signOut")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
FolderOpen,
|
||||
LayoutTemplate,
|
||||
@@ -12,17 +13,18 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ label: "My Projects", href: "/dashboard", icon: FolderOpen },
|
||||
{ label: "Templates", href: "/templates", icon: LayoutTemplate },
|
||||
{ label: "Upgrade", href: "/#pricing", icon: Zap },
|
||||
{ label: "Settings", href: "/dashboard/settings", icon: Settings },
|
||||
{ labelKey: "myProjects", href: "/dashboard", icon: FolderOpen },
|
||||
{ labelKey: "templates", href: "/templates", icon: LayoutTemplate },
|
||||
{ labelKey: "upgrade", href: "/#pricing", icon: Zap },
|
||||
{ labelKey: "settings", href: "/dashboard/settings", icon: Settings },
|
||||
] as const;
|
||||
|
||||
export function DashboardSidebarNav() {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("auto.componentsDashboardDashboardSidebarNav");
|
||||
|
||||
return (
|
||||
<nav className="flex-1 space-y-1 px-3 py-4" aria-label="Dashboard">
|
||||
<nav className="flex-1 space-y-1 px-3 py-4" aria-label={t("navLabel")}>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
@@ -32,7 +34,7 @@ export function DashboardSidebarNav() {
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
key={item.labelKey}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2",
|
||||
@@ -42,7 +44,7 @@ export function DashboardSidebarNav() {
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { NewProjectMenu } from "@/components/dashboard/NewProjectMenu";
|
||||
|
||||
@@ -13,6 +14,8 @@ export function DashboardTopBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: DashboardTopBarProps) {
|
||||
const t = useTranslations("auto.componentsDashboardDashboardTopBar");
|
||||
|
||||
return (
|
||||
<header className="flex flex-col gap-4 border-b border-gray-100 bg-white px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="relative max-w-md flex-1">
|
||||
@@ -24,7 +27,7 @@ export function DashboardTopBar({
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search projects..."
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-full rounded-lg border border-gray-100 bg-neutral-50 py-2.5 pl-10 pr-4 text-sm text-neutral-900 placeholder:text-neutral-400 focus-visible:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Clapperboard, ImageIcon, Plus, Scissors } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -20,12 +21,14 @@ interface NewProjectMenuProps {
|
||||
}
|
||||
|
||||
export function NewProjectMenu({
|
||||
triggerLabel = "New Project",
|
||||
triggerLabel,
|
||||
triggerClassName,
|
||||
align = "end",
|
||||
}: NewProjectMenuProps) {
|
||||
const t = useTranslations("auto.componentsDashboardNewProjectMenu");
|
||||
const router = useRouter();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const label = triggerLabel ?? t("newProject");
|
||||
|
||||
const createProject = async (type: ProjectType) => {
|
||||
setIsCreating(true);
|
||||
@@ -64,7 +67,7 @@ export function NewProjectMenu({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className={triggerClassName} disabled={isCreating}>
|
||||
<Plus className="h-4 w-4" aria-hidden />
|
||||
{isCreating ? "Creating…" : triggerLabel}
|
||||
{isCreating ? t("creating") : label}
|
||||
<ChevronDown className="h-4 w-4 opacity-80" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -74,21 +77,21 @@ export function NewProjectMenu({
|
||||
onClick={() => router.push("/studio/video/new")}
|
||||
>
|
||||
<Clapperboard className="h-4 w-4 text-primary-600" />
|
||||
Video Project
|
||||
{t("videoProject")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onClick={() => createProject("image")}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4 text-violet-600" />
|
||||
Image Project
|
||||
{t("imageProject")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2"
|
||||
onClick={() => createProject("trimmer")}
|
||||
>
|
||||
<Scissors className="h-4 w-4 text-amber-600" />
|
||||
Trim/Crop Video
|
||||
{t("trimCropVideo")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Copy, Download, ExternalLink, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
@@ -39,14 +40,14 @@ function statusBadgeClass(status: DashboardProject["status"]): string {
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: DashboardProject["status"]): string {
|
||||
function statusLabelKey(status: DashboardProject["status"]): "statusRendering" | "statusReady" | "statusDraft" {
|
||||
switch (status) {
|
||||
case "rendering":
|
||||
return "Rendering";
|
||||
return "statusRendering";
|
||||
case "ready":
|
||||
return "Ready";
|
||||
return "statusReady";
|
||||
default:
|
||||
return "Draft";
|
||||
return "statusDraft";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +67,7 @@ function typeBadgeClass(type: DashboardProject["type"]): string {
|
||||
const fadeTransition = { duration: 0.25, ease: "easeOut" as const };
|
||||
|
||||
export function ProjectCard({ project }: ProjectCardProps) {
|
||||
const t = useTranslations("auto.componentsDashboardProjectCard");
|
||||
const studioPath = getProjectStudioPath(project);
|
||||
const showRenderStatus = project.type === "video";
|
||||
|
||||
@@ -133,7 +135,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
||||
>
|
||||
<Link href={studioPath}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Open in Studio
|
||||
{t("openInStudio")}
|
||||
</Link>
|
||||
</Button>
|
||||
{project.status === "ready" && project.renderUrl ? (
|
||||
@@ -145,7 +147,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
||||
>
|
||||
<a href={project.renderUrl} download>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
{t("download")}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -173,7 +175,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
||||
statusBadgeClass(project.status)
|
||||
)}
|
||||
>
|
||||
{statusLabel(project.status)}
|
||||
{t(statusLabelKey(project.status))}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-xs text-neutral-500">
|
||||
@@ -185,7 +187,7 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||
aria-label={`Actions for ${project.name}`}
|
||||
aria-label={t("actionsFor", { name: project.name })}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -193,30 +195,30 @@ export function ProjectCard({ project }: ProjectCardProps) {
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={studioPath} className="gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open in Studio
|
||||
{t("openInStudio")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{project.renderUrl ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={project.renderUrl} download className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
{t("download")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<Pencil className="h-4 w-4" />
|
||||
Rename
|
||||
{t("rename")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<Copy className="h-4 w-4" />
|
||||
Duplicate
|
||||
{t("duplicate")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2 text-red-600 focus:text-red-600">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CreditCard, Loader2, Zap } from "lucide-react";
|
||||
|
||||
import { apiFetch } from "@/lib/api/fetch";
|
||||
@@ -10,10 +11,10 @@ interface SettingsBillingProps {
|
||||
plan: PlanId;
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<PlanId, string> = {
|
||||
free: "Free",
|
||||
pro: "Pro",
|
||||
business: "Business",
|
||||
const PLAN_LABEL_KEYS: Record<PlanId, string> = {
|
||||
free: "planFree",
|
||||
pro: "planPro",
|
||||
business: "planBusiness",
|
||||
};
|
||||
|
||||
const PLAN_COLORS: Record<PlanId, string> = {
|
||||
@@ -22,32 +23,33 @@ const PLAN_COLORS: Record<PlanId, string> = {
|
||||
business: "bg-violet-50 text-violet-700",
|
||||
};
|
||||
|
||||
const PLAN_FEATURES: Record<PlanId, string[]> = {
|
||||
free: ["5 projects", "720p export", "Community templates"],
|
||||
pro: ["Unlimited projects", "4K export", "All templates", "Priority render queue", "Custom fonts"],
|
||||
business: ["Everything in Pro", "Team seats", "White-label export", "API access", "Dedicated support"],
|
||||
const PLAN_FEATURE_KEYS: Record<PlanId, string[]> = {
|
||||
free: ["featureFree5Projects", "featureFree720pExport", "featureFreeCommunityTemplates"],
|
||||
pro: ["featureProUnlimitedProjects", "featurePro4kExport", "featureProAllTemplates", "featureProPriorityRenderQueue", "featureProCustomFonts"],
|
||||
business: ["featureBusinessEverythingInPro", "featureBusinessTeamSeats", "featureBusinessWhiteLabelExport", "featureBusinessApiAccess", "featureBusinessDedicatedSupport"],
|
||||
};
|
||||
|
||||
export function SettingsBilling({ plan }: SettingsBillingProps) {
|
||||
const t = useTranslations("auto.componentsDashboardSettingsSettingsBilling");
|
||||
const isPaid = plan !== "free";
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const [cancelError, setCancelError] = useState<string | null>(null);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm("Cancel your plan? You'll keep access until the current period ends.")) return;
|
||||
if (!confirm(t("cancelConfirm"))) return;
|
||||
setCancelling(true);
|
||||
setCancelError(null);
|
||||
try {
|
||||
const res = await apiFetch("/api/billing/cancel", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
setCancelError(data?.error ?? "Failed to cancel plan. Please try again.");
|
||||
setCancelError(data?.error ?? t("cancelFailed"));
|
||||
} else {
|
||||
setCancelled(true);
|
||||
}
|
||||
} catch {
|
||||
setCancelError("Network error. Please try again.");
|
||||
setCancelError(t("networkError"));
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
@@ -55,8 +57,8 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Billing & Plan</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">Manage your subscription and payment method.</p>
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
|
||||
|
||||
{/* Current plan card */}
|
||||
<div className="mt-6 rounded-lg border border-gray-100 bg-neutral-50 p-4">
|
||||
@@ -66,11 +68,11 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
|
||||
<Zap className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">Current plan</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">{t("currentPlan")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-heading text-lg font-bold text-neutral-900">{PLAN_LABELS[plan]}</p>
|
||||
<p className="font-heading text-lg font-bold text-neutral-900">{t(PLAN_LABEL_KEYS[plan])}</p>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${PLAN_COLORS[plan]}`}>
|
||||
{cancelled ? "Cancels at period end" : isPaid ? "Active" : "Free tier"}
|
||||
{cancelled ? t("statusCancelsAtPeriodEnd") : isPaid ? t("statusActive") : t("statusFreeTier")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,17 +83,17 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
>
|
||||
<Zap className="h-4 w-4" aria-hidden />
|
||||
Upgrade
|
||||
{t("upgrade")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features list */}
|
||||
<ul className="mt-4 space-y-1.5">
|
||||
{PLAN_FEATURES[plan].map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
{PLAN_FEATURE_KEYS[plan].map((key) => (
|
||||
<li key={key} className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary-500" aria-hidden />
|
||||
{f}
|
||||
{t(key)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -105,7 +107,7 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
<CreditCard className="h-4 w-4" aria-hidden />
|
||||
Change plan
|
||||
{t("changePlan")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
@@ -114,7 +116,7 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{cancelling ? "Cancelling…" : "Cancel plan"}
|
||||
{cancelling ? t("cancelling") : t("cancelPlan")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -127,13 +129,13 @@ export function SettingsBilling({ plan }: SettingsBillingProps) {
|
||||
|
||||
{cancelled && (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
Your plan has been cancelled. You'll keep access until the end of your billing period.
|
||||
{t("cancelledNotice")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isPaid && (
|
||||
<p className="mt-4 text-xs text-neutral-400">
|
||||
Upgrade to unlock unlimited projects, 4K export, and premium templates.
|
||||
{t("upgradeHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Toggle {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
defaultOn: boolean;
|
||||
}
|
||||
|
||||
const TOGGLES: Toggle[] = [
|
||||
{
|
||||
id: "render-complete",
|
||||
label: "Render complete",
|
||||
description: "Get notified when your video export finishes.",
|
||||
labelKey: "renderCompleteLabel",
|
||||
descriptionKey: "renderCompleteDescription",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "project-shared",
|
||||
label: "Project shared with you",
|
||||
description: "When a team member shares a project.",
|
||||
labelKey: "projectSharedLabel",
|
||||
descriptionKey: "projectSharedDescription",
|
||||
defaultOn: true,
|
||||
},
|
||||
{
|
||||
id: "weekly-digest",
|
||||
label: "Weekly digest",
|
||||
description: "Summary of new templates and platform updates.",
|
||||
labelKey: "weeklyDigestLabel",
|
||||
descriptionKey: "weeklyDigestDescription",
|
||||
defaultOn: false,
|
||||
},
|
||||
{
|
||||
id: "product-news",
|
||||
label: "Product news",
|
||||
description: "New features, tips, and announcements.",
|
||||
labelKey: "productNewsLabel",
|
||||
descriptionKey: "productNewsDescription",
|
||||
defaultOn: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsNotifications() {
|
||||
const t = useTranslations("auto.componentsDashboardSettingsSettingsNotifications");
|
||||
const [prefs, setPrefs] = useState<Record<string, boolean>>(
|
||||
Object.fromEntries(TOGGLES.map((t) => [t.id, t.defaultOn]))
|
||||
);
|
||||
@@ -55,15 +57,15 @@ export function SettingsNotifications() {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Notifications</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">Choose which emails you receive from FlatRender.</p>
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
|
||||
|
||||
<div className="mt-6 divide-y divide-gray-100">
|
||||
{TOGGLES.map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">{item.label}</p>
|
||||
<p className="mt-0.5 text-xs text-neutral-500">{item.description}</p>
|
||||
<p className="text-sm font-medium text-neutral-900">{t(item.labelKey)}</p>
|
||||
<p className="mt-0.5 text-xs text-neutral-500">{t(item.descriptionKey)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -90,9 +92,9 @@ export function SettingsNotifications() {
|
||||
onClick={save}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
Save preferences
|
||||
{t("savePreferences")}
|
||||
</button>
|
||||
{saved && <span className="text-sm text-green-600">Saved!</span>}
|
||||
{saved && <span className="text-sm text-green-600">{t("saved")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { User } from "lucide-react";
|
||||
|
||||
interface SettingsProfileProps {
|
||||
@@ -9,6 +10,7 @@ interface SettingsProfileProps {
|
||||
}
|
||||
|
||||
export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
|
||||
const t = useTranslations("auto.componentsDashboardSettingsSettingsProfile");
|
||||
const [name, setName] = useState(displayName ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
@@ -27,12 +29,12 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!res.ok) {
|
||||
setMessage({ type: "error", text: data?.error ?? "Could not update profile." });
|
||||
setMessage({ type: "error", text: data?.error ?? t("updateFailed") });
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Profile updated successfully." });
|
||||
setMessage({ type: "success", text: t("updateSuccess") });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Network error. Please try again." });
|
||||
setMessage({ type: "error", text: t("networkError") });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -40,8 +42,8 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Profile</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">Your public name and account email.</p>
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-100 font-heading text-xl font-bold text-primary-700">
|
||||
@@ -56,25 +58,25 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
|
||||
<form onSubmit={(e) => void handleSave(e)} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="display-name" className="block text-sm font-medium text-neutral-700">
|
||||
Display name
|
||||
{t("displayNameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="display-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
placeholder={t("displayNamePlaceholder")}
|
||||
className="mt-1.5 block w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700">Email</label>
|
||||
<label className="block text-sm font-medium text-neutral-700">{t("emailLabel")}</label>
|
||||
<div className="mt-1.5 flex items-center gap-2 rounded-lg border border-gray-200 bg-neutral-50 px-3 py-2">
|
||||
<User className="h-4 w-4 text-neutral-400" aria-hidden />
|
||||
<span className="text-sm text-neutral-500">{email}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-400">Email cannot be changed here. Contact support.</p>
|
||||
<p className="mt-1 text-xs text-neutral-400">{t("emailHint")}</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
@@ -88,7 +90,7 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
{saving ? t("saving") : t("saveChanges")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
export function SettingsSecurity() {
|
||||
const t = useTranslations("auto.componentsDashboardSettingsSettingsSecurity");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
@@ -16,11 +18,11 @@ export function SettingsSecurity() {
|
||||
setMessage(null);
|
||||
|
||||
if (next.length < 8) {
|
||||
setMessage({ type: "error", text: "New password must be at least 8 characters." });
|
||||
setMessage({ type: "error", text: t("errorMinLength") });
|
||||
return;
|
||||
}
|
||||
if (next !== confirm) {
|
||||
setMessage({ type: "error", text: "Passwords do not match." });
|
||||
setMessage({ type: "error", text: t("errorMismatch") });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,13 +37,13 @@ export function SettingsSecurity() {
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!res.ok) {
|
||||
setMessage({ type: "error", text: data?.error ?? "Could not change password." });
|
||||
setMessage({ type: "error", text: data?.error ?? t("errorChangeFailed") });
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Password changed successfully." });
|
||||
setMessage({ type: "success", text: t("changeSuccess") });
|
||||
setCurrent(""); setNext(""); setConfirm("");
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Network error. Please try again." });
|
||||
setMessage({ type: "error", text: t("networkError") });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -64,7 +66,7 @@ export function SettingsSecurity() {
|
||||
type="button"
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-neutral-400 hover:text-neutral-600"
|
||||
aria-label={showPw ? "Hide password" : "Show password"}
|
||||
aria-label={showPw ? t("hidePassword") : t("showPassword")}
|
||||
>
|
||||
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -75,13 +77,13 @@ export function SettingsSecurity() {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-6">
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">Security</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">Change your account password.</p>
|
||||
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
|
||||
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="mt-6 space-y-4">
|
||||
<PwInput id="current-pw" label="Current password" value={current} onChange={setCurrent} />
|
||||
<PwInput id="new-pw" label="New password" value={next} onChange={setNext} />
|
||||
<PwInput id="confirm-pw" label="Confirm new password" value={confirm} onChange={setConfirm} />
|
||||
<PwInput id="current-pw" label={t("currentPasswordLabel")} value={current} onChange={setCurrent} />
|
||||
<PwInput id="new-pw" label={t("newPasswordLabel")} value={next} onChange={setNext} />
|
||||
<PwInput id="confirm-pw" label={t("confirmPasswordLabel")} value={confirm} onChange={setConfirm} />
|
||||
|
||||
{message && (
|
||||
<p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}>
|
||||
@@ -94,7 +96,7 @@ export function SettingsSecurity() {
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
{saving ? "Saving…" : "Change password"}
|
||||
{saving ? t("saving") : t("changePassword")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "@/lib/image-editor-store";
|
||||
|
||||
export function AiRemoveBgModal() {
|
||||
const t = useTranslations("auto.componentsImageEditorAiRemoveBgModal");
|
||||
const isOpen = useImageEditorStore((s) => s.isAiModalOpen);
|
||||
const setAiModalOpen = useImageEditorStore((s) => s.setAiModalOpen);
|
||||
const replaceBaseImage = useImageEditorStore((s) => s.replaceBaseImage);
|
||||
@@ -29,7 +31,7 @@ export function AiRemoveBgModal() {
|
||||
const stage = getImageEditorStage();
|
||||
const base = getBaseImageLayer({ layers });
|
||||
if (!stage || !base) {
|
||||
toast({ title: "Open an image first." });
|
||||
toast({ title: t("openImageFirst") });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,15 +50,15 @@ export function AiRemoveBgModal() {
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.image) {
|
||||
toast({ title: payload.error ?? "Background removal failed." });
|
||||
toast({ title: payload.error ?? t("removalFailed") });
|
||||
return;
|
||||
}
|
||||
|
||||
replaceBaseImage(payload.image);
|
||||
toast({ title: "Background removed!" });
|
||||
toast({ title: t("backgroundRemoved") });
|
||||
setAiModalOpen(false);
|
||||
} catch {
|
||||
toast({ title: "Could not reach background removal service." });
|
||||
toast({ title: t("serviceUnreachable") });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -68,12 +70,9 @@ export function AiRemoveBgModal() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary-400" />
|
||||
AI Background Removal
|
||||
{t("title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove the background from your base image. The result replaces the
|
||||
background layer with a transparent PNG.
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -84,10 +83,10 @@ export function AiRemoveBgModal() {
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Processing…
|
||||
{t("processing")}
|
||||
</>
|
||||
) : (
|
||||
"Remove Background"
|
||||
t("removeBackground")
|
||||
)}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ImageCropAspectRatio } from "@/lib/image-editor-types";
|
||||
@@ -16,6 +17,7 @@ const ASPECT_OPTIONS: { id: ImageCropAspectRatio; label: string }[] = [
|
||||
];
|
||||
|
||||
export function ImageCropControls() {
|
||||
const t = useTranslations("auto.componentsImageEditorImageCropControls");
|
||||
const [applying, setApplying] = useState(false);
|
||||
const activeTool = useImageEditorStore((s) => s.activeTool);
|
||||
const cropAspectRatio = useImageEditorStore((s) => s.cropAspectRatio);
|
||||
@@ -49,7 +51,7 @@ export function ImageCropControls() {
|
||||
: "border-gray-700 bg-gray-800 text-gray-200 hover:border-gray-600"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
{option.id === "free" ? t("aspectFree") : option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -62,7 +64,7 @@ export function ImageCropControls() {
|
||||
onClick={cancelCrop}
|
||||
disabled={applying}
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -71,7 +73,7 @@ export function ImageCropControls() {
|
||||
onClick={() => void handleApply()}
|
||||
disabled={applying}
|
||||
>
|
||||
{applying ? "Applying…" : "Apply Crop"}
|
||||
{applying ? t("applying") : t("applyCrop")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { AdjustPanel } from "@/components/image-editor/panels/AdjustPanel";
|
||||
import { FiltersPanel } from "@/components/image-editor/panels/FiltersPanel";
|
||||
import { LayersPanel } from "@/components/image-editor/panels/LayersPanel";
|
||||
@@ -7,20 +9,21 @@ import type { ImagePanelTab } from "@/lib/image-editor-types";
|
||||
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TABS: { id: ImagePanelTab; label: string }[] = [
|
||||
{ id: "adjust", label: "Adjust" },
|
||||
{ id: "filters", label: "Filters" },
|
||||
{ id: "layers", label: "Layers" },
|
||||
const TAB_IDS: { id: ImagePanelTab; labelKey: string }[] = [
|
||||
{ id: "adjust", labelKey: "tabAdjust" },
|
||||
{ id: "filters", labelKey: "tabFilters" },
|
||||
{ id: "layers", labelKey: "tabLayers" },
|
||||
];
|
||||
|
||||
export function ImageEditorRightPanel() {
|
||||
const t = useTranslations("auto.componentsImageEditorImageEditorRightPanel");
|
||||
const activePanelTab = useImageEditorStore((s) => s.activePanelTab);
|
||||
const setActivePanelTab = useImageEditorStore((s) => s.setActivePanelTab);
|
||||
|
||||
return (
|
||||
<aside className="flex w-[280px] shrink-0 flex-col border-l border-gray-800 bg-gray-900">
|
||||
<div className="flex border-b border-gray-800">
|
||||
{TABS.map((tab) => (
|
||||
{TAB_IDS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
@@ -32,7 +35,7 @@ export function ImageEditorRightPanel() {
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Crop,
|
||||
MousePointer2,
|
||||
@@ -19,23 +20,25 @@ import type { ImageShapeKind, ImageTool } from "@/lib/image-editor-types";
|
||||
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TOOLS: { id: ImageTool; label: string; icon: typeof MousePointer2 }[] = [
|
||||
{ id: "select", label: "Select", icon: MousePointer2 },
|
||||
{ id: "crop", label: "Crop", icon: Crop },
|
||||
{ id: "text", label: "Text", icon: Type },
|
||||
{ id: "shape", label: "Shape", icon: Shapes },
|
||||
{ id: "draw", label: "Draw", icon: Pencil },
|
||||
{ id: "ai", label: "AI", icon: Sparkles },
|
||||
];
|
||||
const TOOLS: { id: ImageTool; labelKey: string; icon: typeof MousePointer2 }[] =
|
||||
[
|
||||
{ id: "select", labelKey: "toolSelect", icon: MousePointer2 },
|
||||
{ id: "crop", labelKey: "toolCrop", icon: Crop },
|
||||
{ id: "text", labelKey: "toolText", icon: Type },
|
||||
{ id: "shape", labelKey: "toolShape", icon: Shapes },
|
||||
{ id: "draw", labelKey: "toolDraw", icon: Pencil },
|
||||
{ id: "ai", labelKey: "toolAi", icon: Sparkles },
|
||||
];
|
||||
|
||||
const SHAPES: { id: ImageShapeKind; label: string }[] = [
|
||||
{ id: "rect", label: "Rectangle" },
|
||||
{ id: "circle", label: "Circle" },
|
||||
{ id: "line", label: "Line" },
|
||||
{ id: "arrow", label: "Arrow" },
|
||||
const SHAPES: { id: ImageShapeKind; labelKey: string }[] = [
|
||||
{ id: "rect", labelKey: "shapeRectangle" },
|
||||
{ id: "circle", labelKey: "shapeCircle" },
|
||||
{ id: "line", labelKey: "shapeLine" },
|
||||
{ id: "arrow", labelKey: "shapeArrow" },
|
||||
];
|
||||
|
||||
export function ImageEditorToolbar() {
|
||||
const t = useTranslations("auto.componentsImageEditorImageEditorToolbar");
|
||||
const [shapeOpen, setShapeOpen] = useState(false);
|
||||
const activeTool = useImageEditorStore((s) => s.activeTool);
|
||||
const setActiveTool = useImageEditorStore((s) => s.setActiveTool);
|
||||
@@ -52,7 +55,7 @@ export function ImageEditorToolbar() {
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={tool.label}
|
||||
title={t(tool.labelKey)}
|
||||
onClick={() => setActiveTool("shape")}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
|
||||
@@ -76,7 +79,7 @@ export function ImageEditorToolbar() {
|
||||
}}
|
||||
className="flex w-full rounded-md px-2 py-2 text-left text-sm text-gray-200 hover:bg-gray-700"
|
||||
>
|
||||
{shape.label}
|
||||
{t(shape.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
@@ -88,7 +91,7 @@ export function ImageEditorToolbar() {
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
title={tool.label}
|
||||
title={t(tool.labelKey)}
|
||||
onClick={() => {
|
||||
if (tool.id === "ai") {
|
||||
setAiModalOpen(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Download, FolderOpen, Sparkles } from "lucide-react";
|
||||
|
||||
@@ -28,6 +29,7 @@ export function ImageEditorTopBar({
|
||||
saveStatus = "idle",
|
||||
onSaveRetry,
|
||||
}: ImageEditorTopBarProps) {
|
||||
const t = useTranslations("auto.componentsImageEditorImageEditorTopBar");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
@@ -52,11 +54,11 @@ export function ImageEditorTopBar({
|
||||
const handleExport = () => {
|
||||
const stage = getImageEditorStage();
|
||||
if (!stage) {
|
||||
toast({ title: "Canvas not ready." });
|
||||
toast({ title: t("canvasNotReady") });
|
||||
return;
|
||||
}
|
||||
downloadStageImage(stage, exportFormat, exportQuality);
|
||||
toast({ title: "Export started" });
|
||||
toast({ title: t("exportStarted") });
|
||||
setExportOpen(false);
|
||||
};
|
||||
|
||||
@@ -69,7 +71,7 @@ export function ImageEditorTopBar({
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-violet-500" />
|
||||
<span className="font-heading font-semibold text-white">
|
||||
{projectName ?? "Image Editor"}
|
||||
{projectName ?? t("defaultProjectName")}
|
||||
</span>
|
||||
</Link>
|
||||
{projectId ? (
|
||||
@@ -95,7 +97,7 @@ export function ImageEditorTopBar({
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open
|
||||
{t("open")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -108,11 +110,13 @@ export function ImageEditorTopBar({
|
||||
onClick={() => setExportOpen((v) => !v)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
{t("export")}
|
||||
</Button>
|
||||
{exportOpen ? (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-64 rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-xl">
|
||||
<p className="mb-2 text-xs font-semibold text-gray-400">Format</p>
|
||||
<p className="mb-2 text-xs font-semibold text-gray-400">
|
||||
{t("format")}
|
||||
</p>
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(["png", "jpg", "webp"] as ExportImageFormat[]).map((fmt) => (
|
||||
<button
|
||||
@@ -133,7 +137,7 @@ export function ImageEditorTopBar({
|
||||
{exportFormat !== "png" ? (
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex justify-between text-xs text-gray-400">
|
||||
<span>Quality</span>
|
||||
<span>{t("quality")}</span>
|
||||
<span>{exportQuality}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
@@ -150,7 +154,7 @@ export function ImageEditorTopBar({
|
||||
className="w-full bg-primary-600 hover:bg-primary-700"
|
||||
onClick={handleExport}
|
||||
>
|
||||
Download
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||
|
||||
const SLIDERS = [
|
||||
{ key: "brightness" as const, label: "Brightness", min: -100, max: 100 },
|
||||
{ key: "contrast" as const, label: "Contrast", min: -100, max: 100 },
|
||||
{ key: "saturation" as const, label: "Saturation", min: -100, max: 100 },
|
||||
{ key: "hue" as const, label: "Hue", min: -180, max: 180 },
|
||||
{ key: "blur" as const, label: "Blur", min: 0, max: 20 },
|
||||
{ key: "sharpen" as const, label: "Sharpen", min: 0, max: 10 },
|
||||
{ key: "vignette" as const, label: "Vignette", min: 0, max: 100 },
|
||||
{ key: "brightness" as const, labelKey: "brightness", min: -100, max: 100 },
|
||||
{ key: "contrast" as const, labelKey: "contrast", min: -100, max: 100 },
|
||||
{ key: "saturation" as const, labelKey: "saturation", min: -100, max: 100 },
|
||||
{ key: "hue" as const, labelKey: "hue", min: -180, max: 180 },
|
||||
{ key: "blur" as const, labelKey: "blur", min: 0, max: 20 },
|
||||
{ key: "sharpen" as const, labelKey: "sharpen", min: 0, max: 10 },
|
||||
{ key: "vignette" as const, labelKey: "vignette", min: 0, max: 100 },
|
||||
];
|
||||
|
||||
export function AdjustPanel() {
|
||||
const t = useTranslations("auto.componentsImageEditorPanelsAdjustPanel");
|
||||
const adjustments = useImageEditorStore((s) => s.adjustments);
|
||||
const setAdjustments = useImageEditorStore((s) => s.setAdjustments);
|
||||
const hasBase = useImageEditorStore((s) => s.layers.some((l) => l.type === "image"));
|
||||
|
||||
if (!hasBase) {
|
||||
return (
|
||||
<p className="text-xs text-gray-500">Open an image to use adjustments.</p>
|
||||
<p className="text-xs text-gray-500">{t("emptyState")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{SLIDERS.map(({ key, label, min, max }) => (
|
||||
{SLIDERS.map(({ key, labelKey, min, max }) => (
|
||||
<div key={key}>
|
||||
<div className="mb-2 flex justify-between text-xs text-gray-400">
|
||||
<span>{label}</span>
|
||||
<span>{t(labelKey)}</span>
|
||||
<span className="tabular-nums text-gray-300">
|
||||
{adjustments[key]}
|
||||
</span>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { FILTER_PRESETS } from "@/lib/image-editor-filters";
|
||||
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FiltersPanel() {
|
||||
const t = useTranslations("auto.componentsImageEditorPanelsFiltersPanel");
|
||||
const activeFilterPreset = useImageEditorStore((s) => s.activeFilterPreset);
|
||||
const applyFilterPreset = useImageEditorStore((s) => s.applyFilterPreset);
|
||||
const hasBase = useImageEditorStore((s) => s.layers.some((l) => l.type === "image"));
|
||||
|
||||
if (!hasBase) {
|
||||
return <p className="text-xs text-gray-500">Open an image to apply filters.</p>;
|
||||
return <p className="text-xs text-gray-500">{t("emptyState")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Eye, EyeOff, GripVertical, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { ImageLayer } from "@/lib/image-editor-types";
|
||||
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||
@@ -38,6 +39,7 @@ function layerIcon(type: ImageLayer["type"]): string {
|
||||
}
|
||||
|
||||
function SortableLayerRow({ layer }: { layer: ImageLayer }) {
|
||||
const t = useTranslations("auto.componentsImageEditorPanelsLayersPanel");
|
||||
const selectedLayerId = useImageEditorStore((s) => s.selectedLayerId);
|
||||
const setSelectedLayer = useImageEditorStore((s) => s.setSelectedLayer);
|
||||
const toggleLayerVisibility = useImageEditorStore((s) => s.toggleLayerVisibility);
|
||||
@@ -63,7 +65,7 @@ function SortableLayerRow({ layer }: { layer: ImageLayer }) {
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab text-gray-500 hover:text-gray-300"
|
||||
aria-label={`Reorder ${layer.name}`}
|
||||
aria-label={t("reorderLayer", { name: layer.name ?? "" })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -81,7 +83,7 @@ function SortableLayerRow({ layer }: { layer: ImageLayer }) {
|
||||
type="button"
|
||||
onClick={() => toggleLayerVisibility(layer.id)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
aria-label={layer.visible ? "Hide layer" : "Show layer"}
|
||||
aria-label={layer.visible ? t("hideLayer") : t("showLayer")}
|
||||
>
|
||||
{layer.visible ? (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
@@ -94,7 +96,7 @@ function SortableLayerRow({ layer }: { layer: ImageLayer }) {
|
||||
type="button"
|
||||
onClick={() => deleteLayer(layer.id)}
|
||||
className="text-gray-400 hover:text-red-400"
|
||||
aria-label={`Delete ${layer.name}`}
|
||||
aria-label={t("deleteLayer", { name: layer.name ?? "" })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -106,6 +108,7 @@ function SortableLayerRow({ layer }: { layer: ImageLayer }) {
|
||||
}
|
||||
|
||||
export function LayersPanel() {
|
||||
const t = useTranslations("auto.componentsImageEditorPanelsLayersPanel");
|
||||
const layers = useImageEditorStore((s) => s.layers);
|
||||
const reorderLayers = useImageEditorStore((s) => s.reorderLayers);
|
||||
|
||||
@@ -133,7 +136,7 @@ export function LayersPanel() {
|
||||
};
|
||||
|
||||
if (layers.length === 0) {
|
||||
return <p className="text-xs text-gray-500">No layers yet.</p>;
|
||||
return <p className="text-xs text-gray-500">{t("emptyState")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { OptimizedImage } from "@/components/ui/optimized-image";
|
||||
import { placeholderSrc } from "@/lib/placeholder";
|
||||
|
||||
export function ImageMakerBeforeAfter() {
|
||||
const t = useTranslations("auto.componentsImageMakerImageMakerBeforeAfter");
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-xl">
|
||||
<div className="grid grid-cols-2 divide-x divide-gray-100">
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[4/5] sm:aspect-square">
|
||||
<OptimizedImage
|
||||
src="https://picsum.photos/seed/im-before/400/500"
|
||||
alt="Before editing"
|
||||
src={placeholderSrc("im-before", 400, 500)}
|
||||
alt={t("beforeAlt")}
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 1024px) 50vw, 320px"
|
||||
@@ -16,14 +22,14 @@ export function ImageMakerBeforeAfter() {
|
||||
/>
|
||||
</div>
|
||||
<span className="absolute left-3 top-3 rounded-md bg-neutral-900/70 px-2 py-1 text-xs font-semibold text-white backdrop-blur-sm">
|
||||
Before
|
||||
{t("beforeLabel")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[4/5] sm:aspect-square">
|
||||
<OptimizedImage
|
||||
src="https://picsum.photos/seed/im-after/400/500"
|
||||
alt="After editing with AI"
|
||||
src={placeholderSrc("im-after", 400, 500)}
|
||||
alt={t("afterAlt")}
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 1024px) 50vw, 320px"
|
||||
@@ -31,12 +37,12 @@ export function ImageMakerBeforeAfter() {
|
||||
/>
|
||||
</div>
|
||||
<span className="absolute left-3 top-3 rounded-md bg-violet-600 px-2 py-1 text-xs font-semibold text-white">
|
||||
After
|
||||
{t("afterLabel")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="border-t border-gray-100 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
|
||||
AI-enhanced color, layout, and brand styling applied in one click
|
||||
{t("caption")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { OptimizedImage } from "@/components/ui/optimized-image";
|
||||
import { SectionReveal } from "@/components/sections/SectionReveal";
|
||||
import { placeholderSrc } from "@/lib/placeholder";
|
||||
|
||||
import { GALLERY_ITEMS } from "./image-maker-gallery-data";
|
||||
|
||||
export function ImageMakerGallery() {
|
||||
export async function ImageMakerGallery() {
|
||||
const t = await getTranslations("auto.componentsImageMakerImageMakerGallery");
|
||||
|
||||
return (
|
||||
<section id="gallery" className="bg-neutral-50 py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<SectionReveal>
|
||||
<h2 className="text-center font-heading text-3xl font-bold text-neutral-900 sm:text-4xl">
|
||||
Example outputs from creators
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-center text-neutral-600">
|
||||
Real-world layouts and styles you can recreate—or use as inspiration
|
||||
for your next project.
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</SectionReveal>
|
||||
|
||||
@@ -25,7 +29,7 @@ export function ImageMakerGallery() {
|
||||
>
|
||||
<div className={`relative w-full ${item.aspectClass}`}>
|
||||
<OptimizedImage
|
||||
src={`https://picsum.photos/seed/${item.id}/600/800`}
|
||||
src={placeholderSrc(item.id, 600, 800)}
|
||||
alt={item.alt}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { DirectionProvider as RadixDirectionProvider } from "@radix-ui/react-direction";
|
||||
|
||||
/**
|
||||
* Propagates the document direction to all Radix UI primitives (dropdown menus,
|
||||
* selects, popovers, sheets…). Radix portals its content out of the DOM tree and
|
||||
* assumes LTR unless told otherwise — without this, RTL menus render left-aligned
|
||||
* with `align="start"` resolving to the wrong side.
|
||||
*/
|
||||
export function DirectionProvider({
|
||||
dir,
|
||||
children,
|
||||
}: {
|
||||
dir: "rtl" | "ltr";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <RadixDirectionProvider dir={dir}>{children}</RadixDirectionProvider>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, LayoutGrid } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -68,11 +69,14 @@ interface NavbarLearnDropdownProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function NavbarLearnDropdown({ items, label = "Learn" }: NavbarLearnDropdownProps) {
|
||||
export function NavbarLearnDropdown({ items, label }: NavbarLearnDropdownProps) {
|
||||
const t = useTranslations("auto.componentsLayoutNavbarMenuDropdown");
|
||||
const resolvedLabel = label ?? t("learn");
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={triggerClassName}>
|
||||
{label}
|
||||
{resolvedLabel}
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-500" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={cn(panelClassName, "min-w-[180px]")}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -17,11 +18,13 @@ const linkClass =
|
||||
"flex min-h-11 items-center rounded-lg px-3 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900";
|
||||
|
||||
export function NavbarMobileMenu({ onNavigate }: NavbarMobileMenuProps) {
|
||||
const t = useTranslations("auto.componentsLayoutNavbarMobileMenu");
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 overflow-y-auto">
|
||||
<section>
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Video Maker
|
||||
{t("videoMaker")}
|
||||
</p>
|
||||
<Link
|
||||
href={VIDEO_MAKER_NAV.browseHref}
|
||||
@@ -44,7 +47,7 @@ export function NavbarMobileMenu({ onNavigate }: NavbarMobileMenuProps) {
|
||||
|
||||
<section>
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Image Maker
|
||||
{t("imageMaker")}
|
||||
</p>
|
||||
<Link
|
||||
href={IMAGE_MAKER_NAV.browseHref}
|
||||
@@ -67,13 +70,13 @@ export function NavbarMobileMenu({ onNavigate }: NavbarMobileMenuProps) {
|
||||
|
||||
<section>
|
||||
<Link href="/pricing" onClick={onNavigate} className={linkClass}>
|
||||
Pricing
|
||||
{t("pricing")}
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Learn
|
||||
{t("learn")}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{LEARN_NAV_ITEMS.map((item) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
|
||||
import { VideoPlayOverlay } from "@/components/sections/VideoPlayOverlay";
|
||||
@@ -8,10 +9,10 @@ import { getHeroPreviewVideoSrc } from "@/lib/template-preview-media";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const previewTemplates = [
|
||||
{ id: "hero-3d", title: "Factory of 3D Animations" },
|
||||
{ id: "hero-whiteboard", title: "Whiteboard Animation Toolkit" },
|
||||
{ id: "hero-explainer", title: "3D Explainer Video Toolkit" },
|
||||
{ id: "hero-trendy", title: "Trendy Explainer Toolkit" },
|
||||
{ id: "hero-3d", titleKey: "template3dTitle" },
|
||||
{ id: "hero-whiteboard", titleKey: "templateWhiteboardTitle" },
|
||||
{ id: "hero-explainer", titleKey: "templateExplainerTitle" },
|
||||
{ id: "hero-trendy", titleKey: "templateTrendyTitle" },
|
||||
] as const;
|
||||
|
||||
const containerVariants: Variants = {
|
||||
@@ -37,6 +38,8 @@ interface HeroVideoThumbProps {
|
||||
}
|
||||
|
||||
function HeroVideoThumb({ videoSrc, label }: HeroVideoThumbProps) {
|
||||
const t = useTranslations("auto.componentsSectionsHeroPreviewCards");
|
||||
|
||||
return (
|
||||
<div className="group/thumb relative aspect-[4/3] overflow-hidden rounded-xl border border-neutral-200/80 bg-neutral-100 shadow-sm transition-shadow duration-300 hover:shadow-md">
|
||||
<video
|
||||
@@ -47,7 +50,7 @@ function HeroVideoThumb({ videoSrc, label }: HeroVideoThumbProps) {
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover/thumb:scale-[1.02]"
|
||||
aria-label={`${label} preview`}
|
||||
aria-label={t("previewAriaLabel", { label })}
|
||||
/>
|
||||
<VideoPlayOverlay
|
||||
size="lg"
|
||||
@@ -58,6 +61,8 @@ function HeroVideoThumb({ videoSrc, label }: HeroVideoThumbProps) {
|
||||
}
|
||||
|
||||
export function HeroPreviewCards() {
|
||||
const t = useTranslations("auto.componentsSectionsHeroPreviewCards");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
@@ -67,29 +72,32 @@ export function HeroPreviewCards() {
|
||||
className="mx-auto mt-14 w-full max-w-7xl sm:mt-16"
|
||||
>
|
||||
<p className="text-center font-heading text-xl font-bold tracking-tight text-neutral-900 sm:text-2xl">
|
||||
Made by world-class motion designers
|
||||
{t("heading")}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-4 sm:gap-5 lg:grid-cols-4 lg:gap-6">
|
||||
{previewTemplates.map((template, index) => (
|
||||
<motion.div key={template.id} variants={cardVariants}>
|
||||
<Link
|
||||
href="/templates"
|
||||
className={cn(
|
||||
"group block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue focus-visible:ring-offset-2",
|
||||
"transition-transform duration-300 hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<HeroVideoThumb
|
||||
videoSrc={getHeroPreviewVideoSrc(index)}
|
||||
label={template.title}
|
||||
/>
|
||||
<p className="mt-3 text-center font-heading text-sm font-semibold text-neutral-900 sm:text-[15px]">
|
||||
{template.title}
|
||||
</p>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
{previewTemplates.map((template, index) => {
|
||||
const title = t(template.titleKey);
|
||||
return (
|
||||
<motion.div key={template.id} variants={cardVariants}>
|
||||
<Link
|
||||
href="/templates"
|
||||
className={cn(
|
||||
"group block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue focus-visible:ring-offset-2",
|
||||
"transition-transform duration-300 hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<HeroVideoThumb
|
||||
videoSrc={getHeroPreviewVideoSrc(index)}
|
||||
label={title}
|
||||
/>
|
||||
<p className="mt-3 text-center font-heading text-sm font-semibold text-neutral-900 sm:text-[15px]">
|
||||
{title}
|
||||
</p>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -22,6 +23,7 @@ export function PricingAnimatedPrice({
|
||||
size = "default",
|
||||
}: PricingAnimatedPriceProps) {
|
||||
const isCompact = size === "compact";
|
||||
const t = useTranslations("auto.componentsSectionsPricingAnimatedPrice");
|
||||
|
||||
return (
|
||||
<div className={isCompact ? "mt-2" : "mt-4"}>
|
||||
@@ -53,7 +55,7 @@ export function PricingAnimatedPrice({
|
||||
${formatPrice(price)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<span className="ml-1 text-sm font-normal text-neutral-500">/ month</span>
|
||||
<span className="ml-1 text-sm font-normal text-neutral-500">{t("perMonth")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { BillingPeriod } from "@/components/sections/pricing-data";
|
||||
import { ANNUAL_SAVINGS_PERCENT } from "@/components/sections/pricing-data";
|
||||
@@ -17,12 +18,13 @@ export function PricingBillingToggle({
|
||||
onChange,
|
||||
layoutId = "pricing-billing-pill",
|
||||
}: PricingBillingToggleProps) {
|
||||
const t = useTranslations("auto.componentsSectionsPricingBillingToggle");
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="inline-flex rounded-full border border-gray-200 bg-white p-1 shadow-sm">
|
||||
{(["monthly", "annual"] as const).map((period) => {
|
||||
const isActive = billing === period;
|
||||
const label = period === "monthly" ? "Monthly" : "Yearly";
|
||||
const label = period === "monthly" ? t("monthly") : t("yearly");
|
||||
return (
|
||||
<button
|
||||
key={period}
|
||||
@@ -47,10 +49,10 @@ export function PricingBillingToggle({
|
||||
</div>
|
||||
{billing === "annual" ? (
|
||||
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-semibold text-green-700">
|
||||
Save {ANNUAL_SAVINGS_PERCENT}%
|
||||
{t("savePercent", { percent: ANNUAL_SAVINGS_PERCENT })}
|
||||
</span>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-400">Switch to Yearly to save more</p>
|
||||
<p className="text-sm text-neutral-400">{t("switchToYearly")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { Tag } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { PricingAnimatedPrice } from "@/components/sections/PricingAnimatedPrice";
|
||||
import { PricingCheckoutButton } from "@/components/sections/PricingCheckoutButton";
|
||||
@@ -22,6 +23,7 @@ export interface PricingCardProps {
|
||||
}
|
||||
|
||||
export function PricingCard({ tier, billing }: PricingCardProps) {
|
||||
const t = useTranslations("auto.componentsSectionsPricingCard");
|
||||
const price = getDisplayPrice(tier, billing);
|
||||
const compareAt = getCompareAtPrice(tier, billing);
|
||||
const highlighted = tier.highlighted ?? false;
|
||||
@@ -38,7 +40,7 @@ export function PricingCard({ tier, billing }: PricingCardProps) {
|
||||
>
|
||||
{highlighted ? (
|
||||
<div className="bg-gradient-to-r from-rose-400 via-violet-500 to-violet-600 px-4 py-2 text-center text-sm font-semibold text-white">
|
||||
Most Popular
|
||||
{t("mostPopular")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import type { BillingPeriod } from "@/components/sections/pricing-data";
|
||||
@@ -25,6 +26,7 @@ export function PricingCheckoutButton({
|
||||
variant = "default",
|
||||
}: PricingCheckoutButtonProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("auto.componentsSectionsPricingCheckoutButton");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -46,7 +48,7 @@ export function PricingCheckoutButton({
|
||||
router.push(`/auth?tab=sign-up&plan=${plan}`);
|
||||
return;
|
||||
}
|
||||
throw new Error(data.error ?? "Checkout failed.");
|
||||
throw new Error(data.error ?? t("checkoutFailed"));
|
||||
}
|
||||
|
||||
if (data.url) {
|
||||
@@ -54,12 +56,12 @@ export function PricingCheckoutButton({
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("No checkout URL returned.");
|
||||
throw new Error(t("noCheckoutUrl"));
|
||||
} catch (checkoutError) {
|
||||
setError(
|
||||
checkoutError instanceof Error
|
||||
? checkoutError.message
|
||||
: "Checkout failed."
|
||||
: t("checkoutFailed")
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Fragment } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { PricingAnimatedPrice } from "@/components/sections/PricingAnimatedPrice";
|
||||
import { PricingBillingToggle } from "@/components/sections/PricingBillingToggle";
|
||||
@@ -62,6 +63,7 @@ function PlanHeaderCell({
|
||||
tier: PricingTier;
|
||||
billing: BillingPeriod;
|
||||
}) {
|
||||
const t = useTranslations("auto.componentsSectionsPricingCompareTable");
|
||||
const highlighted = tier.highlighted ?? false;
|
||||
const isStripePlan = tier.id === "pro" || tier.id === "business";
|
||||
|
||||
@@ -74,7 +76,7 @@ function PlanHeaderCell({
|
||||
>
|
||||
{highlighted ? (
|
||||
<span className="mb-2 inline-block rounded-full bg-gradient-to-r from-violet-500 to-blue-600 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white">
|
||||
Most Popular
|
||||
{t("mostPopular")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mb-2 block h-5" aria-hidden />
|
||||
@@ -118,6 +120,7 @@ export function PricingCompareTable({
|
||||
billing,
|
||||
onBillingChange,
|
||||
}: PricingCompareTableProps) {
|
||||
const t = useTranslations("auto.componentsSectionsPricingCompareTable");
|
||||
const lite = PRICING_TIERS.find((t) => t.id === "lite");
|
||||
const pro = PRICING_TIERS.find((t) => t.id === "pro");
|
||||
const business = PRICING_TIERS.find((t) => t.id === "business");
|
||||
@@ -131,7 +134,7 @@ export function PricingCompareTable({
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="w-[38%] px-6 pb-4 pt-6 text-left align-top">
|
||||
<h3 className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text font-heading text-lg font-bold text-transparent sm:text-xl">
|
||||
Compare Plans & Features
|
||||
{t("compareHeading")}
|
||||
</h3>
|
||||
<div className="mt-4 items-start">
|
||||
<PricingBillingToggle
|
||||
@@ -141,7 +144,7 @@ export function PricingCompareTable({
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-blue-600">
|
||||
Save up to {COMPARE_ANNUAL_SAVINGS_BADGE}%
|
||||
{t("saveUpTo", { percent: COMPARE_ANNUAL_SAVINGS_BADGE })}
|
||||
<SavingsArrowIcon />
|
||||
</p>
|
||||
</th>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Zap } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function PricingCreditsBanner() {
|
||||
const t = useTranslations("auto.componentsSectionsPricingCreditsBanner");
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-sky-50 px-3 py-2.5 text-left">
|
||||
<Zap className="mt-0.5 h-4 w-4 shrink-0 text-rf-blue" aria-hidden />
|
||||
<p className="text-xs leading-snug text-neutral-700">
|
||||
You can refill AI credits anytime with an active plan
|
||||
{t("refillCredits")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Info } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { PricingFeature } from "@/components/sections/pricing-data";
|
||||
|
||||
@@ -11,6 +14,8 @@ export function PricingFeatureList({
|
||||
heading,
|
||||
features,
|
||||
}: PricingFeatureListProps) {
|
||||
const t = useTranslations("auto.componentsSectionsPricingFeatureList");
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{heading ? (
|
||||
@@ -36,7 +41,7 @@ export function PricingFeatureList({
|
||||
{feature.info ? (
|
||||
<Info
|
||||
className="h-3.5 w-3.5 shrink-0 text-neutral-400"
|
||||
aria-label="More information"
|
||||
aria-label={t("moreInformation")}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function PricingFreeBanner() {
|
||||
const t = useTranslations("auto.componentsSectionsPricingFreeBanner");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-between gap-6 rounded-xl border border-gray-100 bg-white px-6 py-6 shadow-sm sm:flex-row sm:items-center sm:px-8">
|
||||
<div className="max-w-xl">
|
||||
<h3 className="font-heading text-lg font-bold text-neutral-900 sm:text-xl">
|
||||
Always Free to Try
|
||||
{t("title")}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-neutral-600 sm:text-[15px]">
|
||||
Explore CreatorStudio with a Free plan — create HD videos with a
|
||||
watermark, try basic features, and experiment before you subscribe.
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -20,7 +24,7 @@ export function PricingFreeBanner() {
|
||||
className="shrink-0 rounded-lg border-2 border-rf-blue bg-white px-8 text-rf-blue hover:bg-rf-blue-light"
|
||||
asChild
|
||||
>
|
||||
<Link href="/auth?tab=sign-up">Get Started</Link>
|
||||
<Link href="/auth?tab=sign-up">{t("ctaLabel")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { OptimizedImage } from "@/components/ui/optimized-image";
|
||||
@@ -40,9 +41,12 @@ export function TemplateCard({
|
||||
priority = false,
|
||||
onUseTemplate,
|
||||
isUsingTemplate = false,
|
||||
useTemplateLabel = "Use Template",
|
||||
openingLabel = "Opening…",
|
||||
useTemplateLabel,
|
||||
openingLabel,
|
||||
}: TemplateCardProps) {
|
||||
const t = useTranslations("auto.componentsSectionsTemplateCard");
|
||||
const resolvedUseTemplateLabel = useTemplateLabel ?? t("useTemplateLabel");
|
||||
const resolvedOpeningLabel = openingLabel ?? t("openingLabel");
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const seed = previewSeed ?? name;
|
||||
const videoSrc = previewVideoUrl ?? getTemplatePreviewVideoSrc(seed);
|
||||
@@ -64,7 +68,7 @@ export function TemplateCard({
|
||||
<Link
|
||||
href={detailHref}
|
||||
className="absolute inset-0 z-0 block no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||
aria-label={`View ${name} template`}
|
||||
aria-label={t("viewTemplateAriaLabel", { name })}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={imageSrc}
|
||||
@@ -120,7 +124,7 @@ export function TemplateCard({
|
||||
onUseTemplate?.();
|
||||
}}
|
||||
>
|
||||
{isUsingTemplate ? openingLabel : useTemplateLabel}
|
||||
{isUsingTemplate ? resolvedOpeningLabel : resolvedUseTemplateLabel}
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Star } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -13,6 +16,7 @@ export function TestimonialCard({
|
||||
testimonial,
|
||||
className,
|
||||
}: TestimonialCardProps) {
|
||||
const t = useTranslations("auto.componentsSectionsTestimonialCard");
|
||||
const { name, role, company, quote, initials } = testimonial;
|
||||
|
||||
return (
|
||||
@@ -22,7 +26,7 @@ export function TestimonialCard({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<p className="sr-only">Rated 5 out of 5 stars</p>
|
||||
<p className="sr-only">{t("ratingLabel")}</p>
|
||||
<div className="flex items-center gap-1" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { placeholderSrc } from "@/lib/placeholder";
|
||||
|
||||
export const FILTER_TABS = [
|
||||
"All",
|
||||
"Videos",
|
||||
@@ -78,7 +80,7 @@ export function filterTemplates(
|
||||
}
|
||||
|
||||
export function getTemplateImageSrc(id: string): string {
|
||||
return `https://picsum.photos/seed/${id}/400/300`;
|
||||
return placeholderSrc(id, 400, 300);
|
||||
}
|
||||
|
||||
/** Video presets for /studio/video/new onboarding */
|
||||
@@ -94,49 +96,49 @@ export const TEMPLATE_GALLERY_ITEMS: TemplateGalleryItem[] = [
|
||||
{
|
||||
id: "promo-reel",
|
||||
name: "Animated Inspirational Video",
|
||||
imageSrc: "https://picsum.photos/seed/promo-reel/400/280",
|
||||
imageSrc: placeholderSrc("promo-reel", 400, 280),
|
||||
previewVideoUrl: MIXKIT.sunsetPlateaus,
|
||||
},
|
||||
{
|
||||
id: "product-launch",
|
||||
name: "Cybersecurity Company Promo",
|
||||
imageSrc: "https://picsum.photos/seed/product-launch/400/280",
|
||||
imageSrc: placeholderSrc("product-launch", 400, 280),
|
||||
previewVideoUrl: MIXKIT.cityTraffic,
|
||||
},
|
||||
{
|
||||
id: "brand-story",
|
||||
name: "Get to Know Your Customers Day",
|
||||
imageSrc: "https://picsum.photos/seed/brand-story/400/280",
|
||||
imageSrc: placeholderSrc("brand-story", 400, 280),
|
||||
previewVideoUrl: MIXKIT.cloudsRunner,
|
||||
},
|
||||
{
|
||||
id: "instagram-carousel",
|
||||
name: "SEO Agency Introduction",
|
||||
imageSrc: "https://picsum.photos/seed/instagram/400/280",
|
||||
imageSrc: placeholderSrc("instagram", 400, 280),
|
||||
previewVideoUrl: MIXKIT.meadow,
|
||||
},
|
||||
{
|
||||
id: "tiktok-hook",
|
||||
name: "Tech Startup Promo",
|
||||
imageSrc: "https://picsum.photos/seed/tiktok/400/280",
|
||||
imageSrc: placeholderSrc("tiktok", 400, 280),
|
||||
previewVideoUrl: MIXKIT.skyscrapers,
|
||||
},
|
||||
{
|
||||
id: "pitch-deck",
|
||||
name: "Corporate Explainer",
|
||||
imageSrc: "https://picsum.photos/seed/pitch-deck/400/280",
|
||||
imageSrc: placeholderSrc("pitch-deck", 400, 280),
|
||||
previewVideoUrl: MIXKIT.cityTraffic,
|
||||
},
|
||||
{
|
||||
id: "hero-promo",
|
||||
name: "Hero Product Launch",
|
||||
imageSrc: "https://picsum.photos/seed/hero-promo/400/280",
|
||||
imageSrc: placeholderSrc("hero-promo", 400, 280),
|
||||
previewVideoUrl: MIXKIT.sunsetPlateaus,
|
||||
},
|
||||
{
|
||||
id: "event-recap",
|
||||
name: "Event Recap Highlight",
|
||||
imageSrc: "https://picsum.photos/seed/event-recap/400/280",
|
||||
imageSrc: placeholderSrc("event-recap", 400, 280),
|
||||
previewVideoUrl: MIXKIT.meadow,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FilePlus, LayoutTemplate, Plus } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ interface AddSceneMenuProps {
|
||||
}
|
||||
|
||||
export function AddSceneMenu({ onAddBlank, variant = "footer" }: AddSceneMenuProps) {
|
||||
const t = useTranslations("auto.componentsStudioAddSceneMenu");
|
||||
const [open, setOpen] = useState(false);
|
||||
const isHeader = variant === "header";
|
||||
|
||||
@@ -26,8 +28,8 @@ export function AddSceneMenu({ onAddBlank, variant = "footer" }: AddSceneMenuPro
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-[#8b91a7] transition-colors hover:bg-[#1f2234] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
||||
aria-label="Add scene"
|
||||
title="Add scene"
|
||||
aria-label={t("addScene")}
|
||||
title={t("addScene")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -37,7 +39,7 @@ export function AddSceneMenu({ onAddBlank, variant = "footer" }: AddSceneMenuPro
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-[#2a2d3e] bg-[#1a1d2e]/50 px-3 py-2 text-xs font-medium text-gray-300 transition-colors hover:border-[#3d4260] hover:bg-[#1f2234] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||||
Add Scene
|
||||
{t("addScene")}
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
@@ -51,14 +53,14 @@ export function AddSceneMenu({ onAddBlank, variant = "footer" }: AddSceneMenuPro
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-gray-200 hover:bg-[#252938] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
||||
>
|
||||
<FilePlus className="h-4 w-4 shrink-0" aria-hidden />
|
||||
Blank Scene
|
||||
{t("blankScene")}
|
||||
</button>
|
||||
<Link
|
||||
href="/templates"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm text-gray-200 hover:bg-[#252938] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
||||
>
|
||||
<LayoutTemplate className="h-4 w-4 shrink-0" aria-hidden />
|
||||
From Template
|
||||
{t("fromTemplate")}
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical } from "lucide-react";
|
||||
@@ -28,6 +29,7 @@ export function DraggableSceneItem({
|
||||
onDuplicate,
|
||||
onRename,
|
||||
}: DraggableSceneItemProps) {
|
||||
const t = useTranslations("auto.componentsStudioDraggableSceneItem");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(scene.name);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -80,7 +82,7 @@ export function DraggableSceneItem({
|
||||
type="button"
|
||||
ref={setActivatorNodeRef}
|
||||
className="flex w-6 shrink-0 cursor-grab items-center justify-center text-gray-500 hover:text-gray-300 active:cursor-grabbing"
|
||||
aria-label={`Drag scene ${scene.name}`}
|
||||
aria-label={t("dragScene", { name: scene.name })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -129,7 +131,7 @@ export function DraggableSceneItem({
|
||||
}
|
||||
}}
|
||||
className="mt-1.5 w-full rounded border border-[#2a2d3e] bg-[#1a1d2e] px-1.5 py-0.5 text-xs text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#4c6ef5]"
|
||||
aria-label="Scene name"
|
||||
aria-label={t("sceneNameLabel")}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,6 +18,8 @@ export function ProjectSaveIndicator({
|
||||
onRetry,
|
||||
className,
|
||||
}: ProjectSaveIndicatorProps) {
|
||||
const t = useTranslations("auto.componentsStudioProjectSaveIndicator");
|
||||
|
||||
if (status === "idle") return null;
|
||||
|
||||
if (status === "pending" || status === "saving") {
|
||||
@@ -29,7 +32,7 @@ export function ProjectSaveIndicator({
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
Saving…
|
||||
{t("saving")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +47,7 @@ export function ProjectSaveIndicator({
|
||||
aria-live="polite"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
Saved
|
||||
{t("saved")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -55,14 +58,14 @@ export function ProjectSaveIndicator({
|
||||
className={cn("text-xs font-medium text-gray-400", className)}
|
||||
aria-live="polite"
|
||||
>
|
||||
Local save
|
||||
{t("localSave")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("flex items-center gap-2", className)} aria-live="polite">
|
||||
<span className="text-xs text-red-400">Save failed</span>
|
||||
<span className="text-xs text-red-400">{t("saveFailed")}</span>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -71,7 +74,7 @@ export function ProjectSaveIndicator({
|
||||
className="h-7 border-[#2a2d3e] bg-[#1a1d2e] px-2 text-xs text-gray-200 hover:bg-[#252938]"
|
||||
onClick={onRetry}
|
||||
>
|
||||
Retry
|
||||
{t("retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { MousePointer2, SlidersHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { CommonLayerControls } from "@/components/studio/properties/CommonLayerControls";
|
||||
import { ImageLayerProperties } from "@/components/studio/properties/ImageLayerProperties";
|
||||
@@ -14,6 +15,7 @@ export interface PropertiesPanelProps {
|
||||
}
|
||||
|
||||
export function PropertiesPanel({ className }: PropertiesPanelProps) {
|
||||
const t = useTranslations("auto.componentsStudioPropertiesPanel");
|
||||
const scenes = useStudioStore((state) => state.scenes);
|
||||
const activeSceneId = useStudioStore((state) => state.activeSceneId);
|
||||
const selectedLayerId = useStudioStore((state) => state.selectedLayerId);
|
||||
@@ -33,7 +35,7 @@ export function PropertiesPanel({ className }: PropertiesPanelProps) {
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 px-3 py-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Properties
|
||||
{t("title")}
|
||||
</h2>
|
||||
<SlidersHorizontal className="h-4 w-4 text-gray-400" aria-hidden />
|
||||
</div>
|
||||
@@ -44,14 +46,14 @@ export function PropertiesPanel({ className }: PropertiesPanelProps) {
|
||||
<MousePointer2 className="h-6 w-6 text-gray-300" aria-hidden />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Select a layer to edit properties
|
||||
{t("emptyState")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 space-y-5 overflow-y-auto p-3">
|
||||
<p className="text-[11px] font-medium capitalize text-gray-400">
|
||||
{layer.type} layer
|
||||
{t("layerLabel", { type: layer.type })}
|
||||
</p>
|
||||
{layer.type === "text" ? <TextLayerProperties layer={layer} /> : null}
|
||||
{layer.type === "image" ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Download, Link2, Loader2, RefreshCw } from "lucide-react";
|
||||
|
||||
import { apiFetch } from "@/lib/api/fetch";
|
||||
@@ -49,6 +50,7 @@ export function RenderModal({
|
||||
scenes,
|
||||
preset = null,
|
||||
}: RenderModalProps) {
|
||||
const t = useTranslations("auto.componentsStudioRenderModal");
|
||||
const [resolution, setResolution] =
|
||||
useState<RenderSettings["resolution"]>("1080p");
|
||||
const [fps, setFps] = useState<RenderSettings["fps"]>(30);
|
||||
@@ -93,13 +95,13 @@ export function RenderModal({
|
||||
|
||||
if (!response.ok) {
|
||||
setJobStatus("failed");
|
||||
setErrorMessage("Could not fetch render status.");
|
||||
setErrorMessage(t("errorFetchStatus"));
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(data.progress ?? 0);
|
||||
setProgressMessage(
|
||||
data.progressMessage ?? `Rendering… ${data.progress}%`
|
||||
data.progressMessage ?? t("renderingProgress", { progress: data.progress })
|
||||
);
|
||||
if (data.previewB64) setPreviewB64(data.previewB64);
|
||||
|
||||
@@ -112,18 +114,18 @@ export function RenderModal({
|
||||
|
||||
if (data.status === "failed") {
|
||||
setJobStatus("failed");
|
||||
setErrorMessage(data.errorMessage ?? "Render failed.");
|
||||
setErrorMessage(data.errorMessage ?? t("errorRenderFailed"));
|
||||
}
|
||||
} catch {
|
||||
setJobStatus("failed");
|
||||
setErrorMessage("Network error while polling status.");
|
||||
setErrorMessage(t("errorNetworkPolling"));
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
const intervalId = window.setInterval(poll, 3000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [jobStatus, jobId]);
|
||||
}, [jobStatus, jobId, t]);
|
||||
|
||||
const startRender = async () => {
|
||||
setJobStatus("submitting");
|
||||
@@ -150,17 +152,17 @@ export function RenderModal({
|
||||
|
||||
if (!response.ok || !data.jobId) {
|
||||
setJobStatus("failed");
|
||||
setErrorMessage(data.error ?? "Failed to start render.");
|
||||
setErrorMessage(data.error ?? t("errorStartRender"));
|
||||
return;
|
||||
}
|
||||
|
||||
setJobId(data.jobId);
|
||||
setJobStatus("polling");
|
||||
setProgress(0);
|
||||
setProgressMessage("Queued for rendering…");
|
||||
setProgressMessage(t("queued"));
|
||||
} catch {
|
||||
setJobStatus("failed");
|
||||
setErrorMessage("Could not reach render API.");
|
||||
setErrorMessage(t("errorReachApi"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,17 +172,17 @@ export function RenderModal({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{presetLabel ?? "Export"}</DialogTitle>
|
||||
<DialogTitle>{presetLabel ?? t("dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{preset
|
||||
? RENDER_EXPORT_PRESETS[preset].description
|
||||
: "Export your project as MP4 via the nexrender pipeline."}
|
||||
: t("dialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{jobStatus === "completed" && outputUrl ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-green-400">Your video is ready.</p>
|
||||
<p className="text-sm text-green-400">{t("videoReady")}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href={outputUrl}
|
||||
@@ -188,7 +190,7 @@ export function RenderModal({
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download MP4
|
||||
{t("downloadMp4")}
|
||||
</a>
|
||||
<a
|
||||
href={outputUrl}
|
||||
@@ -197,7 +199,7 @@ export function RenderModal({
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[#2a2d3e] px-4 py-2.5 text-sm text-gray-200 hover:bg-[#1f2234] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
Share link
|
||||
{t("shareLink")}
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
@@ -206,13 +208,13 @@ export function RenderModal({
|
||||
className="w-full border-[#2a2d3e]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
{t("close")}
|
||||
</Button>
|
||||
</div>
|
||||
) : jobStatus === "failed" ? (
|
||||
<div className="space-y-4">
|
||||
<p className="rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2 text-sm text-red-300">
|
||||
{errorMessage ?? "Something went wrong."}
|
||||
{errorMessage ?? t("errorGeneric")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -220,7 +222,7 @@ export function RenderModal({
|
||||
onClick={startRender}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Retry
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : isBusy ? (
|
||||
@@ -231,7 +233,7 @@ export function RenderModal({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`data:image/png;base64,${previewB64}`}
|
||||
alt="Render preview"
|
||||
alt={t("previewAlt")}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -241,12 +243,12 @@ export function RenderModal({
|
||||
)}
|
||||
{/* Step badge */}
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-black/60 px-2 py-0.5 text-[10px] font-medium text-gray-300 backdrop-blur-sm">
|
||||
{progressMessage || "Rendering…"}
|
||||
{progressMessage || t("rendering")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-xs text-gray-500">
|
||||
<span>Progress</span>
|
||||
<span>{t("progress")}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[#1a1d2e]">
|
||||
@@ -261,7 +263,7 @@ export function RenderModal({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-gray-400">
|
||||
Resolution
|
||||
{t("resolution")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{RESOLUTIONS.map((item) => (
|
||||
@@ -282,13 +284,13 @@ export function RenderModal({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-gray-400">Format</p>
|
||||
<p className="mb-2 text-xs font-medium text-gray-400">{t("format")}</p>
|
||||
<div className="rounded-lg border border-primary-500 bg-primary-600/20 px-3 py-2 text-center text-xs font-medium text-white">
|
||||
MP4
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-gray-400">FPS</p>
|
||||
<p className="mb-2 text-xs font-medium text-gray-400">{t("fps")}</p>
|
||||
<div className="flex gap-2">
|
||||
{FPS_OPTIONS.map((item) => (
|
||||
<button
|
||||
@@ -313,7 +315,7 @@ export function RenderModal({
|
||||
onClick={startRender}
|
||||
disabled={scenes.length === 0}
|
||||
>
|
||||
Start Rendering
|
||||
{t("startRendering")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Clock, ImageIcon, User } from "lucide-react";
|
||||
|
||||
import { getScenePreviewVideoSrc } from "@/lib/template-preview-media";
|
||||
@@ -52,6 +53,7 @@ export function SceneBrowserCard({
|
||||
isSelected,
|
||||
onToggle,
|
||||
}: SceneBrowserCardProps) {
|
||||
const t = useTranslations("auto.componentsStudioSceneBrowserCard");
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const videoSrc = getScenePreviewVideoSrc(scene.category, scene.id);
|
||||
|
||||
@@ -141,7 +143,7 @@ export function SceneBrowserCard({
|
||||
{!isSelected && hovered && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-lg">
|
||||
Select
|
||||
{t("selectCta")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LayoutGrid, Search, X } from "lucide-react";
|
||||
|
||||
import { SceneBrowserCard } from "@/components/studio/SceneBrowserCard";
|
||||
@@ -34,6 +35,7 @@ export function SceneBrowserModal({
|
||||
onOpenChange,
|
||||
onScenesAdd,
|
||||
}: SceneBrowserModalProps) {
|
||||
const t = useTranslations("auto.componentsStudioSceneBrowserModal");
|
||||
const [categoryId, setCategoryId] = useState<SceneBrowserCategoryId>("all");
|
||||
const [mediaFilter, setMediaFilter] = useState<SceneBrowserMediaFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -92,13 +94,13 @@ export function SceneBrowserModal({
|
||||
<DialogHeader className="shrink-0 border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<DialogTitle className="font-heading text-lg font-semibold text-gray-900">
|
||||
Select Scenes
|
||||
{t("title")}
|
||||
</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600"
|
||||
aria-label="Close"
|
||||
aria-label={t("closeAriaLabel")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -111,9 +113,9 @@ export function SceneBrowserModal({
|
||||
onValueChange={(v) => setMediaFilter(v as SceneBrowserMediaFilter)}
|
||||
>
|
||||
<TabsList className="bg-gray-100">
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="video">Video</TabsTrigger>
|
||||
<TabsTrigger value="photo">Photo</TabsTrigger>
|
||||
<TabsTrigger value="all">{t("filterAll")}</TabsTrigger>
|
||||
<TabsTrigger value="video">{t("filterVideo")}</TabsTrigger>
|
||||
<TabsTrigger value="photo">{t("filterPhoto")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@@ -121,7 +123,7 @@ export function SceneBrowserModal({
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search scenes..."
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white pl-9 pr-3 text-sm placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600"
|
||||
@@ -160,7 +162,7 @@ export function SceneBrowserModal({
|
||||
{filteredScenes.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-center text-sm text-gray-500">
|
||||
<LayoutGrid className="h-10 w-10 text-gray-300" aria-hidden />
|
||||
No scenes match your filters.
|
||||
{t("emptyState")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
@@ -184,7 +186,7 @@ export function SceneBrowserModal({
|
||||
{selectedCount > 0 && (
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="font-semibold text-gray-900">{selectedCount}</span>{" "}
|
||||
scene{selectedCount !== 1 ? "s" : ""} selected
|
||||
{t("selectedSuffix", { count: selectedCount })}
|
||||
</span>
|
||||
)}
|
||||
{selectedCount > 0 && (
|
||||
@@ -193,7 +195,7 @@ export function SceneBrowserModal({
|
||||
onClick={deselectAll}
|
||||
className="text-sm text-gray-500 underline hover:text-gray-700 focus-visible:outline-none"
|
||||
>
|
||||
Deselect All
|
||||
{t("deselectAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -205,7 +207,7 @@ export function SceneBrowserModal({
|
||||
onClick={handleClose}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -214,8 +216,8 @@ export function SceneBrowserModal({
|
||||
className="min-w-[140px] bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{selectedCount === 0
|
||||
? "Add to Video"
|
||||
: `Add to Video (${selectedCount})`}
|
||||
? t("addToVideo")
|
||||
: t("addToVideoCount", { count: selectedCount })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface SceneItemActionsProps {
|
||||
sceneName: string;
|
||||
@@ -15,6 +16,7 @@ export function SceneItemActions({
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: SceneItemActionsProps) {
|
||||
const t = useTranslations("auto.componentsStudioSceneItemActions");
|
||||
return (
|
||||
<div
|
||||
className="absolute right-1 top-1 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
@@ -27,7 +29,7 @@ export function SceneItemActions({
|
||||
onDuplicate();
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-[#0f111a]/90 text-gray-300 hover:bg-[#1f2234] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
||||
aria-label={`Duplicate ${sceneName}`}
|
||||
aria-label={t("duplicate", { sceneName })}
|
||||
>
|
||||
<Copy className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
@@ -39,7 +41,7 @@ export function SceneItemActions({
|
||||
onDelete();
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-[#0f111a]/90 text-gray-300 hover:bg-red-600/90 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
||||
aria-label={`Delete ${sceneName}`}
|
||||
aria-label={t("delete", { sceneName })}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -19,6 +20,7 @@ export function SceneTransitionPicker({
|
||||
transitionType,
|
||||
onChange,
|
||||
}: SceneTransitionPickerProps) {
|
||||
const t = useTranslations("auto.componentsStudioSceneTransitionPicker");
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeOption =
|
||||
SCENE_TRANSITION_OPTIONS.find((option) => option.id === transitionType) ??
|
||||
@@ -30,8 +32,8 @@ export function SceneTransitionPicker({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title="Transition"
|
||||
aria-label="Transition"
|
||||
title={t("transition")}
|
||||
aria-label={t("transition")}
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full border border-[#2a2d3e] bg-[#1a1d2e] text-[#8b91a7] transition-colors hover:border-[#3d4260] hover:bg-[#252938] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]",
|
||||
transitionType !== "none" && "border-[#4c6ef5]/60 text-[#7b9eff]"
|
||||
@@ -42,7 +44,7 @@ export function SceneTransitionPicker({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-44 p-2">
|
||||
<p className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wide text-[#5c6278]">
|
||||
Transition
|
||||
{t("transition")}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{SCENE_TRANSITION_OPTIONS.map((option) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Monitor } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -9,11 +10,11 @@ export interface StudioMobileGateProps {
|
||||
variant?: "video" | "image";
|
||||
}
|
||||
|
||||
export function StudioMobileGate({ variant = "video" }: StudioMobileGateProps) {
|
||||
export async function StudioMobileGate({ variant = "video" }: StudioMobileGateProps) {
|
||||
const t = await getTranslations("auto.componentsStudioStudioMobileGate");
|
||||
|
||||
const title =
|
||||
variant === "video"
|
||||
? "The Video Studio requires a desktop browser."
|
||||
: "The Image Editor requires a desktop browser.";
|
||||
variant === "video" ? t("titleVideo") : t("titleImage");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -31,14 +32,14 @@ export function StudioMobileGate({ variant = "video" }: StudioMobileGateProps) {
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-3 max-w-sm text-sm leading-relaxed text-gray-400">
|
||||
Please open this project on a desktop or laptop.
|
||||
{t("description")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-8 bg-blue-600 hover:bg-blue-700 focus-visible:ring-blue-500"
|
||||
>
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
<Link href="/dashboard">{t("dashboardCta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ArrowRight,
|
||||
Circle,
|
||||
@@ -23,13 +24,13 @@ import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
const SHAPE_OPTIONS: {
|
||||
kind: ShapeKind;
|
||||
label: string;
|
||||
labelKey: string;
|
||||
icon: typeof Square;
|
||||
config: AddLayerInput;
|
||||
}[] = [
|
||||
{
|
||||
kind: "rect",
|
||||
label: "Rectangle",
|
||||
labelKey: "shapeRectangle",
|
||||
icon: Square,
|
||||
config: {
|
||||
type: "shape",
|
||||
@@ -42,7 +43,7 @@ const SHAPE_OPTIONS: {
|
||||
},
|
||||
{
|
||||
kind: "circle",
|
||||
label: "Circle",
|
||||
labelKey: "shapeCircle",
|
||||
icon: Circle,
|
||||
config: {
|
||||
type: "shape",
|
||||
@@ -55,7 +56,7 @@ const SHAPE_OPTIONS: {
|
||||
},
|
||||
{
|
||||
kind: "line",
|
||||
label: "Line",
|
||||
labelKey: "shapeLine",
|
||||
icon: Minus,
|
||||
config: {
|
||||
type: "shape",
|
||||
@@ -68,7 +69,7 @@ const SHAPE_OPTIONS: {
|
||||
},
|
||||
{
|
||||
kind: "arrow",
|
||||
label: "Arrow",
|
||||
labelKey: "shapeArrow",
|
||||
icon: ArrowRight,
|
||||
config: {
|
||||
type: "shape",
|
||||
@@ -87,6 +88,7 @@ const SHAPE_OPTIONS: {
|
||||
];
|
||||
|
||||
export function StudioToolbar() {
|
||||
const t = useTranslations("auto.componentsStudioStudioToolbar");
|
||||
const addLayer = useStudioStore((state) => state.addLayer);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -100,7 +102,7 @@ export function StudioToolbar() {
|
||||
width: 300,
|
||||
height: 60,
|
||||
props: {
|
||||
text: "Edit this text",
|
||||
text: t("defaultText"),
|
||||
fontSize: 48,
|
||||
fill: "#ffffff",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
@@ -162,19 +164,19 @@ export function StudioToolbar() {
|
||||
onChange={handleVideoFile}
|
||||
/>
|
||||
|
||||
<ToolbarIconButton label="Add text" onClick={handleAddText}>
|
||||
<ToolbarIconButton label={t("addText")} onClick={handleAddText}>
|
||||
<Type className="h-4 w-4" aria-hidden />
|
||||
</ToolbarIconButton>
|
||||
|
||||
<ToolbarIconButton
|
||||
label="Add image"
|
||||
label={t("addImage")}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" aria-hidden />
|
||||
</ToolbarIconButton>
|
||||
|
||||
<ToolbarIconButton
|
||||
label="Add video clip"
|
||||
label={t("addVideoClip")}
|
||||
onClick={() => videoInputRef.current?.click()}
|
||||
>
|
||||
<Clapperboard className="h-4 w-4" aria-hidden />
|
||||
@@ -185,7 +187,7 @@ export function StudioToolbar() {
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add shape"
|
||||
aria-label={t("addShape")}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-500 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<Square className="h-4 w-4" aria-hidden />
|
||||
@@ -194,12 +196,12 @@ export function StudioToolbar() {
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-[10px] font-medium text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
Add shape
|
||||
{t("addShape")}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-40">
|
||||
{SHAPE_OPTIONS.map(({ kind, label, icon: Icon, config }) => (
|
||||
{SHAPE_OPTIONS.map(({ kind, labelKey, icon: Icon, config }) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
@@ -210,7 +212,7 @@ export function StudioToolbar() {
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Rect, Text } from "react-konva";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type Konva from "konva";
|
||||
|
||||
import type { Layer } from "@/lib/studio-types";
|
||||
@@ -26,9 +27,12 @@ export function VideoLayerNode({
|
||||
onTransformEnd,
|
||||
registerNode,
|
||||
}: VideoLayerNodeProps) {
|
||||
const t = useTranslations("auto.componentsStudioCanvasVideoLayerNode");
|
||||
const hasVideo = Boolean(getVideoSrc(layer.props));
|
||||
const fileName =
|
||||
typeof layer.props.fileName === "string" ? layer.props.fileName : "Video";
|
||||
typeof layer.props.fileName === "string"
|
||||
? layer.props.fileName
|
||||
: t("defaultFileName");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -62,7 +66,7 @@ export function VideoLayerNode({
|
||||
width={layer.width}
|
||||
height={20}
|
||||
rotation={layer.rotation}
|
||||
text={hasVideo ? fileName : "Video clip"}
|
||||
text={hasVideo ? fileName : t("placeholder")}
|
||||
fontSize={14}
|
||||
fill="#E5E7EB"
|
||||
align="center"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ interface CommonLayerControlsProps {
|
||||
}
|
||||
|
||||
export function CommonLayerControls({ layer }: CommonLayerControlsProps) {
|
||||
const t = useTranslations("auto.componentsStudioPropertiesCommonLayerControls");
|
||||
const [aspectLocked, setAspectLocked] = useState(false);
|
||||
const aspectRatioRef = useRef(layer.width / layer.height || 1);
|
||||
const { update } = useLayerUpdater(layer);
|
||||
@@ -44,7 +46,7 @@ export function CommonLayerControls({ layer }: CommonLayerControlsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelSection title="Transform">
|
||||
<PanelSection title={t("transformTitle")}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PropertyNumberInput
|
||||
label="X"
|
||||
@@ -60,13 +62,13 @@ export function CommonLayerControls({ layer }: CommonLayerControlsProps) {
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid flex-1 grid-cols-2 gap-2">
|
||||
<PropertyNumberInput
|
||||
label="Width"
|
||||
label={t("widthLabel")}
|
||||
value={layer.width}
|
||||
min={8}
|
||||
onChange={setWidth}
|
||||
/>
|
||||
<PropertyNumberInput
|
||||
label="Height"
|
||||
label={t("heightLabel")}
|
||||
value={layer.height}
|
||||
min={8}
|
||||
onChange={setHeight}
|
||||
@@ -85,13 +87,13 @@ export function CommonLayerControls({ layer }: CommonLayerControlsProps) {
|
||||
/>
|
||||
</div>
|
||||
<PropertyNumberInput
|
||||
label="Rotation (°)"
|
||||
label={t("rotationLabel")}
|
||||
value={layer.rotation}
|
||||
onChange={(rotation) => update({ rotation })}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Layer order">
|
||||
<PanelSection title={t("layerOrderTitle")}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -99,7 +101,7 @@ export function CommonLayerControls({ layer }: CommonLayerControlsProps) {
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600 hover:border-gray-300 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" aria-hidden />
|
||||
To front
|
||||
{t("toFront")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -107,7 +109,7 @@ export function CommonLayerControls({ layer }: CommonLayerControlsProps) {
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600 hover:border-gray-300 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" aria-hidden />
|
||||
To back
|
||||
{t("toBack")}
|
||||
</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
@@ -118,7 +120,7 @@ export function CommonLayerControls({ layer }: CommonLayerControlsProps) {
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-xs font-medium text-red-500 transition-colors hover:border-red-300 hover:bg-red-100 hover:text-red-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
Delete layer
|
||||
{t("deleteLayer")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type ChangeEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FlipHorizontal, FlipVertical, ImagePlus } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ interface ImageLayerPropertiesProps {
|
||||
}
|
||||
|
||||
export function ImageLayerProperties({ layer }: ImageLayerPropertiesProps) {
|
||||
const t = useTranslations("auto.componentsStudioPropertiesImageLayerProperties");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { update, updateProps } = useLayerUpdater(layer);
|
||||
const image = getImageProps(layer.props);
|
||||
@@ -35,9 +37,9 @@ export function ImageLayerProperties({ layer }: ImageLayerPropertiesProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelSection title="Image">
|
||||
<PanelSection title={t("sectionTitle")}>
|
||||
<PropertySlider
|
||||
label="Opacity"
|
||||
label={t("opacity")}
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round(layer.opacity * 100)}
|
||||
@@ -53,7 +55,7 @@ export function ImageLayerProperties({ layer }: ImageLayerPropertiesProps) {
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<FlipHorizontal className="h-3.5 w-3.5" aria-hidden />
|
||||
Flip H
|
||||
{t("flipHorizontal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -61,7 +63,7 @@ export function ImageLayerProperties({ layer }: ImageLayerPropertiesProps) {
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-2 text-[11px] text-gray-600 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<FlipVertical className="h-3.5 w-3.5" aria-hidden />
|
||||
Flip V
|
||||
{t("flipVertical")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -77,10 +79,10 @@ export function ImageLayerProperties({ layer }: ImageLayerPropertiesProps) {
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 hover:border-gray-300 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<ImagePlus className="h-3.5 w-3.5" aria-hidden />
|
||||
Replace image
|
||||
{t("replaceImage")}
|
||||
</button>
|
||||
<PropertySlider
|
||||
label="Border radius"
|
||||
label={t("borderRadius")}
|
||||
min={0}
|
||||
max={100}
|
||||
value={image.cornerRadius}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Lock, Unlock } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -167,12 +168,13 @@ export function AspectRatioLockButton({
|
||||
locked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const t = useTranslations("auto.componentsStudioPropertiesPropertyControls");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex h-[30px] w-8 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-gray-500 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label={locked ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
aria-label={locked ? t("unlockAspectRatio") : t("lockAspectRatio")}
|
||||
aria-pressed={locked}
|
||||
>
|
||||
{locked ? (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
PanelSection,
|
||||
PropertyColorInput,
|
||||
@@ -14,23 +16,24 @@ interface ShapeLayerPropertiesProps {
|
||||
}
|
||||
|
||||
export function ShapeLayerProperties({ layer }: ShapeLayerPropertiesProps) {
|
||||
const t = useTranslations("auto.componentsStudioPropertiesShapeLayerProperties");
|
||||
const { update, updateProps } = useLayerUpdater(layer);
|
||||
const shape = getShapeProps(layer.props);
|
||||
|
||||
return (
|
||||
<PanelSection title="Shape">
|
||||
<PanelSection title={t("sectionTitle")}>
|
||||
<PropertyColorInput
|
||||
label="Fill color"
|
||||
label={t("fillColor")}
|
||||
value={shape.fill}
|
||||
onChange={(fill) => updateProps({ fill })}
|
||||
/>
|
||||
<PropertyColorInput
|
||||
label="Stroke color"
|
||||
label={t("strokeColor")}
|
||||
value={shape.stroke}
|
||||
onChange={(stroke) => updateProps({ stroke })}
|
||||
/>
|
||||
<PropertySlider
|
||||
label="Stroke width"
|
||||
label={t("strokeWidth")}
|
||||
min={0}
|
||||
max={40}
|
||||
value={shape.strokeWidth}
|
||||
@@ -38,7 +41,7 @@ export function ShapeLayerProperties({ layer }: ShapeLayerPropertiesProps) {
|
||||
/>
|
||||
{shape.shape === "rect" ? (
|
||||
<PropertySlider
|
||||
label="Border radius"
|
||||
label={t("borderRadius")}
|
||||
min={0}
|
||||
max={100}
|
||||
value={shape.cornerRadius}
|
||||
@@ -46,7 +49,7 @@ export function ShapeLayerProperties({ layer }: ShapeLayerPropertiesProps) {
|
||||
/>
|
||||
) : null}
|
||||
<PropertySlider
|
||||
label="Opacity"
|
||||
label={t("opacity")}
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round(layer.opacity * 100)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Italic,
|
||||
Underline,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
PanelSection,
|
||||
@@ -31,13 +32,14 @@ interface TextLayerPropertiesProps {
|
||||
}
|
||||
|
||||
export function TextLayerProperties({ layer }: TextLayerPropertiesProps) {
|
||||
const t = useTranslations("auto.componentsStudioPropertiesTextLayerProperties");
|
||||
const { update, updateProps } = useLayerUpdater(layer);
|
||||
const text = getTextProps(layer.props);
|
||||
|
||||
return (
|
||||
<PanelSection title="Text">
|
||||
<PanelSection title={t("sectionTitle")}>
|
||||
<PropertySelect
|
||||
label="Font family"
|
||||
label={t("fontFamily")}
|
||||
value={text.fontFamily}
|
||||
options={FONT_FAMILY_OPTIONS.map((item) => ({
|
||||
label: item.label,
|
||||
@@ -46,7 +48,7 @@ export function TextLayerProperties({ layer }: TextLayerPropertiesProps) {
|
||||
onChange={(fontFamily) => updateProps({ fontFamily })}
|
||||
/>
|
||||
<PropertySlider
|
||||
label="Font size"
|
||||
label={t("fontSize")}
|
||||
min={8}
|
||||
max={200}
|
||||
value={text.fontSize}
|
||||
@@ -54,21 +56,21 @@ export function TextLayerProperties({ layer }: TextLayerPropertiesProps) {
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<ToggleIconButton
|
||||
label="Bold"
|
||||
label={t("bold")}
|
||||
active={text.bold}
|
||||
onClick={() => updateProps({ bold: !text.bold })}
|
||||
>
|
||||
<Bold className="h-3.5 w-3.5" />
|
||||
</ToggleIconButton>
|
||||
<ToggleIconButton
|
||||
label="Italic"
|
||||
label={t("italic")}
|
||||
active={text.italic}
|
||||
onClick={() => updateProps({ italic: !text.italic })}
|
||||
>
|
||||
<Italic className="h-3.5 w-3.5" />
|
||||
</ToggleIconButton>
|
||||
<ToggleIconButton
|
||||
label="Underline"
|
||||
label={t("underline")}
|
||||
active={text.underline}
|
||||
onClick={() => updateProps({ underline: !text.underline })}
|
||||
>
|
||||
@@ -76,22 +78,22 @@ export function TextLayerProperties({ layer }: TextLayerPropertiesProps) {
|
||||
</ToggleIconButton>
|
||||
</div>
|
||||
<PropertyColorInput
|
||||
label="Text color"
|
||||
label={t("textColor")}
|
||||
value={text.fill}
|
||||
onChange={(fill) => updateProps({ fill })}
|
||||
/>
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-medium text-gray-400">Alignment</p>
|
||||
<p className="mb-1 text-[11px] font-medium text-gray-400">{t("alignment")}</p>
|
||||
<div className="flex gap-1">
|
||||
{(
|
||||
[
|
||||
{ value: "left" as TextAlign, icon: AlignLeft, label: "Left" },
|
||||
{ value: "left" as TextAlign, icon: AlignLeft, label: t("alignLeft") },
|
||||
{
|
||||
value: "center" as TextAlign,
|
||||
icon: AlignCenter,
|
||||
label: "Center",
|
||||
label: t("alignCenter"),
|
||||
},
|
||||
{ value: "right" as TextAlign, icon: AlignRight, label: "Right" },
|
||||
{ value: "right" as TextAlign, icon: AlignRight, label: t("alignRight") },
|
||||
] as const
|
||||
).map(({ value, icon: Icon, label }) => (
|
||||
<ToggleIconButton
|
||||
@@ -106,7 +108,7 @@ export function TextLayerProperties({ layer }: TextLayerPropertiesProps) {
|
||||
</div>
|
||||
</div>
|
||||
<PropertySlider
|
||||
label="Letter spacing"
|
||||
label={t("letterSpacing")}
|
||||
min={-5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
@@ -114,7 +116,7 @@ export function TextLayerProperties({ layer }: TextLayerPropertiesProps) {
|
||||
onChange={(letterSpacing) => updateProps({ letterSpacing })}
|
||||
/>
|
||||
<PropertySlider
|
||||
label="Line height"
|
||||
label={t("lineHeight")}
|
||||
min={0.8}
|
||||
max={3}
|
||||
step={0.1}
|
||||
@@ -123,7 +125,7 @@ export function TextLayerProperties({ layer }: TextLayerPropertiesProps) {
|
||||
onChange={(lineHeight) => updateProps({ lineHeight })}
|
||||
/>
|
||||
<PropertySlider
|
||||
label="Opacity"
|
||||
label={t("opacity")}
|
||||
min={0}
|
||||
max={100}
|
||||
value={Math.round(layer.opacity * 100)}
|
||||
@@ -131,7 +133,7 @@ export function TextLayerProperties({ layer }: TextLayerPropertiesProps) {
|
||||
onChange={(opacity) => update({ opacity: opacity / 100 })}
|
||||
/>
|
||||
<PropertySelect<TextAnimation>
|
||||
label="Animation"
|
||||
label={t("animation")}
|
||||
value={text.animation}
|
||||
options={TEXT_ANIMATION_OPTIONS}
|
||||
onChange={(animation) => updateProps({ animation })}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { AudioSidebarMusicTab } from "@/components/studio/sidebar/AudioSidebarMusicTab";
|
||||
import { AudioSidebarVoiceoverPane } from "@/components/studio/sidebar/AudioSidebarVoiceoverPane";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AudioSidebarContent() {
|
||||
const t = useTranslations("auto.componentsStudioSidebarAudioSidebarContent");
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -19,13 +23,13 @@ export function AudioSidebarContent() {
|
||||
value="music"
|
||||
className="rounded-full text-xs font-medium data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Music
|
||||
{t("musicTab")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="voiceover"
|
||||
className="rounded-full text-xs font-medium data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Voiceover
|
||||
{t("voiceoverTab")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { Box, HardDrive, Search, UploadCloud } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -11,6 +12,7 @@ import { useStudioStore } from "@/lib/studio-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AudioSidebarMusicTab() {
|
||||
const t = useTranslations("auto.componentsStudioSidebarAudioSidebarMusicTab");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const setAudioTrack = useStudioStore((state) => state.setAudioTrack);
|
||||
const [includeTemplateSfx, setIncludeTemplateSfx] = useState(true);
|
||||
@@ -51,7 +53,7 @@ export function AudioSidebarMusicTab() {
|
||||
<div className="space-y-2">
|
||||
<SourceButton
|
||||
icon={UploadCloud}
|
||||
label="Upload"
|
||||
label={t("upload")}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
/>
|
||||
<SourceButton icon={Box} label="Dropbox" onClick={() => undefined} />
|
||||
@@ -64,12 +66,12 @@ export function AudioSidebarMusicTab() {
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] leading-snug text-gray-500">
|
||||
Include template sound effect
|
||||
{t("includeTemplateSfx")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={includeTemplateSfx}
|
||||
onCheckedChange={setIncludeTemplateSfx}
|
||||
aria-label="Include template sound effect"
|
||||
aria-label={t("includeTemplateSfx")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +82,7 @@ export function AudioSidebarMusicTab() {
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search music"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white pl-8 pr-3 text-xs text-white placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
@@ -93,13 +95,13 @@ export function AudioSidebarMusicTab() {
|
||||
value="library"
|
||||
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Music library
|
||||
{t("musicLibrary")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="my-music"
|
||||
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
My music
|
||||
{t("myMusic")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -129,7 +131,7 @@ export function AudioSidebarMusicTab() {
|
||||
<TabsContent value="my-music" className="mt-3">
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 bg-white/40 px-3 py-8 text-center">
|
||||
<UploadCloud className="h-8 w-8 text-gray-500" aria-hidden />
|
||||
<p className="mt-3 text-xs text-gray-500">Upload your own music</p>
|
||||
<p className="mt-3 text-xs text-gray-500">{t("uploadOwnMusic")}</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -137,7 +139,7 @@ export function AudioSidebarMusicTab() {
|
||||
className="mt-3 h-8 border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
Upload
|
||||
{t("upload")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Mic2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/** Voiceover tab body (same content as legacy TtsSidebarContent). */
|
||||
export function AudioSidebarVoiceoverPane() {
|
||||
const t = useTranslations(
|
||||
"auto.componentsStudioSidebarAudioSidebarVoiceoverPane"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-10 text-center">
|
||||
<Mic2 className="h-10 w-10 text-violet-400" aria-hidden />
|
||||
<p className="mt-4 text-sm font-medium text-gray-300">Coming soon</p>
|
||||
<p className="mt-4 text-sm font-medium text-gray-300">{t("comingSoon")}</p>
|
||||
<p className="mt-1 max-w-[180px] text-xs text-gray-500">
|
||||
Generate voiceovers from your script directly in the studio.
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function ColorsCustomTab() {
|
||||
const t = useTranslations("auto.componentsStudioSidebarColorsCustomTab");
|
||||
const sceneBackgroundColor = useStudioStore(
|
||||
(state) => state.sceneBackgroundColor
|
||||
);
|
||||
@@ -23,7 +25,7 @@ export function ColorsCustomTab() {
|
||||
htmlFor="colors-main"
|
||||
className="shrink-0 text-xs text-gray-600"
|
||||
>
|
||||
Main Color
|
||||
{t("mainColor")}
|
||||
</label>
|
||||
<input
|
||||
id="colors-main"
|
||||
@@ -39,7 +41,7 @@ export function ColorsCustomTab() {
|
||||
htmlFor="colors-additional"
|
||||
className="shrink-0 text-xs text-gray-600"
|
||||
>
|
||||
Additional Color
|
||||
{t("additionalColor")}
|
||||
</label>
|
||||
<input
|
||||
id="colors-additional"
|
||||
@@ -58,7 +60,7 @@ export function ColorsCustomTab() {
|
||||
applyPaletteToAllScenes(sceneBackgroundColor, sceneAccentColor)
|
||||
}
|
||||
>
|
||||
Apply to all scenes
|
||||
{t("applyToAllScenes")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { COLOR_PALETTES, PALETTE_NAMES } from "@/lib/studio-color-palettes";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,6 +15,7 @@ export function ColorsPalettesTab({
|
||||
activeColorIndex,
|
||||
onSelectPalette,
|
||||
}: ColorsPalettesTabProps) {
|
||||
const t = useTranslations("auto.componentsStudioSidebarColorsPalettesTab");
|
||||
const applyPaletteToAllScenes = useStudioStore(
|
||||
(state) => state.applyPaletteToAllScenes
|
||||
);
|
||||
@@ -22,13 +25,14 @@ export function ColorsPalettesTab({
|
||||
<ul className="space-y-1.5">
|
||||
{COLOR_PALETTES.map((palette, index) => {
|
||||
const isActive = activeColorIndex === index;
|
||||
const name = PALETTE_NAMES[index] ?? `Palette ${index + 1}`;
|
||||
const name =
|
||||
PALETTE_NAMES[index] ?? t("paletteFallback", { number: index + 1 });
|
||||
return (
|
||||
<li key={index}>
|
||||
<button
|
||||
type="button"
|
||||
title={name}
|
||||
aria-label={`Apply ${name} palette`}
|
||||
aria-label={t("applyPaletteAriaLabel", { name })}
|
||||
onClick={() => {
|
||||
onSelectPalette(index);
|
||||
applyPaletteToAllScenes(palette[0] ?? "#ffffff", palette[1] ?? "#94a3b8");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ColorsCustomTab } from "@/components/studio/sidebar/ColorsCustomTab";
|
||||
@@ -10,6 +11,7 @@ import { COLOR_PALETTES } from "@/lib/studio-color-palettes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ColorsSidebarContent() {
|
||||
const t = useTranslations("auto.componentsStudioSidebarColorsSidebarContent");
|
||||
const [activeColorIndex, setActiveColorIndex] = useState(0);
|
||||
|
||||
const activePalette = COLOR_PALETTES[activeColorIndex] ?? COLOR_PALETTES[0]!;
|
||||
@@ -34,13 +36,13 @@ export function ColorsSidebarContent() {
|
||||
value="palettes"
|
||||
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Palettes
|
||||
{t("palettesTab")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="custom"
|
||||
className="rounded-full text-[11px] data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-500"
|
||||
>
|
||||
Custom
|
||||
{t("customTab")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { PALETTE_NAMES } from "@/lib/studio-color-palettes";
|
||||
import { contrastTextColor } from "@/lib/studio-color-palettes";
|
||||
|
||||
@@ -10,8 +14,13 @@ export function ColorsTemplatePreviewCard({
|
||||
palette,
|
||||
paletteIndex,
|
||||
}: ColorsTemplatePreviewCardProps) {
|
||||
const t = useTranslations(
|
||||
"auto.componentsStudioSidebarColorsTemplatePreviewCard"
|
||||
);
|
||||
const [mainColor, accentColor] = palette;
|
||||
const paletteName = PALETTE_NAMES[paletteIndex] ?? `Palette ${paletteIndex + 1}`;
|
||||
const paletteName =
|
||||
PALETTE_NAMES[paletteIndex] ??
|
||||
t("paletteFallback", { number: paletteIndex + 1 });
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -31,7 +40,7 @@ export function ColorsTemplatePreviewCard({
|
||||
color: contrastTextColor(mainColor),
|
||||
}}
|
||||
>
|
||||
Main Color
|
||||
{t("mainColor")}
|
||||
</div>
|
||||
<div
|
||||
className="h-2.5 w-16 rounded-sm text-[8px] leading-none px-1 flex items-center"
|
||||
@@ -40,7 +49,7 @@ export function ColorsTemplatePreviewCard({
|
||||
color: `${contrastTextColor(mainColor)}cc`,
|
||||
}}
|
||||
>
|
||||
Additional
|
||||
{t("additional")}
|
||||
</div>
|
||||
<div
|
||||
className="h-2 w-14 rounded-sm"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
import { PropertySelect } from "@/components/studio/properties/PropertyControls";
|
||||
@@ -9,6 +10,7 @@ import { FONT_FAMILY_OPTIONS } from "@/lib/studio-layer-props";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
export function FontSidebarContent() {
|
||||
const t = useTranslations("auto.componentsStudioSidebarFontSidebarContent");
|
||||
const applyFontFamilyToAllTextLayers = useStudioStore(
|
||||
(state) => state.applyFontFamilyToAllTextLayers
|
||||
);
|
||||
@@ -23,14 +25,14 @@ export function FontSidebarContent() {
|
||||
className="w-full border-gray-200 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900"
|
||||
onClick={() => applyFontFamilyToAllTextLayers(fontFamily)}
|
||||
>
|
||||
Apply to all text layers
|
||||
{t("applyToAll")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarPanelShell title="Font" footer={footer}>
|
||||
<SidebarPanelShell title={t("title")} footer={footer}>
|
||||
<PropertySelect
|
||||
label="Font family"
|
||||
label={t("fontFamily")}
|
||||
value={fontFamily}
|
||||
options={FONT_FAMILY_OPTIONS.map((item) => ({
|
||||
label: item.label,
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ImagePlus, Plus, Type } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { getTextProps, mergeLayerProps } from "@/lib/studio-layer-props";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
export function SceneEditSidebarContent() {
|
||||
const t = useTranslations("auto.componentsStudioSidebarSceneEditSidebarContent");
|
||||
const scenes = useStudioStore((state) => state.scenes);
|
||||
const activeSceneId = useStudioStore((state) => state.activeSceneId);
|
||||
const updateLayer = useStudioStore((state) => state.updateLayer);
|
||||
@@ -24,7 +26,7 @@ export function SceneEditSidebarContent() {
|
||||
width: 800,
|
||||
height: 80,
|
||||
props: {
|
||||
text: "Your text here",
|
||||
text: t("defaultText"),
|
||||
fontSize: 48,
|
||||
fill: "#111827",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
@@ -42,7 +44,7 @@ export function SceneEditSidebarContent() {
|
||||
{/* Panel header */}
|
||||
<div className="shrink-0 border-b border-gray-200 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Edit Scene
|
||||
{t("panelTitle")}
|
||||
</p>
|
||||
{activeScene && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-gray-500">
|
||||
@@ -67,7 +69,11 @@ export function SceneEditSidebarContent() {
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<label className="flex items-center gap-1.5 text-[11px] font-semibold text-gray-600">
|
||||
<Type className="h-3 w-3 text-gray-400" aria-hidden />
|
||||
{idx === 0 ? "Title" : idx === 1 ? "Subtitle" : `Text ${idx + 1}`}
|
||||
{idx === 0
|
||||
? t("titleLabel")
|
||||
: idx === 1
|
||||
? t("subtitleLabel")
|
||||
: t("textLabel", { index: idx + 1 })}
|
||||
</label>
|
||||
<span
|
||||
className={
|
||||
@@ -90,7 +96,7 @@ export function SceneEditSidebarContent() {
|
||||
}),
|
||||
})
|
||||
}
|
||||
placeholder="Type here…"
|
||||
placeholder={t("textPlaceholder")}
|
||||
className="w-full resize-none rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-blue-400 focus:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
@@ -105,7 +111,7 @@ export function SceneEditSidebarContent() {
|
||||
{imageLayers.map((layer, idx) => (
|
||||
<ImageLayerInput
|
||||
key={layer.id}
|
||||
label={`Image ${idx + 1}`}
|
||||
label={t("imageLabel", { index: idx + 1 })}
|
||||
layerProps={layer.props}
|
||||
onReplace={(src) =>
|
||||
updateLayer(layer.id, {
|
||||
@@ -122,10 +128,10 @@ export function SceneEditSidebarContent() {
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Type className="mx-auto mb-3 h-8 w-8 text-gray-300" aria-hidden />
|
||||
<p className="text-xs text-gray-500">
|
||||
This scene has no content yet.
|
||||
{t("emptyStateTitle")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-gray-400">
|
||||
Add a text layer to start editing.
|
||||
{t("emptyStateHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -139,7 +145,7 @@ export function SceneEditSidebarContent() {
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-blue-300 bg-blue-50 px-3 py-2 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||||
Add Text Layer
|
||||
{t("addTextLayer")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,6 +162,7 @@ function ImageLayerInput({
|
||||
layerProps: Record<string, unknown>;
|
||||
onReplace: (src: string) => void;
|
||||
}) {
|
||||
const t = useTranslations("auto.componentsStudioSidebarSceneEditSidebarContent");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const src = typeof layerProps.src === "string" ? layerProps.src : null;
|
||||
|
||||
@@ -195,7 +202,7 @@ function ImageLayerInput({
|
||||
<ImagePlus className="h-5 w-5 shrink-0 text-gray-300" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 truncate text-gray-500">
|
||||
{src ? "Replace image" : "Upload image"}
|
||||
{src ? t("replaceImage") : t("uploadImage")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
import type { SceneTransition } from "@/lib/studio-types";
|
||||
@@ -36,10 +37,12 @@ function optionToTransitionType(id: TransitionOptionId): SceneTransition {
|
||||
|
||||
function TransitionOptionCard({
|
||||
option,
|
||||
label,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
option: (typeof TRANSITION_OPTIONS)[number];
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
@@ -63,13 +66,14 @@ function TransitionOptionCard({
|
||||
<span className="mx-auto h-2 w-3/4 rounded bg-white/30" aria-hidden />
|
||||
</div>
|
||||
<p className="bg-gray-100 py-1.5 text-center text-[11px] font-semibold text-gray-700">
|
||||
{option.label}
|
||||
{label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransitionsSidebarContent() {
|
||||
const t = useTranslations("auto.componentsStudioSidebarTransitionsSidebarContent");
|
||||
const scenes = useStudioStore((state) => state.scenes);
|
||||
const applyTransitionToAllScenes = useStudioStore(
|
||||
(state) => state.applyTransitionToAllScenes
|
||||
@@ -91,7 +95,7 @@ export function TransitionsSidebarContent() {
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-col overflow-hidden bg-white text-gray-900">
|
||||
<h2 className="shrink-0 border-b border-gray-200 px-3 py-3 text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Transitions
|
||||
{t("heading")}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 p-2">
|
||||
@@ -99,6 +103,7 @@ export function TransitionsSidebarContent() {
|
||||
<TransitionOptionCard
|
||||
key={option.id}
|
||||
option={option}
|
||||
label={option.id === "random" ? t("randomTransition") : t("noTransition")}
|
||||
selected={selectedOption === option.id}
|
||||
onSelect={() => handleSelect(option.id)}
|
||||
/>
|
||||
@@ -107,7 +112,7 @@ export function TransitionsSidebarContent() {
|
||||
|
||||
<div className="flex items-start gap-2 px-3 py-2 text-[10px] leading-relaxed text-gray-500">
|
||||
<Info className="mt-0.5 h-3 w-3 shrink-0 text-gray-600" aria-hidden />
|
||||
<p>Applied transitions will be visible on all scenes after export.</p>
|
||||
<p>{t("exportNote")}</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Mic2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { SidebarPanelShell } from "@/components/studio/sidebar/SidebarPanelShell";
|
||||
|
||||
export function TtsSidebarContent() {
|
||||
const t = useTranslations("auto.componentsStudioSidebarTtsSidebarContent");
|
||||
|
||||
return (
|
||||
<SidebarPanelShell title="Text to Speech">
|
||||
<SidebarPanelShell title={t("title")}>
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 bg-gray-50 py-10 text-center">
|
||||
<Mic2 className="h-10 w-10 text-violet-400" aria-hidden />
|
||||
<p className="mt-4 text-sm font-medium text-gray-300">Coming soon</p>
|
||||
<p className="mt-4 text-sm font-medium text-gray-300">{t("comingSoon")}</p>
|
||||
<p className="mt-1 max-w-[180px] text-xs text-gray-500">
|
||||
Generate voiceovers from your script directly in the studio.
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
</SidebarPanelShell>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { SidebarPanelShell } from "@/components/studio/sidebar/SidebarPanelShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,20 +10,23 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const WATERMARK_POSITIONS = [
|
||||
{ id: "top-left", label: "Top left" },
|
||||
{ id: "top-center", label: "Top center" },
|
||||
{ id: "top-right", label: "Top right" },
|
||||
{ id: "middle-left", label: "Middle left" },
|
||||
{ id: "center", label: "Center" },
|
||||
{ id: "middle-right", label: "Middle right" },
|
||||
{ id: "bottom-left", label: "Bottom left" },
|
||||
{ id: "bottom-center", label: "Bottom center" },
|
||||
{ id: "bottom-right", label: "Bottom right" },
|
||||
{ id: "top-left", labelKey: "positionTopLeft" },
|
||||
{ id: "top-center", labelKey: "positionTopCenter" },
|
||||
{ id: "top-right", labelKey: "positionTopRight" },
|
||||
{ id: "middle-left", labelKey: "positionMiddleLeft" },
|
||||
{ id: "center", labelKey: "positionCenter" },
|
||||
{ id: "middle-right", labelKey: "positionMiddleRight" },
|
||||
{ id: "bottom-left", labelKey: "positionBottomLeft" },
|
||||
{ id: "bottom-center", labelKey: "positionBottomCenter" },
|
||||
{ id: "bottom-right", labelKey: "positionBottomRight" },
|
||||
] as const;
|
||||
|
||||
type WatermarkPosition = (typeof WATERMARK_POSITIONS)[number]["id"];
|
||||
|
||||
export function WatermarkSidebarContent() {
|
||||
const t = useTranslations(
|
||||
"auto.componentsStudioSidebarWatermarkSidebarContent"
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedPosition, setSelectedPosition] =
|
||||
useState<WatermarkPosition>("bottom-right");
|
||||
@@ -34,12 +38,12 @@ export function WatermarkSidebarContent() {
|
||||
variant="outline"
|
||||
className="w-full border-gray-200 bg-white text-gray-200 hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Apply to all scenes
|
||||
{t("applyToAllScenes")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarPanelShell title="My Watermark" footer={footer}>
|
||||
<SidebarPanelShell title={t("title")} footer={footer}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
@@ -47,9 +51,9 @@ export function WatermarkSidebarContent() {
|
||||
>
|
||||
<Upload className="h-8 w-8 text-gray-400" aria-hidden />
|
||||
<p className="mt-3 text-xs font-medium text-gray-300">
|
||||
Upload your watermark logo
|
||||
{t("uploadLogo")}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-gray-400">PNG or SVG, max 2MB</p>
|
||||
<p className="mt-1 text-[10px] text-gray-400">{t("uploadHint")}</p>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -60,13 +64,15 @@ export function WatermarkSidebarContent() {
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="mb-2 text-[11px] font-medium text-gray-500">Position</p>
|
||||
<p className="mb-2 text-[11px] font-medium text-gray-500">
|
||||
{t("position")}
|
||||
</p>
|
||||
<div className="grid w-fit grid-cols-3 gap-1.5">
|
||||
{WATERMARK_POSITIONS.map((position) => (
|
||||
<button
|
||||
key={position.id}
|
||||
type="button"
|
||||
aria-label={position.label}
|
||||
aria-label={t(position.labelKey)}
|
||||
onClick={() => setSelectedPosition(position.id)}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
@@ -81,7 +87,9 @@ export function WatermarkSidebarContent() {
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-[11px] font-medium text-gray-500">Opacity</p>
|
||||
<p className="text-[11px] font-medium text-gray-500">
|
||||
{t("opacity")}
|
||||
</p>
|
||||
<span className="text-[10px] tabular-nums text-gray-400">
|
||||
{opacity}%
|
||||
</span>
|
||||
@@ -92,7 +100,7 @@ export function WatermarkSidebarContent() {
|
||||
step={1}
|
||||
value={[opacity]}
|
||||
onValueChange={([value]) => setOpacity(value)}
|
||||
aria-label="Watermark opacity"
|
||||
aria-label={t("opacityAriaLabel")}
|
||||
/>
|
||||
</div>
|
||||
</SidebarPanelShell>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type ChangeEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Music } from "lucide-react";
|
||||
|
||||
interface AudioTrackProps {
|
||||
@@ -16,6 +17,7 @@ export function AudioTrack({
|
||||
fileName,
|
||||
onFileSelect,
|
||||
}: AudioTrackProps) {
|
||||
const t = useTranslations("auto.componentsStudioTimelineAudioTrack");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const width = Math.max(totalDuration * pxPerSecond, pxPerSecond * 5);
|
||||
|
||||
@@ -50,7 +52,7 @@ export function AudioTrack({
|
||||
>
|
||||
<Music className="h-3.5 w-3.5 shrink-0 text-gray-500" aria-hidden />
|
||||
<span className="truncate">
|
||||
{fileName ?? "No audio — click to add"}
|
||||
{fileName ?? t("emptyState")}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
clampSceneDuration,
|
||||
@@ -26,6 +27,7 @@ export function SceneBlock({
|
||||
onSelect,
|
||||
onDurationChange,
|
||||
}: SceneBlockProps) {
|
||||
const t = useTranslations("auto.componentsStudioTimelineSceneBlock");
|
||||
const [resizeDuration, setResizeDuration] = useState<number | null>(null);
|
||||
const resizeRef = useRef({
|
||||
startX: 0,
|
||||
@@ -96,7 +98,7 @@ export function SceneBlock({
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${scene.name} duration`}
|
||||
aria-label={t("resizeDuration", { name: scene.name })}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Copy, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ export function SceneThumbnailBlock({
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: SceneThumbnailBlockProps) {
|
||||
const t = useTranslations("auto.componentsStudioTimelineSceneThumbnailBlock");
|
||||
const [resizeDuration, setResizeDuration] = useState<number | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(scene.name);
|
||||
@@ -154,7 +156,7 @@ export function SceneThumbnailBlock({
|
||||
onDuplicate();
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-black/60 text-white hover:bg-black/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label={`Duplicate ${scene.name}`}
|
||||
aria-label={t("duplicateScene", { name: scene.name })}
|
||||
>
|
||||
<Copy className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
@@ -166,7 +168,7 @@ export function SceneThumbnailBlock({
|
||||
onDelete();
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-black/60 text-white hover:bg-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label={`Delete ${scene.name}`}
|
||||
aria-label={t("deleteScene", { name: scene.name })}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
@@ -184,7 +186,7 @@ export function SceneThumbnailBlock({
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${scene.name} duration`}
|
||||
aria-label={t("resizeSceneDuration", { name: scene.name })}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -211,7 +213,7 @@ export function SceneThumbnailBlock({
|
||||
}
|
||||
}}
|
||||
className="mt-1 w-full rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] text-gray-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500"
|
||||
aria-label="Scene name"
|
||||
aria-label={t("sceneNameLabel")}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
@@ -221,7 +223,7 @@ export function SceneThumbnailBlock({
|
||||
event.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
title="Double-click to rename"
|
||||
title={t("doubleClickToRename")}
|
||||
>
|
||||
{scene.name}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LayoutGrid, Plus } from "lucide-react";
|
||||
|
||||
import { SceneBrowserModal } from "@/components/studio/SceneBrowserModal";
|
||||
@@ -19,6 +20,7 @@ interface SceneThumbnailStripProps {
|
||||
}
|
||||
|
||||
export function SceneThumbnailStrip({ onSceneSelect }: SceneThumbnailStripProps) {
|
||||
const t = useTranslations("auto.componentsStudioTimelineSceneThumbnailStrip");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [browserOpen, setBrowserOpen] = useState(false);
|
||||
|
||||
@@ -84,7 +86,7 @@ export function SceneThumbnailStrip({ onSceneSelect }: SceneThumbnailStripProps)
|
||||
type="button"
|
||||
onClick={() => setBrowserOpen(true)}
|
||||
className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 transition-colors hover:border-blue-400 hover:bg-blue-50 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Browse scenes"
|
||||
aria-label={t("browseScenes")}
|
||||
>
|
||||
<LayoutGrid className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
@@ -93,7 +95,7 @@ export function SceneThumbnailStrip({ onSceneSelect }: SceneThumbnailStripProps)
|
||||
type="button"
|
||||
onClick={() => addScene()}
|
||||
className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 text-gray-300 transition-colors hover:border-blue-400 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Add scene"
|
||||
aria-label={t("addScene")}
|
||||
>
|
||||
<Plus className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
import { formatTimelineTime } from "@/lib/studio-timeline";
|
||||
@@ -32,6 +33,7 @@ export function TimeRuler({
|
||||
currentTime,
|
||||
onSeek,
|
||||
}: TimeRulerProps) {
|
||||
const t = useTranslations("auto.componentsStudioTimelineTimeRuler");
|
||||
const safeDuration = Math.max(totalDuration, 5);
|
||||
const width = safeDuration * pxPerSecond + 160;
|
||||
|
||||
@@ -48,7 +50,7 @@ export function TimeRuler({
|
||||
return (
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Timeline ruler — click to seek"
|
||||
aria-label={t("rulerAriaLabel")}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={totalDuration}
|
||||
aria-valuenow={currentTime ?? 0}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type ChangeEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
export function TimelineActionRow() {
|
||||
const t = useTranslations("auto.componentsStudioTimelineTimelineActionRow");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const setAudioTrack = useStudioStore((state) => state.setAudioTrack);
|
||||
|
||||
@@ -35,14 +37,14 @@ export function TimelineActionRow() {
|
||||
type="button"
|
||||
className="text-xs text-gray-500 transition-colors hover:text-gray-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded px-1"
|
||||
>
|
||||
🎤 Add text to speech
|
||||
🎤 {t("addTextToSpeech")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="text-xs text-gray-500 transition-colors hover:text-gray-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded px-1"
|
||||
>
|
||||
🎵 Add audio
|
||||
🎵 {t("addAudio")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Square,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
export function TimelineControlBar() {
|
||||
const t = useTranslations("auto.componentsStudioTimelineTimelineControlBar");
|
||||
const scenes = useStudioStore((state) => state.scenes);
|
||||
const currentTime = useStudioStore((state) => state.currentTime);
|
||||
const pxPerSecond = useStudioStore((state) => state.pxPerSecond);
|
||||
@@ -57,7 +59,7 @@ export function TimelineControlBar() {
|
||||
disabled={!selectedLayerId}
|
||||
onClick={() => selectedLayerId && copyLayer(selectedLayerId)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Copy layer"
|
||||
aria-label={t("copyLayer")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -66,7 +68,7 @@ export function TimelineControlBar() {
|
||||
disabled={!selectedLayerId}
|
||||
onClick={() => selectedLayerId && deleteLayer(selectedLayerId)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Delete layer"
|
||||
aria-label={t("deleteLayer")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -81,7 +83,7 @@ export function TimelineControlBar() {
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" aria-hidden />
|
||||
Stop
|
||||
{t("stop")}
|
||||
</button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
@@ -90,16 +92,16 @@ export function TimelineControlBar() {
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
Preview
|
||||
{t("preview")}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" aria-hidden />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
<DropdownMenuItem onClick={() => handlePreview(false)}>
|
||||
Preview
|
||||
{t("preview")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handlePreview(true)}>
|
||||
Preview from start
|
||||
{t("previewFromStart")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -109,7 +111,7 @@ export function TimelineControlBar() {
|
||||
type="button"
|
||||
onClick={() => setCurrentTime(0)}
|
||||
className="rounded px-1 font-mono text-sm tabular-nums text-gray-500 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
title="Seek to start"
|
||||
title={t("seekToStart")}
|
||||
>
|
||||
{formatTimelineTime(currentTime)} | {formatTimelineTime(totalDuration)}
|
||||
</button>
|
||||
@@ -120,7 +122,7 @@ export function TimelineControlBar() {
|
||||
onClick={timelineZoomOut}
|
||||
disabled={!canZoomOut}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Zoom out"
|
||||
aria-label={t("zoomOut")}
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -131,14 +133,14 @@ export function TimelineControlBar() {
|
||||
step={30}
|
||||
value={[pxPerSecond]}
|
||||
onValueChange={([value]) => setPxPerSecond(snapZoomLevel(value))}
|
||||
aria-label="Timeline zoom"
|
||||
aria-label={t("timelineZoom")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={timelineZoomIn}
|
||||
disabled={!canZoomIn}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
aria-label="Zoom in"
|
||||
aria-label={t("zoomIn")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquare, Music2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -15,6 +16,7 @@ export function TimelineQuickActions({
|
||||
onOpenAudio,
|
||||
className,
|
||||
}: TimelineQuickActionsProps) {
|
||||
const t = useTranslations("auto.componentsStudioTimelineTimelineQuickActions");
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -28,7 +30,7 @@ export function TimelineQuickActions({
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-xs text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-gray-400" aria-hidden />
|
||||
Add text to speech
|
||||
{t("addTextToSpeech")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -36,7 +38,7 @@ export function TimelineQuickActions({
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-2 border-t border-gray-100 px-3 text-xs text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
||||
>
|
||||
<Music2 className="h-3.5 w-3.5 shrink-0 text-gray-400" aria-hidden />
|
||||
Add audio
|
||||
{t("addAudio")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,19 +2,25 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Info } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function CanvasLoading() {
|
||||
const t = useTranslations("auto.componentsStudioVideoCanvasArea");
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-b-lg bg-gray-100 text-sm text-gray-400">
|
||||
{t("loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CanvasEditor = dynamic(
|
||||
() =>
|
||||
import("@/components/studio/CanvasEditor").then((mod) => mod.CanvasEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-b-lg bg-gray-100 text-sm text-gray-400">
|
||||
Loading canvas…
|
||||
</div>
|
||||
),
|
||||
loading: () => <CanvasLoading />,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -23,6 +29,7 @@ export interface CanvasAreaProps {
|
||||
}
|
||||
|
||||
export function CanvasArea({ className }: CanvasAreaProps) {
|
||||
const t = useTranslations("auto.componentsStudioVideoCanvasArea");
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -34,9 +41,11 @@ export function CanvasArea({ className }: CanvasAreaProps) {
|
||||
<div className="mx-4 mb-0 mt-0 flex items-center gap-2 rounded-t-lg border border-b-0 border-gray-200 bg-white px-4 py-2 text-xs text-gray-500">
|
||||
<Info className="h-3.5 w-3.5 shrink-0 text-blue-500" aria-hidden />
|
||||
<span>
|
||||
You're in editing mode — visuals may look different. Press{" "}
|
||||
<strong className="text-gray-700">Preview</strong> to see the final
|
||||
result.
|
||||
{t.rich("editingNotice", {
|
||||
preview: (chunks) => (
|
||||
<strong className="text-gray-700">{chunks}</strong>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-4 aspect-video h-full max-h-full min-h-0 flex-1 self-stretch rounded-t-none rounded-b-lg">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Type,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -27,16 +28,16 @@ export type StudioSidebarTool =
|
||||
|
||||
const MAIN_DOCK_ITEMS: {
|
||||
id: StudioSidebarTool;
|
||||
label: string;
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
}[] = [
|
||||
{ id: "scenes", label: "Scenes", icon: Layers2 },
|
||||
{ id: "audio", label: "Audio", icon: Music2 },
|
||||
{ id: "tts", label: "Text to Speech", icon: Mic2 },
|
||||
{ id: "colors", label: "Colors", icon: Palette },
|
||||
{ id: "transitions", label: "Transitions", icon: ArrowLeftRight },
|
||||
{ id: "font", label: "Font", icon: Type },
|
||||
{ id: "watermark", label: "My Watermark", icon: Stamp },
|
||||
{ id: "scenes", labelKey: "scenes", icon: Layers2 },
|
||||
{ id: "audio", labelKey: "audio", icon: Music2 },
|
||||
{ id: "tts", labelKey: "textToSpeech", icon: Mic2 },
|
||||
{ id: "colors", labelKey: "colors", icon: Palette },
|
||||
{ id: "transitions", labelKey: "transitions", icon: ArrowLeftRight },
|
||||
{ id: "font", labelKey: "font", icon: Type },
|
||||
{ id: "watermark", labelKey: "myWatermark", icon: Stamp },
|
||||
];
|
||||
|
||||
interface StudioSidebarDockProps {
|
||||
@@ -107,15 +108,17 @@ export function StudioSidebarDock({
|
||||
activeTool,
|
||||
onToolChange,
|
||||
}: StudioSidebarDockProps) {
|
||||
const t = useTranslations("auto.componentsStudioVideoStudioSidebarDock");
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex h-full w-14 shrink-0 flex-col border-r border-gray-200 bg-white py-2"
|
||||
aria-label="Studio tools"
|
||||
aria-label={t("toolsNavLabel")}
|
||||
>
|
||||
{MAIN_DOCK_ITEMS.map((item) => (
|
||||
<DockToolButton
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
label={t(item.labelKey)}
|
||||
icon={item.icon}
|
||||
isActive={activeTool === item.id}
|
||||
onClick={() => onToolChange(item.id)}
|
||||
@@ -125,18 +128,18 @@ export function StudioSidebarDock({
|
||||
<div className="min-h-0 flex-1" aria-hidden />
|
||||
|
||||
<DockToolButton
|
||||
label="Guide me"
|
||||
label={t("guideMe")}
|
||||
icon={Lightbulb}
|
||||
isActive={false}
|
||||
onClick={() => toast({ title: "👋 Guide coming soon!" })}
|
||||
onClick={() => toast({ title: t("guideComingSoon") })}
|
||||
/>
|
||||
|
||||
<div className="flex justify-center py-1">
|
||||
<DockIconButton
|
||||
label="Keyboard shortcuts"
|
||||
label={t("keyboardShortcuts")}
|
||||
icon={Keyboard}
|
||||
onClick={() =>
|
||||
toast({ title: "Keyboard shortcuts coming soon!" })
|
||||
toast({ title: t("keyboardShortcutsComingSoon") })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Camera,
|
||||
@@ -49,6 +50,7 @@ export function StudioTopBar({
|
||||
usingLocalStorage,
|
||||
className,
|
||||
}: StudioTopBarProps) {
|
||||
const t = useTranslations("auto.componentsStudioVideoStudioTopBar");
|
||||
const undo = useStudioStore((state) => state.undo);
|
||||
const redo = useStudioStore((state) => state.redo);
|
||||
const canUndo = useStudioStore((state) => state.past.length > 0);
|
||||
@@ -72,7 +74,7 @@ export function StudioTopBar({
|
||||
const handleSnapshot = () => {
|
||||
const saved = downloadCanvasSnapshot();
|
||||
toast({
|
||||
title: saved ? "Snapshot saved!" : "Canvas not ready. Try again.",
|
||||
title: saved ? t("snapshotSaved") : t("canvasNotReady"),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -87,20 +89,20 @@ export function StudioTopBar({
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label="FlatRender home"
|
||||
aria-label={t("homeLink")}
|
||||
>
|
||||
<Sparkles className="h-5 w-5 text-white" aria-hidden />
|
||||
</Link>
|
||||
|
||||
<nav
|
||||
className="flex min-w-0 items-center gap-1 text-sm text-gray-400"
|
||||
aria-label="Breadcrumb"
|
||||
aria-label={t("breadcrumb")}
|
||||
>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="shrink-0 hover:text-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded"
|
||||
>
|
||||
My Projects
|
||||
{t("myProjects")}
|
||||
</Link>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<input
|
||||
@@ -108,7 +110,7 @@ export function StudioTopBar({
|
||||
value={projectName}
|
||||
onChange={(event) => onProjectNameChange(event.target.value)}
|
||||
className="min-w-0 max-w-[200px] truncate rounded-md border border-transparent bg-transparent px-1 py-0.5 text-sm font-medium text-white hover:border-[#2a2d3e] focus:border-[#252936] focus:bg-[#1a1d2e] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4c6ef5]"
|
||||
aria-label="Project name"
|
||||
aria-label={t("projectName")}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
@@ -124,7 +126,7 @@ export function StudioTopBar({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-gray-500 hover:bg-gray-100 hover:text-gray-900"
|
||||
aria-label="Undo"
|
||||
aria-label={t("undo")}
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
@@ -135,7 +137,7 @@ export function StudioTopBar({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-gray-500 hover:bg-gray-100 hover:text-gray-900"
|
||||
aria-label="Redo"
|
||||
aria-label={t("redo")}
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
@@ -159,7 +161,7 @@ export function StudioTopBar({
|
||||
onClick={stopPlayback}
|
||||
>
|
||||
<Square className="h-4 w-4" aria-hidden />
|
||||
Stop
|
||||
{t("stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -170,7 +172,7 @@ export function StudioTopBar({
|
||||
onClick={startPlayback}
|
||||
>
|
||||
<Play className="h-4 w-4" aria-hidden />
|
||||
Preview
|
||||
{t("preview")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -179,7 +181,7 @@ export function StudioTopBar({
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 border-[#2a2d3e] bg-transparent text-gray-300 hover:bg-[#252938] hover:text-white"
|
||||
aria-label="Take snapshot"
|
||||
aria-label={t("takeSnapshot")}
|
||||
onClick={handleSnapshot}
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
@@ -192,7 +194,7 @@ export function StudioTopBar({
|
||||
size="sm"
|
||||
className="gap-1 bg-[#4c6ef5] hover:bg-[#3d5de8]"
|
||||
>
|
||||
Export
|
||||
{t("export")}
|
||||
<ChevronDown className="h-4 w-4 opacity-80" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { ProjectSaveStatus } from "@/lib/project-save-status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -14,6 +16,8 @@ export function StudioTopBarSaveBadge({
|
||||
usingLocalStorage,
|
||||
className,
|
||||
}: StudioTopBarSaveBadgeProps) {
|
||||
const t = useTranslations("auto.componentsStudioVideoStudioTopBarSaveBadge");
|
||||
|
||||
if (status === "saving" || status === "pending") {
|
||||
return (
|
||||
<span
|
||||
@@ -21,8 +25,8 @@ export function StudioTopBarSaveBadge({
|
||||
"h-2 w-2 shrink-0 animate-pulse rounded-full bg-gray-500",
|
||||
className
|
||||
)}
|
||||
title="Saving…"
|
||||
aria-label="Saving"
|
||||
title={t("savingTitle")}
|
||||
aria-label={t("savingLabel")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +35,8 @@ export function StudioTopBarSaveBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn("h-2 w-2 shrink-0 rounded-full bg-red-500", className)}
|
||||
title="Save failed"
|
||||
aria-label="Save failed"
|
||||
title={t("errorTitle")}
|
||||
aria-label={t("errorLabel")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +49,7 @@ export function StudioTopBarSaveBadge({
|
||||
className
|
||||
)}
|
||||
>
|
||||
Local
|
||||
{t("local")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +62,7 @@ export function StudioTopBarSaveBadge({
|
||||
className
|
||||
)}
|
||||
>
|
||||
Saved ✓
|
||||
{t("saved")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Bold, Italic } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useLayerUpdater } from "@/components/studio/properties/useLayerUpdater";
|
||||
import { ToggleIconButton } from "@/components/studio/properties/PropertyControls";
|
||||
@@ -19,6 +20,7 @@ export function StudioTopBarTextControls({
|
||||
}: StudioTopBarTextControlsProps) {
|
||||
const { updateProps } = useLayerUpdater(layer);
|
||||
const text = getTextProps(layer.props);
|
||||
const t = useTranslations("auto.componentsStudioVideoStudioTopBarTextControls");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -27,12 +29,12 @@ export function StudioTopBarTextControls({
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
aria-label="Text layer properties"
|
||||
aria-label={t("groupLabel")}
|
||||
>
|
||||
<select
|
||||
value={text.fontFamily}
|
||||
onChange={(event) => updateProps({ fontFamily: event.target.value })}
|
||||
aria-label="Font family"
|
||||
aria-label={t("fontFamily")}
|
||||
className="h-8 max-w-[110px] rounded-md border border-gray-200 bg-gray-50 px-2 text-xs text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
{FONT_FAMILY_OPTIONS.map((option) => (
|
||||
@@ -51,20 +53,20 @@ export function StudioTopBarTextControls({
|
||||
const next = Number(event.target.value);
|
||||
if (!Number.isNaN(next)) updateProps({ fontSize: next });
|
||||
}}
|
||||
aria-label="Font size"
|
||||
aria-label={t("fontSize")}
|
||||
className="h-8 w-14 rounded-md border border-gray-200 bg-gray-50 px-2 text-xs tabular-nums text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
/>
|
||||
|
||||
<div className="flex gap-0.5">
|
||||
<ToggleIconButton
|
||||
label="Bold"
|
||||
label={t("bold")}
|
||||
active={text.bold}
|
||||
onClick={() => updateProps({ bold: !text.bold })}
|
||||
>
|
||||
<Bold className="h-3.5 w-3.5" />
|
||||
</ToggleIconButton>
|
||||
<ToggleIconButton
|
||||
label="Italic"
|
||||
label={t("italic")}
|
||||
active={text.italic}
|
||||
onClick={() => updateProps({ italic: !text.italic })}
|
||||
>
|
||||
@@ -76,7 +78,7 @@ export function StudioTopBarTextControls({
|
||||
type="color"
|
||||
value={text.fill}
|
||||
onChange={(event) => updateProps({ fill: event.target.value })}
|
||||
aria-label="Text color"
|
||||
aria-label={t("textColor")}
|
||||
className="h-8 w-8 cursor-pointer rounded border border-gray-200 bg-gray-50 p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { TemplateGalleryItem } from "@/components/sections/template-gallery-data";
|
||||
import { getTemplatePreviewVideoSrc } from "@/lib/template-preview-media";
|
||||
@@ -14,6 +15,7 @@ interface VideoNewPresetCardProps {
|
||||
const fadeTransition = { duration: 0.25, ease: "easeOut" as const };
|
||||
|
||||
export function VideoNewPresetCard({ preset, onClick }: VideoNewPresetCardProps) {
|
||||
const t = useTranslations("auto.componentsStudioVideoVideoNewPresetCard");
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const videoSrc =
|
||||
@@ -84,7 +86,7 @@ export function VideoNewPresetCard({ preset, onClick }: VideoNewPresetCardProps)
|
||||
transition={fadeTransition}
|
||||
>
|
||||
<span className="block w-full rounded-lg bg-blue-600 px-3 py-1.5 text-center text-xs font-semibold text-white shadow-lg">
|
||||
Use Template
|
||||
{t("useTemplate")}
|
||||
</span>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Clapperboard, Search, Sparkles } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { SceneBrowserModal } from "@/components/studio/SceneBrowserModal";
|
||||
import { VideoNewOptionCard } from "@/components/studio/video/VideoNewOptionCard";
|
||||
@@ -14,6 +15,7 @@ import { TEMPLATE_GALLERY_ITEMS } from "@/components/sections/template-gallery-d
|
||||
import { createVideoProject } from "@/lib/create-video-project";
|
||||
|
||||
export function VideoProjectNewContent() {
|
||||
const t = useTranslations("auto.componentsStudioVideoVideoProjectNewContent");
|
||||
const router = useRouter();
|
||||
const [sceneModalOpen, setSceneModalOpen] = useState(false);
|
||||
const [loadingKey, setLoadingKey] = useState<string | null>(null);
|
||||
@@ -60,18 +62,18 @@ export function VideoProjectNewContent() {
|
||||
<span className="text-sm text-gray-400" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-600">Create new video</span>
|
||||
<span className="text-sm font-medium text-gray-600">{t("breadcrumbCreate")}</span>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-4 pb-16 sm:px-6 lg:px-8">
|
||||
<h1 className="mt-12 text-center font-heading text-2xl font-semibold text-neutral-900">
|
||||
Select one of the options to start creating
|
||||
{t("heading")}
|
||||
</h1>
|
||||
|
||||
<div className="mx-auto mt-8 grid max-w-2xl grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<VideoNewOptionCard
|
||||
title="Select Scenes"
|
||||
description="Browse scenes and build your project from scratch"
|
||||
title={t("selectScenesTitle")}
|
||||
description={t("selectScenesDescription")}
|
||||
gradientFrom="from-blue-100"
|
||||
gradientTo="to-violet-100"
|
||||
icon={Clapperboard}
|
||||
@@ -80,14 +82,14 @@ export function VideoProjectNewContent() {
|
||||
onClick={() => setSceneModalOpen(true)}
|
||||
/>
|
||||
<VideoNewOptionCard
|
||||
title="Create with AI"
|
||||
description="Transform your ideas or script into AI-generated videos effortlessly"
|
||||
title={t("createWithAiTitle")}
|
||||
description={t("createWithAiDescription")}
|
||||
gradientFrom="from-violet-100"
|
||||
gradientTo="to-pink-100"
|
||||
icon={Sparkles}
|
||||
iconClassName="text-violet-500"
|
||||
isLoading={loadingKey === "ai"}
|
||||
onClick={() => void startProject("ai", { name: "AI Video Project" })}
|
||||
onClick={() => void startProject("ai", { name: t("aiProjectName") })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -97,13 +99,13 @@ export function VideoProjectNewContent() {
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-gray-50 px-4 text-sm font-medium text-gray-400">
|
||||
OR
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="font-semibold text-gray-900">Start with Presets</h2>
|
||||
<h2 className="font-semibold text-gray-900">{t("startWithPresets")}</h2>
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400"
|
||||
@@ -111,7 +113,7 @@ export function VideoProjectNewContent() {
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search presets..."
|
||||
placeholder={t("searchPresetsPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSearch(e.target.value)
|
||||
@@ -141,7 +143,7 @@ export function VideoProjectNewContent() {
|
||||
onOpenChange={setSceneModalOpen}
|
||||
onScenesAdd={(scenes) => {
|
||||
// On the new-project page use the first selected scene name for the project
|
||||
const firstName = scenes[0]?.name ?? "New Video";
|
||||
const firstName = scenes[0]?.name ?? t("newVideoName");
|
||||
void (async () => {
|
||||
setLoadingKey("scenes");
|
||||
const result = await createVideoProject({
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface TemplateDetailBreadcrumbProps {
|
||||
templateName: string;
|
||||
}
|
||||
|
||||
export function TemplateDetailBreadcrumb({
|
||||
export async function TemplateDetailBreadcrumb({
|
||||
templateName,
|
||||
}: TemplateDetailBreadcrumbProps) {
|
||||
const t = await getTranslations("auto.componentsTemplatesTemplateDetailBreadcrumb");
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="text-sm text-gray-500">
|
||||
<nav aria-label={t("breadcrumbAriaLabel")} className="text-sm text-gray-500">
|
||||
<ol className="flex flex-wrap items-center gap-1">
|
||||
<li>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-sm hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600"
|
||||
>
|
||||
Home
|
||||
{t("home")}
|
||||
</Link>
|
||||
</li>
|
||||
<li aria-hidden>
|
||||
@@ -27,7 +30,7 @@ export function TemplateDetailBreadcrumb({
|
||||
href="/templates"
|
||||
className="rounded-sm hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600"
|
||||
>
|
||||
Templates
|
||||
{t("templates")}
|
||||
</Link>
|
||||
</li>
|
||||
<li aria-hidden>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { getVideoTemplateExampleImageSrc } from "@/lib/video-templates-catalog";
|
||||
|
||||
interface TemplateDetailExamplesProps {
|
||||
@@ -7,10 +9,12 @@ interface TemplateDetailExamplesProps {
|
||||
const EXAMPLE_COUNT = 5;
|
||||
|
||||
export function TemplateDetailExamples({ templateId }: TemplateDetailExamplesProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesTemplateDetailExamples");
|
||||
|
||||
return (
|
||||
<section className="mt-12">
|
||||
<h2 className="mb-5 font-heading text-xl font-bold text-gray-900">
|
||||
Videos created using this template
|
||||
{t("heading")}
|
||||
</h2>
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{Array.from({ length: EXAMPLE_COUNT }).map((_, index) => (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Clapperboard, Heart, Loader2 } from "lucide-react";
|
||||
|
||||
import { StatDot, TemplateDetailRating } from "@/components/templates/TemplateDetailRating";
|
||||
@@ -19,16 +20,19 @@ import {
|
||||
} from "@/lib/video-templates-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STYLE_LABELS = ["Classic", "Modern", "Bold", "Minimal"] as const;
|
||||
|
||||
const FALLBACK_DESCRIPTION =
|
||||
"Create stunning videos with this professional template. Choose scenes, customize text, and export in minutes.";
|
||||
const STYLE_LABEL_KEYS = [
|
||||
"styleClassic",
|
||||
"styleModern",
|
||||
"styleBold",
|
||||
"styleMinimal",
|
||||
] as const;
|
||||
|
||||
interface TemplateDetailInfoProps {
|
||||
template: VideoCatalogTemplate;
|
||||
}
|
||||
|
||||
export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesTemplateDetailInfo");
|
||||
const router = useRouter();
|
||||
const [selectedStyle, setSelectedStyle] = useState(0);
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
@@ -36,7 +40,7 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
||||
|
||||
const categoryLabel = getVideoTemplateCategoryLabel(template.videoCategory);
|
||||
const durationLabel =
|
||||
template.durationType === "flexible" ? "Flexible" : "Fixed";
|
||||
template.durationType === "flexible" ? t("durationFlexible") : t("durationFixed");
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true);
|
||||
@@ -52,7 +56,7 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
||||
router.push("/studio/video/new");
|
||||
return;
|
||||
}
|
||||
toast({ title: `Could not create project: ${result.error}` });
|
||||
toast({ title: t("createError", { error: result.error }) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,7 +68,7 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
||||
<h1 className="font-heading text-2xl font-bold text-gray-900">{template.name}</h1>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||
<span>{template.sceneCount} scenes</span>
|
||||
<span>{t("sceneCount", { count: template.sceneCount })}</span>
|
||||
<StatDot />
|
||||
<span>{categoryLabel}</span>
|
||||
<StatDot />
|
||||
@@ -74,12 +78,12 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
||||
<TemplateDetailRating />
|
||||
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-700">
|
||||
{template.description ?? FALLBACK_DESCRIPTION}
|
||||
{template.description ?? t("fallbackDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="text-sm font-semibold text-gray-700">
|
||||
Available styles ({TEMPLATE_STYLE_COUNT})
|
||||
{t("availableStyles", { count: TEMPLATE_STYLE_COUNT })}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-3 overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{Array.from({ length: TEMPLATE_STYLE_COUNT }).map((_, index) => (
|
||||
@@ -107,7 +111,7 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-center text-[10px] text-gray-600">
|
||||
{STYLE_LABELS[index]}
|
||||
{t(STYLE_LABEL_KEYS[index] ?? "styleClassic")}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
@@ -126,12 +130,12 @@ export function TemplateDetailInfo({ template }: TemplateDetailInfoProps) {
|
||||
) : (
|
||||
<Clapperboard className="h-5 w-5" aria-hidden />
|
||||
)}
|
||||
Create Now
|
||||
{t("createNow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFavorite((value) => !value)}
|
||||
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
aria-label={isFavorite ? t("removeFromFavorites") : t("addToFavorites")}
|
||||
aria-pressed={isFavorite}
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-gray-200 transition-colors hover:border-rose-300 hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import { getTemplatePreviewVideoSrc } from "@/lib/template-preview-media";
|
||||
@@ -19,6 +20,7 @@ interface TemplateDetailPreviewProps {
|
||||
}
|
||||
|
||||
export function TemplateDetailPreview({ template }: TemplateDetailPreviewProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesTemplateDetailPreview");
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [selectedRatio, setSelectedRatio] = useState<TemplateDetailAspectRatio>("16:9");
|
||||
const aspectOptions = getTemplateDetailAspectRatios(template);
|
||||
@@ -42,14 +44,14 @@ export function TemplateDetailPreview({ template }: TemplateDetailPreviewProps)
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={posterSrc}
|
||||
alt={`${template.name} preview`}
|
||||
alt={t("posterAlt", { name: template.name })}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPlaying(true)}
|
||||
className="absolute left-1/2 top-1/2 flex h-16 w-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 shadow-xl transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||
aria-label="Play template preview"
|
||||
aria-label={t("playPreview")}
|
||||
>
|
||||
<Play className="ml-1 h-7 w-7 fill-blue-600 text-blue-600" aria-hidden />
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Star } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -5,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
const FAKE_RATING = { score: 4.5, count: 2268 } as const;
|
||||
|
||||
export function TemplateDetailRating() {
|
||||
const t = useTranslations("auto.componentsTemplatesTemplateDetailRating");
|
||||
const { score, count } = FAKE_RATING;
|
||||
const fullStars = Math.floor(score);
|
||||
const hasHalf = score % 1 >= 0.5;
|
||||
@@ -12,7 +16,7 @@ export function TemplateDetailRating() {
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-0.5" aria-label={`${score} out of 5 stars`}>
|
||||
<div className="flex items-center gap-0.5" aria-label={t("starsAriaLabel", { score })}>
|
||||
{Array.from({ length: fullStars }).map((_, index) => (
|
||||
<Star
|
||||
key={`full-${index}`}
|
||||
@@ -39,7 +43,7 @@ export function TemplateDetailRating() {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">{score}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
({count.toLocaleString()} Ratings)
|
||||
{t("ratingsCount", { count: count.toLocaleString() })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -25,6 +26,8 @@ export function TemplatesActiveFilters({
|
||||
chips,
|
||||
className,
|
||||
}: TemplatesActiveFiltersProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesTemplatesActiveFilters");
|
||||
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -34,7 +37,7 @@ export function TemplatesActiveFilters({
|
||||
key={chip.id}
|
||||
type="button"
|
||||
onClick={chip.onRemove}
|
||||
aria-label={`Remove filter: ${chip.label}`}
|
||||
aria-label={t("removeFilter", { label: chip.label })}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-gray-100 bg-white px-3 py-1.5 text-sm text-neutral-700 shadow-sm transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
{chip.label}
|
||||
@@ -54,6 +57,7 @@ export function buildActiveFilterChips(options: {
|
||||
onStyleChange: (style: TemplateStyle | null) => void;
|
||||
onColorChange: (color: TemplateColorId | null) => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
searchLabel?: (query: string) => string;
|
||||
}): ActiveFilterChip[] {
|
||||
const chips: ActiveFilterChip[] = [];
|
||||
|
||||
@@ -83,9 +87,12 @@ export function buildActiveFilterChips(options: {
|
||||
}
|
||||
|
||||
if (options.search.trim()) {
|
||||
const query = options.search.trim();
|
||||
chips.push({
|
||||
id: "search",
|
||||
label: `Search: "${options.search.trim()}"`,
|
||||
label: options.searchLabel
|
||||
? options.searchLabel(query)
|
||||
: `Search: "${query}"`,
|
||||
onRemove: () => options.onSearchChange(""),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
TEMPLATE_CATEGORIES,
|
||||
@@ -29,11 +31,12 @@ export function TemplatesSidebar({
|
||||
onColorChange,
|
||||
className,
|
||||
}: TemplatesSidebarProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesTemplatesSidebar");
|
||||
return (
|
||||
<aside className={cn("space-y-8", className)}>
|
||||
<div>
|
||||
<h2 className="font-heading text-sm font-semibold text-neutral-900">
|
||||
Category
|
||||
{t("categoryHeading")}
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{TEMPLATE_CATEGORIES.map((item) => (
|
||||
@@ -57,7 +60,7 @@ export function TemplatesSidebar({
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-sm font-semibold text-neutral-900">
|
||||
Style
|
||||
{t("styleHeading")}
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{TEMPLATE_STYLES.map((item) => (
|
||||
@@ -83,7 +86,7 @@ export function TemplatesSidebar({
|
||||
|
||||
<div>
|
||||
<h2 className="font-heading text-sm font-semibold text-neutral-900">
|
||||
Color
|
||||
{t("colorHeading")}
|
||||
</h2>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{TEMPLATE_COLORS.map((swatch) => (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Crown, Loader2 } from "lucide-react";
|
||||
|
||||
import { VideoPlayOverlay } from "@/components/sections/VideoPlayOverlay";
|
||||
@@ -23,6 +24,7 @@ export function VideoTemplateCompactCard({
|
||||
isUsing = false,
|
||||
className,
|
||||
}: VideoTemplateCompactCardProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesVideoVideoTemplateCompactCard");
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const imageSrc = getVideoTemplateImageSrc(template.id);
|
||||
@@ -64,7 +66,7 @@ export function VideoTemplateCompactCard({
|
||||
<Link
|
||||
href={detailHref}
|
||||
className="absolute inset-0 z-0 block no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||
aria-label={`View ${template.name} template`}
|
||||
aria-label={t("viewTemplateAria", { name: template.name })}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -115,7 +117,7 @@ export function VideoTemplateCompactCard({
|
||||
onClick={handleUseClick}
|
||||
className="w-full rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow-lg transition-colors hover:bg-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:opacity-70"
|
||||
>
|
||||
{isUsing ? "Opening…" : "Use Template"}
|
||||
{isUsing ? t("opening") : t("useTemplate")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -134,7 +136,7 @@ export function VideoTemplateCompactCard({
|
||||
{template.name}
|
||||
</Link>
|
||||
<p className="mt-0.5 text-xs text-neutral-500">
|
||||
{template.sceneCount} scenes
|
||||
{t("sceneCount", { count: template.sceneCount })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { VideoTemplateCompactCard } from "@/components/templates/video/VideoTemplateCompactCard";
|
||||
@@ -27,6 +28,7 @@ export function VideoTemplatesCarouselRow({
|
||||
showSeeAll = true,
|
||||
categoryId,
|
||||
}: VideoTemplatesCarouselRowProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesVideoVideoTemplatesCarouselRow");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scroll = (direction: "left" | "right") => {
|
||||
@@ -54,7 +56,7 @@ export function VideoTemplatesCarouselRow({
|
||||
href={`/templates?category=${categoryId}`}
|
||||
className="text-sm font-medium text-rf-blue hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue rounded-sm"
|
||||
>
|
||||
See all
|
||||
{t("seeAll")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -62,7 +64,7 @@ export function VideoTemplatesCarouselRow({
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Scroll ${title} left`}
|
||||
aria-label={t("scrollLeftAria", { title })}
|
||||
onClick={() => scroll("left")}
|
||||
className="absolute -left-3 top-[38%] z-10 hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-gray-200 bg-white shadow-md transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue lg:flex"
|
||||
>
|
||||
@@ -88,7 +90,7 @@ export function VideoTemplatesCarouselRow({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Scroll ${title} right`}
|
||||
aria-label={t("scrollRightAria", { title })}
|
||||
onClick={() => scroll("right")}
|
||||
className="absolute -right-3 top-[38%] z-10 hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-gray-200 bg-white shadow-md transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rf-blue lg:flex"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Clapperboard,
|
||||
Gift,
|
||||
@@ -26,22 +27,22 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const SIDEBAR_CATEGORIES: {
|
||||
id: VideoSidebarCategoryId;
|
||||
label: string;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
count?: number;
|
||||
}[] = [
|
||||
{ id: "all", label: "All Templates", icon: LayoutGrid },
|
||||
{ id: "animation", label: "Animation Videos", icon: Play, count: 418 },
|
||||
{ id: "intros", label: "Intros and Logos", icon: Clapperboard, count: 851 },
|
||||
{ id: "editing", label: "Video Editing", icon: Scissors },
|
||||
{ id: "invitation", label: "Invitation Videos", icon: Mail },
|
||||
{ id: "holiday", label: "Holiday Videos", icon: Gift },
|
||||
{ id: "slideshow", label: "Slideshow", icon: Images },
|
||||
{ id: "presentations", label: "Presentations", icon: Monitor },
|
||||
{ id: "social", label: "Social Media Videos", icon: Share2 },
|
||||
{ id: "ads", label: "Video Ad Templates", icon: Megaphone },
|
||||
{ id: "sales", label: "Sales Videos", icon: TrendingUp },
|
||||
{ id: "music", label: "Music Visualization", icon: Music2 },
|
||||
{ id: "all", labelKey: "categoryAll", icon: LayoutGrid },
|
||||
{ id: "animation", labelKey: "categoryAnimation", icon: Play, count: 418 },
|
||||
{ id: "intros", labelKey: "categoryIntros", icon: Clapperboard, count: 851 },
|
||||
{ id: "editing", labelKey: "categoryEditing", icon: Scissors },
|
||||
{ id: "invitation", labelKey: "categoryInvitation", icon: Mail },
|
||||
{ id: "holiday", labelKey: "categoryHoliday", icon: Gift },
|
||||
{ id: "slideshow", labelKey: "categorySlideshow", icon: Images },
|
||||
{ id: "presentations", labelKey: "categoryPresentations", icon: Monitor },
|
||||
{ id: "social", labelKey: "categorySocial", icon: Share2 },
|
||||
{ id: "ads", labelKey: "categoryAds", icon: Megaphone },
|
||||
{ id: "sales", labelKey: "categorySales", icon: TrendingUp },
|
||||
{ id: "music", labelKey: "categoryMusic", icon: Music2 },
|
||||
];
|
||||
|
||||
interface VideoTemplatesCategorySidebarProps {
|
||||
@@ -65,8 +66,9 @@ export function VideoTemplatesCategorySidebar({
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
}: VideoTemplatesCategorySidebarProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesVideoVideoTemplatesCategorySidebar");
|
||||
return (
|
||||
<nav aria-label="Template categories" className="sticky top-24">
|
||||
<nav aria-label={t("categoriesNavLabel")} className="sticky top-24">
|
||||
<ul className="space-y-0.5">
|
||||
{SIDEBAR_CATEGORIES.map((category) => {
|
||||
const Icon = category.icon;
|
||||
@@ -90,7 +92,7 @@ export function VideoTemplatesCategorySidebar({
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{category.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{t(category.labelKey)}</span>
|
||||
{category.count !== undefined ? (
|
||||
<span className="ml-auto rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-500">
|
||||
{category.count}
|
||||
@@ -111,7 +113,7 @@ export function VideoTemplatesCategorySidebar({
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 text-gray-500" aria-hidden />
|
||||
Filters
|
||||
{t("filters")}
|
||||
</button>
|
||||
|
||||
{showFilters ? (
|
||||
@@ -123,7 +125,7 @@ export function VideoTemplatesCategorySidebar({
|
||||
className="justify-between"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-gray-500">Size</p>
|
||||
<p className="text-xs font-medium text-gray-500">{t("sizeLabel")}</p>
|
||||
<VideoTemplatesSizeSelect
|
||||
value={aspectRatio}
|
||||
onValueChange={onAspectRatioChange}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Crown } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -29,13 +30,14 @@ export function VideoTemplatesPremiumToggle({
|
||||
className,
|
||||
switchId = "premium-only-toggle",
|
||||
}: VideoTemplatesPremiumToggleProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesVideoVideoTemplatesFilterControls");
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<Crown className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />
|
||||
<span className="text-sm font-medium text-gray-700">Premium Only</span>
|
||||
<span className="text-sm font-medium text-gray-700">{t("premiumOnly")}</span>
|
||||
<Switch
|
||||
id={switchId}
|
||||
aria-label="Premium only"
|
||||
aria-label={t("premiumOnlyAriaLabel")}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-gray-200"
|
||||
@@ -55,13 +57,14 @@ export function VideoTemplatesSizeSelect({
|
||||
onValueChange,
|
||||
triggerClassName,
|
||||
}: VideoTemplatesSizeSelectProps) {
|
||||
const t = useTranslations("auto.componentsTemplatesVideoVideoTemplatesFilterControls");
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onValueChange(v as AspectRatioFilter)}>
|
||||
<SelectTrigger
|
||||
className={cn("h-10 w-[140px] border-gray-200 bg-white", triggerClassName)}
|
||||
aria-label="Template size"
|
||||
aria-label={t("sizeAriaLabel")}
|
||||
>
|
||||
<SelectValue placeholder="All Sizes" />
|
||||
<SelectValue placeholder={t("sizePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASPECT_RATIO_OPTIONS.map((option) => (
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function VideoTemplatesHero() {
|
||||
const t = useTranslations("auto.componentsTemplatesVideoVideoTemplatesHero");
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<nav aria-label="Breadcrumb" className="mb-1 text-xs text-gray-400">
|
||||
<Link href="/" className="hover:text-gray-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 rounded-sm">
|
||||
Home
|
||||
{t("breadcrumbHome")}
|
||||
</Link>
|
||||
<span aria-hidden className="mx-1">
|
||||
›
|
||||
</span>
|
||||
<span>Templates</span>
|
||||
<span>{t("breadcrumbTemplates")}</span>
|
||||
</nav>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Video Templates for All Your Needs
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Find customizable video templates. Create animated promos, logo reveals,
|
||||
slideshows, and more with FlatRender's online video maker.
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user