@bardioc/create-bardioc-app 0.7.0 → 0.7.1

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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/templates/_base/.changeset/README.md +4 -6
  3. package/templates/_base/.claude/commands/changeset-app.md +11 -12
  4. package/templates/_base/.claude/commands/refresh-bundle.md +1 -1
  5. package/templates/_base/AGENTS.md +128 -0
  6. package/templates/_base/CLAUDE.md +1 -0
  7. package/templates/_base/README.md +11 -17
  8. package/templates/_opt-link/scripts/link-source.mjs +1 -1
  9. package/templates/_opt-pipeline/bitbucket-pipelines.yml +12 -56
  10. package/templates/angular/package.json +1 -1
  11. package/templates/nextjs/package.json +1 -1
  12. package/templates/preact/package.json +1 -1
  13. package/templates/solid/package.json +1 -1
  14. package/templates/svelte/package.json +1 -1
  15. package/templates/vite/CLAUDE.md +149 -135
  16. package/templates/vite/package.json +3 -3
  17. package/templates/vite/src/App.tsx +6 -3
  18. package/templates/vite/src/auth/onboarding.tsx +19 -23
  19. package/templates/vite/src/climate/climate-data.ts +19 -0
  20. package/templates/vite/src/climate/conditions-chart.tsx +11 -6
  21. package/templates/vite/src/climate/location-card.tsx +2 -5
  22. package/templates/vite/src/climate/location-select.tsx +7 -2
  23. package/templates/vite/src/climate/metrics-grid.tsx +6 -1
  24. package/templates/vite/src/climate/overview-view.tsx +26 -7
  25. package/templates/vite/src/climate/stations-view.tsx +73 -9
  26. package/templates/vite/src/climate/types.ts +5 -5
  27. package/templates/vite/src/dashboard-provider.tsx +15 -5
  28. package/templates/vite/src/events/live-events-view.tsx +67 -37
  29. package/templates/vite/src/events/mock-events.ts +33 -0
  30. package/templates/vite/src/graph/explorer-view.tsx +81 -63
  31. package/templates/vite/src/i18n.tsx +6 -2
  32. package/templates/vite/src/index.css +0 -6
  33. package/templates/vite/src/locales/de.json +55 -51
  34. package/templates/vite/src/locales/en.json +44 -40
  35. package/templates/vite/src/preview-state.tsx +29 -0
  36. package/templates/vite/src/profile/profile-view.tsx +43 -25
  37. package/templates/vite/src/shell/about-dialog.tsx +6 -5
  38. package/templates/vite/src/shell/app-shell.tsx +46 -45
  39. package/templates/vite/src/shell/debug-menu.tsx +86 -0
  40. package/templates/vite/src/shell/header.tsx +6 -4
  41. package/templates/vite/src/shell/view-content.tsx +69 -0
  42. package/templates/vue/package.json +1 -1
@@ -18,5 +18,10 @@ export function MetricsGrid({ reading }: Readonly<MetricsGridProps>) {
18
18
  color: def.color,
19
19
  }));
20
20
 
21
- return <GenericMetrics metrics={metrics} className="grid grid-cols-2 gap-3 md:grid-cols-5" />;
21
+ return (
22
+ <GenericMetrics
23
+ metrics={metrics}
24
+ className="grid grid-cols-1 gap-4 @sm/page:grid-cols-2 @3xl/page:grid-cols-5"
25
+ />
26
+ );
22
27
  }
@@ -1,4 +1,5 @@
1
- import { Database } from 'lucide-react';
1
+ import { Empty } from '@bardioc/ui/components';
2
+ import { CloudOff, Database } from 'lucide-react';
2
3
  import { useDashboard } from '../dashboard-provider';
3
4
  import { useTranslation } from '../i18n';
4
5
  import { ConditionsChart } from './conditions-chart';
