@object-ui/app-shell 3.3.0 → 3.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +22 -2
- package/dist/chrome/CommandPalette.d.ts +16 -0
- package/dist/chrome/CommandPalette.js +74 -0
- package/dist/chrome/ConditionalAuthWrapper.d.ts +23 -0
- package/dist/chrome/ConditionalAuthWrapper.js +135 -0
- package/dist/chrome/ConsoleToaster.d.ts +11 -0
- package/dist/chrome/ConsoleToaster.js +28 -0
- package/dist/chrome/ErrorBoundary.d.ts +39 -0
- package/dist/chrome/ErrorBoundary.js +64 -0
- package/dist/chrome/KeyboardShortcutsDialog.d.ts +7 -0
- package/dist/chrome/KeyboardShortcutsDialog.js +63 -0
- package/dist/chrome/LoadingScreen.d.ts +12 -0
- package/dist/chrome/LoadingScreen.js +44 -0
- package/dist/chrome/OnboardingWalkthrough.d.ts +9 -0
- package/dist/chrome/OnboardingWalkthrough.js +11 -0
- package/dist/chrome/ThemeProvider.d.ts +13 -0
- package/dist/chrome/ThemeProvider.js +37 -0
- package/dist/chrome/index.d.ts +8 -0
- package/dist/chrome/index.js +8 -0
- package/dist/console/AppContent.d.ts +26 -0
- package/dist/console/AppContent.js +266 -0
- package/dist/console/ConsoleShell.d.ts +66 -0
- package/dist/console/ConsoleShell.js +97 -0
- package/dist/console/auth/AuthPageLayout.d.ts +9 -0
- package/dist/console/auth/AuthPageLayout.js +4 -0
- package/dist/console/auth/ForgotPasswordPage.d.ts +4 -0
- package/dist/console/auth/ForgotPasswordPage.js +23 -0
- package/dist/console/auth/LoginPage.d.ts +4 -0
- package/dist/console/auth/LoginPage.js +24 -0
- package/dist/console/auth/RegisterPage.d.ts +4 -0
- package/dist/console/auth/RegisterPage.js +29 -0
- package/dist/console/auth/index.d.ts +4 -0
- package/dist/console/auth/index.js +4 -0
- package/dist/console/home/AppCard.d.ts +14 -0
- package/dist/console/home/AppCard.js +36 -0
- package/dist/console/home/HomeLayout.d.ts +15 -0
- package/dist/console/home/HomeLayout.js +20 -0
- package/dist/console/home/HomePage.d.ts +18 -0
- package/dist/console/home/HomePage.js +54 -0
- package/dist/console/home/QuickActions.d.ts +9 -0
- package/dist/console/home/QuickActions.js +53 -0
- package/dist/console/home/RecentApps.d.ts +13 -0
- package/dist/console/home/RecentApps.js +32 -0
- package/dist/console/home/StarredApps.d.ts +13 -0
- package/dist/console/home/StarredApps.js +29 -0
- package/dist/console/home/index.d.ts +11 -0
- package/dist/console/home/index.js +11 -0
- package/dist/console/organizations/CreateWorkspaceDialog.d.ts +16 -0
- package/dist/console/organizations/CreateWorkspaceDialog.js +71 -0
- package/dist/console/organizations/OrganizationsLayout.d.ts +15 -0
- package/dist/console/organizations/OrganizationsLayout.js +20 -0
- package/dist/console/organizations/OrganizationsPage.d.ts +9 -0
- package/dist/console/organizations/OrganizationsPage.js +76 -0
- package/dist/console/organizations/index.d.ts +8 -0
- package/dist/console/organizations/index.js +8 -0
- package/dist/console/organizations/manage/AcceptInvitationPage.d.ts +7 -0
- package/dist/console/organizations/manage/AcceptInvitationPage.js +100 -0
- package/dist/console/organizations/manage/InvitationsPage.d.ts +6 -0
- package/dist/console/organizations/manage/InvitationsPage.js +107 -0
- package/dist/console/organizations/manage/InviteMemberDialog.d.ts +16 -0
- package/dist/console/organizations/manage/InviteMemberDialog.js +83 -0
- package/dist/console/organizations/manage/MembersPage.d.ts +6 -0
- package/dist/console/organizations/manage/MembersPage.js +89 -0
- package/dist/console/organizations/manage/OrganizationLayout.d.ts +8 -0
- package/dist/console/organizations/manage/OrganizationLayout.js +51 -0
- package/dist/console/organizations/manage/SettingsPage.d.ts +6 -0
- package/dist/console/organizations/manage/SettingsPage.js +141 -0
- package/dist/console/organizations/manage/orgContext.d.ts +5 -0
- package/dist/console/organizations/manage/orgContext.js +4 -0
- package/dist/context/FavoritesProvider.d.ts +50 -0
- package/dist/context/FavoritesProvider.js +129 -0
- package/dist/context/NavigationContext.d.ts +46 -0
- package/dist/context/NavigationContext.js +59 -0
- package/dist/context/index.d.ts +3 -0
- package/dist/context/index.js +2 -0
- package/dist/hooks/index.d.ts +7 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/useFavorites.d.ts +13 -0
- package/dist/hooks/useFavorites.js +13 -0
- package/dist/hooks/useMetadataService.d.ts +13 -0
- package/dist/hooks/useMetadataService.js +18 -0
- package/dist/hooks/useNavPins.d.ts +16 -0
- package/dist/hooks/useNavPins.js +72 -0
- package/dist/hooks/useNavigationSync.d.ts +82 -0
- package/dist/hooks/useNavigationSync.js +495 -0
- package/dist/hooks/useObjectActions.d.ts +38 -0
- package/dist/hooks/useObjectActions.js +92 -0
- package/dist/hooks/useRecentItems.d.ts +21 -0
- package/dist/hooks/useRecentItems.js +50 -0
- package/dist/hooks/useResponsiveSidebar.d.ts +8 -0
- package/dist/hooks/useResponsiveSidebar.js +28 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +35 -0
- package/dist/layout/ActivityFeed.d.ts +22 -0
- package/dist/layout/ActivityFeed.js +57 -0
- package/dist/layout/AppHeader.d.ts +33 -0
- package/dist/layout/AppHeader.js +182 -0
- package/dist/layout/AppSidebar.d.ts +14 -0
- package/dist/layout/AppSidebar.js +202 -0
- package/dist/layout/AppSwitcher.d.ts +12 -0
- package/dist/layout/AppSwitcher.js +24 -0
- package/dist/layout/AuthPageLayout.d.ts +9 -0
- package/dist/layout/AuthPageLayout.js +4 -0
- package/dist/layout/ConnectionStatus.d.ts +14 -0
- package/dist/layout/ConnectionStatus.js +36 -0
- package/dist/layout/ConsoleFloatingChatbot.d.ts +10 -0
- package/dist/layout/ConsoleFloatingChatbot.js +27 -0
- package/dist/layout/ConsoleLayout.d.ts +22 -0
- package/dist/layout/ConsoleLayout.js +49 -0
- package/dist/layout/LocaleSwitcher.d.ts +7 -0
- package/dist/layout/LocaleSwitcher.js +27 -0
- package/dist/layout/ModeToggle.d.ts +1 -0
- package/dist/layout/ModeToggle.js +10 -0
- package/dist/layout/UnifiedSidebar.d.ts +27 -0
- package/dist/layout/UnifiedSidebar.js +186 -0
- package/dist/layout/index.d.ts +11 -0
- package/dist/layout/index.js +10 -0
- package/dist/providers/AdapterProvider.d.ts +21 -0
- package/dist/providers/AdapterProvider.js +52 -0
- package/dist/providers/ExpressionProvider.d.ts +50 -0
- package/dist/providers/ExpressionProvider.js +67 -0
- package/dist/providers/MetadataProvider.d.ts +18 -0
- package/dist/providers/MetadataProvider.js +324 -0
- package/dist/services/MetadataService.d.ts +119 -0
- package/dist/services/MetadataService.js +211 -0
- package/dist/skeletons/SkeletonDashboard.d.ts +13 -0
- package/dist/skeletons/SkeletonDashboard.js +12 -0
- package/dist/skeletons/SkeletonDetail.d.ts +15 -0
- package/dist/skeletons/SkeletonDetail.js +12 -0
- package/dist/skeletons/SkeletonGrid.d.ts +15 -0
- package/dist/skeletons/SkeletonGrid.js +12 -0
- package/dist/skeletons/index.d.ts +3 -0
- package/dist/skeletons/index.js +3 -0
- package/dist/utils/getIcon.d.ts +24 -0
- package/dist/utils/getIcon.js +87 -0
- package/dist/utils/index.d.ts +33 -0
- package/dist/utils/index.js +62 -0
- package/dist/utils/metadataConverters.d.ts +78 -0
- package/dist/utils/metadataConverters.js +74 -0
- package/dist/views/ActionConfirmDialog.d.ts +22 -0
- package/dist/views/ActionConfirmDialog.js +22 -0
- package/dist/views/ActionParamDialog.d.ts +23 -0
- package/dist/views/ActionParamDialog.js +67 -0
- package/dist/views/DashboardView.d.ts +9 -0
- package/dist/views/DashboardView.js +357 -0
- package/dist/views/DesignDrawer.d.ts +28 -0
- package/dist/views/DesignDrawer.js +51 -0
- package/dist/views/MetadataInspector.d.ts +40 -0
- package/dist/views/MetadataInspector.js +69 -0
- package/dist/views/ObjectView.d.ts +11 -0
- package/dist/views/ObjectView.js +769 -0
- package/dist/views/PageView.d.ts +6 -0
- package/dist/views/PageView.js +49 -0
- package/dist/views/RecordDetailView.d.ts +14 -0
- package/dist/views/RecordDetailView.js +508 -0
- package/dist/views/ReportView.d.ts +4 -0
- package/dist/views/ReportView.js +285 -0
- package/dist/views/SearchResultsPage.d.ts +9 -0
- package/dist/views/SearchResultsPage.js +107 -0
- package/dist/views/ViewConfigPanel.d.ts +56 -0
- package/dist/views/ViewConfigPanel.js +91 -0
- package/dist/views/index.d.ts +11 -0
- package/dist/views/index.js +11 -0
- package/package.json +59 -11
- package/.turbo/turbo-build.log +0 -4
- package/src/components/AppShell.tsx +0 -31
- package/src/components/DashboardRenderer.tsx +0 -36
- package/src/components/FormRenderer.tsx +0 -71
- package/src/components/ObjectRenderer.tsx +0 -122
- package/src/components/PageRenderer.tsx +0 -28
- package/src/index.ts +0 -20
- package/src/types.ts +0 -78
- package/tsconfig.json +0 -20
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* SettingsPage
|
|
4
|
+
*
|
|
5
|
+
* Organization settings: general info form + danger zone.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
8
|
+
import { Button, Input, Label, Separator, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@object-ui/components';
|
|
9
|
+
import { useAuth } from '@object-ui/auth';
|
|
10
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
11
|
+
import { Loader2 } from 'lucide-react';
|
|
12
|
+
import { toast } from 'sonner';
|
|
13
|
+
import { useNavigate } from 'react-router-dom';
|
|
14
|
+
import { useOrgContext } from './orgContext';
|
|
15
|
+
export function SettingsPage() {
|
|
16
|
+
const { t } = useObjectTranslation();
|
|
17
|
+
const { org } = useOrgContext();
|
|
18
|
+
const { user, getMembers, updateOrganization, deleteOrganization, leaveOrganization, } = useAuth();
|
|
19
|
+
const navigate = useNavigate();
|
|
20
|
+
// Form state
|
|
21
|
+
const [name, setName] = useState(org.name);
|
|
22
|
+
const [slug, setSlug] = useState(org.slug ?? '');
|
|
23
|
+
const [logo, setLogo] = useState(org.logo ?? '');
|
|
24
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
25
|
+
// Owner check
|
|
26
|
+
const [isOwner, setIsOwner] = useState(null);
|
|
27
|
+
const [membersLoading, setMembersLoading] = useState(true);
|
|
28
|
+
// Danger zone dialogs
|
|
29
|
+
const [isLeaveOpen, setIsLeaveOpen] = useState(false);
|
|
30
|
+
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
31
|
+
const [deleteConfirmSlug, setDeleteConfirmSlug] = useState('');
|
|
32
|
+
const [isDangerLoading, setIsDangerLoading] = useState(false);
|
|
33
|
+
// Sync form when org changes (e.g., after save)
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setName(org.name);
|
|
36
|
+
setSlug(org.slug ?? '');
|
|
37
|
+
setLogo(org.logo ?? '');
|
|
38
|
+
}, [org]);
|
|
39
|
+
// Load members to determine if current user is owner
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
setMembersLoading(true);
|
|
43
|
+
getMembers(org.id)
|
|
44
|
+
.then((members) => {
|
|
45
|
+
if (cancelled)
|
|
46
|
+
return;
|
|
47
|
+
const me = members.find((m) => m.userId === user?.id);
|
|
48
|
+
setIsOwner(me?.role === 'owner');
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
if (!cancelled)
|
|
52
|
+
setIsOwner(false);
|
|
53
|
+
})
|
|
54
|
+
.finally(() => {
|
|
55
|
+
if (!cancelled)
|
|
56
|
+
setMembersLoading(false);
|
|
57
|
+
});
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
};
|
|
61
|
+
}, [org.id, user?.id, getMembers]);
|
|
62
|
+
const handleSave = useCallback(async (e) => {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
setIsSaving(true);
|
|
65
|
+
try {
|
|
66
|
+
await updateOrganization(org.id, {
|
|
67
|
+
name: name.trim(),
|
|
68
|
+
slug: slug.trim() || undefined,
|
|
69
|
+
logo: logo.trim() || undefined,
|
|
70
|
+
});
|
|
71
|
+
toast.success(t('organization.settings.saved', { defaultValue: 'Settings saved' }));
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
toast.error(err instanceof Error
|
|
75
|
+
? err.message
|
|
76
|
+
: t('organization.settings.saveFailed', { defaultValue: 'Failed to save settings' }));
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
setIsSaving(false);
|
|
80
|
+
}
|
|
81
|
+
}, [org.id, name, slug, logo, updateOrganization, t]);
|
|
82
|
+
const handleLeave = async () => {
|
|
83
|
+
setIsDangerLoading(true);
|
|
84
|
+
try {
|
|
85
|
+
await leaveOrganization(org.id);
|
|
86
|
+
toast.success(t('organization.settings.leftOrg', { defaultValue: 'You have left the organization' }));
|
|
87
|
+
navigate('/organizations');
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
toast.error(err instanceof Error
|
|
91
|
+
? err.message
|
|
92
|
+
: t('organization.settings.leaveFailed', { defaultValue: 'Failed to leave organization' }));
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
setIsDangerLoading(false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const handleDelete = async () => {
|
|
99
|
+
setIsDangerLoading(true);
|
|
100
|
+
try {
|
|
101
|
+
await deleteOrganization(org.id);
|
|
102
|
+
toast.success(t('organization.settings.deleted', { defaultValue: 'Organization deleted' }));
|
|
103
|
+
navigate('/organizations');
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
toast.error(err instanceof Error
|
|
107
|
+
? err.message
|
|
108
|
+
: t('organization.settings.deleteFailed', { defaultValue: 'Failed to delete organization' }));
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
setIsDangerLoading(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
if (membersLoading) {
|
|
115
|
+
return (_jsx("div", { className: "flex items-center justify-center py-20", children: _jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }) }));
|
|
116
|
+
}
|
|
117
|
+
return (_jsxs("div", { className: "space-y-8", "data-testid": "settings-page", children: [_jsxs("section", { children: [_jsx("h2", { className: "text-lg font-semibold", children: t('organization.settings.generalTitle', { defaultValue: 'General' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('organization.settings.generalDescription', {
|
|
118
|
+
defaultValue: 'Update your organization information.',
|
|
119
|
+
}) }), _jsx(Separator, { className: "my-4" }), !isOwner ? (_jsx("div", { className: "rounded-lg border bg-muted/50 p-4 text-sm text-muted-foreground", children: t('organization.settings.readOnlyNote', {
|
|
120
|
+
defaultValue: 'Only owners can change settings.',
|
|
121
|
+
}) })) : (_jsxs("form", { onSubmit: handleSave, className: "space-y-4 max-w-md", children: [_jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "org-name", children: t('organization.settings.nameLabel', { defaultValue: 'Organization name' }) }), _jsx(Input, { id: "org-name", value: name, onChange: (e) => setName(e.target.value), required: true, "data-testid": "settings-name-input" })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "org-slug", children: t('organization.settings.slugLabel', { defaultValue: 'Slug' }) }), _jsx(Input, { id: "org-slug", value: slug, onChange: (e) => setSlug(e.target.value), "data-testid": "settings-slug-input" })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx(Label, { htmlFor: "org-logo", children: t('organization.settings.logoLabel', { defaultValue: 'Logo URL (optional)' }) }), _jsx(Input, { id: "org-logo", type: "url", value: logo, onChange: (e) => setLogo(e.target.value), placeholder: "https://example.com/logo.png", "data-testid": "settings-logo-input" })] }), _jsxs(Button, { type: "submit", disabled: isSaving, "data-testid": "settings-save-btn", children: [isSaving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), t('organization.settings.save', { defaultValue: 'Save changes' })] })] }))] }), _jsxs("section", { children: [_jsx("h2", { className: "text-lg font-semibold text-destructive", children: t('organization.settings.dangerZone', { defaultValue: 'Danger zone' }) }), _jsx(Separator, { className: "my-4" }), _jsxs("div", { className: "space-y-4 rounded-lg border border-destructive/50 bg-destructive/5 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("div", { children: [_jsx("p", { className: "font-medium text-sm", children: t('organization.settings.leaveTitle', { defaultValue: 'Leave organization' }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('organization.settings.leaveDescription', {
|
|
122
|
+
defaultValue: 'You will lose access to this organization.',
|
|
123
|
+
}) })] }), _jsx(Button, { variant: "destructive", size: "sm", onClick: () => setIsLeaveOpen(true), children: t('organization.settings.leaveAction', { defaultValue: 'Leave' }) })] }), _jsx(Separator, {}), _jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("div", { children: [_jsx("p", { className: "font-medium text-sm", children: t('organization.settings.deleteTitle', { defaultValue: 'Delete organization' }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('organization.settings.deleteDescription', {
|
|
124
|
+
defaultValue: 'Permanently delete this organization and all its data.',
|
|
125
|
+
}) })] }), _jsx(Button, { variant: "destructive", size: "sm", onClick: () => {
|
|
126
|
+
setDeleteConfirmSlug('');
|
|
127
|
+
setIsDeleteOpen(true);
|
|
128
|
+
}, disabled: !isOwner, children: t('organization.settings.deleteAction', { defaultValue: 'Delete' }) })] })] })] }), _jsx(AlertDialog, { open: isLeaveOpen, onOpenChange: setIsLeaveOpen, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: t('organization.settings.leaveConfirmTitle', { defaultValue: 'Leave organization?' }) }), _jsx(AlertDialogDescription, { children: t('organization.settings.leaveConfirmDescription', {
|
|
129
|
+
defaultValue: 'Are you sure you want to leave {{name}}? You will lose access immediately.',
|
|
130
|
+
name: org.name,
|
|
131
|
+
}) })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: isDangerLoading, children: t('common.cancel', { defaultValue: 'Cancel' }) }), _jsxs(AlertDialogAction, { className: "bg-destructive text-destructive-foreground hover:bg-destructive/90", onClick: handleLeave, disabled: isDangerLoading, children: [isDangerLoading && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), t('organization.settings.leaveConfirmAction', { defaultValue: 'Leave' })] })] })] }) }), _jsx(AlertDialog, { open: isDeleteOpen, onOpenChange: (open) => {
|
|
132
|
+
setIsDeleteOpen(open);
|
|
133
|
+
if (!open)
|
|
134
|
+
setDeleteConfirmSlug('');
|
|
135
|
+
}, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: t('organization.settings.deleteConfirmTitle', { defaultValue: 'Delete organization?' }) }), _jsx(AlertDialogDescription, { children: t('organization.settings.deleteConfirmDescription', {
|
|
136
|
+
defaultValue: 'This action is irreversible. All data will be permanently deleted. Type the organization slug to confirm.',
|
|
137
|
+
}) })] }), _jsxs("div", { className: "px-6 pb-2", children: [_jsx(Label, { htmlFor: "delete-confirm-slug", className: "text-sm font-medium", children: t('organization.settings.deleteConfirmSlugLabel', {
|
|
138
|
+
defaultValue: 'Type "{{slug}}" to confirm',
|
|
139
|
+
slug: org.slug,
|
|
140
|
+
}) }), _jsx(Input, { id: "delete-confirm-slug", className: "mt-2", value: deleteConfirmSlug, onChange: (e) => setDeleteConfirmSlug(e.target.value), placeholder: org.slug, "data-testid": "delete-confirm-slug-input" })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: isDangerLoading, children: t('common.cancel', { defaultValue: 'Cancel' }) }), _jsxs(AlertDialogAction, { className: "bg-destructive text-destructive-foreground hover:bg-destructive/90", onClick: handleDelete, disabled: isDangerLoading || deleteConfirmSlug !== org.slug, children: [isDangerLoading && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), t('organization.settings.deleteConfirmAction', { defaultValue: 'Delete organization' })] })] })] }) })] }));
|
|
141
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FavoritesProvider
|
|
3
|
+
*
|
|
4
|
+
* React Context + Provider for shared favorites state across all consumers
|
|
5
|
+
* (HomePage, AppCard, AppSidebar, UnifiedSidebar, StarredApps).
|
|
6
|
+
*
|
|
7
|
+
* Replaces the standalone `useFavorites` hook pattern — all callers now share
|
|
8
|
+
* a single state instance so that toggling a star in AppCard immediately
|
|
9
|
+
* reflects in HomePage's Starred section and sidebar.
|
|
10
|
+
*
|
|
11
|
+
* Persistence: localStorage (key: "objectui-favorites", max 20 items).
|
|
12
|
+
*
|
|
13
|
+
* TODO: Migrate persistence to server-side storage via the adapter/API layer
|
|
14
|
+
* (e.g. PUT /api/user/preferences) so favorites sync across devices and browsers.
|
|
15
|
+
* The provider should accept an optional `persistenceAdapter` prop that implements
|
|
16
|
+
* `load(): Promise<FavoriteItem[]>` and `save(items: FavoriteItem[]): Promise<void>`.
|
|
17
|
+
* When the adapter is provided, localStorage should be used only as a fallback
|
|
18
|
+
* during the initial load while the server response is in-flight.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
import { type ReactNode } from 'react';
|
|
23
|
+
export interface FavoriteItem {
|
|
24
|
+
/** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
href: string;
|
|
28
|
+
type: 'object' | 'dashboard' | 'page' | 'report';
|
|
29
|
+
/** ISO timestamp of when the item was favorited */
|
|
30
|
+
favoritedAt: string;
|
|
31
|
+
}
|
|
32
|
+
interface FavoritesContextValue {
|
|
33
|
+
favorites: FavoriteItem[];
|
|
34
|
+
addFavorite: (item: Omit<FavoriteItem, 'favoritedAt'>) => void;
|
|
35
|
+
removeFavorite: (id: string) => void;
|
|
36
|
+
toggleFavorite: (item: Omit<FavoriteItem, 'favoritedAt'>) => void;
|
|
37
|
+
isFavorite: (id: string) => boolean;
|
|
38
|
+
clearFavorites: () => void;
|
|
39
|
+
}
|
|
40
|
+
interface FavoritesProviderProps {
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
export declare function FavoritesProvider({ children }: FavoritesProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
44
|
+
/**
|
|
45
|
+
* Access shared favorites state.
|
|
46
|
+
*
|
|
47
|
+
* Must be used inside `<FavoritesProvider>`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function useFavorites(): FavoritesContextValue;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* FavoritesProvider
|
|
4
|
+
*
|
|
5
|
+
* React Context + Provider for shared favorites state across all consumers
|
|
6
|
+
* (HomePage, AppCard, AppSidebar, UnifiedSidebar, StarredApps).
|
|
7
|
+
*
|
|
8
|
+
* Replaces the standalone `useFavorites` hook pattern — all callers now share
|
|
9
|
+
* a single state instance so that toggling a star in AppCard immediately
|
|
10
|
+
* reflects in HomePage's Starred section and sidebar.
|
|
11
|
+
*
|
|
12
|
+
* Persistence: localStorage (key: "objectui-favorites", max 20 items).
|
|
13
|
+
*
|
|
14
|
+
* TODO: Migrate persistence to server-side storage via the adapter/API layer
|
|
15
|
+
* (e.g. PUT /api/user/preferences) so favorites sync across devices and browsers.
|
|
16
|
+
* The provider should accept an optional `persistenceAdapter` prop that implements
|
|
17
|
+
* `load(): Promise<FavoriteItem[]>` and `save(items: FavoriteItem[]): Promise<void>`.
|
|
18
|
+
* When the adapter is provided, localStorage should be used only as a fallback
|
|
19
|
+
* during the initial load while the server response is in-flight.
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Storage helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const STORAGE_KEY = 'objectui-favorites';
|
|
28
|
+
const MAX_FAVORITES = 20;
|
|
29
|
+
function loadFavorites() {
|
|
30
|
+
try {
|
|
31
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
32
|
+
return raw ? JSON.parse(raw) : [];
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function saveFavorites(items) {
|
|
39
|
+
try {
|
|
40
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Storage full — silently ignore
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Context
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
const FavoritesContext = createContext(null);
|
|
50
|
+
export function FavoritesProvider({ children }) {
|
|
51
|
+
const [favorites, setFavorites] = useState(loadFavorites);
|
|
52
|
+
// Re-sync from storage on mount (handles cases where storage was written
|
|
53
|
+
// before this provider was mounted, e.g. on initial page load).
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setFavorites(loadFavorites());
|
|
56
|
+
}, []);
|
|
57
|
+
const addFavorite = useCallback((item) => {
|
|
58
|
+
setFavorites(prev => {
|
|
59
|
+
if (prev.some(f => f.id === item.id))
|
|
60
|
+
return prev;
|
|
61
|
+
const updated = [
|
|
62
|
+
{ ...item, favoritedAt: new Date().toISOString() },
|
|
63
|
+
...prev,
|
|
64
|
+
].slice(0, MAX_FAVORITES);
|
|
65
|
+
saveFavorites(updated);
|
|
66
|
+
return updated;
|
|
67
|
+
});
|
|
68
|
+
}, []);
|
|
69
|
+
const removeFavorite = useCallback((id) => {
|
|
70
|
+
setFavorites(prev => {
|
|
71
|
+
const updated = prev.filter(f => f.id !== id);
|
|
72
|
+
saveFavorites(updated);
|
|
73
|
+
return updated;
|
|
74
|
+
});
|
|
75
|
+
}, []);
|
|
76
|
+
const toggleFavorite = useCallback((item) => {
|
|
77
|
+
setFavorites(prev => {
|
|
78
|
+
const exists = prev.some(f => f.id === item.id);
|
|
79
|
+
const updated = exists
|
|
80
|
+
? prev.filter(f => f.id !== item.id)
|
|
81
|
+
: [{ ...item, favoritedAt: new Date().toISOString() }, ...prev].slice(0, MAX_FAVORITES);
|
|
82
|
+
saveFavorites(updated);
|
|
83
|
+
return updated;
|
|
84
|
+
});
|
|
85
|
+
}, []);
|
|
86
|
+
const clearFavorites = useCallback(() => {
|
|
87
|
+
setFavorites([]);
|
|
88
|
+
saveFavorites([]);
|
|
89
|
+
}, []);
|
|
90
|
+
const value = useMemo(() => ({
|
|
91
|
+
favorites,
|
|
92
|
+
addFavorite,
|
|
93
|
+
removeFavorite,
|
|
94
|
+
toggleFavorite,
|
|
95
|
+
// Inlined here so useMemo sees the freshest `favorites` without needing
|
|
96
|
+
// a separate useCallback([favorites]) entry in the deps array.
|
|
97
|
+
isFavorite: (id) => favorites.some(f => f.id === id),
|
|
98
|
+
clearFavorites,
|
|
99
|
+
}),
|
|
100
|
+
// addFavorite / removeFavorite / toggleFavorite / clearFavorites are all
|
|
101
|
+
// stable (useCallback with [] deps) and never cause extra re-renders.
|
|
102
|
+
[favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites]);
|
|
103
|
+
return (_jsx(FavoritesContext.Provider, { value: value, children: children }));
|
|
104
|
+
}
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Hook
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
/**
|
|
109
|
+
* Access shared favorites state.
|
|
110
|
+
*
|
|
111
|
+
* Must be used inside `<FavoritesProvider>`.
|
|
112
|
+
*/
|
|
113
|
+
export function useFavorites() {
|
|
114
|
+
const ctx = useContext(FavoritesContext);
|
|
115
|
+
if (!ctx) {
|
|
116
|
+
// Graceful fallback: when a consumer (e.g. AppSidebar) is rendered outside
|
|
117
|
+
// a FavoritesProvider — common in unit tests that only need to assert on
|
|
118
|
+
// navigation rendering — return a no-op implementation rather than crash.
|
|
119
|
+
return {
|
|
120
|
+
favorites: [],
|
|
121
|
+
addFavorite: () => { },
|
|
122
|
+
removeFavorite: () => { },
|
|
123
|
+
toggleFavorite: () => { },
|
|
124
|
+
isFavorite: () => false,
|
|
125
|
+
clearFavorites: () => { },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return ctx;
|
|
129
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavigationContext
|
|
3
|
+
*
|
|
4
|
+
* Provides global navigation state for the unified sidebar and breadcrumb.
|
|
5
|
+
* Tracks whether the user is in "Home" context (workspace view) or "App"
|
|
6
|
+
* context (specific app), plus an optional record-detail title that record
|
|
7
|
+
* pages publish so the top-bar breadcrumb can show the human-readable title
|
|
8
|
+
* (e.g. "Acme Platform Upgrade") instead of the raw record ID.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
import { type ReactNode } from 'react';
|
|
13
|
+
export type NavigationContextType = 'home' | 'app';
|
|
14
|
+
interface NavigationContextValue {
|
|
15
|
+
/** Current navigation context (home or app) */
|
|
16
|
+
context: NavigationContextType;
|
|
17
|
+
/** Set the navigation context */
|
|
18
|
+
setContext: (context: NavigationContextType) => void;
|
|
19
|
+
/** Current app name when in app context */
|
|
20
|
+
currentAppName?: string;
|
|
21
|
+
/** Set the current app name */
|
|
22
|
+
setCurrentAppName: (appName?: string) => void;
|
|
23
|
+
/**
|
|
24
|
+
* Human-readable title of the currently displayed record (when on a record
|
|
25
|
+
* detail page). Published by RecordDetailView once data loads; consumed by
|
|
26
|
+
* AppHeader to replace the raw `#shortId` segment in the breadcrumb.
|
|
27
|
+
*/
|
|
28
|
+
recordTitle?: string;
|
|
29
|
+
/** Update the current record title (or clear it when leaving a record page). */
|
|
30
|
+
setRecordTitle: (title?: string) => void;
|
|
31
|
+
}
|
|
32
|
+
interface NavigationProviderProps {
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
export declare function NavigationProvider({ children }: NavigationProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
36
|
+
/**
|
|
37
|
+
* Hook to access navigation context
|
|
38
|
+
*/
|
|
39
|
+
export declare function useNavigationContext(): NavigationContextValue;
|
|
40
|
+
/**
|
|
41
|
+
* Helper hook for record pages: sets the record title on mount and clears it
|
|
42
|
+
* on unmount, so the breadcrumb only shows a record title while a record page
|
|
43
|
+
* is actually visible.
|
|
44
|
+
*/
|
|
45
|
+
export declare function useRecordBreadcrumbTitle(title: string | undefined): void;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* NavigationContext
|
|
4
|
+
*
|
|
5
|
+
* Provides global navigation state for the unified sidebar and breadcrumb.
|
|
6
|
+
* Tracks whether the user is in "Home" context (workspace view) or "App"
|
|
7
|
+
* context (specific app), plus an optional record-detail title that record
|
|
8
|
+
* pages publish so the top-bar breadcrumb can show the human-readable title
|
|
9
|
+
* (e.g. "Acme Platform Upgrade") instead of the raw record ID.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import { createContext, useContext, useState, useMemo, useEffect } from 'react';
|
|
14
|
+
const NavigationContext = createContext(undefined);
|
|
15
|
+
export function NavigationProvider({ children }) {
|
|
16
|
+
const [context, setContext] = useState('home');
|
|
17
|
+
const [currentAppName, setCurrentAppName] = useState();
|
|
18
|
+
const [recordTitle, setRecordTitle] = useState();
|
|
19
|
+
const value = useMemo(() => ({
|
|
20
|
+
context,
|
|
21
|
+
setContext,
|
|
22
|
+
currentAppName,
|
|
23
|
+
setCurrentAppName,
|
|
24
|
+
recordTitle,
|
|
25
|
+
setRecordTitle,
|
|
26
|
+
}), [context, currentAppName, recordTitle]);
|
|
27
|
+
return (_jsx(NavigationContext.Provider, { value: value, children: children }));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Hook to access navigation context
|
|
31
|
+
*/
|
|
32
|
+
export function useNavigationContext() {
|
|
33
|
+
const context = useContext(NavigationContext);
|
|
34
|
+
if (!context) {
|
|
35
|
+
// Graceful fallback for consumers rendered outside <NavigationProvider>
|
|
36
|
+
// (common in lightweight unit tests). Production paths always wrap.
|
|
37
|
+
return {
|
|
38
|
+
context: 'app',
|
|
39
|
+
setContext: () => { },
|
|
40
|
+
currentAppName: undefined,
|
|
41
|
+
setCurrentAppName: () => { },
|
|
42
|
+
recordTitle: undefined,
|
|
43
|
+
setRecordTitle: () => { },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return context;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Helper hook for record pages: sets the record title on mount and clears it
|
|
50
|
+
* on unmount, so the breadcrumb only shows a record title while a record page
|
|
51
|
+
* is actually visible.
|
|
52
|
+
*/
|
|
53
|
+
export function useRecordBreadcrumbTitle(title) {
|
|
54
|
+
const { setRecordTitle } = useNavigationContext();
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
setRecordTitle(title);
|
|
57
|
+
return () => setRecordTitle(undefined);
|
|
58
|
+
}, [title, setRecordTitle]);
|
|
59
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { useFavorites, type FavoriteItem } from './useFavorites';
|
|
2
|
+
export { useMetadataService } from './useMetadataService';
|
|
3
|
+
export { useNavPins } from './useNavPins';
|
|
4
|
+
export { useNavigationSync, NavigationSyncEffect, generateNavId, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, type UseNavigationSyncReturn, } from './useNavigationSync';
|
|
5
|
+
export { useObjectActions } from './useObjectActions';
|
|
6
|
+
export { useRecentItems, type RecentItem } from './useRecentItems';
|
|
7
|
+
export { useResponsiveSidebar } from './useResponsiveSidebar';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { useFavorites } from './useFavorites';
|
|
2
|
+
export { useMetadataService } from './useMetadataService';
|
|
3
|
+
export { useNavPins } from './useNavPins';
|
|
4
|
+
export { useNavigationSync, NavigationSyncEffect, generateNavId, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, } from './useNavigationSync';
|
|
5
|
+
export { useObjectActions } from './useObjectActions';
|
|
6
|
+
export { useRecentItems } from './useRecentItems';
|
|
7
|
+
export { useResponsiveSidebar } from './useResponsiveSidebar';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFavorites — re-export shim
|
|
3
|
+
*
|
|
4
|
+
* The favorites state has been migrated to a React Context so all consumers
|
|
5
|
+
* share a single state instance (fixes star toggle not updating HomePage).
|
|
6
|
+
*
|
|
7
|
+
* All existing imports of `useFavorites` and `FavoriteItem` from this path
|
|
8
|
+
* continue to work without any changes at the call sites.
|
|
9
|
+
*
|
|
10
|
+
* @see apps/console/src/context/FavoritesProvider.tsx
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
export { useFavorites, type FavoriteItem } from '../context/FavoritesProvider';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFavorites — re-export shim
|
|
3
|
+
*
|
|
4
|
+
* The favorites state has been migrated to a React Context so all consumers
|
|
5
|
+
* share a single state instance (fixes star toggle not updating HomePage).
|
|
6
|
+
*
|
|
7
|
+
* All existing imports of `useFavorites` and `FavoriteItem` from this path
|
|
8
|
+
* continue to work without any changes at the call sites.
|
|
9
|
+
*
|
|
10
|
+
* @see apps/console/src/context/FavoritesProvider.tsx
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
export { useFavorites } from '../context/FavoritesProvider';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useMetadataService
|
|
3
|
+
*
|
|
4
|
+
* React hook that creates a memoised `MetadataService` instance from the
|
|
5
|
+
* current `ObjectStackAdapter` (via `useAdapter`).
|
|
6
|
+
*
|
|
7
|
+
* Returns `null` when the adapter is not yet available (e.g. while
|
|
8
|
+
* the connection is being established).
|
|
9
|
+
*
|
|
10
|
+
* @module hooks/useMetadataService
|
|
11
|
+
*/
|
|
12
|
+
import { MetadataService } from '../services/MetadataService';
|
|
13
|
+
export declare function useMetadataService(): MetadataService | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useMetadataService
|
|
3
|
+
*
|
|
4
|
+
* React hook that creates a memoised `MetadataService` instance from the
|
|
5
|
+
* current `ObjectStackAdapter` (via `useAdapter`).
|
|
6
|
+
*
|
|
7
|
+
* Returns `null` when the adapter is not yet available (e.g. while
|
|
8
|
+
* the connection is being established).
|
|
9
|
+
*
|
|
10
|
+
* @module hooks/useMetadataService
|
|
11
|
+
*/
|
|
12
|
+
import { useMemo } from 'react';
|
|
13
|
+
import { useAdapter } from '../providers/AdapterProvider';
|
|
14
|
+
import { MetadataService } from '../services/MetadataService';
|
|
15
|
+
export function useMetadataService() {
|
|
16
|
+
const adapter = useAdapter();
|
|
17
|
+
return useMemo(() => (adapter ? new MetadataService(adapter) : null), [adapter]);
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useNavPins
|
|
3
|
+
*
|
|
4
|
+
* Tracks user-pinned navigation items with localStorage persistence.
|
|
5
|
+
* Pinned items are shown at the top of the sidebar as a "Pinned" section.
|
|
6
|
+
* Works alongside the existing `useFavorites` hook (record-level favorites).
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import type { NavigationItem } from '@object-ui/types';
|
|
10
|
+
export declare function useNavPins(): {
|
|
11
|
+
pinnedIds: string[];
|
|
12
|
+
togglePin: (itemId: string, pinned: boolean) => void;
|
|
13
|
+
isPinned: (itemId: string) => boolean;
|
|
14
|
+
applyPins: (items: NavigationItem[]) => NavigationItem[];
|
|
15
|
+
clearPins: () => void;
|
|
16
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useNavPins
|
|
3
|
+
*
|
|
4
|
+
* Tracks user-pinned navigation items with localStorage persistence.
|
|
5
|
+
* Pinned items are shown at the top of the sidebar as a "Pinned" section.
|
|
6
|
+
* Works alongside the existing `useFavorites` hook (record-level favorites).
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
10
|
+
const STORAGE_KEY = 'objectui-nav-pins';
|
|
11
|
+
const MAX_PINS = 20;
|
|
12
|
+
function loadPins() {
|
|
13
|
+
try {
|
|
14
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
15
|
+
if (!raw)
|
|
16
|
+
return [];
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
if (!Array.isArray(parsed))
|
|
19
|
+
return [];
|
|
20
|
+
return parsed.filter((id) => typeof id === 'string');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function savePins(ids) {
|
|
27
|
+
try {
|
|
28
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Storage full — silently ignore
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function useNavPins() {
|
|
35
|
+
const [pinnedIds, setPinnedIds] = useState(loadPins);
|
|
36
|
+
// Sync from storage on mount
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setPinnedIds(loadPins());
|
|
39
|
+
}, []);
|
|
40
|
+
const togglePin = useCallback((itemId, pinned) => {
|
|
41
|
+
setPinnedIds(prev => {
|
|
42
|
+
const filtered = prev.filter(id => id !== itemId);
|
|
43
|
+
const updated = pinned
|
|
44
|
+
? [...filtered, itemId].slice(0, MAX_PINS)
|
|
45
|
+
: filtered;
|
|
46
|
+
savePins(updated);
|
|
47
|
+
return updated;
|
|
48
|
+
});
|
|
49
|
+
}, []);
|
|
50
|
+
const isPinned = useCallback((itemId) => pinnedIds.includes(itemId), [pinnedIds]);
|
|
51
|
+
/**
|
|
52
|
+
* Apply pinned state to a navigation item tree.
|
|
53
|
+
* Returns new items with `pinned` property set based on stored pin state.
|
|
54
|
+
*/
|
|
55
|
+
const applyPins = useCallback((items) => {
|
|
56
|
+
return items.map(item => {
|
|
57
|
+
const pinned = pinnedIds.includes(item.id);
|
|
58
|
+
const children = item.children?.length
|
|
59
|
+
? applyPins(item.children)
|
|
60
|
+
: item.children;
|
|
61
|
+
if (pinned !== (item.pinned ?? false) || children !== item.children) {
|
|
62
|
+
return { ...item, pinned, children };
|
|
63
|
+
}
|
|
64
|
+
return item;
|
|
65
|
+
});
|
|
66
|
+
}, [pinnedIds]);
|
|
67
|
+
const clearPins = useCallback(() => {
|
|
68
|
+
setPinnedIds([]);
|
|
69
|
+
savePins([]);
|
|
70
|
+
}, []);
|
|
71
|
+
return { pinnedIds, togglePin, isPinned, applyPins, clearPins };
|
|
72
|
+
}
|