Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
+170
View File
@@ -0,0 +1,170 @@
.mobile-layout {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background-color: var(--bg-background);
}
.mobile-toggle {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.mobile-brand {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.mobile-brand-icon {
height: 1.5rem;
width: auto;
flex-shrink: 0;
}
.mobile-brand-text {
height: 1.5rem;
width: auto;
max-width: 100%;
}
.mobile-toggle-buttons {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.2rem;
border-radius: 9999px;
background: var(--bg-background);
border: 1px solid var(--border-subtle);
}
.mobile-toggle-button {
border: none;
border-radius: 9999px;
padding: 0.4rem 0.9rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
background: transparent;
transition:
background 0.2s ease,
color 0.2s ease;
}
.mobile-toggle-button:focus-visible {
outline: 2px solid var(--primary-color, #228be6);
outline-offset: 2px;
}
.mobile-toggle-button.active {
background: var(--primary-surface, rgba(34, 139, 230, 0.12));
color: var(--text-primary);
}
.mobile-toggle-hint {
font-size: 0.7rem;
color: var(--text-muted);
text-align: center;
}
.mobile-slider {
display: flex;
flex: 1 1 auto;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
-ms-overflow-style: none;
touch-action: pan-x pinch-zoom;
}
.mobile-slider::-webkit-scrollbar {
display: none;
}
.mobile-slide {
flex: 0 0 100%;
width: 100%;
height: 100%;
scroll-snap-align: start;
display: flex;
flex-direction: column;
min-height: 0;
}
.mobile-slide-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.mobile-slide-content > * {
flex: 1 1 auto;
min-height: 0;
}
.mobile-bottom-bar {
display: flex;
align-items: center;
justify-content: space-around;
padding: 0.5rem;
border-top: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
gap: 0.5rem;
position: relative;
z-index: 10;
touch-action: manipulation;
}
.mobile-bottom-button {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.5rem;
border: none;
background: transparent;
color: var(--text-primary);
cursor: pointer;
border-radius: 0.5rem;
transition: background 0.2s ease;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
min-height: 44px;
}
@media (hover: hover) and (pointer: fine) {
.mobile-bottom-button:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
}
}
.mobile-bottom-button:active {
background: var(--bg-active, rgba(0, 0, 0, 0.1));
}
.mobile-bottom-button-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
}
+394
View File
@@ -0,0 +1,394 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { Group } from "@mantine/core";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { LogoIcon } from "@app/components/shared/LogoIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
import { useFileContext } from "@app/contexts/file/fileHooks";
import {
useNavigationState,
useNavigationActions,
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useLocation } from "react-router-dom";
import AppsIcon from "@mui/icons-material/AppsRounded";
import ToolPanel from "@app/components/tools/ToolPanel";
import Workbench from "@app/components/layout/Workbench";
import FileSidebar from "@app/components/shared/FileSidebar";
import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModalLazy";
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
import "@app/pages/HomePage.css";
type MobileView = "tools" | "workbench";
export default function HomePage() {
const { t } = useTranslation();
const { sidebarRefs } = useSidebarContext();
const { quickAccessRef } = sidebarRefs;
const {
selectedTool,
selectedToolKey,
handleToolSelect,
handleBackToTools,
readerMode,
setLeftPanelView,
toolAvailability,
customWorkbenchViews,
} = useToolWorkflow();
const { openFilesModal } = useFilesModalContext();
const { config } = useAppConfig();
const isMobile = useIsMobile();
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(false);
const location = useLocation();
// Open the config modal whenever the URL is /settings/* (e.g. from the admin
// tour's openConfigModal action which navigates to /settings/overview).
useEffect(() => {
const isSettings = location.pathname.startsWith("/settings");
setConfigModalOpen(isSettings);
}, [location.pathname]);
const { activeFiles } = useFileContext();
const navigationState = useNavigationState();
const { actions } = useNavigationActions();
const { setActiveFileIndex } = useViewer();
const prevFileCountRef = useRef(activeFiles.length);
// Startup/open transition behavior:
// - opening exactly 1 file from empty -> viewer (unless already in fileEditor)
// - opening 2+ files from empty -> fileEditor
useEffect(() => {
const prevCount = prevFileCountRef.current;
const currentCount = activeFiles.length;
console.log("[HomePage] Navigation effect triggered:", {
prevCount,
currentCount,
currentWorkbench: navigationState.workbench,
selectedToolKey,
});
const action = getStartupNavigationAction(
prevCount,
currentCount,
selectedToolKey,
navigationState.workbench,
);
console.log("[HomePage] Navigation action returned:", action);
if (action) {
console.log("[HomePage] Applying navigation:", action);
actions.setWorkbench(action.workbench);
if (typeof action.activeFileIndex === "number") {
setActiveFileIndex(action.activeFileIndex);
}
} else {
console.log("[HomePage] No navigation - staying in current workbench");
}
prevFileCountRef.current = currentCount;
}, [
activeFiles.length,
actions,
setActiveFileIndex,
selectedToolKey,
navigationState.workbench,
]);
const hideToolPanel =
customWorkbenchViews.find(
(v) => v.workbenchId === navigationState.workbench,
)?.hideToolPanel ?? false;
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const handleSelectMobileView = useCallback((view: MobileView) => {
setActiveMobileView(view);
}, []);
useEffect(() => {
if (isMobile) {
const container = sliderRef.current;
if (container) {
isProgrammaticScroll.current = true;
const offset = activeMobileView === "tools" ? 0 : container.offsetWidth;
container.scrollTo({ left: offset, behavior: "smooth" });
// Re-enable scroll listener after animation completes
setTimeout(() => {
isProgrammaticScroll.current = false;
}, 500);
}
return;
}
setActiveMobileView("tools");
const container = sliderRef.current;
if (container) {
container.scrollTo({ left: 0, behavior: "auto" });
}
}, [activeMobileView, isMobile]);
useEffect(() => {
if (!isMobile) return;
const container = sliderRef.current;
if (!container) return;
let animationFrame = 0;
const handleScroll = () => {
if (isProgrammaticScroll.current) {
return;
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
animationFrame = window.requestAnimationFrame(() => {
const { scrollLeft, offsetWidth } = container;
const threshold = offsetWidth / 2;
const nextView: MobileView =
scrollLeft >= threshold ? "workbench" : "tools";
setActiveMobileView((current) =>
current === nextView ? current : nextView,
);
});
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
}, [isMobile]);
// Automatically switch to workbench when read mode or multiTool is activated in mobile
useEffect(() => {
if (isMobile && (readerMode || selectedToolKey === "multiTool")) {
setActiveMobileView("workbench");
}
}, [isMobile, readerMode, selectedToolKey]);
// Automatically switch to workbench slide when a custom workbench (e.g. signing) is active on mobile.
// hideToolPanel is true for all custom workbenches that take over the full screen.
useEffect(() => {
if (isMobile && hideToolPanel) {
setActiveMobileView("workbench");
}
}, [isMobile, hideToolPanel]);
// When navigating back to tools view in mobile with a workbench-only tool, show tool picker
useEffect(() => {
if (isMobile && activeMobileView === "tools" && selectedTool) {
// Check if this is a workbench-only tool (has workbench but no component)
if (selectedTool.workbench && !selectedTool.component) {
setLeftPanelView("toolPicker");
}
}
}, [isMobile, activeMobileView, selectedTool, setLeftPanelView]);
const baseUrl = useBaseUrl();
// Update document meta when tool changes
const appName = config?.appNameNavbar || "Stirling PDF";
useDocumentMeta({
title: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
description:
selectedTool?.description ||
t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
ogDescription:
selectedTool?.description ||
t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: selectedToolKey
? `${baseUrl}/og_images/${selectedToolKey}.png`
: `${baseUrl}/og_images/home.png`,
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl,
});
// Note: File selection limits are now handled directly by individual tools
return (
<div className="h-screen overflow-hidden">
<HomePageExtensions />
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
<div className="mobile-header">
<div className="mobile-brand">
<LogoIcon className="mobile-brand-icon" />
<Wordmark alt={brandAltText} className="mobile-brand-text" />
</div>
</div>
<div
className="mobile-toggle-buttons"
role="tablist"
aria-label={t(
"home.mobile.viewSwitcher",
"Switch workspace view",
)}
>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "tools"}
className={`mobile-toggle-button ${activeMobileView === "tools" ? "active" : ""}`}
onClick={() => handleSelectMobileView("tools")}
>
{t("home.mobile.tools", "Tools")}
</button>
<button
type="button"
role="tab"
aria-selected={activeMobileView === "workbench"}
className={`mobile-toggle-button ${activeMobileView === "workbench" ? "active" : ""}`}
onClick={() => handleSelectMobileView("workbench")}
>
{t("home.mobile.workspace", "Workspace")}
</button>
</div>
<span className="mobile-toggle-hint">
{t(
"home.mobile.swipeHint",
"Swipe left or right to switch views",
)}
</span>
</div>
<div ref={sliderRef} className="mobile-slider">
<div
className="mobile-slide"
aria-label={t("home.mobile.toolsSlide", "Tool selection panel")}
>
<div className="mobile-slide-content">
<ToolPanel />
</div>
</div>
<div
className="mobile-slide"
aria-label={t("home.mobile.workbenchSlide", "Workspace panel")}
>
<div className="mobile-slide-content">
<div className="flex-1 min-h-0 flex">
<Workbench />
</div>
</div>
</div>
</div>
<div className="mobile-bottom-bar">
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.allTools", "Tools")}
onClick={() => {
handleBackToTools();
if (isMobile) {
setActiveMobileView("tools");
}
}}
>
<AppsIcon sx={{ fontSize: "1.5rem" }} />
<span className="mobile-bottom-button-label">
{t("quickAccess.allTools", "Tools")}
</span>
</button>
{toolAvailability["automate"]?.available !== false && (
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.automate", "Automate")}
onClick={() => {
handleToolSelect("automate");
if (isMobile) {
setActiveMobileView("tools");
}
}}
>
<LocalIcon
icon="automation-outline"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.automate", "Automate")}
</span>
</button>
)}
<button
className="mobile-bottom-button"
aria-label={t("home.mobile.openFiles", "Open files")}
onClick={() => openFilesModal()}
>
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">
{t("quickAccess.files", "Files")}
</span>
</button>
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.config", "Config")}
onClick={() => setConfigModalOpen(true)}
>
<LocalIcon
icon="settings-rounded"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.config", "Config")}
</span>
</button>
</div>
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</div>
) : (
<Group align="flex-start" gap={0} h="100%" className="flex-nowrap flex">
<FileSidebar
ref={quickAccessRef}
collapsed={fileSidebarCollapsed}
onToggleCollapse={() => setFileSidebarCollapsed((c) => !c)}
onOpenSettings={() => setConfigModalOpen(true)}
/>
<Workbench />
{!hideToolPanel && <ToolPanel />}
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</Group>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff