merge main
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 |
+32
-223
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
+3
-2
@@ -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({
|
||||
|
||||
-82
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user