Restructure frontend code to allow for extensions (#4721)

# Description of Changes
Move frontend code into `core` folder and add infrastructure for
`proprietary` folder to include premium, non-OSS features
This commit is contained in:
James Brunton
2025-10-28 10:29:36 +00:00
committed by GitHub
parent 960d48f80c
commit d2b38ef4b8
725 changed files with 2485 additions and 2226 deletions
+168
View File
@@ -0,0 +1,168 @@
.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);
}
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { Group, useMantineColorScheme } from "@mantine/core";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { BASE_PATH } from "@app/constants/app";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useMediaQuery } from "@mantine/hooks";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "@app/components/tools/ToolPanel";
import Workbench from "@app/components/layout/Workbench";
import QuickAccessBar from "@app/components/shared/QuickAccessBar";
import RightRail from "@app/components/shared/RightRail";
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/AppConfigModal";
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
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,
} = useToolWorkflow();
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const { config } = useAppConfig();
const isMobile = useMediaQuery("(max-width: 1024px)");
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
// Show admin analytics choice modal if analytics settings not configured
useEffect(() => {
if (config && config.enableAnalytics === null) {
setShowAnalyticsModal(true);
}
}, [config]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;
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]);
// 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
useDocumentMeta({
title: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF',
description: selectedTool?.description || t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF',
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">
<AdminAnalyticsChoiceModal
opened={showAnalyticsModal}
onClose={() => setShowAnalyticsModal(false)}
/>
<ToolPanelModePrompt />
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
<div className="mobile-header">
<div className="mobile-brand">
<img src={brandIconSrc} alt="" className="mobile-brand-icon" />
<img src={brandTextSrc} 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 />
<RightRail />
</div>
</div>
</div>
</div>
<div className="mobile-bottom-bar">
<button
className="mobile-bottom-button"
aria-label={t('quickAccess.allTools', 'All Tools')}
onClick={() => {
handleBackToTools();
if (isMobile) {
setActiveMobileView('tools');
}
}}
>
<AppsIcon sx={{ fontSize: '1.5rem' }} />
<span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'All Tools')}</span>
</button>
<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"
>
<QuickAccessBar ref={quickAccessRef} />
<ToolPanel />
<Workbench />
<RightRail />
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</Group>
)}
</div>
);
}