@pablozaiden/webapp 0.3.4 → 0.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -70,6 +70,8 @@ type StoredSidebarPin = {
70
70
  route: WebAppRoute;
71
71
  };
72
72
 
73
+ type SidebarCollapsedState = Record<string, boolean>;
74
+
73
75
  function isSidebarShortcutEditableTarget(target: EventTarget | null): boolean {
74
76
  if (!(target instanceof HTMLElement)) {
75
77
  return false;
@@ -251,6 +253,17 @@ function pinStorageKey(appName: string, explicitKey?: string): string {
251
253
  return explicitKey ?? `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.pins`;
252
254
  }
253
255
 
256
+ function sidebarCollapsedStorageKey(appName: string): string {
257
+ return `webapp.${appName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.sidebar.collapsed`;
258
+ }
259
+
260
+ function isSidebarCollapsedState(value: unknown): value is SidebarCollapsedState {
261
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
262
+ return false;
263
+ }
264
+ return Object.values(value).every((entry) => typeof entry === "boolean");
265
+ }
266
+
254
267
  function toStoredPin(node: SidebarNode): StoredSidebarPin | undefined {
255
268
  if (!node.route) return undefined;
256
269
  return {
@@ -291,6 +304,33 @@ function useSidebarPins(appName: string, storageKey?: string) {
291
304
  return { pins, pinIds, pin, unpin };
292
305
  }
293
306
 
307
+ function useSidebarCollapsedState(appName: string) {
308
+ const key = sidebarCollapsedStorageKey(appName);
309
+ const [collapsed, setCollapsed] = useState<SidebarCollapsedState>(() => {
310
+ try {
311
+ const raw = localStorage.getItem(key);
312
+ if (!raw) return {};
313
+ const parsed: unknown = JSON.parse(raw);
314
+ return isSidebarCollapsedState(parsed) ? parsed : {};
315
+ } catch {
316
+ return {};
317
+ }
318
+ });
319
+
320
+ useEffect(() => {
321
+ localStorage.setItem(key, JSON.stringify(collapsed));
322
+ }, [key, collapsed]);
323
+
324
+ const toggleCollapsed = useCallback((id: string, isCollapsed: boolean) => {
325
+ setCollapsed((current) => {
326
+ const currentIsCollapsed = current[id] ?? isCollapsed;
327
+ return { ...current, [id]: !currentIsCollapsed };
328
+ });
329
+ }, []);
330
+
331
+ return { collapsed, toggleCollapsed };
332
+ }
333
+
294
334
  function Icon({ name }: { name: "settings" | "sidebar" | "plus" | "home" | "search" | "bolt" | "chat" | "code" | "refresh" }) {
295
335
  const common = { "aria-hidden": true, viewBox: "0 0 24 24", className: "wapp-svg" };
296
336
  if (name === "settings") return <svg {...common}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82V22a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33H2a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-.6 1.65 1.65 0 0 0 .33-1.82V2a2 2 0 1 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.14.33.34.64.6 1 .26.36.61.6 1 .6H22a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-2.51.4Z" /></svg>;
@@ -500,8 +540,7 @@ function sidebarIndentStyle(level: number, parentKind: SidebarTreeParentKind): {
500
540
  return { marginLeft: `${baseIndentRem + nestedSectionIndentRem}rem` };
501
541
  }
502
542
 
503
- function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }: { nodes: SidebarNode[]; route: WebAppRoute; navigate: (route: WebAppRoute) => void; level?: number; parentKind?: SidebarTreeParentKind }) {
504
- const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
543
+ function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed, level = 0, parentKind = "root" }: { nodes: SidebarNode[]; route: WebAppRoute; navigate: (route: WebAppRoute) => void; collapsed: SidebarCollapsedState; toggleCollapsed: (id: string, isCollapsed: boolean) => void; level?: number; parentKind?: SidebarTreeParentKind }) {
505
544
  const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
506
545
  return (
507
546
  <>
@@ -513,7 +552,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
513
552
  <section className={`wapp-sidebar-section ${level === 0 ? "top" : "nested"}`} key={node.id}>
514
553
  <div className="wapp-sidebar-section-title" style={sidebarIndentStyle(level, parentKind)}>
515
554
  {hasChildren ? (
516
- <button type="button" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => setCollapsed((current) => ({ ...current, [node.id]: !isCollapsed }))}>
555
+ <button type="button" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => toggleCollapsed(node.id, isCollapsed)}>
517
556
  <span>{isCollapsed ? "▶" : "▼"}</span>{node.title}
518
557
  </button>
519
558
  ) : (
@@ -521,7 +560,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
521
560
  )}
522
561
  {node.action ? <button type="button" className="wapp-sidebar-action" title={node.action.title} aria-label={node.action.title} onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
523
562
  </div>
524
- {!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} level={level + 1} parentKind="section" /> : null}
563
+ {!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} level={level + 1} parentKind="section" /> : null}
525
564
  {!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
526
565
  </section>
527
566
  );
