bloby-bot 0.37.1 → 0.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/supervisor/harnesses/codex.ts +34 -0
- package/worker/prompts/bloby-system-prompt.txt +2 -2
- package/workspace/client/index.html +3 -0
- package/workspace/client/public/.well-known/assetlinks.json +8 -0
- package/workspace/client/public/bloby-cyberpunk.png +0 -0
- package/workspace/client/public/brand/blackrock.svg +8 -0
- package/workspace/client/public/kid-breakfast.png +0 -0
- package/workspace/client/public/wallpapers/bg.jpg +0 -0
- package/workspace/client/public/wallpapers/crypto_bg.png +0 -0
- package/workspace/client/public/wallpapers/wp-dusk.jpg +0 -0
- package/workspace/client/public/wallpapers/wp-mountain.jpg +0 -0
- package/workspace/client/public/wallpapers/wp-ocean.jpg +0 -0
- package/workspace/client/src/App.tsx +20 -9
- package/workspace/client/src/components/Dashboard/AiChatPage.tsx +145 -0
- package/workspace/client/src/components/Dashboard/CryptoPage.tsx +470 -0
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +117 -113
- package/workspace/client/src/components/Dashboard/WishlistPage.tsx +464 -0
- package/workspace/client/src/components/Layout/DashboardLayout.tsx +32 -34
- package/workspace/client/src/components/Layout/MiniSidebar.tsx +64 -0
- package/workspace/client/src/components/Layout/MobileNav.tsx +103 -6
- package/workspace/client/src/components/Layout/Sidebar.tsx +11 -10
- package/workspace/client/src/components/Lock/PinInput.tsx +107 -0
- package/workspace/client/src/components/Lock/WorkspaceLock.tsx +484 -0
- package/workspace/client/src/components/StickyNotes/StickyNotesOverlay.tsx +396 -0
- package/workspace/client/src/components/StickyNotes/StickyNotesSettingsPage.tsx +427 -0
- package/workspace/client/src/components/Wallpaper/WallpaperBackground.tsx +12 -0
- package/workspace/client/src/components/Wallpaper/WallpaperContext.tsx +160 -0
- package/workspace/client/src/components/Wallpaper/WallpaperPicker.tsx +67 -0
- package/workspace/client/src/styles/globals.css +89 -4
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { useEffect, useState, type FormEvent } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Heart, ShoppingCart, Tag, ExternalLink, Edit, Trash2, Plus, Zap, Eye, CheckCircle, X,
|
|
4
|
+
} from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
interface WishlistItem {
|
|
7
|
+
id: number;
|
|
8
|
+
name: string;
|
|
9
|
+
url: string | null;
|
|
10
|
+
features: string | null;
|
|
11
|
+
price: number | null;
|
|
12
|
+
target_price: number | null;
|
|
13
|
+
alert_threshold_percent: number | null;
|
|
14
|
+
priority: string;
|
|
15
|
+
status: string;
|
|
16
|
+
notes: string | null;
|
|
17
|
+
image_url: string | null;
|
|
18
|
+
last_checked: string | null;
|
|
19
|
+
last_deal_found: string | null;
|
|
20
|
+
created_at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Stats {
|
|
24
|
+
total: number;
|
|
25
|
+
watching: number;
|
|
26
|
+
urgent: number;
|
|
27
|
+
purchased: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PRIORITIES = ['low', 'normal', 'urgent'];
|
|
31
|
+
const STATUSES = ['watching', 'purchased', 'dismissed'];
|
|
32
|
+
|
|
33
|
+
function formatUSD(n: number | null | undefined): string {
|
|
34
|
+
if (n == null || isNaN(n)) return '—';
|
|
35
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function prettyLabel(s: string): string {
|
|
39
|
+
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function priorityBadge(p: string): string {
|
|
43
|
+
const map: Record<string, string> = {
|
|
44
|
+
urgent: 'bg-rose-300/15 border-rose-200/20 text-rose-200',
|
|
45
|
+
normal: 'bg-white/[0.06] border-white/10 text-white/70',
|
|
46
|
+
low: 'bg-white/[0.04] border-white/10 text-white/50',
|
|
47
|
+
};
|
|
48
|
+
return map[p] || 'bg-white/[0.06] border-white/10 text-white/70';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function statusBadge(s: string): string {
|
|
52
|
+
const map: Record<string, string> = {
|
|
53
|
+
watching: 'bg-cyan-300/15 border-cyan-200/20 text-cyan-200',
|
|
54
|
+
purchased: 'bg-emerald-300/15 border-emerald-200/20 text-emerald-200',
|
|
55
|
+
dismissed: 'bg-white/[0.06] border-white/10 text-white/60',
|
|
56
|
+
};
|
|
57
|
+
return map[s] || 'bg-white/[0.06] border-white/10 text-white/60';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function WishlistPage() {
|
|
61
|
+
const [items, setItems] = useState<WishlistItem[]>([]);
|
|
62
|
+
const [stats, setStats] = useState<Stats | null>(null);
|
|
63
|
+
const [loading, setLoading] = useState(true);
|
|
64
|
+
const [modal, setModal] = useState<{ open: boolean; item: WishlistItem | null }>({ open: false, item: null });
|
|
65
|
+
|
|
66
|
+
async function loadAll() {
|
|
67
|
+
setLoading(true);
|
|
68
|
+
try {
|
|
69
|
+
const [list, s] = await Promise.all([
|
|
70
|
+
fetch('/app/api/wishlist').then((r) => r.json()),
|
|
71
|
+
fetch('/app/api/wishlist/stats').then((r) => r.json()),
|
|
72
|
+
]);
|
|
73
|
+
setItems(list);
|
|
74
|
+
setStats(s);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error('Failed to load wishlist', err);
|
|
77
|
+
} finally {
|
|
78
|
+
setLoading(false);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
useEffect(() => { loadAll(); }, []);
|
|
83
|
+
|
|
84
|
+
async function deleteItem(item: WishlistItem) {
|
|
85
|
+
if (!confirm(`Delete "${item.name}"? This cannot be undone.`)) return;
|
|
86
|
+
const res = await fetch(`/app/api/wishlist/${item.id}`, { method: 'DELETE' });
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
const err = await res.json().catch(() => ({ error: 'Delete failed' }));
|
|
89
|
+
alert(err.error || 'Delete failed');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
loadAll();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="flex flex-col h-full px-4 sm:px-6 md:px-20 pt-10 sm:pt-16 pb-32 max-w-5xl mx-auto w-full">
|
|
97
|
+
{/* Header */}
|
|
98
|
+
<div className="flex items-start justify-between mb-8 gap-4">
|
|
99
|
+
<div className="flex items-start gap-4">
|
|
100
|
+
<div className="h-12 w-12 rounded-2xl flex items-center justify-center bg-gradient-to-br from-pink-300/40 to-rose-500/20 border border-white/10 backdrop-blur-xl shrink-0">
|
|
101
|
+
<Heart className="h-5 w-5 text-pink-200" fill="currentColor" />
|
|
102
|
+
</div>
|
|
103
|
+
<div>
|
|
104
|
+
<h1 className="text-2xl sm:text-3xl font-semibold text-white tracking-tight">Wishlist</h1>
|
|
105
|
+
<p className="text-sm text-white/60 mt-1">Track items you're watching for deals.</p>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => setModal({ open: true, item: null })}
|
|
110
|
+
className="flex items-center gap-1.5 px-4 py-2 rounded-full bg-gradient-to-br from-pink-500 to-rose-500 text-white text-xs font-semibold shadow-lg shadow-rose-500/20 hover:brightness-110 transition shrink-0"
|
|
111
|
+
>
|
|
112
|
+
<Plus className="h-3.5 w-3.5" />
|
|
113
|
+
Add Item
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Stats row */}
|
|
118
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
|
|
119
|
+
<StatCard
|
|
120
|
+
icon={Heart}
|
|
121
|
+
label="Total"
|
|
122
|
+
value={stats?.total ?? 0}
|
|
123
|
+
gradientFrom="#C9A7E8"
|
|
124
|
+
gradientTo="#F2B5C7"
|
|
125
|
+
/>
|
|
126
|
+
<StatCard
|
|
127
|
+
icon={Eye}
|
|
128
|
+
label="Watching"
|
|
129
|
+
value={stats?.watching ?? 0}
|
|
130
|
+
gradientFrom="#5DE0E6"
|
|
131
|
+
gradientTo="#85C7FF"
|
|
132
|
+
/>
|
|
133
|
+
<StatCard
|
|
134
|
+
icon={Zap}
|
|
135
|
+
label="Urgent"
|
|
136
|
+
value={stats?.urgent ?? 0}
|
|
137
|
+
gradientFrom="#FFB37A"
|
|
138
|
+
gradientTo="#FF7AAA"
|
|
139
|
+
/>
|
|
140
|
+
<StatCard
|
|
141
|
+
icon={CheckCircle}
|
|
142
|
+
label="Purchased"
|
|
143
|
+
value={stats?.purchased ?? 0}
|
|
144
|
+
gradientFrom="#9BE7B5"
|
|
145
|
+
gradientTo="#5DE0B8"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{loading ? (
|
|
150
|
+
<p className="text-white/60 text-sm">Loading…</p>
|
|
151
|
+
) : items.length === 0 ? (
|
|
152
|
+
<EmptyState onAdd={() => setModal({ open: true, item: null })} />
|
|
153
|
+
) : (
|
|
154
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
155
|
+
{items.map((it) => (
|
|
156
|
+
<ItemCard
|
|
157
|
+
key={it.id}
|
|
158
|
+
item={it}
|
|
159
|
+
onEdit={() => setModal({ open: true, item: it })}
|
|
160
|
+
onDelete={() => deleteItem(it)}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{modal.open && (
|
|
167
|
+
<ItemModal
|
|
168
|
+
item={modal.item}
|
|
169
|
+
onClose={() => setModal({ open: false, item: null })}
|
|
170
|
+
onSaved={() => { setModal({ open: false, item: null }); loadAll(); }}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function StatCard({
|
|
178
|
+
icon: Icon,
|
|
179
|
+
label,
|
|
180
|
+
value,
|
|
181
|
+
gradientFrom,
|
|
182
|
+
gradientTo,
|
|
183
|
+
}: {
|
|
184
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
185
|
+
label: string;
|
|
186
|
+
value: string | number;
|
|
187
|
+
gradientFrom: string;
|
|
188
|
+
gradientTo: string;
|
|
189
|
+
}) {
|
|
190
|
+
const gradient = `linear-gradient(135deg, ${gradientFrom}, ${gradientTo})`;
|
|
191
|
+
return (
|
|
192
|
+
<div className="glass-card glass-card-md p-4">
|
|
193
|
+
<div className="relative">
|
|
194
|
+
<div className="flex items-center gap-1.5 mb-2">
|
|
195
|
+
<Icon className="h-3 w-3 text-white/70" />
|
|
196
|
+
<span
|
|
197
|
+
className="text-[10px] font-semibold uppercase tracking-wider"
|
|
198
|
+
style={{
|
|
199
|
+
backgroundImage: gradient,
|
|
200
|
+
WebkitBackgroundClip: 'text',
|
|
201
|
+
WebkitTextFillColor: 'transparent',
|
|
202
|
+
backgroundClip: 'text',
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
{label}
|
|
206
|
+
</span>
|
|
207
|
+
</div>
|
|
208
|
+
<p className="text-2xl sm:text-3xl font-semibold tracking-tight text-white tabular-nums">{value}</p>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
|
215
|
+
return (
|
|
216
|
+
<div className="glass-card p-12 flex flex-col items-center justify-center text-center">
|
|
217
|
+
<div className="relative">
|
|
218
|
+
<div className="h-14 w-14 rounded-2xl flex items-center justify-center bg-gradient-to-br from-pink-300/40 to-rose-500/20 border border-white/10 backdrop-blur-xl mb-4 mx-auto">
|
|
219
|
+
<Heart className="h-6 w-6 text-pink-200" />
|
|
220
|
+
</div>
|
|
221
|
+
<p className="text-base font-semibold text-white mb-1">Your wishlist is empty</p>
|
|
222
|
+
<p className="text-sm text-white/60 mb-5">Add something you're watching for a deal.</p>
|
|
223
|
+
<button
|
|
224
|
+
onClick={onAdd}
|
|
225
|
+
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-full bg-gradient-to-br from-pink-500 to-rose-500 text-white text-xs font-semibold shadow-lg shadow-rose-500/20 hover:brightness-110 transition"
|
|
226
|
+
>
|
|
227
|
+
<Plus className="h-3.5 w-3.5" />
|
|
228
|
+
Add your first item
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function ItemCard({ item, onEdit, onDelete }: { item: WishlistItem; onEdit: () => void; onDelete: () => void }) {
|
|
236
|
+
return (
|
|
237
|
+
<div className="glass-card glass-card-md p-5 flex flex-col">
|
|
238
|
+
<div className="relative flex flex-col h-full">
|
|
239
|
+
<div className="flex items-start justify-between gap-2 mb-3">
|
|
240
|
+
<h3 className="text-base font-semibold leading-tight text-white tracking-tight flex-1">{item.name}</h3>
|
|
241
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
242
|
+
<IconBtn icon={Edit} onClick={onEdit} title="Edit" />
|
|
243
|
+
<IconBtn icon={Trash2} onClick={onDelete} title="Delete" danger />
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div className="flex flex-wrap items-center gap-1.5 mb-3">
|
|
248
|
+
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full border uppercase tracking-wider ${priorityBadge(item.priority)}`}>
|
|
249
|
+
{item.priority === 'urgent' && <Zap className="h-2.5 w-2.5 inline mr-0.5 -mt-0.5" />}
|
|
250
|
+
{prettyLabel(item.priority)}
|
|
251
|
+
</span>
|
|
252
|
+
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full border uppercase tracking-wider ${statusBadge(item.status)}`}>
|
|
253
|
+
{prettyLabel(item.status)}
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{item.url && (
|
|
258
|
+
<a
|
|
259
|
+
href={item.url}
|
|
260
|
+
target="_blank"
|
|
261
|
+
rel="noopener noreferrer"
|
|
262
|
+
className="text-[11px] text-cyan-200 hover:text-cyan-100 flex items-center gap-1 mb-2 truncate"
|
|
263
|
+
>
|
|
264
|
+
<ExternalLink className="h-3 w-3 shrink-0" />
|
|
265
|
+
<span className="truncate">{(() => { try { return new URL(item.url!).hostname.replace('www.', ''); } catch { return item.url; } })()}</span>
|
|
266
|
+
</a>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{item.features && (
|
|
270
|
+
<p className="text-xs text-white/60 mb-3 leading-relaxed">{item.features}</p>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{(item.price != null || item.target_price != null) && (
|
|
274
|
+
<div className="flex items-center gap-3 mb-2 text-xs">
|
|
275
|
+
{item.price != null && (
|
|
276
|
+
<div className="flex items-center gap-1">
|
|
277
|
+
<Tag className="h-3 w-3 text-white/50" />
|
|
278
|
+
<span className="text-white/50">Price:</span>
|
|
279
|
+
<span className="font-semibold text-white tabular-nums">{formatUSD(item.price)}</span>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
{item.target_price != null && (
|
|
283
|
+
<div className="flex items-center gap-1">
|
|
284
|
+
<span className="text-white/50">Target:</span>
|
|
285
|
+
<span className="font-semibold text-emerald-200 tabular-nums">{formatUSD(item.target_price)}</span>
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{item.alert_threshold_percent != null && (
|
|
292
|
+
<p className="text-[10px] text-white/40 mb-2">
|
|
293
|
+
Alert at {item.alert_threshold_percent}%+ off
|
|
294
|
+
</p>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{item.last_deal_found && (
|
|
298
|
+
<div className="mt-auto pt-3 border-t border-white/[0.06] flex items-start gap-2">
|
|
299
|
+
<div className="h-6 px-2 rounded-full bg-amber-300/15 border border-amber-200/20 text-amber-200 text-[10px] font-semibold uppercase tracking-wider flex items-center shrink-0">
|
|
300
|
+
Latest
|
|
301
|
+
</div>
|
|
302
|
+
<p className="text-xs text-white/85 leading-relaxed">{item.last_deal_found}</p>
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function IconBtn({ icon: Icon, onClick, title, danger }: { icon: React.ComponentType<{ className?: string }>; onClick: () => void; title: string; danger?: boolean }) {
|
|
311
|
+
return (
|
|
312
|
+
<button
|
|
313
|
+
onClick={onClick}
|
|
314
|
+
title={title}
|
|
315
|
+
className={`h-8 w-8 rounded-full flex items-center justify-center bg-white/5 border border-white/10 hover:bg-white/10 transition ${danger ? 'text-rose-300 hover:text-rose-200' : 'text-white/70 hover:text-white'}`}
|
|
316
|
+
>
|
|
317
|
+
<Icon className="h-3.5 w-3.5" />
|
|
318
|
+
</button>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const INPUT = 'w-full px-3 py-2 bg-white/5 border border-white/10 rounded-xl text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/30 focus:bg-white/10 transition backdrop-blur-md';
|
|
323
|
+
const LABEL = 'block text-[11px] font-semibold text-white/60 mb-1.5 uppercase tracking-wider';
|
|
324
|
+
|
|
325
|
+
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
|
326
|
+
return (
|
|
327
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={onClose}>
|
|
328
|
+
<div className="glass-card w-full max-w-lg max-h-[90dvh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
|
329
|
+
<div className="relative flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
|
|
330
|
+
<h2 className="text-base font-semibold text-white tracking-tight">{title}</h2>
|
|
331
|
+
<button onClick={onClose} className="h-8 w-8 rounded-full flex items-center justify-center bg-white/5 border border-white/10 hover:bg-white/10 transition text-white/70">
|
|
332
|
+
<X className="h-4 w-4" />
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
<div className="relative p-5">{children}</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
|
|
342
|
+
return (
|
|
343
|
+
<div>
|
|
344
|
+
<label className={LABEL}>{label}</label>
|
|
345
|
+
{children}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function ItemModal({ item, onClose, onSaved }: { item: WishlistItem | null; onClose: () => void; onSaved: () => void }) {
|
|
351
|
+
const [form, setForm] = useState({
|
|
352
|
+
name: item?.name ?? '',
|
|
353
|
+
url: item?.url ?? '',
|
|
354
|
+
features: item?.features ?? '',
|
|
355
|
+
price: item?.price ?? '',
|
|
356
|
+
target_price: item?.target_price ?? '',
|
|
357
|
+
alert_threshold_percent: item?.alert_threshold_percent ?? '',
|
|
358
|
+
priority: item?.priority ?? 'normal',
|
|
359
|
+
status: item?.status ?? 'watching',
|
|
360
|
+
notes: item?.notes ?? '',
|
|
361
|
+
last_deal_found: item?.last_deal_found ?? '',
|
|
362
|
+
});
|
|
363
|
+
const [error, setError] = useState('');
|
|
364
|
+
const [saving, setSaving] = useState(false);
|
|
365
|
+
|
|
366
|
+
async function submit(e: FormEvent) {
|
|
367
|
+
e.preventDefault();
|
|
368
|
+
if (!form.name.trim()) { setError('Name is required'); return; }
|
|
369
|
+
setSaving(true);
|
|
370
|
+
setError('');
|
|
371
|
+
const url = item ? `/app/api/wishlist/${item.id}` : '/app/api/wishlist';
|
|
372
|
+
const method = item ? 'PUT' : 'POST';
|
|
373
|
+
const payload = {
|
|
374
|
+
name: form.name.trim(),
|
|
375
|
+
url: form.url.toString().trim() || null,
|
|
376
|
+
features: form.features.toString().trim() || null,
|
|
377
|
+
price: form.price === '' ? null : Number(form.price),
|
|
378
|
+
target_price: form.target_price === '' ? null : Number(form.target_price),
|
|
379
|
+
alert_threshold_percent: form.alert_threshold_percent === '' ? null : Number(form.alert_threshold_percent),
|
|
380
|
+
priority: form.priority,
|
|
381
|
+
status: form.status,
|
|
382
|
+
notes: form.notes.toString().trim() || null,
|
|
383
|
+
last_deal_found: form.last_deal_found.toString().trim() || null,
|
|
384
|
+
};
|
|
385
|
+
const res = await fetch(url, {
|
|
386
|
+
method,
|
|
387
|
+
headers: { 'Content-Type': 'application/json' },
|
|
388
|
+
body: JSON.stringify(payload),
|
|
389
|
+
});
|
|
390
|
+
setSaving(false);
|
|
391
|
+
if (!res.ok) {
|
|
392
|
+
const err = await res.json().catch(() => ({ error: 'Save failed' }));
|
|
393
|
+
setError(err.error || 'Save failed');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
onSaved();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<Modal title={item ? 'Edit Item' : 'Add Item'} onClose={onClose}>
|
|
401
|
+
<form onSubmit={submit} className="space-y-3">
|
|
402
|
+
{error && (
|
|
403
|
+
<div className="px-3 py-2 rounded-xl bg-rose-300/10 border border-rose-200/20 text-rose-200 text-xs">
|
|
404
|
+
{error}
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
407
|
+
<FormField label="Name *">
|
|
408
|
+
<input className={INPUT} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
|
409
|
+
</FormField>
|
|
410
|
+
<FormField label="URL">
|
|
411
|
+
<input type="url" className={INPUT} placeholder="https://..." value={form.url as string} onChange={(e) => setForm({ ...form, url: e.target.value })} />
|
|
412
|
+
</FormField>
|
|
413
|
+
<FormField label="Features / Description">
|
|
414
|
+
<textarea className={INPUT} rows={3} value={form.features as string} onChange={(e) => setForm({ ...form, features: e.target.value })} />
|
|
415
|
+
</FormField>
|
|
416
|
+
<div className="grid grid-cols-2 gap-3">
|
|
417
|
+
<FormField label="Current Price (USD)">
|
|
418
|
+
<input type="number" min="0" step="0.01" className={INPUT} value={form.price as string} onChange={(e) => setForm({ ...form, price: e.target.value })} />
|
|
419
|
+
</FormField>
|
|
420
|
+
<FormField label="Target Price (USD)">
|
|
421
|
+
<input type="number" min="0" step="0.01" className={INPUT} value={form.target_price as string} onChange={(e) => setForm({ ...form, target_price: e.target.value })} />
|
|
422
|
+
</FormField>
|
|
423
|
+
</div>
|
|
424
|
+
<FormField label="Alert Threshold (% off)">
|
|
425
|
+
<input type="number" min="0" max="100" className={INPUT} placeholder="e.g. 30 for 30%+ off" value={form.alert_threshold_percent as string} onChange={(e) => setForm({ ...form, alert_threshold_percent: e.target.value })} />
|
|
426
|
+
</FormField>
|
|
427
|
+
<div className="grid grid-cols-2 gap-3">
|
|
428
|
+
<FormField label="Priority">
|
|
429
|
+
<select className={INPUT} value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })}>
|
|
430
|
+
{PRIORITIES.map((t) => <option key={t} value={t} className="bg-zinc-900">{prettyLabel(t)}</option>)}
|
|
431
|
+
</select>
|
|
432
|
+
</FormField>
|
|
433
|
+
<FormField label="Status">
|
|
434
|
+
<select className={INPUT} value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
|
|
435
|
+
{STATUSES.map((t) => <option key={t} value={t} className="bg-zinc-900">{prettyLabel(t)}</option>)}
|
|
436
|
+
</select>
|
|
437
|
+
</FormField>
|
|
438
|
+
</div>
|
|
439
|
+
<FormField label="Last Deal Found">
|
|
440
|
+
<input className={INPUT} placeholder='e.g. "$349 on official site (50% off)"' value={form.last_deal_found as string} onChange={(e) => setForm({ ...form, last_deal_found: e.target.value })} />
|
|
441
|
+
</FormField>
|
|
442
|
+
<FormField label="Notes">
|
|
443
|
+
<textarea className={INPUT} rows={2} value={form.notes as string} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
|
444
|
+
</FormField>
|
|
445
|
+
<div className="flex justify-end gap-2 pt-3">
|
|
446
|
+
<button
|
|
447
|
+
type="button"
|
|
448
|
+
onClick={onClose}
|
|
449
|
+
className="px-4 py-2 rounded-full text-xs font-semibold text-white/70 hover:text-white bg-white/5 border border-white/10 hover:bg-white/10 transition"
|
|
450
|
+
>
|
|
451
|
+
Cancel
|
|
452
|
+
</button>
|
|
453
|
+
<button
|
|
454
|
+
type="submit"
|
|
455
|
+
disabled={saving}
|
|
456
|
+
className="px-4 py-2 rounded-full text-xs font-semibold bg-gradient-to-br from-pink-500 to-rose-500 text-white shadow-lg shadow-rose-500/20 hover:brightness-110 disabled:opacity-50 transition"
|
|
457
|
+
>
|
|
458
|
+
{saving ? 'Saving…' : item ? 'Save' : 'Create'}
|
|
459
|
+
</button>
|
|
460
|
+
</div>
|
|
461
|
+
</form>
|
|
462
|
+
</Modal>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
-
import Sidebar from './Sidebar';
|
|
4
3
|
import MobileNav from './MobileNav';
|
|
4
|
+
import MiniSidebar from './MiniSidebar';
|
|
5
|
+
import StickyNotesOverlay from '../StickyNotes/StickyNotesOverlay';
|
|
6
|
+
import { WallpaperProvider } from '../Wallpaper/WallpaperContext';
|
|
7
|
+
import WallpaperBackground from '../Wallpaper/WallpaperBackground';
|
|
5
8
|
|
|
6
9
|
interface Props {
|
|
7
10
|
children: ReactNode;
|
|
@@ -14,16 +17,9 @@ export default function DashboardLayout({ children, userName, botName = 'Bloby'
|
|
|
14
17
|
|
|
15
18
|
useEffect(() => {
|
|
16
19
|
const check = () => {
|
|
17
|
-
console.log('[health] checking /app/api/health…');
|
|
18
20
|
fetch('/app/api/health', { signal: AbortSignal.timeout(3000) })
|
|
19
|
-
.then((r) =>
|
|
20
|
-
|
|
21
|
-
setStatus(r.ok ? 'healthy' : 'restarting');
|
|
22
|
-
})
|
|
23
|
-
.catch((err) => {
|
|
24
|
-
console.warn('[health] fetch failed:', err.message ?? err);
|
|
25
|
-
setStatus('restarting');
|
|
26
|
-
});
|
|
21
|
+
.then((r) => setStatus(r.ok ? 'healthy' : 'restarting'))
|
|
22
|
+
.catch(() => setStatus('restarting'));
|
|
27
23
|
};
|
|
28
24
|
check();
|
|
29
25
|
const id = setInterval(check, 10_000);
|
|
@@ -31,34 +27,36 @@ export default function DashboardLayout({ children, userName, botName = 'Bloby'
|
|
|
31
27
|
}, []);
|
|
32
28
|
|
|
33
29
|
return (
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
<header className="flex items-center justify-between px-4 py-3 md:hidden">
|
|
37
|
-
<MobileNav userName={userName} botName={botName} backendStatus={status} />
|
|
38
|
-
<div className="flex items-center gap-2">
|
|
39
|
-
<img src="/bloby.png" alt={botName} className="h-6 w-auto" />
|
|
40
|
-
<span className="font-semibold text-base">{botName}</span>
|
|
41
|
-
</div>
|
|
42
|
-
<div className="w-10" />
|
|
43
|
-
</header>
|
|
30
|
+
<WallpaperProvider>
|
|
31
|
+
<WallpaperBackground />
|
|
44
32
|
|
|
45
|
-
<div className="flex
|
|
46
|
-
{/*
|
|
47
|
-
<
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
<
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
33
|
+
<div className="flex h-dvh flex-col">
|
|
34
|
+
{/* Mobile header */}
|
|
35
|
+
<header className="flex items-center justify-between px-4 py-3 md:hidden">
|
|
36
|
+
<MobileNav userName={userName} botName={botName} backendStatus={status} />
|
|
37
|
+
<div className="flex items-center gap-2">
|
|
38
|
+
<img src="/bloby.png" alt={botName} className="h-6 w-auto" />
|
|
39
|
+
<span className="font-semibold text-base text-white drop-shadow">{botName}</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="w-10" />
|
|
42
|
+
</header>
|
|
43
|
+
|
|
44
|
+
<div className="flex flex-1 overflow-hidden">
|
|
45
|
+
{/* Floating mini-sidebar (desktop only) */}
|
|
46
|
+
<div id="tour-sidebar" className="contents">
|
|
47
|
+
<MiniSidebar />
|
|
54
48
|
</div>
|
|
55
|
-
</div>
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
{/* Main content */}
|
|
51
|
+
<div className="flex flex-1 flex-col overflow-hidden">
|
|
52
|
+
<main id="tour-dashboard" className="relative flex-1 overflow-y-auto">
|
|
53
|
+
{children}
|
|
54
|
+
<StickyNotesOverlay />
|
|
55
|
+
</main>
|
|
56
|
+
</div>
|
|
60
57
|
</div>
|
|
58
|
+
|
|
61
59
|
</div>
|
|
62
|
-
</
|
|
60
|
+
</WallpaperProvider>
|
|
63
61
|
);
|
|
64
62
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { NavLink } from 'react-router';
|
|
3
|
+
import { LayoutDashboard, StickyNote, Bitcoin, MessageCircle, Heart, Settings } from 'lucide-react';
|
|
4
|
+
import WallpaperPicker from '../Wallpaper/WallpaperPicker';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
export default function MiniSidebar() {
|
|
8
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
<aside className="hidden md:flex fixed left-4 top-1/2 -translate-y-1/2 z-40">
|
|
13
|
+
<div className="glass-pill flex flex-col items-center gap-1 p-2" style={{ borderRadius: 28 }}>
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
onClick={() => setPickerOpen(true)}
|
|
17
|
+
className="h-11 w-11 rounded-2xl flex items-center justify-center text-white/80 hover:text-white hover:bg-white/10 transition"
|
|
18
|
+
aria-label="Wallpaper settings"
|
|
19
|
+
title="Wallpaper"
|
|
20
|
+
>
|
|
21
|
+
<Settings className="h-[18px] w-[18px]" />
|
|
22
|
+
</button>
|
|
23
|
+
|
|
24
|
+
<NavItem to="/" icon={LayoutDashboard} label="Dashboard" />
|
|
25
|
+
<NavItem to="/sticky-notes" icon={StickyNote} label="Sticky Notes" />
|
|
26
|
+
<NavItem to="/wishlist" icon={Heart} label="Wishlist" />
|
|
27
|
+
<NavItem to="/crypto" icon={Bitcoin} label="Crypto" />
|
|
28
|
+
<NavItem to="/aichat" icon={MessageCircle} label="AI Chat" />
|
|
29
|
+
</div>
|
|
30
|
+
</aside>
|
|
31
|
+
|
|
32
|
+
<WallpaperPicker open={pickerOpen} onClose={() => setPickerOpen(false)} />
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function NavItem({
|
|
38
|
+
to,
|
|
39
|
+
icon: Icon,
|
|
40
|
+
label,
|
|
41
|
+
}: {
|
|
42
|
+
to: string;
|
|
43
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
44
|
+
label: string;
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<NavLink
|
|
48
|
+
to={to}
|
|
49
|
+
end
|
|
50
|
+
title={label}
|
|
51
|
+
aria-label={label}
|
|
52
|
+
className={({ isActive }) =>
|
|
53
|
+
cn(
|
|
54
|
+
'h-11 w-11 rounded-2xl flex items-center justify-center transition',
|
|
55
|
+
isActive
|
|
56
|
+
? 'glass-inner-active text-white'
|
|
57
|
+
: 'text-white/70 hover:text-white hover:bg-white/10',
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
>
|
|
61
|
+
<Icon className="h-[18px] w-[18px]" />
|
|
62
|
+
</NavLink>
|
|
63
|
+
);
|
|
64
|
+
}
|