Fixes/tour and desktop improvements (#6320)
Added back the desktop file isDirty indicator <img width="414" height="385" alt="Screenshot 2026-05-07 at 1 36 19 PM" src="https://github.com/user-attachments/assets/d2d79c46-469d-4bb2-9540-e86183dcd372" /> Also added back the tours and added tests to ensure we don't delete parts of them in future by mistake <img width="2056" height="1069" alt="Screenshot 2026-05-07 at 1 36 31 PM" src="https://github.com/user-attachments/assets/987741a0-01aa-4a0e-9e06-584a52cc1e43" /> <img width="2056" height="1066" alt="Screenshot 2026-05-07 at 1 36 43 PM" src="https://github.com/user-attachments/assets/48ea369c-816c-485d-889a-6ecce71b1855" />
This commit is contained in:
@@ -1460,14 +1460,14 @@ inactive = "Inactive"
|
||||
|
||||
[adminOnboarding]
|
||||
adminTools = "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."
|
||||
configButton = "Click the <strong>Config</strong> button to access all system settings and administrative controls."
|
||||
configButton = "Open <strong>Settings</strong> to access all system configuration and administrative controls."
|
||||
connectionsSection = "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."
|
||||
databaseSection = "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."
|
||||
settingsOverview = "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."
|
||||
systemCustomization = "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."
|
||||
teamsAndUsers = "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."
|
||||
welcome = "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."
|
||||
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."
|
||||
wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||
|
||||
[adminUserSettings]
|
||||
actions = "Actions"
|
||||
@@ -4892,7 +4892,7 @@ toolInterface = "This is the <strong>Crop</strong> tool interface. As you can se
|
||||
viewer = "The <strong>Viewer</strong> lets you read and annotate your PDFs."
|
||||
viewSwitcher = "Use these controls to select how you want to view your PDFs."
|
||||
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
|
||||
wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."
|
||||
wrapUp = "You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
|
||||
|
||||
[onboarding.buttons]
|
||||
back = "Back"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
interface FileEditorStatusDotProps {
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
export function FileEditorStatusDot(_props: FileEditorStatusDotProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -196,6 +196,26 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.thumbBadgesRight {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
z-index: 3;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.ownershipBadge {
|
||||
background: var(--mantine-color-blue-6);
|
||||
color: white;
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
|
||||
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
@@ -44,6 +43,8 @@ import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot";
|
||||
|
||||
interface FileEditorThumbnailProps {
|
||||
file: StirlingFileStub;
|
||||
@@ -303,6 +304,7 @@ const FileEditorThumbnail = ({
|
||||
label: isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)"),
|
||||
dataTour: "file-card-pin",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (actualFile) {
|
||||
@@ -504,7 +506,7 @@ const FileEditorThumbnail = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Badges — visible on hover via CSS */}
|
||||
{/* Badges — top-left: version, pin, ownership, encrypted */}
|
||||
<div className={styles.thumbBadges}>
|
||||
<span className={styles.versionBadgeThumb}>
|
||||
v{file.versionNumber}
|
||||
@@ -541,6 +543,8 @@ const FileEditorThumbnail = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FileEditorStatusDot file={file} />
|
||||
</div>
|
||||
</div>
|
||||
{/* end thumbUnit */}
|
||||
|
||||
@@ -232,12 +232,15 @@ export default function Onboarding() {
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
pinFile: tourOrch.pinFile,
|
||||
revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu,
|
||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||
executeTool: tourOrch.executeTool,
|
||||
openFilesModal,
|
||||
openSettingsHelpSection: () =>
|
||||
adminTourOrch.navigateToSection("help"),
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal],
|
||||
[t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal],
|
||||
);
|
||||
|
||||
const whatsNewStepsConfig = useMemo(
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
addGlowToElements,
|
||||
removeAllGlows,
|
||||
} from "@app/components/onboarding/tourGlow";
|
||||
import {
|
||||
waitForElement,
|
||||
waitForHighlightable,
|
||||
} from "@app/components/onboarding/tourUtils";
|
||||
|
||||
export enum AdminTourStep {
|
||||
WELCOME,
|
||||
@@ -57,7 +61,7 @@ export function createAdminStepsConfig({
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t(
|
||||
"adminOnboarding.configButton",
|
||||
"Click the <strong>Config</strong> button to access all system settings and administrative controls.",
|
||||
"Open <strong>Settings</strong> to access all system configuration and administrative controls.",
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
@@ -204,15 +208,18 @@ export function createAdminStepsConfig({
|
||||
},
|
||||
},
|
||||
[AdminTourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
selector: '[data-tour="admin-help-nav"]',
|
||||
content: t(
|
||||
"adminOnboarding.wrapUp",
|
||||
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu.",
|
||||
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help.",
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: () => {
|
||||
action: async () => {
|
||||
removeAllGlows();
|
||||
navigateToSection("help");
|
||||
await waitForElement('[data-tour="admin-help-nav"]', 5000);
|
||||
await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Waits for a CSS selector to appear in the DOM using MutationObserver.
|
||||
* Resolves immediately if already present; resolves after timeoutMs if it
|
||||
* never appears (no throw — tour steps are best-effort).
|
||||
*/
|
||||
export function waitForElement(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof document === "undefined") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.querySelector(selector)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.querySelector(selector)) {
|
||||
clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
const nudgeReactour = () => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits for a CSS selector to be present AND have a non-zero bounding box,
|
||||
* then nudges Reactour to recalculate its spotlight position.
|
||||
*
|
||||
* Uses MutationObserver to detect element insertion, then ResizeObserver to
|
||||
* detect when the element receives layout dimensions.
|
||||
*/
|
||||
export function waitForHighlightable(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof document === "undefined") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let mutationObserver: MutationObserver | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
mutationObserver?.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
nudgeReactour();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = setTimeout(done, timeoutMs);
|
||||
|
||||
const watchLayout = (el: HTMLElement) => {
|
||||
if (el.getClientRects().length > 0) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentRect.width > 0 || entry.contentRect.height > 0) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(el);
|
||||
};
|
||||
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (el) {
|
||||
watchLayout(el);
|
||||
return;
|
||||
}
|
||||
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
const found = document.querySelector<HTMLElement>(selector);
|
||||
if (found) {
|
||||
mutationObserver!.disconnect();
|
||||
mutationObserver = null;
|
||||
watchLayout(found);
|
||||
}
|
||||
});
|
||||
|
||||
mutationObserver.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { StepType } from "@reactour/tour";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
waitForElement,
|
||||
waitForHighlightable,
|
||||
} from "@app/components/onboarding/tourUtils";
|
||||
|
||||
export enum TourStep {
|
||||
ALL_TOOLS,
|
||||
@@ -26,9 +30,11 @@ interface UserStepActions {
|
||||
loadSampleFile: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
pinFile: () => void;
|
||||
revealFileCardHoverMenu: () => void;
|
||||
modifyCropSettings: () => void;
|
||||
executeTool: () => void;
|
||||
openFilesModal: () => void;
|
||||
openSettingsHelpSection: () => void;
|
||||
}
|
||||
|
||||
interface CreateUserStepsConfigArgs {
|
||||
@@ -48,9 +54,11 @@ export function createUserStepsConfig({
|
||||
loadSampleFile,
|
||||
switchToActiveFiles,
|
||||
pinFile,
|
||||
revealFileCardHoverMenu,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
openFilesModal,
|
||||
openSettingsHelpSection,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
@@ -95,7 +103,7 @@ export function createUserStepsConfig({
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
actionAfter: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
@@ -105,6 +113,10 @@ export function createUserStepsConfig({
|
||||
),
|
||||
position: "right",
|
||||
padding: 0,
|
||||
action: async () => {
|
||||
await waitForElement('[data-tour="file-sources"]', 5000);
|
||||
await waitForHighlightable('[data-tour="file-sources"]', 5000);
|
||||
},
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
@@ -184,16 +196,22 @@ export function createUserStepsConfig({
|
||||
),
|
||||
position: "left",
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
action: () => revealFileCardHoverMenu(),
|
||||
actionAfter: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
selector: '[data-tour="admin-help-nav"]',
|
||||
content: t(
|
||||
"onboarding.wrapUp",
|
||||
"You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again.",
|
||||
"You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help.",
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
openSettingsHelpSection();
|
||||
await waitForElement('[data-tour="admin-help-nav"]', 5000);
|
||||
await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,52 +1,9 @@
|
||||
import type { StepType } from "@reactour/tour";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
async function waitForElement(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
intervalMs = 100,
|
||||
): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
const start = Date.now();
|
||||
// Immediate hit
|
||||
if (document.querySelector(selector)) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (document.querySelector(selector) || Date.now() - start >= timeoutMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, intervalMs);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHighlightable(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
intervalMs = 500,
|
||||
): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
const isVisible = !!el && el.getClientRects().length > 0;
|
||||
if (isVisible || Date.now() - start >= timeoutMs) {
|
||||
// Nudge Reactour to recalc positions in case layout shifted
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, intervalMs);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
import {
|
||||
waitForElement,
|
||||
waitForHighlightable,
|
||||
} from "@app/components/onboarding/tourUtils";
|
||||
|
||||
export enum WhatsNewTourStep {
|
||||
QUICK_ACCESS,
|
||||
@@ -124,17 +81,15 @@ export function createWhatsNewStepsConfig({
|
||||
),
|
||||
position: "right",
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
openFilesModal();
|
||||
await waitForElement('[data-tour="file-sources"]', 5000, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
openFilesModal();
|
||||
await waitForElement('[data-tour="file-sources"]', 5000);
|
||||
await Promise.resolve(loadSampleFile());
|
||||
closeFilesModal();
|
||||
switchToViewer();
|
||||
// wait for file render and top controls to mount
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.RIGHT_RAIL]: {
|
||||
@@ -150,7 +105,7 @@ export function createWhatsNewStepsConfig({
|
||||
position: "left",
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
|
||||
await waitForElement('[data-tour="right-rail-controls"]', 7000);
|
||||
selectFirstFile();
|
||||
},
|
||||
},
|
||||
@@ -165,8 +120,8 @@ export function createWhatsNewStepsConfig({
|
||||
// Ensure the switcher has mounted before this step renders
|
||||
action: async () => {
|
||||
switchToViewer();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||
@@ -179,8 +134,8 @@ export function createWhatsNewStepsConfig({
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToPageEditor();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
|
||||
@@ -193,8 +148,8 @@ export function createWhatsNewStepsConfig({
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToActiveFiles();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.WRAP_UP]: {
|
||||
|
||||
@@ -108,11 +108,26 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
const loginEnabled = config?.enableLogin ?? false;
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
|
||||
// Navigate back to home when closing modal
|
||||
navigate("/", { replace: true });
|
||||
onClose();
|
||||
}, [confirmIfDirty, navigate, onClose]);
|
||||
|
||||
// Synchronous wrapper for contexts (e.g. tour buttons) that need () => void
|
||||
const handleCloseSync = useCallback(() => {
|
||||
void handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
handleCloseSync,
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
@@ -131,15 +146,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
if (!canProceed) return;
|
||||
|
||||
// Navigate back to home when closing modal
|
||||
navigate("/", { replace: true });
|
||||
onClose();
|
||||
}, [confirmIfDirty, navigate, onClose]);
|
||||
|
||||
const handleNavigation = useCallback(
|
||||
async (key: NavKey) => {
|
||||
const canProceed = await confirmIfDirty();
|
||||
|
||||
@@ -316,6 +316,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
style={{ width, minWidth: width, maxWidth: width }}
|
||||
data-collapsed={collapsed}
|
||||
data-sidebar="file-sidebar"
|
||||
data-tour="quick-access-bar"
|
||||
>
|
||||
<div className="file-sidebar-inner">
|
||||
{/* Header: hamburger + branding */}
|
||||
@@ -392,6 +393,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
data-testid="files-button"
|
||||
data-tour="files-button"
|
||||
onClick={() => {
|
||||
if (collapsed && onToggleCollapse) onToggleCollapse();
|
||||
openFilesModal();
|
||||
@@ -555,6 +557,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
: undefined
|
||||
}
|
||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||
aria-label={
|
||||
onOpenSettings
|
||||
? t("fileSidebar.openSettings", "Open settings")
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface HoverAction {
|
||||
disabled?: boolean;
|
||||
color?: string;
|
||||
hidden?: boolean;
|
||||
dataTour?: string;
|
||||
}
|
||||
|
||||
interface HoverActionMenuProps {
|
||||
@@ -64,6 +65,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
style={{ color: action.color || "var(--right-rail-icon)" }}
|
||||
data-tour={action.dataTour}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -97,7 +97,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
radius={0}
|
||||
style={{
|
||||
background: background ?? toneStyle.background,
|
||||
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
border: "none",
|
||||
borderBottom:
|
||||
borderColor === "transparent"
|
||||
? "none"
|
||||
: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -34,6 +35,7 @@ export const useConfigNavSections = (
|
||||
_isAdmin: boolean = false,
|
||||
_runningEE: boolean = false,
|
||||
_loginEnabled: boolean = false,
|
||||
onRequestClose: () => void = () => {},
|
||||
): ConfigNavSection[] => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -55,6 +57,19 @@ export const useConfigNavSections = (
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("settings.help.title", "Help"),
|
||||
items: [
|
||||
{
|
||||
key: "help",
|
||||
label: t("settings.help.label", "Tours"),
|
||||
icon: "help-rounded",
|
||||
component: (
|
||||
<HelpSection isAdmin={_isAdmin} onRequestClose={onRequestClose} />
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { requestStartTour } from "@app/constants/events";
|
||||
|
||||
interface HelpSectionProps {
|
||||
isAdmin: boolean;
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
const HelpSection: React.FC<HelpSectionProps> = ({
|
||||
isAdmin,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const startTour = (tourType: "tools" | "admin") => {
|
||||
onRequestClose();
|
||||
setTimeout(() => requestStartTour(tourType), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("settings.help.toolsTour.title", "Tools Tour")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.help.toolsTour.description",
|
||||
"Walk through uploading files, picking a tool, and reviewing results.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="build-outline-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => startTour("tools")}
|
||||
>
|
||||
{t("settings.help.toolsTour.start", "Start")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isAdmin && (
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("settings.help.adminTour.title", "Admin Tour")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"settings.help.adminTour.description",
|
||||
"Explore team management, system settings, and enterprise features.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon icon="person-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
onClick={() => startTour("admin")}
|
||||
>
|
||||
{t("settings.help.adminTour.start", "Start")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpSection;
|
||||
@@ -30,6 +30,7 @@ export const VALID_NAV_KEYS = [
|
||||
"adminUsage",
|
||||
"adminEndpoints",
|
||||
"adminStorageSharing",
|
||||
"help",
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -30,6 +30,7 @@ interface TourOrchestrationContextType {
|
||||
// File operations
|
||||
selectFirstFile: () => void;
|
||||
pinFile: () => void;
|
||||
revealFileCardHoverMenu: () => void;
|
||||
|
||||
// Crop settings (placeholder for now)
|
||||
modifyCropSettings: () => void;
|
||||
@@ -192,6 +193,20 @@ export const TourOrchestrationProvider: React.FC<{
|
||||
window.dispatchEvent(event);
|
||||
}, []);
|
||||
|
||||
const revealFileCardHoverMenu = useCallback(() => {
|
||||
// The hover menu is hidden via CSS until the card is hovered.
|
||||
// Override data-force-visible so the CSS rule makes it opacity:1 / pointer-events:auto,
|
||||
// then nudge Reactour to re-measure the now-visible pin button.
|
||||
const menu = document.querySelector(
|
||||
'[data-hover-action-menu-mode="cssHover"]',
|
||||
) as HTMLElement | null;
|
||||
if (menu) {
|
||||
menu.setAttribute("data-force-visible", "true");
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const executeTool = useCallback(() => {
|
||||
// Trigger the run button click
|
||||
const runButton = document.querySelector(
|
||||
@@ -213,6 +228,7 @@ export const TourOrchestrationProvider: React.FC<{
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
revealFileCardHoverMenu,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useNavigationActions,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
@@ -57,6 +58,14 @@ export default function HomePage() {
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Open the config modal whenever the URL is /settings/* (e.g. from the admin
|
||||
// tour's openConfigModal action which navigates to /settings/overview).
|
||||
useEffect(() => {
|
||||
const isSettings = location.pathname.startsWith("/settings");
|
||||
setConfigModalOpen(isSettings);
|
||||
}, [location.pathname]);
|
||||
|
||||
const { activeFiles } = useFileContext();
|
||||
const navigationState = useNavigationState();
|
||||
|
||||
@@ -118,6 +118,8 @@ const DEFAULT_ENDPOINTS_AVAILABILITY = Object.fromEntries(
|
||||
export interface MockAppApiOptions {
|
||||
/** Override `enableLogin`. Default `false` — app loads in anonymous mode. */
|
||||
enableLogin?: boolean;
|
||||
/** Override `isAdmin` in app-config. Default `false`. */
|
||||
isAdmin?: boolean;
|
||||
/** Override the logged-in user returned by `/auth/me`. */
|
||||
user?: {
|
||||
id?: number;
|
||||
@@ -145,6 +147,7 @@ export async function mockAppApis(
|
||||
): Promise<void> {
|
||||
const {
|
||||
enableLogin = false,
|
||||
isAdmin = false,
|
||||
user = {
|
||||
id: 1,
|
||||
username: "testuser",
|
||||
@@ -167,6 +170,7 @@ export async function mockAppApis(
|
||||
route.fulfill({
|
||||
json: {
|
||||
enableLogin,
|
||||
isAdmin,
|
||||
languages,
|
||||
defaultLocale,
|
||||
},
|
||||
|
||||
@@ -1,73 +1,232 @@
|
||||
import path from "path";
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { uploadFiles, openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
test.describe("15. Tour/Onboarding", () => {
|
||||
test.describe("15.1 Tour Button", () => {
|
||||
test("should start and dismiss tour guide", async ({ page }) => {
|
||||
// The help/tours button was in the old QuickAccessBar which is not rendered
|
||||
// in the redesigned UI (replaced by FileSidebar). Skip until a tour entry
|
||||
// point is added to the new layout.
|
||||
test.skip(
|
||||
true,
|
||||
"Help/tour button not yet present in redesigned FileSidebar UI",
|
||||
);
|
||||
/**
|
||||
* Tour selector integrity tests.
|
||||
*
|
||||
* Each test asserts that a `data-tour="…"` element referenced by one of the
|
||||
* three guided tours (user, admin, whats-new) is actually present in the DOM
|
||||
* at the point in the UI where the tour step would fire. If an element is
|
||||
* renamed or removed the test fails immediately, surfacing the breakage before
|
||||
* it silently breaks the tour for real users.
|
||||
*
|
||||
* Selectors under test come from:
|
||||
* - userStepsConfig.ts
|
||||
* - adminStepsConfig.ts
|
||||
* - whatsNewStepsConfig.ts
|
||||
*/
|
||||
|
||||
// Step 1: Click the Tours button (identified by data-tour="help-button")
|
||||
const tourButton = page.locator('[data-tour="help-button"]').first();
|
||||
await expect(tourButton).toBeVisible({ timeout: 5000 });
|
||||
await tourButton.click();
|
||||
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
|
||||
|
||||
// Step 2: Verify the tours menu opens with tour options
|
||||
const toursMenu = page.locator(".mantine-Menu-dropdown").first();
|
||||
await expect(toursMenu).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Step 3: Click on the "See what's new in V2" tour option to start a tour
|
||||
const whatsNewOption = page.getByText(/what.?s new|See what/i).first();
|
||||
await expect(whatsNewOption).toBeVisible({ timeout: 5000 });
|
||||
await whatsNewOption.click();
|
||||
|
||||
// Step 4: Verify the reactour popover appears (tour has started)
|
||||
const tourPopover = page.locator(".reactour__popover").first();
|
||||
await expect(tourPopover).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Step 5: Dismiss the tour by clicking the close button inside the popover
|
||||
const closeButton = tourPopover.locator("button").first();
|
||||
if (await closeButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await closeButton.click();
|
||||
} else {
|
||||
// Fallback: press Escape to close the tour
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
// Step 6: Verify the tour popover disappears
|
||||
await expect(tourPopover).not.toBeVisible({ timeout: 5000 });
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.1 Static layout — always visible on the main page
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.1 Tour selectors — static layout", () => {
|
||||
test("tool-panel is present", async ({ page }) => {
|
||||
await expect(page.locator('[data-tour="tool-panel"]').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("15.2 Tour Tooltip", () => {
|
||||
test("should show and dismiss tour tooltip", async ({ page }) => {
|
||||
// Step 1: Check if a tours tooltip with a close button appears near the help button
|
||||
// The tooltip uses the custom Tooltip component with showCloseButton enabled
|
||||
const tooltipContent = page
|
||||
.locator('[data-radix-popper-content-wrapper], [role="tooltip"]')
|
||||
.first();
|
||||
test("quick-access-bar (FileSidebar) is present", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="quick-access-bar"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// Step 2: If a tooltip with a close button is visible, dismiss it
|
||||
const closeButton = tooltipContent.locator("button").first();
|
||||
if (await closeButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await closeButton.click();
|
||||
test("files-button is present", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="files-button"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// Step 3: Verify the tooltip content disappears
|
||||
await expect(tooltipContent).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
test("config-button is present", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="config-button"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// Step 4: Refresh the page and verify it loads normally
|
||||
await page.reload();
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
test("tool-button-crop is present in tool panel", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="tool-button-crop"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// Step 5: Verify the page loaded (search box or navigation visible)
|
||||
await expect(page.getByPlaceholder(/search|cari/i).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
// help-button: not yet implemented in the redesigned FileSidebar layout.
|
||||
// Re-enable once a tours/help entry point is added to the new UI.
|
||||
test.skip("help-button is present — TODO: add to new sidebar layout", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.locator('[data-tour="help-button"]').first()).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.2 Tour selectors — files modal
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.2 Tour selectors — files modal", () => {
|
||||
test("file-sources is present when files modal is open", async ({ page }) => {
|
||||
await page.getByTestId("files-button").click();
|
||||
await expect(
|
||||
page.locator('[data-tour="file-sources"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.3 Tour selectors — workbench elements (require a loaded file)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.3 Tour selectors — workbench with file", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
});
|
||||
|
||||
test("workbench is present", async ({ page }) => {
|
||||
await expect(page.locator('[data-tour="workbench"]').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("view-switcher is present in viewer mode", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="view-switcher"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("right-rail-controls is present", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="right-rail-controls"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("right-rail-settings is present", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="right-rail-settings"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.4 Tour selectors — active files view (file-card-checkbox, file-card-pin)
|
||||
// Two files → app auto-navigates to fileEditor (active files) mode.
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.4 Tour selectors — active files view", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Two files → getStartupNavigationAction returns workbench:"fileEditor"
|
||||
await uploadFiles(page, [SAMPLE_PDF, SAMPLE_PDF]);
|
||||
});
|
||||
|
||||
test("file-card-checkbox is present in active files view", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="file-card-checkbox"]').first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("file-card-pin is in DOM when file cards are rendered", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The pin button lives inside HoverActionMenu (CSS-hover driven, always
|
||||
// attached to DOM). Hover over the first file card to ensure the element
|
||||
// is rendered, then assert it is attached.
|
||||
const fileCard = page.locator('[data-tour="file-card-checkbox"]').first();
|
||||
await expect(fileCard).toBeVisible({ timeout: 15_000 });
|
||||
await fileCard.hover();
|
||||
await expect(
|
||||
page.locator('[data-tour="file-card-pin"]').first(),
|
||||
).toBeAttached({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.5 Tour selectors — crop tool (crop-settings, run-button)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.5 Tour selectors — crop tool", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/crop", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
});
|
||||
|
||||
test("crop-settings is present", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="crop-settings"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("run-button is present", async ({ page }) => {
|
||||
await expect(page.locator('[data-tour="run-button"]').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.6 Tour selectors — config modal (non-admin)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.6 Tour selectors — config modal", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openSettings(page);
|
||||
});
|
||||
|
||||
test("modal-nav is present", async ({ page }) => {
|
||||
await expect(page.locator(".modal-nav").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("settings-content-area is present", async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('[data-tour="settings-content-area"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.7 Tour selectors — admin config modal nav items
|
||||
// Requires isAdmin:true in app-config so the proprietary admin sections render.
|
||||
// These nav items are EE-only; the test is skipped in core-only builds where
|
||||
// the sections are not registered.
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.7 Tour selectors — admin modal nav items", () => {
|
||||
test.use({
|
||||
stubOptions: { enableLogin: true, isAdmin: true },
|
||||
});
|
||||
|
||||
const adminNavSections = [
|
||||
"people",
|
||||
"teams",
|
||||
"adminGeneral",
|
||||
"adminFeatures",
|
||||
"adminEndpoints",
|
||||
"adminDatabase",
|
||||
"adminConnections",
|
||||
"adminAudit",
|
||||
"adminUsage",
|
||||
"help",
|
||||
] as const;
|
||||
|
||||
for (const section of adminNavSections) {
|
||||
test(`admin-${section}-nav is present`, async ({ page }) => {
|
||||
await openSettings(page);
|
||||
const navItem = page
|
||||
.locator(`[data-tour="admin-${section}-nav"]`)
|
||||
.first();
|
||||
const isPresent = await navItem
|
||||
.isVisible({ timeout: 5_000 })
|
||||
.catch(() => false);
|
||||
if (!isPresent) {
|
||||
test.skip(
|
||||
true,
|
||||
`admin-${section}-nav not rendered — section may be EE-only or not yet ported to this build`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await expect(navItem).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React from "react";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
@@ -14,57 +12,8 @@ const FileEditorFileName = ({
|
||||
file,
|
||||
maxLength = 40,
|
||||
}: FileEditorFileNameProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrivateContent>{truncateCenter(file.name, maxLength)}</PrivateContent>
|
||||
{!file.localFilePath && (
|
||||
<Tooltip label={t("fileNotSavedToDisk", "Not saved to disk")}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--mantine-color-red-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label={t("fileNotSavedToDisk", "Not saved to disk")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{file.localFilePath && file.isDirty && (
|
||||
<Tooltip label={t("unsavedChanges", "Unsaved changes")}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--mantine-color-yellow-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label={t("unsavedChanges", "Unsaved changes")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{file.localFilePath && !file.isDirty && (
|
||||
<Tooltip label={t("fileSavedToDisk", "File saved to disk")}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--mantine-color-green-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label={t("fileSavedToDisk", "File saved to disk")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
<PrivateContent>{truncateCenter(file.name, maxLength)}</PrivateContent>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
|
||||
|
||||
interface FileEditorStatusDotProps {
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
export function FileEditorStatusDot({ file }: FileEditorStatusDotProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const label = !file.localFilePath
|
||||
? t("fileNotSavedToDisk", "Not saved to disk")
|
||||
: file.isDirty
|
||||
? t("unsavedChanges", "Unsaved changes")
|
||||
: t("fileSavedToDisk", "Saved to disk");
|
||||
|
||||
const color = !file.localFilePath
|
||||
? "var(--mantine-color-red-6)"
|
||||
: file.isDirty
|
||||
? "var(--mantine-color-yellow-6)"
|
||||
: "var(--mantine-color-green-6)";
|
||||
|
||||
return (
|
||||
<div className={styles.thumbBadgesRight}>
|
||||
<Tooltip label={label}>
|
||||
<span
|
||||
className={styles.statusDot}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={label}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,11 +31,17 @@ export const useConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
loginEnabled: boolean = false,
|
||||
onRequestClose: () => void = () => {},
|
||||
): ConfigNavSection[] => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get the core sections (just Preferences)
|
||||
const sections = useCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
|
||||
// Get the core sections (Preferences + Help)
|
||||
const sections = useCoreConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
onRequestClose,
|
||||
);
|
||||
|
||||
// Add account management under Preferences
|
||||
const preferencesSection = sections.find((section) =>
|
||||
|
||||
Reference in New Issue
Block a user