@@ -529,7 +568,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
529
568
  const active = node.route?.view === route.view && Object.entries(node.route).every(([key, value]) => key === "view" || route[key] === value);
530
569
  return (
531
570
  <div className={`wapp-sidebar-item-wrap ${hasChildren ? "has-toggle" : ""}`} key={node.id} style={sidebarIndentStyle(level, parentKind)}>
532
- {hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => setCollapsed((current) => ({ ...current, [node.id]: !isCollapsed }))}>{isCollapsed ? "▶" : "▼"}</button> : null}
571
+ {hasChildren ? <button type="button" className="wapp-tree-toggle" aria-expanded={!isCollapsed} aria-label={`${isCollapsed ? "Expand" : "Collapse"} ${node.title}`} onClick={() => toggleCollapsed(node.id, isCollapsed)}>{isCollapsed ? "▶" : "▼"}</button> : null}
533
572
  <button
534
573
  type="button"
535
574
  className={`wapp-sidebar-item ${active ? "active" : ""}`}
@@ -546,7 +585,7 @@ function SidebarTree({ nodes, route, navigate, level = 0, parentKind = "root" }:
546
585
  </span>
547
586
  {node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
548
587
  </button>
549
- {!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} level={level + 1} parentKind="item" /></div> : null}
588
+ {!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} level={level + 1} parentKind="item" /></div> : null}
550
589
  </div>
551
590
  );
552
591
  })}
@@ -992,6 +1031,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
992
1031
  const [search, setSearch] = useState("");
993
1032
  const [sidebarOpen, setSidebarOpen] = useState(false);
