Skip to content

Commit 717d08a

Browse files
committed
feat(dashboard): improve onboarding and recovery flows
1 parent b0cf2a9 commit 717d08a

13 files changed

Lines changed: 423 additions & 125 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Navet (Swedish for "the hub") is a modern, responsive smart home dashboard built
4343
### 🛠️ Functionality
4444
- **Edit Mode** - Drag-and-drop card reordering and resizing
4545
- **Custom Widgets** - Add Calendar, News, Weather, Photo Frame, and Quick Note widgets
46-
- **Entity Visibility Control** - Start with all entities or a blank board, then add/remove entities as needed
46+
- **Entity Visibility Control** - Start with all entities, a blank board, or import a saved config, then add/remove entities as needed
4747
- **Search & Filter** - Real-time search filters dashboard view
4848
- **All View** - See all devices grouped by room
4949
- **Notifications** - System notifications panel
@@ -166,12 +166,15 @@ Navet (Swedish for "the hub") is a modern, responsive smart home dashboard built
166166

167167
#### Dashboard Content
168168
- On first launch, choose whether to start with all discovered entities or a blank dashboard
169+
- You can also import a previously exported dashboard config directly from onboarding
169170
- After onboarding, use **Add Entity** and **Remove Entity** to curate the board
170171
- This is useful for excluding helper, diagnostic, or duplicate Home Assistant entities without switching between dashboard modes
171172
- The empty dashboard state now exposes **Add Entity** directly so blank dashboards do not dead-end
173+
- Restarting onboarding from Settings sends you back to the Home dashboard and reopens the onboarding dialog
172174

173175
#### Dashboard Config
174176
- In **Settings -> Dashboard Config**, export your local dashboard setup to a JSON file
177+
- The same config file can be imported later either from Settings or directly from first-run onboarding
175178
- Import that file later on the same machine or another device to restore:
176179
- theme and wallpaper
177180
- room order and card order

design-system/FEATURES.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ Beautiful placeholder screens for sections without data.
293293

294294
### First-Time User Flow
295295
1. **Login Page** → Enter Home Assistant URL and token
296-
2. **Onboarding Dialog** → Choose start with all entities or a blank dashboard
296+
2. **Onboarding Dialog** → Choose start with all entities, a blank dashboard, or import a config file
297297
3. **Dashboard** → See current entities and rooms
298298
4. **Explore Sections** → Navigate to different sections via sidebar/bottom nav
299299
5. **Customize** → Go to Settings → Change theme and color
@@ -366,6 +366,7 @@ Theme, navigation, search, and Home Assistant access use direct hook modules ins
366366
- `ha_dashboard_primary_color` - Primary color preference
367367
- `ha-dashboard-navigation` - Active section and current room
368368
- `navet-dashboard-entities` - Removed entity ids and onboarding state
369+
- Restarting onboarding should always return the user to Home / All before reopening the wizard
369370

370371
### CSS Variables
371372

@@ -447,7 +448,7 @@ Theme system uses CSS custom properties defined in `/src/styles/theme.css`:
447448
2. Create section component in /components/sections.tsx
448449
3. Add icon and route to Sidebar component
449450
4. Include in mobile bottom navigation (if appropriate)
450-
5. Implement empty state if no data available
451+
5. Implement empty state if no data available, with the primary recovery action visible when possible
451452
6. Test at all breakpoints
452453

453454
### When Adding Theme-Dependent Styling

