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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ jobs:
- name: Typecheck (electron)
run: npm run typecheck:electron

- name: Localization and transcription behavior tests
run: npm run test:localization

- name: Build Next.js
run: npm run build

Expand Down
674 changes: 674 additions & 0 deletions I18N_AND_TRANSCRIPTION_IMPLEMENTATION_PLAN.zh-CN.md

Large diffs are not rendered by default.

22 changes: 16 additions & 6 deletions app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

import { useEffect } from "react";
import { reportError } from "@/lib/sentry";
import {
loadUiLocalePreference,
resolveUiLocale,
systemLanguages,
translate,
} from "@/lib/i18n";

/**
* Last-resort boundary for a crash that escaped the editor's own error handling.
Expand All @@ -18,12 +24,17 @@ export default function GlobalError({
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
const locale = resolveUiLocale(
loadUiLocalePreference(),
systemLanguages()
);
const t = (key: Parameters<typeof translate>[1]) => translate(locale, key);
useEffect(() => {
reportError(error, "render");
}, [error]);

return (
<html lang="en">
<html lang={locale}>
<body
style={{
margin: 0,
Expand All @@ -35,7 +46,7 @@ export default function GlobalError({
color: "var(--fg)",
}}
>
<title>Rescript — something went wrong</title>
<title>{t("globalError.title")}</title>
<style>{`
:root { --bg: #fafafa; --fg: #18181b; --muted: #71717a; --btn: #18181b; --btnfg: #fff; }
@media (prefers-color-scheme: dark) {
Expand All @@ -44,11 +55,10 @@ export default function GlobalError({
`}</style>
<main style={{ padding: "2rem", textAlign: "center", maxWidth: "34rem" }}>
<h1 style={{ fontSize: "17px", fontWeight: 600, margin: 0 }}>
Something went wrong
{t("globalError.heading")}
</h1>
<p style={{ color: "var(--muted)", fontSize: "13px", marginTop: "0.75rem" }}>
The editor hit an unexpected error. Your saved projects are still on
this device — reloading should bring them back.
{t("globalError.body")}
</p>
<button
type="button"
Expand All @@ -65,7 +75,7 @@ export default function GlobalError({
color: "var(--btnfg)",
}}
>
Try again
{t("common.retry")}
</button>
</main>
</body>
Expand Down
6 changes: 6 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const metadata: Metadata = {

/** Apply stored appearance before paint to avoid a light→dark flash. */
const appearanceBootScript = `(function(){try{if(localStorage.getItem("rescript.appearance")==="dark")document.documentElement.classList.add("dark")}catch(e){}})();`;
const localeBootScript = `(function(){try{var p=localStorage.getItem("rescript.ui-locale")||"system";var l=p;if(p==="system"){var a=navigator.languages&&navigator.languages.length?navigator.languages:[navigator.language];l="en";for(var i=0;i<a.length;i++){var v=(a[i]||"").toLowerCase();if(v==="zh"||v.indexOf("zh-")===0){l="zh-CN";break}if(v==="en"||v.indexOf("en-")===0){l="en";break}}}document.documentElement.lang=l==="zh-CN"?"zh-CN":"en"}catch(e){}})();`;

export default function RootLayout({
children,
Expand All @@ -82,6 +83,11 @@ export default function RootLayout({
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: appearanceBootScript }}
/>
<Script
id="locale-boot"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: localeBootScript }}
/>
</head>
<body className="min-h-full">
{children}
Expand Down
7 changes: 6 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import dynamic from "next/dynamic";
import I18nProvider from "@/components/I18nProvider";

// The editor relies on browser-only APIs (Workers, WebAssembly, WebGPU),
// so it is rendered exclusively on the client.
Expand All @@ -14,5 +15,9 @@ const Editor = dynamic(() => import("@/components/Editor"), {
});

export default function Home() {
return <Editor />;
return (
<I18nProvider>
<Editor />
</I18nProvider>
);
}
12 changes: 7 additions & 5 deletions components/DesktopAppBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type Platform,
} from "@/lib/platform";
import { usePlatform } from "@/hooks/usePlatform";
import { useI18n } from "./I18nProvider";

