@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.
- package/.editorconfig +12 -0
- package/.github/workflows/release.yml +44 -0
- package/.impeccable.md +66 -0
- package/.releaserc.json +32 -0
- package/.serena/project.yml +138 -0
- package/CLAUDE.md +35 -0
- package/CONTRIBUTING.md +93 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/bun.lock +1459 -0
- package/bunfig.toml +2 -0
- package/client/index.html +32 -0
- package/client/package.json +37 -0
- package/client/public/icons/icon-192.svg +11 -0
- package/client/public/icons/icon-512.svg +11 -0
- package/client/public/manifest.json +24 -0
- package/client/public/sw.js +61 -0
- package/client/src/App.tsx +28 -0
- package/client/src/components/auth/PassphrasePrompt.tsx +70 -0
- package/client/src/components/chat/ChatInput.tsx +241 -0
- package/client/src/components/chat/ChatView.tsx +727 -0
- package/client/src/components/chat/Message.tsx +362 -0
- package/client/src/components/chat/ModelSelector.tsx +87 -0
- package/client/src/components/chat/PermissionModeSelector.tsx +41 -0
- package/client/src/components/chat/StatusBar.tsx +50 -0
- package/client/src/components/chat/ToolGroup.tsx +129 -0
- package/client/src/components/chat/ToolResultRenderer.tsx +343 -0
- package/client/src/components/chat/toolSummary.ts +41 -0
- package/client/src/components/dashboard/DashboardView.tsx +219 -0
- package/client/src/components/dashboard/ProjectDashboardView.tsx +168 -0
- package/client/src/components/mesh/NodeBadge.tsx +24 -0
- package/client/src/components/mesh/PairingDialog.tsx +281 -0
- package/client/src/components/panels/FileBrowser.tsx +241 -0
- package/client/src/components/panels/StickyNotes.tsx +187 -0
- package/client/src/components/panels/Terminal.tsx +128 -0
- package/client/src/components/project-settings/ProjectClaude.tsx +304 -0
- package/client/src/components/project-settings/ProjectEnvironment.tsx +235 -0
- package/client/src/components/project-settings/ProjectGeneral.tsx +76 -0
- package/client/src/components/project-settings/ProjectMcp.tsx +232 -0
- package/client/src/components/project-settings/ProjectPermissions.tsx +209 -0
- package/client/src/components/project-settings/ProjectRules.tsx +277 -0
- package/client/src/components/project-settings/ProjectSettingsView.tsx +99 -0
- package/client/src/components/project-settings/ProjectSkills.tsx +91 -0
- package/client/src/components/settings/Appearance.tsx +151 -0
- package/client/src/components/settings/ClaudeSettings.tsx +151 -0
- package/client/src/components/settings/Environment.tsx +185 -0
- package/client/src/components/settings/GlobalMcp.tsx +207 -0
- package/client/src/components/settings/GlobalSkills.tsx +125 -0
- package/client/src/components/settings/MeshStatus.tsx +145 -0
- package/client/src/components/settings/SettingsView.tsx +57 -0
- package/client/src/components/settings/SkillMarketplace.tsx +175 -0
- package/client/src/components/settings/mcp-shared.tsx +194 -0
- package/client/src/components/settings/skill-shared.tsx +177 -0
- package/client/src/components/setup/SetupWizard.tsx +750 -0
- package/client/src/components/sidebar/NodeSettingsModal.tsx +180 -0
- package/client/src/components/sidebar/ProjectDropdown.tsx +43 -0
- package/client/src/components/sidebar/ProjectRail.tsx +291 -0
- package/client/src/components/sidebar/SearchFilter.tsx +52 -0
- package/client/src/components/sidebar/SessionList.tsx +384 -0
- package/client/src/components/sidebar/SettingsSidebar.tsx +128 -0
- package/client/src/components/sidebar/Sidebar.tsx +209 -0
- package/client/src/components/sidebar/UserIsland.tsx +59 -0
- package/client/src/components/sidebar/UserMenu.tsx +101 -0
- package/client/src/components/ui/CommandPalette.tsx +321 -0
- package/client/src/components/ui/ErrorBoundary.tsx +56 -0
- package/client/src/components/ui/IconPicker.tsx +209 -0
- package/client/src/components/ui/LatticeLogomark.tsx +19 -0
- package/client/src/components/ui/PopupMenu.tsx +98 -0
- package/client/src/components/ui/SaveFooter.tsx +38 -0
- package/client/src/components/ui/Toast.tsx +112 -0
- package/client/src/hooks/useMesh.ts +89 -0
- package/client/src/hooks/useProjectSettings.ts +56 -0
- package/client/src/hooks/useProjects.ts +66 -0
- package/client/src/hooks/useSaveState.ts +59 -0
- package/client/src/hooks/useSession.ts +317 -0
- package/client/src/hooks/useSidebar.ts +74 -0
- package/client/src/hooks/useSkills.ts +30 -0
- package/client/src/hooks/useTheme.ts +114 -0
- package/client/src/hooks/useWebSocket.ts +26 -0
- package/client/src/main.tsx +10 -0
- package/client/src/providers/WebSocketProvider.tsx +146 -0
- package/client/src/router.tsx +391 -0
- package/client/src/stores/mesh.ts +78 -0
- package/client/src/stores/session.ts +322 -0
- package/client/src/stores/sidebar.ts +336 -0
- package/client/src/stores/theme.ts +44 -0
- package/client/src/styles/global.css +167 -0
- package/client/src/styles/theme-vars.css +18 -0
- package/client/src/themes/index.ts +79 -0
- package/client/src/utils/findDuplicateKeys.ts +12 -0
- package/client/tsconfig.json +14 -0
- package/client/vite.config.ts +20 -0
- package/package.json +46 -0
- package/server/package.json +22 -0
- package/server/src/auth/passphrase.ts +48 -0
- package/server/src/config.ts +55 -0
- package/server/src/daemon.ts +338 -0
- package/server/src/features/ralph-loop.ts +173 -0
- package/server/src/features/scheduler.ts +281 -0
- package/server/src/features/sticky-notes.ts +102 -0
- package/server/src/handlers/chat.ts +194 -0
- package/server/src/handlers/fs.ts +84 -0
- package/server/src/handlers/loop.ts +37 -0
- package/server/src/handlers/mesh.ts +125 -0
- package/server/src/handlers/notes.ts +45 -0
- package/server/src/handlers/project-settings.ts +174 -0
- package/server/src/handlers/scheduler.ts +47 -0
- package/server/src/handlers/session.ts +159 -0
- package/server/src/handlers/settings.ts +109 -0
- package/server/src/handlers/skills.ts +380 -0
- package/server/src/handlers/terminal.ts +70 -0
- package/server/src/identity.ts +26 -0
- package/server/src/index.ts +190 -0
- package/server/src/mesh/connector.ts +209 -0
- package/server/src/mesh/discovery.ts +123 -0
- package/server/src/mesh/pairing.ts +94 -0
- package/server/src/mesh/peers.ts +52 -0
- package/server/src/mesh/proxy.ts +103 -0
- package/server/src/mesh/session-sync.ts +107 -0
- package/server/src/project/context-breakdown.ts +289 -0
- package/server/src/project/file-browser.ts +106 -0
- package/server/src/project/project-files.ts +267 -0
- package/server/src/project/registry.ts +57 -0
- package/server/src/project/sdk-bridge.ts +566 -0
- package/server/src/project/session.ts +432 -0
- package/server/src/project/terminal.ts +69 -0
- package/server/src/tls.ts +51 -0
- package/server/src/ws/broadcast.ts +31 -0
- package/server/src/ws/router.ts +104 -0
- package/server/src/ws/server.ts +2 -0
- package/server/tsconfig.json +16 -0
- package/shared/package.json +11 -0
- package/shared/src/constants.ts +7 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/messages.ts +638 -0
- package/shared/src/models.ts +136 -0
- package/shared/src/project-settings.ts +45 -0
- package/shared/tsconfig.json +11 -0
- package/themes/amoled.json +20 -0
- package/themes/ayu-light.json +9 -0
- package/themes/catppuccin-latte.json +9 -0
- package/themes/catppuccin-mocha.json +9 -0
- package/themes/clay-light.json +10 -0
- package/themes/clay.json +10 -0
- package/themes/dracula.json +9 -0
- package/themes/everforest-light.json +9 -0
- package/themes/everforest.json +9 -0
- package/themes/github-light.json +9 -0
- package/themes/gruvbox-dark.json +9 -0
- package/themes/gruvbox-light.json +9 -0
- package/themes/monokai.json +9 -0
- package/themes/nord-light.json +9 -0
- package/themes/nord.json +9 -0
- package/themes/one-dark.json +9 -0
- package/themes/one-light.json +9 -0
- package/themes/rose-pine-dawn.json +9 -0
- package/themes/rose-pine.json +9 -0
- package/themes/solarized-dark.json +9 -0
- package/themes/solarized-light.json +9 -0
- package/themes/tokyo-night-light.json +9 -0
- package/themes/tokyo-night.json +9 -0
- 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
|
+
}
|