@cryptiklemur/lattice 0.0.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.
Files changed (162) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/workflows/release.yml +44 -0
  3. package/.impeccable.md +66 -0
  4. package/.releaserc.json +32 -0
  5. package/.serena/project.yml +138 -0
  6. package/CLAUDE.md +35 -0
  7. package/CONTRIBUTING.md +93 -0
  8. package/LICENSE +21 -0
  9. package/README.md +83 -0
  10. package/bun.lock +1459 -0
  11. package/bunfig.toml +2 -0
  12. package/client/index.html +32 -0
  13. package/client/package.json +37 -0
  14. package/client/public/icons/icon-192.svg +11 -0
  15. package/client/public/icons/icon-512.svg +11 -0
  16. package/client/public/manifest.json +24 -0
  17. package/client/public/sw.js +61 -0
  18. package/client/src/App.tsx +28 -0
  19. package/client/src/components/auth/PassphrasePrompt.tsx +70 -0
  20. package/client/src/components/chat/ChatInput.tsx +241 -0
  21. package/client/src/components/chat/ChatView.tsx +727 -0
  22. package/client/src/components/chat/Message.tsx +362 -0
  23. package/client/src/components/chat/ModelSelector.tsx +87 -0
  24. package/client/src/components/chat/PermissionModeSelector.tsx +41 -0
  25. package/client/src/components/chat/StatusBar.tsx +50 -0
  26. package/client/src/components/chat/ToolGroup.tsx +129 -0
  27. package/client/src/components/chat/ToolResultRenderer.tsx +343 -0
  28. package/client/src/components/chat/toolSummary.ts +41 -0
  29. package/client/src/components/dashboard/DashboardView.tsx +219 -0
  30. package/client/src/components/dashboard/ProjectDashboardView.tsx +168 -0
  31. package/client/src/components/mesh/NodeBadge.tsx +24 -0
  32. package/client/src/components/mesh/PairingDialog.tsx +281 -0
  33. package/client/src/components/panels/FileBrowser.tsx +241 -0
  34. package/client/src/components/panels/StickyNotes.tsx +187 -0
  35. package/client/src/components/panels/Terminal.tsx +128 -0
  36. package/client/src/components/project-settings/ProjectClaude.tsx +304 -0
  37. package/client/src/components/project-settings/ProjectEnvironment.tsx +235 -0
  38. package/client/src/components/project-settings/ProjectGeneral.tsx +76 -0
  39. package/client/src/components/project-settings/ProjectMcp.tsx +232 -0
  40. package/client/src/components/project-settings/ProjectPermissions.tsx +209 -0
  41. package/client/src/components/project-settings/ProjectRules.tsx +277 -0
  42. package/client/src/components/project-settings/ProjectSettingsView.tsx +99 -0
  43. package/client/src/components/project-settings/ProjectSkills.tsx +91 -0
  44. package/client/src/components/settings/Appearance.tsx +151 -0
  45. package/client/src/components/settings/ClaudeSettings.tsx +151 -0
  46. package/client/src/components/settings/Environment.tsx +185 -0
  47. package/client/src/components/settings/GlobalMcp.tsx +207 -0
  48. package/client/src/components/settings/GlobalSkills.tsx +125 -0
  49. package/client/src/components/settings/MeshStatus.tsx +145 -0
  50. package/client/src/components/settings/SettingsView.tsx +57 -0
  51. package/client/src/components/settings/SkillMarketplace.tsx +175 -0
  52. package/client/src/components/settings/mcp-shared.tsx +194 -0
  53. package/client/src/components/settings/skill-shared.tsx +177 -0
  54. package/client/src/components/setup/SetupWizard.tsx +750 -0
  55. package/client/src/components/sidebar/NodeSettingsModal.tsx +180 -0
  56. package/client/src/components/sidebar/ProjectDropdown.tsx +43 -0
  57. package/client/src/components/sidebar/ProjectRail.tsx +291 -0
  58. package/client/src/components/sidebar/SearchFilter.tsx +52 -0
  59. package/client/src/components/sidebar/SessionList.tsx +384 -0
  60. package/client/src/components/sidebar/SettingsSidebar.tsx +128 -0
  61. package/client/src/components/sidebar/Sidebar.tsx +209 -0
  62. package/client/src/components/sidebar/UserIsland.tsx +59 -0
  63. package/client/src/components/sidebar/UserMenu.tsx +101 -0
  64. package/client/src/components/ui/CommandPalette.tsx +321 -0
  65. package/client/src/components/ui/ErrorBoundary.tsx +56 -0
  66. package/client/src/components/ui/IconPicker.tsx +209 -0
  67. package/client/src/components/ui/LatticeLogomark.tsx +19 -0
  68. package/client/src/components/ui/PopupMenu.tsx +98 -0
  69. package/client/src/components/ui/SaveFooter.tsx +38 -0
  70. package/client/src/components/ui/Toast.tsx +112 -0
  71. package/client/src/hooks/useMesh.ts +89 -0
  72. package/client/src/hooks/useProjectSettings.ts +56 -0
  73. package/client/src/hooks/useProjects.ts +66 -0
  74. package/client/src/hooks/useSaveState.ts +59 -0
  75. package/client/src/hooks/useSession.ts +317 -0
  76. package/client/src/hooks/useSidebar.ts +74 -0
  77. package/client/src/hooks/useSkills.ts +30 -0
  78. package/client/src/hooks/useTheme.ts +114 -0
  79. package/client/src/hooks/useWebSocket.ts +26 -0
  80. package/client/src/main.tsx +10 -0
  81. package/client/src/providers/WebSocketProvider.tsx +146 -0
  82. package/client/src/router.tsx +391 -0
  83. package/client/src/stores/mesh.ts +78 -0
  84. package/client/src/stores/session.ts +322 -0
  85. package/client/src/stores/sidebar.ts +336 -0
  86. package/client/src/stores/theme.ts +44 -0
  87. package/client/src/styles/global.css +167 -0
  88. package/client/src/styles/theme-vars.css +18 -0
  89. package/client/src/themes/index.ts +79 -0
  90. package/client/src/utils/findDuplicateKeys.ts +12 -0
  91. package/client/tsconfig.json +14 -0
  92. package/client/vite.config.ts +20 -0
  93. package/package.json +46 -0
  94. package/server/package.json +22 -0
  95. package/server/src/auth/passphrase.ts +48 -0
  96. package/server/src/config.ts +55 -0
  97. package/server/src/daemon.ts +338 -0
  98. package/server/src/features/ralph-loop.ts +173 -0
  99. package/server/src/features/scheduler.ts +281 -0
  100. package/server/src/features/sticky-notes.ts +102 -0
  101. package/server/src/handlers/chat.ts +194 -0
  102. package/server/src/handlers/fs.ts +84 -0
  103. package/server/src/handlers/loop.ts +37 -0
  104. package/server/src/handlers/mesh.ts +125 -0
  105. package/server/src/handlers/notes.ts +45 -0
  106. package/server/src/handlers/project-settings.ts +174 -0
  107. package/server/src/handlers/scheduler.ts +47 -0
  108. package/server/src/handlers/session.ts +159 -0
  109. package/server/src/handlers/settings.ts +109 -0
  110. package/server/src/handlers/skills.ts +380 -0
  111. package/server/src/handlers/terminal.ts +70 -0
  112. package/server/src/identity.ts +26 -0
  113. package/server/src/index.ts +190 -0
  114. package/server/src/mesh/connector.ts +209 -0
  115. package/server/src/mesh/discovery.ts +123 -0
  116. package/server/src/mesh/pairing.ts +94 -0
  117. package/server/src/mesh/peers.ts +52 -0
  118. package/server/src/mesh/proxy.ts +103 -0
  119. package/server/src/mesh/session-sync.ts +107 -0
  120. package/server/src/project/context-breakdown.ts +289 -0
  121. package/server/src/project/file-browser.ts +106 -0
  122. package/server/src/project/project-files.ts +267 -0
  123. package/server/src/project/registry.ts +57 -0
  124. package/server/src/project/sdk-bridge.ts +566 -0
  125. package/server/src/project/session.ts +432 -0
  126. package/server/src/project/terminal.ts +69 -0
  127. package/server/src/tls.ts +51 -0
  128. package/server/src/ws/broadcast.ts +31 -0
  129. package/server/src/ws/router.ts +104 -0
  130. package/server/src/ws/server.ts +2 -0
  131. package/server/tsconfig.json +16 -0
  132. package/shared/package.json +11 -0
  133. package/shared/src/constants.ts +7 -0
  134. package/shared/src/index.ts +4 -0
  135. package/shared/src/messages.ts +638 -0
  136. package/shared/src/models.ts +136 -0
  137. package/shared/src/project-settings.ts +45 -0
  138. package/shared/tsconfig.json +11 -0
  139. package/themes/amoled.json +20 -0
  140. package/themes/ayu-light.json +9 -0
  141. package/themes/catppuccin-latte.json +9 -0
  142. package/themes/catppuccin-mocha.json +9 -0
  143. package/themes/clay-light.json +10 -0
  144. package/themes/clay.json +10 -0
  145. package/themes/dracula.json +9 -0
  146. package/themes/everforest-light.json +9 -0
  147. package/themes/everforest.json +9 -0
  148. package/themes/github-light.json +9 -0
  149. package/themes/gruvbox-dark.json +9 -0
  150. package/themes/gruvbox-light.json +9 -0
  151. package/themes/monokai.json +9 -0
  152. package/themes/nord-light.json +9 -0
  153. package/themes/nord.json +9 -0
  154. package/themes/one-dark.json +9 -0
  155. package/themes/one-light.json +9 -0
  156. package/themes/rose-pine-dawn.json +9 -0
  157. package/themes/rose-pine.json +9 -0
  158. package/themes/solarized-dark.json +9 -0
  159. package/themes/solarized-light.json +9 -0
  160. package/themes/tokyo-night-light.json +9 -0
  161. package/themes/tokyo-night.json +9 -0
  162. package/tsconfig.json +26 -0