const DISMISSED_STORAGE_KEY = "rescript:desktop-banner-dismissed";

Expand Down Expand Up @@ -87,6 +88,7 @@ function PlatformIcon({ platform }: { platform: Platform }) {
* the user dismisses it.
*/
export default function DesktopAppBanner() {
const { t } = useI18n();
const platform = usePlatform();
const [dismissed, setDismissed] = useState(
() => isElectron || readDismissed(),
Expand All @@ -100,9 +102,9 @@ export default function DesktopAppBanner() {
<div className="relative flex h-11 shrink-0 items-center justify-center gap-3 border-b border-blue-200/60 bg-gradient-to-b from-blue-50/70 to-blue-100/50 pr-11 pl-3 text-[13px] text-blue-900 dark:border-blue-400/15 dark:from-blue-950/40 dark:to-blue-900/25 dark:text-blue-100">
<p className="truncate">
<span className="hidden sm:inline">
Want faster transcription and exports?{" "}
{t("banner.faster")}{" "}
</span>
Get the Rescript desktop app
{t("banner.getDesktop")}
</p>
<a
href={downloadUrlFor(platform)}
Expand All @@ -111,15 +113,15 @@ export default function DesktopAppBanner() {
className="flex h-7 shrink-0 items-center gap-1.5 rounded-full bg-white/70 pr-3.5 pl-3 text-[12px] font-medium text-blue-700 ring-1 ring-blue-300/60 transition hover:bg-white hover:ring-blue-400/70 dark:bg-white/10 dark:text-blue-100 dark:ring-white/15 dark:hover:bg-white/15 dark:hover:ring-white/25"
>
<PlatformIcon platform={platform} />
{label ? `Download for ${label}` : "Download"}
{label ? t("banner.downloadFor", { platform: label }) : t("common.download")}
</a>
<button
onClick={() => {
setDismissed(true);
writeDismissed();
}}
title="Dismiss"
aria-label="Dismiss"
title={t("banner.dismiss")}
aria-label={t("banner.dismiss")}
className="absolute right-2.5 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-blue-500 transition hover:bg-blue-500/10 hover:text-blue-800 dark:text-blue-300 dark:hover:bg-white/10 dark:hover:text-blue-100"
>
<X size={14} />
Expand Down
53 changes: 15 additions & 38 deletions components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels";
import { useEditorStore } from "@/lib/store";
import { getCutRanges, isWordCutOut } from "@/lib/edits";
Expand All @@ -23,7 +23,6 @@ import MediaPreview from "./MediaPreview";
import Timeline from "./Timeline";
import ExportDialog from "./ExportDialog";
import { Download, Redo2, Undo2 } from "lucide-react";
import LogoLoader from "./LogoLoader";
import SettingsMenu from "./SettingsMenu";
import ModelSelector, {
LanguageSection,
Expand All @@ -33,11 +32,7 @@ import ModelSelector, {
import ImportTranscriptOption from "./ImportTranscriptOption";
import { MODEL_ORDER } from "@/lib/models";
import { isTypingTarget } from "@/lib/keyboard";

/** How long the desktop mode-change overlay stays up. Matches the macOS
* `setBounds(..., animate)` duration plus a small buffer so the layout
* underneath isn't revealed mid-resize. */
const WINDOW_MODE_OVERLAY_MS = 380;
import { useI18n } from "./I18nProvider";

/** Transcript and preview split, resizable in both orientations. Wide screens
* put the transcript first (left of the preview); stacked screens lead with
Expand Down Expand Up @@ -128,6 +123,7 @@ function EditorWorkspace() {
}

export default function Editor() {
const { t } = useI18n();
const status = useEditorStore((s) => s.status);
const videoFile = useEditorStore((s) => s.videoFile);
const skipTranscription = useEditorStore((s) => s.skipTranscription);
Expand All @@ -140,9 +136,6 @@ export default function Editor() {
const redo = useEditorStore((s) => s.redo);
const setExportOpen = useEditorStore((s) => s.setExportOpen);

const [modeTransitioning, setModeTransitioning] = useState(false);
const wasIdle = useRef(status === "idle");

// File › Open Project… reaches the same picker the upload screen uses, from
// anywhere in the app.
const menuInputRef = useRef<HTMLInputElement>(null);
Expand All @@ -156,7 +149,7 @@ export default function Editor() {
const { source, pendingTranscript } = useEditorStore.getState();
if (source === "import") {
if (!pendingTranscript) {
alert("Choose a transcript file from the source menu first.");
alert(t("editor.chooseTranscript"));
return;
}
loadVideo(file, {
Expand All @@ -167,7 +160,7 @@ export default function Editor() {
}
loadVideo(file);
},
[loadVideo]
[loadVideo, t]
);

// The pipeline needs SharedArrayBuffer, so a file picked before the page is
Expand All @@ -188,7 +181,7 @@ export default function Editor() {
(file: File | undefined) => {
if (!file) return;
if (!detectMediaKind(file)) {
alert("Please choose a video or audio file.");
alert(t("editor.chooseMedia"));
return;
}
if (!isolated) {
Expand All @@ -197,7 +190,7 @@ export default function Editor() {
}
startMenuFile(file);
},
[isolated, startMenuFile]
[isolated, startMenuFile, t]
);

// Daily-active signal: reports the launch, then again on each day rollover so
Expand Down Expand Up @@ -247,18 +240,11 @@ export default function Editor() {
})();
}, [videoFile, skipTranscription, transcribe]);

// The desktop shell opens as a small upload window and grows once the
// three-pane editor takes over (and shrinks back on "start over").
// Cover the swap with a brief overlay so the layout reflow isn't visible
// while the window animates between sizes.
// The main process uses this mode only to preserve the native close behavior
// (close project first, then close the upload window). It no longer resizes
// the window; user-chosen bounds are persisted by Electron instead.
useEffect(() => {
const idle = status === "idle";
window.rescriptDesktop?.setWindowMode(idle ? "compact" : "expanded");
if (!isElectron || wasIdle.current === idle) return;
wasIdle.current = idle;
setModeTransitioning(true);
const timer = window.setTimeout(() => setModeTransitioning(false), WINDOW_MODE_OVERLAY_MS);
return () => window.clearTimeout(timer);
window.rescriptDesktop?.setWindowMode(status === "idle" ? "compact" : "expanded");
}, [status]);

// Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo, S = split,
Expand Down Expand Up @@ -349,7 +335,7 @@ export default function Editor() {
{status === "idle" ? (
<>
{isElectron && <TopBar>
<ModelSelector groupLabel="Transcript source">
<ModelSelector groupLabel={t("model.transcriptSource")}>
{MODEL_ORDER.map((id) => (
<ModelOption key={id} id={id} />
))}
Expand All @@ -369,15 +355,15 @@ export default function Editor() {
<button
onClick={undo}
disabled={!canUndo}
title="Undo (⌘Z)"
title={t("editor.undo")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-800 cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
>
<Undo2 size={16} />
</button>
<button
onClick={redo}
disabled={!canRedo}
title="Redo (⇧⌘Z)"
title={t("editor.redo")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-800 cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
>
<Redo2 size={16} />
Expand All @@ -389,7 +375,7 @@ export default function Editor() {
className="flex ml-1 h-8 items-center gap-1.5 rounded-full bg-zinc-900 px-4 text-[13px] font-medium text-white transition hover:bg-zinc-700 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
<Download size={14} />
Export
{t("editor.export")}
</button>
<div className="mx-1 h-5 w-px bg-zinc-200 dark:bg-zinc-700" />
<SettingsMenu />
Expand All @@ -398,15 +384,6 @@ export default function Editor() {
<Timeline />
</>
)}
{modeTransitioning && (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-zinc-50 dark:bg-zinc-950"
aria-busy="true"
aria-live="polite"
>
<LogoLoader size={44} />
</div>
)}
{isElectron && (
// Present in the layout tree (not display:none) so the native menu's
// click() reliably opens the picker.
Expand Down
Loading