claude-world-studio 1.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 (46) hide show
  1. package/.env.example +30 -0
  2. package/.mcp.json +51 -0
  3. package/README.md +224 -0
  4. package/client/App.tsx +446 -0
  5. package/client/components/ChatWindow.tsx +790 -0
  6. package/client/components/FileExplorer.tsx +218 -0
  7. package/client/components/FilePreviewModal.tsx +179 -0
  8. package/client/components/PublishDialog.tsx +307 -0
  9. package/client/components/SettingsPage.tsx +452 -0
  10. package/client/components/Sidebar.tsx +198 -0
  11. package/client/components/ToolUseBlock.tsx +140 -0
  12. package/client/index.html +12 -0
  13. package/client/index.tsx +10 -0
  14. package/client/styles/globals.css +48 -0
  15. package/demo/01-welcome.png +0 -0
  16. package/demo/02-pipeline-cards.png +0 -0
  17. package/demo/03-custom-topic-fill.png +0 -0
  18. package/demo/04-topic-typed.png +0 -0
  19. package/demo/05-loading-state.png +0 -0
  20. package/demo/06-tool-calls.png +0 -0
  21. package/demo/07-history-rich.png +0 -0
  22. package/demo/09-en-cards.png +0 -0
  23. package/demo/10-ja-cards.png +0 -0
  24. package/demo/capture-remaining.mjs +73 -0
  25. package/demo/capture.mjs +110 -0
  26. package/demo/demo-walkthrough-2.webm +0 -0
  27. package/demo/demo-walkthrough.webm +0 -0
  28. package/package.json +48 -0
  29. package/postcss.config.js +6 -0
  30. package/scripts/threads_api.py +536 -0
  31. package/server/ai-client.ts +356 -0
  32. package/server/db.ts +299 -0
  33. package/server/mcp-config.ts +85 -0
  34. package/server/routes/accounts.ts +88 -0
  35. package/server/routes/files.ts +175 -0
  36. package/server/routes/publish.ts +77 -0
  37. package/server/routes/sessions.ts +59 -0
  38. package/server/routes/settings.ts +220 -0
  39. package/server/server.ts +261 -0
  40. package/server/services/social-publisher.ts +74 -0
  41. package/server/services/studio-mcp.ts +107 -0
  42. package/server/session.ts +167 -0
  43. package/server/types.ts +86 -0
  44. package/tailwind.config.js +8 -0
  45. package/tsconfig.json +16 -0
  46. package/vite.config.ts +19 -0
