mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Merge branch 'main' into SaaS
This commit is contained in:
@@ -2700,6 +2700,13 @@ whole_doc_compression_round = "Consolidating notes..."
|
||||
whole_doc_read_done = "Finished reading the document..."
|
||||
whole_doc_read_started = "Reading the document..."
|
||||
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
|
||||
ranForSeconds = "Ran for {{count}} seconds"
|
||||
ranForSeconds_one = "Ran for 1 second"
|
||||
ranForSeconds_other = "Ran for {{count}} seconds"
|
||||
ranForMinutes = "Ran for {{count}} minutes"
|
||||
ranForMinutes_one = "Ran for 1 minute"
|
||||
ranForMinutes_other = "Ran for {{count}} minutes"
|
||||
ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec"
|
||||
|
||||
[chat.quickActions]
|
||||
browseYourFiles = "Browse your files"
|
||||
@@ -3042,6 +3049,37 @@ impact = "Any applications or services currently using these keys will stop work
|
||||
title = "Refresh API Keys"
|
||||
warning = "⚠️ Warning: This action will generate new API keys and make your previous keys invalid."
|
||||
|
||||
[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."
|
||||
navLabel = "MCP Server"
|
||||
tip = "Every action your assistant runs is performed as your account and counts towards your usage, just like using Stirling PDF directly."
|
||||
title = "MCP Server"
|
||||
viewApiKeys = "View API keys"
|
||||
|
||||
[config.mcp.copy]
|
||||
configLabel = "Config"
|
||||
copied = "Copied"
|
||||
copy = "Copy"
|
||||
endpointLabel = "Endpoint URL"
|
||||
tooltip = "Copy {{label}}"
|
||||
tooltipCopied = "{{label}} copied"
|
||||
|
||||
[config.mcp.endpoint]
|
||||
label = "Your MCP endpoint"
|
||||
|
||||
[config.mcp.setup]
|
||||
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"
|
||||
@@ -3203,7 +3241,6 @@ creditsRemaining = "{{current}} of {{total}} remaining"
|
||||
creditsThisMonth = "Monthly credits"
|
||||
current = "Current Plan"
|
||||
customPricing = "Custom"
|
||||
customWatchedFolders = "Custom Watched Folders"
|
||||
dedicatedSupportSlas = "Dedicated support & SLAs"
|
||||
enterpriseSubscription = "Enterprise"
|
||||
everythingInCredits = "Everything in Credits, plus:"
|
||||
@@ -3511,7 +3548,6 @@ addFiles = "Add Files"
|
||||
|
||||
[fileManager]
|
||||
active = "Active"
|
||||
addToWatchedFolder = "Add to Watched Folder"
|
||||
addToUpload = "Add to Upload"
|
||||
changesNotUploaded = "Changes not uploaded"
|
||||
clearAll = "Clear All"
|
||||
@@ -4471,7 +4507,7 @@ alreadyHaveAccount = "Already have an account?"
|
||||
choosePassword = "Choose a password"
|
||||
confirmPassword = "Confirm password"
|
||||
confirmPasswordPlaceholder = "Re-enter your password"
|
||||
createAccount = "Create Account"
|
||||
createAccount = "Create account"
|
||||
creating = "Creating Account..."
|
||||
email = "Email address"
|
||||
emailPlaceholder = "Enter your email address"
|
||||
@@ -4554,11 +4590,10 @@ sending = "Sending…"
|
||||
sendMagicLink = "Send Magic Link"
|
||||
sendResetLink = "Send reset link"
|
||||
sessionExpired = "Your session has expired. Please sign in again."
|
||||
createAccount = "Create an account"
|
||||
signin = "Sign in"
|
||||
signInAnonymously = "Sign Up as a Guest"
|
||||
signingIn = "Signing in..."
|
||||
signInWith = "Sign in with"
|
||||
subtitle = "Sign back in to Stirling PDF"
|
||||
title = "Sign in"
|
||||
unexpectedError = "Unexpected error: {{message}}"
|
||||
updatePassword = "Update password"
|
||||
@@ -5774,7 +5809,6 @@ label = "SMTP Username"
|
||||
|
||||
[quickAccess]
|
||||
access = "Access"
|
||||
watchedFolders = "Watched Folders"
|
||||
accessAddPerson = "Add another person"
|
||||
accessBack = "Back"
|
||||
accessCopyLink = "Copy link"
|
||||
@@ -6865,6 +6899,7 @@ createFailed = "Failed to create signing request"
|
||||
|
||||
[signup]
|
||||
accountCreatedSuccessfully = "Account created successfully! You can now sign in."
|
||||
alreadyHaveAccount = "I already have an account"
|
||||
checkEmailConfirmation = "Check your email for a confirmation link to complete your registration."
|
||||
confirmPassword = "Confirm password"
|
||||
confirmPasswordPlaceholder = "Confirm password"
|
||||
@@ -6885,10 +6920,11 @@ passwordsDoNotMatch = "Passwords do not match"
|
||||
passwordTooShort = "Password must be at least 6 characters long"
|
||||
pleaseFillAllFields = "Please fill in all fields"
|
||||
signUp = "Sign Up"
|
||||
signUpWith = "Sign up with"
|
||||
skip = "Skip"
|
||||
subtitle = "Join Stirling PDF to get started"
|
||||
title = "Create an account"
|
||||
unexpectedError = "Unexpected error: {{message}}"
|
||||
useEmailInstead = "Use Email Instead"
|
||||
|
||||
[sizes]
|
||||
large = "Large"
|
||||
@@ -8162,20 +8198,14 @@ newFolder = "New folder"
|
||||
noFolders = "No watched folders yet"
|
||||
sidebarTitle = "Watched Folders"
|
||||
title = "Watched Folders"
|
||||
sidebarFiles = "My Files"
|
||||
|
||||
[watchedFolders.modal]
|
||||
automation = "Automation"
|
||||
automationSaved = "Automation saved"
|
||||
createFolder = "Create Folder"
|
||||
stepsSaved = "Steps saved — click Create Folder to finish"
|
||||
automationRequired = "Add at least one configured step before saving."
|
||||
color = "Accent color"
|
||||
createTitle = "New Watched Folder"
|
||||
description = "Description"
|
||||
descriptionPlaceholder = "What does this folder do?"
|
||||
editTitle = "Edit Watched Folder"
|
||||
icon = "Icon"
|
||||
name = "Folder name"
|
||||
namePlaceholder = "My Watched Folder"
|
||||
nameRequired = "Folder name is required"
|
||||
@@ -8183,17 +8213,13 @@ nameTooLong = "Folder name must be 50 characters or less"
|
||||
saveChanges = "Save Changes"
|
||||
saveFailed = "Failed to save folder. Please try again."
|
||||
automationNameFallback = "Watched Folder automation"
|
||||
retryLabel = "Auto-retry"
|
||||
maxRetries = "Max retries"
|
||||
maxRetriesDesc = "0 to disable"
|
||||
retryDelay = "Delay (minutes)"
|
||||
outputLabel = "Output"
|
||||
outputModeVersion = "Replace original?"
|
||||
outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file"
|
||||
outputModeNewDesc = "Output is saved as a new separate file"
|
||||
outputName = "Output filename prefix"
|
||||
outputNameSuffix = "Suffix"
|
||||
outputNamePlaceholderVersion = "Same as original"
|
||||
sectionFolder = "Folder"
|
||||
sectionSourceOutput = "Source & Output"
|
||||
sectionSteps = "Steps"
|
||||
@@ -8220,14 +8246,10 @@ positionSuffix = "Suffix"
|
||||
|
||||
[watchedFolders.home]
|
||||
create = "Create your first Watched Folder"
|
||||
dropHere = "Drop to process"
|
||||
editFolder = "Edit folder"
|
||||
empty = "No watched folders yet"
|
||||
file = "file"
|
||||
files = "files"
|
||||
openFolder = "Open folder"
|
||||
title = "Watched Folders"
|
||||
subtitle = "Folders that automatically process PDFs with your configured pipeline"
|
||||
emptyTitle = "Automate your PDF workflows"
|
||||
emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does."
|
||||
addAnother = "Add another Watched Folder"
|
||||
@@ -8235,7 +8257,6 @@ addAnotherDesc = "Automatically process files with a new pipeline"
|
||||
resume = "Resume"
|
||||
pause = "Pause"
|
||||
deleteFolder = "Delete folder"
|
||||
noSteps = "No automation steps configured"
|
||||
|
||||
[watchedFolders.card]
|
||||
edit = "Edit folder"
|
||||
@@ -8318,7 +8339,6 @@ previewLoadFailed = "Could not load file preview."
|
||||
back = "Back"
|
||||
dismiss = "Dismiss"
|
||||
retry = "Retry"
|
||||
view = "View"
|
||||
download = "Download"
|
||||
downloadInput = "Download input"
|
||||
downloadOutput = "Download output"
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.8 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.8 KiB |
@@ -10,6 +10,7 @@ export function useChat() {
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
progress: null,
|
||||
progressLog: [] as never[],
|
||||
toggleOpen: () => {},
|
||||
setOpen: (_open: boolean) => {},
|
||||
sendMessage: async (_content: string) => {},
|
||||
|
||||
@@ -19,12 +19,7 @@ test.describe("1. Authentication and Login", () => {
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Step 3: Confirm the heading for "Sign In" / "Login" is visible
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /sign in|login|masuk/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Step 4: Confirm a "Username" text input field is present and empty
|
||||
// Step 3: Confirm a "Username" text input field is present and empty
|
||||
const usernameInput = page.locator("#email");
|
||||
await expect(usernameInput).toBeVisible();
|
||||
await expect(usernameInput).toHaveValue("");
|
||||
|
||||
@@ -37,6 +37,14 @@ export interface ChatMessage {
|
||||
* turns that answered without running any tool.
|
||||
*/
|
||||
toolsUsed?: string[];
|
||||
/**
|
||||
* Full ordered progress log captured during the AI turn that produced this message.
|
||||
* Only set on assistant messages; used to render the "Ran for X seconds" collapsed
|
||||
* history dropdown above the response.
|
||||
*/
|
||||
progressLog?: AiWorkflowProgress[];
|
||||
/** Wall-clock duration of the AI turn in milliseconds. Only set on assistant messages. */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export enum AiWorkflowPhase {
|
||||
@@ -178,12 +186,20 @@ interface ChatState {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
progress: AiWorkflowProgress | null;
|
||||
/** Ordered log of every progress event in the current request. UI shows the last N entries. */
|
||||
progressLog: AiWorkflowProgress[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of progress steps retained in the live buffer.
|
||||
*/
|
||||
export const PROGRESS_LOG_MAX = 4;
|
||||
|
||||
type ChatAction =
|
||||
| { type: "ADD_MESSAGE"; message: ChatMessage }
|
||||
| { 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" };
|
||||
@@ -193,15 +209,40 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
||||
case "ADD_MESSAGE":
|
||||
return { ...state, messages: [...state.messages, action.message] };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.loading };
|
||||
// Reset the log on both start (true) and end (false) of a request.
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.loading,
|
||||
progress: action.loading ? state.progress : null,
|
||||
progressLog: [],
|
||||
};
|
||||
case "SET_PROGRESS":
|
||||
return { ...state, progress: action.progress };
|
||||
case "APPEND_PROGRESS":
|
||||
// Cap the live buffer so each append copies at most PROGRESS_LOG_MAX elements
|
||||
return {
|
||||
...state,
|
||||
progress: action.progress,
|
||||
progressLog:
|
||||
state.progressLog.length < PROGRESS_LOG_MAX
|
||||
? [...state.progressLog, action.progress]
|
||||
: [
|
||||
...state.progressLog.slice(1 - PROGRESS_LOG_MAX),
|
||||
action.progress,
|
||||
],
|
||||
};
|
||||
case "TOGGLE_OPEN":
|
||||
return { ...state, isOpen: !state.isOpen };
|
||||
case "SET_OPEN":
|
||||
return { ...state, isOpen: action.open };
|
||||
case "CLEAR":
|
||||
return { ...state, messages: [], isLoading: false, progress: null };
|
||||
return {
|
||||
...state,
|
||||
messages: [],
|
||||
isLoading: false,
|
||||
progress: null,
|
||||
progressLog: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +366,8 @@ interface ChatContextValue {
|
||||
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>;
|
||||
@@ -339,6 +382,7 @@ const initialState: ChatState = {
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
progress: null,
|
||||
progressLog: [],
|
||||
};
|
||||
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
@@ -440,6 +484,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
abortRef.current = controller;
|
||||
|
||||
const priorMessages = messagesRef.current;
|
||||
const startTime = Date.now();
|
||||
// Mirror every progress event locally so we can attach the full log to
|
||||
// the assistant message when the result arrives — without needing a ref
|
||||
// into the reducer state.
|
||||
const progressLogLocal: AiWorkflowProgress[] = [];
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
@@ -503,16 +552,15 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
) {
|
||||
toolsUsed.push(data.tool);
|
||||
}
|
||||
dispatch({
|
||||
type: "SET_PROGRESS",
|
||||
progress: {
|
||||
phase: data.phase as AiWorkflowPhase,
|
||||
tool: data.tool,
|
||||
stepIndex: data.stepIndex,
|
||||
stepCount: data.stepCount,
|
||||
engineDetail: data.engineDetail,
|
||||
},
|
||||
});
|
||||
const progressItem: AiWorkflowProgress = {
|
||||
phase: data.phase as AiWorkflowPhase,
|
||||
tool: data.tool,
|
||||
stepIndex: data.stepIndex,
|
||||
stepCount: data.stepCount,
|
||||
engineDetail: data.engineDetail,
|
||||
};
|
||||
progressLogLocal.push(progressItem);
|
||||
dispatch({ type: "APPEND_PROGRESS", progress: progressItem });
|
||||
},
|
||||
onResult: (data) => {
|
||||
receivedResult = true;
|
||||
@@ -526,6 +574,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
content: replyContent,
|
||||
timestamp: Date.now(),
|
||||
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
|
||||
progressLog:
|
||||
progressLogLocal.length > 0
|
||||
? [...progressLogLocal]
|
||||
: undefined,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
});
|
||||
if (data.fileId || data.resultFiles?.length) {
|
||||
@@ -599,6 +652,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
isOpen: state.isOpen,
|
||||
isLoading: state.isLoading,
|
||||
progress: state.progress,
|
||||
progressLog: state.progressLog,
|
||||
toggleOpen,
|
||||
setOpen,
|
||||
sendMessage,
|
||||
|
||||
@@ -396,7 +396,182 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Thinking indicator */
|
||||
/* ─── Progress step log ─────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Shown while the AI is working. Each step slides in from below as events
|
||||
* arrive; the active (last) step is full-opacity with a pulsing icon, and
|
||||
* earlier completed steps are dimmed. Only the most-recent N steps are
|
||||
* rendered — there is no scrollable list.
|
||||
*/
|
||||
|
||||
.chat-progress-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.15rem 0.5rem 0.35rem;
|
||||
/* No gap — the connector line div provides the inter-step spacing. */
|
||||
}
|
||||
|
||||
.chat-progress-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
/*
|
||||
* Use color rather than opacity for past-step dimming so the connector
|
||||
* line (a sibling element inside __left, not a text node) is NOT affected
|
||||
* and stays clearly visible. currentColor flows into SVG icon fills;
|
||||
* color inherits into the label span automatically.
|
||||
*/
|
||||
color: var(--text-muted);
|
||||
transition: color 250ms ease-out;
|
||||
animation: chat-step-enter 300ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
/*
|
||||
* Active step: white text on dark mode so currentColor feeds directly into
|
||||
* the StirlingLogoAnimated SVG paths and the label at the same time.
|
||||
* Light-mode falls back to the default text colour so it's not invisible.
|
||||
*/
|
||||
/* Active step: icon and label are coloured independently so the logo can be
|
||||
blue while the text uses the normal text colour. */
|
||||
.chat-progress-step--active .chat-progress-step__icon {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
}
|
||||
|
||||
.chat-progress-step--active .chat-progress-step__label {
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.chat-progress-step--active
|
||||
.chat-progress-step__label {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chat-progress-step__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
/* Icon cell — fixed height so the connector line aligns cleanly. */
|
||||
.chat-progress-step__icon {
|
||||
width: 20px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
/* Inherits color from .chat-progress-step so currentColor flows into icons. */
|
||||
}
|
||||
|
||||
/*
|
||||
* Connector line between steps. Not a child of the label so it is unaffected
|
||||
* by the step's color; it sits on its own and reads clearly against the
|
||||
* toolbar background.
|
||||
*/
|
||||
.chat-progress-step__line {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Active step label — slightly larger, inherits the active color. */
|
||||
.chat-progress-step__label {
|
||||
font-size: 0.875rem;
|
||||
padding-top: 3px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* Past step labels — a touch smaller so the active row reads as the primary. */
|
||||
.chat-progress-step:not(.chat-progress-step--active)
|
||||
.chat-progress-step__label {
|
||||
font-size: 0.8rem;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Wrapper that scales a registry tool icon (LocalIcon, typically 1.5rem/24px)
|
||||
* down to fit the 20px icon column. Transform does not affect layout, so the
|
||||
* parent overflow:hidden clips the un-scaled footprint cleanly.
|
||||
*/
|
||||
.chat-step-icon-scaled {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: scale(0.75);
|
||||
transform-origin: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Entry: steps slide up from a few pixels below their final position. */
|
||||
@keyframes chat-step-enter {
|
||||
from {
|
||||
transform: translateY(7px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Completed progress log dropdown ──────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Shown above each completed assistant turn. The toggle displays a small
|
||||
* "Ran for X seconds" label; expanding reveals the full ordered step list
|
||||
* for that turn in a compact, read-only format.
|
||||
*/
|
||||
|
||||
.chat-completed-log {
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.chat-completed-log__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.1rem 0.25rem 0.1rem 0.1rem;
|
||||
border-radius: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
transition: background 120ms ease-out;
|
||||
}
|
||||
|
||||
.chat-completed-log__toggle:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.chat-completed-log__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.35rem 0.4rem 0.2rem 0.25rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* In the completed log every step is a past step — no active highlighting,
|
||||
no entry animation (the content is static once opened). */
|
||||
.chat-completed-log__steps .chat-progress-step {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Tighten the icon and connector dimensions for the denser historical view. */
|
||||
.chat-completed-log__steps .chat-progress-step__icon {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.chat-completed-log__steps .chat-progress-step__line {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.chat-completed-log__steps .chat-progress-step__label {
|
||||
font-size: 0.775rem;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Legacy thinking indicator (kept in case any other code references it). */
|
||||
.chat-thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -405,6 +580,13 @@
|
||||
color: var(--mantine-color-blue-filled);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-progress-step {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Stirling logo thinking animation ─────────────────────────────────── */
|
||||
|
||||
.stirling-thinking__path-right {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
|
||||
import { TFunction } from "i18next";
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
Box,
|
||||
Collapse,
|
||||
Group,
|
||||
List,
|
||||
Menu,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined";
|
||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||
@@ -33,14 +36,15 @@ import {
|
||||
useChat,
|
||||
AiWorkflowPhase,
|
||||
ChatRole,
|
||||
PROGRESS_LOG_MAX,
|
||||
isKnownEngineProgressDetail,
|
||||
type AiWorkflowProgress,
|
||||
type AnyEngineProgressDetail,
|
||||
} from "@app/components/chat/ChatContext";
|
||||
import { formatRelativeTime } from "@app/utils/timeUtils";
|
||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||
import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated";
|
||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
|
||||
import "@app/components/chat/ChatPanel.css";
|
||||
|
||||
@@ -68,6 +72,27 @@ function useToolNameResolver(): ToolNameResolver {
|
||||
}, [allTools]);
|
||||
}
|
||||
|
||||
/** Resolver mapping a tool endpoint path to its registry icon ReactNode. */
|
||||
type ToolIconResolver = (endpoint: string) => ReactNode | null;
|
||||
|
||||
/**
|
||||
* Look up a tool's icon ReactNode from the tool catalog, keyed by API endpoint path.
|
||||
* Returns null when the endpoint is not found (use a generic fallback icon in that case).
|
||||
*/
|
||||
function useToolIconResolver(): ToolIconResolver {
|
||||
const { allTools } = useTranslatedToolCatalog();
|
||||
return useMemo(() => {
|
||||
const iconByEndpoint = new Map<string, ReactNode>();
|
||||
Object.values(allTools).forEach((tool) => {
|
||||
const endpoint = tool.operationConfig?.endpoint;
|
||||
if (typeof endpoint === "string") {
|
||||
iconByEndpoint.set(endpoint, tool.icon);
|
||||
}
|
||||
});
|
||||
return (endpoint: string) => iconByEndpoint.get(endpoint) ?? null;
|
||||
}, [allTools]);
|
||||
}
|
||||
|
||||
function formatProgress(
|
||||
progress: AiWorkflowProgress,
|
||||
t: TranslateFn,
|
||||
@@ -125,31 +150,152 @@ function formatEngineProgress(
|
||||
}
|
||||
}
|
||||
|
||||
function ToolsUsedBlock({
|
||||
tools,
|
||||
resolveToolName,
|
||||
/**
|
||||
* Choose an icon for a progress step.
|
||||
*
|
||||
* The active (current) step always shows the animated Stirling logo so it reads
|
||||
* as the "live" indicator. Past steps get a phase-specific icon so the trail
|
||||
* is scannable at a glance.
|
||||
*/
|
||||
function progressStepIcon(
|
||||
progress: AiWorkflowProgress,
|
||||
resolveToolIcon: ToolIconResolver,
|
||||
isActive: boolean,
|
||||
): ReactNode {
|
||||
if (isActive) {
|
||||
return <StirlingLogoAnimated size={18} />;
|
||||
}
|
||||
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL) {
|
||||
const registryIcon = progress.tool ? resolveToolIcon(progress.tool) : null;
|
||||
if (registryIcon) {
|
||||
return <span className="chat-step-icon-scaled">{registryIcon}</span>;
|
||||
}
|
||||
return <BuildOutlinedIcon sx={{ fontSize: 17 }} />;
|
||||
}
|
||||
if (
|
||||
progress.phase === AiWorkflowPhase.EXTRACTING_CONTENT ||
|
||||
progress.phase === AiWorkflowPhase.ENGINE_PROGRESS
|
||||
) {
|
||||
return <ArticleOutlinedIcon sx={{ fontSize: 17 }} />;
|
||||
}
|
||||
return <CloudOutlinedIcon sx={{ fontSize: 17 }} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated step-by-step progress log shown while the AI is working.
|
||||
* Displays the last {@link PROGRESS_LOG_VISIBLE} steps from the live event stream,
|
||||
* with the active (most recent) step highlighted and older steps dimmed.
|
||||
*/
|
||||
function ProgressLogDisplay({
|
||||
progressLog,
|
||||
t,
|
||||
resolveToolName,
|
||||
resolveToolIcon,
|
||||
}: {
|
||||
tools: string[];
|
||||
resolveToolName: ToolNameResolver;
|
||||
progressLog: AiWorkflowProgress[];
|
||||
t: TranslateFn;
|
||||
resolveToolName: ToolNameResolver;
|
||||
resolveToolIcon: ToolIconResolver;
|
||||
}) {
|
||||
// Placeholder shown before the first SSE event arrives.
|
||||
if (progressLog.length === 0) {
|
||||
return (
|
||||
<div className="chat-progress-log">
|
||||
<div className="chat-progress-step chat-progress-step--active">
|
||||
<div className="chat-progress-step__left">
|
||||
<div className="chat-progress-step__icon">
|
||||
<StirlingLogoAnimated size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="chat-progress-step__label">
|
||||
{t("chat.progress.thinking")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chronological order: oldest at top, newest (active) at bottom.
|
||||
// The reducer already caps progressLog at PROGRESS_LOG_MAX entries, so this
|
||||
// slice is effectively a no-op but kept for defensive correctness.
|
||||
const visibleSteps = progressLog.slice(-PROGRESS_LOG_MAX);
|
||||
const startIndex = progressLog.length - visibleSteps.length;
|
||||
|
||||
return (
|
||||
<div className="chat-progress-log">
|
||||
{visibleSteps.map((step, i) => {
|
||||
// Stable key based on absolute position in the full log — React reuses
|
||||
// existing DOM elements and only mounts (and animates) new ones.
|
||||
const globalIndex = startIndex + i;
|
||||
const isActive = i === visibleSteps.length - 1; // last = newest = bottom
|
||||
// Connector runs below every step except the active one at the bottom.
|
||||
const showConnector = i < visibleSteps.length - 1;
|
||||
const label = formatProgress(step, t, resolveToolName);
|
||||
return (
|
||||
<div
|
||||
key={globalIndex}
|
||||
className={`chat-progress-step${isActive ? " chat-progress-step--active" : ""}`}
|
||||
>
|
||||
<div className="chat-progress-step__left">
|
||||
<div className="chat-progress-step__icon">
|
||||
{progressStepIcon(step, resolveToolIcon, isActive)}
|
||||
</div>
|
||||
{showConnector && <div className="chat-progress-step__line" />}
|
||||
</div>
|
||||
<span className="chat-progress-step__label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number, t: TranslateFn): string {
|
||||
const totalSeconds = Math.max(1, Math.round(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes === 0) {
|
||||
return t("chat.progress.ranForSeconds", { count: totalSeconds });
|
||||
}
|
||||
if (seconds === 0) {
|
||||
return t("chat.progress.ranForMinutes", { count: minutes });
|
||||
}
|
||||
return t("chat.progress.ranForMinutesSeconds", { minutes, seconds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed "Ran for X seconds" dropdown that appears above each completed
|
||||
* assistant turn. Expands to show the full ordered progress log for that turn.
|
||||
*/
|
||||
function CompletedProgressLogDropdown({
|
||||
progressLog,
|
||||
durationMs,
|
||||
t,
|
||||
resolveToolName,
|
||||
resolveToolIcon,
|
||||
}: {
|
||||
progressLog: AiWorkflowProgress[];
|
||||
durationMs: number;
|
||||
t: TranslateFn;
|
||||
resolveToolName: ToolNameResolver;
|
||||
resolveToolIcon: ToolIconResolver;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const names = tools.map(
|
||||
(endpoint) => resolveToolName(endpoint) ?? t("chat.toolsUsed.unknownTool"),
|
||||
);
|
||||
const label = t("chat.toolsUsed.summary", { count: tools.length });
|
||||
const label = formatDuration(durationMs, t);
|
||||
|
||||
return (
|
||||
<Box mt={6}>
|
||||
<div className="chat-completed-log">
|
||||
<UnstyledButton
|
||||
className="chat-completed-log__toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" align="center">
|
||||
{expanded ? (
|
||||
<ExpandLessIcon sx={{ fontSize: 14 }} />
|
||||
<ExpandLessIcon sx={{ fontSize: 12 }} />
|
||||
) : (
|
||||
<ExpandMoreIcon sx={{ fontSize: 14 }} />
|
||||
<ExpandMoreIcon sx={{ fontSize: 12 }} />
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
@@ -157,19 +303,30 @@ function ToolsUsedBlock({
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse in={expanded}>
|
||||
<List
|
||||
type="ordered"
|
||||
size="xs"
|
||||
mt={4}
|
||||
pl="lg"
|
||||
styles={{ itemWrapper: { lineHeight: 1.4 } }}
|
||||
>
|
||||
{names.map((name, i) => (
|
||||
<List.Item key={i}>{name}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<div className="chat-completed-log__steps">
|
||||
{progressLog.map((step, i) => {
|
||||
const showConnector = i < progressLog.length - 1;
|
||||
const stepLabel = formatProgress(step, t, resolveToolName);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="chat-progress-step chat-progress-step--done"
|
||||
>
|
||||
<div className="chat-progress-step__left">
|
||||
<div className="chat-progress-step__icon">
|
||||
{progressStepIcon(step, resolveToolIcon, false)}
|
||||
</div>
|
||||
{showConnector && (
|
||||
<div className="chat-progress-step__line" />
|
||||
)}
|
||||
</div>
|
||||
<span className="chat-progress-step__label">{stepLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,15 +334,19 @@ function ChatMessageBubble({
|
||||
role,
|
||||
content,
|
||||
timestamp,
|
||||
toolsUsed,
|
||||
progressLog,
|
||||
durationMs,
|
||||
resolveToolName,
|
||||
resolveToolIcon,
|
||||
t,
|
||||
}: {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
toolsUsed?: string[];
|
||||
progressLog?: AiWorkflowProgress[];
|
||||
durationMs?: number;
|
||||
resolveToolName: ToolNameResolver;
|
||||
resolveToolIcon: ToolIconResolver;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -231,16 +392,18 @@ function ChatMessageBubble({
|
||||
return (
|
||||
<div className="chat-message chat-message-assistant">
|
||||
<div className="chat-bubble-assistant">
|
||||
{progressLog && progressLog.length > 0 && durationMs != null && (
|
||||
<CompletedProgressLogDropdown
|
||||
progressLog={progressLog}
|
||||
durationMs={durationMs}
|
||||
t={t}
|
||||
resolveToolName={resolveToolName}
|
||||
resolveToolIcon={resolveToolIcon}
|
||||
/>
|
||||
)}
|
||||
<Text size="sm" component="div">
|
||||
{renderMarkdown(content)}
|
||||
</Text>
|
||||
{toolsUsed && toolsUsed.length > 0 && (
|
||||
<ToolsUsedBlock
|
||||
tools={toolsUsed}
|
||||
resolveToolName={resolveToolName}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,20 +419,55 @@ export interface ChatPanelProps {
|
||||
|
||||
export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { messages, isLoading, progress, sendMessage, clearChat } = useChat();
|
||||
const { messages, isLoading, progressLog, sendMessage, clearChat } =
|
||||
useChat();
|
||||
const resolveToolName = useToolNameResolver();
|
||||
const resolveToolIcon = useToolIconResolver();
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Tracks whether the user manually scrolled away from the bottom.
|
||||
// A ref (not state) so scroll events don't cause re-renders.
|
||||
const userScrolledUp = useRef(false);
|
||||
|
||||
// Jump to the bottom on first render so existing conversations open at the
|
||||
// most recent message rather than the top.
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Attach a passive scroll listener to track whether the user has scrolled
|
||||
// away from the bottom (breaks auto-scroll) or returned to it (re-latches).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
userScrolledUp.current = distFromBottom > 50;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Scroll to the bottom when messages arrive or live progress steps update,
|
||||
// unless the user has scrolled up (they're reading history).
|
||||
// Scrolling back to the bottom resets the ref, so the next update re-latches.
|
||||
//
|
||||
// RAF defers the scroll until after the browser has laid out the new nodes,
|
||||
// so scrollHeight is correct. Direct scrollTop assignment avoids the
|
||||
// smooth-scroll interruption problem that occurs when SSE events arrive
|
||||
// faster than a smooth animation can complete.
|
||||
useEffect(() => {
|
||||
if (!userScrolledUp.current) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages, progressLog]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
@@ -343,21 +541,21 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
timestamp={msg.timestamp}
|
||||
toolsUsed={msg.toolsUsed}
|
||||
progressLog={msg.progressLog}
|
||||
durationMs={msg.durationMs}
|
||||
resolveToolName={resolveToolName}
|
||||
resolveToolIcon={resolveToolIcon}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="chat-message chat-message-assistant">
|
||||
<div className="chat-thinking">
|
||||
<StirlingLogoAnimated size={20} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{progress
|
||||
? formatProgress(progress, t, resolveToolName)
|
||||
: t("chat.progress.thinking")}
|
||||
</Text>
|
||||
</div>
|
||||
<ProgressLogDisplay
|
||||
progressLog={progressLog}
|
||||
t={t}
|
||||
resolveToolName={resolveToolName}
|
||||
resolveToolIcon={resolveToolIcon}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -17,12 +17,11 @@ import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import { useBackendProbe } from "@app/hooks/useBackendProbe";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { BASE_PATH, withBasePath } from "@app/constants/app";
|
||||
import { type OAuthProvider } from "@app/auth/oauthTypes";
|
||||
import { updateSupportedLanguages } from "@app/i18n";
|
||||
|
||||
// Import login components
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import OAuthButtons, {
|
||||
@@ -74,7 +73,6 @@ export default function Login() {
|
||||
const autoLoginErrorRecorded = useRef(false);
|
||||
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
|
||||
const isSsoOnlyMode = loginMethod !== "all" && loginMethod !== "normal";
|
||||
const isSingleSsoOnly = !isUserPassAllowed && enabledProviders.length === 1;
|
||||
|
||||
const AUTO_LOGIN_ATTEMPTS_KEY = "stirling_sso_auto_login_attempts";
|
||||
const AUTO_LOGIN_ERRORS_KEY = "stirling_sso_auto_login_errors";
|
||||
@@ -461,7 +459,6 @@ export default function Login() {
|
||||
|
||||
// If backend isn't ready yet, show a lightweight status screen instead of the form
|
||||
if (backendProbe.status !== "up" && !loginDisabled) {
|
||||
const backendTitle = t("backendStartup.notFoundTitle", "Backend not found");
|
||||
const handleRetry = async () => {
|
||||
const result = await backendProbe.probe();
|
||||
if (result.status === "up") {
|
||||
@@ -471,7 +468,18 @@ export default function Login() {
|
||||
};
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={backendTitle} />
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--dark"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="auth-section"
|
||||
style={{
|
||||
@@ -555,10 +563,18 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={isSingleSsoOnly ? "" : t("login.login") || "Sign in"}
|
||||
centerOnly={isSingleSsoOnly}
|
||||
/>
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Success message */}
|
||||
{successMessage && (
|
||||
|
||||
@@ -5,10 +5,9 @@ import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { BASE_PATH, withBasePath } from "@app/constants/app";
|
||||
|
||||
// Import signup components
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import SignupForm from "@app/routes/signup/SignupForm";
|
||||
@@ -92,10 +91,18 @@ export default function Signup() {
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={t("signup.title", "Create an account")}
|
||||
subtitle={t("signup.subtitle", "Join Stirling PDF")}
|
||||
/>
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
|
||||
@@ -615,3 +615,90 @@
|
||||
background-color: #af3434 !important;
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
/* ── Logo block ─────────────────────────────────────────────────────── */
|
||||
.auth-logo-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 0.5rem;
|
||||
animation: authFadeUp 0.4s ease both;
|
||||
}
|
||||
|
||||
.auth-logo-header {
|
||||
height: 8rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.auth-logo-header--dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Page entrance animation ────────────────────────────────────────── */
|
||||
@keyframes authFadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expandable trigger button state ────────────────────────────────── */
|
||||
.auth-expandable-trigger {
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
border-color 180ms ease;
|
||||
}
|
||||
|
||||
.auth-expandable-trigger--active {
|
||||
border-color: #af3434 !important;
|
||||
box-shadow: 0 0 0 3px rgba(175, 52, 52, 0.12);
|
||||
}
|
||||
|
||||
/* ── Animated expand/collapse via grid-template-rows ───────────────── */
|
||||
.auth-expand-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition:
|
||||
grid-template-rows 280ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 220ms ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.auth-expand-grid--open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.auth-expand-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Icon+label group — keeps icons vertically aligned across buttons ── */
|
||||
.oauth-btn-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
|
||||
.oauth-btn-label {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Icon helpers inside oauth-button-fullwidth ─────────────────────── */
|
||||
.auth-at-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
margin-right: 0.5rem;
|
||||
display: inline-block;
|
||||
width: 1.75rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Group,
|
||||
Alert,
|
||||
Code,
|
||||
Badge,
|
||||
Button,
|
||||
CopyButton,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { openAppSettings } from "@app/utils/appSettings";
|
||||
|
||||
/** Strip a single trailing slash so we can safely append paths. */
|
||||
function trimTrailingSlash(url: string): string {
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
/** A small copy-to-clipboard button that sits inline next to a URL/snippet. */
|
||||
function CopyInline({ value, label }: { value: string; label: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<CopyButton value={value} timeout={1500}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={
|
||||
copied
|
||||
? t("config.mcp.copy.tooltipCopied", "{{label}} copied", {
|
||||
label,
|
||||
})
|
||||
: t("config.mcp.copy.tooltip", "Copy {{label}}", { label })
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color={copied ? "teal" : "gray"}
|
||||
onClick={copy}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={copied ? "check-rounded" : "content-copy-rounded"}
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{copied
|
||||
? t("config.mcp.copy.copied", "Copied")
|
||||
: t("config.mcp.copy.copy", "Copy")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
}
|
||||
|
||||
// SaaS MCP guide: always shown, explains how to point an AI assistant at the
|
||||
// OAuth-protected /mcp endpoint with per-client config.
|
||||
export default function McpSection() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
const baseUrl = useMemo(() => {
|
||||
const raw =
|
||||
config?.baseUrl ||
|
||||
(typeof window !== "undefined" ? window.location.origin : "");
|
||||
return trimTrailingSlash(raw);
|
||||
}, [config?.baseUrl]);
|
||||
|
||||
const mcpUrl = `${baseUrl}/mcp`;
|
||||
|
||||
// Per-client connection snippets pointing at this deployment's /mcp endpoint.
|
||||
const clients = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: "claude",
|
||||
label: "Claude Desktop",
|
||||
file: "claude_desktop_config.json",
|
||||
config: JSON.stringify(
|
||||
{ mcpServers: { "stirling-pdf": { type: "http", url: mcpUrl } } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "codex",
|
||||
label: "Codex CLI",
|
||||
file: "~/.codex/config.toml",
|
||||
config: `[mcp_servers.stirling-pdf]\nurl = "${mcpUrl}"`,
|
||||
},
|
||||
{
|
||||
value: "vscode",
|
||||
label: "VS Code",
|
||||
file: ".vscode/mcp.json",
|
||||
config: JSON.stringify(
|
||||
{ servers: { "stirling-pdf": { type: "http", url: mcpUrl } } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
[mcpUrl],
|
||||
);
|
||||
|
||||
const tools: { icon: string; key: string; fallback: string }[] = [
|
||||
{
|
||||
icon: "sync-alt-rounded",
|
||||
key: "config.mcp.tools.convert",
|
||||
fallback: "Convert",
|
||||
},
|
||||
{
|
||||
icon: "description-rounded",
|
||||
key: "config.mcp.tools.pages",
|
||||
fallback: "Pages",
|
||||
},
|
||||
{ icon: "lock", key: "config.mcp.tools.security", fallback: "Security" },
|
||||
{
|
||||
icon: "construction-rounded",
|
||||
key: "config.mcp.tools.misc",
|
||||
fallback: "Misc",
|
||||
},
|
||||
{ icon: "smart-toy-rounded", key: "config.mcp.tools.ai", fallback: "AI" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="md" className="settings-section-content">
|
||||
<div>
|
||||
<Group gap="sm" align="center">
|
||||
<ThemeIcon variant="light" size="lg" radius="md">
|
||||
<LocalIcon icon="smart-toy-rounded" width={22} height={22} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="lg">
|
||||
{t("config.mcp.title", "MCP Server")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={6}>
|
||||
{t(
|
||||
"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.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Endpoint */}
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
{t("config.mcp.endpoint.label", "Your MCP endpoint")}
|
||||
</Text>
|
||||
<Code style={{ overflowX: "auto" }}>{mcpUrl}</Code>
|
||||
</Stack>
|
||||
<CopyInline
|
||||
value={mcpUrl}
|
||||
label={t("config.mcp.copy.endpointLabel", "Endpoint URL")}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Per-client setup */}
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="xs">
|
||||
<Text fw={500} size="sm">
|
||||
{t("config.mcp.setup.title", "Connect your AI assistant")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"config.mcp.setup.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.",
|
||||
)}
|
||||
</Text>
|
||||
<Tabs defaultValue="claude" variant="pills" radius="md" mt={4}>
|
||||
<Tabs.List>
|
||||
{clients.map((c) => (
|
||||
<Tabs.Tab key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{clients.map((c) => (
|
||||
<Tabs.Panel key={c.value} value={c.value} pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("config.mcp.setup.addTo", "Add to")}{" "}
|
||||
<Code>{c.file}</Code>
|
||||
</Text>
|
||||
<CopyInline
|
||||
value={c.config}
|
||||
label={t("config.mcp.copy.configLabel", "Config")}
|
||||
/>
|
||||
</Group>
|
||||
<Code block>{c.config}</Code>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Available tools */}
|
||||
<div>
|
||||
<Text fw={500} size="sm" mb="xs">
|
||||
{t("config.mcp.tools.title", "What your assistant can do")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{tools.map((tool) => (
|
||||
<Badge
|
||||
key={tool.key}
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
size="lg"
|
||||
leftSection={
|
||||
<LocalIcon icon={tool.icon} width={13} height={13} />
|
||||
}
|
||||
>
|
||||
{t(tool.key, tool.fallback)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Tip / cross-link */}
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"config.mcp.tip",
|
||||
"Every action your assistant runs is performed as your account and counts towards your usage, just like using Stirling PDF directly.",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
style={{ flexShrink: 0 }}
|
||||
leftSection={
|
||||
<LocalIcon icon="key-rounded" width={14} height={14} />
|
||||
}
|
||||
onClick={() => openAppSettings("api-keys")}
|
||||
>
|
||||
{t("config.mcp.viewApiKeys", "View API keys")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import GeneralSection from "@app/components/shared/config/configSections/General
|
||||
import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity";
|
||||
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
|
||||
import Plan from "@app/components/shared/config/configSections/Plan";
|
||||
import McpSection from "@app/components/shared/config/configSections/McpSection";
|
||||
|
||||
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
|
||||
|
||||
@@ -108,6 +109,44 @@ function appendBillingSection(
|
||||
];
|
||||
}
|
||||
|
||||
// Add an "MCP Server" tab in the Developer section. Always shown in SaaS;
|
||||
// purely informational, so it appears for anonymous users too.
|
||||
function appendMcpSection(
|
||||
sections: ConfigNavSection[],
|
||||
t: TFunction<"translation", undefined>,
|
||||
): ConfigNavSection[] {
|
||||
const hasMcp = sections.some((section) =>
|
||||
section.items.some((item) => item.key === "mcp"),
|
||||
);
|
||||
|
||||
if (hasMcp) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
const mcpItem = {
|
||||
key: "mcp" as const,
|
||||
label: t("config.mcp.navLabel", "MCP Server"),
|
||||
icon: "smart-toy-rounded",
|
||||
component: <McpSection />,
|
||||
};
|
||||
|
||||
const developerIndex = sections.findIndex((section) =>
|
||||
section.items.some(
|
||||
(item) => item.key === "developer" || item.key === "api-keys",
|
||||
),
|
||||
);
|
||||
|
||||
if (developerIndex === -1) {
|
||||
return [...sections, { title: "Developer", items: [mcpItem] }];
|
||||
}
|
||||
|
||||
return sections.map((section, index) =>
|
||||
index === developerIndex
|
||||
? { ...section, items: [...section.items, mcpItem] }
|
||||
: section,
|
||||
);
|
||||
}
|
||||
|
||||
export function createSaasConfigNavSections(
|
||||
Overview: OverviewComponent,
|
||||
onLogoutClick: () => void,
|
||||
@@ -151,6 +190,7 @@ export function createSaasConfigNavSections(
|
||||
|
||||
sections = ensurePreferencesSection(sections);
|
||||
sections = appendDeveloperSection(sections);
|
||||
sections = appendMcpSection(sections, t);
|
||||
|
||||
if (!isAnonymous) {
|
||||
sections = appendBillingSection(sections, t);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types";
|
||||
|
||||
// SaaS adds an "overview" account section. All other keys (including ones
|
||||
// SaaS doesn't render today) come from core - subtracting them here would
|
||||
// just break the type union without affecting runtime nav.
|
||||
export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview"] as const;
|
||||
// SaaS adds an "overview" account section and an "mcp" integrations tab. All
|
||||
// other keys (including ones SaaS doesn't render today) come from core -
|
||||
// subtracting them here would just break the type union without affecting
|
||||
// runtime nav.
|
||||
export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview", "mcp"] as const;
|
||||
|
||||
export type NavKey = (typeof VALID_NAV_KEYS)[number];
|
||||
|
||||
@@ -7,17 +7,18 @@ import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
|
||||
import {
|
||||
absoluteWithBasePath,
|
||||
getBaseUrl,
|
||||
withBasePath,
|
||||
} from "@app/constants/app";
|
||||
import LinkRoundedIcon from "@mui/icons-material/LinkRounded";
|
||||
|
||||
// Import login components
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import MagicLinkForm from "@app/routes/login/MagicLinkForm";
|
||||
import OAuthButtons from "@app/routes/login/OAuthButtons";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import LoggedInState from "@app/routes/login/LoggedInState";
|
||||
import { absoluteWithBasePath, getBaseUrl } from "@app/constants/app";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
@@ -25,11 +26,13 @@ export default function Login() {
|
||||
const { t } = useTranslation();
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showMagicLink, setShowMagicLink] = useState(false);
|
||||
const [showMagicLinkForm, setShowMagicLinkForm] = useState(false);
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [magicLinkEmail, setMagicLinkEmail] = useState("");
|
||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||
|
||||
// Prefill email from query param (e.g. after password reset)
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -37,6 +40,7 @@ export default function Login() {
|
||||
const emailFromQuery = url.searchParams.get("email");
|
||||
if (emailFromQuery) {
|
||||
setEmail(emailFromQuery);
|
||||
setShowEmailForm(true);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
@@ -174,12 +178,9 @@ export default function Login() {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError(null);
|
||||
alert(t("login.magicLinkSent", { email: magicLinkEmail }));
|
||||
setMagicLinkEmail("");
|
||||
setShowMagicLink(false);
|
||||
setMagicLinkSent(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Login] Unexpected error:", err);
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
@@ -190,10 +191,6 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = () => {
|
||||
navigate("/auth/reset");
|
||||
};
|
||||
|
||||
const handleAnonymousSignIn = async () => {
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
@@ -227,115 +224,210 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEmailForm = () => {
|
||||
setShowEmailForm((v) => !v);
|
||||
setShowMagicLinkForm(false);
|
||||
setMagicLinkSent(false);
|
||||
};
|
||||
|
||||
const toggleMagicLink = () => {
|
||||
setShowMagicLinkForm((v) => !v);
|
||||
setShowEmailForm(false);
|
||||
setMagicLinkSent(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout isEmailFormExpanded={showEmailForm}>
|
||||
<LoginHeader
|
||||
title={t("login.login")}
|
||||
subtitle={t("login.subtitle", "Sign back in to Stirling PDF")}
|
||||
/>
|
||||
<AuthLayout isEmailFormExpanded={showEmailForm || showMagicLinkForm}>
|
||||
{/* Centered logo */}
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSigningIn}
|
||||
layout="fullwidth"
|
||||
/>
|
||||
{/* OAuth + magic link group — single flex column so gap is uniform */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginBottom: "2.5rem",
|
||||
}}
|
||||
>
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSigningIn}
|
||||
layout="fullwidth"
|
||||
labelPrefix={`${t("login.signInWith", "Sign in with")} `}
|
||||
/>
|
||||
|
||||
{/* Divider between OAuth and Email */}
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
{/* Magic link button + its expandable form as one unit */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSigningIn}
|
||||
onClick={toggleMagicLink}
|
||||
className={`oauth-button-fullwidth auth-expandable-trigger ${showMagicLinkForm ? "auth-expandable-trigger--active" : ""}`}
|
||||
>
|
||||
<span className="oauth-btn-group">
|
||||
<LinkRoundedIcon
|
||||
style={{
|
||||
width: "1.75rem",
|
||||
height: "1.75rem",
|
||||
marginRight: "0.5rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span className="oauth-btn-label">
|
||||
{t("login.useMagicLink", "Use magic link")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Sign in with email button (primary color to match signup CTA) */}
|
||||
<div className="auth-section">
|
||||
<div
|
||||
className={`auth-expand-grid ${showMagicLinkForm ? "auth-expand-grid--open" : ""}`}
|
||||
>
|
||||
<div className="auth-expand-inner">
|
||||
<div style={{ paddingTop: "0.25rem" }}>
|
||||
{magicLinkSent ? (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
color: "#059669",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{t("login.magicLinkSent", { email: magicLinkEmail })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="auth-magic-row">
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t(
|
||||
"login.enterEmailForMagicLink",
|
||||
"Enter your email",
|
||||
)}
|
||||
value={magicLinkEmail}
|
||||
onChange={(e) => setMagicLinkEmail(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
!isSigningIn &&
|
||||
signInWithMagicLink()
|
||||
}
|
||||
className="auth-input"
|
||||
/>
|
||||
<button
|
||||
onClick={signInWithMagicLink}
|
||||
disabled={isSigningIn || !magicLinkEmail}
|
||||
className="auth-magic-button"
|
||||
>
|
||||
{isSigningIn
|
||||
? t("login.sending")
|
||||
: t("login.sendMagicLink")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email & Password button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSigningIn}
|
||||
onClick={toggleEmailForm}
|
||||
className={`oauth-button-fullwidth auth-expandable-trigger ${showEmailForm ? "auth-expandable-trigger--active" : ""}`}
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
>
|
||||
<span className="oauth-btn-group">
|
||||
<span className="auth-at-icon">@</span>
|
||||
<span className="oauth-btn-label">{`${t("login.signInWith", "Sign in with")} email`}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Email form — animated expand */}
|
||||
<div
|
||||
className={`auth-expand-grid ${showEmailForm ? "auth-expand-grid--open" : ""}`}
|
||||
>
|
||||
<div className="auth-expand-inner">
|
||||
<div style={{ paddingBottom: "0.5rem" }}>
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSigningIn}
|
||||
submitButtonText={
|
||||
isSigningIn ? t("login.loggingIn") : t("login.login")
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/auth/reset")}
|
||||
className="auth-link-black"
|
||||
style={{ fontSize: "0.8125rem", marginTop: "0.25rem" }}
|
||||
>
|
||||
{t("login.forgotPassword", "Forgot your password?")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip */}
|
||||
<div style={{ textAlign: "center", margin: "1rem 0" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmailForm((v) => !v)}
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningIn}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 700,
|
||||
color: "#000000",
|
||||
}}
|
||||
>
|
||||
{t("login.useEmailInstead", "Sign in with email")}
|
||||
{isSigningIn
|
||||
? t("login.signingIn", "Signing in...")
|
||||
: `${t("signup.skip", "Skip")} →`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSigningIn}
|
||||
submitButtonText={
|
||||
isSigningIn ? t("login.loggingIn") : t("login.login")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEmailForm && (
|
||||
<div className="auth-section-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotPassword}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t("login.forgotPassword", "Forgot your password?")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider then Guest */}
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
|
||||
<GuestSignInButton
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningIn}
|
||||
label={
|
||||
isSigningIn
|
||||
? t("login.signingIn", "Signing in...")
|
||||
: t("login.signInAnonymously", "Sign in as a Guest")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="auth-bottom-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMagicLink(true)}
|
||||
className="auth-link-black"
|
||||
>
|
||||
{t("login.useMagicLink", "Sign in with magic link")}
|
||||
</button>
|
||||
|
||||
{/* Bottom */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: "auto",
|
||||
paddingTop: "1rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/signup")}
|
||||
className="auth-link-black"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
color: "#9ca3af",
|
||||
}}
|
||||
>
|
||||
{t("signup.signUp", "Sign up")}
|
||||
{t("login.createAccount", "Create an account")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Magic link form renders on demand */}
|
||||
{showMagicLink && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<MagicLinkForm
|
||||
showMagicLink={showMagicLink}
|
||||
magicLinkEmail={magicLinkEmail}
|
||||
setMagicLinkEmail={setMagicLinkEmail}
|
||||
setShowMagicLink={setShowMagicLink}
|
||||
onSubmit={signInWithMagicLink}
|
||||
isSubmitting={isSigningIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,15 @@ import { signInAnonymously } from "@app/auth/supabase";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import { getBaseUrl } from "@app/constants/app";
|
||||
import { getBaseUrl, withBasePath } from "@app/constants/app";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
// Import signup components
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import OAuthButtons from "@app/routes/login/OAuthButtons";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import SignupForm from "@app/routes/signup/SignupForm";
|
||||
import {
|
||||
useSignupFormValidation,
|
||||
@@ -184,84 +181,112 @@ export default function Signup() {
|
||||
|
||||
return (
|
||||
<AuthLayout isEmailFormExpanded={showEmailForm}>
|
||||
<LoginHeader title={t("signup.title")} subtitle={t("signup.subtitle")} />
|
||||
{/* Centered logo */}
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
{/* OAuth providers */}
|
||||
<div>
|
||||
<OAuthButtons
|
||||
onProviderClick={handleProviderSignIn}
|
||||
isSubmitting={isSigningUp}
|
||||
layout="fullwidth"
|
||||
labelPrefix={`${t("signup.signUpWith", "Sign up with")} `}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider between OAuth and Email */}
|
||||
<div style={{ margin: "0.5rem 0" }}>
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
{/* Email & Password button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSigningUp}
|
||||
onClick={() => setShowEmailForm((v) => !v)}
|
||||
className={`oauth-button-fullwidth auth-expandable-trigger ${showEmailForm ? "auth-expandable-trigger--active" : ""}`}
|
||||
style={{ marginTop: "2.5rem", marginBottom: "0.75rem" }}
|
||||
>
|
||||
<span className="oauth-btn-group">
|
||||
<span className="auth-at-icon">@</span>
|
||||
<span className="oauth-btn-label">{`${t("signup.signUpWith", "Sign up with")} email`}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Email form — animated expand */}
|
||||
<div
|
||||
className={`auth-expand-grid ${showEmailForm ? "auth-expand-grid--open" : ""}`}
|
||||
>
|
||||
<div className="auth-expand-inner">
|
||||
<div style={{ paddingBottom: "0.5rem" }}>
|
||||
<SignupForm
|
||||
name={name}
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
agree={agree}
|
||||
setName={setName}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
setAgree={setAgree}
|
||||
onSubmit={handleSignUp}
|
||||
isSubmitting={isSigningUp}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use Email Instead button (toggles email form) */}
|
||||
<div className="auth-section">
|
||||
{/* Skip */}
|
||||
<div style={{ textAlign: "center", margin: "1rem 0" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningUp}
|
||||
onClick={() => setShowEmailForm((v) => !v)}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 700,
|
||||
color: "#000000",
|
||||
}}
|
||||
>
|
||||
{t("signup.useEmailInstead", "Use Email Instead")}
|
||||
{isSigningUp
|
||||
? t("login.signingIn", "Signing in...")
|
||||
: `${t("signup.skip", "Skip")} →`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<SignupForm
|
||||
name={name}
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
agree={agree}
|
||||
setName={setName}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
setAgree={setAgree}
|
||||
onSubmit={handleSignUp}
|
||||
isSubmitting={isSigningUp}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="auth-section-sm">
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GuestSignInButton
|
||||
onClick={handleAnonymousSignIn}
|
||||
disabled={isSigningUp}
|
||||
label={
|
||||
isSigningUp
|
||||
? t("login.signingIn", "Signing in...")
|
||||
: t("login.signInAnonymously", "Sign in as a Guest")
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="auth-bottom-right">
|
||||
{/* Bottom */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: "auto",
|
||||
paddingTop: "1rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="auth-link-black"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
color: "#9ca3af",
|
||||
}}
|
||||
>
|
||||
{t("login.logIn", "Log In")}
|
||||
{t("signup.alreadyHaveAccount", "I already have an account")}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.625rem;
|
||||
border-radius: 100px;
|
||||
background-color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
@@ -22,6 +22,10 @@
|
||||
cursor: pointer;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth:disabled {
|
||||
@@ -29,6 +33,11 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.oauth-button-fullwidth:hover:not(:disabled) {
|
||||
background-color: #fafafa;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
@@ -72,3 +81,90 @@
|
||||
color: #9c2f30;
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
/* ── Logo block ─────────────────────────────────────────────────────── */
|
||||
.auth-logo-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 0.5rem;
|
||||
animation: authFadeUp 0.4s ease both;
|
||||
}
|
||||
|
||||
.auth-logo-header {
|
||||
height: 8rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.auth-logo-header--dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Page entrance animation ────────────────────────────────────────── */
|
||||
@keyframes authFadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expandable trigger button state ────────────────────────────────── */
|
||||
.auth-expandable-trigger {
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
border-color 180ms ease;
|
||||
}
|
||||
|
||||
.auth-expandable-trigger--active {
|
||||
border-color: #af3434 !important;
|
||||
box-shadow: 0 0 0 3px rgba(175, 52, 52, 0.12);
|
||||
}
|
||||
|
||||
/* ── Animated expand/collapse via grid-template-rows ───────────────── */
|
||||
.auth-expand-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition:
|
||||
grid-template-rows 280ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 220ms ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.auth-expand-grid--open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.auth-expand-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Icon+label group — keeps icons vertically aligned across buttons ── */
|
||||
.oauth-btn-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
|
||||
.oauth-btn-label {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Icon helpers inside oauth-button-fullwidth ─────────────────────── */
|
||||
.auth-at-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
margin-right: 0.5rem;
|
||||
display: inline-block;
|
||||
width: 1.75rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface OAuthButtonsProps {
|
||||
isSubmitting: boolean;
|
||||
layout?: "vertical" | "grid" | "icons" | "fullwidth";
|
||||
enabledProviders?: string[]; // List of enabled provider IDs from backend
|
||||
labelPrefix?: string;
|
||||
}
|
||||
|
||||
export default function OAuthButtons({
|
||||
@@ -24,6 +25,7 @@ export default function OAuthButtons({
|
||||
isSubmitting,
|
||||
layout = "vertical",
|
||||
enabledProviders: _enabledProviders = [],
|
||||
labelPrefix = "",
|
||||
}: OAuthButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -92,12 +94,18 @@ export default function OAuthButtons({
|
||||
className="oauth-button-fullwidth"
|
||||
title={p.label}
|
||||
>
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
/>
|
||||
{p.label}
|
||||
<span className="oauth-btn-group">
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
style={{ marginRight: "0.5rem", flexShrink: 0 }}
|
||||
/>
|
||||
<span className="oauth-btn-label">
|
||||
{labelPrefix}
|
||||
{p.label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,15 @@ vi.mock("@app/auth/supabase", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub apiClient's heavy UI/util deps so importing it stays cheap. None are
|
||||
// exercised here (toast, plan settings and handleHttpError paths aren't hit),
|
||||
// and re-importing the real toast graph per test made these tests time out.
|
||||
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
|
||||
vi.mock("@app/utils/appSettings", () => ({ openPlanSettings: vi.fn() }));
|
||||
vi.mock("@app/services/httpErrorHandler", () => ({
|
||||
handleHttpError: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe("apiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
--auth-label-text: #2b3230;
|
||||
--auth-button-bg: #af3434;
|
||||
--auth-button-text: #ffffff;
|
||||
--auth-magic-button-bg: #8b5cf6;
|
||||
--auth-magic-button-bg: #af3434;
|
||||
--auth-magic-button-text: #ffffff;
|
||||
|
||||
/* Light-only auth colors (no dark mode equivalents) used for login/signup */
|
||||
@@ -45,7 +45,7 @@
|
||||
--auth-label-text-light-only: #2b3230;
|
||||
--auth-button-bg-light-only: #af3434;
|
||||
--auth-button-text-light-only: #ffffff;
|
||||
--auth-magic-button-bg-light-only: #8b5cf6;
|
||||
--auth-magic-button-bg-light-only: #af3434;
|
||||
--auth-magic-button-text-light-only: #ffffff;
|
||||
--auth-bg-color-light-only: #ffffff;
|
||||
--auth-card-bg-light-only: #ffffff;
|
||||
|
||||
Reference in New Issue
Block a user