@object-ui/app-shell 4.5.0 → 4.7.0
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 +33 -0
- package/README.md +21 -0
- package/dist/console/AppContent.js +6 -39
- package/dist/console/ConsoleShell.js +45 -4
- package/dist/console/home/AppCard.d.ts +2 -1
- package/dist/console/home/AppCard.js +24 -5
- package/dist/console/home/HomePage.js +29 -7
- package/dist/console/home/QuickActions.js +13 -7
- package/dist/console/home/RecentApps.js +12 -5
- package/dist/console/home/StarredApps.js +12 -5
- package/dist/context/FavoritesProvider.d.ts +9 -12
- package/dist/context/FavoritesProvider.js +83 -37
- package/dist/context/RecentItemsProvider.d.ts +45 -0
- package/dist/context/RecentItemsProvider.js +139 -0
- package/dist/context/UserStateAdapters.d.ts +61 -0
- package/dist/context/UserStateAdapters.js +141 -0
- package/dist/context/index.d.ts +4 -0
- package/dist/context/index.js +2 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useRecentItems.d.ts +9 -17
- package/dist/hooks/useRecentItems.js +9 -46
- package/dist/hooks/useTrackRouteAsRecent.d.ts +18 -0
- package/dist/hooks/useTrackRouteAsRecent.js +91 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/layout/PageHeader.d.ts +29 -2
- package/dist/layout/PageHeader.js +20 -2
- package/dist/utils/resolveActionParams.d.ts +11 -0
- package/dist/utils/resolveActionParams.js +19 -0
- package/dist/views/ActionParamDialog.js +31 -2
- package/dist/views/ObjectView.js +63 -164
- package/dist/views/RecordDetailView.d.ts +20 -1
- package/dist/views/RecordDetailView.js +23 -9
- package/package.json +24 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @object-ui/app-shell — Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @object-ui/types@4.7.0
|
|
8
|
+
- @object-ui/core@4.7.0
|
|
9
|
+
- @object-ui/i18n@4.7.0
|
|
10
|
+
- @object-ui/react@4.7.0
|
|
11
|
+
- @object-ui/components@4.7.0
|
|
12
|
+
- @object-ui/fields@4.7.0
|
|
13
|
+
- @object-ui/layout@4.7.0
|
|
14
|
+
- @object-ui/data-objectstack@4.7.0
|
|
15
|
+
- @object-ui/auth@4.7.0
|
|
16
|
+
- @object-ui/permissions@4.7.0
|
|
17
|
+
- @object-ui/collaboration@4.7.0
|
|
18
|
+
|
|
19
|
+
## 4.6.0
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [3ee436d]
|
|
24
|
+
- @object-ui/components@4.6.0
|
|
25
|
+
- @object-ui/fields@4.6.0
|
|
26
|
+
- @object-ui/layout@4.6.0
|
|
27
|
+
- @object-ui/types@4.6.0
|
|
28
|
+
- @object-ui/core@4.6.0
|
|
29
|
+
- @object-ui/i18n@4.6.0
|
|
30
|
+
- @object-ui/react@4.6.0
|
|
31
|
+
- @object-ui/data-objectstack@4.6.0
|
|
32
|
+
- @object-ui/auth@4.6.0
|
|
33
|
+
- @object-ui/permissions@4.6.0
|
|
34
|
+
- @object-ui/collaboration@4.6.0
|
|
35
|
+
|
|
3
36
|
## 4.5.0
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -229,6 +229,27 @@ via the action runner, regardless of the object's `editMode`:
|
|
|
229
229
|
See [`content/docs/guide/record-edit-modes.md`](../../content/docs/guide/record-edit-modes.md)
|
|
230
230
|
for a longer walkthrough.
|
|
231
231
|
|
|
232
|
+
## User-scoped state (favorites, recent items)
|
|
233
|
+
|
|
234
|
+
`<ConsoleShell>` includes `FavoritesProvider` and `RecentItemsProvider` —
|
|
235
|
+
shared, user-scoped state for pinned apps and recently visited entities.
|
|
236
|
+
|
|
237
|
+
Both providers are **localStorage-first**: instant first paint, no flash of
|
|
238
|
+
empty UI. If a `UserDataAdapter` is attached via `UserStateAdaptersProvider`,
|
|
239
|
+
they additionally hydrate from and write through to a backend (debounced).
|
|
240
|
+
The official ObjectStack adapter lives in `@object-ui/data-objectstack`
|
|
241
|
+
(`createObjectStackUserStateAdapter`).
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
import { useFavorites, useRecentItems } from '@object-ui/app-shell';
|
|
245
|
+
|
|
246
|
+
const { favorites, toggleFavorite, isFavorite } = useFavorites();
|
|
247
|
+
const { recentItems, addRecentItem } = useRecentItems();
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
See [User-Scoped State Persistence](../../content/docs/guide/user-state-persistence.md)
|
|
251
|
+
for the adapter contract, backend schema, and how to plug in your own backend.
|
|
252
|
+
|
|
232
253
|
<!-- release-metadata:v3.3.0 -->
|
|
233
254
|
|
|
234
255
|
## Compatibility
|
|
@@ -19,7 +19,7 @@ import { useAuth } from '@object-ui/auth';
|
|
|
19
19
|
import { useMetadata } from '../providers/MetadataProvider';
|
|
20
20
|
import { useAdapter } from '../providers/AdapterProvider';
|
|
21
21
|
import { ExpressionProvider, evaluateVisibility } from '../providers/ExpressionProvider';
|
|
22
|
-
import {
|
|
22
|
+
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent';
|
|
23
23
|
import { resolveRecordFormTarget, resolveNavigateCreateUrl, resolveNavigateEditUrl } from '../utils/recordFormNavigation';
|
|
24
24
|
import { ExpressionEvaluator } from '@object-ui/core';
|
|
25
25
|
// Components (eagerly loaded — always needed)
|
|
@@ -82,7 +82,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
82
82
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
83
83
|
const [editingRecord, setEditingRecord] = useState(null);
|
|
84
84
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
85
|
-
const { addRecentItem } = useRecentItems();
|
|
86
85
|
const { execute: executeAction, runner } = useActionRunner();
|
|
87
86
|
useGlobalUndo({
|
|
88
87
|
dataSource: dataSource ?? undefined,
|
|
@@ -190,43 +189,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
|
|
|
190
189
|
executeAction({ type: 'dialog_cancel' });
|
|
191
190
|
}, [executeAction]);
|
|
192
191
|
// Track recent items on route change.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (objName === 'view' || objName === 'record' || objName === 'page' || objName === 'dashboard' || objName === 'design') {
|
|
199
|
-
objName = '';
|
|
200
|
-
}
|
|
201
|
-
const basePath = `/apps/${activeApp.name}`;
|
|
202
|
-
if (objName) {
|
|
203
|
-
const obj = allObjects.find((o) => o.name === objName);
|
|
204
|
-
if (obj) {
|
|
205
|
-
addRecentItem({
|
|
206
|
-
id: `object:${obj.name}`,
|
|
207
|
-
label: obj.label || obj.name,
|
|
208
|
-
href: `${basePath}/${obj.name}`,
|
|
209
|
-
type: 'object',
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
else if (parts[2] === 'dashboard' && parts[3]) {
|
|
214
|
-
addRecentItem({
|
|
215
|
-
id: `dashboard:${parts[3]}`,
|
|
216
|
-
label: parts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
217
|
-
href: `${basePath}/dashboard/${parts[3]}`,
|
|
218
|
-
type: 'dashboard',
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
else if (parts[2] === 'report' && parts[3]) {
|
|
222
|
-
addRecentItem({
|
|
223
|
-
id: `report:${parts[3]}`,
|
|
224
|
-
label: parts[3].replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
225
|
-
href: `${basePath}/report/${parts[3]}`,
|
|
226
|
-
type: 'report',
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
}, [location.pathname, addRecentItem]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
192
|
+
useTrackRouteAsRecent({
|
|
193
|
+
pathname: location.pathname,
|
|
194
|
+
appName: activeApp?.name,
|
|
195
|
+
objects: allObjects,
|
|
196
|
+
});
|
|
230
197
|
const handleEdit = (record) => {
|
|
231
198
|
// Page-mode opt-in: when the object metadata declares
|
|
232
199
|
// `editMode: 'page'`, route to the full-screen create/edit page instead
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* Console building blocks — composable JSX elements that consumers assemble in
|
|
4
4
|
* their own App.tsx to build the console routing tree.
|
|
@@ -10,14 +10,17 @@ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
|
10
10
|
* JSX in App.tsx. See examples/console-starter/src/App.tsx for a minimal example,
|
|
11
11
|
* apps/console/src/App.tsx for one with custom system routes + CreateApp.
|
|
12
12
|
*/
|
|
13
|
-
import { Suspense } from 'react';
|
|
13
|
+
import { Suspense, useEffect } from 'react';
|
|
14
14
|
import { Navigate, useLocation } from 'react-router-dom';
|
|
15
15
|
import { AuthGuard, useAuth } from '@object-ui/auth';
|
|
16
16
|
import { SchemaRendererProvider } from '@object-ui/react';
|
|
17
|
+
import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
|
|
17
18
|
import { AdapterProvider, useAdapter } from '../providers/AdapterProvider';
|
|
18
19
|
import { MetadataProvider, useMetadata } from '../providers/MetadataProvider';
|
|
19
20
|
import { NavigationProvider } from '../context/NavigationContext';
|
|
20
21
|
import { FavoritesProvider } from '../context/FavoritesProvider';
|
|
22
|
+
import { RecentItemsProvider } from '../context/RecentItemsProvider';
|
|
23
|
+
import { UserStateAdaptersProvider, useAttachUserStateAdapters, } from '../context/UserStateAdapters';
|
|
21
24
|
import { ThemeProvider } from '../chrome/ThemeProvider';
|
|
22
25
|
export function LoadingFallback() {
|
|
23
26
|
return (_jsx("div", { className: "h-screen flex items-center justify-center text-sm text-muted-foreground", children: "Loading\u2026" }));
|
|
@@ -37,7 +40,7 @@ export function LoadingFallback() {
|
|
|
37
40
|
* </BrowserRouter>
|
|
38
41
|
*/
|
|
39
42
|
export function ConsoleShell({ children }) {
|
|
40
|
-
return (_jsx(ThemeProvider, { defaultTheme: "system", storageKey: "object-ui-theme", children: _jsx(NavigationProvider, { children: _jsx(FavoritesProvider, { children: _jsx(Suspense, { fallback: _jsx(LoadingFallback, {}), children: children }) }) }) }));
|
|
43
|
+
return (_jsx(ThemeProvider, { defaultTheme: "system", storageKey: "object-ui-theme", children: _jsx(NavigationProvider, { children: _jsx(UserStateAdaptersProvider, { children: _jsx(FavoritesProvider, { children: _jsx(RecentItemsProvider, { children: _jsx(Suspense, { fallback: _jsx(LoadingFallback, {}), children: children }) }) }) }) }) }));
|
|
41
44
|
}
|
|
42
45
|
/**
|
|
43
46
|
* ConnectedShell — mounts the data layer (AdapterProvider + MetadataProvider).
|
|
@@ -53,7 +56,45 @@ function ConnectedShellInner({ children }) {
|
|
|
53
56
|
return _jsx(LoadingFallback, {});
|
|
54
57
|
// Expose the adapter via SchemaRendererContext so descendant hooks like
|
|
55
58
|
// useDiscovery() (used to gate the global AI chatbot) can resolve it.
|
|
56
|
-
return (_jsx(SchemaRendererProvider, { dataSource: adapter, children:
|
|
59
|
+
return (_jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsxs(MetadataProvider, { adapter: adapter, children: [_jsx(UserStateBridge, {}), children] }) }));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* UserStateBridge — once we have an authenticated user + a connected data
|
|
63
|
+
* adapter, plug ObjectStack-backed persistence into the favorites and
|
|
64
|
+
* recent-items providers. Renders nothing.
|
|
65
|
+
*
|
|
66
|
+
* Failure modes (object schema not configured, network errors, etc.) are
|
|
67
|
+
* absorbed by the adapter itself — the UI then transparently falls back to
|
|
68
|
+
* localStorage-only behaviour.
|
|
69
|
+
*/
|
|
70
|
+
function UserStateBridge() {
|
|
71
|
+
const { user } = useAuth();
|
|
72
|
+
const dataSource = useAdapter();
|
|
73
|
+
const attach = useAttachUserStateAdapters();
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!user?.id || !dataSource) {
|
|
76
|
+
attach('favorites', null);
|
|
77
|
+
attach('recent', null);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const favorites = createObjectStackUserStateAdapter({
|
|
81
|
+
dataSource,
|
|
82
|
+
userId: user.id,
|
|
83
|
+
kind: 'favorites',
|
|
84
|
+
});
|
|
85
|
+
const recent = createObjectStackUserStateAdapter({
|
|
86
|
+
dataSource,
|
|
87
|
+
userId: user.id,
|
|
88
|
+
kind: 'recent',
|
|
89
|
+
});
|
|
90
|
+
attach('favorites', favorites);
|
|
91
|
+
attach('recent', recent);
|
|
92
|
+
return () => {
|
|
93
|
+
attach('favorites', null);
|
|
94
|
+
attach('recent', null);
|
|
95
|
+
};
|
|
96
|
+
}, [user?.id, dataSource, attach]);
|
|
97
|
+
return null;
|
|
57
98
|
}
|
|
58
99
|
/**
|
|
59
100
|
* RequireOrganization — redirects to /organizations when the multi-tenant
|
|
@@ -9,6 +9,7 @@ interface AppCardProps {
|
|
|
9
9
|
app: any;
|
|
10
10
|
onClick: () => void;
|
|
11
11
|
isFavorite: boolean;
|
|
12
|
+
index?: number;
|
|
12
13
|
}
|
|
13
|
-
export declare function AppCard({ app, onClick, isFavorite }: AppCardProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
export declare function AppCard({ app, onClick, isFavorite, index }: AppCardProps): import("react/jsx-runtime").JSX.Element;
|
|
14
15
|
export {};
|
|
@@ -6,14 +6,30 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import { Star, StarOff } from 'lucide-react';
|
|
9
|
+
import { Star, StarOff, ArrowUpRight } from 'lucide-react';
|
|
10
10
|
import { Card, CardContent, Button, Badge } from '@object-ui/components';
|
|
11
11
|
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
|
|
12
12
|
import { resolveI18nLabel } from '../../utils';
|
|
13
13
|
import { useFavorites } from '../../hooks/useFavorites';
|
|
14
14
|
import { getIcon } from '../../utils/getIcon';
|
|
15
15
|
import { cn } from '@object-ui/components';
|
|
16
|
-
|
|
16
|
+
// Deterministic accent palette for apps that don't declare a brand color.
|
|
17
|
+
const ACCENTS = [
|
|
18
|
+
{ from: 'from-blue-500/15', to: 'to-indigo-500/10', text: 'text-blue-600 dark:text-blue-400', ring: 'group-hover:border-blue-500/40', solid: 'bg-blue-500' },
|
|
19
|
+
{ from: 'from-emerald-500/15', to: 'to-teal-500/10', text: 'text-emerald-600 dark:text-emerald-400', ring: 'group-hover:border-emerald-500/40', solid: 'bg-emerald-500' },
|
|
20
|
+
{ from: 'from-fuchsia-500/15', to: 'to-pink-500/10', text: 'text-fuchsia-600 dark:text-fuchsia-400', ring: 'group-hover:border-fuchsia-500/40', solid: 'bg-fuchsia-500' },
|
|
21
|
+
{ from: 'from-amber-500/15', to: 'to-orange-500/10', text: 'text-amber-600 dark:text-amber-400', ring: 'group-hover:border-amber-500/40', solid: 'bg-amber-500' },
|
|
22
|
+
{ from: 'from-sky-500/15', to: 'to-cyan-500/10', text: 'text-sky-600 dark:text-sky-400', ring: 'group-hover:border-sky-500/40', solid: 'bg-sky-500' },
|
|
23
|
+
{ from: 'from-violet-500/15', to: 'to-purple-500/10', text: 'text-violet-600 dark:text-violet-400', ring: 'group-hover:border-violet-500/40', solid: 'bg-violet-500' },
|
|
24
|
+
{ from: 'from-rose-500/15', to: 'to-red-500/10', text: 'text-rose-600 dark:text-rose-400', ring: 'group-hover:border-rose-500/40', solid: 'bg-rose-500' },
|
|
25
|
+
];
|
|
26
|
+
function hashStr(s) {
|
|
27
|
+
let h = 0;
|
|
28
|
+
for (let i = 0; i < s.length; i++)
|
|
29
|
+
h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
30
|
+
return Math.abs(h);
|
|
31
|
+
}
|
|
32
|
+
export function AppCard({ app, onClick, isFavorite, index = 0 }) {
|
|
17
33
|
const { t } = useObjectTranslation();
|
|
18
34
|
const { appLabel, appDescription } = useObjectLabel();
|
|
19
35
|
const { toggleFavorite } = useFavorites();
|
|
@@ -21,6 +37,7 @@ export function AppCard({ app, onClick, isFavorite }) {
|
|
|
21
37
|
const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) });
|
|
22
38
|
const description = appDescription({ name: app.name, description: resolveI18nLabel(app.description, t) });
|
|
23
39
|
const primaryColor = app.branding?.primaryColor;
|
|
40
|
+
const accent = ACCENTS[(hashStr(app.name) + index) % ACCENTS.length];
|
|
24
41
|
const handleToggleFavorite = (e) => {
|
|
25
42
|
e.stopPropagation();
|
|
26
43
|
toggleFavorite({
|
|
@@ -30,7 +47,9 @@ export function AppCard({ app, onClick, isFavorite }) {
|
|
|
30
47
|
type: 'object',
|
|
31
48
|
});
|
|
32
49
|
};
|
|
33
|
-
return (
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
return (_jsxs(Card, { className: cn('group relative cursor-pointer overflow-hidden border border-border/70 bg-card/80 backdrop-blur-sm', 'transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg', !primaryColor && accent.ring), onClick: onClick, "data-testid": `app-card-${app.name}`, style: primaryColor ? { borderColor: undefined } : undefined, children: [_jsx("div", { "aria-hidden": true, className: cn('absolute inset-x-0 top-0 h-1', primaryColor ? '' : accent.solid), style: primaryColor ? { backgroundColor: primaryColor } : undefined }), !primaryColor && (_jsx("div", { "aria-hidden": true, className: cn('absolute inset-0 bg-gradient-to-br opacity-0 transition-opacity duration-300 group-hover:opacity-100', accent.from, accent.to) })), _jsxs(CardContent, { className: "relative p-5", children: [_jsx(Button, { variant: "ghost", size: "sm", className: "absolute top-2 right-2 h-8 w-8 p-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity", onClick: handleToggleFavorite, "aria-label": isFavorite
|
|
51
|
+
? t('common.removeFromFavorites', { defaultValue: 'Remove from favorites' }) + ` — ${label}`
|
|
52
|
+
: t('common.addToFavorites', { defaultValue: 'Add to favorites' }) + ` — ${label}`, "aria-pressed": isFavorite, "data-testid": `favorite-btn-${app.name}`, children: isFavorite ? (_jsx(Star, { className: "h-4 w-4 fill-amber-400 text-amber-400" })) : (_jsx(StarOff, { className: "h-4 w-4" })) }), _jsx("div", { className: cn('inline-flex h-14 w-14 items-center justify-center rounded-xl mb-4 ring-1 ring-inset', primaryColor ? '' : cn('bg-gradient-to-br', accent.from, accent.to, 'ring-border/40')), style: primaryColor
|
|
53
|
+
? { backgroundColor: `${primaryColor}1f`, boxShadow: `inset 0 0 0 1px ${primaryColor}33` }
|
|
54
|
+
: undefined, children: _jsx(Icon, { className: cn('h-7 w-7', primaryColor ? '' : accent.text), style: primaryColor ? { color: primaryColor } : undefined }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("h3", { className: "font-semibold text-base sm:text-lg leading-tight truncate", children: label }), app.isDefault && (_jsx(Badge, { variant: "secondary", className: "shrink-0 text-[10px] px-1.5 py-0", children: t('home.appCard.default', { defaultValue: 'Default' }) }))] }), _jsx("p", { className: cn('text-sm text-muted-foreground mt-1.5 line-clamp-2 min-h-[2.5rem]', !description && 'italic'), children: description || t('home.appCard.noDescription', { defaultValue: 'No description' }) })] }), _jsx("div", { className: "mt-4 flex items-center justify-between text-xs font-medium", children: _jsxs("span", { className: "inline-flex items-center gap-1 text-muted-foreground transition-colors group-hover:text-foreground", children: [t('home.open', { defaultValue: 'Open' }), _jsx(ArrowUpRight, { className: "h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" })] }) })] })] }));
|
|
36
55
|
}
|
|
@@ -16,40 +16,62 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
16
16
|
*
|
|
17
17
|
* @module
|
|
18
18
|
*/
|
|
19
|
+
import { useMemo } from 'react';
|
|
19
20
|
import { useNavigate } from 'react-router-dom';
|
|
20
21
|
import { useMetadata } from '../../providers/MetadataProvider';
|
|
21
22
|
import { useRecentItems } from '../../hooks/useRecentItems';
|
|
22
23
|
import { useFavorites } from '../../hooks/useFavorites';
|
|
23
24
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
25
|
+
import { useAuth } from '@object-ui/auth';
|
|
24
26
|
import { AppCard } from './AppCard';
|
|
25
27
|
import { RecentApps } from './RecentApps';
|
|
26
28
|
import { StarredApps } from './StarredApps';
|
|
27
29
|
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
|
|
28
|
-
import { Plus, Settings } from 'lucide-react';
|
|
30
|
+
import { Plus, Settings, Sparkles, Star, Clock, ArrowDown } from 'lucide-react';
|
|
31
|
+
function pickGreetingKey(hour) {
|
|
32
|
+
if (hour < 5)
|
|
33
|
+
return 'home.greetingNight';
|
|
34
|
+
if (hour < 12)
|
|
35
|
+
return 'home.greetingMorning';
|
|
36
|
+
if (hour < 18)
|
|
37
|
+
return 'home.greetingAfternoon';
|
|
38
|
+
if (hour < 23)
|
|
39
|
+
return 'home.greetingEvening';
|
|
40
|
+
return 'home.greetingNight';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Friendly onboarding hint shown above the All Apps grid when the user has
|
|
44
|
+
* no starred or recent items yet. Replaces per-section empty states (which
|
|
45
|
+
* would flicker as the backend adapter hydrates).
|
|
46
|
+
*/
|
|
47
|
+
function GettingStartedHint({ t }) {
|
|
48
|
+
return (_jsxs("section", { "data-testid": "home-getting-started", className: "relative overflow-hidden rounded-2xl border border-border/60 bg-card/70 backdrop-blur-sm p-6 sm:p-8", children: [_jsx("div", { "aria-hidden": true, className: "pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-fuchsia-500/5" }), _jsxs("div", { className: "relative flex flex-col sm:flex-row sm:items-center gap-5", children: [_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsx("span", { className: "inline-flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10 ring-1 ring-amber-500/20 text-amber-600 dark:text-amber-400", children: _jsx(Star, { className: "h-5 w-5" }) }), _jsx("span", { className: "inline-flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/10 ring-1 ring-emerald-500/20 text-emerald-600 dark:text-emerald-400", children: _jsx(Clock, { className: "h-5 w-5" }) })] }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h2", { className: "text-lg font-semibold tracking-tight", children: t('home.gettingStarted.title', { defaultValue: 'Make this home yours' }) }), _jsx("p", { className: "text-sm text-muted-foreground mt-1 max-w-xl", children: t('home.gettingStarted.description', {
|
|
49
|
+
defaultValue: 'Star an app to pin it here for one-click access. Anything you open will show up under Recently Accessed automatically.',
|
|
50
|
+
}) })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs font-medium text-muted-foreground", children: [_jsx("span", { children: t('home.gettingStarted.cta', { defaultValue: 'Browse all applications' }) }), _jsx(ArrowDown, { className: "h-3.5 w-3.5" })] })] })] }));
|
|
51
|
+
}
|
|
29
52
|
export function HomePage() {
|
|
30
53
|
const navigate = useNavigate();
|
|
31
54
|
const { t } = useObjectTranslation();
|
|
32
55
|
const { apps, loading } = useMetadata();
|
|
33
56
|
const { recentItems } = useRecentItems();
|
|
34
57
|
const { favorites } = useFavorites();
|
|
35
|
-
|
|
58
|
+
const { user } = useAuth();
|
|
36
59
|
const activeApps = apps.filter((a) => a.active !== false);
|
|
37
|
-
// Get recent apps (only apps, not objects/dashboards)
|
|
38
60
|
const recentApps = recentItems
|
|
39
61
|
.filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page')
|
|
40
62
|
.slice(0, 6);
|
|
41
|
-
// Get starred apps
|
|
42
63
|
const starredApps = favorites
|
|
43
64
|
.filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page')
|
|
44
65
|
.slice(0, 8);
|
|
66
|
+
const greeting = useMemo(() => t(pickGreetingKey(new Date().getHours()), { defaultValue: 'Welcome' }), [t]);
|
|
67
|
+
const displayName = (user?.name?.trim() || user?.email?.split('@')[0] || '').trim();
|
|
45
68
|
if (loading) {
|
|
46
|
-
return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx("div", { className: "text-muted-foreground", children:
|
|
69
|
+
return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx("div", { className: "text-muted-foreground", children: t('home.loading', { defaultValue: 'Loading workspace...' }) }) }));
|
|
47
70
|
}
|
|
48
|
-
// Empty state - no apps configured
|
|
49
71
|
if (activeApps.length === 0) {
|
|
50
72
|
return (_jsx("div", { className: "flex flex-1 items-center justify-center p-6", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('home.welcome', { defaultValue: 'Welcome to ObjectUI' }) }), _jsx(EmptyDescription, { children: t('home.welcomeDescription', {
|
|
51
73
|
defaultValue: 'Get started by creating your first application or configure your system settings.',
|
|
52
74
|
}) }), _jsxs("div", { className: "mt-6 flex flex-col sm:flex-row items-center gap-3", children: [_jsxs(Button, { onClick: () => navigate('/create-app'), "data-testid": "create-first-app-btn", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('home.createFirstApp', { defaultValue: 'Create Your First App' })] }), _jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup'), "data-testid": "go-to-settings-btn", children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('home.systemSettings', { defaultValue: 'System Settings' })] })] })] }) }));
|
|
53
75
|
}
|
|
54
|
-
return (_jsxs("div", { className: "bg-background", children: [_jsxs("div", { className: "px-4 sm:px-6 pt-
|
|
76
|
+
return (_jsxs("div", { className: "relative isolate min-h-full bg-gradient-to-b from-background via-background to-muted/40", children: [_jsxs("div", { "aria-hidden": true, className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[28rem] overflow-hidden", children: [_jsx("div", { className: "absolute -top-32 -left-24 h-[28rem] w-[28rem] rounded-full bg-primary/30 blur-3xl opacity-70 dark:opacity-40" }), _jsx("div", { className: "absolute -top-20 right-[-6rem] h-[26rem] w-[36rem] rounded-full bg-sky-400/30 blur-3xl opacity-70 dark:opacity-35" }), _jsx("div", { className: "absolute top-32 left-1/3 h-[18rem] w-[24rem] rounded-full bg-fuchsia-400/25 blur-3xl opacity-60 dark:opacity-25" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-b from-transparent via-background/40 to-background" })] }), _jsx("section", { className: "px-4 sm:px-6 lg:px-8 pt-10 pb-6", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsxs("div", { className: "flex items-center gap-2 text-xs font-medium text-muted-foreground mb-3", children: [_jsx(Sparkles, { className: "h-3.5 w-3.5 text-primary" }), _jsx("span", { className: "uppercase tracking-wider", children: t('home.title', { defaultValue: 'Home' }) })] }), _jsxs("h1", { className: "text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight", children: [_jsxs("span", { className: "bg-gradient-to-r from-foreground via-foreground to-foreground/70 bg-clip-text text-transparent", children: [greeting, displayName ? `, ${displayName}` : ''] }), _jsx("span", { className: "text-foreground/40", children: "." })] }), _jsx("p", { className: "text-base sm:text-lg text-muted-foreground mt-2 max-w-2xl", children: t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' }) })] }) }), _jsx("div", { className: "px-4 sm:px-6 lg:px-8 pb-16", children: _jsxs("div", { className: "max-w-7xl mx-auto space-y-10", children: [starredApps.length === 0 && recentApps.length === 0 && (_jsx(GettingStartedHint, { t: t })), starredApps.length > 0 && _jsx(StarredApps, { items: starredApps }), recentApps.length > 0 && _jsx(RecentApps, { items: recentApps }), _jsxs("section", { children: [_jsx("div", { className: "flex items-end justify-between mb-5", children: _jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsxs("p", { className: "text-sm text-muted-foreground mt-1", children: [activeApps.length, ' · ', t('home.stats.apps', { defaultValue: 'Applications' })] })] }) }), _jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app, idx) => (_jsx(AppCard, { app: app, index: idx, onClick: () => navigate(`/apps/${app.name}`), isFavorite: favorites.some(f => f.id === `app:${app.name}`) }, app.name))) })] })] }) })] }));
|
|
55
77
|
}
|
|
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
import { useNavigate } from 'react-router-dom';
|
|
11
11
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
12
12
|
import { Card, CardContent } from '@object-ui/components';
|
|
13
|
-
import { Plus, Settings, Database } from 'lucide-react';
|
|
13
|
+
import { Plus, Settings, Database, ArrowUpRight } from 'lucide-react';
|
|
14
14
|
import { cn } from '@object-ui/components';
|
|
15
15
|
export function QuickActions() {
|
|
16
16
|
const navigate = useNavigate();
|
|
@@ -22,7 +22,9 @@ export function QuickActions() {
|
|
|
22
22
|
description: t('home.quickActions.createAppDesc', { defaultValue: 'Start with a new application' }),
|
|
23
23
|
icon: Plus,
|
|
24
24
|
href: '/create-app',
|
|
25
|
-
|
|
25
|
+
iconBg: 'bg-gradient-to-br from-blue-500/15 to-indigo-500/10 ring-blue-500/20',
|
|
26
|
+
iconText: 'text-blue-600 dark:text-blue-400',
|
|
27
|
+
hoverBorder: 'hover:border-blue-500/40',
|
|
26
28
|
},
|
|
27
29
|
{
|
|
28
30
|
id: 'manage-objects',
|
|
@@ -30,7 +32,9 @@ export function QuickActions() {
|
|
|
30
32
|
description: t('home.quickActions.manageObjectsDesc', { defaultValue: 'Configure data models' }),
|
|
31
33
|
icon: Database,
|
|
32
34
|
href: '/apps/setup/system/metadata/object',
|
|
33
|
-
|
|
35
|
+
iconBg: 'bg-gradient-to-br from-violet-500/15 to-purple-500/10 ring-violet-500/20',
|
|
36
|
+
iconText: 'text-violet-600 dark:text-violet-400',
|
|
37
|
+
hoverBorder: 'hover:border-violet-500/40',
|
|
34
38
|
},
|
|
35
39
|
{
|
|
36
40
|
id: 'system-settings',
|
|
@@ -38,16 +42,18 @@ export function QuickActions() {
|
|
|
38
42
|
description: t('home.quickActions.systemSettingsDesc', { defaultValue: 'Configure your workspace' }),
|
|
39
43
|
icon: Settings,
|
|
40
44
|
href: '/apps/setup',
|
|
41
|
-
|
|
45
|
+
iconBg: 'bg-gradient-to-br from-emerald-500/15 to-teal-500/10 ring-emerald-500/20',
|
|
46
|
+
iconText: 'text-emerald-600 dark:text-emerald-400',
|
|
47
|
+
hoverBorder: 'hover:border-emerald-500/40',
|
|
42
48
|
},
|
|
43
49
|
];
|
|
44
|
-
return (_jsxs("section", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight mb-
|
|
50
|
+
return (_jsxs("section", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight mb-5", children: t('home.quickActions.title', { defaultValue: 'Quick Actions' }) }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-3 gap-4", children: actions.map((action) => {
|
|
45
51
|
const Icon = action.icon;
|
|
46
|
-
return (_jsx(Card, { className:
|
|
52
|
+
return (_jsx(Card, { className: cn('group cursor-pointer border border-border/70 bg-card/80 backdrop-blur-sm', 'transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', action.hoverBorder), onClick: () => navigate(action.href), "data-testid": `quick-action-${action.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
|
|
47
53
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
48
54
|
e.preventDefault();
|
|
49
55
|
navigate(action.href);
|
|
50
56
|
}
|
|
51
|
-
}, "aria-label": action.label, children: _jsx(CardContent, { className: "p-
|
|
57
|
+
}, "aria-label": action.label, children: _jsx(CardContent, { className: "p-5", children: _jsxs("div", { className: "flex items-start gap-4", children: [_jsx("div", { className: cn('inline-flex h-11 w-11 items-center justify-center rounded-xl ring-1 ring-inset shrink-0', action.iconBg), children: _jsx(Icon, { className: cn('h-5 w-5', action.iconText) }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("h3", { className: "font-semibold text-base leading-tight", children: action.label }), _jsx(ArrowUpRight, { className: "h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-all duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: action.description })] })] }) }) }, action.id));
|
|
52
58
|
}) })] }));
|
|
53
59
|
}
|
|
@@ -8,25 +8,32 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
*/
|
|
9
9
|
import { useNavigate } from 'react-router-dom';
|
|
10
10
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
11
|
-
import { Card, CardContent } from '@object-ui/components';
|
|
12
|
-
import { Clock } from 'lucide-react';
|
|
11
|
+
import { Card, CardContent, cn } from '@object-ui/components';
|
|
12
|
+
import { Clock, ArrowUpRight } from 'lucide-react';
|
|
13
13
|
import { getIcon } from '../../utils/getIcon';
|
|
14
14
|
import { capitalizeFirst } from '../../utils';
|
|
15
|
+
const TYPE_TONES = {
|
|
16
|
+
object: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 ring-blue-500/20',
|
|
17
|
+
dashboard: 'bg-violet-500/10 text-violet-600 dark:text-violet-400 ring-violet-500/20',
|
|
18
|
+
page: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-emerald-500/20',
|
|
19
|
+
record: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20',
|
|
20
|
+
};
|
|
15
21
|
export function RecentApps({ items }) {
|
|
16
22
|
const navigate = useNavigate();
|
|
17
23
|
const { t } = useObjectTranslation();
|
|
18
24
|
if (items.length === 0)
|
|
19
25
|
return null;
|
|
20
|
-
return (_jsxs("section", { children: [_jsxs("div", { className: "flex items-center gap-2 mb-
|
|
26
|
+
return (_jsxs("section", { children: [_jsxs("div", { className: "flex items-center gap-2 mb-5", children: [_jsx("span", { className: "inline-flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 ring-1 ring-sky-500/20 text-sky-600 dark:text-sky-400", children: _jsx(Clock, { className: "h-4 w-4" }) }), _jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.recentApps.title', { defaultValue: 'Recently Accessed' }) })] }), _jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3", children: items.map((item) => {
|
|
21
27
|
const Icon = getIcon(item.type);
|
|
22
28
|
const typeLabel = t(`home.recentApps.itemType.${item.type}`, {
|
|
23
29
|
defaultValue: capitalizeFirst(item.type),
|
|
24
30
|
});
|
|
25
|
-
|
|
31
|
+
const tone = TYPE_TONES[item.type] || TYPE_TONES.object;
|
|
32
|
+
return (_jsx(Card, { className: "group cursor-pointer border border-border/70 bg-card/80 backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-foreground/20", onClick: () => navigate(item.href), "data-testid": `recent-item-${item.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
|
|
26
33
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
27
34
|
e.preventDefault();
|
|
28
35
|
navigate(item.href);
|
|
29
36
|
}
|
|
30
|
-
}, children: _jsx(CardContent, { className: "p-
|
|
37
|
+
}, children: _jsx(CardContent, { className: "p-3.5", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: cn('inline-flex h-10 w-10 items-center justify-center rounded-lg ring-1 shrink-0', tone), children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "font-medium text-sm truncate", children: item.label }), _jsx("p", { className: "text-xs text-muted-foreground", children: typeLabel })] }), _jsx(ArrowUpRight, { className: "h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-all duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }) }) }, item.id));
|
|
31
38
|
}) })] }));
|
|
32
39
|
}
|
|
@@ -8,22 +8,29 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
*/
|
|
9
9
|
import { useNavigate } from 'react-router-dom';
|
|
10
10
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
11
|
-
import { Card, CardContent } from '@object-ui/components';
|
|
12
|
-
import { Star } from 'lucide-react';
|
|
11
|
+
import { Card, CardContent, cn } from '@object-ui/components';
|
|
12
|
+
import { Star, ArrowUpRight } from 'lucide-react';
|
|
13
13
|
import { getIcon } from '../../utils/getIcon';
|
|
14
14
|
import { capitalizeFirst } from '../../utils';
|
|
15
|
+
const TYPE_TONES = {
|
|
16
|
+
object: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 ring-blue-500/20',
|
|
17
|
+
dashboard: 'bg-violet-500/10 text-violet-600 dark:text-violet-400 ring-violet-500/20',
|
|
18
|
+
page: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-emerald-500/20',
|
|
19
|
+
record: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20',
|
|
20
|
+
};
|
|
15
21
|
export function StarredApps({ items }) {
|
|
16
22
|
const navigate = useNavigate();
|
|
17
23
|
const { t } = useObjectTranslation();
|
|
18
24
|
if (items.length === 0)
|
|
19
25
|
return null;
|
|
20
|
-
return (_jsxs("section", { children: [_jsxs("div", { className: "flex items-center gap-2 mb-
|
|
26
|
+
return (_jsxs("section", { children: [_jsxs("div", { className: "flex items-center gap-2 mb-5", children: [_jsx("span", { className: "inline-flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 ring-1 ring-amber-500/20 text-amber-600 dark:text-amber-400", children: _jsx(Star, { className: "h-4 w-4 fill-current" }) }), _jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.starredApps.title', { defaultValue: 'Starred' }) })] }), _jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3", children: items.map((item) => {
|
|
21
27
|
const Icon = getIcon(item.type);
|
|
22
|
-
|
|
28
|
+
const tone = TYPE_TONES[item.type] || TYPE_TONES.object;
|
|
29
|
+
return (_jsx(Card, { className: "group cursor-pointer border border-border/70 bg-card/80 backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-foreground/20", onClick: () => navigate(item.href), "data-testid": `starred-item-${item.id}`, role: "link", tabIndex: 0, onKeyDown: (e) => {
|
|
23
30
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
24
31
|
e.preventDefault();
|
|
25
32
|
navigate(item.href);
|
|
26
33
|
}
|
|
27
|
-
}, children: _jsx(CardContent, { className: "p-
|
|
34
|
+
}, children: _jsx(CardContent, { className: "p-3.5", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: cn('inline-flex h-10 w-10 items-center justify-center rounded-lg ring-1 shrink-0', tone), children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h3", { className: "font-medium text-sm truncate", children: item.label }), _jsx("p", { className: "text-xs text-muted-foreground", children: capitalizeFirst(item.type) })] }), _jsx(ArrowUpRight, { className: "h-4 w-4 text-muted-foreground opacity-0 -translate-x-1 transition-all duration-200 group-hover:opacity-100 group-hover:translate-x-0" })] }) }) }, item.id));
|
|
28
35
|
}) })] }));
|
|
29
36
|
}
|
|
@@ -4,18 +4,15 @@
|
|
|
4
4
|
* React Context + Provider for shared favorites state across all consumers
|
|
5
5
|
* (HomePage, AppCard, AppSidebar, UnifiedSidebar, StarredApps).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
7
|
+
* Persistence is **localStorage-first** with optional backend hydration:
|
|
8
|
+
* - On mount we render synchronously from localStorage (no flash of empty UI).
|
|
9
|
+
* - If a `UserDataAdapter<FavoriteItem>` is attached via `UserStateAdaptersProvider`
|
|
10
|
+
* (see `./UserStateAdapters`), the provider hydrates from the backend and
|
|
11
|
+
* writes-through every mutation through a debounced `adapter.save()`.
|
|
12
|
+
* - localStorage stays in sync so offline / pre-auth sessions still work and
|
|
13
|
+
* gives an instant first paint after sign-in.
|
|
14
|
+
* - The local key is scoped per `user.id` so different accounts on the same
|
|
15
|
+
* browser don't cross-contaminate.
|
|
19
16
|
*
|
|
20
17
|
* @module
|
|
21
18
|
*/
|