@@ -0,0 +1,452 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import type { Language } from "../App";
3
+
4
+ interface SettingsPageProps {
5
+ isVisible: boolean;
6
+ onClose: () => void;
7
+ language: Language;
8
+ onLanguageChange: (lang: Language) => void;
9
+ }
10
+
11
+ interface SettingsData {
12
+ trendPulseVenvPython: string;
13
+ cfBrowserVenvPython: string;
14
+ notebooklmServerPath: string;
15
+ cfBrowserUrl: string;
16
+ cfBrowserApiKey: string;
17
+ defaultWorkspace: string;
18
+ }
19
+
20
+ interface Account {
21
+ id: string;
22
+ name: string;
23
+ handle: string;
24
+ platform: string;
25
+ token: string;
26
+ user_id: string;
27
+ style: string;
28
+ persona_prompt: string;
29
+ }
30
+
31
+ type DetectedMap = Record<string, { value: string; found: boolean }>;
32
+
33
+ interface FieldDef {
34
+ key: string;
35
+ label: string;
36
+ placeholder: string;
37
+ sensitive?: boolean;
38
+ }
39
+
40
+ interface SettingGroup {
41
+ title: string;
42
+ fields: FieldDef[];
43
+ guide?: { title: string; steps: string[]; links?: { label: string; url: string }[] };
44
+ }
45
+
46
+ const SETTING_GROUPS: SettingGroup[] = [
47
+ {
48
+ title: "MCP Servers",
49
+ fields: [
50
+ { key: "trendPulseVenvPython", label: "trend-pulse Python path", placeholder: "/path/to/trend-pulse/.venv/bin/python" },
51
+ { key: "cfBrowserVenvPython", label: "cf-browser Python path", placeholder: "/path/to/cf-browser/.venv/bin/python" },
52
+ { key: "notebooklmServerPath", label: "NotebookLM server path", placeholder: "/path/to/mcp-server/server.py" },
53
+ { key: "cfBrowserUrl", label: "CF Browser URL", placeholder: "https://cf-browser.your-subdomain.workers.dev" },
54
+ { key: "cfBrowserApiKey", label: "CF Browser API Key", placeholder: "api-key", sensitive: true },
55
+ ],
56
+ guide: {
57
+ title: "How to set up MCP Servers",
58
+ steps: [
59
+ "--- trend-pulse ---",
60
+ "git clone https://github.com/claude-world/trend-pulse.git",
61
+ "cd trend-pulse && python3 -m venv .venv && .venv/bin/pip install -e '.[mcp]'",
62
+ "",
63
+ "--- cf-browser ---",
64
+ "git clone https://github.com/claude-world/cf-browser.git",
65
+ "cd cf-browser && bash setup.sh",
66
+ "",
67
+ "--- NotebookLM ---",
68
+ "git clone https://github.com/claude-world/notebooklm-skill.git",
69
+ "cd notebooklm-skill && pip install -r requirements.txt",
70
+ ],
71
+ links: [
72
+ { label: "trend-pulse", url: "https://github.com/claude-world/trend-pulse" },
73
+ { label: "cf-browser", url: "https://github.com/claude-world/cf-browser" },
74
+ { label: "notebooklm-skill", url: "https://github.com/claude-world/notebooklm-skill" },
75
+ ],
76
+ },
77
+ },
78
+ {
79
+ title: "General",
80
+ fields: [
81
+ { key: "defaultWorkspace", label: "Default workspace path", placeholder: "/path/to/workspace" },
82
+ ],
83
+ },
84
+ ];
85
+
86
+ const LANGUAGE_OPTIONS: { code: Language; label: string }[] = [
87
+ { code: "zh-TW", label: "繁體中文 (Taiwan)" },
88
+ { code: "en", label: "English" },
89
+ { code: "ja", label: "日本語 (Japanese)" },
90
+ ];
91
+
92
+ function StatusDot({ found }: { found: boolean }) {
93
+ return (
94
+ <span className={`inline-block w-2 h-2 rounded-full ${found ? "bg-green-500" : "bg-gray-300"}`} title={found ? "Detected" : "Not found"} />
95
+ );
96
+ }
97
+
98
+ function SetupGuide({ guide }: { guide: NonNullable<SettingGroup["guide"]> }) {
99
+ const [open, setOpen] = useState(false);
100
+ return (
101
+ <div className="mb-4">
102
+ <button onClick={() => setOpen(!open)} className="flex items-center gap-2 text-xs text-blue-600 hover:text-blue-800 font-medium">
103
+ <svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-90" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
104
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
105
+ </svg>
106
+ {guide.title}
107
+ </button>
108
+ {open && (
109
+ <div className="mt-2 ml-5 p-3 bg-blue-50 border border-blue-100 rounded-lg text-xs text-gray-700 space-y-1">
110
+ {guide.steps.map((step, i) => {
111
+ if (step === "") return <div key={i} className="h-2" />;
112
+ if (step.startsWith("---")) return <div key={i} className="font-semibold text-gray-800 pt-1">{step.replace(/^-+\s*/, "").replace(/\s*-+$/, "")}</div>;
113
+ return <div key={i}><code className="whitespace-pre-wrap break-all">{step}</code></div>;
114
+ })}
115
+ {guide.links && (
116
+ <div className="pt-2 border-t border-blue-200 mt-2 flex flex-wrap gap-3">
117
+ {guide.links.map((link) => (
118
+ <a key={link.url} href={link.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline">
119
+ {link.label}
120
+ </a>
121
+ ))}
122
+ </div>
123
+ )}
124
+ </div>
125
+ )}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ // ─── Accounts Manager ───
131
+
132
+ const EMPTY_ACCOUNT: Omit<Account, "id"> = {
133
+ name: "", handle: "", platform: "threads", token: "", user_id: "", style: "", persona_prompt: "",
134
+ };
135
+
136
+ function AccountsManager() {
137
+ const [accounts, setAccounts] = useState<Account[]>([]);
138
+ const [editing, setEditing] = useState<Account | null>(null);
139
+ const [isNew, setIsNew] = useState(false);
140
+ const [saving, setSaving] = useState(false);
141
+ const [notice, setNotice] = useState("");
142
+
143
+ const fetchAccounts = () => {
144
+ fetch("/api/accounts").then((r) => r.ok ? r.json() : []).then(setAccounts).catch(() => {});
145
+ };
146
+
147
+ useEffect(() => { fetchAccounts(); }, []);
148
+
149
+ const handleSave = async () => {
150
+ if (!editing || !editing.name || !editing.handle) return;
151
+ setSaving(true);
152
+
153
+ const url = isNew ? "/api/accounts" : `/api/accounts/${editing.id}`;
154
+ const method = isNew ? "POST" : "PUT";
155
+
156
+ try {
157
+ const res = await fetch(url, {
158
+ method,
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify(editing),
161
+ });
162
+ if (res.ok) {
163
+ setEditing(null);
164
+ setIsNew(false);
165
+ fetchAccounts();
166
+ setNotice("Saved. Start a new session to use updated accounts.");
167
+ }
168
+ } catch {}
169
+ setSaving(false);
170
+ };
171
+
172
+ const handleDelete = async (id: string) => {
173
+ try {
174
+ const res = await fetch(`/api/accounts/${id}`, { method: "DELETE" });
175
+ if (res.ok) { fetchAccounts(); setNotice("Deleted. Start a new session to apply."); }
176
+ } catch {}
177
+ };
178
+
179
+ const startNew = () => {
180
+ setEditing({ id: "", ...EMPTY_ACCOUNT });
181
+ setIsNew(true);
182
+ };
183
+
184
+ const startEdit = (account: Account) => {
185
+ setEditing({ ...account, token: "" }); // Don't prefill masked token
186
+ setIsNew(false);
187
+ };
188
+
189
+ return (
190
+ <div>
191
+ <div className="flex items-center justify-between mb-3">
192
+ <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">Social Accounts</h3>
193
+ <button onClick={startNew} className="text-xs px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium">
194
+ + Add Account
195
+ </button>
196
+ </div>
197
+
198
+ {/* Notice */}
199
+ {notice && (
200
+ <div className="mb-3 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700 flex items-center gap-2">
201
+ <span>&#9888;</span>
202
+ <span>{notice}</span>
203
+ </div>
204
+ )}
205
+
206
+ {/* Account list */}
207
+ {accounts.length === 0 && !editing && (
208
+ <p className="text-xs text-gray-400 mb-4">No accounts configured. Click "Add Account" to get started.</p>
209
+ )}
210
+
211
+ <div className="space-y-2 mb-4">
212
+ {accounts.map((a) => (
213
+ <div key={a.id} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg">
214
+ <div className="min-w-0">
215
+ <div className="flex items-center gap-2">
216
+ <span className="text-sm font-medium text-gray-800">{a.handle}</span>
217
+ <span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${a.platform === "threads" ? "bg-gray-100 text-gray-600" : "bg-pink-50 text-pink-600"}`}>
218
+ {a.platform}
219
+ </span>
220
+ {a.style && <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">{a.style}</span>}
221
+ </div>
222
+ <div className="text-xs text-gray-400 mt-0.5">{a.name}</div>
223
+ </div>
224
+ <div className="flex items-center gap-1.5 shrink-0">
225
+ <button onClick={() => startEdit(a)} className="text-xs px-2 py-1 border border-gray-200 rounded hover:bg-gray-50 text-gray-600">Edit</button>
226
+ <button onClick={() => handleDelete(a.id)} className="text-xs px-2 py-1 text-red-500 hover:bg-red-50 rounded">Delete</button>
227
+ </div>
228
+ </div>
229
+ ))}
230
+ </div>
231
+
232
+ {/* Edit form */}
233
+ {editing && (
234
+ <div className="p-4 border border-blue-200 rounded-lg bg-blue-50/30 space-y-3">
235
+ <div className="text-sm font-medium text-gray-700">{isNew ? "New Account" : `Edit: ${editing.handle}`}</div>
236
+ <div className="grid grid-cols-2 gap-3">
237
+ <div>
238
+ <label className="text-xs text-gray-500 block mb-1">Name *</label>
239
+ <input type="text" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} placeholder="Claude World Taiwan" className="w-full px-3 py-1.5 border border-gray-300 rounded text-sm" />
240
+ </div>
241
+ <div>
242
+ <label className="text-xs text-gray-500 block mb-1">Handle *</label>
243
+ <input type="text" value={editing.handle} onChange={(e) => setEditing({ ...editing, handle: e.target.value })} placeholder="@your.account" className="w-full px-3 py-1.5 border border-gray-300 rounded text-sm" />
244
+ </div>
245
+ <div>
246
+ <label className="text-xs text-gray-500 block mb-1">Platform *</label>
247
+ <select value={editing.platform} onChange={(e) => setEditing({ ...editing, platform: e.target.value })} className="w-full px-3 py-1.5 border border-gray-300 rounded text-sm">
248
+ <option value="threads">Threads</option>
249
+ <option value="instagram">Instagram</option>
250
+ </select>
251
+ </div>
252
+ <div>
253
+ <label className="text-xs text-gray-500 block mb-1">Style</label>
254
+ <input type="text" value={editing.style} onChange={(e) => setEditing({ ...editing, style: e.target.value })} placeholder="tech-educator, futurist..." className="w-full px-3 py-1.5 border border-gray-300 rounded text-sm" />
255
+ </div>
256
+ <div>
257
+ <label className="text-xs text-gray-500 block mb-1">Token {!isNew && "(leave empty to keep current)"}</label>
258
+ <input type="password" value={editing.token} onChange={(e) => setEditing({ ...editing, token: e.target.value })} placeholder="API token" className="w-full px-3 py-1.5 border border-gray-300 rounded text-sm font-mono" />
259
+ </div>
260
+ <div>
261
+ <label className="text-xs text-gray-500 block mb-1">User ID</label>
262
+ <input type="text" value={editing.user_id} onChange={(e) => setEditing({ ...editing, user_id: e.target.value })} placeholder="your-threads-user-id" className="w-full px-3 py-1.5 border border-gray-300 rounded text-sm font-mono" />
263
+ </div>
264
+ </div>
265
+ <div>
266
+ <label className="text-xs text-gray-500 block mb-1">Persona Prompt (AI uses this to adapt content style for this account)</label>
267
+ <textarea value={editing.persona_prompt} onChange={(e) => setEditing({ ...editing, persona_prompt: e.target.value })} rows={3} placeholder="You are a tech educator focused on Claude Code. Write in Traditional Chinese. Tone: professional yet approachable..." className="w-full px-3 py-2 border border-gray-300 rounded text-sm resize-none" />
268
+ </div>
269
+ <div className="flex gap-2">
270
+ <button onClick={handleSave} disabled={!editing.name || !editing.handle || saving} className="text-xs px-4 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-40">
271
+ {saving ? "Saving..." : isNew ? "Create" : "Update"}
272
+ </button>
273
+ <button onClick={() => { setEditing(null); setIsNew(false); }} className="text-xs px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600">Cancel</button>
274
+ </div>
275
+ </div>
276
+ )}
277
+ </div>
278
+ );
279
+ }
280
+
281
+ // ─── Main Settings Page ───
282
+
283
+ export function SettingsPage({ isVisible, onClose, language, onLanguageChange }: SettingsPageProps) {
284
+ const [settings, setSettings] = useState<Partial<SettingsData>>({});
285
+ const [detected, setDetected] = useState<DetectedMap>({});
286
+ const [detecting, setDetecting] = useState(false);
287
+ const [saving, setSaving] = useState(false);
288
+ const [message, setMessage] = useState("");
289
+
290
+ useEffect(() => {
291
+ if (!isVisible) return;
292
+ fetch("/api/settings").then((r) => r.ok ? r.json() : {}).then(setSettings).catch(() => {});
293
+ }, [isVisible]);
294
+
295
+ const handleDetect = async () => {
296
+ setDetecting(true);
297
+ setMessage("");
298
+ try {
299
+ const res = await fetch("/api/settings/detect");
300
+ if (!res.ok) throw new Error();
301
+ const data: DetectedMap = await res.json();
302
+ setDetected(data);
303
+ const found = Object.values(data).filter((d) => d.found).length;
304
+ setMessage(`Detected ${found}/${Object.keys(data).length} settings.`);
305
+ } catch { setMessage("Detection failed."); }
306
+ setDetecting(false);
307
+ };
308
+
309
+ const handleApplyDetected = async () => {
310
+ const updates: Partial<SettingsData> = {};
311
+ for (const [key, info] of Object.entries(detected)) {
312
+ if (info.found && info.value) {
313
+ (updates as any)[key] = info.value;
314
+ }
315
+ }
316
+ setSettings((prev) => ({ ...prev, ...updates }));
317
+
318
+ const hasSensitive = detected["cfBrowserApiKey"]?.found;
319
+ if (hasSensitive) {
320
+ try {
321
+ const res = await fetch("/api/settings/detect/apply", { method: "POST" });
322
+ if (!res.ok) { setMessage("Warning: Failed to apply server-side."); return; }
323
+ } catch { setMessage("Warning: Failed to apply server-side."); return; }
324
+ }
325
+ setMessage("Applied. Click Save to persist.");
326
+ };
327
+
328
+ const handleSave = async () => {
329
+ setSaving(true);
330
+ setMessage("");
331
+ try {
332
+ const res = await fetch("/api/settings", {
333
+ method: "PUT",
334
+ headers: { "Content-Type": "application/json" },
335
+ body: JSON.stringify(settings),
336
+ });
337
+ if (!res.ok) throw new Error();
338
+ setMessage("Settings saved! Start a new session to apply MCP changes.");
339
+ } catch { setMessage("Error saving settings."); }
340
+ setSaving(false);
341
+ };
342
+
343
+ if (!isVisible) return null;
344
+
345
+ const allKeys = SETTING_GROUPS.flatMap((g) => g.fields.map((f) => f.key));
346
+ const detectedCount = allKeys.filter((k) => detected[k]?.found).length;
347
+ const configuredCount = allKeys.filter((k) => (settings as any)[k]).length;
348
+
349
+ return (
350
+ <div className="flex-1 flex flex-col bg-white overflow-y-auto">
351
+ {/* Header */}
352
+ <div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between sticky top-0 bg-white z-10">
353
+ <div className="flex items-center gap-4">
354
+ <h2 className="text-lg font-semibold text-gray-800">Settings</h2>
355
+ <span className="text-xs text-gray-400">{configuredCount}/{allKeys.length} configured</span>
356
+ </div>
357
+ <button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Back to Chat</button>
358
+ </div>
359
+
360
+ <div className="p-6 max-w-2xl space-y-8">
361
+ {/* Auto-detect */}
362
+ <div className="p-4 bg-gradient-to-r from-emerald-50 to-blue-50 border border-emerald-200 rounded-lg">
363
+ <div className="flex items-center justify-between mb-2">
364
+ <h3 className="text-sm font-semibold text-gray-800">Auto-Detect MCP Tools</h3>
365
+ <div className="flex gap-2">
366
+ <button onClick={handleDetect} disabled={detecting} className="px-3 py-1.5 bg-emerald-600 text-white rounded-md hover:bg-emerald-700 disabled:opacity-50 text-xs font-medium">
367
+ {detecting ? "Scanning..." : "Scan System"}
368
+ </button>
369
+ {detectedCount > 0 && (
370
+ <button onClick={handleApplyDetected} className="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-xs font-medium">
371
+ Apply {detectedCount} found
372
+ </button>
373
+ )}
374
+ </div>
375
+ </div>
376
+ {Object.keys(detected).length > 0 && (
377
+ <div className="mt-3 grid grid-cols-2 gap-1.5">
378
+ {allKeys.map((key) => {
379
+ const info = detected[key];
380
+ if (!info) return null;
381
+ const label = SETTING_GROUPS.flatMap((g) => g.fields).find((f) => f.key === key)?.label || key;
382
+ return (
383
+ <div key={key} className="flex items-center gap-1.5 text-xs">
384
+ <StatusDot found={info.found} />
385
+ <span className={info.found ? "text-gray-700" : "text-gray-400"}>{label}</span>
386
+ </div>
387
+ );
388
+ })}
389
+ </div>
390
+ )}
391
+ </div>
392
+
393
+ {/* Language */}
394
+ <div>
395
+ <h3 className="text-sm font-semibold text-gray-700 mb-3 uppercase tracking-wider">Language / 語言</h3>
396
+ <div className="flex gap-2">
397
+ {LANGUAGE_OPTIONS.map((opt) => (
398
+ <button key={opt.code} onClick={() => onLanguageChange(opt.code)} className={`px-4 py-2 rounded-lg text-sm font-medium transition-all border ${language === opt.code ? "bg-blue-600 text-white border-blue-600 shadow" : "bg-white text-gray-600 border-gray-300 hover:border-gray-400"}`}>
399
+ {opt.label}
400
+ </button>
401
+ ))}
402
+ </div>
403
+ </div>
404
+
405
+ {/* Social Accounts */}
406
+ <AccountsManager />
407
+
408
+ {/* MCP + General settings */}
409
+ {SETTING_GROUPS.map((group) => (
410
+ <div key={group.title}>
411
+ <h3 className="text-sm font-semibold text-gray-700 mb-3 uppercase tracking-wider">{group.title}</h3>
412
+ {group.guide && <SetupGuide guide={group.guide} />}
413
+ <div className="space-y-3">
414
+ {group.fields.map((field) => {
415
+ const det = detected[field.key];
416
+ const hasValue = !!(settings as any)[field.key];
417
+ return (
418
+ <div key={field.key}>
419
+ <label className="text-sm text-gray-600 flex items-center gap-2 mb-1">
420
+ {hasValue ? <StatusDot found={true} /> : det ? <StatusDot found={det.found} /> : null}
421
+ {field.label}
422
+ {det?.found && !(settings as any)[field.key] && (
423
+ <button onClick={() => setSettings((prev) => ({ ...prev, [field.key]: det.value }))} className="text-xs text-emerald-600 hover:text-emerald-800 font-medium ml-1">
424
+ use detected
425
+ </button>
426
+ )}
427
+ </label>
428
+ <input
429
+ type={field.sensitive ? "password" : "text"}
430
+ value={(settings as any)[field.key] || ""}
431
+ onChange={(e) => setSettings((prev) => ({ ...prev, [field.key]: e.target.value }))}
432
+ placeholder={field.placeholder}
433
+ className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono ${hasValue ? "border-green-300 bg-green-50/30" : "border-gray-300"}`}
434
+ />
435
+ </div>
436
+ );
437
+ })}
438
+ </div>
439
+ </div>
440
+ ))}
441
+
442
+ {/* Save */}
443
+ <div className="flex items-center gap-3">
444
+ <button onClick={handleSave} disabled={saving} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm">
445
+ {saving ? "Saving..." : "Save Settings"}
446
+ </button>
447
+ {message && <span className="text-sm text-gray-600">{message}</span>}
448
+ </div>
449
+ </div>
450
+ </div>
451
+ );
452
+ }
@@ -0,0 +1,198 @@
1
+ import React from "react";
2
+ import type { Language } from "../App";
3
+
4
+ interface Session {
5
+ id: string;
6
+ title: string;
7
+ workspace_path: string;
8
+ created_at: string;
9
+ updated_at: string;
10
+ }
11
+
12
+ const LANGUAGES: { code: Language; flag: string; label: string }[] = [
13
+ { code: "zh-TW", flag: "TW", label: "繁中" },
14
+ { code: "en", flag: "EN", label: "English" },
15
+ { code: "ja", flag: "JA", label: "日本語" },
16
+ ];
17
+
18
+ interface SidebarProps {
19
+ sessions: Session[];
20
+ selectedSessionId: string | null;
21
+ onSelectSession: (id: string) => void;
22
+ onNewSession: () => void;
23
+ onDeleteSession: (id: string) => void;
24
+ onShowSettings: () => void;
25
+ defaultWorkspace: string;
26
+ isConnected: boolean;
27
+ language: Language;
28
+ onLanguageChange: (lang: Language) => void;
29
+ }
30
+
31
+ function formatTime(dateStr: string): string {
32
+ const d = new Date(dateStr);
33
+ const now = new Date();
34
+ const diffMs = now.getTime() - d.getTime();
35
+ const diffMin = Math.floor(diffMs / 60000);
36
+ if (diffMin < 1) return "just now";
37
+ if (diffMin < 60) return `${diffMin}m ago`;
38
+ const diffHr = Math.floor(diffMin / 60);
39
+ if (diffHr < 24) return `${diffHr}h ago`;
40
+ return d.toLocaleDateString();
41
+ }
42
+
43
+ export function Sidebar({
44
+ sessions,
45
+ selectedSessionId,
46
+ onSelectSession,
47
+ onNewSession,
48
+ onDeleteSession,
49
+ onShowSettings,
50
+ defaultWorkspace,
51
+ isConnected,
52
+ language,
53
+ onLanguageChange,
54
+ }: SidebarProps) {
55
+ return (
56
+ <div className="flex flex-col h-full bg-gray-900 text-white">
57
+ {/* Header */}
58
+ <div className="p-4 border-b border-gray-700">
59
+ <div className="flex items-center gap-2 mb-3">
60
+ <div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
61
+ CW
62
+ </div>
63
+ <div>
64
+ <h1 className="text-sm font-bold text-gray-200 leading-tight">Claude World Studio</h1>
65
+ <div className="flex items-center gap-1.5 mt-0.5">
66
+ <span className={`inline-block w-1.5 h-1.5 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
67
+ <span className="text-[10px] text-gray-500">
68
+ {isConnected ? 'Connected' : 'Reconnecting...'}
69
+ </span>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ <button
74
+ onClick={onNewSession}
75
+ className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors text-sm font-medium"
76
+ >
77
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
78
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
79
+ </svg>
80
+ New Session
81
+ </button>
82
+ </div>
83
+
84
+ {/* Session list */}
85
+ <div className="flex-1 overflow-y-auto">
86
+ {sessions.length === 0 ? (
87
+ <div className="p-4 text-center">
88
+ <div className="text-gray-500 space-y-3 mt-2">
89
+ <p className="text-sm font-medium text-gray-400">Get Started</p>
90
+ <div className="text-xs text-left space-y-2 px-2">
91
+ <div className="flex gap-2">
92
+ <span className="text-blue-400 font-mono shrink-0">1.</span>
93
+ <span className="text-gray-400">Click <strong className="text-gray-300">New Session</strong> above</span>
94
+ </div>
95
+ <div className="flex gap-2">
96
+ <span className="text-blue-400 font-mono shrink-0">2.</span>
97
+ <span className="text-gray-400">Ask Claude to discover trends</span>
98
+ </div>
99
+ <div className="flex gap-2">
100
+ <span className="text-blue-400 font-mono shrink-0">3.</span>
101
+ <span className="text-gray-400">Let it research and create content</span>
102
+ </div>
103
+ <div className="flex gap-2">
104
+ <span className="text-blue-400 font-mono shrink-0">4.</span>
105
+ <span className="text-gray-400">Publish directly to Threads/IG</span>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ ) : (
111
+ <div className="p-2 space-y-0.5">
112
+ <div className="px-3 py-1.5 text-[10px] font-semibold text-gray-500 uppercase tracking-wider">
113
+ Sessions
114
+ </div>
115
+ {sessions.map((session) => (
116
+ <div
117
+ key={session.id}
118
+ className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
119
+ selectedSessionId === session.id
120
+ ? "bg-gray-700"
121
+ : "hover:bg-gray-800"
122
+ }`}
123
+ onClick={() => onSelectSession(session.id)}
124
+ >
125
+ <div className="flex-1 min-w-0">
126
+ <div className="text-sm truncate">{session.title}</div>
127
+ <div className="text-[10px] text-gray-500 mt-0.5">
128
+ {formatTime(session.updated_at)}
129
+ </div>
130
+ </div>
131
+ <button
132
+ onClick={(e) => {
133
+ e.stopPropagation();
134
+ onDeleteSession(session.id);
135
+ }}
136
+ className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-600 rounded transition-all text-gray-400 hover:text-white"
137
+ title="Delete session"
138
+ >
139
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
140
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
141
+ </svg>
142
+ </button>
143
+ </div>
144
+ ))}
145
+ </div>
146
+ )}
147
+ </div>
148
+
149
+ {/* Footer */}
150
+ <div className="p-3 border-t border-gray-700 space-y-2.5">
151
+ {/* Language switcher */}
152
+ <div>
153
+ <div className="text-[10px] text-gray-500 uppercase tracking-wider px-1 mb-1.5">
154
+ Language
155
+ </div>
156
+ <div className="flex gap-1">
157
+ {LANGUAGES.map((lang) => (
158
+ <button
159
+ key={lang.code}
160
+ onClick={() => onLanguageChange(lang.code)}
161
+ className={`flex-1 text-[11px] py-1.5 rounded transition-all font-medium ${
162
+ language === lang.code
163
+ ? "bg-blue-600 text-white shadow-sm"
164
+ : "text-gray-400 hover:text-white hover:bg-gray-800"
165
+ }`}
166
+ title={lang.label}
167
+ >
168
+ {lang.flag}
169
+ </button>
170
+ ))}
171
+ </div>
172
+ </div>
173
+
174
+ {defaultWorkspace && (
175
+ <div
176
+ className="flex items-center gap-1.5 text-[10px] text-gray-500 truncate px-1"
177
+ title={defaultWorkspace}
178
+ >
179
+ <svg className="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
180
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
181
+ </svg>
182
+ {defaultWorkspace.split("/").slice(-2).join("/")}
183
+ </div>
184
+ )}
185
+ <button
186
+ onClick={onShowSettings}
187
+ className="w-full flex items-center justify-center gap-1.5 text-xs text-gray-400 hover:text-white py-1.5 hover:bg-gray-800 rounded transition-colors"
188
+ >
189
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
190
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
191
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
192
+ </svg>
193
+ Settings
194
+ </button>
195
+ </div>
196
+ </div>
197
+ );
198
+ }