v1.0.7 (fixes #17)
This commit is contained in:
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [1.0.7] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `Ctrl+K` / `Cmd+K` command palette for global search (credits @LinkPhoenix).
|
||||||
|
- Calendar date picker for date and time fields (credits @LinkPhoenix).
|
||||||
|
- Dynamic document titles per page (credits @LinkPhoenix).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Code-split the admin shell and heavy feature pages to speed up the initial load (credits @LinkPhoenix).
|
||||||
|
- Center forms horizontally on wide screens (credits @LinkPhoenix).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Redirect URLs without a view to the first accessible page of their section (credits @LinkPhoenix).
|
||||||
|
- Sidebar groups auto-open and scroll the active item into view after navigation (credits @LinkPhoenix).
|
||||||
|
- Keep the sidebar section synced with the URL on full page loads (credits @LinkPhoenix).
|
||||||
|
- Date and time fields no longer shift values by the UTC offset when editing (credits @LinkPhoenix).
|
||||||
|
- Clip the table header background inside the rounded card border (credits @LinkPhoenix).
|
||||||
|
- Keep the selected account across page reloads (#17).
|
||||||
|
- Refresh open views when switching accounts (#17).
|
||||||
|
- Custom logos no longer flash the default logo while loading.
|
||||||
|
|
||||||
## [1.0.6] - 2026-07-28
|
## [1.0.6] - 2026-07-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+673
-298
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stalwart-webui",
|
"name": "stalwart-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"description": "Stalwart WebUI",
|
"description": "Stalwart WebUI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"format:check": "prettier --check src/"
|
"format:check": "prettier --check src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@daypicker/react": "^10.0.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-checkbox": "^1.3.11",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.20",
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
@@ -35,14 +36,14 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.28.0",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.2",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.1.5",
|
"vite": "^8.2.0",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,14 +4,18 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={<LoadingFallback fullScreen />}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||||
|
import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch';
|
||||||
|
import type { SearchIndexEntry } from '@/stores/schemaStore';
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||||
|
const { query, setQuery, debouncedQuery, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette);
|
||||||
|
|
||||||
|
const groupLabels: Record<SearchIndexEntry['type'], string> = useMemo(
|
||||||
|
() => ({
|
||||||
|
link: t('globalSearch.pages', 'Pages'),
|
||||||
|
form: t('globalSearch.formSections', 'Form Sections'),
|
||||||
|
field: t('globalSearch.fields', 'Fields'),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) reset();
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="top-[15%] translate-y-0 overflow-hidden p-0" showCloseButton={false}>
|
||||||
|
<DialogTitle className="sr-only">{t('globalSearch.title', 'Search')}</DialogTitle>
|
||||||
|
<Command
|
||||||
|
shouldFilter={false}
|
||||||
|
loop
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
trailing={
|
||||||
|
<kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{debouncedQuery.trim()
|
||||||
|
? t('globalSearch.noResults', 'No results found.')
|
||||||
|
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
|
||||||
|
</CommandEmpty>
|
||||||
|
{Array.from(groups.entries()).map(([type, entries]) => (
|
||||||
|
<CommandGroup key={type} heading={groupLabels[type]}>
|
||||||
|
{entries.map((entry, idx) => {
|
||||||
|
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
||||||
|
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
||||||
|
const itemValue = `${type}-${idx}-${entry.viewName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem key={itemValue} value={itemValue} onSelect={() => selectEntry(entry)}>
|
||||||
|
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{actionLabel}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Search, List, Settings, Plus } from 'lucide-react';
|
|
||||||
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
|
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
|
||||||
import { resolveObject } from '@/lib/schemaResolver';
|
|
||||||
import type { Schema } from '@/types/schema';
|
|
||||||
|
|
||||||
const MAX_RESULTS = 15;
|
|
||||||
|
|
||||||
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
|
|
||||||
link: 0,
|
|
||||||
form: 1,
|
|
||||||
field: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getObjectKind(schema: Schema, viewName: string): 'singleton' | 'object' | null {
|
|
||||||
const resolved = resolveObject(schema, viewName);
|
|
||||||
if (!resolved) return null;
|
|
||||||
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActionInfo(
|
|
||||||
entryType: SearchIndexEntry['type'],
|
|
||||||
objectKind: 'singleton' | 'object' | null,
|
|
||||||
t: (key: string, fallback: string) => string,
|
|
||||||
): { label: string; Icon: typeof List } {
|
|
||||||
if (entryType === 'link') {
|
|
||||||
return objectKind === 'singleton'
|
|
||||||
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
|
||||||
: { label: t('globalSearch.list', 'List'), Icon: List };
|
|
||||||
}
|
|
||||||
return objectKind === 'singleton'
|
|
||||||
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
|
||||||
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNavigationPath(
|
|
||||||
entryType: SearchIndexEntry['type'],
|
|
||||||
objectKind: 'singleton' | 'object' | null,
|
|
||||||
section: string,
|
|
||||||
viewName: string,
|
|
||||||
): string {
|
|
||||||
const encodedView = viewName;
|
|
||||||
if (entryType === 'link') {
|
|
||||||
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}`;
|
|
||||||
}
|
|
||||||
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}/new`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function friendlyName(viewName: string): string {
|
|
||||||
const stripped = viewName.replace(/^x:/, '');
|
|
||||||
const parts = stripped.split('/');
|
|
||||||
return parts[parts.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GlobalSearchProps {
|
|
||||||
onAfterSelect?: () => void;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GlobalSearch({ onAfterSelect, autoFocus }: GlobalSearchProps = {}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
|
|
||||||
link: t('globalSearch.pages', 'Pages'),
|
|
||||||
form: t('globalSearch.formSections', 'Form Sections'),
|
|
||||||
field: t('globalSearch.fields', 'Fields'),
|
|
||||||
};
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
|
||||||
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
|
||||||
|
|
||||||
const handleQueryChange = useCallback((value: string) => {
|
|
||||||
setQuery(value);
|
|
||||||
setActiveIndex(-1);
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => setDebouncedQuery(value), 300);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const results = useMemo(() => {
|
|
||||||
if (!debouncedQuery.trim() || !schema) return [];
|
|
||||||
|
|
||||||
const tokens = debouncedQuery
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((s) => s.length > 0);
|
|
||||||
if (tokens.length === 0) return [];
|
|
||||||
|
|
||||||
const filtered = searchIndex.filter((entry) => {
|
|
||||||
const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase();
|
|
||||||
for (const token of tokens) {
|
|
||||||
if (!haystack.includes(token)) return false;
|
|
||||||
}
|
|
||||||
const resolved = resolveObject(schema, entry.viewName);
|
|
||||||
if (!resolved) return false;
|
|
||||||
return hasObjectPermission(resolved.permissionPrefix, 'Get');
|
|
||||||
});
|
|
||||||
|
|
||||||
filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
|
||||||
return filtered.slice(0, MAX_RESULTS);
|
|
||||||
}, [debouncedQuery, searchIndex, schema, hasObjectPermission]);
|
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
|
||||||
const map = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
|
|
||||||
for (const entry of results) {
|
|
||||||
const arr = map.get(entry.type);
|
|
||||||
if (arr) arr.push(entry);
|
|
||||||
else map.set(entry.type, [entry]);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [results]);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(entry: SearchIndexEntry) => {
|
|
||||||
if (!schema) return;
|
|
||||||
const objectKind = getObjectKind(schema, entry.viewName);
|
|
||||||
const path = getNavigationPath(entry.type, objectKind, entry.section, entry.viewName);
|
|
||||||
setDropdownOpen(false);
|
|
||||||
setQuery('');
|
|
||||||
setDebouncedQuery('');
|
|
||||||
navigate(path);
|
|
||||||
onAfterSelect?.();
|
|
||||||
},
|
|
||||||
[schema, navigate, onAfterSelect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (!dropdownOpen || results.length === 0) return;
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveIndex((i) => (i + 1) % results.length);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveIndex((i) => (i - 1 + results.length) % results.length);
|
|
||||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(results[activeIndex]);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dropdownOpen, results, activeIndex, handleSelect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full max-w-md" ref={containerRef}>
|
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
onChange={(e) => {
|
|
||||||
handleQueryChange(e.target.value);
|
|
||||||
setDropdownOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (query.trim()) setDropdownOpen(true);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showDropdown && (
|
|
||||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
|
||||||
{t('globalSearch.noResults', 'No results found.')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-80 overflow-y-auto py-1">
|
|
||||||
{Array.from(groups.entries()).map(([type, entries]) => (
|
|
||||||
<div key={type}>
|
|
||||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{GROUP_LABELS[type]}</div>
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const flatIdx = results.indexOf(entry);
|
|
||||||
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
|
||||||
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${type}-${entry.viewName}-${flatIdx}`}
|
|
||||||
type="button"
|
|
||||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent ${
|
|
||||||
flatIdx === activeIndex ? 'bg-accent' : ''
|
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(entry);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setActiveIndex(flatIdx)}
|
|
||||||
>
|
|
||||||
<ActionIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{actionLabel}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center justify-center gap-3', fullScreen ? 'min-h-screen' : 'p-8')}>
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,5 +77,9 @@ export default function Logo() {
|
|||||||
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!failed) {
|
||||||
|
return <div className="h-7" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
return <DefaultLogo />;
|
return <DefaultLogo />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -742,7 +742,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
|
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, type KeyboardEvent } from 'react';
|
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
@@ -20,8 +20,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
|
||||||
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight } from 'lucide-react';
|
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
DURATION_UNITS,
|
DURATION_UNITS,
|
||||||
} from '@/lib/durationFormat';
|
} from '@/lib/durationFormat';
|
||||||
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
|
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
|
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
|
||||||
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
|
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
|
||||||
@@ -831,60 +833,69 @@ interface DateTimeFieldProps {
|
|||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DATE_TIME_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
|
||||||
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const strValue = typeof value === 'string' ? value : '';
|
const strValue = typeof value === 'string' ? value : '';
|
||||||
|
|
||||||
const toLocal = (iso: string): string => {
|
const selected = useMemo(() => {
|
||||||
if (!iso) return '';
|
const d = strValue ? new Date(strValue) : null;
|
||||||
try {
|
return d && !isNaN(d.getTime()) ? d : null;
|
||||||
const d = new Date(iso);
|
}, [strValue]);
|
||||||
if (isNaN(d.getTime())) return '';
|
|
||||||
return d.toISOString().slice(0, 16);
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toIso = (local: string): string | null => {
|
const localTime = selected ? selected.toTimeString().slice(0, 5) : '';
|
||||||
if (!local) return nullable ? null : '';
|
|
||||||
return new Date(local).toISOString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const [local, setLocal] = useBufferedValue(strValue, toLocal);
|
const commit = (day: Date, time: string) => {
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
const commit = () => {
|
const next = new Date(day);
|
||||||
const iso = toIso(local);
|
next.setHours(hours || 0, minutes || 0, 0, 0);
|
||||||
if (iso !== strValue) onChange(iso);
|
onChange(next.toISOString());
|
||||||
};
|
};
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
if (!strValue) {
|
if (!strValue) {
|
||||||
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
||||||
}
|
}
|
||||||
let formatted = strValue;
|
return <span className="text-sm">{selected ? DATE_TIME_FORMAT.format(selected) : strValue}</span>;
|
||||||
try {
|
|
||||||
const d = new Date(strValue);
|
|
||||||
if (!isNaN(d.getTime())) {
|
|
||||||
formatted = new Intl.DateTimeFormat(undefined, {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(d);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line no-empty
|
|
||||||
} catch {}
|
|
||||||
return <span className="text-sm">{formatted}</span>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Popover>
|
||||||
type="datetime-local"
|
<PopoverTrigger asChild>
|
||||||
value={local}
|
<Button
|
||||||
onChange={(e) => setLocal(e.target.value)}
|
type="button"
|
||||||
onBlur={commit}
|
variant="outline"
|
||||||
className="flex-1"
|
className={cn('flex-1 justify-start text-left font-normal', !selected && 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{selected ? DATE_TIME_FORMAT.format(selected) : t('field.pickDate', 'Pick a date')}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selected ?? undefined}
|
||||||
|
defaultMonth={selected ?? undefined}
|
||||||
|
autoFocus
|
||||||
|
onSelect={(day) => {
|
||||||
|
if (day) commit(day, localTime || '00:00');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="border-t p-3">
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={localTime}
|
||||||
|
disabled={!selected}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (selected && e.target.value) commit(selected, e.target.value);
|
||||||
|
}}
|
||||||
|
aria-label={t('field.time', 'Time')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
{nullable && strValue && (
|
{nullable && strValue && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { lazy, Suspense, useEffect, type ComponentType, type ReactNode } from 'react';
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
import { useCacheStore } from '@/stores/cacheStore';
|
import { useCacheStore } from '@/stores/cacheStore';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
@@ -12,11 +12,33 @@ import { resolveObject } from '@/lib/schemaResolver';
|
|||||||
import { DynamicList } from '@/components/lists/DynamicList';
|
import { DynamicList } from '@/components/lists/DynamicList';
|
||||||
import { DynamicForm } from '@/components/forms/DynamicForm';
|
import { DynamicForm } from '@/components/forms/DynamicForm';
|
||||||
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
||||||
import { DashboardView } from '@/features/dashboard/components/DashboardView';
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
|
import type { Schema } from '@/types/schema';
|
||||||
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
|
|
||||||
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
|
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
|
||||||
import { ActionPage } from '@/features/actions/ActionPage';
|
return lazy(() => load().then((module) => ({ default: select(module) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardView = lazyFeature(
|
||||||
|
() => import('@/features/dashboard/components/DashboardView'),
|
||||||
|
(m) => m.DashboardView,
|
||||||
|
);
|
||||||
|
const DeliveryTracePage = lazyFeature(
|
||||||
|
() => import('@/features/troubleshoot/DeliveryTracePage'),
|
||||||
|
(m) => m.DeliveryTracePage,
|
||||||
|
);
|
||||||
|
const LiveTracingPage = lazyFeature(
|
||||||
|
() => import('@/features/tracing/components/LiveTracingPage'),
|
||||||
|
(m) => m.LiveTracingPage,
|
||||||
|
);
|
||||||
|
const TraceDetailView = lazyFeature(
|
||||||
|
() => import('@/features/tracing/components/TraceDetailView'),
|
||||||
|
(m) => m.TraceDetailView,
|
||||||
|
);
|
||||||
|
const ActionPage = lazyFeature(
|
||||||
|
() => import('@/features/actions/ActionPage'),
|
||||||
|
(m) => m.ActionPage,
|
||||||
|
);
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
viewName?: string;
|
viewName?: string;
|
||||||
@@ -32,6 +54,10 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
|
|||||||
invalidateAllObjectLists();
|
invalidateAllObjectLists();
|
||||||
}, [viewName, invalidateAllObjectLists]);
|
}, [viewName, invalidateAllObjectLists]);
|
||||||
|
|
||||||
|
return <Suspense fallback={<LoadingFallback />}>{renderView(schema, viewName, id, section)}</Suspense>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderView(schema: Schema | null, viewName?: string, id?: string, section?: string): ReactNode {
|
||||||
if (!viewName) {
|
if (!viewName) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
|
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
|
||||||
@@ -84,7 +110,7 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
if (resolved.objectName === 'x:Trace' && id !== 'new') {
|
if (resolved.objectName === 'x:Trace') {
|
||||||
return <TraceDetailView viewName={viewName} objectId={id} />;
|
return <TraceDetailView viewName={viewName} objectId={id} />;
|
||||||
}
|
}
|
||||||
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
const { ChevronDown, Lock } = LucideIcons;
|
const { ChevronDown, Lock } = LucideIcons;
|
||||||
@@ -74,6 +74,25 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutoOpenCollapsibleProps {
|
||||||
|
containsActive: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) {
|
||||||
|
const [open, setOpen] = useState(containsActive);
|
||||||
|
const [prevContainsActive, setPrevContainsActive] = useState(containsActive);
|
||||||
|
if (containsActive !== prevContainsActive) {
|
||||||
|
setPrevContainsActive(containsActive);
|
||||||
|
if (containsActive) setOpen(true);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
{children}
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function checkLinkVisible(viewName: string): boolean {
|
function checkLinkVisible(viewName: string): boolean {
|
||||||
const schema = useSchemaStore.getState().schema;
|
const schema = useSchemaStore.getState().schema;
|
||||||
if (!schema) return true;
|
if (!schema) return true;
|
||||||
@@ -95,6 +114,8 @@ function checkIsEnterprise(viewName: string): boolean {
|
|||||||
return isLinkEnterprise(schema, viewName, edition);
|
return isLinkEnterprise(schema, viewName, edition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ActiveItemRef = (el: HTMLButtonElement | null) => void;
|
||||||
|
|
||||||
interface SidebarSubItemProps {
|
interface SidebarSubItemProps {
|
||||||
item: LayoutSubItem;
|
item: LayoutSubItem;
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -103,9 +124,19 @@ interface SidebarSubItemProps {
|
|||||||
navigate: ReturnType<typeof useNavigate>;
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
edition: string;
|
edition: string;
|
||||||
onUpsell: () => void;
|
onUpsell: () => void;
|
||||||
|
activeItemRef: ActiveItemRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
|
function SidebarSubItem({
|
||||||
|
item,
|
||||||
|
depth,
|
||||||
|
sectionName,
|
||||||
|
currentPath,
|
||||||
|
navigate,
|
||||||
|
edition,
|
||||||
|
onUpsell,
|
||||||
|
activeItemRef,
|
||||||
|
}: SidebarSubItemProps) {
|
||||||
if (item.type === 'link') {
|
if (item.type === 'link') {
|
||||||
if (!checkLinkVisible(item.viewName)) return null;
|
if (!checkLinkVisible(item.viewName)) return null;
|
||||||
|
|
||||||
@@ -120,6 +151,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
ref={isActive ? activeItemRef : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start gap-2 font-normal',
|
'w-full justify-start gap-2 font-normal',
|
||||||
isActive && 'bg-accent text-accent-foreground',
|
isActive && 'bg-accent text-accent-foreground',
|
||||||
@@ -145,7 +177,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
|
|
||||||
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
||||||
return (
|
return (
|
||||||
<Collapsible defaultOpen={containsActive}>
|
<AutoOpenCollapsible containsActive={containsActive}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -167,10 +199,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
|
activeItemRef={activeItemRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</AutoOpenCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,9 +217,18 @@ interface SidebarTopItemProps {
|
|||||||
navigate: ReturnType<typeof useNavigate>;
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
edition: string;
|
edition: string;
|
||||||
onUpsell: () => void;
|
onUpsell: () => void;
|
||||||
|
activeItemRef: ActiveItemRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
|
function SidebarTopItem({
|
||||||
|
item,
|
||||||
|
sectionName,
|
||||||
|
currentPath,
|
||||||
|
navigate,
|
||||||
|
edition,
|
||||||
|
onUpsell,
|
||||||
|
activeItemRef,
|
||||||
|
}: SidebarTopItemProps) {
|
||||||
if ('link' in item) {
|
if ('link' in item) {
|
||||||
const { name, icon, viewName } = item.link;
|
const { name, icon, viewName } = item.link;
|
||||||
|
|
||||||
@@ -203,6 +245,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
ref={isActive ? activeItemRef : undefined}
|
||||||
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
@@ -226,7 +269,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible defaultOpen={containsActive}>
|
<AutoOpenCollapsible containsActive={containsActive}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
||||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||||
@@ -245,10 +288,11 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
|
activeItemRef={activeItemRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</AutoOpenCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,23 +308,19 @@ export function Sidebar() {
|
|||||||
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const edition = useAccountStore((s) => s.edition);
|
const edition = useAccountStore((s) => s.edition);
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
const permissions = useAccountStore((s) => s.permissions);
|
||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
|
const activeItem = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const activeItemRef = useCallback<ActiveItemRef>((el) => {
|
||||||
|
activeItem.current = el;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const layouts = useMemo(
|
const layouts = useMemo(() => {
|
||||||
() =>
|
if (!schema) return [];
|
||||||
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
[schema, edition, hasObjectPermission, hasPermission],
|
return visibleLayouts(schema, edition, canGet, hasPermission);
|
||||||
);
|
}, [schema, edition, permissions, hasPermission]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!schema) return;
|
|
||||||
if (layouts.length === 0) return;
|
|
||||||
if (!layouts.find((l) => l.name === activeSection)) {
|
|
||||||
setActiveSection(layouts[0].name);
|
|
||||||
}
|
|
||||||
}, [schema, layouts, activeSection, setActiveSection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -289,6 +329,10 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}, [location.pathname, setSidebarOpen]);
|
}, [location.pathname, setSidebarOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeItem.current?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [location.pathname, activeSection]);
|
||||||
|
|
||||||
if (!sidebarOpen || !schema) return null;
|
if (!sidebarOpen || !schema) return null;
|
||||||
|
|
||||||
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
||||||
@@ -296,7 +340,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const handleSectionClick = (target: Layout) => {
|
const handleSectionClick = (target: Layout) => {
|
||||||
setActiveSection(target.name);
|
setActiveSection(target.name);
|
||||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
const first =
|
const first =
|
||||||
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
||||||
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
||||||
@@ -322,6 +366,7 @@ export function Sidebar() {
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={() => setUpsellOpen(true)}
|
onUpsell={() => setUpsellOpen(true)}
|
||||||
|
activeItemRef={activeItemRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
|
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GlobalSearch } from '@/components/common/GlobalSearch';
|
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -27,10 +26,12 @@ import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleL
|
|||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
|
||||||
|
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||||
|
|
||||||
function getIcon(name: string): LucideIcons.LucideIcon {
|
function getIcon(name: string): LucideIcons.LucideIcon {
|
||||||
const formatted = name
|
const formatted = name
|
||||||
.split('-')
|
.split('-')
|
||||||
@@ -55,7 +56,18 @@ export function TopBar() {
|
|||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setPaletteOpen((open) => !open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const navigableLayouts = schema
|
const navigableLayouts = schema
|
||||||
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
||||||
@@ -81,7 +93,17 @@ export function TopBar() {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
|
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
|
||||||
<GlobalSearch />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="flex-1 text-left">{t('globalSearch.placeholder', 'Search pages, fields, settings...')}</span>
|
||||||
|
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
|
||||||
|
{IS_MAC ? '⌘K' : 'Ctrl K'}
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
||||||
@@ -91,18 +113,13 @@ export function TopBar() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="md:hidden"
|
className="md:hidden"
|
||||||
onClick={() => setMobileSearchOpen(true)}
|
onClick={() => setPaletteOpen(true)}
|
||||||
aria-label={t('search', 'Search')}
|
aria-label={t('search', 'Search')}
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={mobileSearchOpen} onOpenChange={setMobileSearchOpen}>
|
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||||
<DialogContent className="top-4 translate-y-0 max-w-[calc(100vw-2rem)] p-4">
|
|
||||||
<DialogTitle className="sr-only">{t('search', 'Search')}</DialogTitle>
|
|
||||||
<GlobalSearch autoFocus onAfterSelect={() => setMobileSearchOpen(false)} />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
||||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||||
|
|||||||
@@ -1215,7 +1215,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded-lg border bg-background shadow-sm">
|
<div className="rounded-lg border bg-background shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto rounded-[calc(var(--radius-lg)-1px)]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted">
|
<tr className="border-b bg-muted">
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { DayPicker, getDefaultClassNames, type DayButton } from '@daypicker/react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = 'label',
|
||||||
|
buttonVariant = 'ghost',
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=popover-content]_&]:bg-transparent',
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn('w-fit', defaultClassNames.root),
|
||||||
|
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||||
|
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||||
|
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
|
defaultClassNames.button_previous,
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
|
defaultClassNames.button_next,
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||||
|
defaultClassNames.month_caption,
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||||
|
defaultClassNames.dropdowns,
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||||
|
defaultClassNames.dropdown_root,
|
||||||
|
),
|
||||||
|
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
|
||||||
|
caption_label: cn(
|
||||||
|
'font-medium select-none',
|
||||||
|
captionLayout === 'label'
|
||||||
|
? 'text-sm'
|
||||||
|
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||||
|
defaultClassNames.caption_label,
|
||||||
|
),
|
||||||
|
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
|
||||||
|
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none',
|
||||||
|
defaultClassNames.weekday,
|
||||||
|
),
|
||||||
|
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||||
|
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
|
||||||
|
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
|
||||||
|
day: cn(
|
||||||
|
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||||
|
props.showWeekNumber
|
||||||
|
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md'
|
||||||
|
: '[&:first-child[data-selected=true]_button]:rounded-l-md',
|
||||||
|
defaultClassNames.day,
|
||||||
|
),
|
||||||
|
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
|
||||||
|
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||||
|
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
|
||||||
|
defaultClassNames.today,
|
||||||
|
),
|
||||||
|
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
||||||
|
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||||
|
hidden: cn('invisible', defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => (
|
||||||
|
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
|
||||||
|
),
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
const Icon = orientation === 'left' ? ChevronLeft : orientation === 'right' ? ChevronRight : ChevronDown;
|
||||||
|
return <Icon className={cn('size-4', className)} {...props} />;
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||||
|
defaultClassNames.day,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton };
|
||||||
@@ -41,8 +41,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
|
|||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
const CommandInput = React.forwardRef<
|
||||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & { trailing?: React.ReactNode }
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, trailing, ...props }, ref) => (
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
@@ -53,6 +53,7 @@ const CommandInput = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
{trailing}
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
@@ -48,10 +48,12 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const APP_NAME = 'Stalwart WebUI';
|
||||||
|
|
||||||
|
export function useDocumentTitle(title?: string | null) {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = title ? `${title} · ${APP_NAME}` : APP_NAME;
|
||||||
|
return () => {
|
||||||
|
document.title = APP_NAME;
|
||||||
|
};
|
||||||
|
}, [title]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { List, Plus, Settings } from 'lucide-react';
|
||||||
|
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { resolveObject } from '@/lib/schemaResolver';
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
|
||||||
|
const MAX_RESULTS = 15;
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
|
||||||
|
link: 0,
|
||||||
|
form: 1,
|
||||||
|
field: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ObjectKind = 'singleton' | 'object' | null;
|
||||||
|
|
||||||
|
export function getObjectKind(schema: Schema, viewName: string): ObjectKind {
|
||||||
|
const resolved = resolveObject(schema, viewName);
|
||||||
|
if (!resolved) return null;
|
||||||
|
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActionInfo(
|
||||||
|
entryType: SearchIndexEntry['type'],
|
||||||
|
objectKind: ObjectKind,
|
||||||
|
t: (key: string, fallback: string) => string,
|
||||||
|
): { label: string; Icon: typeof List } {
|
||||||
|
if (objectKind === 'singleton') {
|
||||||
|
return { label: t('globalSearch.settings', 'Settings'), Icon: Settings };
|
||||||
|
}
|
||||||
|
return entryType === 'link'
|
||||||
|
? { label: t('globalSearch.list', 'List'), Icon: List }
|
||||||
|
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNavigationPath(
|
||||||
|
entryType: SearchIndexEntry['type'],
|
||||||
|
objectKind: ObjectKind,
|
||||||
|
section: string,
|
||||||
|
viewName: string,
|
||||||
|
): string {
|
||||||
|
if (objectKind === 'singleton') return `/${section}/${viewName}/singleton`;
|
||||||
|
return entryType === 'link' ? `/${section}/${viewName}` : `/${section}/${viewName}/new`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendlyName(viewName: string): string {
|
||||||
|
const stripped = viewName.replace(/^x:/, '');
|
||||||
|
const parts = stripped.split('/');
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalSearch(onAfterSelect?: () => void) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
||||||
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
|
|
||||||
|
const handleQueryChange = useCallback((value: string) => {
|
||||||
|
setQuery(value);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setDebouncedQuery(value), DEBOUNCE_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!debouncedQuery.trim() || !schema) return [];
|
||||||
|
|
||||||
|
const tokens = debouncedQuery
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
if (tokens.length === 0) return [];
|
||||||
|
|
||||||
|
const filtered = searchIndex.filter((entry) => {
|
||||||
|
const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase();
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!haystack.includes(token)) return false;
|
||||||
|
}
|
||||||
|
const resolved = resolveObject(schema, entry.viewName);
|
||||||
|
if (!resolved) return false;
|
||||||
|
return hasObjectPermission(resolved.permissionPrefix, 'Get');
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
||||||
|
return filtered.slice(0, MAX_RESULTS);
|
||||||
|
}, [debouncedQuery, searchIndex, schema, hasObjectPermission]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
|
||||||
|
for (const entry of results) {
|
||||||
|
const arr = map.get(entry.type);
|
||||||
|
if (arr) arr.push(entry);
|
||||||
|
else map.set(entry.type, [entry]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
const selectEntry = useCallback(
|
||||||
|
(entry: SearchIndexEntry) => {
|
||||||
|
if (!schema) return;
|
||||||
|
const objectKind = getObjectKind(schema, entry.viewName);
|
||||||
|
navigate(getNavigationPath(entry.type, objectKind, entry.section, entry.viewName));
|
||||||
|
onAfterSelect?.();
|
||||||
|
},
|
||||||
|
[schema, navigate, onAfterSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
setQuery('');
|
||||||
|
setDebouncedQuery('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { query, setQuery: handleQueryChange, debouncedQuery, results, groups, selectEntry, reset, schema };
|
||||||
|
}
|
||||||
+9
-1
@@ -60,6 +60,7 @@
|
|||||||
"preset30d": "Last 30 days",
|
"preset30d": "Last 30 days",
|
||||||
"preset7d": "Last 7 days",
|
"preset7d": "Last 7 days",
|
||||||
"preset90d": "Last 90 days",
|
"preset90d": "Last 90 days",
|
||||||
|
"title": "Dashboard",
|
||||||
"to": "To"
|
"to": "To"
|
||||||
},
|
},
|
||||||
"deliveryTrace": {
|
"deliveryTrace": {
|
||||||
@@ -169,10 +170,12 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"notSet": "Not set",
|
"notSet": "Not set",
|
||||||
"optional": "(optional)",
|
"optional": "(optional)",
|
||||||
|
"pickDate": "Pick a date",
|
||||||
"required": "required",
|
"required": "required",
|
||||||
"selectEllipsis": "Select...",
|
"selectEllipsis": "Select...",
|
||||||
"selectKey": "Select key...",
|
"selectKey": "Select key...",
|
||||||
"selectOptions": "Select options...",
|
"selectOptions": "Select options...",
|
||||||
|
"time": "Time",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"typeAndPressEnter": "Type and press Enter...",
|
"typeAndPressEnter": "Type and press Enter...",
|
||||||
"unknownObjectType": "Unknown object type: {{name}}"
|
"unknownObjectType": "Unknown object type: {{name}}"
|
||||||
@@ -222,7 +225,10 @@
|
|||||||
"list": "List",
|
"list": "List",
|
||||||
"noResults": "No results found.",
|
"noResults": "No results found.",
|
||||||
"pages": "Pages",
|
"pages": "Pages",
|
||||||
"settings": "Settings"
|
"placeholder": "Search pages, fields, settings...",
|
||||||
|
"settings": "Settings",
|
||||||
|
"title": "Search",
|
||||||
|
"typeToSearch": "Type to search the admin panel."
|
||||||
},
|
},
|
||||||
"jmapErrors": {
|
"jmapErrors": {
|
||||||
"addressBookHasContents": "This address book has contacts. Remove them first.",
|
"addressBookHasContents": "This address book has contacts. Remove them first.",
|
||||||
@@ -287,6 +293,7 @@
|
|||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"error": "An unexpected error occurred",
|
"error": "An unexpected error occurred",
|
||||||
"prompt": "Enter your account name to continue",
|
"prompt": "Enter your account name to continue",
|
||||||
|
"title": "Sign in",
|
||||||
"usernamePlaceholder": "user@example.com"
|
"usernamePlaceholder": "user@example.com"
|
||||||
},
|
},
|
||||||
"logo": {
|
"logo": {
|
||||||
@@ -305,6 +312,7 @@
|
|||||||
"missingParams": "Missing authorization code or state parameter",
|
"missingParams": "Missing authorization code or state parameter",
|
||||||
"processing": "Completing sign in...",
|
"processing": "Completing sign in...",
|
||||||
"stateMismatch": "State parameter mismatch. Please try logging in again.",
|
"stateMismatch": "State parameter mismatch. Please try logging in again.",
|
||||||
|
"title": "Signing in",
|
||||||
"tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}"
|
"tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}"
|
||||||
},
|
},
|
||||||
"otp": {
|
"otp": {
|
||||||
|
|||||||
+1
-1
@@ -12,8 +12,8 @@ import './index.css';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import OAuthCallback from './pages/OAuthCallback';
|
import OAuthCallback from './pages/OAuthCallback';
|
||||||
import AdminPanel from './pages/AdminPanel';
|
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
|
import { AdminPanel } from './pages/AdminPanel.lazy';
|
||||||
import { ProtectedRoute } from './components/layout/ProtectedRoute';
|
import { ProtectedRoute } from './components/layout/ProtectedRoute';
|
||||||
import { getBasePath } from './lib/basePath';
|
import { getBasePath } from './lib/basePath';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const AdminPanel = lazy(() => import('./AdminPanel'));
|
||||||
+64
-41
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
@@ -17,7 +17,7 @@ import { TopBar } from '@/components/layout/TopBar';
|
|||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
import { MainContent } from '@/components/layout/MainContent';
|
import { MainContent } from '@/components/layout/MainContent';
|
||||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
import { BootstrapWizard } from '@/components/bootstrap/BootstrapWizard';
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import {
|
import {
|
||||||
findFirstAccessibleLinkInLayout,
|
findFirstAccessibleLinkInLayout,
|
||||||
findFirstVisibleLinkInLayout,
|
findFirstVisibleLinkInLayout,
|
||||||
@@ -25,7 +25,12 @@ import {
|
|||||||
visibleLayouts,
|
visibleLayouts,
|
||||||
} from '@/lib/layout';
|
} from '@/lib/layout';
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
|
import { friendlyName } from '@/hooks/useGlobalSearch';
|
||||||
|
|
||||||
|
const BootstrapWizard = lazy(() =>
|
||||||
|
import('@/components/bootstrap/BootstrapWizard').then((m) => ({ default: m.BootstrapWizard })),
|
||||||
|
);
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -69,9 +74,10 @@ export default function AdminPanel() {
|
|||||||
const setSchema = useSchemaStore((s) => s.setSchema);
|
const setSchema = useSchemaStore((s) => s.setSchema);
|
||||||
const setAccountInfo = useAccountStore((s) => s.setAccountInfo);
|
const setAccountInfo = useAccountStore((s) => s.setAccountInfo);
|
||||||
const edition = useAccountStore((s) => s.edition);
|
const edition = useAccountStore((s) => s.edition);
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
const permissions = useAccountStore((s) => s.permissions);
|
||||||
const setSession = useAuthStore((s) => s.setSession);
|
const setSession = useAuthStore((s) => s.setSession);
|
||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
const { canViewObject } = usePermissions();
|
const { canViewObject } = usePermissions();
|
||||||
@@ -79,6 +85,26 @@ export default function AdminPanel() {
|
|||||||
const [initError, setInitError] = useState<string | null>(null);
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
const [initializing, setInitializing] = useState(!isSchemaLoaded);
|
const [initializing, setInitializing] = useState(!isSchemaLoaded);
|
||||||
|
|
||||||
|
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
||||||
|
const pageTitle = useMemo(() => {
|
||||||
|
if (!section) return t('dashboard.title', 'Dashboard');
|
||||||
|
if (!viewName) return section;
|
||||||
|
let label: string | undefined;
|
||||||
|
for (const entry of searchIndex) {
|
||||||
|
if (entry.type !== 'link' || entry.viewName !== viewName) continue;
|
||||||
|
if (entry.section === section) {
|
||||||
|
label = entry.text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
label ??= entry.text;
|
||||||
|
}
|
||||||
|
const name = label ?? friendlyName(viewName);
|
||||||
|
const title = id === 'new' ? t('form.createTitle', 'Create {{name}}', { name }) : name;
|
||||||
|
return `${title} · ${section}`;
|
||||||
|
}, [section, viewName, id, searchIndex, t]);
|
||||||
|
|
||||||
|
useDocumentTitle(pageTitle);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||||
if (bypassToken && !accessToken) {
|
if (bypassToken && !accessToken) {
|
||||||
@@ -140,20 +166,19 @@ export default function AdminPanel() {
|
|||||||
};
|
};
|
||||||
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
|
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
|
||||||
|
|
||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
|
||||||
const isBootstrapMode = useMemo(() => {
|
const isBootstrapMode = useMemo(() => {
|
||||||
if (!schema) return false;
|
if (!schema) return false;
|
||||||
if (!canViewObject('x:Bootstrap')) return false;
|
if (!canViewObject('x:Bootstrap')) return false;
|
||||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
const hasPerm = (perm: string) => hasPermission(perm);
|
const hasPerm = (perm: string) => permissions.includes(perm);
|
||||||
return visibleLayouts(schema, edition, canGet, hasPerm).length === 0;
|
return visibleLayouts(schema, edition, canGet, hasPerm).length === 0;
|
||||||
}, [schema, canViewObject, edition, hasObjectPermission, hasPermission]);
|
}, [schema, canViewObject, edition, permissions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!schema) return;
|
if (!schema) return;
|
||||||
if (isBootstrapMode) return;
|
if (isBootstrapMode) return;
|
||||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
const hasPerm = (perm: string) => hasPermission(perm);
|
const hasPerm = (perm: string) => permissions.includes(perm);
|
||||||
const layouts = visibleLayouts(schema, edition, canGet, hasPerm);
|
const layouts = visibleLayouts(schema, edition, canGet, hasPerm);
|
||||||
const pickDefault = (): { layoutName: string; link: string | null } | null => {
|
const pickDefault = (): { layoutName: string; link: string | null } | null => {
|
||||||
for (const layout of layouts) {
|
for (const layout of layouts) {
|
||||||
@@ -169,50 +194,46 @@ export default function AdminPanel() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const redirectTo = (layoutName: string, link: string | null) => {
|
||||||
|
setActiveSection(layoutName);
|
||||||
|
if (link) navigate(`/${layoutName}/${link}`, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
if (section && viewName && !isLinkAccessible(schema, viewName, edition, canGet, hasPerm)) {
|
if (section && viewName && !isLinkAccessible(schema, viewName, edition, canGet, hasPerm)) {
|
||||||
const fallback = pickDefault();
|
const fallback = pickDefault();
|
||||||
if (fallback && (fallback.layoutName !== section || fallback.link !== viewName)) {
|
if (fallback && (fallback.layoutName !== section || fallback.link !== viewName)) {
|
||||||
setActiveSection(fallback.layoutName);
|
redirectTo(fallback.layoutName, fallback.link);
|
||||||
if (fallback.link) {
|
|
||||||
navigate(`/${fallback.layoutName}/${fallback.link}`, { replace: true });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (section && viewName) {
|
||||||
|
const ownLayout = layouts.find((l) => l.name.toLowerCase() === section.toLowerCase());
|
||||||
|
setActiveSection(ownLayout?.name ?? layouts[0]?.name ?? section);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (section) {
|
if (section) {
|
||||||
setActiveSection(section);
|
const ownLayout = layouts.find((l) => l.name.toLowerCase() === section.toLowerCase());
|
||||||
|
const ownLink = ownLayout
|
||||||
|
? (findFirstAccessibleLinkInLayout(schema, ownLayout, edition, canGet, hasPerm) ??
|
||||||
|
findFirstVisibleLinkInLayout(schema, ownLayout, edition, canGet, hasPerm))
|
||||||
|
: null;
|
||||||
|
if (ownLayout && ownLink) {
|
||||||
|
redirectTo(ownLayout.name, ownLink);
|
||||||
|
} else {
|
||||||
|
const target = pickDefault();
|
||||||
|
if (target) redirectTo(target.layoutName, target.link);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = pickDefault();
|
const target = pickDefault();
|
||||||
if (target) {
|
if (target) redirectTo(target.layoutName, target.link);
|
||||||
setActiveSection(target.layoutName);
|
}, [section, viewName, schema, setActiveSection, navigate, edition, permissions, isBootstrapMode]);
|
||||||
if (target.link) {
|
|
||||||
navigate(`/${target.layoutName}/${target.link}`, { replace: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
section,
|
|
||||||
viewName,
|
|
||||||
schema,
|
|
||||||
setActiveSection,
|
|
||||||
navigate,
|
|
||||||
edition,
|
|
||||||
hasObjectPermission,
|
|
||||||
hasPermission,
|
|
||||||
isBootstrapMode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (initializing) {
|
if (initializing) {
|
||||||
return (
|
return <LoadingFallback fullScreen />;
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initError) {
|
if (initError) {
|
||||||
@@ -237,7 +258,9 @@ export default function AdminPanel() {
|
|||||||
if (isBootstrapMode) {
|
if (isBootstrapMode) {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={<LoadingFallback fullScreen />}>
|
||||||
<BootstrapWizard />
|
<BootstrapWizard />
|
||||||
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,7 +273,7 @@ export default function AdminPanel() {
|
|||||||
<main
|
<main
|
||||||
className={`flex-1 overflow-auto bg-content-background p-6 transition-[margin] ${sidebarOpen ? 'md:ml-64' : ''}`}
|
className={`flex-1 overflow-auto bg-content-background p-6 transition-[margin] ${sidebarOpen ? 'md:ml-64' : ''}`}
|
||||||
>
|
>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
||||||
<MainContent viewName={viewName} id={id} section={section} />
|
<MainContent viewName={viewName} id={id} section={section} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import Logo from '@/components/common/Logo';
|
import Logo from '@/components/common/Logo';
|
||||||
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
@@ -23,6 +24,8 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useDocumentTitle(t('login.title', 'Sign in'));
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = username.trim();
|
const trimmed = username.trim();
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useDocumentTitle(t('errors.notFound'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
import { getBasePath } from '@/lib/basePath';
|
import { getBasePath } from '@/lib/basePath';
|
||||||
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
|
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ export default function OAuthCallback() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useDocumentTitle(t('oauth.title', 'Signing in'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function handleCallback() {
|
async function handleCallback() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
import { useCacheStore } from './cacheStore';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
@@ -21,6 +22,7 @@ const initialState = {
|
|||||||
describe('authStore', () => {
|
describe('authStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useAuthStore.setState(initialState);
|
useAuthStore.setState(initialState);
|
||||||
|
useCacheStore.getState().clearAll();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,6 +126,29 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves an activeAccountId that exists in the new accounts', () => {
|
||||||
|
useAuthStore.setState({ activeAccountId: 'acc-2' });
|
||||||
|
|
||||||
|
useAuthStore.getState().setSession(
|
||||||
|
{
|
||||||
|
'acc-1': { name: 'Personal', isPersonal: true },
|
||||||
|
'acc-2': { name: 'Group', isPersonal: false },
|
||||||
|
},
|
||||||
|
'acc-1',
|
||||||
|
'https://api',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().activeAccountId).toBe('acc-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to primaryAccountId when the previous activeAccountId is unknown', () => {
|
||||||
|
useAuthStore.setState({ activeAccountId: 'gone' });
|
||||||
|
|
||||||
|
useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api');
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('switchAccount', () => {
|
describe('switchAccount', () => {
|
||||||
@@ -149,6 +174,35 @@ describe('authStore', () => {
|
|||||||
useAuthStore.getState().switchAccount('nonexistent');
|
useAuthStore.getState().switchAccount('nonexistent');
|
||||||
expect(useAuthStore.getState().activeAccountId).toBe('a1');
|
expect(useAuthStore.getState().activeAccountId).toBe('a1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears cached objects when the account changes', () => {
|
||||||
|
useCacheStore.getState().setDisplayNames('Mailbox', { m1: 'Inbox' });
|
||||||
|
useCacheStore.getState().setObjectList('Mailbox', [{ id: 'm1', label: 'Inbox' }]);
|
||||||
|
useAuthStore.setState({
|
||||||
|
accounts: {
|
||||||
|
a1: { name: 'A1', isPersonal: true },
|
||||||
|
a2: { name: 'A2', isPersonal: false },
|
||||||
|
},
|
||||||
|
activeAccountId: 'a1',
|
||||||
|
});
|
||||||
|
|
||||||
|
useAuthStore.getState().switchAccount('a2');
|
||||||
|
|
||||||
|
expect(useCacheStore.getState().displayNames).toEqual({});
|
||||||
|
expect(useCacheStore.getState().objectLists).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the cache when switching to the already active account', () => {
|
||||||
|
useCacheStore.getState().setDisplayNames('Mailbox', { m1: 'Inbox' });
|
||||||
|
useAuthStore.setState({
|
||||||
|
accounts: { a1: { name: 'A1', isPersonal: true } },
|
||||||
|
activeAccountId: 'a1',
|
||||||
|
});
|
||||||
|
|
||||||
|
useAuthStore.getState().switchAccount('a1');
|
||||||
|
|
||||||
|
expect(useCacheStore.getState().getDisplayName('Mailbox', 'm1')).toBe('Inbox');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('logout', () => {
|
describe('logout', () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { useCacheStore } from '@/stores/cacheStore';
|
||||||
|
|
||||||
interface AccountInfo {
|
interface AccountInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -71,10 +72,11 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => {
|
setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => {
|
||||||
|
const current = get().activeAccountId;
|
||||||
set({
|
set({
|
||||||
accounts,
|
accounts,
|
||||||
primaryAccountId,
|
primaryAccountId,
|
||||||
activeAccountId: primaryAccountId,
|
activeAccountId: current && accounts[current] ? current : primaryAccountId,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}),
|
...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}),
|
||||||
...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}),
|
...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}),
|
||||||
@@ -82,9 +84,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
switchAccount: (accountId) => {
|
switchAccount: (accountId) => {
|
||||||
const { accounts } = get();
|
const { accounts, activeAccountId } = get();
|
||||||
if (accounts[accountId]) {
|
if (accounts[accountId] && accountId !== activeAccountId) {
|
||||||
set({ activeAccountId: accountId });
|
set({ activeAccountId: accountId });
|
||||||
|
useCacheStore.getState().clearAll();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -134,6 +137,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
tokenExpiresAt: state.tokenExpiresAt,
|
tokenExpiresAt: state.tokenExpiresAt,
|
||||||
tokenEndpoint: state.tokenEndpoint,
|
tokenEndpoint: state.tokenEndpoint,
|
||||||
endSessionEndpoint: state.endSessionEndpoint,
|
endSessionEndpoint: state.endSessionEndpoint,
|
||||||
|
activeAccountId: state.activeAccountId,
|
||||||
}) as AuthState,
|
}) as AuthState,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface CacheState {
|
|||||||
getObjectList: (key: string) => ObjectListEntry[] | undefined;
|
getObjectList: (key: string) => ObjectListEntry[] | undefined;
|
||||||
invalidateObjectList: (key: string) => void;
|
invalidateObjectList: (key: string) => void;
|
||||||
invalidateAllObjectLists: () => void;
|
invalidateAllObjectLists: () => void;
|
||||||
|
clearAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCacheStore = create<CacheState>()((set, get) => ({
|
export const useCacheStore = create<CacheState>()((set, get) => ({
|
||||||
@@ -75,4 +76,8 @@ export const useCacheStore = create<CacheState>()((set, get) => ({
|
|||||||
invalidateAllObjectLists: () => {
|
invalidateAllObjectLists: () => {
|
||||||
set({ objectLists: {} });
|
set({ objectLists: {} });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearAll: () => {
|
||||||
|
set({ displayNames: {}, objectLists: {} });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
+1
-5
@@ -256,11 +256,7 @@ export interface MassActionSeparator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ItemAction =
|
export type ItemAction =
|
||||||
| ItemActionDelete
|
ItemActionDelete | ItemActionSetProperty | ItemActionQuery | ItemActionView | ItemActionSeparator;
|
||||||
| ItemActionSetProperty
|
|
||||||
| ItemActionQuery
|
|
||||||
| ItemActionView
|
|
||||||
| ItemActionSeparator;
|
|
||||||
|
|
||||||
export interface ItemActionDelete {
|
export interface ItemActionDelete {
|
||||||
type: 'delete';
|
type: 'delete';
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { version } from './package.json'
|
import { version } from './package.json' with { type: 'json' }
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './',
|
base: './',
|
||||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(import.meta.dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user