merge main

This commit is contained in:
Frooodle
2026-06-30 08:48:37 +01:00
218 changed files with 13521 additions and 3210 deletions
@@ -8003,6 +8003,8 @@ zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[viewer.attachments]
addAttachment = "Add attachment"
close = "Close attachments"
empty = "No attachments in this document"
loading = "Loading attachments..."
noDocument = "Open a PDF to view its attachments."
@@ -8016,6 +8018,7 @@ addComment = "Add comment"
addCommentPlaceholder = "Add comment..."
addLink = "Add link"
addReplyPlaceholder = "Add reply..."
close = "Close comments"
deleteAnnotationAndComment = "Delete annotation & comment"
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
deleteTitle = "Remove annotation from comments?"
@@ -8026,6 +8029,7 @@ moreActions = "More actions"
nComments_one = "{{count}} comment"
nComments_other = "{{count}} comments"
pageLabel = "Page {{page}}"
placingHint = "Click a page to place… (cancel)"
removeCommentOnly = "Remove comment only"
saveReply = "Save reply"
title = "Comments"
@@ -5964,12 +5964,40 @@ statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.enforcement]
applying = "Applying {{names}}"
applyingProgress = "Applying {{names}} ({{done}} of {{total}})"
exportFailureBody = "Security policies couldn't be applied. Files were exported as-is."
exportFailureTitle = "Exported without enforcement"
failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were exported as-is."
failureTitle = "Exported without full enforcement"
printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again."
printPolicyAppliedTitle = "Policy applied before printing"
queued = "+{{count}} queued"
successTitle = "{{names}} applied"
summaryMore = "{{first}}, {{second}} and {{more}} more"
summaryTwo = "{{first}} and {{second}}"
[policies.enforcement.triggerVerb]
convert = "Enforcing before convert"
default = "Enforcing"
export = "Enforcing before export"
input = "Enforcing on import"
print = "Enforcing before print"
[policies.fields]
selectedCount = "{{count}} selected"
[policies.pii]
account = "Account numbers (labelled)"
card = "Credit / debit cards"
email = "Email addresses"
fieldLabel = "PII to redact"
iban = "IBANs"
phone = "Phone numbers"
placeholder = "Select PII types"
routing = "US routing numbers (ABA)"
ssn = "Social Security numbers"
[policies.sidebar]
activeCount = "{{count}} active"
@@ -5992,6 +6020,11 @@ setup = "Set up"
enableAriaLabel = "Enable {{tool}}"
infoAriaLabel = "What does {{tool}} do?"
[policies.toolConfig.info]
redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document."
sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it."
watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page."
[policies.wizard]
allDocTypesDescription = "Enable the Classification policy to filter by document type."
allDocTypesTitle = "All document types"
@@ -8077,6 +8110,8 @@ zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[viewer.attachments]
addAttachment = "Add attachment"
close = "Close attachments"
empty = "No attachments in this document"
loading = "Loading attachments..."
noDocument = "Open a PDF to view its attachments."
@@ -8094,6 +8129,7 @@ cancelClearAll = "Cancel"
clearAll = "Clear all comments"
clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document."
clearAllTitle = "Clear all comments?"
close = "Close comments"
deleteAnnotationAndComment = "Delete annotation & comment"
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
deleteTitle = "Remove annotation from comments?"
@@ -8104,6 +8140,7 @@ moreActions = "More actions"
nComments_one = "{{count}} comment"
nComments_other = "{{count}} comments"
pageLabel = "Page {{page}}"
placingHint = "Click a page to place… (cancel)"
removeCommentOnly = "Remove comment only"
saveReply = "Save reply"
title = "Comments"
+6 -2
View File
@@ -1,6 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"productName": "Stirling PDF",
"mainBinaryName": "Stirling-PDF",
"version": "2.13.2",
"identifier": "stirling.pdf.dev",
"build": {
@@ -12,7 +13,7 @@
"app": {
"windows": [
{
"title": "Stirling-PDF",
"title": "Stirling PDF",
"width": 1280,
"height": 800,
"resizable": true,
@@ -63,6 +64,9 @@
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com",
"wix": {
"upgradeCode": "3305fba9-7e5e-5c09-bc71-eca0a65f4fee",
"bannerPath": "windows/wix/banner.bmp",
"dialogImagePath": "windows/wix/dialog.bmp",
"fragmentPaths": ["windows/wix/provisioning.wxs"],
"componentGroupRefs": ["ProvisioningComponentGroup"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

@@ -1,250 +1,59 @@
/**
* Reusable monthly spend-cap control.
*
* One inline row — preset chips, a custom-entry pill that matches the presets,
* a "No cap" chip, and (optionally) a Save button — over a live "≈ N PDFs /
* month" estimate. Extracted from the subscribed plan-page cap editor so the
* exact same control drives the upgrade checkout flow.
*
* <h2>Currency-agnostic by design</h2>
*
* The control never decides a currency. It takes {@code pricePerDocMinor} +
* {@code currency} and renders whatever it's handed: the subscribed plan page
* passes the team's real Stripe-subscription rate/currency; the unsubscribed
* checkout flow passes a USD rate (Stripe hasn't assigned the team a currency
* yet) plus a {@code note} explaining the cap is editable later. When no rate
* is supplied the estimate simply hides.
*
* <h2>Controlled</h2>
*
* Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a
* real $0 cap that keeps everything free) + {@code onChange}. The parent owns
* the working value. When {@code onSave} is provided the control renders the
* inline Save button and computes "dirty" against {@code savedCapUsd}.
* Editor cloud adapter over the shared {@code @shared/billing} spend-cap control:
* supplies the i18n copy (the shared control is copy-agnostic) and the editor's
* {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code
* onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is
* unchanged, so the plan-page cap editor and the upgrade-checkout flow keep
* consuming it as before.
*/
import React, { useState } from "react";
import { Button } from "@mantine/core";
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
import LocalIcon from "@app/components/shared/LocalIcon";
import React from "react";
import { useTranslation } from "react-i18next";
import {
DEFAULT_CAP_PRESETS,
SpendCapControl as SharedSpendCapControl,
} from "@shared/billing";
// eslint-disable-next-line no-restricted-imports
import "./SpendCapControl.css";
// Quick amounts offered everywhere — recognition over recall.
export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const;
export { DEFAULT_CAP_PRESETS };
export interface SpendCapControlProps {
/** Current cap in major currency units; {@code null} = no cap. Controlled. */
capUsd: number | null;
/** Working-value setter. {@code null} signals no-cap. */
onChange: (capUsd: number | null) => void;
/** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */
pricePerDocMinor?: number | null;
/** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */
currency?: string | null;
/** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */
presets?: readonly number[];
/**
* When provided, the control renders an inline Save button. Receives whole
* major units, or {@code null} for no-cap.
*/
onSave?: (capUsd: number | null) => Promise<void> | void;
/** Label for the Save button. */
saveLabel?: string;
/**
* The persisted value to diff against for the dirty check. Same encoding as
* {@link #capUsd} ({@code null} = persisted no-cap). Only used with
* {@link #onSave}.
*/
savedCapUsd?: number | null;
/** Quiet helper line under the estimate (e.g. the USD / editable-later note). */
note?: React.ReactNode;
}
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
function formatMinor(
minor: number,
currency: string | null | undefined,
): string {
const code = (currency ?? "usd").toUpperCase();
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: code,
// Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent
// rate is 0.5). Allow up to 3 fraction digits so they don't round to $0.
maximumFractionDigits: 3,
}).format(minor / 100);
} catch {
return `${(minor / 100).toFixed(2)} ${code}`;
}
}
/** Currency symbol for compact inline use; falls back to the ISO code. */
function currencySymbol(currency: string | null | undefined): string {
switch ((currency ?? "").toLowerCase()) {
case "usd":
case "":
return "$";
case "eur":
return "€";
case "gbp":
return "£";
default:
return currency!.toUpperCase() + " ";
}
}
const SpendCapControl: React.FC<SpendCapControlProps> = ({
capUsd,
onChange,
pricePerDocMinor,
currency,
presets = DEFAULT_CAP_PRESETS,
onSave,
saveLabel,
savedCapUsd,
note,
...rest
}) => {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const sym = currencySymbol(currency);
const isNoCap = capUsd === null;
const presetSelected = capUsd != null && presets.includes(capUsd);
// Custom is "active" when a cap is set that isn't one of the presets — i.e.
// the value came from the custom pill.
const customActive = capUsd != null && !presets.includes(capUsd);
// Local mirror of the custom field's text so partial/empty entry doesn't get
// clobbered by the controlled value. Seeded from a non-preset incoming cap.
const [customText, setCustomText] = useState<string>(
customActive ? String(capUsd) : "",
);
// Mirror of the backend's docCapForMoney: floor(capMinor / rate). The
// one-time free grant is a separate lifetime pool and is NOT added here —
// this is the paid PDFs the monthly cap buys.
const rate =
pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null;
const previewDocs =
capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null;
const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
const selectPreset = (preset: number) => {
setCustomText("");
onChange(preset);
};
const selectNoCap = () => {
setCustomText("");
onChange(null);
};
const onCustomInput = (raw: string) => {
// Digits only; an empty field reads as "no custom value yet" → 0 so the
// estimate still renders sensibly without flipping to no-cap.
const cleaned = raw.replace(/[^0-9]/g, "");
setCustomText(cleaned);
const v = cleaned === "" ? 0 : parseInt(cleaned, 10);
onChange(Number.isNaN(v) ? 0 : v);
};
const handleSave = async () => {
if (!onSave) return;
setSaving(true);
try {
await onSave(isNoCap ? null : Math.round(capUsd ?? 0));
} finally {
setSaving(false);
}
};
return (
<div className="scc">
<div className="scc-row">
{presets.map((preset) => (
<button
key={preset}
type="button"
className="scc-chip"
data-selected={presetSelected && capUsd === preset}
onClick={() => selectPreset(preset)}
>
{sym}
{preset.toLocaleString()}
</button>
))}
{/* Custom-entry pill — dashed until it carries a value, then it fills
like a selected chip. */}
<label className="scc-custom" data-active={customActive}>
<span className="scc-custom__symbol">{sym}</span>
<input
className="scc-custom__input"
inputMode="numeric"
value={customActive ? customText : ""}
placeholder={t("payg.cap.custom", "Custom")}
aria-label={t("payg.cap.amount", "Cap amount")}
onChange={(e) => onCustomInput(e.target.value)}
/>
</label>
<button
type="button"
className={`scc-chip${onSave ? "" : " scc-row__spacer"}`}
data-selected={isNoCap}
onClick={selectNoCap}
>
{t("payg.cap.noCapLabel", "No cap")}
</button>
{onSave && (
<Button
variant="default"
size="xs"
className="scc-row__spacer"
disabled={!dirty || saving}
loading={saving}
leftSection={<LocalIcon icon="check-rounded" />}
onClick={handleSave}
>
{saveLabel ?? t("payg.cap.save", "Update cap")}
</Button>
)}
</div>
{previewDocs != null && (
<div className="scc-estimate">
<DescriptionIcon
className="scc-estimate__icon"
sx={{ fontSize: 22 }}
/>
<div>
<div className="scc-estimate__main">
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
docs: previewDocs.toLocaleString(),
})}
</div>
<div className="scc-estimate__sub">
{t("payg.cap.docsRate", "at {{rate}} / PDF", {
rate: formatMinor(pricePerDocMinor ?? 0, currency),
})}
</div>
</div>
</div>
)}
{isNoCap && (
<div className="scc-note">
{t(
"payg.cap.noCapDesc",
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
)}
</div>
)}
{note && <div className="scc-note">{note}</div>}
</div>
<SharedSpendCapControl
{...rest}
labels={{
custom: t("payg.cap.custom", "Custom"),
amountAria: t("payg.cap.amount", "Cap amount"),
noCap: t("payg.cap.noCapLabel", "No cap"),
save: saveLabel ?? t("payg.cap.save", "Update cap"),
docsEstimate: (docs) =>
t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
docs,
}),
docsRate: (rate) =>
t("payg.cap.docsRate", "at {{rate}} / PDF", { rate }),
noCapDesc: t(
"payg.cap.noCapDesc",
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
),
}}
/>
);
};
@@ -7,36 +7,10 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useWallet, type Wallet } from "@app/hooks/useWallet";
import { currencySymbol, MeterBar, meterState } from "@shared/billing";
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
export type MeterState = "FULL" | "WARNED" | "DEGRADED";
/** Warn/degrade band for a usage meter (mirrors the BE thresholds). */
export function meterState(
used: number,
limit: number,
): { state: MeterState; pct: number } {
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
const state: MeterState =
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
return { state, pct };
}
/** Currency symbol for compact inline use; falls back to the ISO code. */
function currencySymbol(currency: string | null): string {
switch ((currency ?? "").toLowerCase()) {
case "usd":
return "$";
case "eur":
return "€";
case "gbp":
return "£";
default:
return currency ? currency.toUpperCase() + " " : "$";
}
}
// ─── One-time free grant meter ──────────────────────────────────────────────
export interface FreeSnapshot {
@@ -78,38 +52,20 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
: t("payg.free.state.plentyLeft", "Plenty left");
return (
<div className="paygf-meter" data-state={state}>
<div className="paygf-meter__top">
<div className="paygf-meter__figure">
<span className="paygf-meter__num">
{snap.billableUsed.toLocaleString()}
</span>
<span className="paygf-meter__cap">
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
limit: snap.billableLimit.toLocaleString(),
})}
</span>
</div>
<span className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</span>
</div>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="paygf-meter__meta">
<MeterBar
state={state}
pct={pct}
figure={snap.billableUsed.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
limit: snap.billableLimit.toLocaleString(),
})}
statusLabel={stateLabel}
meta={
<span>
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
</span>
</div>
</div>
}
/>
);
}
@@ -158,45 +114,28 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
const symbol = currencySymbol(snap.currency);
return (
<div className="paygf-meter" data-state={state}>
<div className="paygf-meter__top">
<div className="paygf-meter__figure">
<span className="paygf-meter__num">
{symbol}
{snap.spent.toLocaleString()}
<MeterBar
state={state}
pct={pct}
figure={`${symbol}${snap.spent.toLocaleString()}`}
capSuffix={t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
amount: `${symbol}${snap.cap.toLocaleString()}`,
})}
statusLabel={stateLabel}
meta={
<>
<span>
{t(
"payg.spendCapMeter.metaCategories",
"Automation · AI · API spend",
)}
</span>
<span className="paygf-meter__cap">
{t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
amount: `${symbol}${snap.cap.toLocaleString()}`,
})}
<span className="payg-hero__meta-dot">•</span>
<span>
{t("payg.spendCapMeter.resets", "Resets each billing period")}
</span>
</div>
<span className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</span>
</div>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="paygf-meter__meta">
<span>
{t(
"payg.spendCapMeter.metaCategories",
"Automation · AI · API spend",
)}
</span>
<span className="payg-hero__meta-dot">•</span>
<span>
{t("payg.spendCapMeter.resets", "Resets each billing period")}
</span>
</div>
</div>
</>
}
/>
);
}
+18 -115
View File
@@ -50,123 +50,26 @@ import apiClient from "@app/services/apiClient";
import { createPortalSession } from "@app/services/billing";
import { openExternal } from "@app/platform/openExternal";
import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
import type {
Wallet,
WalletStatus,
WalletRole,
WalletMember,
WalletCategoryBreakdown,
WalletActivityRow,
} from "@shared/billing";
// ─── Public types ───────────────────────────────────────────────────────
export type WalletStatus = "free" | "subscribed";
export type WalletRole = "leader" | "member";
/**
* A single team member's billing-relevant info — name + email for the avatar
* row, {@code spendUnits} for their per-member usage display. Mirrors a row of
* the backend's {@code members} array on {@code WalletSnapshot} (joined with
* {@code team_memberships}).
*/
export interface WalletMember {
/** Supabase user id of the member. */
userId: string;
name: string;
email: string;
/** Member's current-period billable spend. */
spendUnits: number;
}
/**
* Per-category breakdown of current-period spend in billable units. The
* categories mirror the {@code FeatureGate} buckets the backend tracks:
* server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and
* pipeline / automation runs ({@code automation}). Numbers sum to {@code
* billableUsed} (modulo rounding in mock data).
*/
export interface WalletCategoryBreakdown {
api: number;
ai: number;
automation: number;
}
/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */
export interface Wallet {
/**
* The caller's primary team_id. Needed when invoking Supabase edge functions
* (create-checkout-session, etc.) that run outside Spring Security and have
* no other way to resolve the caller's team. May be null on the synthetic
* empty snapshot returned to anonymous / team-less callers.
*/
teamId: number | null;
status: WalletStatus;
role: WalletRole;
/**
* ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed;
* the calendar month for free teams.
*/
billingPeriodStart: string;
billingPeriodEnd: string;
/**
* For a free team: the one-time free documents used so far ({@code
* freeAllowance − freeRemaining}). For a subscribed team: documents
* processed this month across automation + AI + API.
*/
billableUsed: number;
/**
* The team's document ceiling for the matching window: the one-time free
* grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap
* {@code floor(cap / perDocRate)} for capped subscribed teams; null when
* subscribed with no cap (uncapped).
*/
billableLimit: number | null;
/**
* The team's one-time free document grant size — the "N" in "X of N free".
* A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets
* and is not lost when the team subscribes.
*/
freeAllowance: number;
/**
* One-time free documents still available to the team
* ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* Survives subscribing — a subscribed team keeps any unused grant.
*/
freeRemaining: number;
/**
* Paid per-document rate in minor units of {@link Wallet#currency} (may be
* fractional); null when the rate can't be resolved — render "unknown",
* never substitute.
*/
pricePerDocMinor: number | null;
/** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */
currency: string | null;
/**
* Estimated charges so far this period in minor units of currency: paid
* (Stripe-metered) documents this period × rate. The free portion was
* already netted out at charge time. Informational — the Stripe invoice
* is authoritative. Null when the rate is unknown.
*/
estimatedBillMinor: number | null;
/** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */
capUsd: number | null;
/** Only meaningful when status=='subscribed'. */
noCap: boolean;
/** Stripe subscription id when subscribed; null when free. */
stripeSubscriptionId: string | null;
/** Current-period spend in billable units. */
spendUnitsThisPeriod: number;
/** Per-category spend breakdown (api / ai / automation). */
categoryBreakdown: WalletCategoryBreakdown;
/**
* Team members, populated for the leader view; empty for members or
* single-seat tenants. Leader-vs-member is still resolved via {@link
* Wallet#role} — this field just carries the per-member rows the leader's
* sub-cap table needs.
*/
members: WalletMember[];
/**
* Recent billable-activity rows. V1 returns {@code []} from the backend;
* the field exists so the Plan page can render an empty state without
* branching on undefined. Each entry is a {@code Record<string, unknown>}
* because the activity-row shape is not yet finalised — when the meter-
* event surface lands, this widens to a real interface.
*/
recent: Array<Record<string, unknown>>;
}
// The wallet contract lives in @shared/billing (shared with the admin portal).
// Re-exported so existing `@app/hooks/useWallet` importers keep their imports.
export type {
Wallet,
WalletStatus,
WalletRole,
WalletMember,
WalletCategoryBreakdown,
WalletActivityRow,
};
export interface UseWalletResult {
wallet: Wallet | null;
@@ -989,6 +989,35 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</div>
) : filteredFileStubs.length > 0 ? (
<div className="file-sidebar-file-list">
<button
type="button"
onClick={() => nativeFileInputRef.current?.click()}
data-testid="add-files-row"
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
padding: "4px 6px",
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
width: "100%",
textAlign: "left",
borderRadius: 4,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--hover-bg)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
<AddIcon sx={{ fontSize: "0.9rem" }} />
{t("fileSidebar.addFiles", "Add files")}
</button>
{filteredFileStubs.map((stub) => {
const workbenchFileId = state.files.ids.find(
(id) => (id as string) === (stub.id as string),
@@ -30,6 +30,9 @@ import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { enforceExportPolicies } from "@app/services/policyExport";
import { downloadFile as downloadRaw } from "@app/services/downloadService";
import { alert as showAlert } from "@app/components/toast";
import {
WorkbenchBarButtonConfig,
WorkbenchBarRenderContext,
@@ -171,13 +174,35 @@ export default function WorkbenchBar({
const filesToExport =
selectedFiles.length > 0 ? selectedFiles : activeFiles;
for (const file of filesToExport) {
const stub = isStirlingFile(file)
const stubs = filesToExport.map((file) =>
isStirlingFile(file)
? selectors.getStirlingFileStub(file.fileId)
: undefined;
: undefined,
);
// Enforce all files in one batch so the toast shows progress across the
// whole set (e.g. "report.pdf (2 of 5)") rather than N invisible solo runs.
let enforced: File[];
try {
enforced = await enforceExportPolicies(
filesToExport as File[],
stubs.map((s) => s?.id),
);
} catch {
enforced = filesToExport as File[];
showAlert({
alertType: "warning",
title: t("policies.enforcement.exportFailureTitle"),
body: t("policies.enforcement.exportFailureBody"),
});
}
for (let idx = 0; idx < filesToExport.length; idx++) {
const file = filesToExport[idx];
const stub = stubs[idx];
try {
const result = await downloadFile({
data: file,
const result = await downloadRaw({
data: enforced[idx],
filename: file.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
fileId: stub?.id,
@@ -4,12 +4,14 @@ import {
ScrollArea,
Text,
ActionIcon,
Button,
Loader,
Stack,
TextInput,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useViewer } from "@app/contexts/ViewerContext";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { PdfAttachmentObject } from "@embedpdf/models";
import AttachmentIcon from "@mui/icons-material/AttachmentRounded";
import DownloadIcon from "@mui/icons-material/DownloadRounded";
@@ -52,7 +54,9 @@ export const AttachmentSidebar = ({
preloadCacheKeys = [],
}: AttachmentSidebarProps) => {
const { t } = useTranslation();
const { attachmentActions, hasAttachmentSupport } = useViewer();
const { attachmentActions, hasAttachmentSupport, toggleAttachmentSidebar } =
useViewer();
const { handleToolSelectForced } = useToolWorkflow();
const [searchTerm, setSearchTerm] = useState("");
const [attachmentSupport, setAttachmentSupport] = useState(() =>
hasAttachmentSupport(),
@@ -139,16 +143,28 @@ export const AttachmentSidebar = ({
const key = documentCacheKey;
const cached = cacheRef.current.get(key);
if (
cached &&
(cached.status === "loading" || cached.status === "success")
) {
// Only short-circuit on a finalised success cache. Skipping when
// cached.status === "loading" caused the sidebar to get stuck: if
// the previous fetch was cancelled (by a parent re-render that
// changed the attachmentActions reference - createViewerActions
// builds a new object every viewer render), the cache still says
// "loading" but no live fetch is in flight. On the re-run we'd
// early-return and never refetch, so the UI would sit on the
// "Loading attachments..." state forever. Same change applied in
// BookmarkSidebar.
if (cached && cached.status === "success") {
return;
}
let cancelled = false;
// Don't write "loading" into the cache - keep the cache for
// terminal states (success/error) only, so a cancelled run can
// never leave a stale "loading" entry behind. The visible
// sidebar state still goes through setActiveEntry below.
const updateEntry = (entry: AttachmentCacheEntry) => {
cacheRef.current.set(key, entry);
if (entry.status === "success" || entry.status === "error") {
cacheRef.current.set(key, entry);
}
if (!cancelled && currentKeyRef.current === key) {
setActiveEntry(entry);
}
@@ -163,10 +179,20 @@ export const AttachmentSidebar = ({
);
const fetchWithRetry = async () => {
const maxAttempts = 10;
// See BookmarkSidebar - matching change. After a file swap the
// attachment bridge briefly unregisters and the action returns
// null until the new document is loaded; without retrying on
// null we'd cache an empty success and miss freshly-added
// attachments.
const maxAttempts = 30;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const result = await attachmentActions.getAttachments();
if (result === null) {
if (attempt === maxAttempts - 1) return [];
await new Promise((resolve) => setTimeout(resolve, 50));
continue;
}
return Array.isArray(result) ? result : [];
} catch (error: any) {
const message =
@@ -239,6 +265,14 @@ export const AttachmentSidebar = ({
attachmentActions.downloadAttachment(attachment);
};
const handleAddAttachment = useCallback(() => {
// Close the attachment sidebar before opening the tool so the user
// doesn't end up looking at two stacked side panels (the sidebar on
// the right + the tool's settings on the left).
toggleAttachmentSidebar();
handleToolSelectForced("addAttachments");
}, [handleToolSelectForced, toggleAttachmentSidebar]);
const filteredAttachments = useMemo(() => {
const attachments = Array.isArray(activeEntry.attachments)
? activeEntry.attachments
@@ -352,6 +386,18 @@ export const AttachmentSidebar = ({
{t("viewer.attachments.title", "Attachments")}
</Text>
</div>
<Box style={{ display: "flex", alignItems: "center", gap: 2 }}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={toggleAttachmentSidebar}
aria-label="Close attachments sidebar"
title={t("viewer.attachments.close", "Close attachments")}
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</Box>
</div>
<Box
@@ -427,20 +473,56 @@ export const AttachmentSidebar = ({
)}
{showEmptyState && (
<div className="sidebar-base__empty-state">
<Stack align="center" gap="sm" py="lg">
<LocalIcon
icon="attachment-rounded"
width="2rem"
height="2rem"
style={{ color: "var(--mantine-color-dimmed)" }}
/>
<Text size="sm" c="dimmed" ta="center">
{t(
"viewer.attachments.empty",
"No attachments in this document",
)}
</Text>
</div>
<Button
variant="light"
size="xs"
onClick={handleAddAttachment}
leftSection={
<LocalIcon icon="add" width="1rem" height="1rem" />
}
>
{t("viewer.attachments.addAttachment", "Add attachment")}
</Button>
</Stack>
)}
{showAttachmentList && (
<div className="attachment-list">
{renderAttachments(filteredAttachments)}
</div>
<>
<Button
variant="subtle"
size="compact-xs"
fullWidth
onClick={handleAddAttachment}
leftSection={
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
}
mb="xs"
styles={{
root: {
justifyContent: "flex-start",
paddingInline: 6,
},
}}
>
{t("viewer.attachments.addAttachment", "Add attachment")}
</Button>
<div className="attachment-list">
{renderAttachments(filteredAttachments)}
</div>
</>
)}
{showSearchEmpty && (
@@ -7,10 +7,18 @@ import {
Loader,
Stack,
TextInput,
NumberInput,
Button,
Group,
UnstyledButton,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useViewer } from "@app/contexts/ViewerContext";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileContext } from "@app/contexts/FileContext";
import { isStirlingFile, type FileId } from "@app/types/fileContext";
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
import apiClient from "@app/services/apiClient";
import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models";
import BookmarksIcon from "@mui/icons-material/BookmarksRounded";
import "@app/components/viewer/SidebarBase.css";
@@ -75,9 +83,25 @@ export const BookmarkSidebar = ({
documentCacheKey,
preloadCacheKeys = [],
}: BookmarkSidebarProps) => {
const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer();
const {
bookmarkActions,
scrollActions,
hasBookmarkSupport,
activeFileId,
activeFileIndex,
setActiveFileId,
getScrollState,
toggleBookmarkSidebar,
} = useViewer();
const { handleToolSelectForced } = useToolWorkflow();
const { selectors, actions: fileActions } = useFileContext();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [searchTerm, setSearchTerm] = useState("");
const [isAddingBookmark, setIsAddingBookmark] = useState(false);
const [newBookmarkTitle, setNewBookmarkTitle] = useState("");
const [newBookmarkPage, setNewBookmarkPage] = useState<number>(1);
const [isSavingBookmark, setIsSavingBookmark] = useState(false);
const [addBookmarkError, setAddBookmarkError] = useState<string | null>(null);
const [bookmarkSupport, setBookmarkSupport] = useState(() =>
hasBookmarkSupport(),
);
@@ -164,16 +188,23 @@ export const BookmarkSidebar = ({
const key = documentCacheKey;
const cached = cacheRef.current.get(key);
if (
cached &&
(cached.status === "loading" || cached.status === "success")
) {
// Only short-circuit on a finalised success cache. Skipping when
// cached.status === "loading" causes the sidebar to get stuck if
// the previous fetch was cancelled by a parent re-render (the
// bookmarkActions reference changes every viewer render because
// createViewerActions rebuilds the object). See matching change
// in AttachmentSidebar.
if (cached && cached.status === "success") {
return;
}
let cancelled = false;
// Don't write "loading" into the cache - cache only terminal
// states so a cancelled run can't poison the cache.
const updateEntry = (entry: BookmarkCacheEntry) => {
cacheRef.current.set(key, entry);
if (entry.status === "success" || entry.status === "error") {
cacheRef.current.set(key, entry);
}
if (!cancelled && currentKeyRef.current === key) {
setActiveEntry(entry);
}
@@ -188,10 +219,24 @@ export const BookmarkSidebar = ({
);
const fetchWithRetry = async () => {
const maxAttempts = 10;
// 30 × 50ms = 1.5s window. After consumeFiles swaps the file the
// embedpdf bookmark plugin tears down for the old document and
// re-registers for the new one; until the bridge is back the
// action returns null. Without retrying on null we'd cache an
// empty "success" and the just-added bookmark would never show
// up in the sidebar.
const maxAttempts = 30;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const result = await bookmarkActions.fetchBookmarks();
if (result === null) {
// Bridge not registered yet (document still loading). Wait
// and retry instead of caching this as a successful empty
// list.
if (attempt === maxAttempts - 1) return [];
await new Promise((resolve) => setTimeout(resolve, 50));
continue;
}
return Array.isArray(result) ? result : [];
} catch (error: any) {
const message =
@@ -256,6 +301,143 @@ export const BookmarkSidebar = ({
setFetchNonce((value) => value + 1);
}, [documentCacheKey, bookmarkActions]);
const handleOpenAddBookmark = useCallback(() => {
setAddBookmarkError(null);
setNewBookmarkTitle("");
// Default the new bookmark's target page to whatever page the user is
// currently viewing - matches Acrobat / Foxit behaviour.
const currentPage = getScrollState?.()?.currentPage ?? 1;
setNewBookmarkPage(currentPage);
setIsAddingBookmark(true);
}, [getScrollState]);
const handleCancelAddBookmark = useCallback(() => {
setIsAddingBookmark(false);
setAddBookmarkError(null);
setNewBookmarkTitle("");
}, []);
// Fallback: open the full Edit Table of Contents tool when inline add is
// not viable (e.g. the active file is a preview / unmanaged file we
// cannot consume + replace via FileContext).
const handleFallbackToTool = useCallback(() => {
handleToolSelectForced("editTableOfContents");
}, [handleToolSelectForced]);
const handleSubmitAddBookmark = useCallback(async () => {
const title = newBookmarkTitle.trim();
if (!title) {
setAddBookmarkError("Bookmark title is required");
return;
}
// Resolve the file the viewer is currently displaying. activeFileId
// is only set explicitly (user clicked a thumbnail / a tool ran);
// on a fresh /read upload it stays null and the viewer falls back
// to activeFileIndex - so we mirror that here. Without this, Save
// would silently route to the full editor every time on a fresh
// upload.
const allFiles = selectors.getFiles();
const resolvedFile = activeFileId
? allFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId)
: (allFiles[activeFileIndex] ?? allFiles[0]);
const resolvedFileId =
resolvedFile && isStirlingFile(resolvedFile)
? (resolvedFile.fileId as FileId)
: null;
if (!resolvedFileId) {
handleFallbackToTool();
return;
}
const fileId = resolvedFileId;
const file = selectors.getFile(fileId);
const parentStub = selectors.getStirlingFileStub(fileId);
if (!file || !parentStub) {
handleFallbackToTool();
return;
}
setIsSavingBookmark(true);
setAddBookmarkError(null);
try {
// Convert existing PDF bookmarks (from embedpdf) to the backend's
// payload shape, then append the new one.
const toPayload = (
b: PdfBookmarkObject,
): {
title: string;
pageNumber: number;
children: any[];
} => ({
title: b.title ?? "",
pageNumber: resolvePageNumber(b) ?? 1,
children: (b.children ?? []).map(toPayload),
});
const existing = (activeEntry.bookmarks ?? []).map(toPayload);
const bookmarkData = [
...existing,
{ title, pageNumber: newBookmarkPage, children: [] },
];
const formData = new FormData();
formData.append("fileInput", file);
formData.append("replaceExisting", "true");
formData.append("bookmarkData", JSON.stringify(bookmarkData));
const response = await apiClient.post(
"/api/v1/general/edit-table-of-contents",
formData,
{ responseType: "blob" },
);
const newFile = new File([response.data as Blob], file.name, {
type: "application/pdf",
});
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(
[newFile],
parentStub,
"editTableOfContents",
);
const outputFileIds = await fileActions.consumeFiles(
[fileId],
stirlingFiles,
stubs,
);
// Point the viewer at the new file. Without this the viewer's
// activeFileId-removed effect nulls activeFileId (old file is
// gone) and the activeFileIndex falls back to 0, which races
// against the embedpdf plugin reloading - the bookmark /
// attachment bridges can end up stuck in a "loading" state.
// useToolOperation does the same thing after consumeFiles.
if (outputFileIds.length === 1) {
setActiveFileId(outputFileIds[0]);
}
// Reset form. The cache is keyed by documentCacheKey (== fileId);
// the new fileId triggers our document-switch effect, which
// resets state and re-fetches once the embedpdf bookmark
// capability has the new document loaded.
setIsAddingBookmark(false);
setNewBookmarkTitle("");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to save bookmark";
setAddBookmarkError(message);
} finally {
setIsSavingBookmark(false);
}
}, [
newBookmarkTitle,
newBookmarkPage,
activeFileId,
activeFileIndex,
selectors,
fileActions,
setActiveFileId,
activeEntry.bookmarks,
handleFallbackToTool,
]);
const bookmarksWithIds = useMemo(() => {
const assignIds = (
nodes: PdfBookmarkObject[],
@@ -293,34 +475,6 @@ export const BookmarkSidebar = ({
}));
};
const expandAll = useCallback(() => {
const allExpanded: Record<string, boolean> = {};
const expandRecursive = (nodes: BookmarkNode[]) => {
nodes.forEach((node) => {
if (node.children && node.children.length > 0) {
allExpanded[node.id] = true;
expandRecursive(node.children as BookmarkNode[]);
}
});
};
expandRecursive(bookmarksWithIds);
setExpanded(allExpanded);
}, [bookmarksWithIds]);
const collapseAll = useCallback(() => {
const allCollapsed: Record<string, boolean> = {};
const collapseRecursive = (nodes: BookmarkNode[]) => {
nodes.forEach((node) => {
if (node.children && node.children.length > 0) {
allCollapsed[node.id] = false;
collapseRecursive(node.children as BookmarkNode[]);
}
});
};
collapseRecursive(bookmarksWithIds);
setExpanded(allCollapsed);
}, [bookmarksWithIds]);
const handleBookmarkClick = (
bookmark: PdfBookmarkObject,
event: React.MouseEvent,
@@ -499,31 +653,18 @@ export const BookmarkSidebar = ({
Bookmarks
</Text>
</div>
{bookmarkSupport && bookmarksWithIds.length > 0 && (
<>
{Object.values(expanded).some((val) => val === false) ? (
<ActionIcon
variant="subtle"
size="sm"
onClick={expandAll}
aria-label="Expand all bookmarks"
title="Expand all"
>
<LocalIcon icon="unfold-more" width="1.1rem" height="1.1rem" />
</ActionIcon>
) : (
<ActionIcon
variant="subtle"
size="sm"
onClick={collapseAll}
aria-label="Collapse all bookmarks"
title="Collapse all"
>
<LocalIcon icon="unfold-less" width="1.1rem" height="1.1rem" />
</ActionIcon>
)}
</>
)}
<Box style={{ display: "flex", alignItems: "center", gap: 2 }}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={toggleBookmarkSidebar}
aria-label="Close bookmarks sidebar"
title="Close bookmarks"
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</Box>
</div>
<Box
@@ -586,18 +727,119 @@ export const BookmarkSidebar = ({
</Stack>
)}
{showEmptyState && (
<div className="sidebar-base__empty-state">
{showEmptyState && !isAddingBookmark && (
<Stack align="center" gap="sm" py="lg">
<LocalIcon
icon="bookmark-add-rounded"
width="2rem"
height="2rem"
style={{ color: "var(--mantine-color-dimmed)" }}
/>
<Text size="sm" c="dimmed" ta="center">
No bookmarks in this document
</Text>
</div>
<Button
variant="light"
size="xs"
onClick={handleOpenAddBookmark}
leftSection={
<LocalIcon icon="add" width="1rem" height="1rem" />
}
>
Add bookmark
</Button>
</Stack>
)}
{isAddingBookmark && (
<Box
mb="sm"
p="sm"
data-testid="bookmark-add-form"
style={{
border: "1px solid var(--border-subtle)",
borderRadius: 6,
background: "var(--bg-raised, var(--mantine-color-gray-0))",
}}
>
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
Add bookmark
</Text>
<TextInput
size="xs"
placeholder="Bookmark title"
aria-label="Bookmark title"
value={newBookmarkTitle}
onChange={(e) => setNewBookmarkTitle(e.currentTarget.value)}
autoFocus
disabled={isSavingBookmark}
/>
<NumberInput
size="xs"
label="Page"
min={1}
clampBehavior="strict"
value={newBookmarkPage}
onChange={(v) =>
setNewBookmarkPage(typeof v === "number" ? v : 1)
}
disabled={isSavingBookmark}
/>
{addBookmarkError && (
<Text size="xs" c="red">
{addBookmarkError}
</Text>
)}
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={handleCancelAddBookmark}
disabled={isSavingBookmark}
>
Cancel
</Button>
<Button
size="xs"
color="blue"
onClick={handleSubmitAddBookmark}
loading={isSavingBookmark}
disabled={!newBookmarkTitle.trim()}
>
Save
</Button>
</Group>
</Stack>
</Box>
)}
{showBookmarkList && (
<div className="bookmark-list">
{renderBookmarks(filteredBookmarks)}
</div>
<>
{!isAddingBookmark && (
<Button
variant="subtle"
size="compact-xs"
fullWidth
onClick={handleOpenAddBookmark}
leftSection={
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
}
mb="xs"
styles={{
root: {
justifyContent: "flex-start",
paddingInline: 6,
},
}}
>
Add bookmark
</Button>
)}
<div className="bookmark-list">
{renderBookmarks(filteredBookmarks)}
</div>
</>
)}
{showSearchEmpty && (
@@ -609,6 +851,41 @@ export const BookmarkSidebar = ({
)}
</Box>
</ScrollArea>
{bookmarkSupport && documentCacheKey && (
<Box
px="sm"
py="xs"
style={{
borderTop: "1px solid var(--border-subtle)",
backgroundColor: "var(--bg-toolbar)",
flexShrink: 0,
}}
>
<UnstyledButton
type="button"
onClick={handleFallbackToTool}
style={{ width: "100%" }}
>
<Group gap="xs" justify="center" wrap="nowrap">
<LocalIcon
icon="bookmark-add-rounded"
width="0.95rem"
height="0.95rem"
style={{ color: "var(--mantine-color-blue-5)" }}
/>
<Text
size="xs"
c="blue.5"
ta="center"
style={{ textDecoration: "underline" }}
>
Need to reorder or nest? Open the Bookmark Editor
</Text>
</Group>
</UnstyledButton>
</Box>
)}
</Box>
);
};
@@ -311,11 +311,17 @@ export function CommentsSidebar({
clearHighlightCommentRequest,
scrollActions,
getZoomState,
toggleCommentsSidebar,
} = useViewer() ?? {};
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const { state, provides } = useAnnotation(documentId);
const { handleToolSelectForced } = useToolWorkflow();
const { activateAnnotationToolRef } = useAnnotationContext();
const {
activateAnnotationToolRef,
activeAnnotationToolId,
setActiveAnnotationToolId,
} = useAnnotationContext();
const isPlacingComment = activeAnnotationToolId === TEXT_COMMENT_TOOL_ID;
const [draftContents, setDraftContents] = useState<Record<string, string>>(
{},
);
@@ -639,12 +645,34 @@ export function CommentsSidebar({
);
const handleAddComment = useCallback(() => {
// Keep the sidebar open this time - the button morphs into a
// "Click on a page... cancel" hint so the user can see exactly
// what state the viewer is in.
handleToolSelectForced(ANNOTATE_PANEL_ID);
requestAnimationFrame(() => {
activateAnnotationToolRef.current?.(TEXT_COMMENT_TOOL_ID);
});
}, [handleToolSelectForced, activateAnnotationToolRef]);
const handleCancelPlacingComment = useCallback(() => {
// De-arm the textComment tool. The panel's activateAnnotationTool
// takes the AnnotationToolId "select" to reset to no-tool state.
activateAnnotationToolRef.current?.("select" as never);
setActiveAnnotationToolId(null);
}, [activateAnnotationToolRef, setActiveAnnotationToolId]);
// ESC cancels placement mode while the sidebar is open.
useEffect(() => {
if (!visible || !isPlacingComment) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleCancelPlacingComment();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [visible, isPlacingComment, handleCancelPlacingComment]);
if (!visible) return null;
return (
@@ -716,6 +744,18 @@ export function CommentsSidebar({
</Menu>
</Group>
)}
{toggleCommentsSidebar && (
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={toggleCommentsSidebar}
aria-label="Close comments sidebar"
title={t("viewer.comments.close", "Close comments")}
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
)}
</div>
<ScrollArea style={{ flex: 1 }}>
<Stack p="sm" gap="md">
@@ -733,434 +773,517 @@ export function CommentsSidebar({
"Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.",
)}
</Text>
<Button
variant="light"
size="xs"
onClick={handleAddComment}
leftSection={
<LocalIcon icon="add" width="1rem" height="1rem" />
}
>
{t("viewer.comments.addComment", "Add comment")}
</Button>
{isPlacingComment ? (
<Button
variant="light"
color="orange"
size="xs"
onClick={handleCancelPlacingComment}
leftSection={
<LocalIcon
icon="touch-app-rounded"
width="1rem"
height="1rem"
/>
}
>
{t(
"viewer.comments.placingHint",
"Click a page to place… (cancel)",
)}
</Button>
) : (
<Button
variant="light"
size="xs"
onClick={handleAddComment}
leftSection={
<LocalIcon icon="add" width="1rem" height="1rem" />
}
>
{t("viewer.comments.addComment", "Add comment")}
</Button>
)}
</Stack>
) : (
pageNumbers.map((pageIndex) => {
const entries = byPage[pageIndex] ?? [];
const pageNum = pageIndex + 1;
return (
<Box key={pageIndex} mb="md">
<Text size="sm" fw={700} mb={2}>
{t("viewer.comments.pageLabel", "Page {{page}}", {
page: pageNum,
})}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t("viewer.comments.nComments", "{{count}} comment(s)", {
count: entries.length,
})}
</Text>
<Box
mb="xs"
style={{
borderBottom: "1px solid var(--border-subtle)",
}}
/>
<Stack gap="sm">
{entries.map((entry) => {
const ann = entry.annotation?.object;
const id = ann?.id;
if (!id) return null;
const key = `${pageIndex}_${id}`;
const replyKey = `${pageIndex}_${id}_reply`;
const displayContent = getCommentDisplayContent(entry);
const draft =
draftContents[key] !== undefined
? draftContents[key]
: displayContent;
const replyDraft = replyDrafts[replyKey] ?? "";
const authorName = getAuthorName(ann, displayName);
/** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */
const hasMainContent =
(displayContent ?? "").trim().length > 0;
const isEditingMain = editingMainKey === key;
<>
{isPlacingComment ? (
<Button
variant="light"
color="orange"
size="compact-xs"
fullWidth
onClick={handleCancelPlacingComment}
leftSection={
<LocalIcon
icon="touch-app-rounded"
width="0.9rem"
height="0.9rem"
/>
}
styles={{
root: {
justifyContent: "flex-start",
paddingInline: 6,
},
}}
>
{t(
"viewer.comments.placingHint",
"Click a page to place… (cancel)",
)}
</Button>
) : (
<Button
variant="subtle"
size="compact-xs"
fullWidth
onClick={handleAddComment}
leftSection={
<LocalIcon icon="add" width="0.9rem" height="0.9rem" />
}
styles={{
root: {
justifyContent: "flex-start",
paddingInline: 6,
},
}}
>
{t("viewer.comments.addComment", "Add comment")}
</Button>
)}
{pageNumbers.map((pageIndex) => {
const entries = byPage[pageIndex] ?? [];
const pageNum = pageIndex + 1;
return (
<Box key={pageIndex} mb="md">
<Text size="sm" fw={700} mb={2}>
{t("viewer.comments.pageLabel", "Page {{page}}", {
page: pageNum,
})}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t("viewer.comments.nComments", "{{count}} comments", {
count: entries.length,
})}
</Text>
<Box
mb="xs"
style={{
borderBottom: "1px solid var(--border-subtle)",
}}
/>
<Stack gap="sm">
{entries.map((entry) => {
const ann = entry.annotation?.object;
const id = ann?.id;
if (!id) return null;
const key = `${pageIndex}_${id}`;
const replyKey = `${pageIndex}_${id}_reply`;
const displayContent = getCommentDisplayContent(entry);
const draft =
draftContents[key] !== undefined
? draftContents[key]
: displayContent;
const replyDraft = replyDrafts[replyKey] ?? "";
const authorName = getAuthorName(ann, displayName);
/** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */
const hasMainContent =
(displayContent ?? "").trim().length > 0;
const isEditingMain = editingMainKey === key;
const mainTimestamp = formatCommentDate(ann);
const typeLabel = getAnnotationTypeLabel(ann, t);
const mainTimestamp = formatCommentDate(ann);
const typeLabel = getAnnotationTypeLabel(ann, t);
return (
<Box
key={key}
data-comment-card={key}
p="sm"
style={{
border: selectedAnnotationIds.has(id)
? "1px solid var(--mantine-color-blue-3)"
: "1px solid var(--border-subtle)",
borderRadius: 8,
backgroundColor: "var(--bg-raised)",
}}
>
<Group
wrap="nowrap"
gap="xs"
justify="space-between"
align="flex-start"
mb="xs"
return (
<Box
key={key}
data-comment-card={key}
p="sm"
style={{
border: selectedAnnotationIds.has(id)
? "1px solid var(--mantine-color-blue-3)"
: "1px solid var(--border-subtle)",
borderRadius: 8,
backgroundColor: "var(--bg-raised)",
}}
>
<Group
wrap="nowrap"
gap="xs"
style={{ minWidth: 0, flex: 1 }}
justify="space-between"
align="flex-start"
mb="xs"
>
<AnnotationTypeIcon ann={ann} />
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{authorName}
</Text>
<Text size="xs" c="dimmed">
{typeLabel}
{mainTimestamp ? ` · ${mainTimestamp}` : ""}
</Text>
</Box>
</Group>
<Group
gap={2}
wrap="nowrap"
style={{ flexShrink: 0 }}
>
<Tooltip
label={t(
"viewer.comments.locateAnnotation",
"Locate in document",
)}
<Group
wrap="nowrap"
gap="xs"
style={{ minWidth: 0, flex: 1 }}
>
<AnnotationTypeIcon ann={ann} />
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{authorName}
</Text>
<Text size="xs" c="dimmed">
{typeLabel}
{mainTimestamp ? ` · ${mainTimestamp}` : ""}
</Text>
</Box>
</Group>
<Group
gap={2}
wrap="nowrap"
style={{ flexShrink: 0 }}
>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={() =>
handleLocateAnnotation(pageIndex, ann)
}
>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
<Menu position="bottom-end" withArrow>
<Menu.Target>
<Tooltip
label={t(
"viewer.comments.moreActions",
"More actions",
)}
>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
>
<MoreHorizIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<EditIcon style={{ fontSize: 18 }} />
}
onClick={() => setEditingMainKey(key)}
>
{t("annotation.editText", "Edit")}
</Menu.Item>
<Menu.Item
leftSection={
<DeleteIcon style={{ fontSize: 18 }} />
}
color="red"
onClick={() =>
handleDeleteClick(pageIndex, id, ann)
}
>
{t("annotation.delete", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
{!hasMainContent || isEditingMain ? (
<>
<Textarea
placeholder={t(
"viewer.comments.addCommentPlaceholder",
"Add comment...",
)}
minRows={2}
autosize
value={draft ?? ""}
onChange={(e) => {
const v =
(e?.currentTarget ?? e?.target)?.value ??
"";
setDraftContents((prev) => ({
...prev,
[key]: v,
}));
if (isEditingMain) {
handleContentsChange(pageIndex, id, v);
}
}}
styles={{ root: { width: "100%" } }}
mb="xs"
/>
<Group gap={4} wrap="nowrap" justify="flex-end">
<Tooltip
label={t(
"viewer.comments.addComment",
"Add comment",
"viewer.comments.locateAnnotation",
"Locate in document",
)}
>
<ActionIcon
variant="filled"
variant="subtle"
size="sm"
color="blue"
onClick={() => {
handleSendMainComment(
pageIndex,
id,
draft ?? "",
);
setEditingMainKey(null);
}}
disabled={!(draft ?? "").trim()}
color="gray"
onClick={() =>
handleLocateAnnotation(pageIndex, ann)
}
>
<CheckIcon
style={{ fontSize: 18, color: "white" }}
/>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<>
<Text
size="sm"
mb="sm"
style={{ whiteSpace: "pre-wrap" }}
>
{displayContent}
</Text>
{entry.replies?.length ? (
<Stack gap="sm" mb="sm">
{entry.replies.map((r) => {
const rObj = r?.object;
const rId = rObj?.id;
if (!rId) return null;
const rAuthor = getAuthorName(
rObj,
displayName,
);
const rTimestamp = formatCommentDate(rObj);
const replyEditKey = `${pageIndex}_${id}_${rId}`;
const isEditingReply =
editingReplyKey === replyEditKey;
const canEditReply =
isReplyAuthoredByCurrentUser(
rObj,
displayName,
);
const replyBody =
replyEditDrafts[replyEditKey] !==
undefined
? replyEditDrafts[replyEditKey]
: (rObj?.contents ?? "");
return (
<Box
key={rId}
pl="xs"
style={{
borderLeft:
"2px solid var(--mantine-color-blue-3)",
}}
<Menu position="bottom-end" withArrow>
<Menu.Target>
<Tooltip
label={t(
"viewer.comments.moreActions",
"More actions",
)}
>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
>
<Box style={{ minWidth: 0 }}>
<Group
wrap="nowrap"
justify="space-between"
align="flex-start"
gap={4}
mb={2}
>
<Text size="sm" fw={600}>
{rAuthor}
</Text>
<Group
wrap="nowrap"
gap="xs"
align="center"
>
{canEditReply &&
!isEditingReply ? (
<UnstyledButton
type="button"
onClick={() => {
setEditingReplyKey(
replyEditKey,
);
setReplyEditDrafts(() => ({
[replyEditKey]: String(
rObj?.contents ?? "",
),
}));
}}
>
<Text size="xs" c="blue">
{t(
"annotation.editText",
"Edit",
)}
</Text>
</UnstyledButton>
) : null}
{rTimestamp ? (
<Text size="xs" c="dimmed">
{rTimestamp}
</Text>
) : null}
</Group>
</Group>
{isEditingReply ? (
<>
<Textarea
minRows={2}
autosize
value={replyBody}
onChange={(e) => {
const v =
(
e?.currentTarget ??
e?.target
)?.value ?? "";
setReplyEditDrafts((p) => ({
...p,
[replyEditKey]: v,
}));
}}
styles={{
root: { width: "100%" },
}}
mb="xs"
/>
<Group
gap={4}
wrap="nowrap"
justify="flex-end"
>
<Tooltip
label={t(
"viewer.comments.saveReply",
"Save reply",
)}
>
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() =>
handleSaveReplyEdit(
replyEditKey,
pageIndex,
rId,
replyBody,
)
}
disabled={!replyBody.trim()}
>
<CheckIcon
style={{
fontSize: 18,
color: "white",
}}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<Text
size="sm"
style={{ whiteSpace: "pre-wrap" }}
>
{rObj?.contents ?? ""}
</Text>
)}
</Box>
</Box>
);
})}
</Stack>
) : null}
<MoreHorizIcon
style={{ fontSize: 20 }}
/>
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<EditIcon style={{ fontSize: 18 }} />
}
onClick={() => setEditingMainKey(key)}
>
{t("annotation.editText", "Edit")}
</Menu.Item>
<Menu.Item
leftSection={
<DeleteIcon style={{ fontSize: 18 }} />
}
color="red"
onClick={() =>
handleDeleteClick(pageIndex, id, ann)
}
>
{t("annotation.delete", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
<Group gap="xs" wrap="nowrap" align="flex-end">
<TextInput
{!hasMainContent || isEditingMain ? (
<>
<Textarea
placeholder={t(
"viewer.comments.addReplyPlaceholder",
"Add reply...",
"viewer.comments.addCommentPlaceholder",
"Add comment...",
)}
size="xs"
value={replyDraft}
minRows={2}
autosize
value={draft ?? ""}
onChange={(e) => {
const v =
(e?.currentTarget ?? e?.target)?.value ??
"";
setReplyDrafts((p) => ({
...p,
[replyKey]: v,
setDraftContents((prev) => ({
...prev,
[key]: v,
}));
}}
style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor:
"var(--mantine-color-blue-3)",
},
}}
/>
<Tooltip
label={t(
"viewer.comments.addComment",
"Add comment",
)}
>
<ActionIcon
variant="filled"
size="md"
color="blue"
style={{
backgroundColor:
"var(--mantine-color-blue-6)",
}}
onClick={() =>
handleSendReply(pageIndex, id, ann?.rect)
if (isEditingMain) {
handleContentsChange(pageIndex, id, v);
}
disabled={!replyDraft.trim()}
}}
styles={{ root: { width: "100%" } }}
mb="xs"
/>
<Group gap={4} wrap="nowrap" justify="flex-end">
<Tooltip
label={t(
"viewer.comments.addComment",
"Add comment",
)}
>
<CheckIcon
style={{ fontSize: 20, color: "white" }}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
)}
</Box>
);
})}
</Stack>
</Box>
);
})
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() => {
handleSendMainComment(
pageIndex,
id,
draft ?? "",
);
setEditingMainKey(null);
}}
disabled={!(draft ?? "").trim()}
>
<CheckIcon
style={{ fontSize: 18, color: "white" }}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<>
<Text
size="sm"
mb="sm"
style={{ whiteSpace: "pre-wrap" }}
>
{displayContent}
</Text>
{entry.replies?.length ? (
<Stack gap="sm" mb="sm">
{entry.replies.map((r) => {
const rObj = r?.object;
const rId = rObj?.id;
if (!rId) return null;
const rAuthor = getAuthorName(
rObj,
displayName,
);
const rTimestamp =
formatCommentDate(rObj);
const replyEditKey = `${pageIndex}_${id}_${rId}`;
const isEditingReply =
editingReplyKey === replyEditKey;
const canEditReply =
isReplyAuthoredByCurrentUser(
rObj,
displayName,
);
const replyBody =
replyEditDrafts[replyEditKey] !==
undefined
? replyEditDrafts[replyEditKey]
: (rObj?.contents ?? "");
return (
<Box
key={rId}
pl="xs"
style={{
borderLeft:
"2px solid var(--mantine-color-blue-3)",
}}
>
<Box style={{ minWidth: 0 }}>
<Group
wrap="nowrap"
justify="space-between"
align="flex-start"
gap={4}
mb={2}
>
<Text size="sm" fw={600}>
{rAuthor}
</Text>
<Group
wrap="nowrap"
gap="xs"
align="center"
>
{canEditReply &&
!isEditingReply ? (
<UnstyledButton
type="button"
onClick={() => {
setEditingReplyKey(
replyEditKey,
);
setReplyEditDrafts(
() => ({
[replyEditKey]:
String(
rObj?.contents ??
"",
),
}),
);
}}
>
<Text size="xs" c="blue">
{t(
"annotation.editText",
"Edit",
)}
</Text>
</UnstyledButton>
) : null}
{rTimestamp ? (
<Text size="xs" c="dimmed">
{rTimestamp}
</Text>
) : null}
</Group>
</Group>
{isEditingReply ? (
<>
<Textarea
minRows={2}
autosize
value={replyBody}
onChange={(e) => {
const v =
(
e?.currentTarget ??
e?.target
)?.value ?? "";
setReplyEditDrafts((p) => ({
...p,
[replyEditKey]: v,
}));
}}
styles={{
root: { width: "100%" },
}}
mb="xs"
/>
<Group
gap={4}
wrap="nowrap"
justify="flex-end"
>
<Tooltip
label={t(
"viewer.comments.saveReply",
"Save reply",
)}
>
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() =>
handleSaveReplyEdit(
replyEditKey,
pageIndex,
rId,
replyBody,
)
}
disabled={
!replyBody.trim()
}
>
<CheckIcon
style={{
fontSize: 18,
color: "white",
}}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<Text
size="sm"
style={{
whiteSpace: "pre-wrap",
}}
>
{rObj?.contents ?? ""}
</Text>
)}
</Box>
</Box>
);
})}
</Stack>
) : null}
<Group gap="xs" wrap="nowrap" align="flex-end">
<TextInput
placeholder={t(
"viewer.comments.addReplyPlaceholder",
"Add reply...",
)}
size="xs"
value={replyDraft}
onChange={(e) => {
const v =
(e?.currentTarget ?? e?.target)
?.value ?? "";
setReplyDrafts((p) => ({
...p,
[replyKey]: v,
}));
}}
style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor:
"var(--mantine-color-blue-3)",
},
}}
/>
<Tooltip
label={t(
"viewer.comments.addComment",
"Add comment",
)}
>
<ActionIcon
variant="filled"
size="md"
color="blue"
style={{
backgroundColor:
"var(--mantine-color-blue-6)",
}}
onClick={() =>
handleSendReply(
pageIndex,
id,
ann?.rect,
)
}
disabled={!replyDraft.trim()}
>
<CheckIcon
style={{ fontSize: 20, color: "white" }}
/>
</ActionIcon>
</Tooltip>
</Group>
</>
)}
</Box>
);
})}
</Stack>
</Box>
);
})}
</>
)}
</Stack>
</ScrollArea>
@@ -12,6 +12,8 @@ import {
import LayersIcon from "@mui/icons-material/Layers";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useViewer } from "@app/contexts/ViewerContext";
import "@app/components/viewer/SidebarBase.css";
import "@app/components/viewer/LayerSidebar.css";
import {
@@ -49,6 +51,7 @@ export function LayerSidebar({
onApplyLayers,
onLayersDetected,
}: LayerSidebarProps) {
const { toggleLayerSidebar } = useViewer();
const [layers, setLayers] = useState<LayerInfo[]>([]);
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
const [status, setStatus] = useState<LoadStatus>("idle");
@@ -337,30 +340,42 @@ export function LayerSidebar({
{isApplying && <Loader size="xs" type="dots" />}
</div>
{status === "ready" && leafIds.length > 0 && (
<div className="layer-sidebar__header-actions">
<ActionIcon
variant="subtle"
size="sm"
onClick={showAll}
disabled={allVisible || isApplying}
aria-label="Show all layers"
title="Show all"
>
<VisibilityIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={hideAll}
disabled={allHidden || isApplying}
aria-label="Hide all layers"
title="Hide all"
>
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</div>
)}
<div className="layer-sidebar__header-actions">
{status === "ready" && leafIds.length > 0 && (
<>
<ActionIcon
variant="subtle"
size="sm"
onClick={showAll}
disabled={allVisible || isApplying}
aria-label="Show all layers"
title="Show all"
>
<VisibilityIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={hideAll}
disabled={allHidden || isApplying}
aria-label="Hide all layers"
title="Hide all"
>
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</>
)}
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={toggleLayerSidebar}
aria-label="Close layers sidebar"
title="Close layers"
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</div>
</div>
{/* Content */}
@@ -1,7 +1,10 @@
import { useState, useEffect, useRef } from "react";
import { Box, ScrollArea } from "@mantine/core";
import { Box, ScrollArea, Text, ActionIcon } from "@mantine/core";
import { useViewer } from "@app/contexts/ViewerContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import LocalIcon from "@app/components/shared/LocalIcon";
import ViewListIcon from "@mui/icons-material/ViewList";
import "@app/components/viewer/SidebarBase.css";
interface ThumbnailSidebarProps {
visible: boolean;
@@ -11,7 +14,7 @@ interface ThumbnailSidebarProps {
export function ThumbnailSidebar({
visible,
onToggle: _onToggle,
onToggle,
activeFileId,
}: ThumbnailSidebarProps) {
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
@@ -153,20 +156,42 @@ export function ThumbnailSidebar({
{/* Thumbnail Sidebar */}
{visible && (
<Box
className="sidebar-base"
style={{
position: "fixed",
right: 0,
top: 0,
bottom: 0,
width: "15rem",
backgroundColor: "var(--bg-surface)",
borderLeft: "1px solid var(--border-subtle)",
zIndex: 998,
display: "flex",
flexDirection: "column",
boxShadow: "-2px 0 8px rgba(0, 0, 0, 0.1)",
}}
>
<div className="sidebar-base__header">
<div className="sidebar-base__header-title">
<span className="sidebar-base__header-icon">
<ViewListIcon fontSize="small" />
</span>
<Text
fw={600}
size="sm"
tt="uppercase"
lts={0.5}
style={{ flex: 1 }}
>
Pages
</Text>
</div>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onToggle}
aria-label="Close thumbnails sidebar"
title="Close thumbnails"
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</div>
{/* Thumbnails Container */}
<ScrollArea style={{ flex: 1 }}>
<Box p="sm">
@@ -1,4 +1,11 @@
import React, { createContext, useContext, ReactNode, useRef } from "react";
import React, {
createContext,
useContext,
ReactNode,
useRef,
useState,
useCallback,
} from "react";
import type {
AnnotationAPI,
AnnotationToolId,
@@ -10,6 +17,16 @@ interface AnnotationContextValue {
activateAnnotationToolRef: React.RefObject<
((toolId: AnnotationToolId) => void) | null
>;
/**
* Observable copy of the annotation panel's currently armed tool.
* Annotate.tsx mirrors its local activeTool state here so the
* CommentsSidebar (and other components outside the panel) can react
* to "textComment is armed" without coupling to the panel's internals.
* Null when no annotation tool is armed (panel not mounted or set to
* "select").
*/
activeAnnotationToolId: AnnotationToolId | null;
setActiveAnnotationToolId: (id: AnnotationToolId | null) => void;
}
const AnnotationContext = createContext<AnnotationContextValue | undefined>(
@@ -23,10 +40,20 @@ export const AnnotationProvider: React.FC<{ children: ReactNode }> = ({
const activateAnnotationToolRef = useRef<
((toolId: AnnotationToolId) => void) | null
>(null);
const [activeAnnotationToolId, setActiveAnnotationToolIdState] =
useState<AnnotationToolId | null>(null);
const setActiveAnnotationToolId = useCallback(
(id: AnnotationToolId | null) => {
setActiveAnnotationToolIdState(id);
},
[],
);
const value: AnnotationContextValue = {
annotationApiRef,
activateAnnotationToolRef,
activeAnnotationToolId,
setActiveAnnotationToolId,
};
return (
@@ -11,6 +11,10 @@ import React, {
import { useNavigation } from "@app/contexts/NavigationContext";
import { useFileState } from "@app/contexts/FileContext";
import { isStirlingFile } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { enforceExportPolicies } from "@app/services/policyExport";
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import {
preferencesService,
type PdfRenderMode,
@@ -216,6 +220,7 @@ interface ViewerProviderProps {
}
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const { t } = useTranslation();
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] =
useState(false);
@@ -537,6 +542,45 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
triggerImmediateZoomUpdate,
});
// Printing is an exit path, so a "run on export" policy must enforce here too.
// Enforce the current file through the same path export uses: when a policy
// rewrites it, that path versions the in-editor file to the enforced output
// and marks it enforced, so a follow-up print of the unedited result prints
// it as-is instead of re-running the (non-idempotent) policy. Ask the user to
// review the updated doc before printing again, rather than printing bytes
// they haven't seen. With no active export policy this is a no-op and print
// runs straight away.
const printWithPolicy = useCallback(async () => {
const file = activeFileId
? selectors.getFiles([activeFileId as FileId])[0]
: undefined;
if (!activeFileId || !file) {
printActions.print();
return;
}
const [enforced] = await enforceExportPolicies(
[file],
[activeFileId],
"print",
);
// Original file back means no policy rewrote it (no active policy, already
// enforced, or graceful failure fallback) — nothing new to review, print it.
if (!enforced || enforced === file) {
printActions.print();
return;
}
alert({
alertType: "warning",
title: t("policies.enforcement.printPolicyAppliedTitle"),
body: t("policies.enforcement.printPolicyAppliedBody"),
});
}, [activeFileId, selectors, printActions]);
const enforcedPrintActions = useMemo<PrintActions>(
() => ({ print: printWithPolicy }),
[printWithPolicy],
);
const value: ViewerContextType = {
// UI state
isThumbnailSidebarVisible,
@@ -610,7 +654,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
exportActions,
bookmarkActions,
attachmentActions,
printActions,
printActions: enforcedPrintActions,
// Bridge registration
registerBridge,
@@ -7,6 +7,7 @@
export async function enforceExportPolicies(
files: File[],
_fileIds?: (string | undefined)[],
_trigger?: "export" | "print" | "convert" | "input",
): Promise<File[]> {
return files;
}
@@ -0,0 +1,218 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
import { runToolAndWaitForReview } from "@app/tests/helpers/ui-helpers";
import * as path from "path";
import * as fs from "fs";
import {
PDFDocument,
PDFName,
PDFDict,
PDFString,
PDFHexString,
} from "@cantoo/pdf-lib";
/**
* End-to-end validation of the new "Add attachment" and "Add bookmark"
* buttons on the viewer's attachment / bookmark sidebars.
*
* Each test:
* 1. Logs in and uploads a sample PDF via the Read tool's viewer.
* 2. Opens the relevant sidebar.
* 3. Confirms the empty-state Add button is visible.
* 4. Clicks it - URL must switch to the corresponding tool page.
* 5. Completes the tool's flow (pick a file to attach / type a
* bookmark title).
* 6. Runs the tool and intercepts the backend response.
* 7. Loads the produced PDF with pdf-lib and verifies it actually
* contains the new attachment / new bookmark.
*
* Requires a real Spring Boot backend on :8080 - registered under the
* `live` Playwright project. The `live-setup` project bootstraps the
* admin user before this runs.
*/
function fixture(filename: string): string {
const candidates = [
path.resolve(
process.cwd(),
"src",
"core",
"tests",
"test-fixtures",
filename,
),
path.resolve(
process.cwd(),
"frontend",
"src",
"core",
"tests",
"test-fixtures",
filename,
),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
throw new Error(
`Test fixture not found: ${filename} (tried: ${candidates.join(", ")})`,
);
}
async function openSamplePdfInViewer(page: import("@playwright/test").Page) {
await page.goto("/read");
await page.waitForLoadState("domcontentloaded");
await page
.locator('[data-testid="file-input"]')
.first()
.setInputFiles(fixture("sample.pdf"));
// Page indicator confirms the embedded viewer has the document loaded.
await expect(page.getByText(/\/\s*1/).first()).toBeVisible({
timeout: 30_000,
});
}
test.describe("Viewer sidebar add buttons - real PDF round-trip", () => {
test.describe.configure({ timeout: 180_000 });
test.beforeEach(async ({ page }) => {
await loginAndSetup(page);
});
test("Add attachment from viewer sidebar embeds the chosen file in the produced PDF", async ({
page,
}) => {
await openSamplePdfInViewer(page);
await page
.getByRole("button", { name: /Toggle Attachments/i })
.first()
.click();
const addBtn = page.getByRole("button", { name: /^Add attachment$/i });
await expect(addBtn).toBeVisible({ timeout: 15_000 });
await addBtn.click();
await expect(page).toHaveURL(/\/add-attachments$/, { timeout: 10_000 });
// Hidden picker the AddAttachments tool exposes. Attach a small known
// file - reuse the sample fixture as the attachment payload so we can
// assert on its filename below.
const attachmentName = "sample.pdf";
await page
.locator("#attachments-input")
.setInputFiles(fixture(attachmentName));
// Capture the backend response so we can inspect the produced PDF.
const responsePromise = page.waitForResponse(
(r) =>
/\/api\/v1\/(general|misc)\/add-attachments$/.test(r.url()) &&
r.status() === 200,
{ timeout: 90_000 },
);
await runToolAndWaitForReview(page);
const response = await responsePromise;
const pdfBytes = await response.body();
// Sanity: response is a PDF (starts with %PDF-)
expect(pdfBytes.slice(0, 5).toString()).toBe("%PDF-");
// Verify the produced PDF actually contains the attachment.
const doc = await PDFDocument.load(pdfBytes, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
const attachments = doc.getAttachments();
expect(attachments.length).toBeGreaterThan(0);
expect(attachments.map((a) => a.name)).toContain(attachmentName);
});
test("Add bookmark from viewer sidebar adds the bookmark to the produced PDF outline", async ({
page,
}) => {
await openSamplePdfInViewer(page);
const viewerUrl = page.url();
await page
.getByRole("button", { name: /Toggle Bookmarks/i })
.first()
.click();
const addBtn = page.getByRole("button", { name: /^Add bookmark$/i });
await expect(addBtn).toBeVisible({ timeout: 15_000 });
await addBtn.click();
// Stays in the viewer - URL doesn't change to /edit-table-of-contents.
expect(page.url()).toBe(viewerUrl);
// Inline form appears inside the sidebar with title + page inputs.
const form = page.locator('[data-testid="bookmark-add-form"]');
await expect(form).toBeVisible({ timeout: 10_000 });
const BOOKMARK_TITLE = `Playwright test bookmark ${Date.now()}`;
await form
.getByRole("textbox", { name: /Bookmark title/i })
.fill(BOOKMARK_TITLE);
// Capture the backend POST the inline Save kicks off.
const responsePromise = page.waitForResponse(
(r) =>
/\/api\/v1\/general\/edit-table-of-contents$/.test(r.url()) &&
r.status() === 200,
{ timeout: 90_000 },
);
await form.getByRole("button", { name: /^Save$/i }).click();
const response = await responsePromise;
const pdfBytes = await response.body();
// Form should close on success and the user should still be in the
// viewer (no tool navigation).
await expect(form).not.toBeVisible({ timeout: 10_000 });
expect(page.url()).toBe(viewerUrl);
expect(pdfBytes.slice(0, 5).toString()).toBe("%PDF-");
// Verify the produced PDF has an /Outlines entry in the catalog and
// the bookmark title we set is present in the document.
const doc = await PDFDocument.load(pdfBytes, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
const outlinesRef = doc.catalog.get(PDFName.of("Outlines"));
expect(outlinesRef, "PDF catalog must have /Outlines entry").toBeDefined();
// Walk the outline tree and collect titles.
const outlinesDict = doc.context.lookup(outlinesRef, PDFDict);
const collectTitles = (
dictRef: ReturnType<PDFDict["get"]> | undefined,
acc: string[],
): string[] => {
if (!dictRef) return acc;
const node = doc.context.lookupMaybe(dictRef, PDFDict);
if (!node) return acc;
const title = node.get(PDFName.of("Title"));
if (title instanceof PDFString || title instanceof PDFHexString) {
try {
acc.push(title.decodeText());
} catch {
// Title couldn't decode - fall back to asString
acc.push(title.asString());
}
}
collectTitles(node.get(PDFName.of("First")), acc);
collectTitles(node.get(PDFName.of("Next")), acc);
return acc;
};
const titles = collectTitles(outlinesDict.get(PDFName.of("First")), []);
expect(
titles,
`expected outline titles to include "${BOOKMARK_TITLE}", got ${JSON.stringify(titles)}`,
).toContain(BOOKMARK_TITLE);
});
});
@@ -0,0 +1,198 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
/**
* Verifies the new "Add attachment" and "Add bookmark" buttons on the
* viewer's attachment + bookmark sidebars.
*
* After the UX refactor:
* - Attachment sidebar's Add button still navigates to the
* AddAttachments tool, BUT also closes the attachment sidebar so
* the user doesn't see two stacked side panels.
* - Bookmark sidebar's Add button opens an inline form (title + page,
* defaulting to the current page) inside the sidebar - the user
* never leaves the viewer. Submitting POSTs to the backend (not
* covered here - see the live spec for that).
*
* Backend-free spec.
*/
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
async function openViewerWithSample(page: import("@playwright/test").Page) {
await page.goto("/read");
await page.waitForLoadState("domcontentloaded");
await page
.locator('[data-testid="file-input"]')
.first()
.setInputFiles(SAMPLE_PDF);
await expect(page.getByText(/\/\s*1/).first()).toBeVisible({
timeout: 30_000,
});
}
test.describe("Viewer sidebar: Add attachment / Add bookmark buttons", () => {
test("Attachment sidebar Add button navigates to tool and closes the sidebar", async ({
page,
}) => {
await openViewerWithSample(page);
const attachmentsToggle = page
.getByRole("button", { name: /Toggle Attachments/i })
.first();
await attachmentsToggle.click();
// Sidebar header shows up
const sidebarTitle = page.getByText(/^Attachments$/i).first();
await expect(sidebarTitle).toBeVisible({ timeout: 10_000 });
const addBtn = page.getByRole("button", { name: /^Add attachment$/i });
await expect(addBtn).toBeVisible({ timeout: 15_000 });
await addBtn.click();
// URL syncs to /add-attachments
await expect(page).toHaveURL(/\/add-attachments$/, { timeout: 10_000 });
// Sidebar should have auto-closed (no stacked panels)
await expect(sidebarTitle).not.toBeVisible({ timeout: 5_000 });
});
test("Bookmark sidebar Add button opens an inline form (no navigation away from viewer)", async ({
page,
}) => {
await openViewerWithSample(page);
const initialUrl = page.url();
await page
.getByRole("button", { name: /Toggle Bookmarks/i })
.first()
.click();
await expect(page.getByText(/^Bookmarks$/i).first()).toBeVisible({
timeout: 10_000,
});
const addBtn = page.getByRole("button", { name: /^Add bookmark$/i });
await expect(addBtn).toBeVisible({ timeout: 15_000 });
await addBtn.click();
// Inline form appears (title + page inputs + Save/Cancel) - the
// form is identifiable by its data-testid so we don't depend on
// matching label text fragility.
const form = page.locator('[data-testid="bookmark-add-form"]');
await expect(form).toBeVisible({ timeout: 10_000 });
await expect(
form.getByRole("textbox", { name: /Bookmark title/i }),
).toBeVisible();
await expect(form.getByRole("button", { name: /^Save$/i })).toBeVisible();
await expect(form.getByRole("button", { name: /^Cancel$/i })).toBeVisible();
// Crucially, the URL did NOT change to the tool route - user stays
// in the viewer.
expect(page.url()).toBe(initialUrl);
// Cancel closes the form (no backend hit)
await form.getByRole("button", { name: /^Cancel$/i }).click();
await expect(form).not.toBeVisible({ timeout: 5_000 });
});
test("Bookmark Save POSTs to backend, closes the form, and stays in viewer", async ({
page,
}) => {
// Stub the edit-table-of-contents endpoint with a tiny valid PDF
// blob so the Save flow can complete without the live backend.
// The minimal PDF below is just enough that PDFDocument loaders
// accept it as application/pdf - we don't need real bookmarks in
// the response for this assertion. We only check that:
// (1) the click actually issues the POST (regression for the
// silent-fallback bug where activeFileId was null on a fresh
// upload and Save quietly routed to the full editor); and
// (2) the form closes and the viewer URL doesn't change.
const MINIMAL_PDF =
"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000010 00000 n\n0000000053 00000 n\n0000000098 00000 n\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n149\n%%EOF\n";
let postSeen = false;
await page.route(
"**/api/v1/general/edit-table-of-contents",
async (route) => {
postSeen = true;
await route.fulfill({
status: 200,
contentType: "application/pdf",
body: Buffer.from(MINIMAL_PDF, "binary"),
});
},
);
await openViewerWithSample(page);
const viewerUrl = page.url();
await page
.getByRole("button", { name: /Toggle Bookmarks/i })
.first()
.click();
await page.getByRole("button", { name: /^Add bookmark$/i }).click();
const form = page.locator('[data-testid="bookmark-add-form"]');
await expect(form).toBeVisible({ timeout: 10_000 });
await form
.getByRole("textbox", { name: /Bookmark title/i })
.fill("Stub bookmark");
await form.getByRole("button", { name: /^Save$/i }).click();
// The POST must actually have fired. The earlier silent-fallback
// bug would have routed to /edit-table-of-contents without ever
// calling the API.
await expect.poll(() => postSeen, { timeout: 15_000 }).toBe(true);
// Form closes on success, viewer URL unchanged (no tool nav).
await expect(form).not.toBeVisible({ timeout: 10_000 });
expect(page.url()).toBe(viewerUrl);
});
test("Each sidebar header has a close (X) button that dismisses it", async ({
page,
}) => {
await openViewerWithSample(page);
// Visibility of the close button itself is the stable signal:
// it's rendered only when the sidebar is mounted.
//
// Mantine 8 ActionIcon has a known pointer-event sequence quirk
// where Playwright's synthesized click() can leave the button in
// mantine-active without firing the React onClick handler. We
// sidestep it by dispatching mousedown + mouseup + click explicitly,
// which mirrors a real user's pointer interaction in browser.
const closeViaButton = async (name: RegExp) => {
const btn = page.getByRole("button", { name });
await expect(btn).toBeVisible({ timeout: 10_000 });
await btn.dispatchEvent("mousedown");
await btn.dispatchEvent("mouseup");
await btn.dispatchEvent("click");
await expect(btn).not.toBeVisible({ timeout: 5_000 });
};
// Bookmark sidebar
await page
.getByRole("button", { name: /Toggle Bookmarks/i })
.first()
.click();
await closeViaButton(/Close bookmarks sidebar/i);
// Attachment sidebar
await page
.getByRole("button", { name: /Toggle Attachments/i })
.first()
.click();
await closeViaButton(/Close attachments sidebar/i);
// Thumbnail sidebar
await page
.getByRole("button", { name: /Toggle Sidebar/i })
.first()
.click();
await closeViaButton(/Close thumbnails sidebar/i);
});
});
+17 -2
View File
@@ -89,7 +89,8 @@ const Annotate = (_props: BaseToolProps) => {
placementPreviewSize,
setPlacementPreviewSize,
} = useSignature();
const { activateAnnotationToolRef } = useAnnotationContext();
const { activateAnnotationToolRef, setActiveAnnotationToolId } =
useAnnotationContext();
const viewerContext = useContext(ViewerContext);
const viewerContextRef = useRef(viewerContext);
useEffect(() => {
@@ -141,7 +142,21 @@ const Annotate = (_props: BaseToolProps) => {
useEffect(() => {
activeToolRef.current = activeTool;
}, [activeTool]);
// Mirror the panel's armed tool to AnnotationContext so callers
// outside the panel (CommentsSidebar's Add Comment hint) can react
// without coupling to this component's internals. "select" means
// nothing is armed - publish null so consumers can branch cheaply.
setActiveAnnotationToolId(activeTool === "select" ? null : activeTool);
}, [activeTool, setActiveAnnotationToolId]);
// Make sure we clear the published tool when the annotation panel
// unmounts (user navigated away from /annotate). Otherwise the
// sidebar would think textComment is still armed.
useEffect(() => {
return () => {
setActiveAnnotationToolId(null);
};
}, [setActiveAnnotationToolId]);
// CSS to PDF size conversion accounting for zoom
const cssToPdfSize = useCallback(
@@ -25,6 +25,7 @@ import DownloadIcon from "@mui/icons-material/Download";
import SaveIcon from "@mui/icons-material/Save";
import EditNoteIcon from "@mui/icons-material/EditNote";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
interface FormSaveBarProps {
/** The current file being viewed */
@@ -77,15 +78,12 @@ export function FormSaveBar({
setSaving(true);
try {
const blob = await submitForm(file, false);
// Trigger browser download
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file instanceof File ? file.name : "filled-form.pdf";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Route through the export gateway so a "run on export" policy enforces on
// the filled PDF before it leaves the app (no-op when no such policy is set).
await downloadFileWithPolicy({
data: blob,
filename: file instanceof File ? file.name : "filled-form.pdf",
});
} catch (err) {
console.error("[FormSaveBar] Download failed:", err);
} finally {
@@ -1,6 +1,8 @@
import { useEffect } from "react";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { downloadFile } from "@app/services/downloadService";
// Save through the export gateway so a "run on export" policy enforces before
// the file is written out (no-op when no such policy is active).
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
/**
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
@@ -1,10 +1,9 @@
import type { FileId } from "@app/types/fileContext";
import type { OperationSaveContext } from "@core/services/operationResultsSaveService";
import {
downloadFile,
downloadFromUrl,
DownloadResult,
} from "@app/services/downloadService";
import { downloadFromUrl, DownloadResult } from "@app/services/downloadService";
// Save through the export gateway so a "run on export" policy enforces before
// the file is written out (no-op when no such policy is active).
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
export type { OperationSaveContext };
@@ -0,0 +1,46 @@
/**
* Compact status row for the {@link enforcementQueue}, shown in the Policies
* panel whenever enforcement jobs are pending or running. The queue is serial,
* so a slow policy run would otherwise be invisible — this surfaces what's being
* enforced (before export, print, convert, …) and how many jobs are waiting.
*/
import { useTranslation } from "react-i18next";
import { Group, Text, Loader } from "@mantine/core";
import { useEnforcementQueue } from "@app/components/policies/enforcementQueue";
export function EnforcementQueueStatus() {
const { t } = useTranslation();
const jobs = useEnforcementQueue();
const active = jobs.filter(
(j) => j.status === "pending" || j.status === "running",
);
if (active.length === 0) return null;
// The running job leads the row; everything else is still queued behind it.
const lead = active.find((j) => j.status === "running") ?? active[0];
const queued = active.length - 1;
return (
<Group
gap="xs"
wrap="nowrap"
px="sm"
py={6}
role="status"
aria-live="polite"
>
<Loader size="xs" />
<Text size="xs" c="dimmed" truncate>
{t(`policies.enforcement.triggerVerb.${lead.trigger}`, {
defaultValue: t("policies.enforcement.triggerVerb.default"),
})}
: {lead.label}
{queued > 0
? ` · ${t("policies.enforcement.queued", { count: queued })}`
: "…"}
</Text>
</Group>
);
}
export default EnforcementQueueStatus;
@@ -0,0 +1,100 @@
/**
* Serial enforcement queue. Every policy enforcement — before export, before
* print, before a convert/extract, and (later) as files arrive — runs through
* here, one at a time. The backend rejects concurrent policy runs under load,
* and a single in-flight run keeps the queue the user sees honest.
*
* Jobs carry their {@link EnforcementTrigger} and a status so the UI can show
* what's pending/running. Input enforcement reuses this contract unchanged: it
* just submits jobs with `trigger: "input"`.
*/
import { useSyncExternalStore } from "react";
export type EnforcementTrigger = "export" | "print" | "convert" | "input";
export type EnforcementStatus = "pending" | "running" | "done" | "failed";
export interface EnforcementJob {
id: string;
/** Human-readable label, e.g. the policy/file name, shown in the queue UI. */
label: string;
trigger: EnforcementTrigger;
status: EnforcementStatus;
}
/** How long a finished job lingers in the list before it's dropped. */
const DONE_LINGER_MS = 2500;
type Listener = () => void;
const listeners = new Set<Listener>();
let jobs: EnforcementJob[] = [];
// The tail of the serial chain — each new job runs after this resolves.
let tail: Promise<unknown> = Promise.resolve();
let seq = 0;
function emit() {
for (const listener of listeners) listener();
}
function setStatus(id: string, status: EnforcementStatus) {
jobs = jobs.map((j) => (j.id === id ? { ...j, status } : j));
emit();
}
function scheduleRemoval(id: string) {
setTimeout(() => {
jobs = jobs.filter((j) => j.id !== id);
emit();
}, DONE_LINGER_MS);
}
/**
* Run `task` after every job queued before it has finished, tracking its status
* for the UI. The returned promise resolves/rejects with the task's result, so
* callers can `await runQueued(...)` exactly as they would the bare work.
*/
export function runQueued<T>(
meta: { label: string; trigger: EnforcementTrigger },
task: () => Promise<T>,
): Promise<T> {
const id = `enf-${++seq}`;
jobs = [
...jobs,
{ id, label: meta.label, trigger: meta.trigger, status: "pending" },
];
emit();
const run = tail.then(async () => {
setStatus(id, "running");
try {
const result = await task();
setStatus(id, "done");
return result;
} catch (error) {
setStatus(id, "failed");
throw error;
} finally {
scheduleRemoval(id);
}
});
// Keep the chain alive when a task rejects so the next job still runs; callers
// still see the rejection through `run`.
tail = run.catch(() => {});
return run;
}
export function getQueueJobs(): EnforcementJob[] {
return jobs;
}
export function subscribeQueue(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** React view of the live queue (pending + running + briefly-lingering jobs). */
export function useEnforcementQueue(): EnforcementJob[] {
return useSyncExternalStore(subscribeQueue, getQueueJobs, getQueueJobs);
}
@@ -67,7 +67,7 @@ export default function AdminFeaturesSection() {
} = {
serverCertificate: systemData.serverCertificate || {
enabled: true,
organizationName: "Stirling-PDF",
organizationName: "Stirling PDF Inc",
validity: 365,
regenerateOnStartup: false,
},
@@ -258,7 +258,8 @@ export default function AdminFeaturesSection() {
"Organization name for generated certificates",
)}
value={
settings.serverCertificate?.organizationName || "Stirling-PDF"
settings.serverCertificate?.organizationName ||
"Stirling PDF Inc"
}
onChange={(e) =>
setSettings({
@@ -35,8 +35,6 @@ interface SecuritySettingsData {
loginResetTimeMinutes?: number;
xFrameOptions?: string;
jwt?: {
persistence?: boolean;
enableKeyRotation?: boolean;
enableKeyCleanup?: boolean;
tokenExpiryMinutes?: number;
desktopTokenExpiryMinutes?: number;
@@ -163,9 +161,6 @@ export default function AdminSecuritySection() {
securitySettings.loginResetTimeMinutes,
"security.xFrameOptions": securitySettings.xFrameOptions,
// JWT settings
"security.jwt.persistence": securitySettings.jwt?.persistence,
"security.jwt.enableKeyRotation":
securitySettings.jwt?.enableKeyRotation,
"security.jwt.enableKeyCleanup": securitySettings.jwt?.enableKeyCleanup,
"security.jwt.tokenExpiryMinutes":
securitySettings.jwt?.tokenExpiryMinutes,
@@ -519,83 +514,6 @@ export default function AdminSecuritySection() {
{t("admin.settings.security.jwt.label", "JWT Configuration")}
</Text>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.jwt.persistence.label",
"Enable Key Persistence",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.security.jwt.persistence.description",
"Store JWT keys persistently (required for multi-instance deployments)",
)}
</Text>
</div>
<Group gap="xs">
<Switch
name="jwt_persistence"
checked={settings?.jwt?.persistence || false}
onChange={(e) =>
setSettings({
...settings,
jwt: { ...settings?.jwt, persistence: e.target.checked },
})
}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending("jwt.persistence")} />
</Group>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t(
"admin.settings.security.jwt.enableKeyRotation.label",
"Enable Key Rotation",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.security.jwt.enableKeyRotation.description",
"Automatically rotate JWT signing keys for improved security",
)}
</Text>
</div>
<Group gap="xs">
<Switch
name="jwt_enableKeyRotation"
checked={settings?.jwt?.enableKeyRotation || false}
onChange={(e) =>
setSettings({
...settings,
jwt: {
...settings?.jwt,
enableKeyRotation: e.target.checked,
},
})
}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending("jwt.enableKeyRotation")} />
</Group>
</div>
<div
style={{
display: "flex",
@@ -17,10 +17,18 @@ import {
getPolicyRun,
downloadPolicyOutput,
} from "@app/services/policyApi";
import { recordRunStart } from "@app/components/policies/policyRunStore";
import {
recordRunStart,
isDispatched,
} from "@app/components/policies/policyRunStore";
import {
runQueued,
type EnforcementTrigger,
} from "@app/components/policies/enforcementQueue";
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
import { alert, updateToast, dismissToast } from "@app/components/toast";
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
import i18n from "@app/i18n";
/** Poll cadence + cap for a single export run (≈2.5 min worst case). */
const POLL_MS = 2000;
@@ -98,10 +106,29 @@ async function runToCompletion(
if (view.status === "FAILED" || view.status === "CANCELLED") {
throw new Error(view.error || `policy run ${view.status.toLowerCase()}`);
}
if (view.status === "WAITING_FOR_INPUT") {
throw new Error(
"policy requires interactive input and cannot run automatically",
);
}
}
throw new Error("policy run timed out");
}
function enforcedFilesSummary(names: string[]): string {
if (names.length === 1) return names[0];
if (names.length === 2)
return i18n.t("policies.enforcement.summaryTwo", {
first: names[0],
second: names[1],
});
return i18n.t("policies.enforcement.summaryMore", {
first: names[0],
second: names[1],
more: names.length - 2,
});
}
/**
* Enforce every active export-policy on each PDF just before export, returning
* the files in order (enforced, or the original on failure). `fileIds[i]` is the
@@ -113,80 +140,119 @@ async function runToCompletion(
export async function enforceExportPolicies(
files: File[],
fileIds?: (string | undefined)[],
trigger: EnforcementTrigger = "export",
): Promise<File[]> {
const active = activeExportPolicies();
const targets = files.flatMap((f, i) => (isPdf(f) ? [i] : []));
if (!active.length || targets.length === 0) return files;
// Policies that haven't already enforced this exact file version. Enforcing
// versions the in-editor file to the policy's output and marks that output
// dispatched, so an unedited re-export skips re-running — re-applying a
// non-idempotent policy would stack watermarks/flattens. Editing produces a
// new file id that isn't dispatched, so an edited file enforces afresh.
const pendingFor = (fileId: string | undefined) =>
active.filter((p) => !(fileId && isDispatched(p.categoryId, fileId)));
if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files;
const names = active.map((p) => p.label).join(", ");
const toastId = alert({
alertType: "neutral",
title: `Applying ${names}`,
body: `Enforcing ${
targets.length === 1 ? "your file" : `${targets.length} files`
} before export…`,
isPersistentPopup: true,
expandable: false,
glowColor: active[0].accent,
});
const out = [...files];
let failures = 0;
for (const i of targets) {
const file = files[i];
const fileId = fileIds?.[i];
try {
let current = file;
// The last "new version" policy's output is what versions the editor file
// (recording every policy would double-consume the same input).
let versionRun: PolicyRunResult & { categoryId: string };
let hasVersionRun = false;
for (const policy of active) {
const result = await runToCompletion(policy.backendId, current);
current = result.file;
if (policy.outputMode === "new_version" && fileId) {
versionRun = { ...result, categoryId: policy.categoryId };
hasVersionRun = true;
// Serialise through the enforcement queue: one policy run in flight at a time
// (the backend rejects concurrent runs under load), and the user can see
// what's pending. Export, print and convert all share this queue.
return runQueued({ label: names, trigger }, async () => {
// An earlier queued job may have just enforced these same files and marked
// them dispatched, so re-check at run time before doing (or announcing) work.
if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files;
const pending = targets.filter((i) => pendingFor(fileIds?.[i]).length > 0);
const total = pending.length;
const progressTitle = (done: number) =>
total === 1
? i18n.t("policies.enforcement.applying", { names })
: i18n.t("policies.enforcement.applyingProgress", {
names,
done: done + 1,
total,
});
const progressBody = (done: number) => files[pending[done]].name;
const toastId = alert({
alertType: "neutral",
title: progressTitle(0),
body: progressBody(0),
isPersistentPopup: true,
expandable: false,
glowColor: active[0].accent,
});
const out = [...files];
let failures = 0;
let done = 0;
for (const i of pending) {
const file = files[i];
const fileId = fileIds?.[i];
const toRun = pendingFor(fileId);
try {
let current = file;
// The last "new version" policy's output is what versions the editor
// file (recording every policy would double-consume the same input).
let versionRun: (PolicyRunResult & { categoryId: string }) | undefined;
for (const policy of toRun) {
const result = await runToCompletion(policy.backendId, current);
current = result.file;
if (policy.outputMode === "new_version" && fileId) {
versionRun = { ...result, categoryId: policy.categoryId };
}
}
out[i] = current;
done += 1;
if (done < total)
updateToast(toastId, {
title: progressTitle(done),
body: progressBody(done),
});
if (versionRun && fileId) {
recordRunStart({
runId: versionRun.runId,
categoryId: versionRun.categoryId,
fileId,
fileName: file.name,
fileSize: file.size,
status: "COMPLETED",
outputs: versionRun.outputs,
error: null,
startedAt: Date.now(),
});
}
} catch {
failures += 1; // leave out[i] as the original — never hard-block.
}
out[i] = current;
if (hasVersionRun && fileId) {
recordRunStart({
runId: versionRun!.runId,
categoryId: versionRun!.categoryId,
fileId,
fileName: file.name,
fileSize: file.size,
status: "COMPLETED",
outputs: versionRun!.outputs,
error: null,
startedAt: Date.now(),
});
}
} catch {
failures += 1; // leave out[i] as the original — never hard-block.
}
}
updateToast(
toastId,
failures
? {
alertType: "warning",
title: "Exported without full enforcement",
body: `${failures} of ${targets.length} file(s) couldn't be processed and were exported as-is.`,
isPersistentPopup: false,
glowColor: undefined,
}
: {
alertType: "success",
title: `${names} applied`,
body: "Enforced before export.",
isPersistentPopup: false,
glowColor: undefined,
},
);
// update() doesn't reschedule auto-dismiss, so fade the result out explicitly.
window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS);
return out;
updateToast(
toastId,
failures
? {
alertType: "warning",
title: i18n.t("policies.enforcement.failureTitle"),
body: i18n.t("policies.enforcement.failureBody", {
failures,
total,
}),
isPersistentPopup: false,
glowColor: undefined,
}
: {
alertType: "success",
title: i18n.t("policies.enforcement.successTitle", { names }),
body: enforcedFilesSummary(pending.map((i) => files[i].name)),
isPersistentPopup: false,
glowColor: undefined,
},
);
// update() doesn't reschedule auto-dismiss, so fade the result out explicitly.
window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS);
return out;
});
}
@@ -1,55 +1,3 @@
/* SaaS-specific auth styles — imported alongside the base auth.css */
.oauth-container-fullwidth {
display: flex;
flex-direction: column;
gap: 0.75rem; /* 12px */
}
.oauth-button-fullwidth {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 100px;
background-color: #ffffff;
font-size: 1rem;
font-weight: 600;
color: #000000;
cursor: pointer;
gap: 0.5rem;
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
transition:
background-color 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
}
.oauth-button-fullwidth:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.oauth-button-fullwidth:hover:not(:disabled) {
background-color: #fafafa;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
[data-mantine-color-scheme="dark"] .oauth-button-fullwidth {
background-color: var(--bg-surface);
color: var(--text-primary);
border-color: var(--border-default);
box-shadow: none;
}
[data-mantine-color-scheme="dark"]
.oauth-button-fullwidth:hover:not(:disabled) {
background-color: var(--bg-raised);
box-shadow: none;
}
.auth-dropdown-wrapper {
position: relative;
}