design-system/LAYOUT-STRUCTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ App Container
1616
├── Room Navigation Tabs
1717
│ └── Customize / Add Entity / Add Card / Done Editing
1818
└── Device Grid (Main Content)
19-
└── Dynamic Card Grid
19+
└── Dynamic Card Grid / empty-state recovery actions
2020
```
2121

2222
---

docs/DOCKER_HOME_ASSISTANT_ADDON.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ The current dashboard build includes a few runtime-focused optimizations:
151151
- Zustand-backed search result state to reduce context fan-out
152152
- Stable device-map reuse to avoid rerendering unchanged cards
153153
- Onboarding-based dashboard visibility with add/remove entity curation
154-
- Local dashboard config export/import for layout and preference backup
154+
- Local dashboard config export/import for layout and preference backup, including first-run import from onboarding
155155
- Configurable entity card interaction styles with a live preview in Settings
156156
- Optional no-animation mode for slower devices such as Raspberry Pi deployments
157157

src/app/App.tsx

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { LoadingProvider } from './contexts/loading-context';
3030
import { LoginPage } from './features/auth/login-page';
3131
import { AllViewGrid } from './features/dashboard/all-view-grid';
3232
import type { CardType } from './features/dashboard/components/AddCardDialogContainer';
33+
import { AddEntityDialog } from './features/dashboard/components/add-entity-dialog';
3334
import { DashboardLayout } from './features/dashboard/dashboard-layout';
3435
import { DeviceGrid } from './features/dashboard/device-grid';
3536
import {
@@ -46,17 +47,14 @@ import {
4647
import { useCustomCards } from './hooks/use-custom-cards';
4748
import { useDevices, useRooms } from './hooks/use-devices';
4849
import { useDashboardEntitiesStore, useSettingsStore } from './stores';
50+
import { importDashboardConfigFromFile } from './utils/dashboard-config';
4951
import { getDeviceRoom, getDeviceRoomLabel } from './utils/device-location';
5052

5153
const AddCardDialog = lazy(async () => {
5254
const module = await import('./features/dashboard/components/AddCardDialogContainer');
5355
return { default: module.AddCardDialogContainer };
5456
});
5557

56-
const AddEntityDialog = lazy(async () => {
57-
const module = await import('./features/dashboard/components/add-entity-dialog');
58-
return { default: module.AddEntityDialog };
59-
});
6058
const DashboardOnboardingDialog = lazy(async () => {
6159
const module = await import('./features/dashboard/components/dashboard-onboarding-dialog');
6260
return { default: module.DashboardOnboardingDialog };
@@ -72,7 +70,7 @@ const SettingsSection = lazy(async () => {
7270
* The main dashboard view after authentication
7371
*/
7472
function Dashboard() {
75-
const { activeSection } = useNavigation();
73+
const { activeSection, setActiveSection } = useNavigation();
7674
const { connected, connecting, error } = useHomeAssistant();
7775
const [devicesLoaded, setDevicesLoaded] = useState(false);
7876
const [showAddCardDialog, setShowAddCardDialog] = useState(false);
@@ -106,6 +104,10 @@ function Dashboard() {
106104
const { deviceMap } = useDeviceMap(devices);
107105
const { deviceMap: availableDeviceMap } = useDeviceMap(allDevices);
108106
const allEntityIds = useMemo(() => Array.from(availableDeviceMap.keys()), [availableDeviceMap]);
107+
const addableEntityIds = useMemo(
108+
() => (hiddenEntityIds.length > 0 ? hiddenEntityIds : allEntityIds),
109+
[allEntityIds, hiddenEntityIds]
110+
);
109111
const lightDeviceMap = useMemo(
110112
() => new Map(Array.from(deviceMap.entries()).filter(([, device]) => device.type === 'lights')),
111113
[deviceMap]
@@ -215,6 +217,30 @@ function Dashboard() {
215217
[hideAutoEntity]
216218
);
217219

220+
const handleImportDashboardConfig = useCallback(async (file: File) => {
221+
try {
222+
await importDashboardConfigFromFile(file);
223+
toast.success('Dashboard config imported. Reloading...');
224+
window.setTimeout(() => {
225+
window.location.reload();
226+
}, 600);
227+
} catch {
228+
toast.error('Failed to import dashboard config');
229+
}
230+
}, []);
231+
232+
const handleChooseAllEntities = useCallback(() => {
233+
setActiveSection('home');
234+
changeRoom('All');
235+
completeOnboarding(allEntityIds, false);
236+
}, [allEntityIds, changeRoom, completeOnboarding, setActiveSection]);
237+
238+
const handleChooseBlankDashboard = useCallback(() => {
239+
setActiveSection('home');
240+
changeRoom('All');
241+
completeOnboarding(allEntityIds, true);
242+
}, [allEntityIds, changeRoom, completeOnboarding, setActiveSection]);
243+
218244
// Handle updating a card
219245
const handleUpdateCard = useCallback(
220246
(cardId: string, data: Record<string, unknown>) => {
@@ -281,6 +307,7 @@ function Dashboard() {
281307
? 'All light entities have been removed from the dashboard.'
282308
: 'No Home Assistant light entities are currently available.'
283309
}
310+
actionIcon={Lightbulb}
284311
actionLabel={hiddenEntityIds.length > 0 ? 'Add Entity' : undefined}
285312
onAction={hiddenEntityIds.length > 0 ? () => setShowAddEntityDialog(true) : undefined}
286313
/>
@@ -334,7 +361,7 @@ function Dashboard() {
334361
onToggleEditMode={toggleEditMode}
335362
onMoveRoom={moveRoom}
336363
onAddCard={() => setShowAddCardDialog(true)}
337-
onAddEntity={hiddenEntityIds.length > 0 ? () => setShowAddEntityDialog(true) : undefined}
364+
onAddEntity={addableEntityIds.length > 0 ? () => setShowAddEntityDialog(true) : undefined}
338365
addEntityLabel="Add Entity"
339366
/>
340367

@@ -378,8 +405,9 @@ function Dashboard() {
378405
icon={Lightbulb}
379406
title="No Visible Entities"
380407
description="Your dashboard is empty. Add entities from your hidden list or add custom cards to start building it."
381-
actionLabel={hiddenEntityIds.length > 0 ? 'Add Entity' : undefined}
382-
onAction={hiddenEntityIds.length > 0 ? () => setShowAddEntityDialog(true) : undefined}
408+
actionIcon={Lightbulb}
409+
actionLabel={addableEntityIds.length > 0 ? 'Add Entity' : undefined}
410+
onAction={addableEntityIds.length > 0 ? () => setShowAddEntityDialog(true) : undefined}
383411
/>
384412
)}
385413

@@ -395,27 +423,30 @@ function Dashboard() {
395423
)}
396424

397425
{showAddEntityDialog && (
398-
<Suspense fallback={null}>
399-
<AddEntityDialog
400-
open={showAddEntityDialog}
401-
onClose={() => setShowAddEntityDialog(false)}
402-
onAddEntity={handleAddEntity}
403-
currentRoom={activeRoom}
404-
deviceMap={availableDeviceMap}
405-
addedEntityIds={[]}
406-
visibleEntityIds={hiddenEntityIds}
407-
title="Add Entity"
408-
description="Add Home Assistant entities back to the dashboard."
409-
actionLabel="Add"
410-
/>
411-
</Suspense>
426+
<AddEntityDialog
427+
open={showAddEntityDialog}
428+
onClose={() => setShowAddEntityDialog(false)}
429+
onAddEntity={handleAddEntity}
430+
currentRoom={activeRoom}
431+
deviceMap={availableDeviceMap}
432+
addedEntityIds={[]}
433+
visibleEntityIds={addableEntityIds}
434+
title="Add Entity"
435+
description={
436+
hiddenEntityIds.length > 0
437+
? 'Add Home Assistant entities back to the dashboard.'
438+
: 'Choose Home Assistant entities to add to the dashboard.'
439+
}
440+
actionLabel="Add"
441+
/>
412442
)}
413443
{!onboardingCompleted && allEntityIds.length > 0 && (
414444
<Suspense fallback={null}>
415445
<DashboardOnboardingDialog
416446
open
417-
onChooseAll={() => completeOnboarding(allEntityIds, false)}
418-
onChooseBlank={() => completeOnboarding(allEntityIds, true)}
447+
onChooseAll={handleChooseAllEntities}
448+
onChooseBlank={handleChooseBlankDashboard}
449+
onImportConfig={handleImportDashboardConfig}
419450
/>
420451
</Suspense>
421452
)}

src/app/components/layout/room-nav.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
22
import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable';
33
import { CSS } from '@dnd-kit/utilities';
4-
import { Check, Edit3, Plus } from 'lucide-react';
4+
import { Check, Edit3, LayoutGrid, Lightbulb } from 'lucide-react';
55
import { memo } from 'react';
66
import { useTheme } from '@/app/hooks';
77
import { getThemeColorValue } from '@/app/utils/theme-colors';
@@ -102,7 +102,7 @@ export const RoomNav = memo(function RoomNav({
102102
onClick={onAddEntity}
103103
className={`p-2 rounded-lg transition-colors flex items-center gap-2 px-3 ${inactiveBg} ${hoverBg}`}
104104
>
105-
<Plus className={`w-4 h-4 ${textSecondary}`} />
105+
<Lightbulb className={`w-4 h-4 ${textSecondary}`} />
106106
<span className={`text-xs font-medium hidden md:inline ${textSecondary}`}>
107107
{addEntityLabel}
108108
</span>
@@ -115,7 +115,7 @@ export const RoomNav = memo(function RoomNav({
115115
onClick={onAddCard}
116116
className={`p-2 rounded-lg transition-colors flex items-center gap-2 px-3 ${inactiveBg} ${hoverBg}`}
117117
>
118-
<Plus className={`w-4 h-4 ${textSecondary}`} />
118+
<LayoutGrid className={`w-4 h-4 ${textSecondary}`} />
119119
<span className={`text-xs font-medium hidden md:inline ${textSecondary}`}>
120120
Add Card
121121
</span>

src/app/components/shared/empty-state.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ interface EmptyStateProps {
55
icon: React.ComponentType<{ className?: string }>;
66
title: string;
77
description: string;
8+
actionIcon?: React.ComponentType<{ className?: string }>;
89
actionLabel?: string;
910
onAction?: () => void;
1011
}
@@ -13,6 +14,7 @@ export const EmptyState = memo(function EmptyState({
1314
icon: Icon,
1415
title,
1516
description,
17+
actionIcon: ActionIcon,
1618
actionLabel,
1719
onAction,
1820
}: EmptyStateProps) {
@@ -38,8 +40,9 @@ export const EmptyState = memo(function EmptyState({
3840
<button
3941
type="button"
4042
onClick={onAction}
41-
className="mt-6 px-4 py-2 rounded-xl bg-gray-900 text-white text-sm font-medium"
43+
className="mt-6 inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-gray-900 text-white text-sm font-medium"
4244
>
45+
{ActionIcon && <ActionIcon className="h-4 w-4" />}
4346
{actionLabel}
4447
</button>
4548
)}

src/app/components/ui/alert-dialog.tsx

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
44
import type * as React from 'react';
5-
import { buttonVariants } from './button';
5+
import { useTheme } from '@/app/hooks';
6+
import { getThemeColorValue } from '@/app/utils/theme-colors';
67
import { cn } from './utils';
78

89
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
@@ -27,7 +28,7 @@ function AlertDialogOverlay({
2728
<AlertDialogPrimitive.Overlay
2829
data-slot="alert-dialog-overlay"
2930
className={cn(
30-
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
31+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/55 backdrop-blur-sm',
3132
className
3233
)}
3334
{...props}
@@ -39,15 +40,26 @@ function AlertDialogContent({
3940
className,
4041
...props
4142
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
43+
const { theme, primaryColor } = useTheme();
44+
const accentColor = getThemeColorValue(primaryColor);
45+
const surfaceClass =
46+
theme === 'light' ? 'border-gray-200/80 text-gray-900' : 'border-white/10 text-white';
47+
const background =
48+
theme === 'light'
49+
? `linear-gradient(180deg, rgba(255,255,255,0.96) 0%, rgba(255,255,255,0.92) 72%, ${accentColor}10 100%)`
50+
: `linear-gradient(180deg, rgba(18,18,20,0.96) 0%, rgba(12,12,14,0.94) 72%, ${accentColor}14 100%)`;
51+
4252
return (
4353
<AlertDialogPortal>
4454
<AlertDialogOverlay />
4555
<AlertDialogPrimitive.Content
4656
data-slot="alert-dialog-content"
4757
className={cn(
48-
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
58+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-5 rounded-[32px] border p-6 shadow-2xl backdrop-blur-xl duration-200 sm:max-w-lg sm:p-8',
59+
surfaceClass,
4960
className
5061
)}
62+
style={{ background }}
5163
{...props}
5264
/>
5365
</AlertDialogPortal>
@@ -68,7 +80,7 @@ function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>)
6880
return (
6981
<div
7082
data-slot="alert-dialog-footer"
71-
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
83+
className={cn('flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end', className)}
7284
{...props}
7385
/>
7486
);
@@ -81,7 +93,7 @@ function AlertDialogTitle({
8193
return (
8294
<AlertDialogPrimitive.Title
8395
data-slot="alert-dialog-title"
84-
className={cn('text-lg font-semibold', className)}
96+
className={cn('text-xl font-semibold tracking-tight', className)}
8597
{...props}
8698
/>
8799
);
@@ -91,10 +103,15 @@ function AlertDialogDescription({
91103
className,
92104
...props
93105
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
106+
const { theme } = useTheme();
94107
return (
95108
<AlertDialogPrimitive.Description
96109
data-slot="alert-dialog-description"
97-
className={cn('text-muted-foreground text-sm', className)}
110+
className={cn(
111+
'text-sm leading-relaxed',
112+
theme === 'light' ? 'text-gray-600' : 'text-gray-300',
113+
className
114+
)}
98115
{...props}
99116
/>
100117
);
@@ -104,16 +121,37 @@ function AlertDialogAction({
104121
className,
105122
...props
106123
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
107-
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />;
124+
const { primaryColor } = useTheme();
125+
const accentColor = getThemeColorValue(primaryColor);
126+
return (
127+
<AlertDialogPrimitive.Action
128+
className={cn(
129+
'inline-flex h-10 items-center justify-center gap-2 rounded-full border-0 px-5 text-sm font-medium text-white shadow-sm transition-all hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
130+
className
131+
)}
132+
style={{ backgroundColor: accentColor }}
133+
{...props}
134+
/>
135+
);
108136
}
109137

110138
function AlertDialogCancel({
111139
className,
112140
...props
113141
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
142+
const { theme } = useTheme();
143+
const cancelClass =
144+
theme === 'light'
145+
? 'border-gray-200/80 bg-gray-100 text-gray-900 hover:bg-gray-200'
146+
: 'border-white/10 bg-white/5 text-white hover:bg-white/10';
147+
114148
return (
115149
<AlertDialogPrimitive.Cancel
116-
className={cn(buttonVariants({ variant: 'outline' }), className)}
150+
className={cn(
151+
'inline-flex h-10 items-center justify-center gap-2 rounded-full border px-5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
152+
cancelClass,
153+
className
154+
)}
117155
{...props}
118156
/>
119157
);

0 commit comments

Comments
 (0)