diff --git a/components/dashboard/DashboardSidebar.tsx b/components/dashboard/DashboardSidebar.tsx index 163fba17..354e3505 100644 --- a/components/dashboard/DashboardSidebar.tsx +++ b/components/dashboard/DashboardSidebar.tsx @@ -1,7 +1,6 @@ "use client"; import { ReactNode, useContext, useState } from "react"; -import { mutate } from "swr"; import { DashboardContext } from "@src/context/DashboardContext"; import { Info, LogIn, LogOut } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -9,8 +8,7 @@ import { useTranslations } from "next-intl"; import styles from "./DashboardModal.module.css"; import dangerStyles from "./project/DangerZone.module.css"; import modal from "../utils/ModalBtn.module.css"; -import { signOut } from "next-auth/react"; -import { isTauri } from "@tauri-apps/api/core"; +import { signOutAccount } from "@src/lib/utils/auth-actions"; import { useCookieUser } from "@src/lib/utils/hooks"; export type Category = @@ -54,15 +52,7 @@ const SidebarMenu = ({ structure, activeTab, onTabChange }: SidebarMenuProps) => const [showLogOutConfirm, setShowLogOutConfirm] = useState(false); const onLogOut = async () => { - if (isTauri()) { - // Desktop holds the bearer token locally; the server has no cookie to clear. - const { clearDesktopToken } = await import("@src/lib/desktop-auth"); - await clearDesktopToken(); - } else { - await signOut({ redirect: false }); - } - - await mutate("/api/users/cookie", undefined); + await signOutAccount(); closeDashboard(); }; diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 04822d0c..f2a6236d 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -47,9 +47,10 @@ export interface DocumentEditorPanelProps { focusedTypeOverride?: "screenplay" | "title" | "draft"; } -// Scroll distance (px) that fully hides the mobile chrome. Roughly the navbar -// height so the bar slides away at content speed — a 1:1 feel with the scroll. -const CHROME_HIDE_RANGE = 64; +// Scroll distance (px) that fully hides the mobile chrome. Deliberately several +// times the navbar height so the bar eases away gradually over a longer swipe +// rather than snapping shut after a flick — matches the pace of a natural scroll. +const CHROME_HIDE_RANGE = 220; const DocumentEditorPanel = ({ config, @@ -829,10 +830,16 @@ const DocumentEditorPanel = ({ tapTimer.current = null; if (isReadOnly) return; setMobileEditMode(true); - // setEditable flips in an effect after this render, so defer the - // focus a tick until the editor is actually editable. + // Make the editor editable and focus it SYNCHRONOUSLY inside this tap + // gesture. iOS only raises the on-screen keyboard when focus() runs in + // the same user-gesture turn — deferring it (setTimeout) breaks that + // chain and the keyboard stays down. The mobileEditMode effect also + // flips setEditable(true), so this just gets there a tick earlier. const ed = editor; - if (ed) setTimeout(() => ed.commands.focus(), 0); + if (ed) { + ed.setEditable(true); + ed.commands.focus(); + } return; } @@ -846,17 +853,23 @@ const DocumentEditorPanel = ({ const onScroll = (e: React.UIEvent) => { if (suggestions.length > 0) updateSuggestions?.([]); - const scrollTop = e.currentTarget.scrollTop; + const el = e.currentTarget; + // Clamp to the real scroll range. iOS rubber-band overscroll reports a + // scrollTop below 0 (top) or beyond the maximum (bottom) and then springs + // back, which would otherwise feed spurious up/down deltas into the chrome + // hide and make the navbar flicker as the bounce settles. Clamping pins + // the delta to 0 while overscrolling, so the bounce leaves the bar alone. + const maxScroll = el.scrollHeight - el.clientHeight; + const scrollTop = Math.max(0, Math.min(el.scrollTop, maxScroll)); setIsScrolled(scrollTop > 0); if (isPhone) { updateThumb(); revealScrollThumb(); - // Map scroll movement onto the hide progress 1:1 over CHROME_HIDE_RANGE - // px: scrolling down reveals more of the script by sliding the chrome - // away at content speed; scrolling up brings it straight back. Snap - // fully open at the very top. In edit mode the navbar carries the - // exit/undo/redo controls, so keep it pinned open there. + // Accumulate scroll movement into the hide progress over + // CHROME_HIDE_RANGE px: scrolling down slides the chrome away, scrolling + // up brings it back. Snap fully open at the very top. In edit mode the + // navbar carries the exit/undo/redo controls, so keep it pinned open. const delta = scrollTop - lastScrollTop.current; if (mobileEditMode || scrollTop <= 4) { applyChromeHide(0); diff --git a/components/editor/MobileFormatToolbar.module.css b/components/editor/MobileFormatToolbar.module.css index ee52d862..d1f57b3d 100644 --- a/components/editor/MobileFormatToolbar.module.css +++ b/components/editor/MobileFormatToolbar.module.css @@ -1,4 +1,4 @@ -/* Phone-only formatting bar pinned just above the on-screen keyboard. `bottom` +/* Phone-only formatting bar that floats just above the on-screen keyboard. `bottom` * is set inline from the tracked keyboard inset (see MobileFormatToolbar.tsx). */ .toolbar { position: fixed; @@ -6,18 +6,26 @@ right: 0; z-index: 60; + /* Float as a rounded island rather than a full-width docked bar: inset from + * the screen edges and lifted off the keyboard's top edge (margins), with + * rounded corners of its own, so it echoes the iOS keyboard's rounded shape + * and reads as part of the same floating chrome instead of a hard-edged bar + * butted against it. `bottom: keyboardInset` (inline) pins it to the keyboard + * top; the bottom margin is the visible gap between the two. */ + margin: 0 8px 8px; + display: flex; flex-direction: row; align-items: center; justify-content: flex-start; gap: 8px; - padding: 6px 12px; - padding-bottom: max(6px, env(safe-area-inset-bottom, 0px)); + padding: 6px 8px; background-color: var(--secondary); - border-top: 1px solid var(--separator); - box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.12); + border: 1px solid var(--separator); + border-radius: 18px; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18); user-select: none; -webkit-user-select: none; diff --git a/components/navbar/ProjectNavbar.module.css b/components/navbar/ProjectNavbar.module.css index d9ad5303..e6f8871e 100644 --- a/components/navbar/ProjectNavbar.module.css +++ b/components/navbar/ProjectNavbar.module.css @@ -56,6 +56,16 @@ background-color: var(--secondary); border-radius: 999px; box-shadow: var(--panel-shadow); + + /* Hide by sliding the pill straight up off the bar and fading in lockstep, + * both driven by --chrome-hide (0 shown → 1 hidden). The pill translates its + * FULL travel (a whole navbar height) so it keeps moving until it's gone, + * rather than collapsing in place and leaving a clipped sliver to fade out — + * translate and opacity now finish together. No transition: it tracks the + * scroll gesture directly (see applyChromeHide). On desktop these clusters + * don't render and --chrome-hide stays 0, so this is inert there. */ + transform: translateY(calc(var(--chrome-hide, 0) * -1 * var(--navbar-height))); + opacity: calc(1 - var(--chrome-hide, 0)); } /* Always hug the right edge — the only other cluster (edit-mode controls) is on @@ -111,13 +121,15 @@ padding-block: 0; padding-top: var(--safe-top); overflow: hidden; - /* Collapse continuously as the reader scrolls down: --chrome-hide runs - * 0 (shown) → 1 (hidden), driven straight from scrollTop in - * DocumentEditorPanel. Height reclaims the space (the workspace is a flex - * column, so this reflows cleanly) and there is intentionally no - * transition — the bar tracks the gesture rather than easing after it. */ + /* Collapse the bar's height continuously as the reader scrolls down: + * --chrome-hide runs 0 (shown) → 1 (hidden), driven straight from + * scrollTop in DocumentEditorPanel. Height reclaims the space (the + * workspace is a flex column, so this reflows cleanly) and there is + * intentionally no transition — it tracks the gesture, not an ease. The + * fade lives on the pill clusters themselves (.mobile_left/.mobile_right) + * so it stays in lockstep with their slide-up rather than fading the + * whole row on a separate curve. */ height: calc(var(--navbar-height) * (1 - var(--chrome-hide, 0))); - opacity: calc(1 - var(--chrome-hide, 0)); } /* Fully hidden: drop hit-testing so the collapsed sliver can't catch taps. */ diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index 4c48def2..da02d1bd 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -1,658 +1,23 @@ "use client"; -import { useContext, useEffect, useMemo, useRef, useState } from "react"; -import { useTranslations } from "next-intl"; -import { ConnectionStatus } from "@src/lib/utils/enums"; -import { useCookieUser, useIsPhone, useIsPro, useProjectIdFromUrl } from "@src/lib/utils/hooks"; - -import { ProjectContext } from "@src/context/ProjectContext"; -import { UserContext } from "@src/context/UserContext"; -import { useViewContext } from "@src/context/ViewContext"; -import ProjectNavbarMobileMenu from "./ProjectNavbarMobileMenu"; -import debounce from "debounce"; -import { editProject } from "@src/lib/utils/requests"; -import { join } from "@src/lib/utils/misc"; -import { redirectHome } from "@src/lib/utils/redirects"; -import { uploadToCloudPopup } from "@src/lib/screenplay/popup"; -import type { StorageUsage } from "@src/lib/assets/cloud-asset-sync"; -import { DashboardContext } from "@src/context/DashboardContext"; -import { - AudioLines, - BarChart2, - Check, - CircleArrowLeft, - CircleCheckBig, - CloudUpload, - History, - Info, - LogIn, - Lock, - Menu, - Monitor, - Redo2, - Settings, - Undo2, - WifiOff, - WifiSync, -} from "lucide-react"; -import AnalyticsModal from "@components/analytics/AnalyticsModal"; -import { useDashboardMenu } from "@components/dashboard/useDashboardMenu"; -import SavesPanel from "./SavesPanel"; -import ProductionPanel from "./ProductionPanel"; -import ReadAloudPanel from "./ReadAloudPanel"; - -import navbar from "./ProjectNavbar.module.css"; -import mobileMenu from "./ProjectNavbarMobileMenu.module.css"; -import navBtn from "@components/utils/NavbarIconButton.module.css"; -import ScreenplayFormatDropdown from "./ScreenplayFormatDropdown"; -import ScreenplaySearch from "./ScreenplaySearch"; -import { useActiveEditor } from "@src/lib/editor/use-active-editor"; - -/** Human-readable byte size, e.g. 1.4 GB. */ -const formatBytes = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - const units = ["KB", "MB", "GB", "TB"]; - let value = bytes / 1024; - let i = 0; - while (value >= 1024 && i < units.length - 1) { - value /= 1024; - i++; - } - return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`; -}; - -/** Last fetched usage, so re-hovering shows the previous numbers immediately (the - * panel unmounts on mouse-leave) instead of placeholders. Tagged with its project - * id so switching projects doesn't flash the wrong project's figures. */ -let cachedStorage: { projectId: string; usage: StorageUsage } | null = null; - -const StorageUsageBody = ({ projectId }: { projectId: string }) => { - const t = useTranslations("navbar"); - const [usage, setUsage] = useState(() => - cachedStorage?.projectId === projectId ? cachedStorage.usage : null, - ); - - useEffect(() => { - let cancelled = false; - (async () => { - const { fetchProjectStorage } = await import("@src/lib/assets/cloud-asset-sync"); - const data = await fetchProjectStorage(projectId); - // Keep the last known value on a failed refresh rather than wiping it. - if (!cancelled && data) { - cachedStorage = { projectId, usage: data }; - setUsage(data); - } - })(); - return () => { - cancelled = true; - }; - }, [projectId]); - - // Render the full layout immediately (stable size); only the amounts fill in - // once fetched, so the panel doesn't grow after appearing. - const pct = usage && usage.quota > 0 ? Math.min(100, Math.round((usage.ownerTotalUsed / usage.quota) * 100)) : 0; - - return ( - <> -
- {t("storageProject")} - {usage ? formatBytes(usage.projectUsed) : "—"} -
-
- {t("storageTotal")} - - {usage ? `${formatBytes(usage.ownerTotalUsed)} / ${formatBytes(usage.quota)}` : "—"} - -
-
-
-
- - ); -}; - -const StatusIndicator = () => { - const { connectionStatus } = useContext(ProjectContext); - const projectId = useProjectIdFromUrl(); - const [hovered, setHovered] = useState(false); - const t = useTranslations("navbar"); - const STATUS: Record = { - connected: t("synced"), - disconnected: t("noConnection"), - connecting: t("reconnecting"), - }; - return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {connectionStatus === "connected" && ( - - )} - {connectionStatus === "disconnected" && ( - - )} - {connectionStatus === "connecting" && ( - - )} - {hovered && ( -
-
{STATUS[connectionStatus]}
- {projectId && ( - <> -
- - - )} -
- )} -
- ); -}; - -const getInitial = (name: string): string => { - if (!name) return "?"; - return name.charAt(0).toUpperCase(); -}; - -const CollaboratorsDisplay = () => { - const { users } = useContext(ProjectContext); - - if (users.length <= 1) return null; - - const MAX_VISIBLE = 4; - const visibleUsers = users.slice(0, MAX_VISIBLE); - const remainingCount = users.length - MAX_VISIBLE; - - return ( -
- {visibleUsers.map((user, index) => ( -
- {getInitial(user.name)} -
- ))} - {remainingCount > 0 &&
+{remainingCount}
} -
- ); -}; - +import { useIsPhone } from "@src/lib/utils/hooks"; + +import ProjectNavbarDesktop from "./ProjectNavbarDesktop"; +import ProjectNavbarMobile from "./ProjectNavbarMobile"; + +/** + * Project navbar entry point. A thin dispatcher that picks the phone or the + * desktop/web layout — the two are separate components ([ProjectNavbarMobile] / + * [ProjectNavbarDesktop]) sharing their project state through {@link useProjectNavbar} + * and their status/collaborator pieces through [ProjectNavbarShared], so neither + * layout carries the other's markup. + * + * Branching here (before either sub-component's hooks run) keeps the shared state + * hook from being instantiated twice. + */ const ProjectNavbar = () => { - const { openDashboard } = useContext(DashboardContext); - const { project: membership, setProjectTitle: setContextTitle } = useContext(ProjectContext); - const userCtx = useContext(UserContext); - - const [projectTitle, setProjectTitle] = useState(""); - const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); - const [isSavesOpen, setIsSavesOpen] = useState(false); - const [isProductionOpen, setIsProductionOpen] = useState(false); - const [isReadAloudOpen, setIsReadAloudOpen] = useState(false); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const [isLocalOnly, setIsLocalOnly] = useState(null); - const isLocalEdit = useRef(false); - const isPhone = useIsPhone(); - const { setLeftSidebarOpen, setRightSidebarOpen, chromeHidden, mobileEditMode, setMobileEditMode } = - useViewContext(); - const activeEditor = useActiveEditor(); - - // Undo/redo are backed by the collaboration UndoManager (registered by the - // Collaboration extension). Guard on the command existing so calling before - // Yjs is ready can't throw; an empty history no-ops harmlessly. - const runHistory = (action: "undo" | "redo") => { - if (activeEditor && typeof activeEditor.commands[action] === "function") { - activeEditor.chain().focus()[action]().run(); - } - }; - - // Leave edit mode: drop focus so the keyboard dismisses (setEditable(false) - // in the panel also does this, but blur here makes it immediate). - const exitEditMode = () => { - activeEditor?.commands.blur(); - setMobileEditMode(false); - }; - - // Return to the projects list by clearing the ?projectId query. Use the same - // redirect() primitive that opens a project (ProjectItem → redirectScreenplay): - // in the Tauri static export a query-only router.replace("/projects") does not - // reliably re-render the layout back to the list (useSearchParams doesn't pick - // up the change), whereas redirect() — which successfully swaps projectId in the - // other direction — does. redirect() navigates cleanly from a client handler - // here (it's what ProjectItem relies on), so it works on web and native alike. - const backToProjects = () => redirectHome(); - - const { isPro } = useIsPro(); - const { user } = useCookieUser(); - const projectId = useProjectIdFromUrl(); - - const t = useTranslations("navbar"); - const tModal = useTranslations("modal"); - const tSidebar = useTranslations("sidebar"); - - // The dashboard's nav lives in this burger on phone (the modal drops its - // sidebar there), so the same grouped tabs are rendered below and open the - // dashboard directly on the chosen tab. - const { structure: dashboardMenu, isSignedIn } = useDashboardMenu(); - - useEffect(() => { - if (!projectId) { - setIsLocalOnly(null); - return; - } - let cancelled = false; - (async () => { - const { isLocalOnlyProject, cachedProjectExists } = - await import("@src/lib/persistence/storage-provider/local-persistence"); - const exists = await cachedProjectExists(projectId); - const local = exists ? await isLocalOnlyProject(projectId) : false; - if (!cancelled) setIsLocalOnly(local); - })(); - return () => { - cancelled = true; - }; - }, [projectId, membership]); - - const canUploadToCloud = !membership && !!user && isPro && !!projectId && isLocalOnly === true; - - const isInProject = !!projectId; - - const deferredTitleUpdate = useMemo( - () => - debounce(async (projectId: string, newTitle: string) => { - const { isLocalOnlyProject, updateCachedProject } = - await import("@src/lib/persistence/storage-provider/local-persistence"); - if (await isLocalOnlyProject(projectId)) { - await updateCachedProject(projectId, { title: newTitle }); - } else { - await editProject(projectId, { title: newTitle }); - await updateCachedProject(projectId, { title: newTitle }); - } - }, 1000), - [], - ); - - // Load project title - from membership or local storage - useEffect(() => { - if (membership && !isLocalEdit.current) { - setProjectTitle(membership.project.title); - return; - } - - // For local projects, load title from local storage (SQLite on desktop, IndexedDB on browser) - if (projectId && !membership) { - const loadLocalTitle = async () => { - const { isCachedProject, getCachedProject } = - await import("@src/lib/persistence/storage-provider/local-persistence"); - if (await isCachedProject(projectId)) { - const cachedProject = await getCachedProject(projectId); - if (cachedProject && !isLocalEdit.current) { - setProjectTitle(cachedProject.title); - } - } - }; - loadLocalTitle(); - } - }, [membership, projectId]); - - // Update browser tab title when project title changes - useEffect(() => { - if (projectTitle && isInProject) { - document.title = `${projectTitle}`; - } - }, [projectTitle, isInProject]); - - if (isPhone) { - return ( - - ); - } - - return ( - - ); + return isPhone ? : ; }; export default ProjectNavbar; diff --git a/components/navbar/ProjectNavbarDesktop.tsx b/components/navbar/ProjectNavbarDesktop.tsx new file mode 100644 index 00000000..73ad1276 --- /dev/null +++ b/components/navbar/ProjectNavbarDesktop.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { + AudioLines, + BarChart2, + CircleArrowLeft, + CloudUpload, + History, + Lock, + Monitor, + Settings, +} from "lucide-react"; + +import { uploadToCloudPopup } from "@src/lib/screenplay/popup"; +import { join } from "@src/lib/utils/misc"; + +import { useProjectNavbar } from "./useProjectNavbar"; +import { StatusIndicator, CollaboratorsDisplay } from "./ProjectNavbarShared"; +import SavesPanel from "./SavesPanel"; +import ProductionPanel from "./ProductionPanel"; +import ReadAloudPanel from "./ReadAloudPanel"; +import ScreenplayFormatDropdown from "./ScreenplayFormatDropdown"; +import ScreenplaySearch from "./ScreenplaySearch"; +import AnalyticsModal from "@components/analytics/AnalyticsModal"; + +import navbar from "./ProjectNavbar.module.css"; +import navBtn from "@components/utils/NavbarIconButton.module.css"; + +/** Wrapper that anchors a panel (Saves/Production/ReadAloud) to its trigger. */ +const panelAnchorStyle: React.CSSProperties = { + position: "relative", + height: "100%", + width: "fit-content", + display: "flex", + alignItems: "center", +}; + +/** + * Desktop/web project navbar: back button + title + history/production/read-aloud + * on the left, the format dropdown centred, collaborators/search/analytics/settings + * on the right. The phone layout lives in [ProjectNavbarMobile]; both draw shared + * project state from {@link useProjectNavbar}. + */ +const ProjectNavbarDesktop = () => { + const t = useTranslations("navbar"); + + const { + openDashboard, + membership, + userCtx, + isPro, + projectId, + isInProject, + canUploadToCloud, + projectTitle, + onTitleChange, + onTitleBlur, + backToProjects, + } = useProjectNavbar(); + + const [isSavesOpen, setIsSavesOpen] = useState(false); + const [isProductionOpen, setIsProductionOpen] = useState(false); + const [isReadAloudOpen, setIsReadAloudOpen] = useState(false); + const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); + + return ( + + ); +}; + +export default ProjectNavbarDesktop; diff --git a/components/navbar/ProjectNavbarMobile.tsx b/components/navbar/ProjectNavbarMobile.tsx new file mode 100644 index 00000000..6bae196f --- /dev/null +++ b/components/navbar/ProjectNavbarMobile.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { + AudioLines, + BarChart2, + Check, + CircleArrowLeft, + CloudUpload, + History, + Info, + LogIn, + LogOut, + Lock, + Menu, + Monitor, + Redo2, + Undo2, +} from "lucide-react"; + +import { useViewContext } from "@src/context/ViewContext"; +import { useActiveEditor } from "@src/lib/editor/use-active-editor"; +import { uploadToCloudPopup } from "@src/lib/screenplay/popup"; +import { join } from "@src/lib/utils/misc"; + +import { useProjectNavbar } from "./useProjectNavbar"; +import { StatusIndicator } from "./ProjectNavbarShared"; +import ProjectNavbarMobileMenu from "./ProjectNavbarMobileMenu"; +import SavesPanel from "./SavesPanel"; +import ProductionPanel from "./ProductionPanel"; +import ReadAloudPanel from "./ReadAloudPanel"; +import ScreenplaySearch from "./ScreenplaySearch"; +import AnalyticsModal from "@components/analytics/AnalyticsModal"; + +import navbar from "./ProjectNavbar.module.css"; +import mobileMenu from "./ProjectNavbarMobileMenu.module.css"; +import navBtn from "@components/utils/NavbarIconButton.module.css"; + +/** + * Phone project navbar: a slim two-cluster bar (edit-mode controls + search/burger) + * whose burger opens the full menu that the desktop spreads across the bar and the + * dashboard sidebar. Shared project state (title, sign-out, dashboard menu) comes + * from {@link useProjectNavbar}; the desktop layout lives in [ProjectNavbar]. + */ +const ProjectNavbarMobile = () => { + const t = useTranslations("navbar"); + const tModal = useTranslations("modal"); + const tSidebar = useTranslations("sidebar"); + + const { + openDashboard, + membership, + userCtx, + canUploadToCloud, + dashboardMenu, + isSignedIn, + projectId, + isInProject, + isPro, + projectTitle, + onTitleChange, + onTitleBlur, + backToProjects, + onSignOut, + } = useProjectNavbar(); + + const { setLeftSidebarOpen, setRightSidebarOpen, chromeHidden, mobileEditMode, setMobileEditMode } = + useViewContext(); + const activeEditor = useActiveEditor(); + + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isSavesOpen, setIsSavesOpen] = useState(false); + const [isProductionOpen, setIsProductionOpen] = useState(false); + const [isReadAloudOpen, setIsReadAloudOpen] = useState(false); + const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); + + // Undo/redo are backed by the collaboration UndoManager. Guard on the command + // existing so calling before Yjs is ready can't throw. + const runHistory = (action: "undo" | "redo") => { + if (activeEditor && typeof activeEditor.commands[action] === "function") { + activeEditor.chain().focus()[action]().run(); + } + }; + + // Leave edit mode: blur so the keyboard dismisses immediately (the panel's + // setEditable(false) also does this, but blur here makes it instant). + const exitEditMode = () => { + activeEditor?.commands.blur(); + setMobileEditMode(false); + }; + + return ( + + ); +}; + +export default ProjectNavbarMobile; diff --git a/components/navbar/ProjectNavbarShared.tsx b/components/navbar/ProjectNavbarShared.tsx new file mode 100644 index 00000000..2feed114 --- /dev/null +++ b/components/navbar/ProjectNavbarShared.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useContext, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { CircleCheckBig, WifiOff, WifiSync } from "lucide-react"; + +import { ProjectContext } from "@src/context/ProjectContext"; +import { useProjectIdFromUrl } from "@src/lib/utils/hooks"; +import { ConnectionStatus } from "@src/lib/utils/enums"; +import type { StorageUsage } from "@src/lib/assets/cloud-asset-sync"; + +import navbar from "./ProjectNavbar.module.css"; + +/** + * Presentational navbar pieces shared by the desktop bar ([ProjectNavbar]) and + * the phone bar ([ProjectNavbarMobile]). Kept here so neither layout has to reach + * into the other's file and the two can't drift. + */ + +/** Human-readable byte size, e.g. 1.4 GB. */ +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let i = 0; + while (value >= 1024 && i < units.length - 1) { + value /= 1024; + i++; + } + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`; +}; + +/** Last fetched usage, so re-hovering shows the previous numbers immediately (the + * panel unmounts on mouse-leave) instead of placeholders. Tagged with its project + * id so switching projects doesn't flash the wrong project's figures. */ +let cachedStorage: { projectId: string; usage: StorageUsage } | null = null; + +const StorageUsageBody = ({ projectId }: { projectId: string }) => { + const t = useTranslations("navbar"); + const [usage, setUsage] = useState(() => + cachedStorage?.projectId === projectId ? cachedStorage.usage : null, + ); + + useEffect(() => { + let cancelled = false; + (async () => { + const { fetchProjectStorage } = await import("@src/lib/assets/cloud-asset-sync"); + const data = await fetchProjectStorage(projectId); + // Keep the last known value on a failed refresh rather than wiping it. + if (!cancelled && data) { + cachedStorage = { projectId, usage: data }; + setUsage(data); + } + })(); + return () => { + cancelled = true; + }; + }, [projectId]); + + // Render the full layout immediately (stable size); only the amounts fill in + // once fetched, so the panel doesn't grow after appearing. + const pct = usage && usage.quota > 0 ? Math.min(100, Math.round((usage.ownerTotalUsed / usage.quota) * 100)) : 0; + + return ( + <> +
+ {t("storageProject")} + {usage ? formatBytes(usage.projectUsed) : "—"} +
+
+ {t("storageTotal")} + + {usage ? `${formatBytes(usage.ownerTotalUsed)} / ${formatBytes(usage.quota)}` : "—"} + +
+
+
+
+ + ); +}; + +/** Connection status dot with a hover panel showing sync state + storage usage. */ +export const StatusIndicator = () => { + const { connectionStatus } = useContext(ProjectContext); + const projectId = useProjectIdFromUrl(); + const [hovered, setHovered] = useState(false); + const t = useTranslations("navbar"); + const STATUS: Record = { + connected: t("synced"), + disconnected: t("noConnection"), + connecting: t("reconnecting"), + }; + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {connectionStatus === "connected" && ( + + )} + {connectionStatus === "disconnected" && ( + + )} + {connectionStatus === "connecting" && ( + + )} + {hovered && ( +
+
{STATUS[connectionStatus]}
+ {projectId && ( + <> +
+ + + )} +
+ )} +
+ ); +}; + +const getInitial = (name: string): string => { + if (!name) return "?"; + return name.charAt(0).toUpperCase(); +}; + +/** Stacked avatars of the project's collaborators (desktop only, hidden solo). */ +export const CollaboratorsDisplay = () => { + const { users } = useContext(ProjectContext); + + if (users.length <= 1) return null; + + const MAX_VISIBLE = 4; + const visibleUsers = users.slice(0, MAX_VISIBLE); + const remainingCount = users.length - MAX_VISIBLE; + + return ( +
+ {visibleUsers.map((user, index) => ( +
+ {getInitial(user.name)} +
+ ))} + {remainingCount > 0 &&
+{remainingCount}
} +
+ ); +}; diff --git a/components/navbar/useProjectNavbar.ts b/components/navbar/useProjectNavbar.ts new file mode 100644 index 00000000..586d2422 --- /dev/null +++ b/components/navbar/useProjectNavbar.ts @@ -0,0 +1,141 @@ +"use client"; + +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import debounce from "debounce"; + +import { ProjectContext } from "@src/context/ProjectContext"; +import { UserContext } from "@src/context/UserContext"; +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 { signOutAccount } from "@src/lib/utils/auth-actions"; + +/** + * State and actions the desktop bar ([ProjectNavbar]) and the phone bar + * ([ProjectNavbarMobile]) both need: the project title (with its debounced + * persistence), whether the project is local-only / cloud-uploadable, the shared + * dashboard menu, and the back/sign-out actions. Owning it in one hook keeps the + * two layouts free of duplicated effects and guarantees they stay in sync. + */ +export const useProjectNavbar = () => { + const { openDashboard } = useContext(DashboardContext); + const { project: membership, setProjectTitle: setContextTitle } = useContext(ProjectContext); + const userCtx = useContext(UserContext); + const { isPro } = useIsPro(); + const { user } = useCookieUser(); + const projectId = useProjectIdFromUrl(); + const { structure: dashboardMenu, isSignedIn } = useDashboardMenu(); + + const [projectTitle, setProjectTitle] = useState(""); + const [isLocalOnly, setIsLocalOnly] = useState(null); + const isLocalEdit = useRef(false); + + const isInProject = !!projectId; + const canUploadToCloud = !membership && !!user && isPro && !!projectId && isLocalOnly === true; + + const deferredTitleUpdate = useMemo( + () => + debounce(async (projectId: string, newTitle: string) => { + const { isLocalOnlyProject, updateCachedProject } = + await import("@src/lib/persistence/storage-provider/local-persistence"); + if (await isLocalOnlyProject(projectId)) { + await updateCachedProject(projectId, { title: newTitle }); + } else { + await editProject(projectId, { title: newTitle }); + await updateCachedProject(projectId, { title: newTitle }); + } + }, 1000), + [], + ); + + // Resolve whether the open project lives only on this device (drives the + // cloud-upload affordance and the local-project status glyph). + useEffect(() => { + if (!projectId) { + setIsLocalOnly(null); + return; + } + let cancelled = false; + (async () => { + const { isLocalOnlyProject, cachedProjectExists } = + await import("@src/lib/persistence/storage-provider/local-persistence"); + const exists = await cachedProjectExists(projectId); + const local = exists ? await isLocalOnlyProject(projectId) : false; + if (!cancelled) setIsLocalOnly(local); + })(); + return () => { + cancelled = true; + }; + }, [projectId, membership]); + + // Load the project title from membership (cloud) or local storage. + useEffect(() => { + if (membership && !isLocalEdit.current) { + setProjectTitle(membership.project.title); + return; + } + + if (projectId && !membership) { + const loadLocalTitle = async () => { + const { isCachedProject, getCachedProject } = + await import("@src/lib/persistence/storage-provider/local-persistence"); + if (await isCachedProject(projectId)) { + const cachedProject = await getCachedProject(projectId); + if (cachedProject && !isLocalEdit.current) { + setProjectTitle(cachedProject.title); + } + } + }; + loadLocalTitle(); + } + }, [membership, projectId]); + + // Mirror the project title into the browser tab. + useEffect(() => { + if (projectTitle && isInProject) { + document.title = `${projectTitle}`; + } + }, [projectTitle, isInProject]); + + const onTitleChange = (value: string) => { + if (!projectId) return; + isLocalEdit.current = true; + setProjectTitle(value); + setContextTitle(value); + deferredTitleUpdate(projectId, value); + }; + const onTitleBlur = () => { + 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(); + + // Sign out via the shared flow, then land on the home/landing screen. + const onSignOut = async () => { + await signOutAccount(); + redirectHome(); + }; + + return { + openDashboard, + membership, + userCtx, + isPro, + user, + projectId, + isInProject, + canUploadToCloud, + dashboardMenu, + isSignedIn, + projectTitle, + onTitleChange, + onTitleBlur, + backToProjects, + onSignOut, + }; +}; diff --git a/components/project/ProjectWorkspace.module.css b/components/project/ProjectWorkspace.module.css index 589d03df..262c137e 100644 --- a/components/project/ProjectWorkspace.module.css +++ b/components/project/ProjectWorkspace.module.css @@ -97,7 +97,9 @@ * clears the home indicator via the safe-area inset. */ .edit_fab { position: fixed; - right: calc(20px + var(--safe-right)); + /* Anchored bottom-LEFT: the right edge is the scroll drag-handle's travel + * path, so a right-side FAB would sit under the thumb. */ + left: calc(20px + var(--safe-left)); bottom: calc(20px + var(--safe-bottom)); z-index: 55; diff --git a/components/project/ProjectWorkspace.tsx b/components/project/ProjectWorkspace.tsx index eaee10ed..f041934e 100644 --- a/components/project/ProjectWorkspace.tsx +++ b/components/project/ProjectWorkspace.tsx @@ -32,12 +32,17 @@ const ProjectWorkspace = () => { const activeEditor = useActiveEditor(); // Enter edit mode and drop the caret into the reader's current editor so the - // keyboard comes up straight away. setEditable flips in a DocumentEditorPanel - // effect after this render, so defer the focus a tick until it's editable. + // keyboard comes up straight away. Focus must happen SYNCHRONOUSLY inside this + // tap gesture — iOS only raises the keyboard when focus() runs in the same + // user-gesture turn, so we flip setEditable(true) and focus right here rather + // than deferring to the mobileEditMode effect (which runs after this render). const enterEditMode = () => { setMobileEditMode(true); const editor = activeEditor; - if (editor) setTimeout(() => editor.commands.focus(), 0); + if (editor) { + editor.setEditable(true); + editor.commands.focus(); + } }; // The pen shows on phone in reader mode, only when there's an editable text diff --git a/src/lib/utils/auth-actions.ts b/src/lib/utils/auth-actions.ts new file mode 100644 index 00000000..0c191a67 --- /dev/null +++ b/src/lib/utils/auth-actions.ts @@ -0,0 +1,25 @@ +import { signOut } from "next-auth/react"; +import { isTauri } from "@tauri-apps/api/core"; +import { mutate } from "swr"; + +/** + * Sign the current account out, everywhere the app tracks a session. + * + * Desktop (Tauri) holds a bearer token in its local secure store and the server + * has no cookie to clear, so it wipes that token; web clears the NextAuth session + * cookie. Either way the cached cookie-user is then invalidated so the UI re-reads + * a signed-out state. Callers own what happens next (redirect home, close a modal, + * …) — this only tears the session down. + * + * Single source of truth for the log-out flow, shared by the dashboard sidebar and + * the phone navbar menu so they can never drift apart. + */ +export async function signOutAccount(): Promise { + if (isTauri()) { + const { clearDesktopToken } = await import("@src/lib/desktop-auth"); + await clearDesktopToken(); + } else { + await signOut({ redirect: false }); + } + await mutate("/api/users/cookie", undefined); +}