@@ -9,7 +10,20 @@ export function OverviewView() {
9
10
  const { t } = useTranslation();
10
11
  const { state, activeStation, historyCount, persisted } = useDashboard();
11
12
 
12
- if (!state || !activeStation) return null;
13
+ if (!state) return null;
14
+
15
+ if (!activeStation) {
16
+ return (
17
+ <Empty
18
+ icon={CloudOff}
19
+ iconVariant="chip"
20
+ title={t('overview.emptyTitle', { fallback: 'No Station Data' })}
21
+ description={t('overview.emptyDescription', {
22
+ fallback: 'Stations appear here once they report.',
23
+ })}
24
+ />
25
+ );
26
+ }
13
27
 
14
28
  const savedTime = new Date(state.updatedAt).toLocaleTimeString([], {
15
29
  hour: '2-digit',
@@ -33,16 +47,21 @@ export function OverviewView() {
33
47
  </span>
34
48
  <span>·</span>
35
49
  <span>
36
- {t('overview.historyOther', {
37
- fallback: '{count} snapshots in history',
38
- params: { count: historyCount },
39
- })}
50
+ {historyCount === 1
51
+ ? t('overview.historyOne', {
52
+ fallback: '{count} snapshot in history',
53
+ params: { count: historyCount },
54
+ })
55
+ : t('overview.historyOther', {
56
+ fallback: '{count} snapshots in history',
57
+ params: { count: historyCount },
58
+ })}
40
59
  </span>
41
60
  </>
42
61
  ) : (
43
62
  <span>
44
63
  {t('overview.notPersisted', {
45
- fallback: 'Preview — not persisted (open inside Bardioc to persist)',
64
+ fallback: 'Preview — not persisted (open inside Bardioc to persist).',
46
65
  })}
47
66
  </span>
48
67
  )}
@@ -1,12 +1,16 @@
1
1
  import {
2
2
  BadgeStatus,
3
+ Button,
3
4
  CheckboxGroup,
4
5
  DataTable,
6
+ Empty,
5
7
  FilterDropdown,
6
8
  SortableColumnHeader,
9
+ StableWidthText,
7
10
  TruncatedCellText,
8
11
  } from '@bardioc/ui/components';
9
12
  import type { TReactTable } from '@bardioc/ui/types';
13
+ import { MapPin, MapPinCheck, SearchX } from 'lucide-react';
10
14
  import { useMemo, useState } from 'react';
11
15
  import { useDashboard } from '../dashboard-provider';
12
16
  import { useTranslation } from '../i18n';
@@ -27,21 +31,34 @@ interface ConditionFilterProps {
27
31
  function ConditionFilter({ selected, onToggle }: Readonly<ConditionFilterProps>) {
28
32
  const { t } = useTranslation();
29
33
  const [open, setOpen] = useState(false);
30
- const label = t('stations.filter', { fallback: 'Condition' });
34
+
35
+ // The trigger says "Filter", or how many are active; what is filtered is the section title.
36
+ // StableWidthText reserves the widest label, so the button keeps its width as the count changes.
37
+ const buttonLabel = (
38
+ <StableWidthText
39
+ placeholder={t('common.filter.activeFilter', { count: 9, fallback: '9 Filters' })}
40
+ >
41
+ {selected.length > 0
42
+ ? t('common.filter.activeFilter', {
43
+ count: selected.length,
44
+ fallback: '{count} Filters',
45
+ })
46
+ : t('common.filter.filter', { fallback: 'Filter' })}
47
+ </StableWidthText>
48
+ );
31
49
 
32
50
  return (
33
51
  <FilterDropdown
34
52
  immediate
35
53
  open={open}
36
54
  setOpen={setOpen}
37
- buttonLabel={selected.length ? `${label} (${selected.length})` : label}
55
+ buttonLabel={buttonLabel}
38
56
  sections={[
39
57
  {
40
58
  id: 'condition',
41
- title: label,
59
+ title: t('stations.filter', { fallback: 'Condition' }),
42
60
  content: (
43
61
  <CheckboxGroup
44
- className="gap-3 capitalize"
45
62
  items={CONDITIONS.map(condition => ({
46
63
  id: condition,
47
64
  label: t(`conditions.${condition}`, { fallback: condition }),
@@ -59,6 +76,7 @@ function ConditionFilter({ selected, onToggle }: Readonly<ConditionFilterProps>)
59
76
  export function StationsView() {
60
77
  const { t } = useTranslation();
61
78
  const { state, setActiveStation } = useDashboard();
79
+ const activeStationId = state?.activeStationId;
62
80
  const [conditions, setConditions] = useState<Condition[]>([]);
63
81
 
64
82
  const onToggle = (condition: Condition) =>
@@ -70,6 +88,7 @@ export function StationsView() {
70
88
  () => [
71
89
  {
72
90
  accessorKey: 'name',
91
+ size: 220,
73
92
  enableSorting: true,
74
93
  header: ({ column }) => (
75
94
  <SortableColumnHeader
@@ -81,11 +100,13 @@ export function StationsView() {
81
100
  },
82
101
  {
83
102
  accessorKey: 'region',
103
+ size: 160,
84
104
  header: () => t('stations.colRegion', { fallback: 'Region' }),
85
105
  cell: ({ row }) => <TruncatedCellText value={row.original.region} />,
86
106
  },
87
107
  {
88
108
  accessorKey: 'condition',
109
+ size: 130,
89
110
  header: () => t('stations.colCondition', { fallback: 'Condition' }),
90
111
  cell: ({ row }) => (
91
112
  <BadgeStatus variant={STATUS[row.original.condition]}>
@@ -95,21 +116,55 @@ export function StationsView() {
95
116
  },
96
117
  {
97
118
  id: 'temp',
119
+ size: 110,
98
120
  header: () => t('stations.colTemp', { fallback: 'Temp' }),
99
121
  cell: ({ row }) => `${row.original.reading.temperature}°C`,
100
122
  },
101
123
  {
102
124
  id: 'humidity',
125
+ size: 110,
103
126
  header: () => t('stations.colHumidity', { fallback: 'Humidity' }),
104
127
  cell: ({ row }) => `${row.original.reading.humidity}%`,
105
128
  },
106
129
  {
107
130
  id: 'vegetation',
131
+ size: 110,
108
132
  header: () => t('stations.colVegetation', { fallback: 'Vegetation' }),
109
133
  cell: ({ row }) => `${row.original.reading.vegetation}%`,
110
134
  },
135
+ {
136
+ // The trailing column reserved for row actions: fixed width, no header, never sorted or
137
+ // resized. The current station's button is filled and checked, so the selection shows here.
138
+ id: 'actions',
139
+ size: 40,
140
+ minSize: 40,
141
+ maxSize: 40,
142
+ enableSorting: false,
143
+ enableResizing: false,
144
+ header: '',
145
+ cell: ({ row }) => {
146
+ const isActive = row.original.id === activeStationId;
147
+ return (
148
+ <div className="flex justify-end">
149
+ <Button
150
+ variant={isActive ? 'default' : 'ghost'}
151
+ size="icon-xs"
152
+ title={
153
+ isActive
154
+ ? t('stations.current', { fallback: 'Current Station' })
155
+ : t('stations.select', { fallback: 'Select Station' })
156
+ }
157
+ aria-pressed={isActive}
158
+ onClick={() => setActiveStation(row.original.id)}
159
+ >
160
+ {isActive ? <MapPinCheck aria-hidden="true" /> : <MapPin aria-hidden="true" />}
161
+ </Button>
162
+ </div>
163
+ );
164
+ },
165
+ },
111
166
  ],
112
- [t]
167
+ [t, activeStationId, setActiveStation]
113
168
  );
114
169
 
115
170
  if (!state) return null;
@@ -120,10 +175,17 @@ export function StationsView() {
120
175
 
121
176
  return (
122
177
  <div className="flex flex-col gap-3">
123
- <p className="text-muted-foreground text-xs">{t('stations.description', { fallback: '' })}</p>
178
+ <p className="text-muted-foreground text-xs">
179
+ {t('stations.description', {
180
+ fallback: 'All stations reporting to this instance. Search, filter, or select one.',
181
+ })}
182
+ </p>
124
183
  <DataTable
125
184
  boxed
126
185
  data={data}
186
+ itemsPerPage={10}
187
+ // Drag a header edge to resize; double-click it to reset the column to its `size`.
188
+ enableColumnResizing
127
189
  columns={columns}
128
190
  filterKey={['name', 'region']}
129
191
  showSearchBar
@@ -132,9 +194,11 @@ export function StationsView() {
132
194
  onRowClick={station => setActiveStation(station.id)}
133
195
  filterSlot={<ConditionFilter selected={conditions} onToggle={onToggle} />}
134
196
  emptyState={
135
- <p className="text-muted-foreground p-6 text-center text-sm">
136
- {t('stations.empty', { fallback: 'No stations match your filters.' })}
137
- </p>
197
+ <Empty
198
+ icon={SearchX}
199
+ iconVariant="chip"
200
+ description={t('stations.empty', { fallback: 'No stations match your filters.' })}
201
+ />
138
202
  }
139
203
  />
140
204
  </div>
@@ -52,7 +52,7 @@ export const METRICS: MetricDef[] = [
52
52
  hintKey: 'metrics.oxygenHint',
53
53
  icon: Gauge,
54
54
  unit: '%',
55
- color: 'text-yellow-500',
55
+ color: 'text-warning',
56
56
  },
57
57
  {
58
58
  key: 'temperature',
@@ -60,7 +60,7 @@ export const METRICS: MetricDef[] = [
60
60
  hintKey: 'metrics.temperatureHint',
61
61
  icon: Thermometer,
62
62
  unit: '°C',
63
- color: 'text-red-500',
63
+ color: 'text-destructive',
64
64
  },
65
65
  {
66
66
  key: 'humidity',
@@ -68,7 +68,7 @@ export const METRICS: MetricDef[] = [
68
68
  hintKey: 'metrics.humidityHint',
69
69
  icon: Droplets,
70
70
  unit: '%',
71
- color: 'text-blue-500',
71
+ color: 'text-info',
72
72
  },
73
73
  {
74
74
  key: 'windSpeed',
@@ -76,7 +76,7 @@ export const METRICS: MetricDef[] = [
76
76
  hintKey: 'metrics.windSpeedHint',
77
77
  icon: Wind,
78
78
  unit: 'km/h',
79
- color: 'text-[var(--chart-4)]',
79
+ color: 'text-chart-4',
80
80
  },
81
81
  {
82
82
  key: 'vegetation',
@@ -84,7 +84,7 @@ export const METRICS: MetricDef[] = [
84
84
  hintKey: 'metrics.vegetationHint',
85
85
  icon: Leaf,
86
86
  unit: '%',
87
- color: 'text-green-500',
87
+ color: 'text-success',
88
88
  },
89
89
  ];
90
90
 
@@ -1,5 +1,6 @@
1
1
  import { createContext, useContext, type ReactNode } from 'react';
2
2
  import { useClimate } from './climate/use-climate';
3
+ import { usePreviewState } from './preview-state';
3
4
  import { useProfile, type ProfileState } from './profile/use-profile';
4
5
 
5
6
  type ClimateApi = ReturnType<typeof useClimate>;
@@ -10,12 +11,21 @@ const DashboardContext = createContext<DashboardContextValue | null>(null);
10
11
  export function DashboardProvider({ children }: Readonly<{ children: ReactNode }>) {
11
12
  const climate = useClimate();
12
13
  const profile = useProfile();
14
+ const { previewState } = usePreviewState();
13
15
 
14
- return (
15
- <DashboardContext.Provider value={{ ...climate, profile }}>
16
- {children}
17
- </DashboardContext.Provider>
18
- );
16
+ // The "Empty" preview hands every view the data an empty instance would return, so each one
17
+ // renders its own empty state rather than a generic placeholder.
18
+ const value: DashboardContextValue =
19
+ previewState === 'empty'
20
+ ? {
21
+ ...climate,
22
+ state: climate.state && { ...climate.state, stations: [] },
23
+ activeStation: null,
24
+ profile: { loading: false, profile: null, error: null },
25
+ }
26
+ : { ...climate, profile };
27
+
28
+ return <DashboardContext.Provider value={value}>{children}</DashboardContext.Provider>;
19
29
  }
20
30
 
21
31
  export function useDashboard(): DashboardContextValue {
@@ -1,19 +1,44 @@
1
1
  import type { GraphEvent } from '@bardioc/app-sdk';
2
2
  import { isInsideIframe } from '@bardioc/app-sdk/dev';
3
3
  import { useGraphEvents } from '@bardioc/app-sdk/react';
4
- import { Badge, Card, Empty } from '@bardioc/ui/components';
4
+ import {
5
+ Badge,
6
+ Card,
7
+ Empty,
8
+ Item,
9
+ ItemActions,
10
+ ItemContent,
11
+ ItemGroup,
12
+ ItemMeta,
13
+ ItemTitle,
14
+ } from '@bardioc/ui/components';
5
15
  import { Activity } from 'lucide-react';
6
16
  import { useState } from 'react';
7
17
  import { useTranslation } from '../i18n';
18
+ import { usePreviewState, type PreviewState } from '../preview-state';
19
+ import { MOCK_GRAPH_EVENTS } from './mock-events';
8
20
 
9
21
  // OGIT types to watch. Broad `ogit/Timeseries` writes are the most common signal in a live
10
22
  // instance; swap these for the entity types your app cares about.
11
23
  const WATCHED_TYPES = ['ogit/Timeseries', 'ogit/Automation/Issue'];
12
24
  const MAX_EVENTS = 50;
13
25
 
26
+ function eventsForPreview(previewState: PreviewState, events: GraphEvent[]): GraphEvent[] {
27
+ switch (previewState) {
28
+ case 'empty':
29
+ return [];
30
+ case 'mock':
31
+ return MOCK_GRAPH_EVENTS;
32
+ default:
33
+ return events;
34
+ }
35
+ }
36
+
14
37
  export function LiveEventsView() {
15
38
  const { t } = useTranslation();
16
39
  const [events, setEvents] = useState<GraphEvent[]>([]);
40
+ const { previewState } = usePreviewState();
41
+ const shown = eventsForPreview(previewState, events);
17
42
 
18
43
  // Subscribes for the mounted lifetime; the host streams matching graph events over SSE.
19
44
  // Outside a valid session no events arrive and the empty state stays.
@@ -21,50 +46,55 @@ export function LiveEventsView() {
21
46
  setEvents(prev => [event, ...prev].slice(0, MAX_EVENTS));
22
47
  });
23
48
 
49
+ // The empty state sits on the page like every other one, and only the list gets a card: inside
50
+ // a card, `Empty`'s page background shows as a darker block in dark mode.
24
51
  return (
25
- <div className="flex h-full min-w-0 flex-col gap-3">
52
+ <div className="flex min-w-0 flex-1 flex-col gap-3">
26
53
  <p className="text-muted-foreground text-xs">
27
54
  {t('events.description', {
28
55
  fallback: 'Live graph events streamed from the OS over SSE (permission "events").',
29
56
  })}
30
57
  </p>
31
58
 
32
- <Card className="min-h-0 min-w-0 flex-1">
33
- {events.length === 0 ? (
34
- <Empty
35
- icon={Activity}
36
- iconVariant="chip"
37
- title={t('events.emptyTitle', { fallback: 'Waiting for Events' })}
38
- description={
39
- isInsideIframe()
40
- ? t('events.emptyHost', {
41
- fallback: 'No graph events yet — activity will appear here as it happens.',
42
- })
43
- : t('events.emptyStandalone', {
44
- fallback:
45
- 'Open inside Bardioc (or run standalone with dev auth) to receive live events.',
46
- })
47
- }
48
- />
49
- ) : (
50
- <div className="flex min-h-0 flex-col gap-1 overflow-auto">
51
- {events.map(event => (
52
- <div
53
- key={event.id}
54
- className="border-border flex min-w-0 items-center gap-2 border-b py-1.5 font-mono text-xs last:border-b-0"
55
- >
56
- <Badge variant="info" numerical>
57
- {event.type}
58
- </Badge>
59
- <span className="text-muted-foreground shrink-0">
60
- {new Date(event.timestamp).toLocaleTimeString()}
61
- </span>
62
- <span className="truncate">{event.id}</span>
63
- </div>
59
+ {shown.length === 0 ? (
60
+ <Empty
61
+ icon={Activity}
62
+ iconVariant="chip"
63
+ title={t('events.emptyTitle', { fallback: 'Waiting for Events' })}
64
+ description={
65
+ isInsideIframe()
66
+ ? t('events.emptyHost', {
67
+ fallback: 'No graph events yet — activity will appear here as it happens.',
68
+ })
69
+ : t('events.emptyStandalone', {
70
+ fallback:
71
+ 'Open inside Bardioc (or run standalone with dev auth) to receive live events.',
72
+ })
73
+ }
74
+ />
75
+ ) : (
76
+ <Card className="min-w-0">
77
+ <ItemGroup>
78
+ {shown.map(event => (
79
+ // A long id truncates (`flex-nowrap` + `ItemTitle truncate`); badge and time keep
80
+ // their width.
81
+ <Item key={`${event.id}-${event.timestamp}`} size="sm" className="flex-nowrap">
82
+ <ItemContent>
83
+ <ItemTitle truncate className="font-mono">
84
+ {event.id}
85
+ </ItemTitle>
86
+ </ItemContent>
87
+ <ItemActions>
88
+ <Badge variant="info" numerical>
89
+ {event.type}
90
+ </Badge>
91
+ </ItemActions>
92
+ <ItemMeta>{new Date(event.timestamp).toLocaleTimeString()}</ItemMeta>
93
+ </Item>
64
94
  ))}
65
- </div>
66
- )}
67
- </Card>
95
+ </ItemGroup>
96
+ </Card>
97
+ )}
68
98
  </div>
69
99
  );
70
100
  }
@@ -0,0 +1,33 @@
1
+ import type { GraphEvent } from '@bardioc/app-sdk';
2
+
3
+ const NOW = Date.now();
4
+ const SECOND = 1000;
5
+
6
+ // Sample graph events for the debug menu's "Mock Data" preview: one of each event type, newest
7
+ // first, with a few long ids so the row truncation can be checked without a live stream.
8
+ export const MOCK_GRAPH_EVENTS: GraphEvent[] = [
9
+ {
10
+ id: 'cju16o7cg0001mz775fnoq66i_cln8olk8k014p0186lue9pl3l',
11
+ type: 'WRITE_TIMESERIES',
12
+ timestamp: NOW - 4 * SECOND,
13
+ },
14
+ { id: 'station-lagoon', type: 'UPDATE', timestamp: NOW - 11 * SECOND },
15
+ {
16
+ id: 'cju16o7cg0001mz775fnoq66i_cjnu3p5tf001bs934wf10n26n',
17
+ type: 'CREATE',
18
+ timestamp: NOW - 27 * SECOND,
19
+ },
20
+ { id: 'issue-2582', type: 'REPLACE', timestamp: NOW - 48 * SECOND },
21
+ {
22
+ id: 'cju16o7cg0001mz775fnoq66i_edge-station-monitors-sensor',
23
+ type: 'CONNECT',
24
+ timestamp: NOW - 73 * SECOND,
25
+ },
26
+ { id: 'station-aurora', type: 'UPDATE', timestamp: NOW - 2 * 60 * SECOND },
27
+ {
28
+ id: 'cju16o7cg0001mz775fnoq66i_edge-sensor-reports-station',
29
+ type: 'DISCONNECT',
30
+ timestamp: NOW - 3 * 60 * SECOND,
31
+ },
32
+ { id: 'sensor-obsolete-7', type: 'DELETE', timestamp: NOW - 5 * 60 * SECOND },
33
+ ];