diff --git a/components/projects/stats/NoStatsContainer.tsx b/components/projects/stats/NoStatsContainer.tsx
index 9b247ce8..aa8eedf5 100644
--- a/components/projects/stats/NoStatsContainer.tsx
+++ b/components/projects/stats/NoStatsContainer.tsx
@@ -1,7 +1,7 @@
"use client";
import BackButton from "@components/utils/BackButton";
-import { redirectScreenplay } from "@src/lib/utils/redirects";
+import { useAppNavigation } from "@src/lib/utils/navigation";
import stats from "./ProjectStatsContainer.module.css";
import noStats from "./NoStatsContainer.module.css";
@@ -11,6 +11,7 @@ type Props = {
};
const NoStatsContainer = ({ projectId }: Props) => {
+ const { goToProject } = useAppNavigation();
return (
@@ -18,7 +19,7 @@ const NoStatsContainer = ({ projectId }: Props) => {
Your screenplay is not long enough
Write some more and come back to see your statistics
- redirectScreenplay(projectId)} />
+ goToProject(projectId)} />
diff --git a/messages/de.json b/messages/de.json
index dd322b2d..43f98f73 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -285,8 +285,10 @@
"untitled": "Unbenannt",
"newProject": "Neues Projekt",
"pageTitle": "Projekte",
+ "noProjects": "Noch keine Projekte",
"importBtn": "Importieren...",
"importing": "Importieren...",
+ "importError": "Import fehlgeschlagen. Bitte überprüfe die Datei und versuche es erneut.",
"createBtn": "Erstellen",
"empty": {
"title": "Deine Geschichte Beginnt Hier",
diff --git a/messages/en.json b/messages/en.json
index a4511554..3dd15f3f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -284,8 +284,10 @@
"untitled": "Untitled",
"newProject": "New Project",
"pageTitle": "Projects",
+ "noProjects": "No projects yet",
"importBtn": "Import...",
"importing": "Importing...",
+ "importError": "Import failed. Please check the file and try again.",
"createBtn": "Create",
"empty": {
"title": "Your Story Starts Here",
diff --git a/messages/es.json b/messages/es.json
index baf54bb5..81e16de2 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -284,8 +284,10 @@
"untitled": "Sin título",
"newProject": "Nuevo proyecto",
"pageTitle": "Proyectos",
+ "noProjects": "Aún no hay proyectos",
"importBtn": "Importar...",
"importing": "Importando...",
+ "importError": "La importación falló. Comprueba el archivo e inténtalo de nuevo.",
"createBtn": "Crear",
"empty": {
"title": "Tu Historia Empieza Aquí",
diff --git a/messages/fr.json b/messages/fr.json
index 345e5851..70efa565 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -285,8 +285,10 @@
"untitled": "Sans titre",
"newProject": "Nouveau projet",
"pageTitle": "Projets",
+ "noProjects": "Aucun projet pour l'instant",
"importBtn": "Importer...",
"importing": "Importation...",
+ "importError": "L'importation a échoué. Vérifiez le fichier et réessayez.",
"createBtn": "Créer",
"empty": {
"title": "Votre Histoire Commence Ici",
diff --git a/messages/ja.json b/messages/ja.json
index ca8ac51e..9f16eaea 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -284,8 +284,10 @@
"untitled": "無題",
"newProject": "新規プロジェクト",
"pageTitle": "プロジェクト",
+ "noProjects": "プロジェクトがありません",
"importBtn": "インポート...",
"importing": "インポート中...",
+ "importError": "インポートに失敗しました。ファイルを確認して、もう一度お試しください。",
"createBtn": "作成",
"empty": {
"title": "あなたの物語はここから始まる",
diff --git a/messages/ko.json b/messages/ko.json
index 4cc3d826..e9f30d83 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -284,8 +284,10 @@
"untitled": "제목 없음",
"newProject": "새 프로젝트",
"pageTitle": "프로젝트",
+ "noProjects": "아직 프로젝트가 없습니다",
"importBtn": "가져오기...",
"importing": "가져오는 중...",
+ "importError": "가져오기에 실패했습니다. 파일을 확인한 후 다시 시도해 주세요.",
"createBtn": "생성",
"empty": {
"title": "당신의 이야기가 여기서 시작됩니다",
diff --git a/messages/pl.json b/messages/pl.json
index fdad89b4..1bd5eccf 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -284,8 +284,10 @@
"untitled": "Bez tytułu",
"newProject": "Nowy projekt",
"pageTitle": "Projekty",
+ "noProjects": "Brak projektów",
"importBtn": "Importuj...",
"importing": "Importowanie...",
+ "importError": "Import nie powiódł się. Sprawdź plik i spróbuj ponownie.",
"createBtn": "Utwórz",
"empty": {
"title": "Twoja Historia Zaczyna Się Tutaj",
diff --git a/messages/zh.json b/messages/zh.json
index 5e1500d7..15857c63 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -284,8 +284,10 @@
"untitled": "未命名",
"newProject": "新项目",
"pageTitle": "项目",
+ "noProjects": "暂无项目",
"importBtn": "导入...",
"importing": "正在导入...",
+ "importError": "导入失败。请检查文件后重试。",
"createBtn": "创建",
"empty": {
"title": "你的故事从这里开始",
diff --git a/src/app/projects/layout.tsx b/src/app/projects/layout.tsx
index 7adc3a1e..1b1a1ead 100644
--- a/src/app/projects/layout.tsx
+++ b/src/app/projects/layout.tsx
@@ -4,10 +4,11 @@ import Loading from "@components/utils/Loading";
import DashboardModal from "@components/dashboard/DashboardModal";
import ProjectUnavailableDialog from "@components/projects/ProjectUnavailableDialog";
import ProjectMigrationErrorDialog from "@components/projects/ProjectMigrationErrorDialog";
-import { useRouter, useSearchParams } from "next/navigation";
+import { useSearchParams } from "next/navigation";
import { ProjectProvider, useProjectReady } from "@src/context/ProjectContext";
import { ViewProvider } from "@src/context/ViewContext";
import { useProjectMembership, useSettings } from "@src/lib/utils/hooks";
+import { useAppNavigation } from "@src/lib/utils/navigation";
import { useLocale } from "@src/context/LocaleContext";
import { useTheme } from "next-themes";
import { ReactNode, Suspense, useEffect } from "react";
@@ -56,7 +57,7 @@ interface ProjectLayoutInnerProps {
}
const ProjectLayoutInner = ({ children }: ProjectLayoutInnerProps) => {
- const router = useRouter();
+ const { goToProjects } = useAppNavigation();
const { status } = useProjectReady();
const { membership, isLoading: isMembershipLoading, isLocalOnly: isBrowserLocalOnly } = useProjectMembership();
@@ -73,11 +74,11 @@ const ProjectLayoutInner = ({ children }: ProjectLayoutInnerProps) => {
// NEXT_REDIRECT mid-render tore down the project session before the async
// navigation committed; React then remounted this subtree (projectId is
// still in the URL), re-fired /api/users + cloud-token, and threw again —
- // an endless 401 loop in production. router.replace runs once, after the
+ // an endless 401 loop in production. goToProjects runs once, after the
// session has mounted cleanly, and stops once projectId leaves the URL.
useEffect(() => {
- if (mustRedirect) router.replace("/projects");
- }, [mustRedirect, router]);
+ if (mustRedirect) goToProjects();
+ }, [mustRedirect, goToProjects]);
// Surface terminal error states first so the user always gets a clear message,
// before any redirect or loading gating runs.
diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx
index 283e04fc..0944587a 100644
--- a/src/app/projects/page.tsx
+++ b/src/app/projects/page.tsx
@@ -1,31 +1,34 @@
"use client";
import { Suspense, useContext, useEffect, useState } from "react";
-import { useSearchParams, useRouter } from "next/navigation";
+import { useSearchParams } from "next/navigation";
import ProjectWorkspace from "@components/project/ProjectWorkspace";
import ProjectPageContainer from "@components/projects/ProjectPageContainer";
import HomeNavbar from "@components/navbar/HomeNavbar";
import DashboardModal from "@components/dashboard/DashboardModal";
import Loading from "@components/utils/Loading";
import { DashboardContext } from "@src/context/DashboardContext";
-import { useIsPhone } from "@src/lib/utils/hooks";
+import { useAppNavigation } from "@src/lib/utils/navigation";
function ProjectsPageContent() {
const params = useSearchParams();
const projectId = params.get("projectId");
const { openDashboard } = useContext(DashboardContext);
- const router = useRouter();
- const isPhone = useIsPhone();
+ const { goToProjects } = useAppNavigation();
// The projects sidebar open-state lives here so the navbar burger (in
// HomeNavbar) and the sidebar itself (in ProjectPageContainer) share it.
- // Desktop: a permanent column (open). Phone: an overlay drawer (starts closed).
- const [sidebarOpen, setSidebarOpen] = useState(!isPhone);
+ // Start closed: on desktop the sidebar is a permanent column shown by CSS
+ // regardless of this flag (the closed-transform is phone-only), so this only
+ // governs the phone drawer, which should begin closed. Deriving it from
+ // `isPhone` instead would depend on a value that's false until after mount
+ // (see useIsPhone) and would leave the phone drawer open on load.
+ const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (!projectId && params.get("pro") === "success") {
sessionStorage.setItem("proWelcome", "1");
- router.replace("/projects");
+ goToProjects();
openDashboard("Subscription");
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
diff --git a/src/lib/adapters/registry.ts b/src/lib/adapters/registry.ts
index 0b38fdf6..9e3edae4 100644
--- a/src/lib/adapters/registry.ts
+++ b/src/lib/adapters/registry.ts
@@ -20,6 +20,9 @@ registeredAdapters.forEach((adapter) => {
adapterMap.set(adapter.extension.toLowerCase(), adapter);
});
+// `.txt` files commonly contain plain Fountain markup, so treat them as Fountain.
+adapterMap.set("txt", new FountainAdapter());
+
export const getAdapterByFilename = (filename: string): ProjectAdapter | undefined => {
const extension = filename.split(".").pop()?.toLowerCase();
return getAdapterByExtension(extension);
diff --git a/src/lib/screenplay/editor.ts b/src/lib/screenplay/editor.ts
index 7acf39c6..0f59ee7f 100644
--- a/src/lib/screenplay/editor.ts
+++ b/src/lib/screenplay/editor.ts
@@ -12,10 +12,16 @@ import { RevisionMark, RevisionAttribute } from "./extensions/revisions-extensio
import { ContdExtension } from "./extensions/contd-extension";
import { FountainExtension } from "./extensions/fountain-extension";
-export const applyMarkToggle = (editor: Editor, style: Style) => {
- if (style & Style.Bold) editor.chain().toggleBold().focus().run();
- if (style & Style.Italic) editor.chain().toggleItalic().focus().run();
- if (style & Style.Underline) editor.chain().toggleUnderline().focus().run();
+// `refocus` re-asserts editor focus after the toggle — needed on desktop where the
+// click that triggered it (a navbar dropdown button) blurred the editor. On mobile
+// the editor is already focused (the toolbar only shows while it is), and the extra
+// programmatic view.dom.focus() there disturbs iOS enough to dismiss the on-screen
+// keyboard on the mark-removal path — so mobile passes refocus: false.
+export const applyMarkToggle = (editor: Editor, style: Style, refocus = true) => {
+ const chain = () => (refocus ? editor.chain().focus() : editor.chain());
+ if (style & Style.Bold) chain().toggleBold().run();
+ if (style & Style.Italic) chain().toggleItalic().run();
+ if (style & Style.Underline) chain().toggleUnderline().run();
};
export const applyElement = (editor: Editor, element: ScreenplayElement) => {
diff --git a/src/lib/titlepage/editor.ts b/src/lib/titlepage/editor.ts
index 161fee3d..7620a918 100644
--- a/src/lib/titlepage/editor.ts
+++ b/src/lib/titlepage/editor.ts
@@ -93,10 +93,13 @@ function isFormatNode(name: string): boolean {
return name === TitlePageElement.Title || name === TitlePageElement.Author || name === TitlePageElement.Date;
}
-export const applyTitlePageMarkToggle = (editor: Editor, style: Style) => {
- if (style & Style.Bold) editor.chain().toggleBold().focus().run();
- if (style & Style.Italic) editor.chain().toggleItalic().focus().run();
- if (style & Style.Underline) editor.chain().toggleUnderline().focus().run();
+// See applyMarkToggle in screenplay/editor.ts for the `refocus` rationale: mobile
+// passes false so the extra view.dom.focus() doesn't drop the iOS keyboard.
+export const applyTitlePageMarkToggle = (editor: Editor, style: Style, refocus = true) => {
+ const chain = () => (refocus ? editor.chain().focus() : editor.chain());
+ if (style & Style.Bold) chain().toggleBold().run();
+ if (style & Style.Italic) chain().toggleItalic().run();
+ if (style & Style.Underline) chain().toggleUnderline().run();
};
/**
diff --git a/src/lib/utils/hooks.ts b/src/lib/utils/hooks.ts
index 09f82cb3..cee3b83c 100644
--- a/src/lib/utils/hooks.ts
+++ b/src/lib/utils/hooks.ts
@@ -86,9 +86,11 @@ const PHONE_QUERY = "(max-width: 767px)";
* the burger navbar. SSR-safe: starts false on the server and syncs on mount.
*/
const useIsPhone = (): boolean => {
- const [isPhone, setIsPhone] = useState
(
- () => typeof window !== "undefined" && window.matchMedia(PHONE_QUERY).matches,
- );
+ // Start `false` so the server render and the client's first (hydration) render
+ // agree — reading window.matchMedia in the initializer would make the client's
+ // first render disagree with the SSR HTML and throw a hydration mismatch. The
+ // real value is resolved in the effect below, right after mount.
+ const [isPhone, setIsPhone] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
diff --git a/src/lib/utils/navigation.ts b/src/lib/utils/navigation.ts
new file mode 100644
index 00000000..9d7c75da
--- /dev/null
+++ b/src/lib/utils/navigation.ts
@@ -0,0 +1,35 @@
+"use client";
+
+/**
+ * Client-side navigation between the projects list (`/projects`) and an open
+ * project (`/projects?projectId=…`). Both are the same route, differing only by
+ * the `projectId` search param — which the projects page reads reactively via
+ * `useSearchParams`, so swapping the param is enough to switch views.
+ *
+ * The swap uses the native History API instead of `router.replace()`. Next
+ * (14.1+) syncs `useSearchParams`/`usePathname` with `history.replaceState`, so
+ * the view updates the same way — but without the router's RSC payload fetch.
+ * That fetch is what broke the Tauri app: under the static-export custom
+ * protocol (`tauri://localhost`, entry `projects.html`) it fails
+ * intermittently, and the router then falls back to a hard navigation to
+ * `/projects`, a path the protocol can't resolve — so the click did nothing.
+ * `history.replaceState` never leaves the webview and also keeps the current
+ * pathname untouched (`/projects.html` in Tauri, `/projects` on the web).
+ *
+ * Also do not use the `redirect()` helpers in `./redirects` from Client
+ * Components: `redirect()` is a server API that navigates by throwing
+ * `NEXT_REDIRECT`, which is unreliable in client event handlers and silently
+ * swallowed after an `await`.
+ */
+const replaceSearch = (search: string) => {
+ window.history.replaceState(null, "", `${window.location.pathname}${search}`);
+};
+
+// Stable identity: no router dependency, so hook consumers can safely list the
+// returned functions in effect/callback deps.
+const navigation = {
+ goToProjects: () => replaceSearch(""),
+ goToProject: (projectId: string) => replaceSearch(`?projectId=${projectId}`),
+};
+
+export const useAppNavigation = () => navigation;
diff --git a/src/lib/utils/redirects.ts b/src/lib/utils/redirects.ts
index f3b8921d..4bc5a764 100644
--- a/src/lib/utils/redirects.ts
+++ b/src/lib/utils/redirects.ts
@@ -1,9 +1,9 @@
+// Server-only navigation helpers (route handlers / server components). Client
+// Components must use `useAppNavigation` from `./navigation` instead — redirect()
+// navigates by throwing NEXT_REDIRECT, which is unreliable in client event
+// handlers and swallowed after an await.
import { redirect, RedirectType } from "next/navigation";
-export const redirectHome = () => {
- redirect("/projects", RedirectType.replace);
-};
-
export const redirectProject = (projectId: string) => {
redirect(`/projects?projectId=${projectId}`, RedirectType.replace);
};