fix many UI issues (#6569)

# Description of Changes

- Tool action button truncation - fixed by allowing Mantine <Button>
label to wrap (whiteSpace: normal, height: auto) instead of clipping
- Role badge truncation on People page - fixed by dropping the column's
fixed w={100} and letting the badge size to its content
- Settings nav item wraps to 3 lines - fixed by hiding the inline ALPHA
badge by default and revealing it on :hover/:focus-within/.active
- Zoom slider cramped on narrow desktop - fixed by removing the
toolbar's hardcoded minWidth: 30rem and giving the slider flexShrink: 0
+ minWidth: 6rem
- "Swipe left or right" hint on desktop - fixed by adding a useIsTouch()
hook (pointer: coarse) and gating the hint on isMobile && isTouch
- Logout doesn't redirect - fixed by replacing navigate('/login') with
window.location.assign('/login') in a finally block so auth context
fully re-bootstraps
- Viewer top toolbar clips icons on mobile - fixed by switching the
wrapped state to justify-content: flex-start + overflow-x: auto so the
icon strip is momentum-scrollable
- Mobile bottom toolbar overflows - fixed by gating layout on
useIsPhone() and reducing the inline bar to prev / page / next / ⋮ only
- Lost controls when shrinking mobile toolbar - fixed by adding a
Mantine <Menu> behind ⋮ that groups First/Last page, Zoom in/out (with
%), Dual-page, Dark/Sepia filter under Page navigation / Zoom / View
labels
- "Upload from computer" label clipped on hover - fixed by unmounting
the Add Files button entirely while Upload is hovered, so Upload claims
width: 100%
- Settings rows clip controls off-screen - fixed by adding flex: 1,
minWidth: 0 to the inner text-block <div> on 44 rows across 10 -files,
so labels shrink and wrap while controls stay anchored to the right

---
Screenshots 

[report-before-after.html](https://github.com/user-attachments/files/28687621/report-before-after.html)

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-06-09 13:18:57 +00:00
committed by GitHub
parent 800a411167
commit 347ae9ebbf
21 changed files with 399 additions and 215 deletions
@@ -8643,12 +8643,16 @@ enableDarkFilter = "Enable Dark Filter"
enableSepiaFilter = "Enable Sepia Filter" enableSepiaFilter = "Enable Sepia Filter"
firstPage = "First Page" firstPage = "First Page"
lastPage = "Last Page" lastPage = "Last Page"
moreOptions = "More"
nextPage = "Next Page" nextPage = "Next Page"
onlyPdfSupported = "This file format is not supported for preview." onlyPdfSupported = "This file format is not supported for preview."
pageNavigation = "Page navigation"
previousPage = "Previous Page" previousPage = "Previous Page"
resetZoom = "Reset zoom" resetZoom = "Reset zoom"
singlePageView = "Single Page View" singlePageView = "Single Page View"
unknownFile = "Unknown file" unknownFile = "Unknown file"
view = "View"
zoom = "Zoom"
zoomIn = "Zoom In" zoomIn = "Zoom In"
zoomOut = "Zoom Out" zoomOut = "Zoom Out"
@@ -104,44 +104,44 @@ const AddFileCard = ({
}} }}
onMouseLeave={() => setIsUploadHover(false)} onMouseLeave={() => setIsUploadHover(false)}
> >
<Button {!isUploadHover && (
style={{ <Button
backgroundColor: "var(--landing-button-bg)", style={{
color: "var(--landing-button-color)", backgroundColor: "var(--landing-button-bg)",
border: "1px solid var(--landing-button-border)", color: "var(--landing-button-color)",
borderRadius: "2rem", border: "1px solid var(--landing-button-border)",
height: "38px", borderRadius: "2rem",
paddingLeft: isUploadHover ? 0 : "1rem", height: "38px",
paddingRight: isUploadHover ? 0 : "1rem", paddingLeft: "1rem",
width: isUploadHover ? "58px" : "calc(100% - 58px - 0.6rem)", paddingRight: "1rem",
minWidth: isUploadHover ? "58px" : undefined, width: "calc(100% - 58px - 0.6rem)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
transition: "width .5s ease, padding .5s ease", transition: "width .5s ease, padding .5s ease",
}} }}
onClick={handleOpenFilesModal} onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)} onMouseEnter={() => setIsUploadHover(false)}
> >
<LocalIcon <LocalIcon
icon="add" icon="add"
width="1.5rem" width="1.5rem"
height="1.5rem" height="1.5rem"
className="text-[var(--accent-interactive)]" className="text-[var(--accent-interactive)]"
/> />
{!isUploadHover && (
<span>{t("landing.addFiles", "Add Files")}</span> <span>{t("landing.addFiles", "Add Files")}</span>
)} </Button>
</Button> )}
<Button <Button
aria-label={t("addFileCard.upload", "Upload")} aria-label={t("addFileCard.upload", "Upload")}
title={terminology.uploadFromComputer}
style={{ style={{
backgroundColor: "var(--landing-button-bg)", backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)", color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)", border: "1px solid var(--landing-button-border)",
borderRadius: "1rem", borderRadius: "1rem",
height: "38px", height: "38px",
width: isUploadHover ? "calc(100% - 58px - 0.6rem)" : "58px", width: isUploadHover ? "100%" : "58px",
minWidth: "58px", minWidth: "58px",
paddingLeft: isUploadHover ? "1rem" : 0, paddingLeft: isUploadHover ? "1rem" : 0,
paddingRight: isUploadHover ? "1rem" : 0, paddingRight: isUploadHover ? "1rem" : 0,
@@ -149,6 +149,7 @@ const AddFileCard = ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
transition: "width .5s ease, padding .5s ease", transition: "width .5s ease, padding .5s ease",
overflow: "hidden",
}} }}
onClick={handleNativeUploadClick} onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)} onMouseEnter={() => setIsUploadHover(true)}
@@ -157,10 +158,18 @@ const AddFileCard = ({
icon={icons.uploadIconName} icon={icons.uploadIconName}
width="1.25rem" width="1.25rem"
height="1.25rem" height="1.25rem"
style={{ color: "var(--accent-interactive)" }} style={{ color: "var(--accent-interactive)", flexShrink: 0 }}
/> />
{isUploadHover && ( {isUploadHover && (
<span style={{ marginLeft: ".5rem" }}> <span
style={{
marginLeft: ".5rem",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
minWidth: 0,
}}
>
{terminology.uploadFromComputer} {terminology.uploadFromComputer}
</span> </span>
)} )}
@@ -106,6 +106,15 @@
margin-bottom: 0.25rem; /* 4px */ margin-bottom: 0.25rem; /* 4px */
} }
.modal-nav-item-badge {
display: none;
}
.modal-nav-item:hover .modal-nav-item-badge,
.modal-nav-item:focus-within .modal-nav-item-badge,
.modal-nav-item.active .modal-nav-item-badge {
display: inline-flex;
}
.modal-content { .modal-content {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -242,7 +242,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
handleNavigation(item.key); handleNavigation(item.key);
} }
}} }}
className={`modal-nav-item ${isMobile ? "mobile" : ""}`} className={`modal-nav-item ${isActive ? "active" : ""} ${isMobile ? "mobile" : ""}`}
style={{ style={{
background: isActive background: isActive
? colors.navItemActiveBg ? colors.navItemActiveBg
@@ -259,8 +259,19 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
style={{ color }} style={{ color }}
/> />
{!isMobile && ( {!isMobile && (
<Group gap={4} align="center" wrap="nowrap"> <Group
<Text size="sm" fw={500} style={{ color }}> gap={4}
align="center"
wrap="nowrap"
style={{ minWidth: 0, flex: 1 }}
>
<Text
size="sm"
fw={500}
truncate
style={{ color, minWidth: 0, flex: 1 }}
title={item.label}
>
{item.label} {item.label}
</Text> </Text>
{item.badge && ( {item.badge && (
@@ -268,6 +279,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
size="xs" size="xs"
variant="light" variant="light"
color={item.badgeColor ?? "orange"} color={item.badgeColor ?? "orange"}
className="modal-nav-item-badge"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
{item.badge} {item.badge}
@@ -97,9 +97,16 @@
order: 3; order: 3;
flex: 0 0 100%; flex: 0 0 100%;
height: auto; height: auto;
overflow: visible;
padding: 4px 0; padding: 4px 0;
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
justify-content: flex-start;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.workbench-bar[data-wrapped="true"] .workbench-bar-center > * {
flex-shrink: 0;
} }
/* ---- Right: global buttons (theme / language / download) ---- */ /* ---- Right: global buttons (theme / language / download) ---- */
@@ -492,7 +492,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t("settings.general.theme", "Theme")} {t("settings.general.theme", "Theme")}
</Text> </Text>
@@ -527,7 +527,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t("settings.general.language", "Language")} {t("settings.general.language", "Language")}
</Text> </Text>
@@ -552,7 +552,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"settings.general.defaultToolPickerMode", "settings.general.defaultToolPickerMode",
@@ -590,7 +590,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"settings.general.defaultStartupView", "settings.general.defaultStartupView",
@@ -632,7 +632,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t("settings.general.defaultViewerZoom", "Default reader zoom")} {t("settings.general.defaultViewerZoom", "Default reader zoom")}
</Text> </Text>
@@ -687,7 +687,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"settings.general.hideUnavailableTools", "settings.general.hideUnavailableTools",
@@ -718,7 +718,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"settings.general.hideUnavailableConversions", "settings.general.hideUnavailableConversions",
@@ -759,7 +759,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
cursor: "help", cursor: "help",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t("settings.general.autoUnzip", "Auto-unzip API responses")} {t("settings.general.autoUnzip", "Auto-unzip API responses")}
</Text> </Text>
@@ -796,7 +796,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
cursor: "help", cursor: "help",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"settings.general.autoUnzipFileLimit", "settings.general.autoUnzipFileLimit",
@@ -101,7 +101,7 @@ export default function ProviderCard({
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{field.label} {field.label}
</Text> </Text>
@@ -91,7 +91,18 @@ const OperationButton = ({
color={color} color={color}
data-testid={dataTestId} data-testid={dataTestId}
data-tour={dataTour} data-tour={dataTour}
style={{ minHeight: "2.5rem", position: "relative" }} style={{ minHeight: "2.5rem", height: "auto", position: "relative" }}
styles={{
label: {
whiteSpace: "normal",
textAlign: "center",
lineHeight: 1.2,
},
inner: {
paddingTop: "0.25rem",
paddingBottom: "0.25rem",
},
}}
> >
{isLoading {isLoading
? loadingText || t("loading", "Loading...") ? loadingText || t("loading", "Loading...")
@@ -4,11 +4,13 @@ import {
Button, Button,
Paper, Paper,
Group, Group,
Menu,
NumberInput, NumberInput,
Slider, Slider,
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useViewer } from "@app/contexts/ViewerContext"; import { useViewer } from "@app/contexts/ViewerContext";
import { useIsPhone } from "@app/hooks/useIsMobile";
import { Tooltip } from "@app/components/shared/Tooltip"; import { Tooltip } from "@app/components/shared/Tooltip";
import FirstPageIcon from "@mui/icons-material/FirstPage"; import FirstPageIcon from "@mui/icons-material/FirstPage";
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos"; import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
@@ -21,6 +23,7 @@ import WbSunnyIcon from "@mui/icons-material/WbSunny";
import WbTwilightIcon from "@mui/icons-material/WbTwilight"; import WbTwilightIcon from "@mui/icons-material/WbTwilight";
import ZoomInIcon from "@mui/icons-material/ZoomIn"; import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut"; import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import MoreVertIcon from "@mui/icons-material/MoreVert";
interface PdfViewerToolbarProps { interface PdfViewerToolbarProps {
// Page navigation props (placeholders for now) // Page navigation props (placeholders for now)
@@ -35,6 +38,9 @@ export function PdfViewerToolbar({
onPageChange, onPageChange,
}: PdfViewerToolbarProps) { }: PdfViewerToolbarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isPhone = useIsPhone();
const buttonMinWidth = isPhone ? "3rem" : "2.5rem";
const buttonSize = isPhone ? "lg" : "md";
const { const {
getScrollState, getScrollState,
getZoomState, getZoomState,
@@ -141,41 +147,45 @@ export function PdfViewerToolbar({
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
flexWrap: "wrap",
rowGap: 8,
gap: 10, gap: 10,
justifyContent: "center",
borderTopLeftRadius: 16, borderTopLeftRadius: 16,
borderTopRightRadius: 16, borderTopRightRadius: 16,
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
borderBottomRightRadius: 0, borderBottomRightRadius: 0,
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)", boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
pointerEvents: "auto", pointerEvents: "auto",
minWidth: "30rem",
}} }}
> >
{/* First Page Button */} {/* First Page Button */}
<Button {!isPhone && (
variant="subtle" <Button
color="blue" variant="subtle"
size="md" color="blue"
px={8} size={buttonSize}
radius="xl" px={8}
onClick={handleFirstPage} radius="xl"
disabled={scrollState.currentPage === 1} onClick={handleFirstPage}
style={{ minWidth: "2.5rem" }} disabled={scrollState.currentPage === 1}
title={t("viewer.firstPage", "First Page")} style={{ minWidth: buttonMinWidth }}
> title={t("viewer.firstPage", "First Page")}
<FirstPageIcon fontSize="small" /> >
</Button> <FirstPageIcon fontSize="small" />
</Button>
)}
{/* Previous Page Button */} {/* Previous Page Button */}
<Button <Button
variant="subtle" variant="subtle"
color="blue" color="blue"
size="md" size={buttonSize}
px={8} px={8}
radius="xl" radius="xl"
onClick={handlePreviousPage} onClick={handlePreviousPage}
disabled={scrollState.currentPage === 1} disabled={scrollState.currentPage === 1}
style={{ minWidth: "2.5rem" }} style={{ minWidth: buttonMinWidth }}
title={t("viewer.previousPage", "Previous Page")} title={t("viewer.previousPage", "Previous Page")}
> >
<ArrowBackIosIcon fontSize="small" /> <ArrowBackIosIcon fontSize="small" />
@@ -212,137 +222,242 @@ export function PdfViewerToolbar({
<Button <Button
variant="subtle" variant="subtle"
color="blue" color="blue"
size="md" size={buttonSize}
px={8} px={8}
radius="xl" radius="xl"
onClick={handleNextPage} onClick={handleNextPage}
disabled={scrollState.currentPage === scrollState.totalPages} disabled={scrollState.currentPage === scrollState.totalPages}
style={{ minWidth: "2.5rem" }} style={{ minWidth: buttonMinWidth }}
title={t("viewer.nextPage", "Next Page")} title={t("viewer.nextPage", "Next Page")}
> >
<ArrowForwardIosIcon fontSize="small" /> <ArrowForwardIosIcon fontSize="small" />
</Button> </Button>
{/* Last Page Button */} {/* Last Page Button */}
<Button {!isPhone && (
variant="subtle" <Button
color="blue" variant="subtle"
size="md" color="blue"
px={8} size={buttonSize}
radius="xl" px={8}
onClick={handleLastPage} radius="xl"
disabled={scrollState.currentPage === scrollState.totalPages} onClick={handleLastPage}
style={{ minWidth: "2.5rem" }} disabled={scrollState.currentPage === scrollState.totalPages}
title={t("viewer.lastPage", "Last Page")} style={{ minWidth: buttonMinWidth }}
> title={t("viewer.lastPage", "Last Page")}
<LastPageIcon fontSize="small" /> >
</Button> <LastPageIcon fontSize="small" />
</Button>
)}
{/* Dual Page Toggle */} {/* Dual Page Toggle */}
<Tooltip {!isPhone && (
content={ <Tooltip
isDualPageActive content={
? t("viewer.singlePageView", "Single Page View") isDualPageActive
: t("viewer.dualPageView", "Dual Page View") ? t("viewer.singlePageView", "Single Page View")
} : t("viewer.dualPageView", "Dual Page View")
position="top" }
arrow position="top"
> arrow
<Button
variant={isDualPageActive ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={handleDualPageToggle}
disabled={scrollState.totalPages <= 1}
style={{ minWidth: "2.5rem" }}
> >
{isDualPageActive ? ( <Button
<DescriptionIcon fontSize="small" /> variant={isDualPageActive ? "filled" : "light"}
) : ( color="blue"
<ViewWeekIcon fontSize="small" /> size={buttonSize}
)} radius="xl"
</Button> onClick={handleDualPageToggle}
</Tooltip> disabled={scrollState.totalPages <= 1}
style={{ minWidth: buttonMinWidth }}
>
{isDualPageActive ? (
<DescriptionIcon fontSize="small" />
) : (
<ViewWeekIcon fontSize="small" />
)}
</Button>
</Tooltip>
)}
{/* PDF Render Mode Toggle */} {/* PDF Render Mode Toggle */}
<Tooltip {!isPhone && (
content={ <Tooltip
pdfRenderMode === "normal" content={
? t("viewer.enableDarkFilter", "Enable Dark Filter")
: pdfRenderMode === "dark"
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
: t("viewer.disableColorFilter", "Disable Color Filter")
}
position="top"
arrow
>
<Button
variant={pdfRenderMode !== "normal" ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={cyclePdfRenderMode}
style={{ minWidth: "2.5rem" }}
aria-label={
pdfRenderMode === "normal" pdfRenderMode === "normal"
? t("viewer.enableDarkFilter", "Enable Dark Filter") ? t("viewer.enableDarkFilter", "Enable Dark Filter")
: pdfRenderMode === "dark" : pdfRenderMode === "dark"
? t("viewer.enableSepiaFilter", "Enable Sepia Filter") ? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
: t("viewer.disableColorFilter", "Disable Color Filter") : t("viewer.disableColorFilter", "Disable Color Filter")
} }
position="top"
arrow
> >
{pdfRenderMode === "normal" && <DarkModeIcon fontSize="small" />} <Button
{pdfRenderMode === "dark" && <WbTwilightIcon fontSize="small" />} variant={pdfRenderMode !== "normal" ? "filled" : "light"}
{pdfRenderMode === "sepia" && <WbSunnyIcon fontSize="small" />} color="blue"
</Button> size={buttonSize}
</Tooltip> radius="xl"
onClick={cyclePdfRenderMode}
style={{ minWidth: buttonMinWidth }}
aria-label={
pdfRenderMode === "normal"
? t("viewer.enableDarkFilter", "Enable Dark Filter")
: pdfRenderMode === "dark"
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
: t("viewer.disableColorFilter", "Disable Color Filter")
}
>
{pdfRenderMode === "normal" && <DarkModeIcon fontSize="small" />}
{pdfRenderMode === "dark" && <WbTwilightIcon fontSize="small" />}
{pdfRenderMode === "sepia" && <WbSunnyIcon fontSize="small" />}
</Button>
</Tooltip>
)}
{/* Zoom Controls */} {/* Desktop zoom controls (slider + buttons) */}
<Group gap={4} align="center" style={{ marginLeft: 16 }}> {!isPhone && (
<ActionIcon <Group
variant="subtle" gap={4}
color="blue" align="center"
radius="md" wrap="nowrap"
onClick={handleZoomOut} style={{ marginLeft: 16, flexShrink: 0 }}
aria-label={t("viewer.zoomOut", "Zoom out")}
> >
<ZoomOutIcon fontSize="small" /> <ActionIcon
</ActionIcon> variant="subtle"
<Slider color="blue"
value={Math.min(Math.max(displayZoomPercent, 20), 500)} radius="md"
min={20} onClick={handleZoomOut}
max={500} aria-label={t("viewer.zoomOut", "Zoom out")}
step={5} >
onChange={(val) => zoomActions.setZoomLevel?.(val / 100)} <ZoomOutIcon fontSize="small" />
size="xs" </ActionIcon>
styles={{ <Slider
root: { width: "6rem" }, value={Math.min(Math.max(displayZoomPercent, 20), 500)}
thumb: { width: 14, height: 14 }, min={20}
track: { height: 3 }, max={500}
}} step={5}
label={null} onChange={(val) => zoomActions.setZoomLevel?.(val / 100)}
/> size="xs"
<ActionIcon styles={{
variant="subtle" root: { minWidth: "6rem", width: "6rem", flexShrink: 0 },
color="blue" thumb: { width: 14, height: 14 },
radius="md" track: { height: 3 },
onClick={handleZoomIn} }}
aria-label={t("viewer.zoomIn", "Zoom in")} label={null}
/>
<ActionIcon
variant="subtle"
color="blue"
radius="md"
onClick={handleZoomIn}
aria-label={t("viewer.zoomIn", "Zoom in")}
>
<ZoomInIcon fontSize="small" />
</ActionIcon>
<span
style={{
minWidth: "2.5rem",
textAlign: "center",
fontSize: 12,
color: "var(--text-muted)",
}}
>
{displayZoomPercent}%
</span>
</Group>
)}
{isPhone && (
<Menu
shadow="md"
width={240}
position="top-end"
closeOnItemClick={false}
> >
<ZoomInIcon fontSize="small" /> <Menu.Target>
</ActionIcon> <ActionIcon
<span variant="light"
style={{ color="blue"
minWidth: "2.5rem", radius="md"
textAlign: "center", size="lg"
fontSize: 12, aria-label={t("viewer.moreOptions", "More")}
color: "var(--text-muted)", style={{ marginLeft: 4 }}
}} >
> <MoreVertIcon fontSize="small" />
{displayZoomPercent}% </ActionIcon>
</span> </Menu.Target>
</Group> <Menu.Dropdown>
<Menu.Label>
{t("viewer.pageNavigation", "Page navigation")}
</Menu.Label>
<Menu.Item
leftSection={<FirstPageIcon fontSize="small" />}
disabled={scrollState.currentPage === 1}
onClick={handleFirstPage}
>
{t("viewer.firstPage", "First page")}
</Menu.Item>
<Menu.Item
leftSection={<LastPageIcon fontSize="small" />}
disabled={scrollState.currentPage === scrollState.totalPages}
onClick={handleLastPage}
>
{t("viewer.lastPage", "Last page")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("viewer.zoom", "Zoom")}</Menu.Label>
<Menu.Item
leftSection={<ZoomOutIcon fontSize="small" />}
onClick={handleZoomOut}
>
{t("viewer.zoomOut", "Zoom out")}
</Menu.Item>
<Menu.Item
leftSection={<ZoomInIcon fontSize="small" />}
onClick={handleZoomIn}
>
{t("viewer.zoomIn", "Zoom in")} ({displayZoomPercent}%)
</Menu.Item>
<Menu.Divider />
<Menu.Label>{t("viewer.view", "View")}</Menu.Label>
<Menu.Item
leftSection={
isDualPageActive ? (
<DescriptionIcon fontSize="small" />
) : (
<ViewWeekIcon fontSize="small" />
)
}
disabled={scrollState.totalPages <= 1}
onClick={handleDualPageToggle}
>
{isDualPageActive
? t("viewer.singlePageView", "Single Page View")
: t("viewer.dualPageView", "Dual Page View")}
</Menu.Item>
<Menu.Item
leftSection={
pdfRenderMode === "normal" ? (
<DarkModeIcon fontSize="small" />
) : pdfRenderMode === "dark" ? (
<WbTwilightIcon fontSize="small" />
) : (
<WbSunnyIcon fontSize="small" />
)
}
onClick={cyclePdfRenderMode}
>
{pdfRenderMode === "normal"
? t("viewer.enableDarkFilter", "Enable Dark Filter")
: pdfRenderMode === "dark"
? t("viewer.enableSepiaFilter", "Enable Sepia Filter")
: t("viewer.disableColorFilter", "Disable Color Filter")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Paper> </Paper>
); );
} }
@@ -15,3 +15,11 @@ export const useIsMobile = (): boolean => {
export const useIsPhone = (): boolean => { export const useIsPhone = (): boolean => {
return useMediaQuery("(max-width: 768px)") ?? false; return useMediaQuery("(max-width: 768px)") ?? false;
}; };
/**
* Custom hook to detect a coarse pointer (touch device)
* Use to gate touch-only UI hints when combined with useIsMobile
*/
export const useIsTouch = (): boolean => {
return useMediaQuery("(pointer: coarse)") ?? false;
};
+10 -7
View File
@@ -5,7 +5,7 @@ import { Group } from "@mantine/core";
import { useSidebarContext } from "@app/contexts/SidebarContext"; import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { useBaseUrl } from "@app/hooks/useBaseUrl"; import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile"; import { useIsMobile, useIsTouch } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useAppConfig } from "@app/contexts/AppConfigContext";
import { LogoIcon } from "@app/components/shared/LogoIcon"; import { LogoIcon } from "@app/components/shared/LogoIcon";
import { Wordmark } from "@app/components/shared/Wordmark"; import { Wordmark } from "@app/components/shared/Wordmark";
@@ -84,6 +84,7 @@ export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { config } = useAppConfig(); const { config } = useAppConfig();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isTouch = useIsTouch();
const sliderRef = useRef<HTMLDivElement | null>(null); const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools"); const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false); const isProgrammaticScroll = useRef(false);
@@ -352,12 +353,14 @@ export default function HomePage() {
{t("home.mobile.workspace", "Workspace")} {t("home.mobile.workspace", "Workspace")}
</button> </button>
</div> </div>
<span className="mobile-toggle-hint"> {isTouch && (
{t( <span className="mobile-toggle-hint">
"home.mobile.swipeHint", {t(
"Swipe left or right to switch views", "home.mobile.swipeHint",
)} "Swipe left or right to switch views",
</span> )}
</span>
)}
</div> </div>
)} )}
{navigationState.workbench === "myFiles" ? ( {navigationState.workbench === "myFiles" ? (
@@ -1,19 +1,18 @@
import { Text, Button } from "@mantine/core"; import { Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession"; import { useAuth } from "@app/auth/UseSession";
import { useNavigate } from "react-router-dom";
export function OverviewHeader() { export function OverviewHeader() {
const { t } = useTranslation(); const { t } = useTranslation();
const { signOut, user } = useAuth(); const { signOut, user } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => { const handleLogout = async () => {
try { try {
await signOut(); await signOut();
navigate("/login");
} catch (error) { } catch (error) {
console.error("Logout error:", error); console.error("Logout error:", error);
} finally {
window.location.assign("/login");
} }
}; };
@@ -503,7 +503,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.advanced.enableAlphaFunctionality.label", "admin.settings.advanced.enableAlphaFunctionality.label",
@@ -543,7 +543,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.advanced.enableUrlToPDF.label", "admin.settings.advanced.enableUrlToPDF.label",
@@ -581,7 +581,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.advanced.disableSanitize.label", "admin.settings.advanced.disableSanitize.label",
@@ -959,7 +959,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.advanced.tempFileManagement.startupCleanup.label", "admin.settings.advanced.tempFileManagement.startupCleanup.label",
@@ -1002,7 +1002,7 @@ export default function AdminAdvancedSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.advanced.tempFileManagement.cleanupSystemTemp.label", "admin.settings.advanced.tempFileManagement.cleanupSystemTemp.label",
@@ -603,7 +603,7 @@ export default function AdminConnectionsSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.connections.ssoAutoLogin.enable", "admin.settings.connections.ssoAutoLogin.enable",
@@ -674,7 +674,7 @@ export default function AdminConnectionsSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.connections.mobileScanner.enable", "admin.settings.connections.mobileScanner.enable",
@@ -496,7 +496,7 @@ export default function AdminDatabaseSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.database.enableCustom.label", "admin.settings.database.enableCustom.label",
@@ -200,7 +200,7 @@ export default function AdminFeaturesSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.features.serverCertificate.enabled.label", "admin.settings.features.serverCertificate.enabled.label",
@@ -316,7 +316,7 @@ export default function AdminFeaturesSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.features.serverCertificate.regenerateOnStartup.label", "admin.settings.features.serverCertificate.regenerateOnStartup.label",
@@ -703,7 +703,7 @@ export default function AdminGeneralSection() {
marginBottom: "1rem", marginBottom: "1rem",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.general.hideDisabledTools.googleDrive.label", "admin.settings.general.hideDisabledTools.googleDrive.label",
@@ -747,7 +747,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.general.hideDisabledTools.mobileScanner.label", "admin.settings.general.hideDisabledTools.mobileScanner.label",
@@ -793,7 +793,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.general.showUpdate.label", "admin.settings.general.showUpdate.label",
@@ -832,7 +832,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.general.showUpdateOnlyAdmin.label", "admin.settings.general.showUpdateOnlyAdmin.label",
@@ -873,7 +873,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.general.customHTMLFiles.label", "admin.settings.general.customHTMLFiles.label",
@@ -938,7 +938,7 @@ export default function AdminGeneralSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.general.customMetadata.autoUpdate.label", "admin.settings.general.customMetadata.autoUpdate.label",
@@ -197,7 +197,7 @@ export default function AdminPremiumSection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.premium.enabled.label", "admin.settings.premium.enabled.label",
@@ -165,7 +165,7 @@ export default function AdminPrivacySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.privacy.enableAnalytics.label", "admin.settings.privacy.enableAnalytics.label",
@@ -203,7 +203,7 @@ export default function AdminPrivacySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.privacy.metricsEnabled.label", "admin.settings.privacy.metricsEnabled.label",
@@ -253,7 +253,7 @@ export default function AdminPrivacySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.privacy.googleVisibility.label", "admin.settings.privacy.googleVisibility.label",
@@ -292,7 +292,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.enableLogin.label", "admin.settings.security.enableLogin.label",
@@ -519,7 +519,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.jwt.persistence.label", "admin.settings.security.jwt.persistence.label",
@@ -556,7 +556,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.jwt.enableKeyRotation.label", "admin.settings.security.jwt.enableKeyRotation.label",
@@ -596,7 +596,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.jwt.enableKeyCleanup.label", "admin.settings.security.jwt.enableKeyCleanup.label",
@@ -780,7 +780,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.jwt.secureCookie.label", "admin.settings.security.jwt.secureCookie.label",
@@ -840,7 +840,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.audit.enabled.label", "admin.settings.security.audit.enabled.label",
@@ -955,7 +955,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.audit.captureFileHash.label", "admin.settings.security.audit.captureFileHash.label",
@@ -995,7 +995,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.audit.capturePdfAuthor.label", "admin.settings.security.audit.capturePdfAuthor.label",
@@ -1035,7 +1035,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.audit.captureOperationResults.label", "admin.settings.security.audit.captureOperationResults.label",
@@ -1097,7 +1097,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.htmlUrlSecurity.enabled.label", "admin.settings.security.htmlUrlSecurity.enabled.label",
@@ -1374,7 +1374,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.label", "admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.label",
@@ -1424,7 +1424,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.htmlUrlSecurity.blockLocalhost.label", "admin.settings.security.htmlUrlSecurity.blockLocalhost.label",
@@ -1473,7 +1473,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.htmlUrlSecurity.blockLinkLocal.label", "admin.settings.security.htmlUrlSecurity.blockLinkLocal.label",
@@ -1522,7 +1522,7 @@ export default function AdminSecuritySection() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div> <div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t( {t(
"admin.settings.security.htmlUrlSecurity.blockCloudMetadata.label", "admin.settings.security.htmlUrlSecurity.blockCloudMetadata.label",
@@ -585,9 +585,12 @@ export default function PeopleSection() {
{t("workspace.people.user")} {t("workspace.people.user")}
</Table.Th> </Table.Th>
<Table.Th <Table.Th
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
whiteSpace: "nowrap",
}}
fz="sm" fz="sm"
w={100}
> >
{t("workspace.people.role")} {t("workspace.people.role")}
</Table.Th> </Table.Th>
@@ -690,7 +693,7 @@ export default function PeopleSection() {
</Box> </Box>
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td w={100}> <Table.Td style={{ whiteSpace: "nowrap" }}>
<Badge <Badge
size="sm" size="sm"
variant="light" variant="light"
@@ -699,6 +702,10 @@ export default function PeopleSection() {
? "blue" ? "blue"
: "cyan" : "cyan"
} }
styles={{
root: { maxWidth: "none" },
label: { overflow: "visible" },
}}
> >
{(user.rolesAsString || "").includes("ROLE_ADMIN") {(user.rolesAsString || "").includes("ROLE_ADMIN")
? t("workspace.people.admin", "Admin") ? t("workspace.people.admin", "Admin")