From 523957d373cfca73c9269b95bccab5b96501247b Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 17 Jul 2026 00:36:54 +0200 Subject: [PATCH] various mobile ux fixes --- .../dashboard/DashboardModal.module.css | 46 ++- components/dashboard/DashboardModal.tsx | 9 +- components/dashboard/DashboardSidebar.tsx | 2 +- components/dashboard/project/DangerZone.tsx | 7 +- .../project/LayoutSettings.module.css | 13 + .../editor/MobileFormatToolbar.module.css | 14 +- components/editor/MobileFormatToolbar.tsx | 281 +++++++++++------- components/navbar/HomeNavbar.tsx | 36 ++- components/navbar/ProjectNavbar.module.css | 30 +- components/navbar/ProjectNavbarMobile.tsx | 5 + .../navbar/ProjectNavbarMobileMenu.module.css | 13 +- components/navbar/ProjectNavbarMobileMenu.tsx | 20 +- components/navbar/ScreenplaySearch.module.css | 3 + components/navbar/ScreenplaySearch.tsx | 199 +++++++------ components/navbar/useProjectNavbar.ts | 12 +- components/project/EditorFooter.module.css | 18 +- components/project/EditorFooter.tsx | 4 +- .../project/SplitPanelContainer.module.css | 16 + components/project/WritingTimer.module.css | 16 + components/projects/CreateProjectPage.tsx | 10 +- .../projects/EmptyProjectPage.module.css | 136 --------- components/projects/EmptyProjectPage.tsx | 42 --- components/projects/ProjectItem.module.css | 14 +- components/projects/ProjectItem.tsx | 5 +- .../projects/ProjectMigrationErrorDialog.tsx | 10 +- .../projects/ProjectPageContainer.module.css | 53 +++- components/projects/ProjectPageContainer.tsx | 56 ++-- .../projects/ProjectUnavailableDialog.tsx | 13 +- .../projects/stats/NoStatsContainer.tsx | 5 +- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/ja.json | 2 + messages/ko.json | 2 + messages/pl.json | 2 + messages/zh.json | 2 + src/app/projects/layout.tsx | 11 +- src/app/projects/page.tsx | 17 +- src/lib/adapters/registry.ts | 3 + src/lib/screenplay/editor.ts | 14 +- src/lib/titlepage/editor.ts | 11 +- src/lib/utils/hooks.ts | 8 +- src/lib/utils/navigation.ts | 35 +++ src/lib/utils/redirects.ts | 8 +- 45 files changed, 720 insertions(+), 491 deletions(-) delete mode 100644 components/projects/EmptyProjectPage.module.css delete mode 100644 components/projects/EmptyProjectPage.tsx create mode 100644 src/lib/utils/navigation.ts diff --git a/components/dashboard/DashboardModal.module.css b/components/dashboard/DashboardModal.module.css index 90b38f88..80700618 100644 --- a/components/dashboard/DashboardModal.module.css +++ b/components/dashboard/DashboardModal.module.css @@ -38,7 +38,8 @@ .sidebarTitle { font-size: 1.2rem; - padding: 0 24; + font-weight: 600; + padding: 0 24px; margin-bottom: 18px; color: var(--primary-text); } @@ -69,7 +70,7 @@ color: var(--secondary-text); margin-bottom: 5px; margin-top: 14px; - padding: 0 12; + padding: 0 12px; opacity: 0.6; } @@ -77,6 +78,13 @@ padding: 0 12px; } +/* Bottom cluster (Sign in / Log out + About). Pinned to the bottom of the sidebar + * on desktop; on phone it gets a separator + gap so it reads as a distinct group + * set apart from the section list above. */ +.navMenuFooter { + margin-top: auto; +} + .navItem { display: flex; align-items: center; @@ -196,14 +204,23 @@ font-family: inherit; } +/* Same look as the sections-list close (.sidebarClose) so the drawer's two close + * buttons don't differ (and matches the project sidebar + burger drawers). */ .close_btn { - height: 22px; - width: 22px; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 10px; + background: none; color: var(--secondary-text); cursor: pointer; } .close_btn:hover { + background-color: var(--secondary-hover); color: var(--primary-text); } @@ -251,19 +268,33 @@ .sidebar { display: none; flex: 1; - padding: 8px 8px calc(16px + var(--safe-bottom)); + padding: 8px calc(8px + var(--safe-right)) calc(16px + var(--safe-bottom)) 8px; border-right: none; overflow-y: auto; + /* On mobile the whole modal is one drawer, so the sections screen wears the + * same flat drawer background as the content screen (and the project menu + + * burger drawers) rather than the desktop two-tone sidebar colour. */ + background: var(--main-bg); } .sidebarHeader { padding: 4px 12px 8px; } + /* Align the title with the section labels + item icons below. Their left edge is + * navMenu(12) + own padding(12); the header only carries 12, so the title needs + * its own 12 to reach the same line. */ .sidebarTitle { + padding: 0 12px; margin-bottom: 0; } + .navMenuFooter { + margin-top: 24px; + padding-top: 12px; + border-top: 1px solid var(--separator); + } + .sidebarClose { display: flex; } @@ -278,7 +309,10 @@ .content { flex: 1; - padding: 16px 12px calc(16px + var(--safe-bottom)); + /* Symmetric horizontal padding + the right safe-area inset so the close + * button and settings panels (Keybinds, Layout, …) never crowd the screen + * edge. */ + padding: 12px calc(16px + var(--safe-right)) calc(16px + var(--safe-bottom)) 16px; /* Nudge inherited/em-based text down a touch for a denser, phone-friendly * scale; width-driven layouts reflow into the narrow drawer on their own. */ font-size: 0.92rem; diff --git a/components/dashboard/DashboardModal.tsx b/components/dashboard/DashboardModal.tsx index c9b12f4a..52d56bcd 100644 --- a/components/dashboard/DashboardModal.tsx +++ b/components/dashboard/DashboardModal.tsx @@ -29,6 +29,7 @@ const DashboardModal = () => { useContext(DashboardContext); const t = useTranslations("modal"); const tSidebar = useTranslations("sidebar"); + const tNav = useTranslations("navbar"); const isPhone = useIsPhone(); // Phone: the drawer shows one screen at a time — the sections list (nav @@ -145,7 +146,13 @@ const DashboardModal = () => { )}

{t(`tabs.${activeTab}` as Parameters[0])}

- +
diff --git a/components/dashboard/DashboardSidebar.tsx b/components/dashboard/DashboardSidebar.tsx index 1d77e3e2..0cb7b7ac 100644 --- a/components/dashboard/DashboardSidebar.tsx +++ b/components/dashboard/DashboardSidebar.tsx @@ -111,7 +111,7 @@ const SidebarMenu = ({ structure, activeTab, onTabChange }: SidebarMenuProps) =>
))} -
+
{/* While the user query is in flight, leave the slot empty rather than rendering a "Sign in" button against an unknown auth state — clicking it during loading races the SWR resolution and ends up on Profile. */} diff --git a/components/dashboard/project/DangerZone.tsx b/components/dashboard/project/DangerZone.tsx index 22a92350..88dd1d9a 100644 --- a/components/dashboard/project/DangerZone.tsx +++ b/components/dashboard/project/DangerZone.tsx @@ -2,7 +2,7 @@ import { deleteProject } from "@src/lib/utils/requests"; import { useProjectMembership } from "@src/lib/utils/hooks"; -import { redirectHome } from "@src/lib/utils/redirects"; +import { useAppNavigation } from "@src/lib/utils/navigation"; import { useContext, useState } from "react"; import { DashboardContext } from "@src/context/DashboardContext"; import { Trash2 } from "lucide-react"; @@ -21,6 +21,7 @@ interface DangerZoneProps { const DangerZone = ({ projectId, isLocalOnly, isOpen }: DangerZoneProps) => { const { membership } = useProjectMembership(); const { closeDashboard } = useContext(DashboardContext); + const { goToProjects } = useAppNavigation(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [loading, setLoading] = useState(false); const t = useTranslations("dangerZone"); @@ -35,7 +36,7 @@ const DangerZone = ({ projectId, isLocalOnly, isOpen }: DangerZoneProps) => { const { deleteCachedProject } = await import("@src/lib/persistence/storage-provider/local-persistence"); await deleteCachedProject(projectId); closeDashboard(); - redirectHome(); + goToProjects(); } else if (membership) { const res = await deleteProject(membership.project.id); if (res.ok) { @@ -44,7 +45,7 @@ const DangerZone = ({ projectId, isLocalOnly, isOpen }: DangerZoneProps) => { await import("@src/lib/persistence/storage-provider/local-persistence"); await deleteCachedProject(projectId); closeDashboard(); - redirectHome(); + goToProjects(); } } } finally { diff --git a/components/dashboard/project/LayoutSettings.module.css b/components/dashboard/project/LayoutSettings.module.css index f24d24e5..036d06b3 100644 --- a/components/dashboard/project/LayoutSettings.module.css +++ b/components/dashboard/project/LayoutSettings.module.css @@ -282,6 +282,19 @@ gap: 12px; } + /* Header/footer: keep the three alignment inputs (left / middle / right) on one + * row even in the narrow drawer — stacking them into a column loses the + * left/center/right meaning. min-width:0 lets the inputs shrink to share the + * width instead of overflowing off-screen. */ + .hfGroup .labelRow { + flex-direction: row; + gap: 8px; + } + + .hfGroup .labelRow > div { + min-width: 0; + } + .hfGroup { grid-template-columns: 1fr; row-gap: 8px; diff --git a/components/editor/MobileFormatToolbar.module.css b/components/editor/MobileFormatToolbar.module.css index d1f57b3d..653dcc37 100644 --- a/components/editor/MobileFormatToolbar.module.css +++ b/components/editor/MobileFormatToolbar.module.css @@ -24,7 +24,9 @@ background-color: var(--secondary); border: 1px solid var(--separator); - border-radius: 18px; + /* Fully rounded ends — the bar reads as a floating pill above the keyboard. + * ~half the bar height (40px controls + 6px padding) gives clean pill caps. */ + border-radius: 26px; box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18); user-select: none; @@ -34,6 +36,16 @@ -webkit-tap-highlight-color: transparent; } +/* Transparent full-screen shield raised for a beat after an element pick. Sits + * under the toolbar (z 60) but over everything else, so the tap iOS synthesizes + * at touch-end lands here — never on the editor, where it would move the caret. */ +.tap_guard { + position: fixed; + inset: 0; + z-index: 59; + background: transparent; +} + .group { display: flex; flex-direction: row; diff --git a/components/editor/MobileFormatToolbar.tsx b/components/editor/MobileFormatToolbar.tsx index 5086ad83..8080afea 100644 --- a/components/editor/MobileFormatToolbar.tsx +++ b/components/editor/MobileFormatToolbar.tsx @@ -95,8 +95,18 @@ const MobileFormatToolbar = () => { const activeEditor = isTitleContext ? titlePageEditor : isDraftContext ? draftEditor : editor; const [selectedAlign, setSelectedAlign] = useState("left"); + // Whether the target editor's contenteditable is actually focused. The keyboard + // being up isn't enough: opening the screenplay search focuses a plain + // (its own keyboard), and focusedEditorType is never cleared on blur — without + // this the toolbar would wrongly ride the search keyboard too. + const [editorFocused, setEditorFocused] = useState(false); const [elementMenuOpen, setElementMenuOpen] = useState(false); - const elementRef = useRef(null); + // Briefly true right after picking an element: shields the editor from the tap + // iOS synthesizes at touch-end so the caret can't jump to the tapped position. + const [tapGuard, setTapGuard] = useState(false); + const tapGuardTimer = useRef | null>(null); + const blurTimer = useRef | null>(null); + const toolbarRef = useRef(null); const ELEMENT_LABELS: Record = { [ScreenplayElement.Scene]: t("elements.scene"), @@ -116,6 +126,35 @@ const MobileFormatToolbar = () => { const elementOrder = isTitleContext ? TITLEPAGE_ELEMENTS_ORDER : SCREENPLAY_ELEMENTS_ORDER; const currentElement = isTitleContext ? selectedTitlePageElement : selectedElement; + // Track real focus of the target editor so the toolbar only rides ITS keyboard, + // not the search input's (see editorFocused above). + useEffect(() => { + if (!activeEditor) { + setEditorFocused(false); + return; + } + const onFocus = () => { + if (blurTimer.current) clearTimeout(blurTimer.current); + setEditorFocused(true); + }; + // Defer acting on a blur: an editor mutation (e.g. a mark toggle) can blur + // and immediately re-focus within a tick, and we don't want that transient + // to tear the toolbar down. A real blur (tapping the search field, dismissing + // the keyboard) stays blurred past the grace window and then hides it. + const onBlur = () => { + if (blurTimer.current) clearTimeout(blurTimer.current); + blurTimer.current = setTimeout(() => setEditorFocused(false), 150); + }; + activeEditor.on("focus", onFocus); + activeEditor.on("blur", onBlur); + setEditorFocused(activeEditor.isFocused); + return () => { + if (blurTimer.current) clearTimeout(blurTimer.current); + activeEditor.off("focus", onFocus); + activeEditor.off("blur", onBlur); + }; + }, [activeEditor]); + // Keep the alignment highlight in sync with the caret's block. useEffect(() => { if (!activeEditor) return; @@ -132,12 +171,14 @@ const MobileFormatToolbar = () => { }; }, [activeEditor]); - // Close the element menu on any pointer-down outside it (without stealing the - // editor focus the way a normal outside tap would blur it). + // Close the element menu only when the pointer-down lands outside the whole + // toolbar. Taps on the style/alignment controls (which sit outside the element + // wrapper but inside the bar) must not dismiss the menu. Scoping to the toolbar + // also means these taps never steal the editor focus / drop the keyboard. useEffect(() => { if (!elementMenuOpen) return; const onDown = (e: PointerEvent) => { - if (elementRef.current && !elementRef.current.contains(e.target as Node)) { + if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) { setElementMenuOpen(false); } }; @@ -149,10 +190,13 @@ const MobileFormatToolbar = () => { (style: Style) => { if (isReadOnly || !activeEditor) return; setSelectedStyles((prev) => (prev ^ style) as Style); + // refocus: false — the editor is already focused here; a programmatic + // re-focus drops the iOS keyboard on the mark-removal path (see + // applyMarkToggle). if (isTitleContext) { - applyTitlePageMarkToggle(activeEditor, style); + applyTitlePageMarkToggle(activeEditor, style, false); } else { - applyMarkToggle(activeEditor, style); + applyMarkToggle(activeEditor, style, false); } }, [activeEditor, isTitleContext, isReadOnly, setSelectedStyles], @@ -160,6 +204,10 @@ const MobileFormatToolbar = () => { const selectElement = useCallback( (element: ScreenplayElement | TitlePageElement) => { + // Close the menu immediately, then raise the tap-guard: iOS fires a + // synthesized mousedown at touch-end, and once the menu item unmounts + // that tap falls through to the editor behind it and moves the caret. + // The guard (see render) covers the editor for a beat to absorb it. setElementMenuOpen(false); if (isReadOnly || !activeEditor) return; if (isTitleContext) { @@ -169,21 +217,30 @@ const MobileFormatToolbar = () => { setSelectedElement(element as ScreenplayElement); applyElement(activeEditor, element as ScreenplayElement); } + setTapGuard(true); + if (tapGuardTimer.current) clearTimeout(tapGuardTimer.current); + tapGuardTimer.current = setTimeout(() => setTapGuard(false), 350); }, [activeEditor, isTitleContext, isReadOnly, setSelectedElement, setSelectedTitlePageElement], ); + // Drop the tap-guard timer if the toolbar unmounts mid-gesture. + useEffect(() => () => { + if (tapGuardTimer.current) clearTimeout(tapGuardTimer.current); + }, []); + const setAlignment = useCallback( (align: string) => { if (isReadOnly || !activeEditor) return; setSelectedAlign(align); + // No .focus(): the editor is already focused, and re-focusing risks + // dropping the iOS keyboard (see applyMarkToggle / toggleStyle). if (isTitleContext) { - activeEditor.chain().focus().updateAttributes("tp-text", { textAlign: align }).run(); + activeEditor.chain().updateAttributes("tp-text", { textAlign: align }).run(); } else { const nodeType = activeEditor.state.selection.$anchor.parent.type.name; activeEditor .chain() - .focus() .updateAttributes(nodeType, { textAlign: align === "left" ? null : align }) .run(); } @@ -191,121 +248,139 @@ const MobileFormatToolbar = () => { [activeEditor, isTitleContext, isReadOnly], ); - // Only mount on phone, once a text editor is focused and the keyboard is up. - const isVisible = isPhone && keyboardInset > 0 && !!activeEditor && focusedEditorType !== null; + // Only mount on phone, once the target editor itself is focused and the keyboard + // is up. editorFocused excludes the case where another field (e.g. search) holds + // focus while a stale focusedEditorType lingers. + const isVisible = + isPhone && keyboardInset > 0 && !!activeEditor && focusedEditorType !== null && editorFocused; if (!isVisible) return null; // Fire on pointer-down and swallow the event so focus (and the on-screen - // keyboard) stays on the editor — a normal click would blur it first. + // keyboard) stays on the editor — a normal click would blur it first. Stopping + // propagation also keeps the tap from reaching the editor as a caret move. const action = (fn: () => void) => (e: React.PointerEvent) => { e.preventDefault(); + e.stopPropagation(); fn(); }; const styleBtn = (active: boolean) => join(styles.btn, active ? styles.active : ""); return ( -
- {/* Element-type selector — the primary control, opens a menu upward. */} -
- {elementMenuOpen && ( -
- {elementOrder.map((element) => ( - - ))} -
- )} - -
- -
-
- - + <> + {/* Transparent shield raised for a beat after an element pick: it sits + over the editor (below the toolbar) so the tap iOS synthesizes at + touch-end lands here and is swallowed, never reaching the editor. */} + {tapGuard && ( +
e.preventDefault()} /> + )} +
+ {/* Element-type selector — the primary control, opens a menu upward. */} +
+ {elementMenuOpen && ( +
+ {elementOrder.map((element) => ( + + ))} +
+ )}
-
+
+
+ + + +
-
- - - +
+ +
+ + + +
-
+ ); }; diff --git a/components/navbar/HomeNavbar.tsx b/components/navbar/HomeNavbar.tsx index 90852f1c..dcf8e3eb 100644 --- a/components/navbar/HomeNavbar.tsx +++ b/components/navbar/HomeNavbar.tsx @@ -36,16 +36,20 @@ const HomeNavbar = ({ onOpenSidebar }: HomeNavbarProps) => { return ( ); diff --git a/components/navbar/ProjectNavbar.module.css b/components/navbar/ProjectNavbar.module.css index fd2c1e37..aa0d0c0a 100644 --- a/components/navbar/ProjectNavbar.module.css +++ b/components/navbar/ProjectNavbar.module.css @@ -91,6 +91,23 @@ background-color: var(--secondary-hover); } +/* The home navbar reuses the pill clusters (.mobile_left/.mobile_right) but has + * no .mobile_bar wrapper, so re-apply the flush-icon treatment here, scoped to + * the home bar, so its burger/settings sit on the pill exactly like the project + * navbar's icons. */ +.home_container .mobile_icon { + background-color: transparent; + border-color: transparent; +} + +.home_container .mobile_icon:hover { + border-color: transparent; +} + +.home_container .mobile_icon:active { + background-color: var(--secondary-hover); +} + @media (max-width: 767px) { .container { /* Push the bar below the status bar / Dynamic Island. --navbar-height @@ -453,11 +470,22 @@ * Scoped to the home navbar so the editor navbar's clusters are untouched. */ @media (max-width: 767px) { .home_container { - padding-inline: calc(16px + var(--safe-left)) calc(16px + var(--safe-right)); + /* Match the editor navbar's phone edge padding (.mobile_bar) so the burger + * and settings buttons sit the same distance from the edges as the project + * navbar's icon clusters. */ + padding-inline: calc(10px + var(--safe-left)) calc(10px + var(--safe-right)); } .home_container .right_btns { width: auto; margin-right: 0; } + + /* Centre the pill clusters instead of letting them stretch to the full bar + * height (these wrappers default to align-items: stretch), so they render at + * their natural ~48px size — the same as the project navbar's .mobile_bar. */ + .home_container .left_btns, + .home_container .right_btns { + align-items: center; + } } diff --git a/components/navbar/ProjectNavbarMobile.tsx b/components/navbar/ProjectNavbarMobile.tsx index ee470074..907fdc16 100644 --- a/components/navbar/ProjectNavbarMobile.tsx +++ b/components/navbar/ProjectNavbarMobile.tsx @@ -249,6 +249,11 @@ const ProjectNavbarMobile = () => {
))} + {/* Set the sign in/out + About actions apart from the dashboard + tab groups above, so they don't read as part of Preferences + (mirrors the DashboardModal sidebar's footer separation). */} +
+ {isSignedIn ? (
- {isOpen && ( -
- {isPhone && ( - - )} - - {/* Navigation section */} -
- - - {searchMatches.length > 0 - ? t("matchCount", { current: currentSearchIndex + 1, total: searchMatches.length }) - : t("noMatches")} - - - -
- - {/* Replace section */} -
- setReplaceValue(e.target.value)} - /> -
- - + {isOpen && + (() => { + const panel = ( +
+ {isPhone && ( + + )} + + {/* Navigation section */} +
+ + + {searchMatches.length > 0 + ? t("matchCount", { + current: currentSearchIndex + 1, + total: searchMatches.length, + }) + : t("noMatches")} + + + +
+ + {/* Replace section */} +
+ setReplaceValue(e.target.value)} + /> +
+ + +
+
+ + {/* Filters section */} +
+ {t("filterByElement")} +
+ {FILTER_ORDER.map((element) => ( + + ))} +
+
-
- - {/* Filters section */} -
- {t("filterByElement")} -
- {FILTER_ORDER.map((element) => ( - - ))} -
-
-
- )} + ); + // Phone: portal past the navbar, whose transform + overflow:hidden + // would otherwise trap and clip this fixed-positioned panel. + return isPhone ? createPortal(panel, document.body) : panel; + })()}
); }; diff --git a/components/navbar/useProjectNavbar.ts b/components/navbar/useProjectNavbar.ts index 68031fff..b8ed9845 100644 --- a/components/navbar/useProjectNavbar.ts +++ b/components/navbar/useProjectNavbar.ts @@ -9,7 +9,7 @@ import { DashboardContext } from "@src/context/DashboardContext"; import { useCookieUser, useIsPro, useProjectIdFromUrl } from "@src/lib/utils/hooks"; import { useDashboardMenu } from "@components/dashboard/useDashboardMenu"; import { editProject } from "@src/lib/utils/requests"; -import { redirectHome } from "@src/lib/utils/redirects"; +import { useAppNavigation } from "@src/lib/utils/navigation"; import { signOutAccount } from "@src/lib/utils/auth-actions"; /** @@ -27,6 +27,7 @@ export const useProjectNavbar = () => { const { user } = useCookieUser(); const projectId = useProjectIdFromUrl(); const { structure: dashboardMenu, isSignedIn } = useDashboardMenu(); + const { goToProjects } = useAppNavigation(); const [projectTitle, setProjectTitle] = useState(""); const [isLocalOnly, setIsLocalOnly] = useState(null); @@ -110,15 +111,14 @@ export const useProjectNavbar = () => { isLocalEdit.current = false; }; - // Return to the projects list. redirectHome() swaps out the ?projectId query - // the same way opening a project does, which the Tauri static export needs - // (a bare router.replace doesn't reliably re-render the layout back). - const backToProjects = () => redirectHome(); + // Return to the projects list by dropping the ?projectId param. The projects + // page reads it via useSearchParams, so replacing the URL swaps the view back. + const backToProjects = () => goToProjects(); // Sign out via the shared flow, then land on the home/landing screen. const onSignOut = async () => { await signOutAccount(); - redirectHome(); + goToProjects(); }; return { diff --git a/components/project/EditorFooter.module.css b/components/project/EditorFooter.module.css index 78f7c889..ce756739 100644 --- a/components/project/EditorFooter.module.css +++ b/components/project/EditorFooter.module.css @@ -67,11 +67,18 @@ /* Phone: centre the island along the bottom — pinned to the right feels off on a * narrow screen. Also lift it above the home-indicator safe area. */ @media (max-width: 767px) { + /* A rounded island in the same chrome family as the Edit FAB and the navbar + * pills — bigger padding and radius than the desktop micro-pill so it reads as + * mobile chrome. It stays a compact cluster of secondary toggles, though, not a + * primary FAB, so it doesn't compete with the bottom-left Edit button. */ .bubble_right { left: 50%; right: auto; transform: translateX(-50%); bottom: calc(16px + var(--safe-bottom)); + gap: 4px; + padding: 5px 7px; + border-radius: 26px; } /* Drop the page count on phone: it's redundant in the default endless-scroll @@ -85,7 +92,14 @@ /* Roomier, thumb-friendly tap targets now that the row is short. */ .action { - width: 34px; - height: 32px; + width: 40px; + height: 40px; + border-radius: 20px; + opacity: 1; + } + + /* Fullscreen focus mode has no purpose on phone (see EditorFooter.tsx). */ + .focus_action { + display: none; } } diff --git a/components/project/EditorFooter.tsx b/components/project/EditorFooter.tsx index 2f551918..49316763 100644 --- a/components/project/EditorFooter.tsx +++ b/components/project/EditorFooter.tsx @@ -137,9 +137,11 @@ const EditorFooter = () => { > + {/* Focus mode relies on the Fullscreen API and hiding desktop sidebar + chrome — neither meaningful on phone — so it's hidden there (CSS). */} - {t("empty.createDesc")} -
-
- - .fountain · .fdx · .txt · .scriptio -
-
-
- ); -}; - -export default EmptyProjectPage; diff --git a/components/projects/ProjectItem.module.css b/components/projects/ProjectItem.module.css index eea8a51f..645d42c5 100644 --- a/components/projects/ProjectItem.module.css +++ b/components/projects/ProjectItem.module.css @@ -5,6 +5,8 @@ /* A row in the project table. Columns mirror the header in * ProjectPageContainer via the shared --projects-grid custom property. */ .container { + position: relative; + z-index: 0; cursor: pointer; width: 100%; padding: 14px 20px; @@ -18,11 +20,21 @@ border-bottom: 1px solid var(--separator); background: none; text-align: left; +} +/* Inset, rounded hover highlight — sits behind the row content so the + * full-width separator and grid layout are unaffected. */ +.container::before { + content: ""; + position: absolute; + inset: 4px 0; + z-index: -1; + border-radius: 8px; + background-color: transparent; transition: background-color 0.15s ease; } -.container:hover { +.container:hover::before { background-color: var(--secondary-hover); } diff --git a/components/projects/ProjectItem.tsx b/components/projects/ProjectItem.tsx index 42e14e99..8fe33ec1 100644 --- a/components/projects/ProjectItem.tsx +++ b/components/projects/ProjectItem.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import Image from "next/image"; import item from "./ProjectItem.module.css"; -import { redirectScreenplay } from "@src/lib/utils/redirects"; +import { useAppNavigation } from "@src/lib/utils/navigation"; import { ProjectMembershipPayload } from "@src/server/repository/project-repository"; import { CloudCheck, HardDrive } from "lucide-react"; @@ -16,6 +16,7 @@ type Props = { const ProjectItem = ({ project, isLocalOnly = false }: Props) => { const t = useTranslations("projects"); + const { goToProject } = useAppNavigation(); const tDates = useTranslations("dates"); const elapsedDays = getElapsedDaysFrom(project.updatedAt); const lastUpdated = @@ -37,7 +38,7 @@ const ProjectItem = ({ project, isLocalOnly = false }: Props) => { const StorageIcon = isLocalOnly ? HardDrive : CloudCheck; return ( - + {importError && ( +

+ {importError} +

+ )}
diff --git a/components/projects/ProjectUnavailableDialog.tsx b/components/projects/ProjectUnavailableDialog.tsx index 5c6b6606..7ec1d8f3 100644 --- a/components/projects/ProjectUnavailableDialog.tsx +++ b/components/projects/ProjectUnavailableDialog.tsx @@ -1,10 +1,11 @@ "use client"; import { useCallback, useContext, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { Save, Trash2 } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; +import { useAppNavigation } from "@src/lib/utils/navigation"; import { discardCloudProjectData, getCachedProject, @@ -14,7 +15,7 @@ import { import styles from "./ProjectUnavailableDialog.module.css"; const ProjectUnavailableDialog = () => { - const router = useRouter(); + const { goToProjects, goToProject } = useAppNavigation(); const params = useSearchParams(); const projectId = params.get("projectId"); const { project, repository } = useContext(ProjectContext); @@ -28,24 +29,24 @@ const ProjectUnavailableDialog = () => { const cached = await getCachedProject(projectId); const title = cached?.title || project?.project?.title || repository?.getTitle() || "Untitled Project"; const newProject = await migrateToCachedProject(projectId, title, cached?.description ?? undefined); - router.replace(`/projects?projectId=${newProject.id}`); + goToProject(newProject.id); } catch (e) { console.error("[ProjectUnavailableDialog] Migration failed:", e); setLoading(false); } - }, [projectId, repository, router, project]); + }, [projectId, repository, goToProject, project]); const handleDiscard = useCallback(async () => { if (!projectId) return; setLoading(true); try { await discardCloudProjectData(projectId); - router.replace("/projects"); + goToProjects(); } catch (e) { console.error("[ProjectUnavailableDialog] Discard failed:", e); setLoading(false); } - }, [projectId, router]); + }, [projectId, goToProjects]); return (
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); };