@@ -0,0 +1,56 @@
1
+ import { Component } from "react";
2
+ import type { ReactNode, ErrorInfo } from "react";
3
+
4
+ interface Props {
5
+ children: ReactNode;
6
+ fallback?: ReactNode;
7
+ }
8
+
9
+ interface State {
10
+ hasError: boolean;
11
+ error: Error | null;
12
+ }
13
+
14
+ export class ErrorBoundary extends Component<Props, State> {
15
+ constructor(props: Props) {
16
+ super(props);
17
+ this.state = { hasError: false, error: null };
18
+ }
19
+
20
+ static getDerivedStateFromError(error: Error): State {
21
+ return { hasError: true, error };
22
+ }
23
+
24
+ componentDidCatch(error: Error, info: ErrorInfo) {
25
+ console.error("[lattice] Render error:", error, info.componentStack);
26
+ }
27
+
28
+ handleReload() {
29
+ window.location.reload();
30
+ }
31
+
32
+ render() {
33
+ if (this.state.hasError) {
34
+ if (this.props.fallback) {
35
+ return this.props.fallback;
36
+ }
37
+ return (
38
+ <div className="min-h-screen bg-base-100 flex items-center justify-center">
39
+ <div className="text-center max-w-[400px] p-8">
40
+ <h2 className="text-[20px] font-semibold text-error mb-3">Something went wrong</h2>
41
+ <p className="text-[14px] text-base-content/60 mb-5">
42
+ {this.state.error?.message || "An unexpected error occurred."}
43
+ </p>
44
+ <button
45
+ onClick={this.handleReload}
46
+ className="btn btn-primary btn-sm"
47
+ >
48
+ Reload
49
+ </button>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+ return this.props.children;
55
+ }
56
+ }
@@ -0,0 +1,209 @@
1
+ import { useState, useMemo } from "react";
2
+ import { icons } from "lucide-react";
3
+ import type { ProjectIcon } from "@lattice/shared";
4
+
5
+ type Tab = "lucide" | "emoji" | "text" | "upload";
6
+
7
+ interface IconPickerProps {
8
+ value?: ProjectIcon;
9
+ onChange: (icon: ProjectIcon) => void;
10
+ }
11
+
12
+ function renderPreview(value?: ProjectIcon) {
13
+ if (!value) {
14
+ return (
15
+ <div className="w-8 h-8 rounded-lg bg-base-300 border border-base-content/15 flex items-center justify-center text-base-content/30 text-[11px]">
16
+ ?
17
+ </div>
18
+ );
19
+ }
20
+
21
+ if (value.type === "lucide") {
22
+ var LucideIcon = icons[value.name as keyof typeof icons];
23
+ if (!LucideIcon) return null;
24
+ return (
25
+ <div className="w-8 h-8 rounded-lg bg-base-300 border border-base-content/15 flex items-center justify-center text-base-content">
26
+ <LucideIcon size={18} />
27
+ </div>
28
+ );
29
+ }
30
+
31
+ if (value.type === "emoji") {
32
+ return (
33
+ <div className="w-8 h-8 rounded-lg bg-base-300 border border-base-content/15 flex items-center justify-center text-[18px]">
34
+ {value.value}
35
+ </div>
36
+ );
37
+ }
38
+
39
+ if (value.type === "text") {
40
+ return (
41
+ <div
42
+ className="w-8 h-8 rounded-lg bg-base-300 border border-base-content/15 flex items-center justify-center text-[14px] font-bold"
43
+ style={value.color ? { color: value.color } : undefined}
44
+ >
45
+ {value.value}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ if (value.type === "image") {
51
+ return (
52
+ <img src={value.path} alt="icon" className="w-8 h-8 rounded-lg object-cover border border-base-content/15" />
53
+ );
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ export function IconPicker({ value, onChange }: IconPickerProps) {
60
+ var [tab, setTab] = useState<Tab>(value?.type === "emoji" ? "emoji" : value?.type === "text" ? "text" : value?.type === "image" ? "upload" : "lucide");
61
+ var [search, setSearch] = useState("");
62
+ var [emojiValue, setEmojiValue] = useState(value?.type === "emoji" ? value.value : "");
63
+ var [textValue, setTextValue] = useState(value?.type === "text" ? value.value : "");
64
+ var [textColor, setTextColor] = useState(value?.type === "text" ? (value.color || "#ffffff") : "#ffffff");
65
+
66
+ var iconNames = useMemo(function () {
67
+ var allNames = Object.keys(icons);
68
+ if (!search.trim()) return allNames.slice(0, 60);
69
+ var term = search.toLowerCase();
70
+ return allNames.filter(function (name) {
71
+ return name.toLowerCase().includes(term);
72
+ }).slice(0, 60);
73
+ }, [search]);
74
+
75
+ var tabs: { id: Tab; label: string }[] = [
76
+ { id: "lucide", label: "Lucide" },
77
+ { id: "emoji", label: "Emoji" },
78
+ { id: "text", label: "Text" },
79
+ { id: "upload", label: "Upload" },
80
+ ];
81
+
82
+ function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
83
+ var file = e.target.files?.[0];
84
+ if (!file) return;
85
+ var reader = new FileReader();
86
+ reader.onload = function (ev) {
87
+ var dataUrl = ev.target?.result as string;
88
+ onChange({ type: "image", path: dataUrl });
89
+ };
90
+ reader.readAsDataURL(file);
91
+ }
92
+
93
+ return (
94
+ <div className="space-y-3">
95
+ <div className="flex items-center gap-3">
96
+ {renderPreview(value)}
97
+ <span className="text-[11px] text-base-content/40">Current icon</span>
98
+ </div>
99
+
100
+ <div className="flex gap-1">
101
+ {tabs.map(function (t) {
102
+ return (
103
+ <button
104
+ key={t.id}
105
+ type="button"
106
+ onClick={function () { setTab(t.id); }}
107
+ className={"btn btn-xs " + (tab === t.id ? "btn-primary" : "btn-ghost")}
108
+ >
109
+ {t.label}
110
+ </button>
111
+ );
112
+ })}
113
+ </div>
114
+
115
+ {tab === "lucide" && (
116
+ <div className="space-y-2">
117
+ <input
118
+ type="text"
119
+ value={search}
120
+ onChange={function (e) { setSearch(e.target.value); }}
121
+ placeholder="Search icons..."
122
+ className="w-full h-8 px-3 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[12px] focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
123
+ />
124
+ <div className="grid grid-cols-10 gap-1 max-h-48 overflow-y-auto">
125
+ {iconNames.map(function (name) {
126
+ var Icon = icons[name as keyof typeof icons];
127
+ var selected = value?.type === "lucide" && value.name === name;
128
+ return (
129
+ <button
130
+ key={name}
131
+ type="button"
132
+ title={name}
133
+ onClick={function () { onChange({ type: "lucide", name: name }); }}
134
+ className={
135
+ "w-8 h-8 flex items-center justify-center rounded-lg text-base-content transition-colors duration-75 " +
136
+ (selected ? "border border-primary bg-base-300" : "hover:bg-base-300")
137
+ }
138
+ >
139
+ <Icon size={16} />
140
+ </button>
141
+ );
142
+ })}
143
+ </div>
144
+ </div>
145
+ )}
146
+
147
+ {tab === "emoji" && (
148
+ <div>
149
+ <input
150
+ type="text"
151
+ value={emojiValue}
152
+ maxLength={2}
153
+ onChange={function (e) {
154
+ setEmojiValue(e.target.value);
155
+ if (e.target.value) {
156
+ onChange({ type: "emoji", value: e.target.value });
157
+ }
158
+ }}
159
+ placeholder="Enter emoji"
160
+ className="w-full h-9 px-3 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[13px] focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
161
+ />
162
+ </div>
163
+ )}
164
+
165
+ {tab === "text" && (
166
+ <div className="flex gap-2">
167
+ <input
168
+ type="text"
169
+ value={textValue}
170
+ maxLength={2}
171
+ onChange={function (e) {
172
+ setTextValue(e.target.value);
173
+ if (e.target.value) {
174
+ onChange({ type: "text", value: e.target.value, color: textColor });
175
+ }
176
+ }}
177
+ placeholder="1-2 chars"
178
+ className="flex-1 h-9 px-3 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[13px] focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]"
179
+ />
180
+ <input
181
+ type="color"
182
+ value={textColor}
183
+ onChange={function (e) {
184
+ setTextColor(e.target.value);
185
+ if (textValue) {
186
+ onChange({ type: "text", value: textValue, color: e.target.value });
187
+ }
188
+ }}
189
+ className="w-9 h-9 rounded-xl border border-base-content/15 bg-base-300 cursor-pointer"
190
+ />
191
+ </div>
192
+ )}
193
+
194
+ {tab === "upload" && (
195
+ <div className="space-y-2">
196
+ <input
197
+ type="file"
198
+ accept="image/*"
199
+ onChange={handleFileChange}
200
+ className="w-full text-[12px] text-base-content/60 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-[12px] file:bg-base-300 file:text-base-content/60 file:cursor-pointer"
201
+ />
202
+ {value?.type === "image" && (
203
+ <img src={value.path} alt="preview" className="w-16 h-16 rounded-xl object-cover border border-base-content/15" />
204
+ )}
205
+ </div>
206
+ )}
207
+ </div>
208
+ );
209
+ }
@@ -0,0 +1,19 @@
1
+ interface LatticeLogomarkProps {
2
+ size: number;
3
+ }
4
+
5
+ export function LatticeLogomark(props: LatticeLogomarkProps) {
6
+ var s = props.size;
7
+ return (
8
+ <svg width={s} height={s} viewBox="0 0 48 48" fill="none" aria-hidden="true">
9
+ <rect x="4" y="4" width="18" height="18" rx="3" fill="currentColor" />
10
+ <rect x="26" y="4" width="18" height="18" rx="3" fill="currentColor" opacity="0.55" />
11
+ <rect x="4" y="26" width="18" height="18" rx="3" fill="currentColor" opacity="0.55" />
12
+ <rect x="26" y="26" width="18" height="18" rx="3" fill="currentColor" opacity="0.25" />
13
+ <line x1="13" y1="22" x2="13" y2="26" stroke="currentColor" strokeWidth="1.5" opacity="0.8" />
14
+ <line x1="35" y1="22" x2="35" y2="26" stroke="currentColor" strokeWidth="1.5" opacity="0.5" />
15
+ <line x1="22" y1="13" x2="26" y2="13" stroke="currentColor" strokeWidth="1.5" opacity="0.8" />
16
+ <line x1="22" y1="35" x2="26" y2="35" stroke="currentColor" strokeWidth="1.5" opacity="0.5" />
17
+ </svg>
18
+ );
19
+ }
@@ -0,0 +1,98 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ export interface PopupMenuItem {
4
+ id: string;
5
+ label: string;
6
+ icon?: React.ReactNode;
7
+ danger?: boolean;
8
+ separator?: boolean;
9
+ }
10
+
11
+ interface PopupMenuProps {
12
+ items: PopupMenuItem[];
13
+ onSelect: (id: string) => void;
14
+ onClose: () => void;
15
+ anchorRef: React.RefObject<HTMLElement | null>;
16
+ position?: "above" | "below" | "right";
17
+ }
18
+
19
+ export function PopupMenu(props: PopupMenuProps) {
20
+ var menuRef = useRef<HTMLDivElement>(null);
21
+
22
+ useEffect(function () {
23
+ function handleClickOutside(e: MouseEvent) {
24
+ if (
25
+ menuRef.current &&
26
+ !menuRef.current.contains(e.target as Node) &&
27
+ props.anchorRef.current &&
28
+ !props.anchorRef.current.contains(e.target as Node)
29
+ ) {
30
+ props.onClose();
31
+ }
32
+ }
33
+ function handleEscape(e: KeyboardEvent) {
34
+ if (e.key === "Escape") {
35
+ props.onClose();
36
+ }
37
+ }
38
+ function handleScroll() {
39
+ props.onClose();
40
+ }
41
+ document.addEventListener("mousedown", handleClickOutside);
42
+ document.addEventListener("keydown", handleEscape);
43
+ window.addEventListener("scroll", handleScroll, true);
44
+ return function () {
45
+ document.removeEventListener("mousedown", handleClickOutside);
46
+ document.removeEventListener("keydown", handleEscape);
47
+ window.removeEventListener("scroll", handleScroll, true);
48
+ };
49
+ }, [props.onClose, props.anchorRef]);
50
+
51
+ var style: React.CSSProperties = {};
52
+ if (props.anchorRef.current) {
53
+ var rect = props.anchorRef.current.getBoundingClientRect();
54
+ var pos = props.position ?? "above";
55
+ if (pos === "above") {
56
+ style.bottom = window.innerHeight - rect.top + 4 + "px";
57
+ style.left = rect.left + "px";
58
+ } else if (pos === "below") {
59
+ style.top = rect.bottom + 4 + "px";
60
+ style.left = rect.left + "px";
61
+ } else if (pos === "right") {
62
+ style.top = rect.top + "px";
63
+ style.left = rect.right + 4 + "px";
64
+ }
65
+ }
66
+
67
+ return (
68
+ <div
69
+ ref={menuRef}
70
+ role="menu"
71
+ aria-label="Actions"
72
+ className="fixed z-[9999] bg-base-300 border border-base-content/10 rounded-lg shadow-xl p-1 min-w-[180px] max-h-[80vh] overflow-y-auto"
73
+ style={style}
74
+ >
75
+ {props.items.map(function (item) {
76
+ if (item.separator) {
77
+ return <div key={item.id} className="h-px bg-base-content/8 my-1 mx-2" />;
78
+ }
79
+ return (
80
+ <button
81
+ key={item.id}
82
+ role="menuitem"
83
+ onClick={function () { props.onSelect(item.id); }}
84
+ className={
85
+ "w-full flex items-center gap-2 px-2.5 py-[6px] rounded text-[11px] text-left cursor-pointer transition-colors duration-[120ms] outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset " +
86
+ (item.danger
87
+ ? "text-error hover:bg-error/10"
88
+ : "text-base-content/70 hover:bg-base-content/5 hover:text-base-content")
89
+ }
90
+ >
91
+ {item.icon && <span className="opacity-60 flex-shrink-0">{item.icon}</span>}
92
+ {item.label}
93
+ </button>
94
+ );
95
+ })}
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,38 @@
1
+ import type { SaveState } from "../../hooks/useSaveState";
2
+
3
+ interface SaveFooterProps {
4
+ dirty: boolean;
5
+ saving: boolean;
6
+ saveState: SaveState;
7
+ onSave: () => void;
8
+ extraStatus?: string;
9
+ }
10
+
11
+ export function SaveFooter({ dirty, saving, saveState, onSave, extraStatus }: SaveFooterProps) {
12
+ var disabled = saving || (!dirty && saveState !== "error");
13
+
14
+ return (
15
+ <div className="flex items-center justify-end gap-3">
16
+ {extraStatus && (
17
+ <div className="text-[11px] text-warning/70">{extraStatus}</div>
18
+ )}
19
+ {!extraStatus && dirty && saveState === "idle" && !saving && (
20
+ <div className="text-[11px] text-warning/70">Unsaved changes</div>
21
+ )}
22
+ {saveState === "error" && (
23
+ <div className="text-[11px] text-error">Save failed — try again</div>
24
+ )}
25
+ <button
26
+ onClick={onSave}
27
+ disabled={disabled}
28
+ className={
29
+ "btn btn-sm " +
30
+ (saveState === "saved" ? "btn-success" : saveState === "error" ? "btn-error" : "btn-primary") +
31
+ (disabled ? " opacity-50 cursor-not-allowed" : "")
32
+ }
33
+ >
34
+ {saving ? "Saving..." : saveState === "saved" ? "Saved" : saveState === "error" ? "Retry" : "Save Changes"}
35
+ </button>
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,112 @@
1
+ import { useEffect, useState, useCallback } from "react";
2
+ import { X, Info, AlertTriangle, AlertCircle } from "lucide-react";
3
+
4
+ export interface ToastItem {
5
+ id: string;
6
+ message: string;
7
+ type: "info" | "error" | "warning";
8
+ }
9
+
10
+ interface ToastProps {
11
+ items: ToastItem[];
12
+ onDismiss: (id: string) => void;
13
+ }
14
+
15
+ var ICON_MAP = {
16
+ info: Info,
17
+ warning: AlertTriangle,
18
+ error: AlertCircle,
19
+ };
20
+
21
+ var ACCENT_MAP = {
22
+ info: "bg-primary",
23
+ warning: "bg-warning",
24
+ error: "bg-error",
25
+ };
26
+
27
+ var ICON_COLOR_MAP = {
28
+ info: "text-primary",
29
+ warning: "text-warning",
30
+ error: "text-error",
31
+ };
32
+
33
+ export function Toast(props: ToastProps) {
34
+ if (props.items.length === 0) {
35
+ return null;
36
+ }
37
+
38
+ return (
39
+ <div className="fixed top-3 right-3 z-[9999] flex flex-col gap-2 max-w-[340px]">
40
+ {props.items.map(function (item) {
41
+ var Icon = ICON_MAP[item.type];
42
+ return (
43
+ <div
44
+ key={item.id}
45
+ className="flex items-start gap-2.5 bg-base-300 border border-base-content/10 rounded-lg shadow-xl px-3 py-2.5 animate-[toast-in_0.18s_ease]"
46
+ >
47
+ <div className={"w-0.5 self-stretch rounded-full flex-shrink-0 " + ACCENT_MAP[item.type]} />
48
+ <Icon size={14} className={"flex-shrink-0 mt-0.5 " + ICON_COLOR_MAP[item.type]} />
49
+ <span className="flex-1 text-[13px] text-base-content/80 leading-snug">{item.message}</span>
50
+ <button
51
+ onClick={function () { props.onDismiss(item.id); }}
52
+ aria-label="Dismiss"
53
+ className="flex-shrink-0 text-base-content/30 hover:text-base-content/60 transition-colors mt-0.5"
54
+ >
55
+ <X size={13} />
56
+ </button>
57
+ </div>
58
+ );
59
+ })}
60
+ </div>
61
+ );
62
+ }
63
+
64
+ var toastListeners: Array<(item: ToastItem) => void> = [];
65
+
66
+ export function showToast(message: string, type: ToastItem["type"] = "info"): void {
67
+ var item: ToastItem = {
68
+ id: Math.random().toString(36).slice(2),
69
+ message,
70
+ type,
71
+ };
72
+ toastListeners.forEach(function (listener) {
73
+ listener(item);
74
+ });
75
+ }
76
+
77
+ export function useToastState(): { items: ToastItem[]; dismiss: (id: string) => void } {
78
+ var [items, setItems] = useState<ToastItem[]>([]);
79
+
80
+ var dismiss = useCallback(function (id: string) {
81
+ setItems(function (prev) {
82
+ return prev.filter(function (item) {
83
+ return item.id !== id;
84
+ });
85
+ });
86
+ }, []);
87
+
88
+ useEffect(function () {
89
+ function listener(item: ToastItem) {
90
+ setItems(function (prev) {
91
+ return [...prev, item];
92
+ });
93
+ setTimeout(function () {
94
+ setItems(function (prev) {
95
+ return prev.filter(function (i) {
96
+ return i.id !== item.id;
97
+ });
98
+ });
99
+ }, 5000);
100
+ }
101
+
102
+ toastListeners.push(listener);
103
+
104
+ return function () {
105
+ toastListeners = toastListeners.filter(function (l) {
106
+ return l !== listener;
107
+ });
108
+ };
109
+ }, []);
110
+
111
+ return { items, dismiss };
112
+ }
@@ -0,0 +1,89 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useStore } from "@tanstack/react-store";
3
+ import type { ServerMessage, NodeInfo } from "@lattice/shared";
4
+ import { useWebSocket } from "./useWebSocket";
5
+ import {
6
+ getMeshStore,
7
+ setNodes,
8
+ setNodeOnline,
9
+ setNodeOffline,
10
+ addOrUpdateNode,
11
+ setSelectedNodeId,
12
+ setInvite,
13
+ } from "../stores/mesh";
14
+
15
+ export interface UseMeshResult {
16
+ nodes: NodeInfo[];
17
+ activeNodeId: string | null;
18
+ setActiveNodeId: (id: string | null) => void;
19
+ generateInvite: () => void;
20
+ inviteCode: string | null;
21
+ inviteQr: string | null;
22
+ }
23
+
24
+ export function useMesh(): UseMeshResult {
25
+ var ws = useWebSocket();
26
+ var store = getMeshStore();
27
+
28
+ var nodes = useStore(store, function (s) { return s.nodes; });
29
+ var activeNodeId = useStore(store, function (s) { return s.selectedNodeId; });
30
+ var inviteCode = useStore(store, function (s) { return s.inviteCode; });
31
+ var inviteQr = useStore(store, function (s) { return s.inviteQr; });
32
+
33
+ var handleRef = useRef<(msg: ServerMessage) => void>(function () {});
34
+
35
+ useEffect(function () {
36
+ handleRef.current = function (msg: ServerMessage) {
37
+ if (msg.type === "mesh:nodes") {
38
+ setNodes(msg.nodes);
39
+ } else if (msg.type === "mesh:node_online") {
40
+ setNodeOnline(msg.nodeId);
41
+ } else if (msg.type === "mesh:node_offline") {
42
+ setNodeOffline(msg.nodeId);
43
+ } else if (msg.type === "mesh:paired") {
44
+ addOrUpdateNode(msg.node);
45
+ } else if (msg.type === "mesh:invite_code") {
46
+ setInvite(msg.code, msg.qrDataUrl);
47
+ }
48
+ };
49
+ });
50
+
51
+ useEffect(function () {
52
+ function handler(msg: ServerMessage) {
53
+ handleRef.current(msg);
54
+ }
55
+
56
+ ws.subscribe("mesh:nodes", handler);
57
+ ws.subscribe("mesh:node_online", handler);
58
+ ws.subscribe("mesh:node_offline", handler);
59
+ ws.subscribe("mesh:paired", handler);
60
+ ws.subscribe("mesh:invite_code", handler);
61
+
62
+ return function () {
63
+ ws.unsubscribe("mesh:nodes", handler);
64
+ ws.unsubscribe("mesh:node_online", handler);
65
+ ws.unsubscribe("mesh:node_offline", handler);
66
+ ws.unsubscribe("mesh:paired", handler);
67
+ ws.unsubscribe("mesh:invite_code", handler);
68
+ };
69
+ }, [ws]);
70
+
71
+ useEffect(function () {
72
+ if (ws.status === "connected") {
73
+ ws.send({ type: "settings:get" });
74
+ }
75
+ }, [ws.status, ws]);
76
+
77
+ function generateInvite() {
78
+ ws.send({ type: "mesh:generate_invite" });
79
+ }
80
+
81
+ return {
82
+ nodes,
83
+ activeNodeId,
84
+ setActiveNodeId: setSelectedNodeId,
85
+ generateInvite,
86
+ inviteCode,
87
+ inviteQr,
88
+ };
89
+ }
@@ -0,0 +1,56 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useWebSocket } from "./useWebSocket";
3
+ import type { ServerMessage, ProjectSettings, ProjectSettingsDataMessage, ProjectSettingsErrorMessage } from "@lattice/shared";
4
+
5
+ export function useProjectSettings(projectSlug: string | null) {
6
+ var { status, send, subscribe, unsubscribe } = useWebSocket();
7
+ var [settings, setSettings] = useState<ProjectSettings | null>(null);
8
+ var [loading, setLoading] = useState(true);
9
+ var [error, setError] = useState<string | null>(null);
10
+
11
+ useEffect(function () {
12
+ if (!projectSlug) return;
13
+
14
+ function handleData(msg: ServerMessage) {
15
+ if (msg.type !== "project-settings:data") return;
16
+ var data = msg as ProjectSettingsDataMessage;
17
+ if (data.projectSlug !== projectSlug) return;
18
+ setSettings(data.settings);
19
+ setLoading(false);
20
+ setError(null);
21
+ }
22
+
23
+ function handleError(msg: ServerMessage) {
24
+ if (msg.type !== "project-settings:error") return;
25
+ var data = msg as ProjectSettingsErrorMessage;
26
+ if (data.projectSlug !== projectSlug) return;
27
+ setError(data.message);
28
+ setLoading(false);
29
+ }
30
+
31
+ subscribe("project-settings:data", handleData);
32
+ subscribe("project-settings:error", handleError);
33
+
34
+ if (status === "connected") {
35
+ setLoading(true);
36
+ send({ type: "project-settings:get", projectSlug: projectSlug });
37
+ }
38
+
39
+ return function () {
40
+ unsubscribe("project-settings:data", handleData);
41
+ unsubscribe("project-settings:error", handleError);
42
+ };
43
+ }, [projectSlug, status]);
44
+
45
+ function updateSection(section: string, data: Record<string, unknown>) {
46
+ if (!projectSlug) return;
47
+ send({
48
+ type: "project-settings:update",
49
+ projectSlug: projectSlug,
50
+ section: section,
51
+ settings: data,
52
+ });
53
+ }
54
+
55
+ return { settings, loading, error, updateSection };
56
+ }