mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
@@ -8,5 +8,8 @@
|
||||
# Userback feedback widget — leave blank to disable
|
||||
VITE_USERBACK_TOKEN=
|
||||
|
||||
# Dev-only auth bypass for localhost. Subpath comes from RUN_SUBPATH (build-time).
|
||||
# URL subpath prefix for SaaS deployments (e.g. "app" if serving at /app/) — leave blank for root
|
||||
VITE_RUN_SUBPATH=
|
||||
|
||||
# Development-only auth bypass — allows unauthenticated access on localhost in dev mode
|
||||
VITE_DEV_BYPASS_AUTH=false
|
||||
|
||||
@@ -20,18 +20,17 @@
|
||||
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
|
||||
/* Toggle colors mirror Mantine Switch (light scheme) */
|
||||
--cc-toggle-on-bg: var(--mantine-primary-color-filled, #007bff);
|
||||
--cc-toggle-off-bg: var(--mantine-color-gray-3, #dee2e6);
|
||||
--cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff);
|
||||
--cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff);
|
||||
--cc-toggle-on-bg: #007bff;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
|
||||
--cc-toggle-readonly-bg: var(--mantine-color-disabled, #f1f3f4);
|
||||
--cc-toggle-readonly-knob-bg: var(--mantine-color-gray-0, #f8f9fa);
|
||||
--cc-toggle-readonly-knob-icon-color: transparent;
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747e;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
|
||||
@@ -70,18 +69,17 @@
|
||||
|
||||
--cc-separator-border-color: #555555;
|
||||
|
||||
/* Toggle colors mirror Mantine Switch (dark scheme) */
|
||||
--cc-toggle-on-bg: var(--mantine-primary-color-filled, #4dabf7);
|
||||
--cc-toggle-off-bg: var(--mantine-color-dark-5, #555555);
|
||||
--cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff);
|
||||
--cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff);
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
|
||||
--cc-toggle-readonly-bg: var(--mantine-color-disabled, #555555);
|
||||
--cc-toggle-readonly-knob-bg: var(--mantine-color-dark-3, #8e8e8e);
|
||||
--cc-toggle-readonly-knob-icon-color: transparent;
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
|
||||
--cc-section-category-border: #555555;
|
||||
|
||||
@@ -178,16 +176,9 @@
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* Banner sits above the chat FAB but behind all modals and onboarding; value
|
||||
is Z_INDEX_COOKIE_CONSENT_BANNER, set as this variable by useCookieConsent */
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: var(--z-index-cookie-consent) !important;
|
||||
}
|
||||
|
||||
/* Preferences dialog sits above the settings modal it opens from; value is
|
||||
Z_INDEX_COOKIE_PREFERENCES_MODAL, set as this variable by useCookieConsent */
|
||||
.show--preferences #cc-main {
|
||||
z-index: var(--z-index-cookie-preferences) !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Ensure consent modal text is visible in both themes */
|
||||
@@ -212,63 +203,3 @@
|
||||
#cc-main .cm__link {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* ── Category toggles restyled to match Mantine Switch (size sm) ──────────
|
||||
Mantine sm metrics: 38×20 track, 14px plain thumb, 2.5px inline padding,
|
||||
150ms ease transitions, no icon inside the thumb. Colors come from the
|
||||
--cc-toggle-* variables above, which point at the Mantine palette. */
|
||||
#cc-main .section__toggle,
|
||||
#cc-main .section__toggle-wrapper,
|
||||
#cc-main .toggle__icon,
|
||||
#cc-main .toggle__label {
|
||||
width: 38px !important;
|
||||
height: 20px !important;
|
||||
border-radius: 1000px !important;
|
||||
}
|
||||
|
||||
/* Track: flat fill, no outline ring or border */
|
||||
#cc-main .toggle__icon {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
transition: background-color 150ms ease !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Always-enabled categories = Mantine disabled switch (must out-prioritise
|
||||
the !important checked-track rule above) */
|
||||
#cc-main .section__toggle:checked:disabled ~ .toggle__icon {
|
||||
background: var(--cc-toggle-readonly-bg) !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:disabled {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Thumb: small plain circle, vertically centred, no drop shadow */
|
||||
#cc-main .toggle__icon-circle {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
top: 3px !important;
|
||||
left: 2.5px !important;
|
||||
box-shadow: none !important;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
background-color 150ms ease !important;
|
||||
}
|
||||
|
||||
/* Checked thumb travel: 38 − 14 − 2.5 = 21.5px end position */
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon .toggle__icon-circle {
|
||||
transform: translateX(19px) !important;
|
||||
}
|
||||
|
||||
/* Mantine switches have no check/cross glyph inside the thumb */
|
||||
#cc-main .toggle__icon-on,
|
||||
#cc-main .toggle__icon-off {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,9 +50,12 @@ help = "Help"
|
||||
imgPrompt = "Select Image(s)"
|
||||
incorrectPasswordMessage = "Current password is incorrect."
|
||||
info = "Info"
|
||||
insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}"
|
||||
invalidUndoData = "Cannot undo: invalid operation data"
|
||||
keepWorking = "Keep Working"
|
||||
loading = "Loading..."
|
||||
loadingCredits = "Checking credits..."
|
||||
loadingProStatus = "Checking subscription status..."
|
||||
logOut = "Log out"
|
||||
marginTooltip = "Distance between the page number and the edge of the page."
|
||||
moreOptions = "More Options"
|
||||
@@ -63,6 +66,7 @@ noFileSelected = "No file loaded. Please upload one."
|
||||
noFilesToUndo = "Cannot undo: no files were processed in the last operation"
|
||||
noOperationToUndo = "No operation to undo"
|
||||
nothingToUndo = "Nothing to undo"
|
||||
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
|
||||
noValidFiles = "No valid files to process"
|
||||
oops = "Oops!"
|
||||
openInNewWindow = "Open in new window"
|
||||
@@ -1130,10 +1134,6 @@ apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Author
|
||||
description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint."
|
||||
title = "MCP Server"
|
||||
|
||||
[admin.settings.mcp.acceptedAudiences]
|
||||
description = "Extra token audience values accepted besides the Resource ID (comma or space separated). Leave blank for strict RFC 8707. Needed for IdPs that cannot mint resource audiences - e.g. Supabase's OAuth server always issues aud=authenticated."
|
||||
label = "Additional accepted audiences (optional)"
|
||||
|
||||
[admin.settings.mcp.allowedOps]
|
||||
description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below."
|
||||
label = "Allowed tools"
|
||||
@@ -1575,7 +1575,30 @@ admin = "Admin"
|
||||
title = "User Control Settings"
|
||||
|
||||
[agents]
|
||||
auto_redaction_description = "Redact PII automatically"
|
||||
auto_redaction_name = "Auto Redaction"
|
||||
back_to_tools = "Back to tools"
|
||||
coming_soon = "Coming soon"
|
||||
compliance_description = "Audit documents for compliance"
|
||||
compliance_name = "Compliance Check"
|
||||
data_extraction_description = "Extract tables & structured data"
|
||||
data_extraction_name = "Data Extraction"
|
||||
doc_summary_description = "Summarize long documents"
|
||||
doc_summary_name = "Summarizer"
|
||||
form_filler_description = "Fill PDF forms intelligently"
|
||||
form_filler_name = "Form Filler"
|
||||
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
|
||||
pdf_to_markdown_name = "PDF to Markdown"
|
||||
section_title = "Agents"
|
||||
show_less = "Show less"
|
||||
start_chat = "Start chatting"
|
||||
stirling_description = "Your general-purpose PDF assistant"
|
||||
stirling_full_name = "Stirling General Agent"
|
||||
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||
stirling_name = "Stirling"
|
||||
stirling_running = "Running..."
|
||||
stirling_tooltip = "Stirling agent"
|
||||
view_all = "View all agents"
|
||||
|
||||
[analytics]
|
||||
learnMore = "Learn more about our analytics"
|
||||
@@ -2655,6 +2678,9 @@ title = "Change Permissions"
|
||||
[changePermissions.tooltip.warning]
|
||||
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
|
||||
|
||||
[chat]
|
||||
resize = "Resize chat panel"
|
||||
|
||||
[chat.actions]
|
||||
copy = "Copy message"
|
||||
|
||||
@@ -2669,7 +2695,6 @@ clearChat = "Clear chat"
|
||||
[chat.input]
|
||||
placeholder = "What do you want to do?"
|
||||
send = "Send message"
|
||||
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
|
||||
|
||||
[chat.progress]
|
||||
analyzing = "Analyzing your request..."
|
||||
@@ -2704,6 +2729,8 @@ mergeMany = "Merge these {{count}} documents into 1"
|
||||
moreFiles = "+{{count}} more"
|
||||
openFromComputer = "Open from computer"
|
||||
removeFile = "Remove {{name}}"
|
||||
rotateMany = "Rotate these documents"
|
||||
rotateOne = "Rotate this document"
|
||||
splitOne = "Split this document"
|
||||
|
||||
[chat.responses]
|
||||
@@ -2714,7 +2741,6 @@ need_clarification = "Could you clarify your request?"
|
||||
not_found = "I couldn't find the requested information."
|
||||
processing = "Processing ({{outcome}})..."
|
||||
unsupported_capability = "Unsupported capability: {{capability}}"
|
||||
usage_limit_reached = "You've reached your usage limit. Check your plan options to keep going."
|
||||
|
||||
[chat.toolsUsed]
|
||||
summary = "Ran {{count}} tools"
|
||||
@@ -2741,6 +2767,7 @@ copy = "Copy"
|
||||
done = "Done"
|
||||
error = "Error"
|
||||
expand = "Expand"
|
||||
learnMore = "Learn more"
|
||||
loading = "Loading..."
|
||||
next = "Next"
|
||||
operation = "this operation"
|
||||
@@ -2939,7 +2966,6 @@ tags = "squish,small,tiny"
|
||||
|
||||
[config]
|
||||
plan = "Plan"
|
||||
team = "Team"
|
||||
|
||||
[config.account.overview]
|
||||
confirmDelete = "Delete My Account"
|
||||
@@ -3035,7 +3061,6 @@ warning = "⚠️ Warning: This action will generate new API keys and make your
|
||||
|
||||
[config.mcp]
|
||||
description = "Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf."
|
||||
guestInfo = "Guest users can't connect MCP clients. Create an account to use the MCP server and let your AI assistant run Stirling PDF tools on your behalf."
|
||||
navLabel = "MCP Server"
|
||||
tip = "Every action your assistant runs is performed as your account and counts toward your usage, just like using Stirling PDF directly."
|
||||
title = "MCP Server"
|
||||
@@ -3057,6 +3082,14 @@ addTo = "Add to"
|
||||
hint = "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy."
|
||||
title = "Connect your AI assistant"
|
||||
|
||||
[config.mcp.tools]
|
||||
ai = "AI"
|
||||
convert = "Convert"
|
||||
misc = "Misc"
|
||||
pages = "Pages"
|
||||
security = "Security"
|
||||
title = "What your assistant can do"
|
||||
|
||||
[config.overview]
|
||||
description = "Current application settings and configuration details."
|
||||
error = "Error"
|
||||
@@ -3754,7 +3787,6 @@ selectFile = "Select file {{name}}"
|
||||
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
|
||||
shareManage = "Manage sharing"
|
||||
showDetails = "Show details"
|
||||
signInRequired = "Sign in to use cloud storage."
|
||||
summary = "{{count}} items"
|
||||
tree = "Folders"
|
||||
upload = "Upload"
|
||||
@@ -4718,34 +4750,6 @@ title = "Authentication Failed"
|
||||
message = "You can close this window and return to Stirling PDF."
|
||||
title = "Authentication Successful"
|
||||
|
||||
[oauthConsent]
|
||||
approve = "Allow access"
|
||||
approving = "Allowing..."
|
||||
decisionFailed = "Could not submit your decision. Please try again."
|
||||
deny = "Deny"
|
||||
denying = "Denying..."
|
||||
loadFailed = "This authorization request is invalid or has expired. Close this tab and try connecting again from the app."
|
||||
loading = "Loading authorization request..."
|
||||
missingId = "Missing authorization request. Start the connection from your app and try again."
|
||||
redirecting = "Returning you to the app..."
|
||||
requesting = "{{app}} wants to access your Stirling PDF account"
|
||||
scopesIntro = "This will allow {{app}} to:"
|
||||
signedInAs = "Signed in as {{name}}"
|
||||
signInButton = "Sign in to continue"
|
||||
signInPrompt = "Sign in to your Stirling PDF account to continue connecting the app."
|
||||
title = "Authorize access"
|
||||
unknownApp = "A third-party application"
|
||||
|
||||
[oauthConsent.access]
|
||||
actAsYou = "Act as you - everything {{app}} does runs under your account and counts towards your usage"
|
||||
tools = "Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents"
|
||||
|
||||
[oauthConsent.scope]
|
||||
email = "See your email address"
|
||||
openid = "Confirm your identity"
|
||||
phone = "See your phone number"
|
||||
profile = "See your basic profile information"
|
||||
|
||||
[ocr]
|
||||
desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text."
|
||||
tags = "recognition,text,image,scan,read,identify,detection,editable"
|
||||
@@ -4863,6 +4867,15 @@ body = "Stirling works best as a desktop app. You can use it offline, access doc
|
||||
title = "Download"
|
||||
titleWithOs = "Download for {{osLabel}}"
|
||||
|
||||
[onboarding.freeTrial]
|
||||
afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access."
|
||||
afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends."
|
||||
body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing."
|
||||
daysRemaining = "{{days}} days remaining"
|
||||
daysRemainingSingular = "{{days}} day remaining"
|
||||
title = "Your 30-Day Pro Trial"
|
||||
trialEnds = "Trial ends {{date}}"
|
||||
|
||||
[onboarding.mfa]
|
||||
authenticationCode = "Authentication code"
|
||||
qrCodeLoading = "Generating your QR code…"
|
||||
@@ -4874,22 +4887,6 @@ adminTitle = "Admin Overview"
|
||||
userBody = "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use."
|
||||
userTitle = "Plan Overview"
|
||||
|
||||
[onboarding.saas.freeEditor]
|
||||
freeLine = "The editor is now <free>completely free</free>."
|
||||
premium = "We've added loads of new features, including <strong>Policies</strong> and <strong>Agent Chat</strong>."
|
||||
title = "Welcome to Stirling"
|
||||
|
||||
[onboarding.saas.team]
|
||||
addButton = "Add"
|
||||
createBody = "Work on documents together: teammates share files, automations and your plan. Add the first member by email to create your team."
|
||||
createTitle = "Create your team"
|
||||
inviteBody = "Everyone on your team shares files, automations and your plan. Add teammates by email and they'll get an invite."
|
||||
inviteTitle = "Invite members to your team"
|
||||
|
||||
[onboarding.saas.usage]
|
||||
body = "Automations, AI and API requests draw from your free allowance. Manual editing never counts against it."
|
||||
title = "Your free Processor allowance"
|
||||
|
||||
[onboarding.securityCheck]
|
||||
message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
|
||||
roleAdmin = "Admin"
|
||||
@@ -5160,208 +5157,6 @@ title = "Page Ranges"
|
||||
bullet1 = "<strong>all</strong> → selects all pages"
|
||||
title = "Special Keywords"
|
||||
|
||||
[payg.activity]
|
||||
docs = "docs"
|
||||
empty = "No billable activity yet this period."
|
||||
subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here."
|
||||
title = "Recent billable activity"
|
||||
|
||||
[payg.cap]
|
||||
amount = "Cap amount"
|
||||
custom = "Custom"
|
||||
docsEstimate = "≈ {{docs}} processed PDFs / month"
|
||||
docsRate = "at {{rate}} / PDF"
|
||||
noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time."
|
||||
noCapLabel = "No cap"
|
||||
noneShort = "No cap"
|
||||
perMonth = "/ month"
|
||||
save = "Update cap"
|
||||
subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier."
|
||||
title = "Monthly spending cap"
|
||||
|
||||
[payg.checkout]
|
||||
connecting = "Connecting to Stripe…"
|
||||
errorTitle = "Stripe error"
|
||||
|
||||
[payg.checkout.error]
|
||||
startFailed = "Couldn't start checkout session"
|
||||
|
||||
[payg.checkout.mock]
|
||||
backend = "Backend is in mock mode; no real Stripe session was created."
|
||||
continue = "Continue with mock subscription"
|
||||
noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured."
|
||||
title = "Stripe Embedded Checkout (mock mode)"
|
||||
|
||||
[payg.confirm]
|
||||
body = "Your team can now run automation, AI, and API operations beyond your 500 free PDFs."
|
||||
capValue = "{{symbol}}{{amount}} / month"
|
||||
noCap = "No cap"
|
||||
note = "You can change your cap, cancel, or open the Stripe customer portal any time from this page."
|
||||
summaryLabel = "Monthly ceiling"
|
||||
title = "Welcome to the Processor plan"
|
||||
|
||||
[payg.docHelp]
|
||||
billable = "Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free."
|
||||
chains = "Running the same file through several steps of one automation counts it once, not once per step."
|
||||
perFile = "Each file you process counts as one PDF. Very long or very large files can count as more than one."
|
||||
refunds = "If a job fails on its first step, the PDF is credited back automatically."
|
||||
toggle = "What counts as a PDF?"
|
||||
|
||||
[payg.error]
|
||||
body = "We couldn't reach the billing service. Refresh the page to try again."
|
||||
title = "Couldn't load your plan"
|
||||
|
||||
[payg.free.cta]
|
||||
benefit1Body = "chain tools, schedule runs, batch process"
|
||||
benefit1Title = "Automation pipelines"
|
||||
benefit2Body = "summarize, classify, redact, AI-OCR"
|
||||
benefit2Title = "AI tools"
|
||||
benefit3Body = "call any Stirling endpoint programmatically"
|
||||
benefit3Title = "API access"
|
||||
button = "Turn on Processor →"
|
||||
reassurance = "No minimum · Set a $0 cap to test · Cancel anytime"
|
||||
subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling, so you stay in control."
|
||||
title = "Turn on the Processor plan"
|
||||
|
||||
[payg.free.editor]
|
||||
eyebrow = "Editor plan · Always free"
|
||||
|
||||
[payg.free.header]
|
||||
freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it."
|
||||
freeTitle = "Unlimited PDF editing"
|
||||
|
||||
[payg.free.hero]
|
||||
capSuffix = "/ {{limit}} free PDFs"
|
||||
metaCategories = "Automation · AI · API requests"
|
||||
|
||||
[payg.free.member]
|
||||
ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like."
|
||||
|
||||
[payg.free.proc]
|
||||
eyebrow = "Processor plan · metered"
|
||||
|
||||
[payg.free.state]
|
||||
approachingLimit = "Approaching limit"
|
||||
limitReached = "Limit reached"
|
||||
plentyLeft = "Plenty left"
|
||||
|
||||
[payg.gates]
|
||||
ai = "AI tools (AI Create, suggestions, AI-OCR)"
|
||||
automation = "Automations & pipelines"
|
||||
client = "Browser-only tools (viewer, page editor, file management)"
|
||||
offsite = "Server tools (compress, OCR, convert, watermark…)"
|
||||
pauses = "pauses at cap"
|
||||
title = "What happens when the cap is reached"
|
||||
|
||||
[payg.header]
|
||||
eyebrow = "Processor plan · {{start}} – {{end}}"
|
||||
freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it."
|
||||
freeLabel = "Always free"
|
||||
freeTitle = "Unlimited PDF editing"
|
||||
meterBody = "{{limit}} free PDFs to start, then billed per PDF up to your cap."
|
||||
meterLabel = "Metered"
|
||||
meterTitle = "Automation · AI · API"
|
||||
|
||||
[payg.member]
|
||||
askLeader = "Only your team owner can change the cap."
|
||||
|
||||
[payg.members]
|
||||
docs = "PDFs"
|
||||
subtitle = "Billable PDFs each teammate has processed this period."
|
||||
title = "Team member usage"
|
||||
|
||||
[payg.role]
|
||||
leader = "Team owner"
|
||||
member = "Member"
|
||||
|
||||
[payg.signupRequired]
|
||||
body = "Stirling PDF gives every signed-up account 500 free operations, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
|
||||
cancel = "Not now"
|
||||
cta = "Sign up free"
|
||||
subtext = "Creating an account is free and takes a few seconds. No credit card required."
|
||||
title = "Sign up to use {{category}}"
|
||||
|
||||
[payg.signupRequired.category]
|
||||
ai = "AI features"
|
||||
api = "this tool"
|
||||
automation = "automations"
|
||||
default = "this feature"
|
||||
|
||||
[payg.spendCapMeter]
|
||||
capSuffix = "/ {{amount}} cap"
|
||||
metaCategories = "Automation · AI · API spend"
|
||||
resets = "Resets each billing period"
|
||||
|
||||
[payg.state]
|
||||
degraded = "Cap reached"
|
||||
full = "Healthy"
|
||||
warned = "Approaching cap"
|
||||
|
||||
[payg.stripe]
|
||||
open = "Open billing portal"
|
||||
subtitle = "Receipts, invoices, payment method, billing currency."
|
||||
title = "Manage billing in Stripe"
|
||||
|
||||
[payg.stripe.toast.unavailable]
|
||||
body = "Billing portal isn't available right now. Try again in a moment."
|
||||
title = "Billing portal unavailable"
|
||||
|
||||
[payg.upgrade]
|
||||
backAria = "Back"
|
||||
closeAria = "Close"
|
||||
|
||||
[payg.upgrade.button]
|
||||
cancel = "Cancel"
|
||||
continue = "Continue →"
|
||||
finish = "Finish"
|
||||
|
||||
[payg.upgrade.cap]
|
||||
help = "We'll never charge above this. Set $0 if you want to keep everything free while testing."
|
||||
title = "Set your monthly spend ceiling"
|
||||
usdNote = "Estimated in USD. You can adjust your cap any time after subscribing, in your own currency."
|
||||
|
||||
[payg.upgrade.checkout]
|
||||
capValue = "{{symbol}}{{amount}} / month"
|
||||
edit = "Edit"
|
||||
help = "Stripe handles your card details. Stirling never sees them."
|
||||
loading = "Loading checkout…"
|
||||
noCap = "No cap"
|
||||
title = "Add your payment method"
|
||||
|
||||
[payg.upgrade.help]
|
||||
aiBody = "summarize, classify, redact, AI-OCR"
|
||||
aiTitle = "AI tools"
|
||||
apiBody = "programmatic access to any Stirling endpoint"
|
||||
apiTitle = "API calls"
|
||||
automationBody = "chained tools or scheduled runs that don't need clicks"
|
||||
automationTitle = "Automation pipelines"
|
||||
footnote = "Manual tools (viewing, editing, merging, splitting, watermarking, compressing, manual OCR) are always free, even past 500. The distinction is the type of work, not where you click."
|
||||
title = "What we count toward billing"
|
||||
|
||||
[payg.upgrade.promise]
|
||||
body = "You only pay for automation pipelines, AI tools, and API calls, the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge."
|
||||
highlight = "Manual tools stay free, always."
|
||||
|
||||
[payg.upgrade.steps]
|
||||
cap = "Set monthly ceiling"
|
||||
payment = "Add payment method"
|
||||
|
||||
[payg.upgrade.title]
|
||||
confirm = "You're subscribed"
|
||||
default = "Upgrade to Processor plan"
|
||||
|
||||
[payg.usage]
|
||||
breakdown = "AI {{ai}} • Automation {{automation}} • API {{api}}"
|
||||
capLine = "{{cap}}/mo cap"
|
||||
estBill = "≈ {{amount}} so far this period"
|
||||
firstFree = "First {{free}} free"
|
||||
noCap = "No monthly cap"
|
||||
ofLimitProcessed = "/ {{limit}} PDFs processed"
|
||||
processed = "PDFs processed"
|
||||
resetsIn = "Resets in {{days}} days"
|
||||
resetsTomorrow = "Resets tomorrow"
|
||||
thisPeriod = "This billing period"
|
||||
|
||||
[payment]
|
||||
autoClose = "This window will close automatically..."
|
||||
canCloseWindow = "You can now close this window."
|
||||
@@ -5685,17 +5480,28 @@ manage = "Manage"
|
||||
perMonth = "/month"
|
||||
perSeat = "/seat"
|
||||
popular = "Popular"
|
||||
purchase = "Purchase"
|
||||
selectCredits = "Select Credit Amount"
|
||||
selectPlan = "Select Plan"
|
||||
showComparison = "Compare All Features"
|
||||
totalCost = "Total Cost"
|
||||
upgrade = "Upgrade"
|
||||
withServer = "+ Server Plan"
|
||||
|
||||
[plan.activePlan]
|
||||
subtitle = "Your current subscription details"
|
||||
title = "Active Plan"
|
||||
|
||||
[plan.api]
|
||||
large = "5,000 Credits"
|
||||
medium = "1,000 Credits"
|
||||
small = "500 Credits"
|
||||
xsmall = "100 Credits"
|
||||
|
||||
[plan.apiPackages]
|
||||
subtitle = "Purchase API credits for your applications"
|
||||
title = "API Credit Packages"
|
||||
|
||||
[plan.availablePlans]
|
||||
loadError = "Unable to load plan pricing. Using default values."
|
||||
subtitle = "Choose the plan that fits your needs"
|
||||
@@ -5747,12 +5553,6 @@ highlight3 = "Community support"
|
||||
included = "Included"
|
||||
name = "Free"
|
||||
|
||||
[plan.freeLimit]
|
||||
cta = "View Processor Plan"
|
||||
dismiss = "Maybe Later"
|
||||
message = "That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day."
|
||||
title = "Woah, {{total}} PDFs Processed!"
|
||||
|
||||
[plan.highlights]
|
||||
advancedIntegrations = "Advanced integrations"
|
||||
allBasicFeatures = "All basic features"
|
||||
@@ -5784,12 +5584,6 @@ highlight2 = "Advanced PDF tools"
|
||||
highlight3 = "No watermarks"
|
||||
name = "Pro"
|
||||
|
||||
[plan.spendCap]
|
||||
cta = "View Spending Limit"
|
||||
dismiss = "Not Now"
|
||||
message = "You've made the most of this month's cap. That's a load of automation, AI and API work! Bump it up whenever you like to keep going."
|
||||
title = "You're on a Roll!"
|
||||
|
||||
[plan.static]
|
||||
activateLicense = "Activate Your License"
|
||||
contactToUpgrade = "Contact us to upgrade or customize your plan"
|
||||
@@ -5818,115 +5612,18 @@ successMessage = "Your license has been successfully activated. You can now clos
|
||||
name = "Team"
|
||||
|
||||
[plan.trial]
|
||||
badge = "Trial"
|
||||
continueWithFree = "Continue with Free"
|
||||
daysRemaining = "Your trial ends in {{days}} days"
|
||||
endDate = "Expires: {{date}}"
|
||||
expired = "Your Trial Has Ended"
|
||||
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
|
||||
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
|
||||
message = ""
|
||||
subscribe = "Subscribe to Pro"
|
||||
subscribeToPro = "Subscribe to Pro"
|
||||
|
||||
[policies]
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
deleteConfirmTitle = "Delete {{label}} policy?"
|
||||
|
||||
[policies.catalog]
|
||||
compliance = "Compliance"
|
||||
ingestion = "Ingestion"
|
||||
retention = "Retention"
|
||||
routing = "Routing"
|
||||
security = "Security"
|
||||
|
||||
[policies.detail]
|
||||
editSettings = "Edit Settings"
|
||||
enforces = "Enforces"
|
||||
managedByOrg = "Managed by your organization. Contact a team leader to change this policy."
|
||||
noActivityDescription = "Documents will appear here once this policy runs."
|
||||
noActivityTitle = "No activity yet"
|
||||
onEveryUpload = "On every upload"
|
||||
originalsNote = "Originals stay untouched • Enforced version saved alongside"
|
||||
pause = "Pause"
|
||||
recentActivity = "Recent Activity"
|
||||
resume = "Resume"
|
||||
retry = "Retry"
|
||||
showLess = "Show less"
|
||||
showMore = "Show more"
|
||||
statActive = "Active"
|
||||
statDataProcessed = "Data processed"
|
||||
statDocsEnforced = "Docs enforced"
|
||||
statusActive = "Active"
|
||||
statusPaused = "Paused"
|
||||
|
||||
[policies.fields]
|
||||
selectedCount = "{{count}} selected"
|
||||
|
||||
[policies.pii]
|
||||
fieldLabel = "PII to redact"
|
||||
placeholder = "Select PII types"
|
||||
|
||||
[policies.sidebar]
|
||||
activeCount = "{{count}} active"
|
||||
infoAriaLabel = "What is a policy?"
|
||||
infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
|
||||
loading = "Loading…"
|
||||
railAriaLabel = "{{label}} policy — {{status}}"
|
||||
railSuffixActive = " (Active)"
|
||||
railSuffixPaused = " (Paused)"
|
||||
setUp = "Set up"
|
||||
title = "Policies"
|
||||
upgradeToEnterprise = "Upgrade to enterprise"
|
||||
|
||||
[policies.status]
|
||||
active = "Active"
|
||||
paused = "Paused"
|
||||
setup = "Set up"
|
||||
|
||||
[policies.toolConfig]
|
||||
enableAriaLabel = "Enable {{tool}}"
|
||||
infoAriaLabel = "What does {{tool}} do?"
|
||||
|
||||
[policies.wizard]
|
||||
allDocTypesDescription = "Enable the Classification policy to filter by document type."
|
||||
allDocTypesTitle = "All document types"
|
||||
back = "Back"
|
||||
builderDesc = "Build the sequence of tools this policy runs on each document."
|
||||
clear = "Clear"
|
||||
continue = "Continue"
|
||||
docTypesLabel = "Document types"
|
||||
edit = "Edit"
|
||||
editTitle = "Edit {{label}} Policy"
|
||||
enablePolicy = "Enable Policy"
|
||||
filenameAutoNumber = "Auto-number"
|
||||
filenamePositionAria = "Filename position"
|
||||
filenamePrefix = "Prefix"
|
||||
filenameSuffix = "Suffix"
|
||||
filenameTextAria = "Filename text"
|
||||
filenameTextPlaceholder = "Text to add (optional)"
|
||||
lockedDescription = "Contact a team leader to change this policy."
|
||||
lockedTitle = "Managed by your organization"
|
||||
maxRetriesLabel = "Max retries"
|
||||
noToolsError = "Add at least one configured tool to the workflow first."
|
||||
outputAsLabel = "Output as"
|
||||
outputFilenameSubhead = "Output filename"
|
||||
outputModeAria = "Output mode"
|
||||
outputNewFile = "New file"
|
||||
outputNewVersion = "New version"
|
||||
outputRetriesLabel = "Output & retries"
|
||||
outputSubhead = "Output"
|
||||
retryDelayAria = "Retry delay minutes"
|
||||
retryDelayLabel = "Retry delay (min)"
|
||||
runOnExport = "Export"
|
||||
runOnLabel = "Run on"
|
||||
runOnSubhead = "Run on"
|
||||
runOnUpload = "Upload"
|
||||
saveChanges = "Save Changes"
|
||||
saveError = "Couldn't save the policy. Please try again."
|
||||
setupClassification = "Set up Classification"
|
||||
setupTitle = "Set up {{label}} Policy"
|
||||
sourcesDesc = "Choose where this policy runs and which document types it applies to."
|
||||
sourcesLabel = "Sources"
|
||||
stepOf = "Step {{step}} of {{total}}"
|
||||
toolChainDesc = "Configure the tools this policy runs on each document."
|
||||
typesSelected = "{{count}} types selected"
|
||||
subscriptionScheduled = "Subscription scheduled - starts {{date}}"
|
||||
title = "Free Trial Active"
|
||||
|
||||
[printFile]
|
||||
title = "Print File"
|
||||
@@ -6871,18 +6568,6 @@ title = "Keyboard Shortcuts"
|
||||
mac = "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut."
|
||||
windows = "Include Ctrl, Alt, or another modifier in your shortcut."
|
||||
|
||||
[settings.legal]
|
||||
label = "Legal"
|
||||
title = "Legal"
|
||||
|
||||
[settings.legal.cookiePreferences]
|
||||
description = "Review or change your cookie consent choices."
|
||||
manage = "Manage"
|
||||
|
||||
[settings.legal.documents]
|
||||
description = "Policies and legal information for this service."
|
||||
title = "Legal Documents"
|
||||
|
||||
[settings.licensingAnalytics]
|
||||
audit = "Audit"
|
||||
plan = "Plan"
|
||||
@@ -7571,6 +7256,7 @@ updateButton = "Update on Server"
|
||||
uploadButton = "Upload to Server"
|
||||
|
||||
[survey]
|
||||
nav = "Survey"
|
||||
title = "Stirling-PDF Survey"
|
||||
|
||||
[swagger]
|
||||
@@ -7603,6 +7289,28 @@ removeError = "Failed to remove member"
|
||||
renameError = "Failed to rename team"
|
||||
renameSuccess = "Team renamed successfully"
|
||||
|
||||
[team.features]
|
||||
badge = "Team Features"
|
||||
subtitle = "Collaborate with your team"
|
||||
title = "Team Collaboration"
|
||||
viewPlans = "View Plans"
|
||||
|
||||
[team.features.billing]
|
||||
description = "Manage billing and subscriptions"
|
||||
title = "Billing Management"
|
||||
|
||||
[team.features.credits]
|
||||
description = "Shared credit pool for all members"
|
||||
title = "Shared Credits"
|
||||
|
||||
[team.features.dashboard]
|
||||
description = "View team activity and usage"
|
||||
title = "Team Dashboard"
|
||||
|
||||
[team.features.invite]
|
||||
description = "Add members to your team"
|
||||
title = "Invite Members"
|
||||
|
||||
[team.invitationBanner]
|
||||
acceptButton = "Accept"
|
||||
message = "has invited you to join"
|
||||
@@ -7624,6 +7332,11 @@ remove = "Remove"
|
||||
roleColumn = "Role"
|
||||
title = "Team Members"
|
||||
|
||||
[team.upgrade]
|
||||
button = "Upgrade to Team"
|
||||
description = "Unlock team collaboration features"
|
||||
title = "Upgrade to Team Plan"
|
||||
|
||||
[textAlign]
|
||||
center = "Center"
|
||||
left = "Left"
|
||||
|
||||
@@ -17,9 +17,8 @@ import "@app/styles/index.css";
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
// Minimal providers for the public, no-auth mobile-scanner page - no API
|
||||
// calls, no authentication
|
||||
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
|
||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||
@@ -35,9 +34,9 @@ export default function App() {
|
||||
<Route
|
||||
path="/mobile-scanner"
|
||||
element={
|
||||
<PublicRouteProviders>
|
||||
<MobileScannerProviders>
|
||||
<MobileScannerPage />
|
||||
</PublicRouteProviders>
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -14,14 +14,6 @@ export interface AuthContextType {
|
||||
* should treat the resulting string as opaque display text.
|
||||
*/
|
||||
displayName: string | null;
|
||||
/**
|
||||
* Whether the current session is an anonymous / guest one. Each layer
|
||||
* derives this from its own native user shape (Supabase `is_anonymous` in
|
||||
* SaaS, the Spring anonymous flag in proprietary). Always `false` in core
|
||||
* OSS, which has no auth context. Consumers use it to gate account-only
|
||||
* actions (cloud folders, MCP) without reaching into a layer-specific user.
|
||||
*/
|
||||
isAnonymous: boolean;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
signOut: () => Promise<void>;
|
||||
@@ -37,7 +29,6 @@ export function useAuth(): AuthContextType {
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Core stubs for the right-rail Agents UI.
|
||||
*
|
||||
* The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx}
|
||||
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
|
||||
* build is active. Core builds render nothing, so the right rail collapses to the
|
||||
* tool list unchanged.
|
||||
*/
|
||||
|
||||
/** Whether the right rail should reserve space for agents UI. False in core. */
|
||||
export function useAgentsEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent chat panel is currently open. Core builds have no chat,
|
||||
* so this always returns false. Proprietary builds bridge to the ChatContext.
|
||||
*/
|
||||
export function useAgentChatOpen(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */
|
||||
export function AgentsSection() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-only agent button rendered in the collapsed (minimised) right rail.
|
||||
* Returns null in core; proprietary renders the Stirling agent shortcut.
|
||||
*/
|
||||
export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents card rendered inside the fullscreen tool picker. Matches the visual
|
||||
* language of the fullscreen category cards (gradient border, title, items).
|
||||
* Returns null in core; proprietary renders the Stirling agent.
|
||||
*/
|
||||
export function AgentsFullscreenSection() {
|
||||
return null;
|
||||
}
|
||||
@@ -7,9 +7,12 @@
|
||||
export function useChat() {
|
||||
return {
|
||||
messages: [] as never[],
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
progress: null,
|
||||
progressLog: [] as never[],
|
||||
toggleOpen: () => {},
|
||||
setOpen: (_open: boolean) => {},
|
||||
sendMessage: async (_content: string) => {},
|
||||
cancelMessage: () => {},
|
||||
clearChat: () => {},
|
||||
|
||||
@@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard";
|
||||
import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||
import { FileId, StirlingFile } from "@app/types/fileContext";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -256,7 +256,6 @@ const FileEditor = ({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: record.localFilePath,
|
||||
fileId,
|
||||
});
|
||||
console.log("[FileEditor] Download complete, checking dirty state:", {
|
||||
localFilePath: record.localFilePath,
|
||||
|
||||
@@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain";
|
||||
import HoverActionMenu, {
|
||||
HoverAction,
|
||||
} from "@app/components/shared/HoverActionMenu";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||
@@ -248,7 +248,6 @@ const FileEditorThumbnail = ({
|
||||
data: fileToSave,
|
||||
filename: file.name,
|
||||
localPath: file.localFilePath,
|
||||
fileId: file.id,
|
||||
});
|
||||
if (!result.cancelled && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(file.id, {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
@@ -18,7 +17,6 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import {
|
||||
@@ -574,31 +572,6 @@ function FolderCard({
|
||||
);
|
||||
}
|
||||
|
||||
/** Shield badges for the policies that have run on a file. */
|
||||
function PolicyBadges({ fileId }: { fileId: string }) {
|
||||
const badges = usePolicyFileBadges().get(fileId) ?? [];
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<span className="files-page-policy-badges" data-no-select>
|
||||
{badges.slice(0, 3).map((policy) => (
|
||||
<Tooltip
|
||||
key={policy.id}
|
||||
label={`${policy.name} policy ran on this file`}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span
|
||||
className="files-page-policy-badge"
|
||||
style={{ color: policy.accentColor }}
|
||||
>
|
||||
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: StirlingFileStub;
|
||||
isSelected: boolean;
|
||||
@@ -758,7 +731,6 @@ function FileCard({
|
||||
<span>{fileSize}</span>
|
||||
<span>·</span>
|
||||
<span>{fileDate}</span>
|
||||
<PolicyBadges fileId={file.id as string} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="files-page-card-actions">
|
||||
@@ -1343,7 +1315,6 @@ function FileRow({
|
||||
)}
|
||||
</span>
|
||||
<FileOriginBadge origin={getFileOrigin(file)} compact />
|
||||
<PolicyBadges fileId={file.id as string} />
|
||||
{isInWorkspace && (
|
||||
<span className="files-page-row-open-pill">
|
||||
<span className="files-page-card-open-dot" />
|
||||
|
||||
@@ -34,8 +34,6 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
@@ -108,27 +106,17 @@ export default function FileManagerView() {
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileUploadAvailable =
|
||||
Boolean(appConfig?.enableMobileScanner) && !isMobile;
|
||||
// Guests (anonymous sessions) have no server-side storage, so every cloud
|
||||
// action is account-only. Rather than let the click fire a guaranteed 401
|
||||
// (which surfaced as an error toast), we disable the control and explain why
|
||||
// on hover - the same affordance the storage-disabled / wrong-tab gates use.
|
||||
const { isAnonymous } = useAuth();
|
||||
const signInRequiredReason = isAnonymous
|
||||
? t("filesPage.signInRequired", "Sign in to use cloud storage.")
|
||||
: null;
|
||||
// Server storage gate; mirrors ConfigController's storageEnabled
|
||||
// (enableLogin && storage.isEnabled). When off, Save-to-server stays
|
||||
// visible but disabled with an explanatory tooltip (discoverability beats
|
||||
// hiding - mirrors the New folder / Manage sharing gates in this view).
|
||||
const uploadEnabled = appConfig?.storageEnabled === true;
|
||||
const saveToServerDisabledReason: string | null =
|
||||
signInRequiredReason ??
|
||||
(uploadEnabled
|
||||
? null
|
||||
: t(
|
||||
"filesPage.saveToServerDisabledHint",
|
||||
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
|
||||
));
|
||||
const saveToServerDisabledReason: string | null = uploadEnabled
|
||||
? null
|
||||
: t(
|
||||
"filesPage.saveToServerDisabledHint",
|
||||
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
|
||||
);
|
||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
@@ -202,11 +190,10 @@ export default function FileManagerView() {
|
||||
|
||||
// Push folder selection into the URL while still on /files.
|
||||
useEffect(() => {
|
||||
const stripped = stripBasePath(window.location.pathname);
|
||||
if (!stripped.startsWith("/files")) return;
|
||||
if (!window.location.pathname.startsWith("/files")) return;
|
||||
const target =
|
||||
currentFolderId === null ? "/files" : `/files/${currentFolderId}`;
|
||||
if (stripped !== target) {
|
||||
if (window.location.pathname !== target) {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
}, [currentFolderId, navigate]);
|
||||
@@ -816,11 +803,6 @@ export default function FileManagerView() {
|
||||
|
||||
// null = New folder actionable; string = disabled tooltip reason.
|
||||
const newFolderDisabledReason: string | null = useMemo(() => {
|
||||
// Guests can't use cloud folders at all - say so before any tab/storage
|
||||
// hint, since switching tabs wouldn't help them.
|
||||
if (signInRequiredReason) {
|
||||
return signInRequiredReason;
|
||||
}
|
||||
if (currentTab === "local") {
|
||||
return t(
|
||||
"filesPage.localFoldersUnavailable",
|
||||
@@ -844,7 +826,7 @@ export default function FileManagerView() {
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [signInRequiredReason, currentTab, folders.serverReachable, t]);
|
||||
}, [currentTab, folders.serverReachable, t]);
|
||||
|
||||
return (
|
||||
<div className="files-page" ref={dropZoneRef}>
|
||||
@@ -911,17 +893,14 @@ export default function FileManagerView() {
|
||||
/>
|
||||
<div className="files-page-header-actions">
|
||||
<Tooltip
|
||||
label={
|
||||
signInRequiredReason ??
|
||||
t("filesPage.refresh", "Refresh from server")
|
||||
}
|
||||
label={t("filesPage.refresh", "Refresh from server")}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
loading={refreshing}
|
||||
disabled={refreshing || Boolean(signInRequiredReason)}
|
||||
disabled={refreshing}
|
||||
aria-busy={refreshing}
|
||||
onClick={handleRefresh}
|
||||
aria-label={t("filesPage.refresh", "Refresh from server")}
|
||||
|
||||
@@ -484,24 +484,6 @@
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Policy activity badges (a shield per policy that has run on the file). */
|
||||
.files-page-policy-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.files-page-policy-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
/* `color` set inline to the policy accent; tint follows it. */
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
}
|
||||
|
||||
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
|
||||
the user can tell which folder each hit lives in without navigating. */
|
||||
.files-page-card-path {
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
import { isBaseWorkbench } from "@app/types/workbench";
|
||||
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||
import styles from "@app/components/layout/Workbench.module.css";
|
||||
|
||||
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||
import LandingPage from "@app/components/shared/LandingPage";
|
||||
import Footer from "@app/components/shared/Footer";
|
||||
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
|
||||
import { ChatFAB } from "@app/components/chat/ChatFAB";
|
||||
|
||||
@@ -37,10 +37,6 @@ export default function Workbench() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// The consent banner used to be initialised by the footer; the legal links
|
||||
// now live in Settings → Legal, so the workbench owns the banner lifecycle.
|
||||
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
@@ -256,6 +252,15 @@ export default function Workbench() {
|
||||
{renderMainContent()}
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
cookiePolicy={config?.cookiePolicy}
|
||||
impressum={config?.impressum}
|
||||
accessibilityStatement={config?.accessibilityStatement}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-5
@@ -34,8 +34,8 @@
|
||||
}
|
||||
|
||||
.standaloneIcon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
@@ -124,6 +124,8 @@
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
position: relative;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
@@ -173,8 +175,8 @@
|
||||
}
|
||||
|
||||
.downloadIcon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
@@ -203,7 +205,6 @@
|
||||
}
|
||||
|
||||
.bodyCopy {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
animation: bodySlideIn 0.25s ease forwards;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_LIGHT_BACKGROUND } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import {
|
||||
DesktopInstallTitle,
|
||||
type OSOption,
|
||||
@@ -47,6 +47,9 @@ export default function DesktopInstallSlide({
|
||||
),
|
||||
body: <DesktopInstallBody />,
|
||||
downloadUrl: osUrl,
|
||||
background: UNIFIED_LIGHT_BACKGROUND,
|
||||
background: {
|
||||
gradientStops: ["#2563EB", "#0EA5E9"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
AnimatedCircleConfig,
|
||||
AnimatedSlideBackgroundProps,
|
||||
} from "@app/types/types";
|
||||
import { AnimatedCircleConfig } from "@app/types/types";
|
||||
|
||||
/**
|
||||
* Unified circle background configuration used across all onboarding slides.
|
||||
@@ -30,36 +27,3 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
|
||||
offsetY: 18,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a light slide background in a slide-specific accent colour: a white
|
||||
* hero fading into a pale horizon tint, with the unified sphere geometry
|
||||
* glowing in the accent on each sphere's inner edge.
|
||||
*/
|
||||
export function createLightSlideBackground(
|
||||
accentRgb: [number, number, number],
|
||||
horizon: string,
|
||||
): AnimatedSlideBackgroundProps {
|
||||
const [r, g, b] = accentRgb;
|
||||
const sphereColors = [
|
||||
`linear-gradient(45deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.3))`,
|
||||
`linear-gradient(225deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.26))`,
|
||||
];
|
||||
return {
|
||||
gradientStops: ["#FFFFFF", horizon],
|
||||
circles: UNIFIED_CIRCLE_CONFIG.map((circle, index) => ({
|
||||
...circle,
|
||||
color: sphereColors[index],
|
||||
})),
|
||||
tone: "light",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default light slide background: white with a light blue horizon and
|
||||
* blue-glow spheres.
|
||||
*/
|
||||
export const UNIFIED_LIGHT_BACKGROUND = createLightSlideBackground(
|
||||
[37, 99, 235],
|
||||
"#DBEAFE",
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ import { FileId } from "@app/types/file";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
@@ -98,7 +98,6 @@ const FileThumbnail = ({
|
||||
void downloadFile({
|
||||
data: maybeFile,
|
||||
filename: maybeFile.name || file.name || "download",
|
||||
fileId: file.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Core stubs for the right-rail Policies UI.
|
||||
*
|
||||
* The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx}
|
||||
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
|
||||
* build is active. Core builds render nothing, so the right rail shows only the
|
||||
* tool list unchanged.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Whether the right rail should host the Policies section. False in core. */
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a policy is open (its detail should take over the rail). Always false
|
||||
* in core; proprietary bridges to the policy-selection store.
|
||||
*/
|
||||
export function usePolicyDetailActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Collapsible policy list rendered above the Tools section. Null in core. */
|
||||
export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */
|
||||
export function PolicyDetailTakeover() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */
|
||||
export function PoliciesCollapsedButton(_props: { onExpand: () => void }) {
|
||||
return null;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Core stub for the policy auto-run controller.
|
||||
*
|
||||
* The real implementation lives in
|
||||
* {@code proprietary/components/policies/PolicyAutoRunController.tsx} and shadows
|
||||
* this stub via the {@code @app/*} alias cascade in the proprietary build. Core
|
||||
* builds have no Policies feature, so this renders nothing and runs no policies.
|
||||
*/
|
||||
export function PolicyAutoRunController() {
|
||||
return null;
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
|
||||
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import {
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
useUnsavedChanges,
|
||||
} from "@app/contexts/UnsavedChangesContext";
|
||||
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -98,14 +96,13 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const switchSection = useCallback(
|
||||
(key: NavKey) => {
|
||||
setActive(key);
|
||||
const alreadyInSettings = stripBasePath(
|
||||
window.location.pathname,
|
||||
).startsWith("/settings");
|
||||
const alreadyInSettings =
|
||||
window.location.pathname.startsWith("/settings");
|
||||
if (alreadyInSettings) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
withBasePath(`/settings/${key}`),
|
||||
`/settings/${key}`,
|
||||
);
|
||||
} else {
|
||||
navigate(`/settings/${key}`);
|
||||
@@ -217,7 +214,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
styles={{ content: { overflowY: "hidden", overscrollBehavior: "none" } }}
|
||||
removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }}
|
||||
>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
|
||||
@@ -396,19 +396,6 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* No colored disc behind an actual photo; keep it for the initials fallback. */
|
||||
.file-sidebar-bottom-avatar--picture {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-sidebar-bottom-name {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
|
||||
import {
|
||||
useIndexedDB,
|
||||
useIndexedDBRevision,
|
||||
@@ -42,7 +41,6 @@ import type { FileId } from "@app/types/file";
|
||||
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
|
||||
import { useFolderMembership } from "@app/hooks/useFolderMembership";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
import {
|
||||
setWatchedFolderDraggedFileIds,
|
||||
clearWatchedFolderDraggedFileIds,
|
||||
@@ -132,7 +130,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
// the workbench). The same map drives the per-file membership dots.
|
||||
const folderMembership = useFolderMembership();
|
||||
const allFolders = useAllWatchedFolders();
|
||||
const policyFileBadges = usePolicyFileBadges();
|
||||
const folderById = useMemo(
|
||||
() => new Map(allFolders.map((f) => [f.id, f])),
|
||||
[allFolders],
|
||||
@@ -187,11 +184,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const displayName =
|
||||
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
|
||||
|
||||
const profilePictureUrl = useProfilePictureUrl();
|
||||
const [pictureFailed, setPictureFailed] = useState(false);
|
||||
useEffect(() => setPictureFailed(false), [profilePictureUrl]);
|
||||
const showProfilePicture = !!profilePictureUrl && !pictureFailed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.enableLogin) {
|
||||
setAccountUsername(null);
|
||||
@@ -894,9 +886,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
onDragStart={handleWatchedFolderDragStart}
|
||||
folders={memberFolders}
|
||||
onFolderClick={openWatchedFolder}
|
||||
policies={
|
||||
policyFileBadges.get(stub.id as string) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -949,21 +938,10 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
style={onOpenSettings ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
<div
|
||||
className={`file-sidebar-bottom-avatar${
|
||||
showProfilePicture ? " file-sidebar-bottom-avatar--picture" : ""
|
||||
}`}
|
||||
className="file-sidebar-bottom-avatar"
|
||||
aria-label={displayName}
|
||||
>
|
||||
{showProfilePicture ? (
|
||||
<img
|
||||
src={profilePictureUrl}
|
||||
alt=""
|
||||
className="file-sidebar-bottom-avatar-img"
|
||||
onError={() => setPictureFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-bottom-name sidebar-content-fade">
|
||||
|
||||
@@ -36,32 +36,6 @@
|
||||
background-color: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
/* A policy-enforced file glows in that policy's accent colour so it's obvious
|
||||
the file has had a policy applied: it pulses a few times to catch the eye and
|
||||
then fades out (no lingering glow). */
|
||||
.file-sidebar-file-item.policy-enforced {
|
||||
animation: policy-glow-fade 4.5s ease-in-out forwards;
|
||||
}
|
||||
@keyframes policy-glow-fade {
|
||||
0%,
|
||||
24%,
|
||||
48% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent),
|
||||
0 0 6px -2px var(--policy-glow);
|
||||
}
|
||||
12%,
|
||||
36%,
|
||||
60% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent),
|
||||
0 0 16px 0 var(--policy-glow);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.selected {
|
||||
background-color: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
@@ -160,25 +134,6 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */
|
||||
.file-sidebar-policy-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-sidebar-policy-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
/* `color` is set inline to the policy's accent; the tint follows it. */
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Folder membership tags ---- */
|
||||
.file-sidebar-folder-tags {
|
||||
display: flex;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
@@ -126,17 +125,6 @@ export interface FileItemFolderRef {
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
/** A policy that has run on this file, used for the activity badges. */
|
||||
export interface FileItemPolicyRef {
|
||||
id: string;
|
||||
name: string;
|
||||
/** CSS colour for the badge (matches the policy's accent). */
|
||||
accentColor: string;
|
||||
/** True only just after the policy was applied — drives the one-off glow, so
|
||||
* it doesn't replay on every reload of an already-enforced file. */
|
||||
recent: boolean;
|
||||
}
|
||||
|
||||
export interface FileItemProps {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
@@ -155,12 +143,9 @@ export interface FileItemProps {
|
||||
folders?: FileItemFolderRef[];
|
||||
/** Clicking a membership dot opens that folder. */
|
||||
onFolderClick?: (folderId: string) => void;
|
||||
/** Policies that have run on this file — rendered as small shield badges. */
|
||||
policies?: FileItemPolicyRef[];
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_FOLDER_TAGS = 2;
|
||||
const MAX_VISIBLE_POLICY_BADGES = 3;
|
||||
|
||||
export function FileItem({
|
||||
fileId,
|
||||
@@ -177,7 +162,6 @@ export function FileItem({
|
||||
onDragStart,
|
||||
folders = [],
|
||||
onFolderClick,
|
||||
policies = [],
|
||||
}: FileItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const ext = getFileExtension(name);
|
||||
@@ -204,9 +188,6 @@ export function FileItem({
|
||||
|
||||
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
|
||||
|
||||
// A just-applied policy (recent run) drives the one-off row glow.
|
||||
const recentPolicy = policies.find((p) => p.recent);
|
||||
|
||||
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
|
||||
const thumbPos =
|
||||
hoverRect && resolvedThumbnail
|
||||
@@ -220,14 +201,7 @@ export function FileItem({
|
||||
<>
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}${recentPolicy ? " policy-enforced" : ""}`}
|
||||
style={
|
||||
recentPolicy
|
||||
? ({
|
||||
"--policy-glow": recentPolicy.accentColor,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
|
||||
onClick={() => onClick(fileId)}
|
||||
draggable={draggable}
|
||||
onDragStart={
|
||||
@@ -263,25 +237,6 @@ export function FileItem({
|
||||
{dateLabel && typeLabel ? " · " : ""}
|
||||
{typeLabel}
|
||||
</span>
|
||||
{policies.length > 0 && (
|
||||
<span className="file-sidebar-policy-badges" data-no-select>
|
||||
{policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
|
||||
<Tooltip
|
||||
key={policy.id}
|
||||
label={`${policy.name} policy ran on this file`}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span
|
||||
className="file-sidebar-policy-badge"
|
||||
style={{ color: policy.accentColor }}
|
||||
>
|
||||
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{folders.length > 0 && (
|
||||
<span className="file-sidebar-folder-tags" data-no-select>
|
||||
|
||||
@@ -77,6 +77,15 @@ export default function Footer({
|
||||
color: forceLightMode ? "#495057" : undefined,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
id="survey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
|
||||
>
|
||||
{t("survey.nav", "Survey")}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
|
||||
@@ -8,8 +8,7 @@ import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
|
||||
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
|
||||
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
@@ -84,16 +83,11 @@ export default function MobileUploadModal({
|
||||
const timerIntervalRef = useRef<number | null>(null);
|
||||
const processedFiles = useRef<Set<string>>(new Set());
|
||||
|
||||
// Build the QR-code URL the phone opens. It must land on the public
|
||||
// /mobile-scanner route under the app's base path, otherwise the phone hits
|
||||
// the auth-gated catch-all route and is bounced to the login page.
|
||||
const mobileUrl = buildMobileScannerUrl({
|
||||
configuredUrl:
|
||||
localStorage.getItem("server_url") || config?.frontendUrl || "",
|
||||
sessionId,
|
||||
origin: window.location.origin,
|
||||
basePath: BASE_PATH,
|
||||
});
|
||||
// Use configured frontendUrl if set, otherwise use current origin
|
||||
// Combine with base path and mobile-scanner route
|
||||
const baseUrl = localStorage.getItem("server_url") || "";
|
||||
const frontendUrl = baseUrl || config?.frontendUrl || window.location.origin;
|
||||
const mobileUrl = `${frontendUrl}${withBasePath("/mobile-scanner")}?session=${sessionId}`;
|
||||
|
||||
// Create session on backend
|
||||
const createSession = useCallback(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useEffect, ReactNode } from "react";
|
||||
import { createContext, useContext, ReactNode } from "react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
|
||||
import { mantineTheme } from "@app/theme/mantineTheme";
|
||||
@@ -7,10 +7,6 @@ import { ToastProvider } from "@app/components/toast";
|
||||
import ToastRenderer from "@app/components/toast/ToastRenderer";
|
||||
import { ToastPortalBinder } from "@app/components/toast";
|
||||
import type { ThemeMode } from "@app/constants/theme";
|
||||
// SUI shared design-system tokens (used by @shared/components). Additive — its
|
||||
// var names don't clash with the editor's own theme.css. The effect below
|
||||
// bridges Mantine's color scheme to the `data-theme` attribute SUI keys on.
|
||||
import "@shared/tokens/tokens.css";
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: ThemeMode;
|
||||
@@ -44,16 +40,6 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
const mantineColorScheme =
|
||||
rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode;
|
||||
|
||||
// Bridge the resolved scheme to the `data-theme` attribute SUI's tokens.css
|
||||
// keys its dark palette on (the editor otherwise only sets Mantine's
|
||||
// data-mantine-color-scheme), so @shared/components theme correctly.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme",
|
||||
mantineColorScheme === "dark" ? "dark" : "light",
|
||||
);
|
||||
}, [mantineColorScheme]);
|
||||
|
||||
return (
|
||||
<RainbowThemeContext.Provider value={rainbowTheme}>
|
||||
<MantineProvider
|
||||
|
||||
@@ -29,7 +29,7 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
|
||||
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 { downloadFile } from "@app/services/downloadService";
|
||||
import {
|
||||
WorkbenchBarButtonConfig,
|
||||
WorkbenchBarRenderContext,
|
||||
@@ -150,7 +150,6 @@ export default function WorkbenchBar({
|
||||
data: new Blob([buffer], { type: "application/pdf" }),
|
||||
filename: fileToExport.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
fileId: stub?.id,
|
||||
});
|
||||
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
@@ -180,7 +179,6 @@ export default function WorkbenchBar({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
fileId: stub?.id,
|
||||
});
|
||||
if (result.cancelled) continue;
|
||||
if (!forceNewFile && stub && result.savedPath) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { NavKey } from "@app/components/shared/config/types";
|
||||
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
|
||||
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
||||
import HelpSection from "@app/components/shared/config/configSections/HelpSection";
|
||||
import LegalSection from "@app/components/shared/config/configSections/LegalSection";
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -71,17 +70,6 @@ export const useConfigNavSections = (
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("settings.legal.title", "Legal"),
|
||||
items: [
|
||||
{
|
||||
key: "legal",
|
||||
label: t("settings.legal.label", "Legal"),
|
||||
icon: "gavel-rounded",
|
||||
component: <LegalSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import React from "react";
|
||||
import { Anchor, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useFooterInfo } from "@app/hooks/useFooterInfo";
|
||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||
|
||||
interface LegalLink {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const LegalSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { footerInfo } = useFooterInfo();
|
||||
|
||||
const analyticsEnabled =
|
||||
config?.enableAnalytics ?? footerInfo?.analyticsEnabled ?? false;
|
||||
const privacyPolicy = config?.privacyPolicy ?? footerInfo?.privacyPolicy;
|
||||
const termsAndConditions =
|
||||
config?.termsAndConditions ?? footerInfo?.termsAndConditions;
|
||||
const accessibilityStatement =
|
||||
config?.accessibilityStatement ?? footerInfo?.accessibilityStatement;
|
||||
const cookiePolicy = config?.cookiePolicy ?? footerInfo?.cookiePolicy;
|
||||
const impressum = config?.impressum ?? footerInfo?.impressum;
|
||||
|
||||
const { showCookiePreferences } = useCookieConsent({
|
||||
analyticsEnabled: analyticsEnabled === true,
|
||||
});
|
||||
|
||||
const isValidLink = (link?: string) => link && link.trim().length > 0;
|
||||
|
||||
const legalLinks: LegalLink[] = [
|
||||
{
|
||||
key: "privacy",
|
||||
label: t("legal.privacy", "Privacy Policy"),
|
||||
href: isValidLink(privacyPolicy)
|
||||
? privacyPolicy!
|
||||
: "https://www.stirling.com/privacy",
|
||||
},
|
||||
{
|
||||
key: "terms",
|
||||
label: t("legal.terms", "Terms and Conditions"),
|
||||
href: isValidLink(termsAndConditions)
|
||||
? termsAndConditions!
|
||||
: "https://www.stirling.com/terms",
|
||||
},
|
||||
...(isValidLink(accessibilityStatement)
|
||||
? [
|
||||
{
|
||||
key: "accessibility",
|
||||
label: t("legal.accessibility", "Accessibility"),
|
||||
href: accessibilityStatement!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isValidLink(cookiePolicy)
|
||||
? [
|
||||
{
|
||||
key: "cookie",
|
||||
label: t("legal.cookie", "Cookie Policy"),
|
||||
href: cookiePolicy!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isValidLink(impressum)
|
||||
? [
|
||||
{
|
||||
key: "impressum",
|
||||
label: t("legal.impressum", "Impressum"),
|
||||
href: impressum!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const renderLink = (link: LegalLink) => (
|
||||
<Anchor
|
||||
key={link.key}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{link.label}
|
||||
<LocalIcon icon="open-in-new-rounded" width="0.9rem" height="0.9rem" />
|
||||
</Group>
|
||||
</Anchor>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("settings.legal.documents.title", "Legal Documents")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.legal.documents.description",
|
||||
"Policies and legal information for this service.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Stack gap="sm">{legalLinks.map(renderLink)}</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{analyticsEnabled === true && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("legal.showCookieBanner", "Cookie Preferences")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.legal.cookiePreferences.description",
|
||||
"Review or change your cookie consent choices.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
id="cookieBanner"
|
||||
onClick={showCookiePreferences}
|
||||
>
|
||||
{t("settings.legal.cookiePreferences.manage", "Manage")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LegalSection;
|
||||
@@ -31,8 +31,6 @@ export const VALID_NAV_KEYS = [
|
||||
"adminStorageSharing",
|
||||
"adminMcp",
|
||||
"help",
|
||||
"legal",
|
||||
"payg",
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -51,27 +51,6 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* A toast tied to a policy pulses a glow in that policy's accent (var set
|
||||
inline as --toast-glow), so it's obvious which policy is being applied. */
|
||||
.toast-item--glow {
|
||||
animation: toast-glow-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes toast-glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
var(--shadow-lg),
|
||||
0 0 0 1px color-mix(in srgb, var(--toast-glow) 35%, transparent),
|
||||
0 0 10px -2px var(--toast-glow);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
var(--shadow-lg),
|
||||
0 0 0 1px color-mix(in srgb, var(--toast-glow) 80%, transparent),
|
||||
0 0 20px 0 var(--toast-glow);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast Alert Type Colors */
|
||||
.toast-item--success {
|
||||
background: var(--color-green-100);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@app/components/toast/ToastContext";
|
||||
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
|
||||
@@ -65,16 +64,7 @@ export default function ToastRenderer() {
|
||||
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
||||
{grouped[loc].map((t) => {
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={`${getToastItemClass(t)}${t.glowColor ? " toast-item--glow" : ""}`}
|
||||
style={
|
||||
t.glowColor
|
||||
? ({ "--toast-glow": t.glowColor } as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div key={t.id} role="status" className={getToastItemClass(t)}>
|
||||
{/* Top row: Icon + Title + Controls */}
|
||||
<div className="toast-header">
|
||||
{/* Icon */}
|
||||
|
||||
@@ -25,10 +25,6 @@ export interface ToastOptions {
|
||||
id?: string;
|
||||
/** If true, show chevron and collapse/expand animation. Defaults to true. */
|
||||
expandable?: boolean;
|
||||
/** Optional accent colour (any CSS colour, e.g. a `var(--color-…)`). When set,
|
||||
* the toast pulses a glow in this colour — used to tie a toast to a policy's
|
||||
* accent. */
|
||||
glowColor?: string;
|
||||
}
|
||||
|
||||
export interface ToastInstance extends Omit<
|
||||
|
||||
@@ -3,6 +3,11 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import {
|
||||
AgentsFullscreenSection,
|
||||
useAgentChatOpen,
|
||||
useAgentsEnabled,
|
||||
} from "@app/components/agents/AgentsPanel";
|
||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
@@ -11,11 +16,13 @@ import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
export function useIsFullscreenExpanded(): boolean {
|
||||
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
|
||||
const isMobile = useIsMobile();
|
||||
const agentChatOpen = useAgentChatOpen();
|
||||
return (
|
||||
toolPanelMode === "fullscreen" &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
!readerMode
|
||||
!readerMode &&
|
||||
!agentChatOpen
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +50,8 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
|
||||
handleToolSelect,
|
||||
} = useToolWorkflow();
|
||||
const isMobile = useIsMobile();
|
||||
const agentsEnabled = useAgentsEnabled();
|
||||
const agentChatOpen = useAgentChatOpen();
|
||||
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
@@ -50,7 +59,8 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
|
||||
toolPanelMode === "fullscreen" &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
!readerMode;
|
||||
!readerMode &&
|
||||
!agentChatOpen;
|
||||
|
||||
useEffect(() => {
|
||||
setAllButtonsDisabled(fullscreenExpanded);
|
||||
@@ -84,6 +94,9 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
|
||||
}
|
||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||
geometry={geometry}
|
||||
agentsSlot={
|
||||
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ interface FullscreenToolSurfaceProps {
|
||||
onToggleDescriptions: () => void;
|
||||
onExitFullscreenMode: () => void;
|
||||
geometry: ToolPanelGeometry | null;
|
||||
/** Optional agents block rendered above the tool list. */
|
||||
agentsSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
const FullscreenToolSurface = ({
|
||||
@@ -39,6 +41,7 @@ const FullscreenToolSurface = ({
|
||||
onToggleDescriptions,
|
||||
onExitFullscreenMode: _onExitFullscreenMode,
|
||||
geometry,
|
||||
agentsSlot,
|
||||
}: FullscreenToolSurfaceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
@@ -89,6 +92,9 @@ const FullscreenToolSurface = ({
|
||||
className="tool-panel__fullscreen-scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
{agentsSlot && (
|
||||
<div className="tool-panel__fullscreen-agents">{agentsSlot}</div>
|
||||
)}
|
||||
<FullscreenToolList
|
||||
filteredTools={filteredTools}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useRef, useCallback } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
@@ -9,13 +9,12 @@ import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import {
|
||||
PoliciesCollapsedButton,
|
||||
PoliciesSection,
|
||||
PolicyDetailTakeover,
|
||||
usePoliciesEnabled,
|
||||
usePolicyDetailActive,
|
||||
} from "@app/components/policies/PoliciesSidebar";
|
||||
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
|
||||
AgentsCollapsedButton,
|
||||
AgentsSection,
|
||||
useAgentsEnabled,
|
||||
} from "@app/components/agents/AgentsPanel";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
import { useToolSections } from "@app/hooks/useToolSections";
|
||||
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
||||
@@ -34,12 +33,16 @@ import {
|
||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
|
||||
const DEFAULT_CHAT_WIDTH_PX = 18.5 * 16; // 18.5rem in px
|
||||
const MIN_CHAT_WIDTH_PX = 240;
|
||||
const MAX_CHAT_WIDTH_PX = 720;
|
||||
|
||||
/**
|
||||
* Right-side rail wrapping the tool panel.
|
||||
*
|
||||
* Owns the rail-level concerns: collapse/expand chrome and the collapsed strip
|
||||
* (favourite/recommended icon shortcuts). Fullscreen takeover lives in
|
||||
* FullscreenToolPanel.
|
||||
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label,
|
||||
* the collapsed strip (agent button + favourite/recommended icon shortcuts),
|
||||
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel.
|
||||
*/
|
||||
export default function RightSidebar() {
|
||||
const { t } = useTranslation();
|
||||
@@ -66,8 +69,7 @@ export default function RightSidebar() {
|
||||
favoriteTools,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const policiesEnabled = usePoliciesEnabled();
|
||||
const rawPolicyDetailActive = usePolicyDetailActive();
|
||||
const agentsEnabled = useAgentsEnabled();
|
||||
const fullscreenExpanded = useIsFullscreenExpanded();
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
enabled: fullscreenExpanded,
|
||||
@@ -89,6 +91,57 @@ export default function RightSidebar() {
|
||||
|
||||
const [allToolsView, setAllToolsView] = useState(false);
|
||||
|
||||
const { isOpen: isChatOpen, setOpen: setChatOpen } = useChat();
|
||||
const [chatWidthPx, setChatWidthPx] = useState(DEFAULT_CHAT_WIDTH_PX);
|
||||
const [isChatDragging, setIsChatDragging] = useState(false);
|
||||
const chatDragState = useRef<{ startX: number; startWidth: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleChatClose = useCallback(() => {
|
||||
withViewTransition(() => setChatOpen(false));
|
||||
setChatWidthPx(DEFAULT_CHAT_WIDTH_PX);
|
||||
}, [setChatOpen]);
|
||||
|
||||
const handleResizeChatPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
chatDragState.current = { startX: e.clientX, startWidth: chatWidthPx };
|
||||
setIsChatDragging(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!chatDragState.current) return;
|
||||
const delta = chatDragState.current.startX - ev.clientX;
|
||||
setChatWidthPx(
|
||||
Math.max(
|
||||
MIN_CHAT_WIDTH_PX,
|
||||
Math.min(
|
||||
MAX_CHAT_WIDTH_PX,
|
||||
chatDragState.current.startWidth + delta,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
chatDragState.current = null;
|
||||
setIsChatDragging(false);
|
||||
document.body.style.removeProperty("cursor");
|
||||
document.body.style.removeProperty("user-select");
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", cleanup);
|
||||
window.removeEventListener("pointercancel", cleanup);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", cleanup);
|
||||
window.addEventListener("pointercancel", cleanup);
|
||||
},
|
||||
[chatWidthPx],
|
||||
);
|
||||
|
||||
const handleShowAllTools = () => {
|
||||
withViewTransition(() => setAllToolsView(true));
|
||||
};
|
||||
@@ -100,34 +153,15 @@ export default function RightSidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
// Opening a policy (e.g. from the collapsed rail) lands the rail in the clean
|
||||
// default tool-picker view — the only view the policy takeover renders in — so
|
||||
// it never collides with an open tool or the all-tools/search view.
|
||||
const handleOpenPolicy = () => {
|
||||
withViewTransition(() => {
|
||||
if (readerMode) setReaderMode(false);
|
||||
setLeftPanelView("toolPicker");
|
||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||
setAllToolsView(false);
|
||||
setSearchQuery("");
|
||||
});
|
||||
};
|
||||
|
||||
// The header shows [back] [search] when we have somewhere to go back to —
|
||||
// i.e. the user is in a specific tool, or already in the all-tools/search view.
|
||||
const inToolView = leftPanelView !== "toolPicker";
|
||||
// Show X (close) button only when there's somewhere to go back to.
|
||||
const showCloseButton = inToolView || allToolsView;
|
||||
// Policies sit above the tool list in the default tool-picker view.
|
||||
const showPolicies =
|
||||
policiesEnabled && !allToolsView && leftPanelView === "toolPicker";
|
||||
// When Policies are shown, the search moves OUT of the header to sit between
|
||||
// the Policies and Tools sections (separating them); otherwise it stays in the
|
||||
// header. Show the header search when there's a close button, or in the
|
||||
// default tool-picker view.
|
||||
const showInlineSearch = showPolicies && !showCloseButton;
|
||||
// Show search input whenever there's a close button, or when agents are off and
|
||||
// we're in the default tool-picker view (search filters the full list inline).
|
||||
const showHeaderSearch =
|
||||
!showInlineSearch && (showCloseButton || leftPanelView === "toolPicker");
|
||||
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
|
||||
|
||||
const handleHeaderBack = () => {
|
||||
if (inToolView) {
|
||||
@@ -160,20 +194,18 @@ export default function RightSidebar() {
|
||||
? (toolRegistry[selectedToolKey as ToolId] ?? null)
|
||||
: null;
|
||||
|
||||
// The detail takeover replaces the tool list ONLY in the same default view —
|
||||
// never over an open tool or the all-tools view (which must keep priority).
|
||||
// A lingering selection is harmless: it stays hidden behind a tool and the
|
||||
// list/takeover reappears on return to the picker (as in the prototype).
|
||||
const policyDetailActive = rawPolicyDetailActive && showPolicies;
|
||||
|
||||
// The rail widens when a policy detail takes it over — the tool list is fine
|
||||
// at 18.5rem, but the policy detail/wizard/settings need more breathing room.
|
||||
const expandedWidth = policyDetailActive ? "25rem" : "18.5rem";
|
||||
// Agents header + section is hidden when:
|
||||
// - the AI engine is off, or
|
||||
// - the user is in the all-tools view, or
|
||||
// - a specific tool is being rendered (leftPanelView ≠ "toolPicker").
|
||||
const showAgents =
|
||||
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) return "100%";
|
||||
if (isChatOpen) return `${chatWidthPx}px`;
|
||||
if (!isPanelVisible) return "3.5rem";
|
||||
return expandedWidth;
|
||||
return "18.5rem";
|
||||
};
|
||||
|
||||
// Collapsed rail: show favourites + recommended tools as icons.
|
||||
@@ -207,17 +239,39 @@ export default function RightSidebar() {
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : isChatOpen ? "" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
style={{
|
||||
width: computedWidth(),
|
||||
padding: "0",
|
||||
...(isChatDragging ? { transition: "none" } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Headless: enforces enabled policies on every uploaded file. */}
|
||||
{policiesEnabled && <PolicyAutoRunController />}
|
||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
||||
{!fullscreenExpanded && isChatOpen && (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="agents-takeover__resize-handle"
|
||||
onPointerDown={handleResizeChatPointerDown}
|
||||
role="separator"
|
||||
aria-label={t("chat.resize", "Resize chat panel")}
|
||||
aria-orientation="vertical"
|
||||
/>
|
||||
<ChatPanel
|
||||
onBack={handleChatClose}
|
||||
backLabel={t("agents.back_to_tools", "Back to tools")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && !isChatOpen && !isPanelVisible && !isMobile && (
|
||||
<div className="tool-panel__collapsed-strip">
|
||||
<div className="tool-panel__collapsed-top">
|
||||
<ActionIcon
|
||||
@@ -225,15 +279,15 @@ export default function RightSidebar() {
|
||||
color="gray.4"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
className="tool-panel__expand-btn"
|
||||
onClick={handleExpand}
|
||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
<AgentsCollapsedButton onExpand={handleExpand} />
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
|
||||
<div className="tool-panel__collapsed-tools">
|
||||
{collapsedRailItems.map(({ id, tool }) => (
|
||||
<AppTooltip
|
||||
@@ -261,7 +315,7 @@ export default function RightSidebar() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && isPanelVisible && (
|
||||
{!fullscreenExpanded && !isChatOpen && isPanelVisible && (
|
||||
<div
|
||||
/* Fixed width matches the expanded panel width so the inner content is
|
||||
laid out at its final size from the moment it mounts. The outer
|
||||
@@ -272,114 +326,84 @@ export default function RightSidebar() {
|
||||
opacity: 1,
|
||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
height: "100%",
|
||||
width: isMobile ? "100%" : expandedWidth,
|
||||
width: isMobile ? "100%" : "18.5rem",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{policyDetailActive ? (
|
||||
<div className="pol-takeover">
|
||||
<PolicyDetailTakeover />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!showPolicies && (
|
||||
<div className="tool-panel__compact-header">
|
||||
{activeTool ? (
|
||||
<div
|
||||
className="tool-panel__active-tool-pill"
|
||||
aria-label={activeTool.name}
|
||||
>
|
||||
<span className="tool-panel__active-tool-pill-icon">
|
||||
<ToolIcon
|
||||
icon={activeTool.icon}
|
||||
marginRight="0"
|
||||
color="var(--mantine-color-blue-filled)"
|
||||
/>
|
||||
</span>
|
||||
<span className="tool-panel__active-tool-pill-label">
|
||||
{activeTool.name}
|
||||
</span>
|
||||
</div>
|
||||
) : showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPolicies && (
|
||||
<PoliciesSection
|
||||
leadingControl={
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInlineSearch && (
|
||||
<div className="tool-panel__between-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
<div className="tool-panel__compact-header">
|
||||
{activeTool ? (
|
||||
<div
|
||||
className="tool-panel__active-tool-pill"
|
||||
aria-label={activeTool.name}
|
||||
>
|
||||
<span className="tool-panel__active-tool-pill-icon">
|
||||
<ToolIcon
|
||||
icon={activeTool.icon}
|
||||
marginRight="0"
|
||||
color="var(--mantine-color-blue-filled)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<span className="tool-panel__active-tool-pill-label">
|
||||
{activeTool.name}
|
||||
</span>
|
||||
</div>
|
||||
) : showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
showAgents && (
|
||||
<span className="tool-panel__section-label">
|
||||
{t("agents.section_title", "Agents")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToolPanel
|
||||
allToolsView={allToolsView}
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showAgents && <AgentsSection />}
|
||||
|
||||
<ToolPanel
|
||||
allToolsView={allToolsView}
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={agentsEnabled && !allToolsView}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -201,13 +201,6 @@
|
||||
border-color: var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
/* The collapse/expand toggle keeps a stable identity across the collapsed strip
|
||||
and the expanded header, so a view transition translates it between the two
|
||||
(same size) instead of cross-fading/scaling it ("big then small"). */
|
||||
.tool-panel__toggle-vt {
|
||||
view-transition-name: sidebar-toggle;
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn svg {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
@@ -278,22 +271,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Search that separates the Policies section from the Tools list below it. */
|
||||
.tool-panel__between-search {
|
||||
padding: 0.5rem 0.75rem 0.25rem;
|
||||
border-top: 1px solid var(--border-subtle, var(--color-border));
|
||||
}
|
||||
|
||||
.tool-panel__between-search .search-input-container {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Slightly recessed search field so it reads as distinct from the panel. */
|
||||
.tool-panel__between-search input {
|
||||
background-color: var(--bg-muted);
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -385,6 +362,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tool-panel__section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tool-panel__mode-toggle {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
@@ -846,6 +832,33 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Agents card lives in its own column above the tool grid. It uses the same
|
||||
.tool-panel__fullscreen-group base styles but with a colourful gradient
|
||||
border so it stands out as a distinct surface (and doesn't blend into the
|
||||
neighbouring category cards). */
|
||||
.tool-panel__fullscreen-agents {
|
||||
padding: 1.5rem 1.75rem 0;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-group--agents {
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group))
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-section-icon--agents {
|
||||
color: var(--mantine-color-blue-6);
|
||||
}
|
||||
|
||||
@keyframes tool-panel-fullscreen-slide-in {
|
||||
from {
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
|
||||
-4
@@ -21,15 +21,12 @@ interface AddWatermarkSingleStepSettingsProps {
|
||||
value: AddWatermarkParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
/** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */
|
||||
showFlatten?: boolean;
|
||||
}
|
||||
|
||||
const AddWatermarkSingleStepSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
showFlatten = true,
|
||||
}: AddWatermarkSingleStepSettingsProps) => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -72,7 +69,6 @@ const AddWatermarkSingleStepSettings = ({
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
showFlatten={showFlatten}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -10,15 +10,12 @@ interface WatermarkFormattingProps {
|
||||
value: AddWatermarkParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
/** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */
|
||||
showFlatten?: boolean;
|
||||
}
|
||||
|
||||
const WatermarkFormatting = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
showFlatten = true,
|
||||
}: WatermarkFormattingProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -98,19 +95,17 @@ const WatermarkFormatting = ({
|
||||
</Group>
|
||||
|
||||
{/* Advanced Options */}
|
||||
{showFlatten && (
|
||||
<Checkbox
|
||||
label={t(
|
||||
"watermark.settings.convertToImage",
|
||||
"Flatten PDF pages to images",
|
||||
)}
|
||||
checked={parameters.convertPDFToImage}
|
||||
onChange={(event) =>
|
||||
onParameterChange("convertPDFToImage", event.currentTarget.checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
label={t(
|
||||
"watermark.settings.convertToImage",
|
||||
"Flatten PDF pages to images",
|
||||
)}
|
||||
checked={parameters.convertPDFToImage}
|
||||
onChange={(event) =>
|
||||
onParameterChange("convertPDFToImage", event.currentTarget.checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
useNavigationState,
|
||||
useNavigationGuard,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
import { BASE_PATH, withBasePath } from "@app/constants/app";
|
||||
import { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext";
|
||||
import TextFieldsIcon from "@mui/icons-material/TextFields";
|
||||
import StraightenIcon from "@mui/icons-material/Straighten";
|
||||
@@ -73,10 +73,17 @@ export function useViewerWorkbenchBarButtons(
|
||||
});
|
||||
}, [registerImmediatePanUpdate]);
|
||||
|
||||
const stripBasePath = useCallback((path: string) => {
|
||||
if (BASE_PATH && path.startsWith(BASE_PATH)) {
|
||||
return path.slice(BASE_PATH.length) || "/";
|
||||
}
|
||||
return path;
|
||||
}, []);
|
||||
|
||||
const isAnnotationsPath = useCallback(() => {
|
||||
const cleanPath = stripBasePath(window.location.pathname).toLowerCase();
|
||||
return cleanPath === "/annotations" || cleanPath.endsWith("/annotations");
|
||||
}, []);
|
||||
}, [stripBasePath]);
|
||||
|
||||
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() =>
|
||||
isAnnotationsPath(),
|
||||
|
||||
@@ -38,13 +38,3 @@ export const absoluteWithBasePath = (path: string): string => {
|
||||
const clean = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${window.location.origin}${BASE_PATH}${clean}`;
|
||||
};
|
||||
|
||||
/** Strip BASE_PATH prefix so route comparisons work under any subpath deploy. */
|
||||
export const stripBasePath = (pathname: string): string => {
|
||||
if (!BASE_PATH) return pathname;
|
||||
if (pathname === BASE_PATH) return "/";
|
||||
if (pathname.startsWith(`${BASE_PATH}/`)) {
|
||||
return pathname.slice(BASE_PATH.length);
|
||||
}
|
||||
return pathname;
|
||||
};
|
||||
|
||||
@@ -4,18 +4,11 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Watched Folders. Disabled for now — gates both the custom workbench
|
||||
* registration (seeding, URL sync, view) and the sidebar entry point, so the
|
||||
* feature is unreachable from the UI. Flip to `true` to restore access.
|
||||
* Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the
|
||||
* custom workbench registration (seeding, URL sync, view) and the sidebar
|
||||
* entry point, so the feature is unreachable from the UI. Flip to `true` to
|
||||
* restore access.
|
||||
*/
|
||||
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
|
||||
// as constant/unreachable conditions by the type checker and linter.
|
||||
export const WATCHED_FOLDERS_ENABLED: boolean = false;
|
||||
|
||||
/**
|
||||
* Policies — a proprietary, automation-backed feature (like Watched Folders but
|
||||
* backend-driven, with non-folder triggers). The implementation lives under
|
||||
* `proprietary/`; this core value stays `false` so the shared sidebar entry
|
||||
* never appears in the open-source build.
|
||||
*/
|
||||
export const POLICIES_ENABLED: boolean = false;
|
||||
|
||||
@@ -14,7 +14,6 @@ export const AUTH_ROUTES = [
|
||||
"/invite",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/oauth/consent",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { useFileActions } from "@app/contexts/file/fileHooks";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
/** View-toggle modes; tuple keeps the union and iterator in sync. */
|
||||
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
|
||||
@@ -141,7 +140,6 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const folders = useFolders();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { config: appConfig } = useAppConfig();
|
||||
const { isAnonymous } = useAuth();
|
||||
|
||||
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -168,7 +166,6 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
const merged = await reconcileServerFiles(localLeaf, {
|
||||
storageEnabled,
|
||||
shareLinksEnabled,
|
||||
isAnonymous,
|
||||
});
|
||||
// Drop the merged result if a newer refresh has already started -
|
||||
// otherwise its stale snapshot will clobber the newer one's state.
|
||||
@@ -184,7 +181,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
// Only the latest refresh should clear the loading state.
|
||||
if (gen === refreshGenRef.current) setLoading(false);
|
||||
}
|
||||
}, [setFoldersError, storageEnabled, shareLinksEnabled, isAnonymous]);
|
||||
}, [setFoldersError, storageEnabled, shareLinksEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { FolderProvider, useFolders } from "@app/contexts/FolderContext";
|
||||
import { createFolderId, FolderId, FolderRecord } from "@app/types/folder";
|
||||
@@ -39,31 +38,6 @@ vi.mock("@app/services/folderSyncService", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// FolderProvider only pulls from the server for a confirmed, non-anonymous
|
||||
// user (guests have no cloud storage). Mock useAuth as a signed-in user so the
|
||||
// pull runs; the guest-skip path is covered by its own test below.
|
||||
const { mockAuth } = vi.hoisted(() => ({
|
||||
mockAuth: {
|
||||
user: { id: "test-user", is_anonymous: false } as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
isAnonymous: false,
|
||||
},
|
||||
}));
|
||||
vi.mock("@app/auth/UseSession", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockAuth.user,
|
||||
isAnonymous: mockAuth.isAnonymous,
|
||||
session: null,
|
||||
displayName: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stateful IDB mock - the revision-driven refresh re-reads getAllFolders
|
||||
// after every state change, so a stateless [] mock would clobber pull results.
|
||||
const { mockIdb } = vi.hoisted(() => ({
|
||||
@@ -136,11 +110,9 @@ function axiosError(status: number, message = "rejected"): Error {
|
||||
|
||||
async function renderAndWaitForPull(): Promise<void> {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FolderProvider>
|
||||
<Probe />
|
||||
</FolderProvider>
|
||||
</MemoryRouter>,
|
||||
<FolderProvider>
|
||||
<Probe />
|
||||
</FolderProvider>,
|
||||
);
|
||||
// The pull is fired from a mount effect; wait until it has resolved by
|
||||
// observing that `mockList` was called at least once and a tick has
|
||||
@@ -158,9 +130,6 @@ describe("FolderContext sync-banner gating", () => {
|
||||
mockList.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockDelete.mockReset();
|
||||
// Default each test to a signed-in, non-anonymous user so the pull runs.
|
||||
mockAuth.user = { id: "test-user", is_anonymous: false };
|
||||
mockAuth.isAnonymous = false;
|
||||
});
|
||||
|
||||
test("401 (unauthorized) does NOT surface a banner", async () => {
|
||||
@@ -212,28 +181,6 @@ describe("FolderContext sync-banner gating", () => {
|
||||
expect(screen.getByTestId("error").textContent).toBe("<null>");
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
|
||||
test("guest (anonymous) session does NOT pull from the server", async () => {
|
||||
// Guests have no cloud storage; pulling would 401 and (historically)
|
||||
// surface a toast. The provider must make no folder request at all.
|
||||
mockAuth.user = { id: "guest", is_anonymous: true };
|
||||
mockAuth.isAnonymous = true;
|
||||
mockList.mockResolvedValue([]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FolderProvider>
|
||||
<Probe />
|
||||
</FolderProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
// Let mount effects run; the pull must never fire.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockList).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("reachable").textContent).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
/** Per-folder mutation 404 = silent cleanup (drop subtree, no banner, pull). */
|
||||
@@ -244,9 +191,6 @@ describe("FolderContext stale-folder 404 cleanup", () => {
|
||||
mockDelete.mockReset();
|
||||
// Reset the stateful IDB mock so each test starts with an empty cache.
|
||||
mockIdb.folders = [];
|
||||
// Signed-in, non-anonymous user so pullFromServer runs.
|
||||
mockAuth.user = { id: "test-user", is_anonymous: false };
|
||||
mockAuth.isAnonymous = false;
|
||||
});
|
||||
|
||||
function makeFolder(name: string, parentFolderId: FolderId | null = null) {
|
||||
@@ -297,11 +241,9 @@ describe("FolderContext stale-folder 404 cleanup", () => {
|
||||
mockList.mockResolvedValueOnce(initial);
|
||||
const apiRef: { current: ProbeApi | null } = { current: null };
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FolderProvider>
|
||||
<ApiProbe onReady={(api) => (apiRef.current = api)} />
|
||||
</FolderProvider>
|
||||
</MemoryRouter>,
|
||||
<FolderProvider>
|
||||
<ApiProbe onReady={(api) => (apiRef.current = api)} />
|
||||
</FolderProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("count").textContent).toBe(
|
||||
|
||||
@@ -38,9 +38,6 @@ import {
|
||||
} from "@app/types/folder";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { isAuthRoute } from "@app/constants/routes";
|
||||
|
||||
interface FolderContextValue {
|
||||
folders: FolderRecord[];
|
||||
@@ -366,28 +363,10 @@ export function FolderProvider({ children }: FolderProviderProps) {
|
||||
// guaranteed-to-403 round-trip per session.
|
||||
const { config: appConfig } = useAppConfig();
|
||||
const storageBackedByServer = appConfig?.storageEnabled === true;
|
||||
// Skip server pulls on auth routes: FolderProvider is mounted globally and
|
||||
// /login has no session yet, so the pull would be a guaranteed 401.
|
||||
const location = useLocation();
|
||||
const onAuthRoute = isAuthRoute(location.pathname);
|
||||
// Guests (anonymous sessions) have no server-side storage, so a pull is a
|
||||
// guaranteed 401 - skip it once we know the session is anonymous. This is
|
||||
// what keeps the "must sign in" affordance toast-free: with no folder request
|
||||
// fired, there's no error to surface. We gate on `isAnonymous` (which is
|
||||
// false for a real signed-in user) rather than the auth `loading` flag, which
|
||||
// can stay true for an authenticated session and would otherwise block the
|
||||
// pull for legitimate users.
|
||||
const { user, isAnonymous } = useAuth();
|
||||
useEffect(() => {
|
||||
// Only pull once we have a confirmed, non-anonymous user. Skipping while
|
||||
// `user` is still null avoids a stray 401 in the brief window before an
|
||||
// anonymous session resolves (at which point `isAnonymous` flips true and
|
||||
// keeps us out). `isAnonymous` alone isn't enough because it defaults false
|
||||
// before the session loads; the auth `loading` flag is unreliable (it can
|
||||
// stay true for a valid signed-in session), so we key off the user object.
|
||||
if (!storageBackedByServer || onAuthRoute || !user || isAnonymous) return;
|
||||
if (!storageBackedByServer) return;
|
||||
void pullFromServer();
|
||||
}, [pullFromServer, storageBackedByServer, onAuthRoute, user, isAnonymous]);
|
||||
}, [pullFromServer, storageBackedByServer]);
|
||||
|
||||
const foldersById = useMemo(() => {
|
||||
const map = new Map<FolderId, FolderRecord>();
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
fileContextReducer,
|
||||
initialFileContextState,
|
||||
} from "@app/contexts/file/FileReducer";
|
||||
import type {
|
||||
FileContextState,
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
function stub(
|
||||
id: string,
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub {
|
||||
return {
|
||||
id: id as FileId,
|
||||
name: `${id}.pdf`,
|
||||
type: "application/pdf",
|
||||
size: 1,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stateWith(stubs: StirlingFileStub[]): FileContextState {
|
||||
return {
|
||||
...initialFileContextState,
|
||||
files: {
|
||||
ids: stubs.map((s) => s.id),
|
||||
byId: Object.fromEntries(stubs.map((s) => [s.id, s])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("fileContextReducer — derivedFromTool provenance", () => {
|
||||
it("ADD_FILES leaves uploads unmarked (a genuine upload is not tool-derived)", () => {
|
||||
const next = fileContextReducer(initialFileContextState, {
|
||||
type: "ADD_FILES",
|
||||
payload: { stirlingFileStubs: [stub("a")] },
|
||||
});
|
||||
expect(next.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it("CONSUME_FILES marks every output as tool-derived", () => {
|
||||
// An upload "a" is consumed by a tool producing an independent artifact "b"
|
||||
// (version metadata identical to an upload — only the flag distinguishes it).
|
||||
const next = fileContextReducer(stateWith([stub("a")]), {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [stub("b")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["b" as FileId].derivedFromTool).toBe(true);
|
||||
// Provenance: "b" records the input it derived from.
|
||||
expect(next.files.byId["b" as FileId].sourceFileIds).toEqual(["a"]);
|
||||
expect(next.files.byId["a" as FileId]).toBeUndefined(); // input consumed
|
||||
});
|
||||
|
||||
it("CONSUME_FILES accumulates sourceFileIds transitively", () => {
|
||||
// "b" already derived from "a"; consuming "b" → "c" carries both, so the
|
||||
// badge still resolves after the intermediate "b" is gone (e.g. split).
|
||||
const start = stateWith([stub("b", { sourceFileIds: ["a" as FileId] })]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["b" as FileId],
|
||||
outputStirlingFileStubs: [stub("c")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["c" as FileId].sourceFileIds).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("CONSUME_FILES with multiple inputs (merge) records all sources", () => {
|
||||
const start = stateWith([stub("a"), stub("b")]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId, "b" as FileId],
|
||||
outputStirlingFileStubs: [stub("merged")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["merged" as FileId].sourceFileIds).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("UNDO_CONSUME_FILES restores the original upload without mislabelling it", () => {
|
||||
// Reverses the swap above: the original upload "a" comes back through the
|
||||
// same helper, but must NOT be flagged tool-derived.
|
||||
const consumed = fileContextReducer(stateWith([stub("a")]), {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [stub("b")],
|
||||
},
|
||||
});
|
||||
const undone = fileContextReducer(consumed, {
|
||||
type: "UNDO_CONSUME_FILES",
|
||||
payload: {
|
||||
inputStirlingFileStubs: [stub("a")],
|
||||
outputFileIds: ["b" as FileId],
|
||||
},
|
||||
});
|
||||
expect(undone.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
|
||||
expect(undone.files.byId["b" as FileId]).toBeUndefined(); // output removed
|
||||
});
|
||||
});
|
||||
@@ -288,33 +288,7 @@ export function fileContextReducer(
|
||||
case "CONSUME_FILES": {
|
||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||
|
||||
// Transitive provenance: the outputs derive from these inputs AND from
|
||||
// whatever those inputs themselves derived from. Accumulating the closure
|
||||
// (rather than just the immediate inputs) means a policy badge still
|
||||
// resolves after an intermediate edit has been consumed and removed —
|
||||
// e.g. redact → edit → split still tags each split part. Captured before
|
||||
// the inputs are swapped out below.
|
||||
const sourceFileIds = Array.from(
|
||||
new Set(
|
||||
inputFileIds.flatMap((id) => [
|
||||
id,
|
||||
...(state.files.byId[id]?.sourceFileIds ?? []),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// Mark every consume output as tool-produced (the single chokepoint for
|
||||
// both versioned edits and independent artifacts like convert/split/merge)
|
||||
// and stamp its provenance. Tag here, not in processFileSwap, so
|
||||
// UNDO_CONSUME (which restores the original inputs through the same helper)
|
||||
// doesn't mislabel real uploads.
|
||||
const taggedOutputs = outputStirlingFileStubs.map((stub) => ({
|
||||
...stub,
|
||||
derivedFromTool: true,
|
||||
sourceFileIds,
|
||||
}));
|
||||
|
||||
return processFileSwap(state, inputFileIds, taggedOutputs);
|
||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||
}
|
||||
|
||||
case "UNDO_CONSUME_FILES": {
|
||||
|
||||
@@ -17,9 +17,7 @@ export function useAllWatchedFolders(): WatchedFolder[] {
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
// Policy-owned folders are managed by Policies, not shown here.
|
||||
const all = await watchedFolderStorage.getAllFolders();
|
||||
setFolders(all.filter((f) => !f.policyCategoryId));
|
||||
setFolders(await watchedFolderStorage.getAllFolders());
|
||||
} catch (err) {
|
||||
console.error("Failed to load smart folders:", err);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@ import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import {
|
||||
Z_INDEX_COOKIE_CONSENT_BANNER,
|
||||
Z_INDEX_COOKIE_PREFERENCES_MODAL,
|
||||
} from "@app/styles/zIndex";
|
||||
import { TOUR_STATE_EVENT, type TourStatePayload } from "@app/constants/events";
|
||||
import { getCookieConsentOverrides } from "@app/extensions/cookieConsentConfig";
|
||||
|
||||
@@ -15,8 +11,6 @@ declare global {
|
||||
run: (config: any) => void;
|
||||
show: (show?: boolean) => void;
|
||||
hide: () => void;
|
||||
showPreferences: () => void;
|
||||
hidePreferences: () => void;
|
||||
getCookie: (name?: string) => any;
|
||||
acceptedCategory: (category: string) => boolean;
|
||||
acceptedService: (serviceName: string, category: string) => boolean;
|
||||
@@ -29,16 +23,6 @@ interface CookieConsentConfig {
|
||||
forceLightMode?: boolean;
|
||||
}
|
||||
|
||||
// Shard so Mantine's scroll-lock doesn't swallow events on the consent dialog;
|
||||
// lazy because #cc-main only exists post-load.
|
||||
export const COOKIE_CONSENT_SCROLL_SHARD = {
|
||||
get current(): HTMLElement | null {
|
||||
return typeof document === "undefined"
|
||||
? null
|
||||
: document.getElementById("cc-main");
|
||||
},
|
||||
};
|
||||
|
||||
export const useCookieConsent = ({
|
||||
analyticsEnabled = false,
|
||||
forceLightMode = false,
|
||||
@@ -50,26 +34,16 @@ export const useCookieConsent = ({
|
||||
useEffect(() => {
|
||||
if (!analyticsEnabled) return;
|
||||
|
||||
// Bridge the layering constants to the static cookie-consent stylesheets
|
||||
document.documentElement.style.setProperty(
|
||||
"--z-index-cookie-consent",
|
||||
String(Z_INDEX_COOKIE_CONSENT_BANNER),
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--z-index-cookie-preferences",
|
||||
String(Z_INDEX_COOKIE_PREFERENCES_MODAL),
|
||||
);
|
||||
|
||||
const mainCSS = document.createElement("link");
|
||||
mainCSS.rel = "stylesheet";
|
||||
mainCSS.href = `${BASE_PATH}/css/cookieconsent.css`;
|
||||
mainCSS.href = `${BASE_PATH}css/cookieconsent.css`;
|
||||
if (!document.querySelector(`link[href="${mainCSS.href}"]`)) {
|
||||
document.head.appendChild(mainCSS);
|
||||
}
|
||||
|
||||
const customCSS = document.createElement("link");
|
||||
customCSS.rel = "stylesheet";
|
||||
customCSS.href = `${BASE_PATH}/css/cookieconsentCustomisation.css`;
|
||||
customCSS.href = `${BASE_PATH}css/cookieconsentCustomisation.css`;
|
||||
if (!document.querySelector(`link[href="${customCSS.href}"]`)) {
|
||||
document.head.appendChild(customCSS);
|
||||
}
|
||||
@@ -83,7 +57,7 @@ export const useCookieConsent = ({
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = `${BASE_PATH}/js/thirdParty/cookieconsent.umd.js`;
|
||||
script.src = `${BASE_PATH}js/thirdParty/cookieconsent.umd.js`;
|
||||
script.onload = () => {
|
||||
setTimeout(() => {
|
||||
const detectTheme = () => {
|
||||
@@ -417,10 +391,7 @@ export const useCookieConsent = ({
|
||||
|
||||
const showCookiePreferences = useCallback(() => {
|
||||
if (isInitialized && window.CookieConsent) {
|
||||
// Open the detailed preferences dialog directly (not the consent
|
||||
// banner) — it gets the `.show--preferences` class, which the CSS
|
||||
// raises above the settings modal.
|
||||
window.CookieConsent?.showPreferences();
|
||||
window.CookieConsent?.show(true);
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
|
||||
@@ -128,14 +128,10 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
"[useEndpointConfig] Fetching all endpoint statuses from server",
|
||||
);
|
||||
|
||||
// Fetch all endpoints at once; auto-fires on app load, so a 401 must
|
||||
// fail silently instead of triggering the global login redirect.
|
||||
// Fetch all endpoints at once - no query params needed
|
||||
const response = await apiClient.get<
|
||||
Record<string, EndpointAvailabilityDetails>
|
||||
>(`/api/v1/config/endpoints-availability`, {
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
>(`/api/v1/config/endpoints-availability`);
|
||||
|
||||
// Populate global cache with all results
|
||||
Object.entries(response.data).forEach(([endpoint, details]) => {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
|
||||
|
||||
/**
|
||||
* Policies that have run on each file, keyed by fileId — drives the shield
|
||||
* badges in the file sidebar. Empty in core; the proprietary build shadows this
|
||||
* with an implementation backed by the policy run store.
|
||||
*/
|
||||
export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
|
||||
return new Map();
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Core stub — no profile picture source; auth-aware layers override this.
|
||||
*/
|
||||
export function useProfilePictureUrl(): string | null {
|
||||
return null;
|
||||
}
|
||||
@@ -21,10 +21,6 @@ import UploadRoundedIcon from "@mui/icons-material/UploadRounded";
|
||||
import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded";
|
||||
import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded";
|
||||
import { loadJscanify } from "@app/utils/loadJscanify";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
// Use the configured API base (e.g. api.stirling.com), not the page origin.
|
||||
const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
* MobileScannerPage
|
||||
@@ -87,7 +83,7 @@ export default function MobileScannerPage() {
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/mobile-scanner/validate-session/${sessionId}`,
|
||||
`/api/v1/mobile-scanner/validate-session/${sessionId}`,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
@@ -845,7 +841,7 @@ export default function MobileScannerPage() {
|
||||
});
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
`${API_BASE}/api/v1/mobile-scanner/upload/${sessionId}`,
|
||||
`/api/v1/mobile-scanner/upload/${sessionId}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
|
||||
@@ -28,11 +28,8 @@ export const accountService = {
|
||||
* This is a public endpoint - doesn't require authentication
|
||||
*/
|
||||
async getLoginPageData(): Promise<LoginPageData> {
|
||||
// Also auto-called by onboarding when a stale stirling_jwt exists; a 401
|
||||
// must never trigger the global login redirect.
|
||||
const response = await apiClient.get<LoginPageData>(
|
||||
"/api/v1/proprietary/ui-data/login",
|
||||
{ suppressErrorToast: true, skipAuthRedirect: true },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Auth headers for raw fetch() calls — empty in core; proprietary/SaaS override. */
|
||||
export async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
/** Auth headers for raw fetch() calls (SSE streams, etc.). Proprietary overrides with JWT + XSRF. */
|
||||
export function getAuthHeaders(): Record<string, string> {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ export interface DownloadRequest {
|
||||
data: Blob | File;
|
||||
filename: string;
|
||||
localPath?: string;
|
||||
/** Workspace fileId of the file being exported, when known. Lets export-time
|
||||
* policy enforcement version the in-editor file (not just the download). */
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Export entry points call {@link downloadFileWithPolicy} instead of
|
||||
* {@link downloadFile} so any "export"-triggered policy enforces on the file
|
||||
* before it's downloaded. The enforcement itself is proprietary (a no-op in the
|
||||
* core build via the `@app/services/policyExport` stub), and never hard-blocks:
|
||||
* on failure the original file is downloaded.
|
||||
*/
|
||||
|
||||
import {
|
||||
downloadFile,
|
||||
type DownloadRequest,
|
||||
type DownloadResult,
|
||||
} from "@app/services/downloadService";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
|
||||
export async function downloadFileWithPolicy(
|
||||
request: DownloadRequest,
|
||||
): Promise<DownloadResult> {
|
||||
// enforceExportPolicies only touches PDFs and is a no-op without an active
|
||||
// export policy, so non-PDF / non-policy downloads pass straight through.
|
||||
const input =
|
||||
request.data instanceof File
|
||||
? request.data
|
||||
: new File([request.data], request.filename, {
|
||||
type: request.data.type,
|
||||
});
|
||||
const [enforced] = await enforceExportPolicies([input], [request.fileId]);
|
||||
return downloadFile({ ...request, data: enforced ?? request.data });
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
legacyDerivedFromTool,
|
||||
type StoredStirlingFileRecord,
|
||||
} from "@app/services/fileStorage";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/**
|
||||
* Backfill of `derivedFromTool` for files persisted before the field existed
|
||||
* (old users' IndexedDB). New records always carry an explicit flag, so this
|
||||
* helper only governs pre-upgrade records.
|
||||
*/
|
||||
function record(
|
||||
overrides: Partial<StoredStirlingFileRecord>,
|
||||
): StoredStirlingFileRecord {
|
||||
return {
|
||||
id: "f" as FileId,
|
||||
fileId: "f" as FileId,
|
||||
quickKey: "k",
|
||||
name: "f.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: "f",
|
||||
versionNumber: 1,
|
||||
data: new ArrayBuffer(0),
|
||||
...overrides,
|
||||
} as StoredStirlingFileRecord;
|
||||
}
|
||||
|
||||
describe("legacyDerivedFromTool — IndexedDB backfill for pre-upgrade files", () => {
|
||||
it("flags a legacy versioned edit (has tool history)", () => {
|
||||
expect(
|
||||
legacyDerivedFromTool(
|
||||
record({ toolHistory: [{ toolId: "compress" as any, timestamp: 0 }] }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a legacy file past its first version", () => {
|
||||
expect(legacyDerivedFromTool(record({ versionNumber: 2 }))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a legacy file with a parent", () => {
|
||||
expect(legacyDerivedFromTool(record({ parentFileId: "p" as FileId }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a clean legacy root unflagged — treated as an upload (safe default for enforcement)", () => {
|
||||
// A genuine upload AND a legacy independent artifact (convert/split/merge)
|
||||
// both look like this; old data can't tell them apart, so we enforce.
|
||||
expect(legacyDerivedFromTool(record({}))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -38,25 +38,6 @@ export interface StorageStats {
|
||||
quota?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort provenance for records persisted before `derivedFromTool`
|
||||
* existed. A version chain (a tool history, a version past the first, or a
|
||||
* parent) is unambiguously a tool output, so flag it. Legacy independent
|
||||
* artifacts (convert/split/merge) recorded none of that and are
|
||||
* indistinguishable from uploads in old data — they stay unflagged, which for
|
||||
* an enforcement feature is the safe default (enforce rather than silently
|
||||
* skip). New records always carry an explicit flag, so this only fires for
|
||||
* pre-existing files on first read after upgrade.
|
||||
*/
|
||||
export function legacyDerivedFromTool(
|
||||
record: StoredStirlingFileRecord,
|
||||
): boolean | undefined {
|
||||
if ((record.toolHistory?.length ?? 0) > 0) return true;
|
||||
if ((record.versionNumber ?? 1) > 1) return true;
|
||||
if (record.parentFileId != null) return true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class FileStorageService {
|
||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||
private readonly storeName = "files";
|
||||
@@ -143,8 +124,6 @@ class FileStorageService {
|
||||
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
|
||||
parentFileId: stub.parentFileId ?? undefined,
|
||||
toolHistory: stub.toolHistory ?? [],
|
||||
derivedFromTool: stub.derivedFromTool ?? false,
|
||||
sourceFileIds: stub.sourceFileIds,
|
||||
|
||||
// Folder organisation (root when null)
|
||||
folderId: stub.folderId ?? null,
|
||||
@@ -268,9 +247,6 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory,
|
||||
derivedFromTool:
|
||||
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
};
|
||||
@@ -327,9 +303,6 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
derivedFromTool:
|
||||
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
@@ -418,9 +391,6 @@ class FileStorageService {
|
||||
originalFileId: record.originalFileId || record.id,
|
||||
parentFileId: record.parentFileId,
|
||||
toolHistory: record.toolHistory || [],
|
||||
derivedFromTool:
|
||||
record.derivedFromTool ?? legacyDerivedFromTool(record),
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
});
|
||||
|
||||
@@ -63,12 +63,6 @@ interface AccessedShareLinkResponse {
|
||||
export interface ReconcileOptions {
|
||||
storageEnabled: boolean;
|
||||
shareLinksEnabled: boolean;
|
||||
/**
|
||||
* Guests (anonymous sessions) have no cloud library; pulling it just 401s and
|
||||
* surfaces a "sign in to load cloud files" toast. Skip the server pull for
|
||||
* them and fall back to locally-cached files only.
|
||||
*/
|
||||
isAnonymous?: boolean;
|
||||
}
|
||||
|
||||
function normalizeServerFileName(fileName: string | undefined | null): string {
|
||||
@@ -114,7 +108,7 @@ export async function reconcileServerFiles(
|
||||
localStubs: StirlingFileStub[],
|
||||
opts: ReconcileOptions,
|
||||
): Promise<StirlingFileStub[]> {
|
||||
if (!opts.storageEnabled || opts.isAnonymous) {
|
||||
if (!opts.storageEnabled) {
|
||||
return localStubs;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,14 +62,8 @@ function toFolderRecord(dto: ServerFolder): FolderRecord {
|
||||
|
||||
export const folderSyncService = {
|
||||
async list(): Promise<FolderRecord[]> {
|
||||
// Auto-fired by FolderProvider on every load; a persistent 401 must fail
|
||||
// silently here or the global handler redirects to /login and loops.
|
||||
const response = await apiClient.get<ServerFolder[]>(
|
||||
"/api/v1/storage/folders",
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
return (response.data ?? []).map(toFolderRecord);
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
clampText,
|
||||
extractAxiosErrorMessage,
|
||||
} from "@app/services/httpErrorUtils";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
// Module-scoped state to reduce global variable usage
|
||||
const recentSpecialByEndpoint: Record<string, number> = {};
|
||||
@@ -46,51 +45,6 @@ function stashPostLoginRedirect(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Loop breaker: a second 401 redirect within this window means the login page
|
||||
// bounced us back with a live session — redirecting again would loop forever.
|
||||
const LOGIN_REDIRECT_THROTTLE_KEY = "stirling_last_401_redirect";
|
||||
const LOGIN_REDIRECT_THROTTLE_MS = 10_000;
|
||||
|
||||
function loginRedirectRecentlyFired(): boolean {
|
||||
try {
|
||||
const last = Number(
|
||||
window.sessionStorage.getItem(LOGIN_REDIRECT_THROTTLE_KEY),
|
||||
);
|
||||
return (
|
||||
Number.isFinite(last) && Date.now() - last < LOGIN_REDIRECT_THROTTLE_MS
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markLoginRedirectFired(): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
LOGIN_REDIRECT_THROTTLE_KEY,
|
||||
String(Date.now()),
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage unavailable — fail open
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the throttle when the user establishes a fresh session via interactive
|
||||
// login. Otherwise a genuine expiry that happens within the throttle window of
|
||||
// the redirect that sent them to /login would be wrongly suppressed, leaving
|
||||
// them on a page with silently failing requests. Login dispatches
|
||||
// "jwt-available"; token refresh does not (it fires "TOKEN_REFRESHED"), so
|
||||
// refresh-driven redirect churn is still dampened by the throttle.
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("jwt-available", () => {
|
||||
try {
|
||||
window.sessionStorage.removeItem(LOGIN_REDIRECT_THROTTLE_KEY);
|
||||
} catch {
|
||||
// sessionStorage unavailable - nothing to clear
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles HTTP errors with toast notifications and file error broadcasting
|
||||
* Returns true if the error should be suppressed (deduplicated), false otherwise
|
||||
@@ -116,13 +70,6 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
||||
|
||||
// If not on auth page, redirect to login with expired session message
|
||||
if (!isAuthPage && !skipAuthRedirect) {
|
||||
if (loginRedirectRecentlyFired()) {
|
||||
console.warn(
|
||||
"[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:",
|
||||
error?.config?.url,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
console.debug("[httpErrorHandler] 401 detected, redirecting to login");
|
||||
// Spring 302-strips the ?from= query from /login, so stash the return
|
||||
// path in sessionStorage (AuthCallback reads it after SSO round-trip).
|
||||
@@ -135,8 +82,7 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
||||
// ignore storage access failures
|
||||
}
|
||||
const expiredPrefix = hadStoredJwt ? "expired=true&" : "";
|
||||
markLoginRedirectFired();
|
||||
window.location.href = `${withBasePath("/login")}?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
|
||||
window.location.href = `/login?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
|
||||
return true; // Suppress toast since we're redirecting
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
setPageRotation,
|
||||
addNewPage,
|
||||
} from "@app/services/pdfiumService";
|
||||
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||
|
||||
// A4 dimensions in PDF points (72 dpi)
|
||||
@@ -259,10 +259,10 @@ export class PDFExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single file, applying any export-triggered policy first.
|
||||
* Download a single file
|
||||
*/
|
||||
downloadFile(blob: Blob, filename: string): void {
|
||||
void downloadFileWithPolicy({ data: blob, filename });
|
||||
void downloadFile({ data: blob, filename });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Open-source stub for export-time policy enforcement. Policies are a proprietary
|
||||
* feature, so in the core build there's nothing to enforce — this returns the
|
||||
* files unchanged. The proprietary build shadows this module via the `@app/*`
|
||||
* alias with the real implementation.
|
||||
*/
|
||||
export async function enforceExportPolicies(
|
||||
files: File[],
|
||||
_fileIds?: (string | undefined)[],
|
||||
): Promise<File[]> {
|
||||
return files;
|
||||
}
|
||||
@@ -26,11 +26,6 @@ export const Z_INDEX_DRAG_BADGE = 1001;
|
||||
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
|
||||
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
|
||||
|
||||
// Cookie-consent banner — above the chat FAB (1050), below all modals and onboarding; reaches CSS via --z-index-cookie-consent
|
||||
export const Z_INDEX_COOKIE_CONSENT_BANNER = 1060;
|
||||
// Cookie-consent preferences dialog — above the config modal it opens from; reaches CSS via --z-index-cookie-preferences
|
||||
export const Z_INDEX_COOKIE_PREFERENCES_MODAL = 1450;
|
||||
|
||||
// Sign-in modal — must appear above all app UI including config and analytics modals
|
||||
export const Z_INDEX_SIGN_IN_MODAL = 9000;
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ export async function mockAppApis(
|
||||
|
||||
// Current user — anonymous by default, configurable for authenticated flows
|
||||
await page.route("**/api/v1/auth/me", (route: Route) =>
|
||||
route.fulfill({ json: { user } }),
|
||||
route.fulfill({ json: user }),
|
||||
);
|
||||
|
||||
// Tool availability — every tool enabled unless overridden
|
||||
@@ -248,14 +248,6 @@ export async function mockAppApis(
|
||||
await page.route("**/api/v1/info/wau", (route: Route) =>
|
||||
route.fulfill({ json: { count: 0 } }),
|
||||
);
|
||||
|
||||
// Policies (proprietary): the reconcile fires GET /api/v1/policies on app
|
||||
// load. Return an empty list so the stubbed (backend-free) env stays clean —
|
||||
// no failed request polluting the console, and the auto-run controller has
|
||||
// nothing to dispatch.
|
||||
await page.route("**/api/v1/policies", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,23 +95,8 @@ test.describe("1. Authentication and Login", () => {
|
||||
await page.goto("/merge");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Simulate a genuinely expired session by forcing the token-refresh
|
||||
// endpoint to fail. Clearing client state alone is not enough: the SPA's
|
||||
// auth client refreshes a fresh JWT from a surviving server-side
|
||||
// credential and re-authenticates, churning between /merge, / and /login
|
||||
// instead of settling on the login page. A 401 here is what a real
|
||||
// expired/revoked session looks like to the client.
|
||||
await page.route("**/api/v1/auth/refresh", (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "session_expired" }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Invalidate the client session: clear cookies + the stored JWT, then
|
||||
// re-add the cookie-consent cookie so the banner doesn't block the login
|
||||
// page after redirect.
|
||||
// Step 1-2: Invalidate session by clearing cookies and localStorage JWT,
|
||||
// then re-add the cookie consent cookie so the banner doesn't block after redirect
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
@@ -131,28 +116,20 @@ test.describe("1. Authentication and Login", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// Full page reload forces the SPA to re-check auth with the backend.
|
||||
// Full page reload forces the SPA to re-check auth with the backend
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
// With refresh failing, the SPA settles on the login page. Wait for the
|
||||
// login form to render (a concrete signal the redirect landed) and then
|
||||
// confirm the URL, rather than racing a URL poll against the bootstrap.
|
||||
await page
|
||||
.locator("#email")
|
||||
.waitFor({ state: "visible", timeout: 30000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
// Step 3: Verify the user is redirected to the login page
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15000 });
|
||||
|
||||
// Stop forcing refresh failures so the fresh login below behaves normally.
|
||||
await page.unroute("**/api/v1/auth/refresh");
|
||||
|
||||
// Log back in with valid credentials.
|
||||
// Step 5: Log in with valid credentials
|
||||
await page.locator("#email").fill("admin");
|
||||
await page.locator("#password").fill("adminadmin");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Verify the user is redirected back off the login page. Any non-/login
|
||||
// URL is acceptable: the app may route to the original page (/merge) or
|
||||
// to the dashboard (/), both are valid post-login states.
|
||||
// Step 6: Verify the user is redirected back to /merge or home.
|
||||
// Any non-/login URL is acceptable — the app may route to the original
|
||||
// page (/merge) or to the dashboard (/), both are valid post-login states.
|
||||
await page.waitForURL((url) => !url.pathname.includes("/login"), {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
@@ -1,107 +1,103 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
test.describe("18. Cookie Preferences", () => {
|
||||
test.describe("18.1 Cookie Banner", () => {
|
||||
test("should open and configure cookie preferences from Settings → Legal", async ({
|
||||
test("should open and configure cookie preferences from footer", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Analytics must be enabled for the Cookie Preferences button to render
|
||||
await page.route("**/api/v1/ui-data/footer-info", (route) =>
|
||||
route.fulfill({ json: { analyticsEnabled: true } }),
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
// Step 1: The "Cookie Preferences" button lives in Settings → Legal
|
||||
await openSettings(page);
|
||||
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
|
||||
await expect(legalNav).toBeVisible({ timeout: 5000 });
|
||||
await legalNav.click();
|
||||
|
||||
const cookieButton = page.locator("#cookieBanner").first();
|
||||
await expect(cookieButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The consent library lazy-loads when the Legal section mounts
|
||||
await page.waitForFunction(
|
||||
() => (window as unknown as { CookieConsent?: unknown }).CookieConsent,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 2: Click the button — it opens the detailed preferences dialog
|
||||
// directly. No force: the click must land with the settings modal open,
|
||||
// proving the dialog stacks above it.
|
||||
await cookieButton.click();
|
||||
|
||||
// Step 3: Verify the preferences dialog opens inside #cc-main
|
||||
const ccMain = page.locator("#cc-main");
|
||||
const prefsDialog = ccMain.locator(".pm").first();
|
||||
await expect(prefsDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 4: Verify the dialog sits above the settings modal
|
||||
// (Z_INDEX_CONFIG_MODAL = 1400)
|
||||
const zIndex = await page.evaluate(() => {
|
||||
const el = document.querySelector("#cc-main");
|
||||
return el ? Number(getComputedStyle(el).zIndex) : -1;
|
||||
});
|
||||
expect(zIndex).toBeGreaterThan(1400);
|
||||
|
||||
// Step 5: Verify the preferences panel shows cookie categories
|
||||
const necessaryCategory = ccMain
|
||||
.locator("text=/Strictly Necessary|necessary/i")
|
||||
// Step 1: Locate the "Cookie Preferences" button in the footer
|
||||
const cookieButton = page
|
||||
.locator(
|
||||
'#cookieBanner, button:has-text("Preferensi Cookie"), button:has-text("Cookie Preferences")',
|
||||
)
|
||||
.first();
|
||||
const analyticsCategory = ccMain.locator("text=/Analytics/i").first();
|
||||
await expect(necessaryCategory.or(analyticsCategory).first()).toBeVisible(
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
// Step 6: Expand all sections and verify the dialog body wheel-scrolls
|
||||
// while the settings modal is open (regression: the modal's
|
||||
// react-remove-scroll lock swallowed wheel events on the dialog)
|
||||
const expanders = ccMain.locator(
|
||||
".pm__section--expandable .pm__section-title",
|
||||
);
|
||||
const expanderCount = await expanders.count();
|
||||
for (let i = 0; i < expanderCount; i++) {
|
||||
await expanders.nth(i).click();
|
||||
}
|
||||
const dialogBody = ccMain.locator(".pm__body").first();
|
||||
const canScroll = await dialogBody.evaluate(
|
||||
(el) => el.scrollHeight > el.clientHeight,
|
||||
);
|
||||
if (canScroll) {
|
||||
const box = await dialogBody.boundingBox();
|
||||
await page.mouse.move(
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2,
|
||||
await page.waitForTimeout(1000);
|
||||
const isVisible = await cookieButton.isVisible().catch(() => false);
|
||||
if (!isVisible) {
|
||||
test.skip(
|
||||
true,
|
||||
"Cookie Preferences button not visible — analytics may be disabled",
|
||||
);
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect
|
||||
.poll(() => dialogBody.evaluate((el) => el.scrollTop), {
|
||||
timeout: 3000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await dialogBody.evaluate((el) => {
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 7: Accept all (or save) to close — again without force
|
||||
const acceptAllBtn = ccMain
|
||||
.locator('button:has-text("Accept all")')
|
||||
await expect(cookieButton).toBeVisible();
|
||||
|
||||
// Step 2: Click the Cookie Preferences button
|
||||
await cookieButton.click({ force: true });
|
||||
|
||||
// Step 3: Verify the cookie consent dialog opens
|
||||
// The CookieConsent library renders inside #cc-main
|
||||
const ccMain = page.locator("#cc-main");
|
||||
const consentDialog = ccMain.getByRole("dialog").first();
|
||||
await expect(consentDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 4: Verify options are available
|
||||
// The initial consent view shows: "Oke", "Tidak, terima kasih", "Kelola preferensi"
|
||||
const okeBtn = ccMain
|
||||
.locator('button:has-text("Oke"), button:has-text("OK")')
|
||||
.first();
|
||||
const savePrefBtn = ccMain
|
||||
.locator('button:has-text("Save preferences")')
|
||||
const noThanksBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Tidak, terima kasih"), button:has-text("No Thanks")',
|
||||
)
|
||||
.first();
|
||||
const manageBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Kelola preferensi"), button:has-text("Manage preferences")',
|
||||
)
|
||||
.first();
|
||||
|
||||
if (await acceptAllBtn.isVisible().catch(() => false)) {
|
||||
await acceptAllBtn.click();
|
||||
} else {
|
||||
await savePrefBtn.click();
|
||||
const hasOke = await okeBtn.isVisible().catch(() => false);
|
||||
const hasNoThanks = await noThanksBtn.isVisible().catch(() => false);
|
||||
const hasManage = await manageBtn.isVisible().catch(() => false);
|
||||
|
||||
expect(hasOke || hasNoThanks || hasManage).toBe(true);
|
||||
|
||||
// Step 5: Click "Kelola preferensi" to open the detailed preferences panel
|
||||
if (hasManage) {
|
||||
await manageBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify the preferences panel shows cookie categories
|
||||
const necessaryCategory = ccMain
|
||||
.locator(
|
||||
"text=/Cookie yang Sangat Diperlukan|Strictly Necessary|necessary/i",
|
||||
)
|
||||
.first();
|
||||
const analyticsCategory = ccMain
|
||||
.locator("text=/Analitik|Analytics/i")
|
||||
.first();
|
||||
await expect(
|
||||
necessaryCategory.or(analyticsCategory).first(),
|
||||
).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Click "Terima semua" (Accept all) or "Simpan preferensi" (Save preferences) to close
|
||||
const acceptAllBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Terima semua"), button:has-text("Accept all")',
|
||||
)
|
||||
.first();
|
||||
const savePrefBtn = ccMain
|
||||
.locator(
|
||||
'button:has-text("Simpan preferensi"), button:has-text("Save preferences")',
|
||||
)
|
||||
.first();
|
||||
|
||||
if (await acceptAllBtn.isVisible().catch(() => false)) {
|
||||
await acceptAllBtn.click();
|
||||
} else if (await savePrefBtn.isVisible().catch(() => false)) {
|
||||
await savePrefBtn.click();
|
||||
}
|
||||
} else if (hasOke) {
|
||||
await okeBtn.click();
|
||||
} else if (hasNoThanks) {
|
||||
await noThanksBtn.click();
|
||||
}
|
||||
|
||||
// Step 8: Verify the dialog is dismissed
|
||||
await expect(prefsDialog).toBeHidden({ timeout: 5000 });
|
||||
// Step 6: Verify the dialog is dismissed
|
||||
await expect(consentDialog).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,14 +141,7 @@ async function settle(page: Page, ms = 350): Promise<void> {
|
||||
}
|
||||
|
||||
test.describe("Files page screenshots", () => {
|
||||
// Seed a logged-in session: the cloud-folder surfaces (move-dialog
|
||||
// create-folder, the seeded "Reports" folder) only render once a confirmed,
|
||||
// non-anonymous user triggers the folder pull (see FolderContext gating).
|
||||
test.use({
|
||||
autoGoto: false,
|
||||
viewport: { width: 1600, height: 900 },
|
||||
seedJwt: true,
|
||||
});
|
||||
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 } });
|
||||
|
||||
test("01_empty_state_ctas", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
|
||||
@@ -539,10 +539,7 @@ test.describe("Files page", () => {
|
||||
});
|
||||
|
||||
test.describe("Move dialog inline create-folder", () => {
|
||||
// The inline create-folder affordance is gated on `serverReachable`, which
|
||||
// only flips true once a confirmed, non-anonymous user triggers the folder
|
||||
// pull (see FolderContext). Seed a JWT so the stubbed session is logged-in.
|
||||
test.use({ autoGoto: false, seedJwt: true });
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Move dialog shows Create new folder affordance", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs";
|
||||
import { openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
test.describe("2. Main Dashboard / Home Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -88,29 +87,33 @@ test.describe("2. Main Dashboard / Home Page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("2.4 Dashboard - Legal Links", () => {
|
||||
test("legal links live in Settings → Legal, not in a footer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.locator('[data-testid="config-button"]').first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The dashboard footer (survey + legal links) was removed
|
||||
await expect(page.locator(".footer-link")).toHaveCount(0);
|
||||
await expect(page.getByText("Survey")).toHaveCount(0);
|
||||
|
||||
await openSettings(page);
|
||||
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
|
||||
await expect(legalNav).toBeVisible({ timeout: 10000 });
|
||||
await legalNav.click();
|
||||
|
||||
test.describe("2.4 Dashboard - Footer Links", () => {
|
||||
test("should display footer links with correct URLs", async ({ page }) => {
|
||||
await expect(page.getByText("Survey").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Privacy Policy").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText(/Terms/i).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Discord").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("GitHub").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const githubLink = page
|
||||
.locator('a[href*="github.com/Stirling-Tools/Stirling-PDF"]')
|
||||
.first();
|
||||
await expect(githubLink).toBeVisible();
|
||||
|
||||
const discordLink = page
|
||||
.locator('a[href*="discord.gg/Cn8pWhQRxZ"]')
|
||||
.first();
|
||||
await expect(discordLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
/**
|
||||
* Policy editing is admin-only (mirrors the backend's enforcement on the
|
||||
* save/delete endpoints). The frontend gate is `canConfigure = !enableLogin ||
|
||||
* isAdmin`, surfaced through `usePolicies()`:
|
||||
* - login enabled + non-admin → read-only: the setup wizard shows the
|
||||
* "Managed by your organization" locked state instead of the steps.
|
||||
* - login enabled + admin → full setup flow ("Step 1 of 2").
|
||||
* - login disabled (single-user/desktop) → open to the local operator.
|
||||
*
|
||||
* Backend-free: the app-config (`enableLogin`/`isAdmin`) and the empty policy
|
||||
* list are stubbed via `mockAppApis`, so this asserts the gating wiring without
|
||||
* a live Spring Boot server. "Security" is the only non-coming-soon policy, so
|
||||
* it's the one we open.
|
||||
*/
|
||||
|
||||
const LOCKED_TITLE = "Managed by your organization";
|
||||
const LOCKED_DESC = "Contact a team leader to change this policy.";
|
||||
|
||||
/** Open the Security policy from the right-sidebar Policies list. */
|
||||
async function openSecurityPolicy(page: import("@playwright/test").Page) {
|
||||
const row = page.locator("button.pol-row").filter({ hasText: "Security" });
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await row.click();
|
||||
// The wizard header confirms we opened the right policy in either state.
|
||||
await expect(page.getByText("Set up Security Policy")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Policy editing gate — non-admin (login on)", () => {
|
||||
test.use({
|
||||
stubOptions: { enableLogin: true, isAdmin: false },
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("non-admin gets the read-only locked state", async ({ page }) => {
|
||||
await openSecurityPolicy(page);
|
||||
await expect(page.getByText(LOCKED_TITLE)).toBeVisible();
|
||||
await expect(page.getByText(LOCKED_DESC)).toBeVisible();
|
||||
// The editable flow must NOT be reachable.
|
||||
await expect(page.getByText(/Step \d+ of \d+/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Policy editing gate — admin (login on)", () => {
|
||||
test.use({
|
||||
stubOptions: { enableLogin: true, isAdmin: true },
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("admin can reach the setup wizard", async ({ page }) => {
|
||||
await openSecurityPolicy(page);
|
||||
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
|
||||
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Policy editing gate — single-user (login off)", () => {
|
||||
test.use({ stubOptions: { enableLogin: false, isAdmin: false } });
|
||||
|
||||
test("local operator can reach the setup wizard with no admin role", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSecurityPolicy(page);
|
||||
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
|
||||
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
* Verifies that the Stripe SDK (@stripe/stripe-js, @stripe/react-stripe-js,
|
||||
* and the js.stripe.com remote script) is NOT fetched on cold page loads —
|
||||
* only when the checkout modal actually mounts. The proprietary
|
||||
* CheckoutProvider and the SaaS Plan settings page gate the modal behind
|
||||
* React.lazy + a conditional render so the Stripe chunk lives in its own
|
||||
* async bundle.
|
||||
* CheckoutProvider, the SaaS TrialStatusBanner, and the SaaS Plan settings
|
||||
* page all gate the modal behind React.lazy + a conditional render so the
|
||||
* Stripe chunk lives in its own async bundle.
|
||||
*/
|
||||
|
||||
const STRIPE_URL_FRAGMENTS = [
|
||||
|
||||
@@ -150,16 +150,6 @@ export const mantineTheme = createTheme({
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Code: {
|
||||
styles: {
|
||||
root: {
|
||||
backgroundColor: "var(--color-gray-100)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Textarea: {
|
||||
styles: (_theme: MantineTheme) => ({
|
||||
input: {
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface AppConfig {
|
||||
enableDesktopInstallSlide?: boolean;
|
||||
premiumEnabled?: boolean;
|
||||
premiumKey?: string;
|
||||
paygEnabled?: boolean;
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
cookiePolicy?: string;
|
||||
|
||||
@@ -37,30 +37,6 @@ export interface BaseFileMetadata {
|
||||
parentFileId?: FileId; // Immediate parent file ID
|
||||
toolHistory?: ToolOperation[]; // Tool chain for history tracking
|
||||
|
||||
/**
|
||||
* True if this file was produced by a tool/automation in-app (any
|
||||
* `consumeFiles` output — a versioned edit OR an independent artifact like a
|
||||
* convert/split/merge result) rather than entering the system as a genuine
|
||||
* upload. Set at the consume chokepoint so it covers both kinds, including
|
||||
* independent artifacts whose version metadata is otherwise indistinguishable
|
||||
* from a fresh upload. Persisted so the distinction survives a reload.
|
||||
* Used by input-mode (upload) policy auto-run to enforce only on real uploads.
|
||||
*/
|
||||
derivedFromTool?: boolean;
|
||||
|
||||
/**
|
||||
* Transitive set of fileIds this file was derived from — the inputs of the
|
||||
* tool operation that produced it, plus those inputs' own `sourceFileIds`.
|
||||
* Recorded at the `consumeFiles` boundary, the only place that knows the
|
||||
* input→output mapping. Unlike `parentFileId` (the version chain) this is a
|
||||
* pure provenance link, so it covers independent artifacts — split (1→N),
|
||||
* merge (N→1), convert — that intentionally have no parent. Being transitive,
|
||||
* it survives an intermediate edit being consumed/removed. Persisted; used so
|
||||
* a policy badge follows a document onto everything derived from it. Legacy
|
||||
* files predate it (the link was never recorded) and stay empty.
|
||||
*/
|
||||
sourceFileIds?: FileId[];
|
||||
|
||||
/**
|
||||
* The cloud folder this file lives in. Semantics:
|
||||
* - `remoteStorageId == null` → file is local-only; folderId MUST be null.
|
||||
|
||||
@@ -16,8 +16,6 @@ export interface AnimatedCircleConfig {
|
||||
export interface AnimatedSlideBackgroundProps {
|
||||
gradientStops: [string, string];
|
||||
circles: AnimatedCircleConfig[];
|
||||
/** Overall background tone; controls on top of the hero (e.g. close button) adapt to stay visible. Defaults to "dark". */
|
||||
tone?: "light" | "dark";
|
||||
}
|
||||
|
||||
export interface SlideConfig {
|
||||
|
||||
@@ -25,9 +25,6 @@ export interface WatchedFolder {
|
||||
hasOutputDirectory?: boolean; // true when a local FS output folder is configured
|
||||
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
|
||||
inputSource?: "idb" | "local-folder";
|
||||
/** Set when this folder is the backing store for a Policy (its category id).
|
||||
* Policy-owned folders are filtered out of the Watched Folders UI. */
|
||||
policyCategoryId?: string;
|
||||
}
|
||||
|
||||
export interface FolderFileMetadata {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
|
||||
/**
|
||||
* Downloads a blob as a file using browser download API
|
||||
@@ -29,11 +27,10 @@ export async function downloadFileFromStorage(
|
||||
throw new Error(`File "${file.name}" not found in storage`);
|
||||
}
|
||||
|
||||
await downloadFileWithPolicy({
|
||||
await downloadFile({
|
||||
data: stirlingFile,
|
||||
filename: stirlingFile.name,
|
||||
localPath: file.localFilePath,
|
||||
fileId: file.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,15 +59,15 @@ export async function downloadFilesAsZip(
|
||||
throw new Error("No files provided for ZIP download");
|
||||
}
|
||||
|
||||
// Convert stored files to File objects (tracking ids so export policies can
|
||||
// version the in-editor file).
|
||||
// Convert stored files to File objects
|
||||
const filesToZip: File[] = [];
|
||||
const fileIds: (string | undefined)[] = [];
|
||||
for (const fileWithUrl of files) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(fileWithUrl.id);
|
||||
const lookupKey = fileWithUrl.id;
|
||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
||||
|
||||
if (stirlingFile) {
|
||||
// StirlingFile is already a File object!
|
||||
filesToZip.push(stirlingFile);
|
||||
fileIds.push(fileWithUrl.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,9 +75,6 @@ export async function downloadFilesAsZip(
|
||||
throw new Error("No valid files found in storage for ZIP download");
|
||||
}
|
||||
|
||||
// Enforce any export-triggered policy on each PDF before they're zipped.
|
||||
const enforced = await enforceExportPolicies(filesToZip, fileIds);
|
||||
|
||||
// Generate default filename if not provided
|
||||
const finalZipFilename =
|
||||
zipFilename ||
|
||||
@@ -88,7 +82,7 @@ export async function downloadFilesAsZip(
|
||||
|
||||
// Create and download ZIP
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(
|
||||
enforced,
|
||||
filesToZip,
|
||||
finalZipFilename,
|
||||
);
|
||||
await downloadFile({ data: zipFile, filename: finalZipFilename });
|
||||
@@ -131,7 +125,7 @@ export async function downloadFiles(
|
||||
* @param filename - Optional custom filename
|
||||
*/
|
||||
export function downloadFileObject(file: File, filename?: string): void {
|
||||
void downloadFileWithPolicy({ data: file, filename: filename || file.name });
|
||||
void downloadFile({ data: file, filename: filename || file.name });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
scoreMatch,
|
||||
minScoreForQuery,
|
||||
isFuzzyMatch,
|
||||
} from "@app/utils/fuzzySearch";
|
||||
|
||||
describe("scoreMatch", () => {
|
||||
it("scores substring matches highest", () => {
|
||||
expect(scoreMatch("rota", "Rotate")).toBeGreaterThanOrEqual(90);
|
||||
expect(scoreMatch("priv", "private")).toBeGreaterThanOrEqual(
|
||||
minScoreForQuery("priv"),
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates small typos", () => {
|
||||
expect(isFuzzyMatch("rotaet", "rotate")).toBe(true);
|
||||
expect(isFuzzyMatch("comprss", "compress")).toBe(true);
|
||||
// Typo inside one token of a multi-word target
|
||||
expect(isFuzzyMatch("watermrk", "Add Watermark")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unrelated words that share half their letters", () => {
|
||||
// Unrelated words can share many letters (rotate vs update is edit
|
||||
// distance 3 of 6) but must not be treated as a fuzzy match.
|
||||
expect(isFuzzyMatch("rotate", "update")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "create")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "private")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "isolated")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "automate")).toBe(false);
|
||||
expect(isFuzzyMatch("rotate", "annotate")).toBe(false);
|
||||
expect(isFuzzyMatch("rotat", "protect")).toBe(false);
|
||||
expect(isFuzzyMatch("rotat", "redact")).toBe(false);
|
||||
expect(isFuzzyMatch("rotat", "contrast")).toBe(false);
|
||||
expect(isFuzzyMatch("rotat", "annotate")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minScoreForQuery", () => {
|
||||
it("does not loosen the threshold for long queries", () => {
|
||||
expect(minScoreForQuery("rot")).toBe(40);
|
||||
expect(minScoreForQuery("rotate")).toBe(30);
|
||||
expect(minScoreForQuery("orientation")).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -63,27 +63,20 @@ export function scoreMatch(queryRaw: string, targetRaw: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
// Levenshtein fallback is for typos only: allow a small absolute number of
|
||||
// edits against the whole target or any single token. Relative similarity is
|
||||
// not enough here; half the letters of an unrelated word can match (e.g.
|
||||
// "rotate" vs "update" is edit distance 3 of 6).
|
||||
const maxEdits = query.length <= 4 ? 1 : 2;
|
||||
let best = 0;
|
||||
for (const candidate of [target, ...tokens]) {
|
||||
if (Math.abs(query.length - candidate.length) > maxEdits) continue;
|
||||
const distance = levenshtein(query, candidate);
|
||||
if (distance > maxEdits) continue;
|
||||
const maxLen = Math.max(query.length, candidate.length, 1);
|
||||
const similarity = 1 - distance / maxLen; // 0..1
|
||||
const score = Math.floor(similarity * 60); // scale below substring scores
|
||||
if (score > best) best = score;
|
||||
}
|
||||
return best;
|
||||
const distance = levenshtein(
|
||||
query,
|
||||
target.length > 64 ? target.slice(0, 64) : target,
|
||||
);
|
||||
const maxLen = Math.max(query.length, target.length, 1);
|
||||
const similarity = 1 - distance / maxLen; // 0..1
|
||||
return Math.floor(similarity * 60); // scale below substring scores
|
||||
}
|
||||
|
||||
export function minScoreForQuery(query: string): number {
|
||||
const len = normalizeText(query).length;
|
||||
return len <= 3 ? 40 : 30;
|
||||
if (len <= 3) return 40;
|
||||
if (len <= 6) return 30;
|
||||
return 25;
|
||||
}
|
||||
|
||||
// Decide if a target matches a query based on a threshold
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
cv?: any;
|
||||
@@ -7,9 +5,8 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// Served under the app's base path (handles sub-path deploys like /app).
|
||||
const OPENCV_SRC = withBasePath("/vendor/jscanify/opencv.js");
|
||||
const JSCANIFY_SRC = withBasePath("/vendor/jscanify/jscanify.js");
|
||||
const OPENCV_SRC = "/vendor/jscanify/opencv.js";
|
||||
const JSCANIFY_SRC = "/vendor/jscanify/jscanify.js";
|
||||
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Unit tests for buildMobileScannerUrl.
|
||||
*
|
||||
* Regression guard: the SaaS frontend is served under a base path (e.g.
|
||||
* `/app`), and `/mobile-scanner` is a public route that only matches under that
|
||||
* base path. The backend advertises `frontendUrl` as a bare origin (no
|
||||
* subpath), so the generated QR URL must still carry the base path. Dropping it
|
||||
* sent phones to the auth-gated catch-all route / login page.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
|
||||
|
||||
const sessionId = "abc-123";
|
||||
|
||||
describe("buildMobileScannerUrl", () => {
|
||||
test("origin-only frontendUrl keeps the app base path (SaaS web regression)", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "https://app.stirlingpdf.com",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("origin-only frontendUrl with a trailing slash keeps the app base path", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "https://app.stirlingpdf.com/",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("configured URL that already carries the subpath is used verbatim (no doubled base)", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "https://app.stirlingpdf.com/app",
|
||||
sessionId,
|
||||
origin: "https://elsewhere.example",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("configured URL subpath with trailing slash is normalized", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "https://host.example/app/",
|
||||
sessionId,
|
||||
origin: "https://host.example",
|
||||
basePath: "",
|
||||
}),
|
||||
).toBe("https://host.example/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("no base path and no configured URL uses the current origin", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "",
|
||||
sessionId,
|
||||
origin: "http://localhost:5173",
|
||||
basePath: "",
|
||||
}),
|
||||
).toBe("http://localhost:5173/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("empty configured URL falls back to origin + base path", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: " ",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("custom port (LAN/desktop) is preserved", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "http://192.168.1.50:8080",
|
||||
sessionId,
|
||||
origin: "http://localhost:8080",
|
||||
basePath: "",
|
||||
}),
|
||||
).toBe("http://192.168.1.50:8080/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("invalid configured URL falls back to the current origin + base path", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "not a url",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
|
||||
test("non-http(s) configured URL is ignored (no javascript: injection)", () => {
|
||||
expect(
|
||||
buildMobileScannerUrl({
|
||||
configuredUrl: "javascript:alert(1)",
|
||||
sessionId,
|
||||
origin: "https://app.stirlingpdf.com",
|
||||
basePath: "/app",
|
||||
}),
|
||||
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Build the URL a phone opens (via the QR code) to reach the SPA's
|
||||
* `/mobile-scanner` route.
|
||||
*
|
||||
* That route is a public, top-level route. It lives under the app's base path,
|
||||
* which is the router's `basename`. If the generated URL omits the base path,
|
||||
* the phone loads a path the router can't match, falls through to the
|
||||
* auth-gated catch-all route, and gets bounced to the login page. So the base
|
||||
* path must always be present.
|
||||
*
|
||||
* A configured `server_url`/`frontendUrl` supplies the host the phone should
|
||||
* reach (desktop / LAN / reverse proxy):
|
||||
* - origin only (no subpath): apply the app's base path. The backend's
|
||||
* `resolveFrontendUrl` advertises a bare origin with no subpath, so this is
|
||||
* the common SaaS web case (frontend served under e.g. `/app`).
|
||||
* - already carries a subpath: it points at the target SPA's base directly,
|
||||
* so use it verbatim and do not add the base path again (no doubled base).
|
||||
*
|
||||
* With no usable configured URL, fall back to the current origin + base path.
|
||||
*/
|
||||
export function buildMobileScannerUrl(params: {
|
||||
configuredUrl: string;
|
||||
sessionId: string;
|
||||
origin: string;
|
||||
basePath: string;
|
||||
}): string {
|
||||
const { configuredUrl, sessionId, origin, basePath } = params;
|
||||
const query = `?session=${sessionId}`;
|
||||
const route = `${basePath}/mobile-scanner`;
|
||||
|
||||
const trimmed = configuredUrl.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
const subpath = parsed.pathname.replace(/\/+$/, "");
|
||||
return subpath
|
||||
? `${parsed.origin}${subpath}/mobile-scanner${query}`
|
||||
: `${parsed.origin}${route}${query}`;
|
||||
}
|
||||
} catch {
|
||||
// invalid configured URL — fall through to the current-origin default
|
||||
}
|
||||
}
|
||||
|
||||
return `${origin}${route}${query}`;
|
||||
}
|
||||
@@ -1,21 +1,53 @@
|
||||
import { NavKey } from "@app/components/shared/config/types";
|
||||
import { stripBasePath, withBasePath } from "@app/constants/app";
|
||||
|
||||
/** Push the URL for a settings section and notify listeners. */
|
||||
/**
|
||||
* Navigate to a specific settings section
|
||||
*
|
||||
* @param section - The settings section key to navigate to
|
||||
*
|
||||
* @example
|
||||
* // Navigate to People section
|
||||
* navigateToSettings('people');
|
||||
*
|
||||
* // Navigate to Admin Premium section
|
||||
* navigateToSettings('adminPremium');
|
||||
*/
|
||||
export function navigateToSettings(section: NavKey) {
|
||||
const newPath = withBasePath(`/settings/${section}`);
|
||||
const basePath = window.location.pathname.split("/settings")[0] || "";
|
||||
const newPath = `${basePath}/settings/${section}`;
|
||||
window.history.pushState({}, "", newPath);
|
||||
|
||||
// Trigger a popstate event to notify components
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
/** URL for a settings section (subpath-aware). */
|
||||
/**
|
||||
* Get the URL path for a settings section
|
||||
* Useful for creating links
|
||||
*
|
||||
* @param section - The settings section key
|
||||
* @returns The URL path for the settings section
|
||||
*
|
||||
* @example
|
||||
* <a href={getSettingsUrl('people')}>Go to People Settings</a>
|
||||
* // Returns: "/settings/people"
|
||||
*/
|
||||
export function getSettingsUrl(section: NavKey): string {
|
||||
return withBasePath(`/settings/${section}`);
|
||||
return `/settings/${section}`;
|
||||
}
|
||||
|
||||
/** Whether the current URL is in /settings (optionally a specific section). */
|
||||
/**
|
||||
* Check if currently viewing a settings section
|
||||
*
|
||||
* @param section - Optional section key to check for specific section
|
||||
* @returns True if in settings (and matching specific section if provided)
|
||||
*/
|
||||
export function isInSettings(section?: NavKey): boolean {
|
||||
const pathname = stripBasePath(window.location.pathname);
|
||||
if (!section) return pathname.startsWith("/settings");
|
||||
const pathname = window.location.pathname;
|
||||
|
||||
if (!section) {
|
||||
return pathname.startsWith("/settings");
|
||||
}
|
||||
|
||||
return pathname === `/settings/${section}`;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
|
||||
import {
|
||||
ToolCategoryId,
|
||||
SubcategoryId,
|
||||
ToolRegistry,
|
||||
ToolRegistryEntry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
|
||||
function makeEntry(name: string, tags: string): ToolRegistryEntry {
|
||||
return {
|
||||
icon: null,
|
||||
name,
|
||||
component: null,
|
||||
description: "",
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
automationSettings: null,
|
||||
synonyms: tags.split(","),
|
||||
};
|
||||
}
|
||||
|
||||
// Real names and tags from the en-GB translations, chosen because they share
|
||||
// letters with "rotat"/"rotate" and stress the partial-query filtering.
|
||||
const registry: Partial<ToolRegistry> = {
|
||||
rotate: makeEntry(
|
||||
"Rotate",
|
||||
"turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation",
|
||||
),
|
||||
addPassword: makeEntry(
|
||||
"Add Password",
|
||||
"encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access",
|
||||
),
|
||||
changeMetadata: makeEntry(
|
||||
"Change Metadata",
|
||||
"edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties",
|
||||
),
|
||||
scannerEffect: makeEntry(
|
||||
"Scanner Effect",
|
||||
"scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan",
|
||||
),
|
||||
adjustContrast: makeEntry(
|
||||
"Adjust Colours/Contrast",
|
||||
"contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance",
|
||||
),
|
||||
annotate: makeEntry(
|
||||
"Annotate",
|
||||
"annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand",
|
||||
),
|
||||
redact: makeEntry(
|
||||
"Redact",
|
||||
"censor,blackout,hide,redact,redaction,black out,block out,remove sensitive,hide text,privacy,confidential,GDPR,PII,sensitive data,permanently remove,cover up,legal redaction",
|
||||
),
|
||||
compare: makeEntry(
|
||||
"Compare",
|
||||
"difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta",
|
||||
),
|
||||
};
|
||||
|
||||
function idsFor(query: string): string[] {
|
||||
return filterToolRegistryByQuery(registry, query).map(({ item: [id] }) => id);
|
||||
}
|
||||
|
||||
describe("filterToolRegistryByQuery", () => {
|
||||
it("returns everything for an empty query", () => {
|
||||
expect(idsFor("")).toHaveLength(Object.keys(registry).length);
|
||||
});
|
||||
|
||||
it("matches tools by tag substring", () => {
|
||||
expect(idsFor("protect")).toEqual(["addPassword"]);
|
||||
expect(idsFor("orientation")).toEqual(["rotate"]);
|
||||
});
|
||||
|
||||
it("only returns Rotate for every prefix of 'rotate'", () => {
|
||||
// Prefixes of "rotate" must not leak tools that merely share letters
|
||||
// (Redact, Annotate, Compare, Adjust Colours/Contrast, etc.).
|
||||
expect(idsFor("rota")).toEqual(["rotate"]);
|
||||
expect(idsFor("rotat")).toEqual(["rotate"]);
|
||||
expect(idsFor("rotate")).toEqual(["rotate"]);
|
||||
});
|
||||
});
|
||||
@@ -390,11 +390,12 @@ self.addEventListener(
|
||||
let compDoc: PDFDocumentProxy | null = null;
|
||||
|
||||
try {
|
||||
// Resolve pdfjs CMap/standard-font URLs against the worker's origin + subpath.
|
||||
const assetsBase = new URL(
|
||||
`${import.meta.env.BASE_URL}pdfjs/`,
|
||||
self.location.origin,
|
||||
).toString();
|
||||
// `CanvasFactory`/`FilterFactory` are supported at runtime but not declared on legacy types.
|
||||
// pdfjs-dist 5.x renamed the option to `CanvasFactory` (capital C); without a FilterFactory
|
||||
// the default DOMFilterFactory crashes in a worker calling document.createElementNS.
|
||||
// Resolve CMap/standard-font URLs against the worker's own origin. Vite copies these
|
||||
// directories from pdfjs-dist at build time via viteStaticCopy (see vite.config.ts).
|
||||
const assetsBase = new URL("/pdfjs/", self.location.origin).toString();
|
||||
const loaderOpts = (data: ArrayBuffer) =>
|
||||
({
|
||||
data,
|
||||
|
||||
@@ -68,16 +68,12 @@ export const useConfigNavSections = (
|
||||
],
|
||||
};
|
||||
|
||||
// In local mode only show Preferences + Connection Mode + Legal — everything
|
||||
// else requires a server and will 500 or show irrelevant admin UI.
|
||||
// In local mode only show Preferences + Connection Mode — everything else
|
||||
// requires a server and will 500 or show irrelevant admin UI.
|
||||
if (isLocalMode) {
|
||||
const result: ConfigNavSection[] = [];
|
||||
if (sections.length > 0) result.push(sections[0]);
|
||||
result.push(connectionModeSection);
|
||||
const legalSection = sections.find((section) =>
|
||||
section.items.some((item) => item.key === "legal"),
|
||||
);
|
||||
if (legalSection) result.push(legalSection);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+169
-2
@@ -10,9 +10,15 @@ import {
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
List,
|
||||
ThemeIcon,
|
||||
Modal,
|
||||
CloseButton,
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
@@ -37,10 +43,15 @@ export function SaaSTeamsSection() {
|
||||
refreshTeams,
|
||||
} = useSaaSTeam();
|
||||
|
||||
// Check Pro status via billing context
|
||||
const { tier } = useSaaSBilling();
|
||||
const isPro = tier !== "free";
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [featuresModalOpened, setFeaturesModalOpened] = useState(false);
|
||||
|
||||
// Team rename state
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
@@ -60,6 +71,12 @@ export function SaaSTeamsSection() {
|
||||
return () => clearInterval(interval);
|
||||
}, []); // Only run on mount/unmount
|
||||
|
||||
const navigateToPlan = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("appConfig:navigate", { detail: { key: "planBilling" } }),
|
||||
);
|
||||
};
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inviteEmail.trim()) return;
|
||||
@@ -304,6 +321,156 @@ export function SaaSTeamsSection() {
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Banner for Free Users */}
|
||||
{isPersonalTeam && !isPro && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<LocalIcon icon="info" width={16} height={16} />}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"team.upgrade.title",
|
||||
"Upgrade to Pro to unlock team features",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{t(
|
||||
"team.upgrade.description",
|
||||
"Invite members, share credits, and more.",
|
||||
)}{" "}
|
||||
<Anchor
|
||||
size="xs"
|
||||
onClick={() => setFeaturesModalOpened(true)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("common.learnMore", "Learn more")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</div>
|
||||
<Button size="sm" variant="light" onClick={navigateToPlan}>
|
||||
{t("team.upgrade.button", "Upgrade to Pro")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Team Features Modal */}
|
||||
<Modal
|
||||
opened={featuresModalOpened}
|
||||
onClose={() => setFeaturesModalOpened(false)}
|
||||
size="md"
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<CloseButton
|
||||
onClick={() => setFeaturesModalOpened(false)}
|
||||
size="lg"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Header */}
|
||||
<Stack gap="md" align="center">
|
||||
<Badge size="lg" color="violet" variant="filled">
|
||||
{t("team.features.badge", "PRO FEATURE")}
|
||||
</Badge>
|
||||
<Text size="xl" fw={700} ta="center">
|
||||
{t("team.features.title", "Team Collaboration")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"team.features.subtitle",
|
||||
"Upgrade to Pro and unlock powerful team features",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Features List */}
|
||||
<List
|
||||
spacing="md"
|
||||
size="sm"
|
||||
icon={
|
||||
<ThemeIcon color="violet" size={24} radius="xl" variant="light">
|
||||
<LocalIcon icon="check" width={14} height={14} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<List.Item>
|
||||
<Text fw={500}>
|
||||
{t("team.features.invite.title", "Invite team members")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"team.features.invite.description",
|
||||
"Add unlimited users with additional seat purchases",
|
||||
)}
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text fw={500}>
|
||||
{t(
|
||||
"team.features.credits.title",
|
||||
"Share credits across your team",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"team.features.credits.description",
|
||||
"Pool resources for collaborative work",
|
||||
)}
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text fw={500}>
|
||||
{t(
|
||||
"team.features.dashboard.title",
|
||||
"Team management dashboard",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"team.features.dashboard.description",
|
||||
"Control permissions, monitor usage, and manage members",
|
||||
)}
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text fw={500}>
|
||||
{t("team.features.billing.title", "Centralized billing")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"team.features.billing.description",
|
||||
"One invoice for all team seats and usage",
|
||||
)}
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
size="md"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setFeaturesModalOpened(false);
|
||||
navigateToPlan();
|
||||
}}
|
||||
>
|
||||
{t("team.features.viewPlans", "View Pro Plans")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Error/Success Messages */}
|
||||
{error && (
|
||||
<Alert color="red" onClose={() => setError(null)} withCloseButton>
|
||||
@@ -317,8 +484,8 @@ export function SaaSTeamsSection() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Invite Members */}
|
||||
{isTeamLeader && (
|
||||
{/* Invite Members (Pro Users) */}
|
||||
{isTeamLeader && isPro && (
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="sm">
|
||||
{t("team.invite.title", "Invite Team Member")}
|
||||
|
||||
@@ -26,9 +26,8 @@ import "@app/styles/auth-theme.css";
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
// Minimal providers for public, no-auth pages (mobile scanner, participant
|
||||
// signing) - no API calls, no authentication
|
||||
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
|
||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||
@@ -51,9 +50,9 @@ export default function App() {
|
||||
<Route
|
||||
path="/mobile-scanner"
|
||||
element={
|
||||
<PublicRouteProviders>
|
||||
<MobileScannerProviders>
|
||||
<MobileScannerPage />
|
||||
</PublicRouteProviders>
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -61,9 +60,9 @@ export default function App() {
|
||||
<Route
|
||||
path="/workflow/sign/:token"
|
||||
element={
|
||||
<PublicRouteProviders>
|
||||
<MobileScannerProviders>
|
||||
<ParticipantViewPage />
|
||||
</PublicRouteProviders>
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { springAuth } from "@app/auth/springAuthClient";
|
||||
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import type {
|
||||
Session,
|
||||
User,
|
||||
@@ -34,8 +33,6 @@ interface AuthContextType {
|
||||
* consumers can fall back to whatever makes sense.
|
||||
*/
|
||||
displayName: string | null;
|
||||
/** Whether the current session is an anonymous (login-disabled) one. */
|
||||
isAnonymous: boolean;
|
||||
loading: boolean;
|
||||
error: AuthError | null;
|
||||
signOut: () => Promise<void>;
|
||||
@@ -63,7 +60,6 @@ const AuthContext = createContext<AuthContextType>({
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
loading: true,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
@@ -171,7 +167,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Clear any platform-specific cached auth on login page init.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
stripBasePath(window.location.pathname).startsWith("/login")
|
||||
window.location.pathname.startsWith("/login")
|
||||
) {
|
||||
await clearPlatformAuthOnLoginInit();
|
||||
}
|
||||
@@ -295,7 +291,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
session,
|
||||
user,
|
||||
displayName: deriveDisplayName(user, t),
|
||||
isAnonymous: user?.is_anonymous === true,
|
||||
loading,
|
||||
error,
|
||||
signOut,
|
||||
|
||||
@@ -16,11 +16,11 @@ export function AppProviders({
|
||||
appConfigProviderProps,
|
||||
}: AppProvidersProps) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<CoreAppProviders
|
||||
appConfigRetryOptions={appConfigRetryOptions}
|
||||
appConfigProviderProps={appConfigProviderProps}
|
||||
>
|
||||
<CoreAppProviders
|
||||
appConfigRetryOptions={appConfigRetryOptions}
|
||||
appConfigProviderProps={appConfigProviderProps}
|
||||
>
|
||||
<AuthProvider>
|
||||
<LicenseProvider>
|
||||
<UpdateSeatsProvider>
|
||||
<ServerExperienceProvider>
|
||||
@@ -31,7 +31,7 @@ export function AppProviders({
|
||||
</ServerExperienceProvider>
|
||||
</UpdateSeatsProvider>
|
||||
</LicenseProvider>
|
||||
</CoreAppProviders>
|
||||
</AuthProvider>
|
||||
</AuthProvider>
|
||||
</CoreAppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
|
||||
|
||||
.agents-takeover {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-toolbar);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
view-transition-name: agents-rail;
|
||||
}
|
||||
|
||||
.agents-takeover__resize-handle {
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.agents-takeover__resize-handle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: transparent;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
width 150ms ease,
|
||||
left 150ms ease;
|
||||
}
|
||||
|
||||
.agents-takeover__resize-handle:hover::after {
|
||||
left: 2px;
|
||||
width: 3px;
|
||||
background: var(--mantine-color-blue-4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ─── Sidebar agents section ─────────────────────────────────────────────── */
|
||||
|
||||
.agents-section {
|
||||
padding: 0.5rem 0.75rem 0.875rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-toolbar);
|
||||
flex-shrink: 0;
|
||||
view-transition-name: agents-rail;
|
||||
}
|
||||
|
||||
/* Hero button — real Stirling agent */
|
||||
.agent-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mantine-color-blue-5) 40%, var(--border-subtle));
|
||||
border-radius: 0.625rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-6) 6%,
|
||||
var(--mantine-color-body)
|
||||
)
|
||||
0%,
|
||||
var(--mantine-color-body) 100%
|
||||
);
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.agent-button:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 65%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-6) 12%,
|
||||
var(--mantine-color-default-hover)
|
||||
)
|
||||
0%,
|
||||
var(--mantine-color-default-hover) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.agent-button__logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Coming-soon agent rows */
|
||||
.agents-sidebar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.agent-button--coming-soon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.65;
|
||||
transition:
|
||||
opacity 180ms ease-out,
|
||||
transform 180ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.agent-button--coming-soon {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
}
|
||||
|
||||
.agent-button--coming-soon:hover {
|
||||
opacity: 0.85;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-gray-4) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.agent-button__icon-plain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agents-view-all {
|
||||
all: unset;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
border-radius: 0.375rem;
|
||||
transition: opacity 120ms ease-out;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.agents-view-all:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dark mode — sidebar */
|
||||
[data-mantine-color-scheme="dark"] .agent-button--coming-soon {
|
||||
background: transparent;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-dark-4) 60%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button--coming-soon:hover {
|
||||
border-color: var(--mantine-color-dark-3);
|
||||
background: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-7) 55%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-9) 35%, transparent) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-8) 45%,
|
||||
rgba(255, 255, 255, 0.04)
|
||||
)
|
||||
0%,
|
||||
rgba(255, 255, 255, 0.04) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button__logo {
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* ─── Collapsed-rail agent button ────────────────────────────────────────── */
|
||||
|
||||
.agents-collapsed-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agents-collapsed-btn:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
/* ─── Running / in-progress status dot ──────────────────────────────────── */
|
||||
|
||||
.agent-status-dot {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--mantine-color-blue-5);
|
||||
border: 1.5px solid var(--mantine-color-body);
|
||||
animation: agent-dot-pulse 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes agent-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
/* Blue outline on hero Stirling button when agent is running */
|
||||
.agent-button.agent-button--running {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button.agent-button--running {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-4) 55%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-status-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Fullscreen hero card ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* .tool-panel__fullscreen-group--agents gives the gradient border via the
|
||||
* padding-box / border-box trick. We only override the padding-box with a
|
||||
* very light tint so the card doesn't read as a colourful surface.
|
||||
*/
|
||||
.agents-hero {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
linear-gradient(
|
||||
125deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-1) 35%, white) 0%,
|
||||
color-mix(in srgb, var(--mantine-color-violet-1) 45%, white) 50%,
|
||||
color-mix(in srgb, var(--mantine-color-pink-0) 35%, white) 100%
|
||||
)
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
}
|
||||
|
||||
/* Body: left CTA + right grid */
|
||||
.agents-hero__body {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Left CTA — plain content, only the button inside is interactive ── */
|
||||
.agents-hero__cta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex: 0 0 18rem;
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.agents-hero__cta-logo {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.agents-hero__cta-headline {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn {
|
||||
all: unset;
|
||||
margin-top: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4375rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--mantine-color-blue-6);
|
||||
color: white;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn:hover {
|
||||
background: var(--mantine-color-blue-7);
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn:focus-visible {
|
||||
outline: 2px solid var(--mantine-color-blue-5);
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Right 2×3 grid — sizes to content, pushed to the right ── */
|
||||
.agents-hero__grid {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: grid;
|
||||
/* auto columns: each column = widest item in that column */
|
||||
grid-template-columns: repeat(2, auto);
|
||||
gap: 0.625rem;
|
||||
align-content: start;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
/* Solid white cards — no opacity so the white is opaque */
|
||||
.agents-hero__grid-item {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: not-allowed;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.agents-hero__grid-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.agents-hero__grid-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Dark mode — fullscreen ── */
|
||||
[data-mantine-color-scheme="dark"] .agents-hero {
|
||||
background:
|
||||
linear-gradient(
|
||||
125deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-9) 55%, #1a1b2e) 0%,
|
||||
color-mix(in srgb, var(--mantine-color-violet-9) 45%, #1a1b2e) 50%,
|
||||
color-mix(in srgb, var(--mantine-color-pink-9) 40%, #1a1b2e) 100%
|
||||
)
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__cta-logo {
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__grid-item {
|
||||
background: var(--fullscreen-bg-item);
|
||||
border-color: var(--fullscreen-border-subtle-70);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__grid-icon {
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
/* ─── agents-rail view transition — slide up ─────────────────────────────── */
|
||||
|
||||
::view-transition-old(agents-rail) {
|
||||
animation: vt-agents-out 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(agents-rail) {
|
||||
animation: vt-agents-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes vt-agents-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vt-agents-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(agents-rail),
|
||||
::view-transition-new(agents-rail) {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { Box, Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded";
|
||||
import SummarizeRoundedIcon from "@mui/icons-material/SummarizeRounded";
|
||||
import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded";
|
||||
import GavelRoundedIcon from "@mui/icons-material/GavelRounded";
|
||||
import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded";
|
||||
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
import "@app/components/agents/AgentsPanel.css";
|
||||
|
||||
interface ComingSoonAgent {
|
||||
id: string;
|
||||
nameKey: string;
|
||||
descriptionKey: string;
|
||||
Icon: ElementType;
|
||||
}
|
||||
|
||||
const COMING_SOON_AGENTS: ComingSoonAgent[] = [
|
||||
{
|
||||
id: "data-extraction",
|
||||
nameKey: "agents.data_extraction_name",
|
||||
descriptionKey: "agents.data_extraction_description",
|
||||
Icon: TableChartRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "doc-summary",
|
||||
nameKey: "agents.doc_summary_name",
|
||||
descriptionKey: "agents.doc_summary_description",
|
||||
Icon: SummarizeRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "auto-redaction",
|
||||
nameKey: "agents.auto_redaction_name",
|
||||
descriptionKey: "agents.auto_redaction_description",
|
||||
Icon: VisibilityOffRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
nameKey: "agents.compliance_name",
|
||||
descriptionKey: "agents.compliance_description",
|
||||
Icon: GavelRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "form-filler",
|
||||
nameKey: "agents.form_filler_name",
|
||||
descriptionKey: "agents.form_filler_description",
|
||||
Icon: AssignmentRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "pdf-to-markdown",
|
||||
nameKey: "agents.pdf_to_markdown_name",
|
||||
descriptionKey: "agents.pdf_to_markdown_description",
|
||||
Icon: CodeRoundedIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function useAgentsEnabled(): boolean {
|
||||
const { config } = useAppConfig();
|
||||
return Boolean(config?.aiEngineEnabled);
|
||||
}
|
||||
|
||||
export function useAgentChatOpen(): boolean {
|
||||
const { isOpen } = useChat();
|
||||
return isOpen;
|
||||
}
|
||||
|
||||
const PREVIEW_COUNT = 3;
|
||||
|
||||
/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */
|
||||
export function AgentsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen, isLoading } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
if (!enabled || isOpen) return null;
|
||||
|
||||
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
|
||||
const visibleAgents = showAll
|
||||
? COMING_SOON_AGENTS
|
||||
: COMING_SOON_AGENTS.slice(0, PREVIEW_COUNT);
|
||||
|
||||
return (
|
||||
<Box className="agents-section" w="100%">
|
||||
{/* Main Stirling agent — real and clickable */}
|
||||
<UnstyledButton
|
||||
className={`agent-button agent-button--hero${isLoading ? " agent-button--running" : ""}`}
|
||||
onClick={() => withViewTransition(() => setOpen(true))}
|
||||
aria-label={t("agents.stirling_name", "Stirling")}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box className="agent-button__logo">
|
||||
<StirlingLogoOutline size={28} />
|
||||
{isLoading && <span className="agent-status-dot" />}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{t("agents.stirling_name", "Stirling")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{isLoading
|
||||
? t("agents.stirling_running", "Running...")
|
||||
: t(
|
||||
"agents.stirling_description",
|
||||
"Your general-purpose PDF assistant",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{/* Coming-soon agents */}
|
||||
<div className="agents-sidebar-list">
|
||||
{visibleAgents.map(({ id, nameKey, descriptionKey, Icon }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={comingSoonLabel}
|
||||
position="left"
|
||||
arrow
|
||||
delay={0}
|
||||
>
|
||||
<UnstyledButton
|
||||
className="agent-button agent-button--coming-soon"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box className="agent-button__icon-plain">
|
||||
<Icon sx={{ fontSize: "1.1rem" }} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t(nameKey)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(descriptionKey)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!showAll ? (
|
||||
<button
|
||||
type="button"
|
||||
className="agents-view-all"
|
||||
onClick={() => withViewTransition(() => setShowAll(true))}
|
||||
>
|
||||
{t("agents.view_all", "View all agents")} →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="agents-view-all"
|
||||
onClick={() => withViewTransition(() => setShowAll(false))}
|
||||
>
|
||||
{t("agents.show_less", "Show less")} ↑
|
||||
</button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Icon-only agent button in the collapsed (minimised) right rail. */
|
||||
export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { setOpen, isLoading } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const label = t("agents.stirling_tooltip", "Stirling agent");
|
||||
|
||||
return (
|
||||
<AppTooltip content={label} position="left" arrow delay={300}>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
onExpand();
|
||||
setOpen(true);
|
||||
}}
|
||||
aria-label={label}
|
||||
className="agents-collapsed-btn"
|
||||
>
|
||||
<StirlingLogoOutline size={22} />
|
||||
{isLoading && <span className="agent-status-dot" />}
|
||||
</UnstyledButton>
|
||||
</AppTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen hero card — Stirling CTA on the left, 2×3 coming-soon grid on
|
||||
* the right. Matches the gradient border of the other fullscreen category cards.
|
||||
*/
|
||||
export function AgentsFullscreenSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled || isOpen) return null;
|
||||
|
||||
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
|
||||
|
||||
return (
|
||||
<section
|
||||
className="agents-hero tool-panel__fullscreen-group--agents"
|
||||
aria-label={t("agents.section_title", "Agents")}
|
||||
>
|
||||
<div className="agents-hero__body">
|
||||
{/* Left: Stirling content — only the button is interactive */}
|
||||
<div className="agents-hero__cta">
|
||||
<div className="agents-hero__cta-logo">
|
||||
<StirlingLogoOutline size={36} />
|
||||
</div>
|
||||
<Text className="agents-hero__cta-headline" fw={700} lh={1.2} mt="xs">
|
||||
{t("agents.stirling_full_name", "Stirling General Agent")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={8} lh={1.55}>
|
||||
{t(
|
||||
"agents.stirling_long_description",
|
||||
"General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents.",
|
||||
)}
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
className="agents-hero__cta-btn"
|
||||
onClick={() => withViewTransition(() => setOpen(true))}
|
||||
>
|
||||
{t("agents.start_chat", "Start chatting")} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: 2×3 grid of coming-soon agents */}
|
||||
<div className="agents-hero__grid">
|
||||
{COMING_SOON_AGENTS.map(({ id, nameKey, descriptionKey, Icon }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={comingSoonLabel}
|
||||
position="top"
|
||||
arrow
|
||||
delay={0}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="agents-hero__grid-item"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className="agents-hero__grid-icon">
|
||||
<Icon sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
<div className="agents-hero__grid-body">
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t(nameKey)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(descriptionKey)}
|
||||
</Text>
|
||||
</div>
|
||||
</button>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { generateId } from "@app/utils/generateId";
|
||||
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { getApiBaseUrl } from "@app/services/apiClientConfig";
|
||||
import { getAuthHeaders } from "@app/services/apiClientSetup";
|
||||
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
|
||||
import { createChildStub } from "@app/contexts/file/fileActions";
|
||||
import {
|
||||
createNewStirlingFileStub,
|
||||
@@ -180,29 +178,11 @@ interface AiWorkflowResponse {
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
/**
|
||||
* Structured error code when a tool call inside the workflow was blocked (e.g.
|
||||
* PAYG_LIMIT_REACHED / FEATURE_DEGRADED). Present instead of a raw failure reason so the
|
||||
* client can pop the usage-limit modal. See {@link isPaygLimitCode}.
|
||||
*/
|
||||
errorCode?: string;
|
||||
/** From the blocking 402: true → over spending cap, false/absent → free allowance spent. */
|
||||
errorSubscribed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage-limit sentinels the agent can surface (matching the saas EntitlementGuard / apiClient
|
||||
* interceptor). When one of these is the result's errorCode, we open the usage-limit modal rather
|
||||
* than render the failure as chat text.
|
||||
*/
|
||||
const PAYG_LIMIT_CODES = new Set(["PAYG_LIMIT_REACHED", "FEATURE_DEGRADED"]);
|
||||
|
||||
function isPaygLimitCode(code: string | null | undefined): boolean {
|
||||
return code != null && PAYG_LIMIT_CODES.has(code);
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: ChatMessage[];
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
progress: AiWorkflowProgress | null;
|
||||
/** Ordered log of every progress event in the current request. UI shows the last N entries. */
|
||||
@@ -219,6 +199,8 @@ type ChatAction =
|
||||
| { type: "SET_LOADING"; loading: boolean }
|
||||
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
|
||||
| { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress }
|
||||
| { type: "TOGGLE_OPEN" }
|
||||
| { type: "SET_OPEN"; open: boolean }
|
||||
| { type: "CLEAR" };
|
||||
|
||||
function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
||||
@@ -248,6 +230,10 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
||||
action.progress,
|
||||
],
|
||||
};
|
||||
case "TOGGLE_OPEN":
|
||||
return { ...state, isOpen: !state.isOpen };
|
||||
case "SET_OPEN":
|
||||
return { ...state, isOpen: action.open };
|
||||
case "CLEAR":
|
||||
return {
|
||||
...state,
|
||||
@@ -376,10 +362,13 @@ async function consumeSSEStream(
|
||||
|
||||
interface ChatContextValue {
|
||||
messages: ChatMessage[];
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
progress: AiWorkflowProgress | null;
|
||||
/** Ordered log of every progress event for the current in-flight request. */
|
||||
progressLog: AiWorkflowProgress[];
|
||||
toggleOpen: () => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
cancelMessage: () => void;
|
||||
/** Abort any in-flight request and reset the chat to an empty conversation. */
|
||||
@@ -390,6 +379,7 @@ const ChatContext = createContext<ChatContextValue | null>(null);
|
||||
|
||||
const initialState: ChatState = {
|
||||
messages: [],
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
progress: null,
|
||||
progressLog: [],
|
||||
@@ -475,6 +465,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
[fileActions, downloadFile],
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []);
|
||||
const setOpen = useCallback(
|
||||
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
|
||||
[],
|
||||
);
|
||||
const cancelMessage = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
@@ -519,59 +514,28 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
formData.append(`conversationHistory[${i}].role`, message.role);
|
||||
formData.append(`conversationHistory[${i}].content`, message.content);
|
||||
});
|
||||
const response = await fetch(
|
||||
`${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: await getAuthHeaders(),
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const response = await fetch("/api/v1/ai/orchestrate/stream", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail: string | undefined;
|
||||
let limitHandled = false;
|
||||
try {
|
||||
const body = await response.json();
|
||||
const code = typeof body?.error === "string" ? body.error : null;
|
||||
// A 402 carrying a usage-limit sentinel means the agent call itself was gated.
|
||||
// Fire the usage-limit modal (free → subscribe, subscribed → raise cap) and show a
|
||||
// brief line below — not a generic "engine failed" error.
|
||||
if (response.status === 402 && isPaygLimitCode(code)) {
|
||||
dispatchPaygLimitReached(
|
||||
typeof body?.subscribed === "boolean" ? body.subscribed : null,
|
||||
);
|
||||
limitHandled = true;
|
||||
} else {
|
||||
detail =
|
||||
body?.message ??
|
||||
body?.detail ??
|
||||
body?.error ??
|
||||
(Array.isArray(body?.errors)
|
||||
? body.errors[0]?.message
|
||||
: undefined);
|
||||
}
|
||||
detail =
|
||||
body?.message ??
|
||||
body?.detail ??
|
||||
body?.error ??
|
||||
(Array.isArray(body?.errors)
|
||||
? body.errors[0]?.message
|
||||
: undefined);
|
||||
} catch {
|
||||
// non-JSON body — ignore
|
||||
}
|
||||
if (limitHandled) {
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
id: generateId(),
|
||||
role: ChatRole.ASSISTANT,
|
||||
content: t(
|
||||
"chat.responses.usage_limit_reached",
|
||||
"You've reached your usage limit. Check your plan options to keep going.",
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
detail ?? `AI engine request failed: ${response.status}`,
|
||||
);
|
||||
@@ -601,20 +565,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
onResult: (data) => {
|
||||
receivedResult = true;
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
// The agent's tool calls run server-side, so a usage-limit 402 surfaces here on the
|
||||
// result (not via the apiClient interceptor that pops the modal for direct calls).
|
||||
// Fire the matching modal and replace the raw "tool failed: 402…" reason with a
|
||||
// brief, non-alarming line.
|
||||
const isLimit = isPaygLimitCode(data.errorCode);
|
||||
if (isLimit) {
|
||||
dispatchPaygLimitReached(data.errorSubscribed ?? null);
|
||||
}
|
||||
const replyContent = isLimit
|
||||
? t(
|
||||
"chat.responses.usage_limit_reached",
|
||||
"You've reached your usage limit. Check your plan options to keep going.",
|
||||
)
|
||||
: formatWorkflowResponse(data, t);
|
||||
const replyContent = formatWorkflowResponse(data, t);
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
@@ -698,9 +649,12 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
messages: state.messages,
|
||||
isOpen: state.isOpen,
|
||||
isLoading: state.isLoading,
|
||||
progress: state.progress,
|
||||
progressLog: state.progressLog,
|
||||
toggleOpen,
|
||||
setOpen,
|
||||
sendMessage,
|
||||
cancelMessage,
|
||||
clearChat,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
.chat-fab-trigger {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
bottom: calc(var(--footer-height, 2rem) + 16px);
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
/* Include box-shadow so the ChatFABButton hover shadow still animates */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ChatFABButton } from "@shared/components/ChatFABButton";
|
||||
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
|
||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAgentsEnabled } from "@app/components/agents/AgentsPanel";
|
||||
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
|
||||
import "@app/components/chat/ChatFAB.css";
|
||||
|
||||
@@ -15,7 +15,8 @@ const PANEL_HEIGHT_PX = 520;
|
||||
const PANEL_MIN_WIDTH_PX = 300;
|
||||
const PANEL_MIN_HEIGHT_PX = 380;
|
||||
const FAB_GAP_PX = 16;
|
||||
const FAB_BOTTOM_OFFSET_PX = FAB_GAP_PX;
|
||||
// footer-height (2rem = 32px) + gap so the panel clears the footer
|
||||
const FAB_BOTTOM_OFFSET_PX = 32 + FAB_GAP_PX;
|
||||
|
||||
const RESET_MS = 380;
|
||||
const RESET_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
|
||||
@@ -53,8 +54,7 @@ export function ChatFAB() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
|
||||
const { isLoading } = useChat();
|
||||
const { config } = useAppConfig();
|
||||
const enabled = Boolean(config?.aiEngineEnabled);
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
// Detect loading → done transition. If the FAB is closed when the agent
|
||||
// finishes, show the tick badge until the user opens the panel.
|
||||
@@ -90,13 +90,9 @@ export function ChatFAB() {
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// The overlay only mounts once the AI engine is enabled (config can load
|
||||
// after first render), so re-measure when that flips true rather than only
|
||||
// on initial mount, otherwise the default position never gets computed.
|
||||
if (!enabled) return;
|
||||
const pos = getDefaultPos();
|
||||
if (pos) setRndPos(pos);
|
||||
}, [enabled]);
|
||||
}, []);
|
||||
|
||||
// Clear the reset timer on unmount to avoid state updates on dead components.
|
||||
// Also ensure body user-select is restored if we unmount mid-resize.
|
||||
@@ -144,12 +140,6 @@ export function ChatFAB() {
|
||||
<ChatFABButton
|
||||
className={`chat-fab-trigger${isOpen ? " chat-fab-trigger--hidden" : ""}`}
|
||||
onClick={() => {
|
||||
// Fallback: ensure a position exists before opening, in case the
|
||||
// layout effect measured before the overlay was laid out.
|
||||
if (rndPos === null) {
|
||||
const pos = getDefaultPos();
|
||||
if (pos) setRndPos(pos);
|
||||
}
|
||||
setIsOpen(true);
|
||||
setHasUnviewedResult(false);
|
||||
}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user