Compare commits

...
5 changed files with 429 additions and 69 deletions
@@ -0,0 +1,101 @@
.landing-dropzone {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: min(92%, 32rem);
max-width: calc(100% - 1.5rem);
height: calc(100% - 1rem);
border-radius: 0.25rem 0.25rem 0 0;
display: flex;
align-items: center;
justify-content: center;
filter: var(--drop-shadow-filter);
background-color: var(--landing-paper-bg);
transition: background-color 0.4s ease;
}
.landing-dropzone__sheet {
position: relative;
width: min(100%, 26rem);
max-width: min(100%, 26rem);
margin: 0 auto;
padding: clamp(1.5rem, 4vw, 2.25rem);
border-radius: 0.5rem;
border: 1px solid var(--landing-inner-paper-border);
background-color: var(--landing-inner-paper-bg);
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(1rem, 2.5vw, 1.5rem);
min-height: clamp(22rem, 45vh, 30rem);
box-sizing: border-box;
}
.landing-dropzone__badge {
position: absolute;
top: clamp(0.75rem, 2vw, 1.25rem);
right: clamp(0.75rem, 2vw, 1.25rem);
height: clamp(2rem, 4vw, 2.5rem);
width: auto;
pointer-events: none;
}
.landing-dropzone__body {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(1rem, 2.5vw, 1.5rem);
}
.landing-dropzone__brand {
justify-content: center;
}
.landing-dropzone__actions {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
width: 100%;
max-width: min(100%, 22rem);
margin: clamp(0.5rem, 2vw, 0.9rem) auto;
}
.landing-dropzone__hint {
font-size: 0.8rem;
color: var(--accent-interactive);
text-align: center;
}
@media (max-width: 900px) {
.landing-dropzone {
width: min(94%, 28rem);
height: calc(100% - 0.75rem);
}
.landing-dropzone__sheet {
max-width: min(100%, 24rem);
padding: clamp(1.35rem, 5vw, 2rem);
min-height: clamp(20rem, 55vh, 28rem);
}
.landing-dropzone__actions {
max-width: min(100%, 18rem);
}
}
@media (min-width: 1100px) {
.landing-dropzone {
width: min(92%, 34rem);
}
.landing-dropzone__sheet {
max-width: min(100%, 30rem);
}
.landing-dropzone__actions {
max-width: min(100%, 24rem);
}
}
+10 -50
View File
@@ -7,6 +7,8 @@ import { useFileHandler } from '../../hooks/useFileHandler';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { BASE_PATH } from '../../constants/app';
import './LandingPage.css';
const LandingPage = () => {
const { addFiles } = useFileHandler();
const fileInputRef = React.useRef<HTMLInputElement>(null);
@@ -43,17 +45,7 @@ const LandingPage = () => {
onDrop={handleFileDrop}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95%]"
style={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
bottom: 0,
borderRadius: '0.25rem 0.25rem 0 0',
filter: 'var(--drop-shadow-filter)',
backgroundColor: 'var(--landing-paper-bg)',
transition: 'background-color 0.4s ease',
}}
className="landing-dropzone"
activateOnClick={false}
styles={{
root: {
@@ -63,41 +55,18 @@ const LandingPage = () => {
},
}}
>
<div
style={{
position: 'absolute',
top: 0,
right: 0,
zIndex: 10,
}}
>
<div className="landing-dropzone__sheet dropzone-inner">
<img
className="landing-dropzone__badge"
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
alt="Stirling PDF Logo"
style={{
height: 'auto',
pointerEvents: 'none',
}}
/>
</div>
<div
className={`min-h-[45vh] flex flex-col items-center justify-center px-8 py-8 w-full min-w-[30rem] max-w-[calc(100%-2rem)] border transition-all duration-200 dropzone-inner relative`}
style={{
borderRadius: '0.5rem',
backgroundColor: 'var(--landing-inner-paper-bg)',
borderColor: 'var(--landing-inner-paper-border)',
borderWidth: '1px',
borderStyle: 'solid',
}}
>
{/* Logo positioned absolutely in top right corner */}
{/* Centered content container */}
<div className="flex flex-col items-center gap-4 flex-none w-full">
<div className="landing-dropzone__body">
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<Group gap="xs" align="center" className="landing-dropzone__brand">
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
alt="Stirling PDF"
@@ -107,15 +76,7 @@ const LandingPage = () => {
{/* Add Files + Native Upload Buttons */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
width: '80%',
marginTop: '0.8rem',
marginBottom: '0.8rem'
}}
className="landing-dropzone__actions"
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
@@ -152,7 +113,7 @@ const LandingPage = () => {
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
@@ -187,8 +148,7 @@ const LandingPage = () => {
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem' }}
className="landing-dropzone__hint"
>
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
</span>
+6 -4
View File
@@ -8,6 +8,7 @@ import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
import { ScrollArea } from '@mantine/core';
import { ToolId } from '../../types/toolId';
import { useMediaQuery } from '@mantine/hooks';
// No props needed - component uses context
@@ -15,6 +16,7 @@ export default function ToolPanel() {
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs;
const isMobile = useMediaQuery('(max-width: 1024px)');
// Use context-based hooks to eliminate prop drilling
@@ -34,17 +36,17 @@ export default function ToolPanel() {
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
className={`flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ''
}`}
} ${isMobile ? 'h-full border-r-0' : 'h-screen'}`}
style={{
width: isPanelVisible ? '18.5rem' : '0',
width: isMobile ? '100%' : isPanelVisible ? '18.5rem' : '0',
padding: '0'
}}
>
<div
style={{
opacity: isPanelVisible ? 1 : 0,
opacity: isMobile || isPanelVisible ? 1 : 0,
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
height: '100%',
display: 'flex',
+118
View File
@@ -0,0 +1,118 @@
.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.4rem;
min-width: 0;
}
.mobile-brand-mark {
height: 1.25rem;
width: auto;
}
.mobile-brand-name {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.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;
}
.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;
}
+194 -15
View File
@@ -1,15 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { Group } from "@mantine/core";
import { ActionIcon, Group, useMantineColorScheme } from "@mantine/core";
import { useSidebarContext } from "../contexts/SidebarContext";
import { useDocumentMeta } from "../hooks/useDocumentMeta";
import { getBaseUrl } from "../constants/app";
import { BASE_PATH, getBaseUrl } from "../constants/app";
import { useMediaQuery } from "@mantine/hooks";
import ToolPanel from "../components/tools/ToolPanel";
import Workbench from "../components/layout/Workbench";
import QuickAccessBar from "../components/shared/QuickAccessBar";
import RightRail from "../components/shared/RightRail";
import FileManager from "../components/FileManager";
import LocalIcon from "../components/shared/LocalIcon";
import { useFilesModalContext } from "../contexts/FilesModalContext";
import "./HomePage.css";
type MobileView = "tools" | "workbench";
export default function HomePage() {
@@ -22,6 +30,106 @@ export default function HomePage() {
const { selectedTool, selectedToolKey } = useToolWorkflow();
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const isMobile = useMediaQuery("(max-width: 900px)");
const sliderRef = useRef<HTMLDivElement | null>(null);
const slideRefs = useRef<Record<MobileView, HTMLDivElement | null>>({
tools: null,
workbench: null,
});
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const brandName = t("home.mobile.brandName", "Stirling");
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandMarkSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const scrollToMobileView = useCallback(
(view: MobileView, behavior: ScrollBehavior = "smooth") => {
const container = sliderRef.current;
const target = slideRefs.current[view];
if (!container || !target) {
return;
}
if (behavior === "auto") {
container.scrollLeft = target.offsetLeft;
return;
}
if (typeof target.scrollIntoView === "function") {
target.scrollIntoView({
behavior,
block: "nearest",
inline: "start",
});
return;
}
if (typeof container.scrollTo === "function") {
container.scrollTo({ left: target.offsetLeft, behavior });
return;
}
container.scrollLeft = target.offsetLeft;
},
[]
);
const handleSelectMobileView = useCallback(
(view: MobileView) => {
scrollToMobileView(view);
setActiveMobileView(view);
},
[scrollToMobileView]
);
useEffect(() => {
if (isMobile) {
scrollToMobileView(activeMobileView, "auto");
return;
}
if (activeMobileView !== "tools") {
setActiveMobileView("tools");
}
scrollToMobileView("tools", "auto");
}, [activeMobileView, isMobile, scrollToMobileView]);
useEffect(() => {
if (!isMobile) return;
const container = sliderRef.current;
if (!container) return;
let animationFrame = 0;
const handleScroll = () => {
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]);
const baseUrl = getBaseUrl();
// Update document meta when tool changes
@@ -38,19 +146,90 @@ export default function HomePage() {
return (
<div className="h-screen overflow-hidden">
<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>
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
<div className="mobile-header">
<div className="mobile-brand">
<img src={brandMarkSrc} alt={brandAltText} className="mobile-brand-mark" />
<span className="mobile-brand-name">{brandName}</span>
</div>
<ActionIcon
variant="subtle"
size="md"
aria-label={t('home.mobile.openFiles', 'Open files')}
onClick={() => openFilesModal()}
>
<LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />
</ActionIcon>
</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')}
ref={(node) => {
slideRefs.current.tools = node;
}}
>
<div className="mobile-slide-content">
<ToolPanel />
</div>
</div>
<div
className="mobile-slide"
aria-label={t('home.mobile.workbenchSlide', 'Workspace panel')}
ref={(node) => {
slideRefs.current.workbench = node;
}}
>
<div className="mobile-slide-content">
<div className="flex-1 min-h-0 flex flex-col">
<Workbench />
</div>
</div>
</div>
</div>
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</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>
);
}