994
1033
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
1034
+ const sidebarTreeState = useSidebarCollapsedState(appName);
995
1035
  const toggleSidebarCollapsed = useCallback(() => {
996
1036
  setSidebarCollapsed((current) => {
997
1037
  const nextCollapsed = !current;
@@ -1153,7 +1193,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
1153
1193
  </div>
1154
1194
  <div className="wapp-sidebar-scroll">
1155
1195
  {sidebarSearchEnabled ? <label className="wapp-search"><span className="sr-only">Search</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search" /></label> : null}
1156
- <SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} />
1196
+ <SidebarTree nodes={nodes} route={route} navigate={(next) => { navigate(next); setSidebarOpen(false); }} collapsed={sidebarTreeState.collapsed} toggleCollapsed={sidebarTreeState.toggleCollapsed} />
1157
1197
  <div className="wapp-sidebar-footer">v{effectiveVersion}<button type="button" aria-label="Reload" onClick={() => window.location.reload()}><Icon name="refresh" /></button></div>
1158
1198
  </div>
1159
1199
  </aside>
@@ -1072,9 +1072,10 @@ textarea::placeholder {
1072
1072
  }
1073
1073
 
1074
1074
  .wapp-badge {
1075
+ --wapp-badge-fg: var(--wapp-muted);
1075
1076
  border-radius: 999px;
1076
1077
  background: color-mix(in srgb, var(--wapp-muted), transparent 86%);
1077
- color: var(--wapp-muted);
1078
+ color: var(--wapp-badge-fg);
1078
1079
  font-size: 0.6875rem;
1079
1080
  font-weight: 700;
1080
1081
  padding: 0.125rem 0.5rem;
@@ -1093,61 +1094,73 @@ textarea::placeholder {
1093
1094
 
1094
1095
  .wapp-badge-success,
1095
1096
  .wapp-badge-completed {
1097
+ --wapp-badge-fg: rgb(22 101 52);
1096
1098
  background: rgb(220 252 231);
1097
- color: rgb(22 101 52);
1099
+ color: var(--wapp-badge-fg);
1098
1100
  }
1099
1101
  .wapp-badge-warning,
1100
1102
  .wapp-badge-plan_ready {
1103
+ --wapp-badge-fg: rgb(146 64 14);
1101
1104
  background: rgb(254 243 199);
1102
- color: rgb(146 64 14);
1105
+ color: var(--wapp-badge-fg);
1103
1106
  }
1104
1107
  .wapp-badge-error,
1105
1108
  .wapp-badge-failed {
1109
+ --wapp-badge-fg: rgb(153 27 27);
1106
1110
  background: rgb(254 226 226);
1107
- color: rgb(153 27 27);
1111
+ color: var(--wapp-badge-fg);
1108
1112
  }
1109
1113
  .wapp-badge-info,
1110
1114
  .wapp-badge-running {
1115
+ --wapp-badge-fg: rgb(30 64 175);
1111
1116
  background: rgb(219 234 254);
1112
- color: rgb(30 64 175);
1117
+ color: var(--wapp-badge-fg);
1113
1118
  }
1114
1119
  .wapp-badge-disabled,
1115
1120
  .wapp-badge-stopped,
1116
1121
  .wapp-badge-idle {
1122
+ --wapp-badge-fg: rgb(31 41 55);
1117
1123
  background: rgb(243 244 246);
1118
- color: rgb(31 41 55);
1124
+ color: var(--wapp-badge-fg);
1119
1125
  }
1120
1126
  .wapp-badge-planning {
1127
+ --wapp-badge-fg: rgb(21 94 117);
1121
1128
  background: rgb(207 250 254);
1122
- color: rgb(21 94 117);
1129
+ color: var(--wapp-badge-fg);
1123
1130
  }
1124
1131
  .wapp-badge-merged {
1132
+ --wapp-badge-fg: rgb(107 33 168);
1125
1133
  background: rgb(243 232 255);
1126
- color: rgb(107 33 168);
1134
+ color: var(--wapp-badge-fg);
1127
1135
  }
1128
1136
  .wapp-badge-pushed {
1137
+ --wapp-badge-fg: rgb(55 48 163);
1129
1138
  background: rgb(224 231 255);
1130
- color: rgb(55 48 163);
1139
+ color: var(--wapp-badge-fg);
1131
1140
  }
1132
1141
  .wapp-badge-deleted {
1142
+ --wapp-badge-fg: rgb(107 114 128);
1133
1143
  background: rgb(243 244 246);
1134
- color: rgb(107 114 128);
1144
+ color: var(--wapp-badge-fg);
1135
1145
  }
1136
1146
 
1137
1147
  :root.dark .wapp-badge-success,
1138
1148
  :root.dark .wapp-badge-completed {
1149
+ --wapp-badge-fg: rgb(134 239 172);
1139
1150
  background: rgb(20 83 45);
1140
- color: rgb(134 239 172);
1151
+ color: var(--wapp-badge-fg);
1141
1152
  }
1142
1153
  :root.dark .wapp-badge-warning,
1143
1154
  :root.dark .wapp-badge-plan_ready {
1155
+ --wapp-badge-fg: rgb(252 211 77);
1144
1156
  background: rgb(120 53 15);
1145
- color: rgb(252 211 77);
1157
+ color: var(--wapp-badge-fg);
1146
1158
  }
1147
1159
  :root.dark .wapp-badge-error,
1148
1160
  :root.dark .wapp-badge-failed {
1161
+ --wapp-badge-fg: rgb(252 165 165);
1149
1162
  background: rgb(127 29 29);
1150
- color: rgb(252 165 165);
1163
+ color: var(--wapp-badge-fg);
1151
1164
  }
1152
1165
  :root.dark .wapp-badge-info,
1153
1166
  :root.dark .wapp-badge-running,
@@ -1159,25 +1172,36 @@ textarea::placeholder {
1159
1172
  }
1160
1173
  :root.dark .wapp-badge-info,
1161
1174
  :root.dark .wapp-badge-running {
1162
- color: rgb(147 197 253);
1175
+ --wapp-badge-fg: rgb(147 197 253);
1176
+ color: var(--wapp-badge-fg);
1163
1177
  }
1164
1178
  :root.dark .wapp-badge-planning {
1165
- color: rgb(103 232 249);
1179
+ --wapp-badge-fg: rgb(103 232 249);
1180
+ color: var(--wapp-badge-fg);
1166
1181
  }
1167
1182
  :root.dark .wapp-badge-merged {
1168
- color: rgb(216 180 254);
1183
+ --wapp-badge-fg: rgb(216 180 254);
1184
+ color: var(--wapp-badge-fg);
1169
1185
  }
1170
1186
  :root.dark .wapp-badge-pushed {
1171
- color: rgb(165 180 252);
1187
+ --wapp-badge-fg: rgb(165 180 252);
1188
+ color: var(--wapp-badge-fg);
1172
1189
  }
1173
1190
  :root.dark .wapp-badge-deleted {
1174
- color: rgb(107 114 128);
1191
+ --wapp-badge-fg: rgb(107 114 128);
1192
+ color: var(--wapp-badge-fg);
1175
1193
  }
1176
1194
  :root.dark .wapp-badge-disabled,
1177
1195
  :root.dark .wapp-badge-stopped,
1178
1196
  :root.dark .wapp-badge-idle {
1197
+ --wapp-badge-fg: rgb(209 213 219);
1179
1198
  background: rgb(64 64 64);
1180
- color: rgb(209 213 219);
1199
+ color: var(--wapp-badge-fg);
1200
+ }
1201
+
1202
+ .wapp-badge.wapp-sidebar-badge,
1203
+ :root.dark .wapp-badge.wapp-sidebar-badge {
1204
+ background: var(--wapp-badge-fg);
1181
1205
  }
1182
1206
 
1183
1207
  .wapp-settings {