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,427 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { Trash2, Eye, EyeOff, NotebookPen, X, Check } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
// Adjust this if your workspace proxies API calls differently.
|
|
5
|
+
// Default Bloby workspaces: '/app/api'. Direct backend: '/api'.
|
|
6
|
+
const API_BASE = '/app/api';
|
|
7
|
+
|
|
8
|
+
interface StickyNote {
|
|
9
|
+
id: number;
|
|
10
|
+
content: string;
|
|
11
|
+
color: string;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
visible: number;
|
|
17
|
+
created_at: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PASTEL_COLORS = ['#E6C97A', '#D4A0B0', '#8BBD9F', '#8BAFC4', '#B0A0C8', '#D4AD8A'];
|
|
21
|
+
|
|
22
|
+
export default function StickyNotesSettingsPage() {
|
|
23
|
+
const [notes, setNotes] = useState<StickyNote[]>([]);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const [defaultColor, setDefaultColor] = useState('#E6C97A');
|
|
26
|
+
const [allVisible, setAllVisible] = useState(true);
|
|
27
|
+
|
|
28
|
+
const fetchNotes = useCallback(async () => {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`${API_BASE}/sticky-notes`);
|
|
31
|
+
if (!res.ok) return;
|
|
32
|
+
const data: StickyNote[] = await res.json();
|
|
33
|
+
setNotes(data);
|
|
34
|
+
setAllVisible(data.length === 0 || data.some((n) => n.visible === 1));
|
|
35
|
+
} catch {
|
|
36
|
+
// silent
|
|
37
|
+
} finally {
|
|
38
|
+
setLoading(false);
|
|
39
|
+
}
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
fetchNotes();
|
|
44
|
+
}, [fetchNotes]);
|
|
45
|
+
|
|
46
|
+
const deleteNote = useCallback(async (id: number) => {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${API_BASE}/sticky-notes/${id}`, { method: 'DELETE' });
|
|
49
|
+
if (!res.ok) return;
|
|
50
|
+
setNotes((prev) => prev.filter((n) => n.id !== id));
|
|
51
|
+
} catch {
|
|
52
|
+
// silent
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const deleteAll = useCallback(async () => {
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(`${API_BASE}/sticky-notes`, { method: 'DELETE' });
|
|
59
|
+
if (!res.ok) return;
|
|
60
|
+
setNotes([]);
|
|
61
|
+
} catch {
|
|
62
|
+
// silent
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const toggleVisibility = useCallback(async () => {
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(`${API_BASE}/sticky-notes/toggle-visibility`, { method: 'POST' });
|
|
69
|
+
if (!res.ok) return;
|
|
70
|
+
const data = await res.json();
|
|
71
|
+
setNotes(data.notes);
|
|
72
|
+
setAllVisible(data.visible === 1);
|
|
73
|
+
} catch {
|
|
74
|
+
// silent
|
|
75
|
+
}
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const createNote = useCallback(async () => {
|
|
79
|
+
const x = 60 + Math.floor(Math.random() * 300);
|
|
80
|
+
const y = 60 + Math.floor(Math.random() * 200);
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch(`${API_BASE}/sticky-notes`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ content: '', color: defaultColor, x, y, width: 200, height: 180, visible: 1 }),
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) return;
|
|
88
|
+
const created = await res.json();
|
|
89
|
+
await fetchNotes();
|
|
90
|
+
// Auto-open the new note in the modal
|
|
91
|
+
const newNote: StickyNote = {
|
|
92
|
+
id: created.id,
|
|
93
|
+
content: '',
|
|
94
|
+
color: defaultColor,
|
|
95
|
+
x, y,
|
|
96
|
+
width: 200,
|
|
97
|
+
height: 180,
|
|
98
|
+
visible: 1,
|
|
99
|
+
created_at: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
setEditingNote(newNote);
|
|
102
|
+
setEditContent('');
|
|
103
|
+
setEditColor(defaultColor);
|
|
104
|
+
} catch {
|
|
105
|
+
// silent
|
|
106
|
+
}
|
|
107
|
+
}, [defaultColor, fetchNotes]);
|
|
108
|
+
|
|
109
|
+
const updateColor = useCallback(async (id: number, color: string) => {
|
|
110
|
+
try {
|
|
111
|
+
await fetch(`${API_BASE}/sticky-notes/${id}`, {
|
|
112
|
+
method: 'PUT',
|
|
113
|
+
headers: { 'Content-Type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({ color }),
|
|
115
|
+
});
|
|
116
|
+
setNotes((prev) => prev.map((n) => (n.id === id ? { ...n, color } : n)));
|
|
117
|
+
} catch {
|
|
118
|
+
// silent
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const updateContent = useCallback(async (id: number, content: string) => {
|
|
123
|
+
try {
|
|
124
|
+
await fetch(`${API_BASE}/sticky-notes/${id}`, {
|
|
125
|
+
method: 'PUT',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({ content }),
|
|
128
|
+
});
|
|
129
|
+
setNotes((prev) => prev.map((n) => (n.id === id ? { ...n, content } : n)));
|
|
130
|
+
} catch {
|
|
131
|
+
// silent
|
|
132
|
+
}
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
// Delete All confirmation
|
|
136
|
+
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
|
|
137
|
+
|
|
138
|
+
// Modal state for editing a note
|
|
139
|
+
const [editingNote, setEditingNote] = useState<StickyNote | null>(null);
|
|
140
|
+
const [editContent, setEditContent] = useState('');
|
|
141
|
+
const [editColor, setEditColor] = useState('');
|
|
142
|
+
const contentRef = useRef(editContent);
|
|
143
|
+
contentRef.current = editContent;
|
|
144
|
+
|
|
145
|
+
const openModal = useCallback((note: StickyNote) => {
|
|
146
|
+
setEditingNote(note);
|
|
147
|
+
setEditContent(note.content);
|
|
148
|
+
setEditColor(note.color);
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
const closeModal = useCallback(() => {
|
|
152
|
+
if (editingNote) {
|
|
153
|
+
const trimmed = contentRef.current;
|
|
154
|
+
if (trimmed !== editingNote.content) {
|
|
155
|
+
updateContent(editingNote.id, trimmed);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
setEditingNote(null);
|
|
159
|
+
}, [editingNote, updateContent]);
|
|
160
|
+
|
|
161
|
+
const changeModalColor = useCallback((color: string) => {
|
|
162
|
+
if (!editingNote) return;
|
|
163
|
+
setEditColor(color);
|
|
164
|
+
updateColor(editingNote.id, color);
|
|
165
|
+
setEditingNote((prev) => prev ? { ...prev, color } : null);
|
|
166
|
+
}, [editingNote, updateColor]);
|
|
167
|
+
|
|
168
|
+
const deleteAndClose = useCallback(() => {
|
|
169
|
+
if (!editingNote) return;
|
|
170
|
+
deleteNote(editingNote.id);
|
|
171
|
+
setEditingNote(null);
|
|
172
|
+
}, [editingNote, deleteNote]);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className="flex flex-col min-h-full px-4 sm:px-6 md:px-20 pt-10 sm:pt-16 pb-32 max-w-5xl mx-auto w-full">
|
|
176
|
+
{/* Header */}
|
|
177
|
+
<div className="flex items-start justify-between mb-8 gap-4">
|
|
178
|
+
<div className="flex items-start gap-4">
|
|
179
|
+
<div className="h-12 w-12 rounded-2xl flex items-center justify-center bg-gradient-to-br from-amber-300/40 to-yellow-500/20 border border-white/10 backdrop-blur-xl shrink-0">
|
|
180
|
+
<NotebookPen className="h-5 w-5 text-amber-200" />
|
|
181
|
+
</div>
|
|
182
|
+
<div>
|
|
183
|
+
<h1 className="text-2xl sm:text-3xl font-semibold text-white tracking-tight">Sticky Notes</h1>
|
|
184
|
+
<p className="text-sm text-white/60 mt-1 hidden md:block">
|
|
185
|
+
Manage your sticky notes. Drag them around on the dashboard.
|
|
186
|
+
</p>
|
|
187
|
+
<p className="text-sm text-white/60 mt-1 md:hidden">Tap a note to edit it.</p>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
191
|
+
<button
|
|
192
|
+
onClick={toggleVisibility}
|
|
193
|
+
className="hidden md:flex items-center gap-1.5 px-3 py-2 rounded-full text-xs font-semibold bg-white/5 border border-white/10 hover:bg-white/10 text-white/80 transition backdrop-blur-md"
|
|
194
|
+
>
|
|
195
|
+
{allVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
196
|
+
{allVisible ? 'Hide All' : 'Show All'}
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
onClick={createNote}
|
|
200
|
+
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold bg-gradient-to-br from-amber-400 to-yellow-500 text-zinc-900 shadow-lg shadow-amber-500/20 hover:brightness-110 transition"
|
|
201
|
+
>
|
|
202
|
+
<NotebookPen className="h-3.5 w-3.5" />
|
|
203
|
+
New Note
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{/* Default Color */}
|
|
209
|
+
<div className="glass-card glass-card-md p-5 mb-6">
|
|
210
|
+
<div className="relative">
|
|
211
|
+
<label className="block text-[10px] font-semibold text-white/60 uppercase tracking-wider mb-3">
|
|
212
|
+
Default Color for New Notes
|
|
213
|
+
</label>
|
|
214
|
+
<div className="flex gap-2.5 flex-wrap">
|
|
215
|
+
{PASTEL_COLORS.map((c) => (
|
|
216
|
+
<button
|
|
217
|
+
key={c}
|
|
218
|
+
onClick={() => setDefaultColor(c)}
|
|
219
|
+
className={`h-9 w-9 rounded-full transition-all border border-white/15 ${
|
|
220
|
+
defaultColor === c ? 'ring-2 ring-white/80 ring-offset-2 ring-offset-transparent scale-110' : 'hover:scale-105'
|
|
221
|
+
}`}
|
|
222
|
+
style={{ backgroundColor: c }}
|
|
223
|
+
/>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* Loading */}
|
|
230
|
+
{loading && (
|
|
231
|
+
<div className="flex-1 flex items-center justify-center">
|
|
232
|
+
<div className="h-6 w-6 border-2 border-white/10 border-t-white/50 rounded-full animate-spin" />
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* Empty State */}
|
|
237
|
+
{!loading && notes.length === 0 && (
|
|
238
|
+
<div className="glass-card p-12 flex flex-col items-center justify-center text-center">
|
|
239
|
+
<div className="relative">
|
|
240
|
+
<div className="h-14 w-14 rounded-2xl flex items-center justify-center bg-gradient-to-br from-amber-300/40 to-yellow-500/20 border border-white/10 backdrop-blur-xl mb-4 mx-auto">
|
|
241
|
+
<NotebookPen className="h-6 w-6 text-amber-200" />
|
|
242
|
+
</div>
|
|
243
|
+
<p className="text-base font-semibold text-white mb-1">No sticky notes yet</p>
|
|
244
|
+
<p className="text-sm text-white/60 mb-1 hidden md:block">
|
|
245
|
+
Click the + button on the dashboard to create one
|
|
246
|
+
</p>
|
|
247
|
+
<p className="text-sm text-white/60 mb-4 md:hidden">Tap the button below to create your first note</p>
|
|
248
|
+
<button
|
|
249
|
+
onClick={createNote}
|
|
250
|
+
className="mt-4 inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold bg-gradient-to-br from-amber-400 to-yellow-500 text-zinc-900 shadow-lg shadow-amber-500/20 hover:brightness-110 transition"
|
|
251
|
+
>
|
|
252
|
+
<NotebookPen className="h-3.5 w-3.5" />
|
|
253
|
+
Create Note
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{/* Notes List */}
|
|
260
|
+
{!loading && notes.length > 0 && (
|
|
261
|
+
<>
|
|
262
|
+
<div className="glass-card glass-card-md overflow-hidden">
|
|
263
|
+
<div className="relative">
|
|
264
|
+
{notes.map((note, idx) => (
|
|
265
|
+
<div
|
|
266
|
+
key={note.id}
|
|
267
|
+
className={`group cursor-pointer ${idx > 0 ? 'border-t border-white/[0.06]' : ''}`}
|
|
268
|
+
onClick={() => openModal(note)}
|
|
269
|
+
>
|
|
270
|
+
{/* Desktop row */}
|
|
271
|
+
<div className="hidden md:flex items-center gap-3 p-3 hover:bg-white/[0.04] transition">
|
|
272
|
+
<div
|
|
273
|
+
className="h-8 w-8 rounded-lg shrink-0 border border-white/15"
|
|
274
|
+
style={{ backgroundColor: note.color }}
|
|
275
|
+
/>
|
|
276
|
+
<div className="flex-1 min-w-0">
|
|
277
|
+
<p
|
|
278
|
+
className="text-sm text-white truncate"
|
|
279
|
+
style={{ fontFamily: "'Caveat', 'Segoe Print', 'Comic Sans MS', cursive" }}
|
|
280
|
+
>
|
|
281
|
+
{note.content || 'Empty note'}
|
|
282
|
+
</p>
|
|
283
|
+
<p className="text-[10px] text-white/40 mt-0.5">
|
|
284
|
+
Position: ({note.x}, {note.y}) · {note.visible ? 'Visible' : 'Hidden'}
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
287
|
+
<button
|
|
288
|
+
onClick={(e) => { e.stopPropagation(); deleteNote(note.id); }}
|
|
289
|
+
className="opacity-0 group-hover:opacity-100 h-8 w-8 rounded-full flex items-center justify-center bg-white/5 border border-white/10 hover:bg-rose-500/20 hover:border-rose-300/30 transition shrink-0 text-white/60 hover:text-rose-200"
|
|
290
|
+
>
|
|
291
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
{/* Mobile row: colored sticky-note style */}
|
|
296
|
+
<div className="md:hidden p-2">
|
|
297
|
+
<div
|
|
298
|
+
className="rounded-xl p-3 flex items-center gap-3 active:scale-[0.98] transition-transform shadow-md"
|
|
299
|
+
style={{ backgroundColor: note.color }}
|
|
300
|
+
>
|
|
301
|
+
<div className="flex-1 min-w-0">
|
|
302
|
+
<p
|
|
303
|
+
className="truncate"
|
|
304
|
+
style={{
|
|
305
|
+
color: '#1a1a1a',
|
|
306
|
+
fontFamily: "'Caveat', 'Segoe Print', 'Comic Sans MS', cursive",
|
|
307
|
+
fontSize: 'clamp(1rem, 4vw, 1.15rem)',
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
{note.content || 'Empty note'}
|
|
311
|
+
</p>
|
|
312
|
+
</div>
|
|
313
|
+
<button
|
|
314
|
+
onClick={(e) => { e.stopPropagation(); deleteNote(note.id); }}
|
|
315
|
+
className="p-1.5 rounded-md transition-all shrink-0"
|
|
316
|
+
>
|
|
317
|
+
<Trash2 className="h-4 w-4" style={{ color: 'rgba(127, 29, 29, 0.5)' }} />
|
|
318
|
+
</button>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
))}
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
{/* Delete All */}
|
|
327
|
+
<div className="mt-6 pt-4 border-t border-white/[0.06]">
|
|
328
|
+
<div className="flex items-center gap-2">
|
|
329
|
+
<button
|
|
330
|
+
onClick={() => setConfirmDeleteAll(true)}
|
|
331
|
+
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-xs font-semibold text-rose-200 bg-rose-500/15 border border-rose-200/20 hover:bg-rose-500/25 transition ${confirmDeleteAll ? 'pointer-events-none' : ''}`}
|
|
332
|
+
>
|
|
333
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
334
|
+
Delete All Notes
|
|
335
|
+
</button>
|
|
336
|
+
{confirmDeleteAll && (
|
|
337
|
+
<div className="flex items-center gap-1.5 text-xs text-white/70 animate-in fade-in slide-in-from-left-2 duration-200">
|
|
338
|
+
<span>Are you sure?</span>
|
|
339
|
+
<button
|
|
340
|
+
onClick={() => { deleteAll(); setConfirmDeleteAll(false); }}
|
|
341
|
+
className="h-7 w-7 rounded-full flex items-center justify-center bg-rose-500/20 border border-rose-200/20 hover:bg-rose-500/30 text-rose-200 transition"
|
|
342
|
+
>
|
|
343
|
+
<Check className="h-3.5 w-3.5" />
|
|
344
|
+
</button>
|
|
345
|
+
<button
|
|
346
|
+
onClick={() => setConfirmDeleteAll(false)}
|
|
347
|
+
className="h-7 w-7 rounded-full flex items-center justify-center bg-white/5 border border-white/10 hover:bg-white/10 text-white/70 transition"
|
|
348
|
+
>
|
|
349
|
+
<X className="h-3.5 w-3.5" />
|
|
350
|
+
</button>
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{/* Edit Modal */}
|
|
359
|
+
{editingNote && (
|
|
360
|
+
<div
|
|
361
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
|
362
|
+
onClick={closeModal}
|
|
363
|
+
>
|
|
364
|
+
<div
|
|
365
|
+
className="w-full max-w-sm rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200 border border-white/15"
|
|
366
|
+
onClick={(e) => e.stopPropagation()}
|
|
367
|
+
style={{ backgroundColor: editColor }}
|
|
368
|
+
>
|
|
369
|
+
{/* Header */}
|
|
370
|
+
<div className="flex items-center justify-between px-4 pt-3 pb-1">
|
|
371
|
+
<div className="flex gap-0.5 opacity-40">
|
|
372
|
+
<span className="block w-1.5 h-1.5 rounded-full" style={{ backgroundColor: '#1a1a1a' }} />
|
|
373
|
+
<span className="block w-1.5 h-1.5 rounded-full" style={{ backgroundColor: '#1a1a1a' }} />
|
|
374
|
+
<span className="block w-1.5 h-1.5 rounded-full" style={{ backgroundColor: '#1a1a1a' }} />
|
|
375
|
+
</div>
|
|
376
|
+
<button
|
|
377
|
+
onClick={closeModal}
|
|
378
|
+
className="p-1 rounded-full hover:bg-black/10 transition-colors"
|
|
379
|
+
>
|
|
380
|
+
<X className="h-4 w-4" style={{ color: '#1a1a1a' }} />
|
|
381
|
+
</button>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
{/* Textarea */}
|
|
385
|
+
<textarea
|
|
386
|
+
value={editContent}
|
|
387
|
+
onChange={(e) => setEditContent(e.target.value)}
|
|
388
|
+
className="w-full px-4 pb-3 min-h-[220px] bg-transparent border-none outline-none resize-none text-lg md:text-base leading-relaxed placeholder:opacity-40"
|
|
389
|
+
style={{ color: '#1a1a1a', fontFamily: "'Caveat', 'Segoe Print', 'Comic Sans MS', cursive", fontSize: 'clamp(1.1rem, 4vw, 1.25rem)' }}
|
|
390
|
+
placeholder="Write something..."
|
|
391
|
+
autoFocus
|
|
392
|
+
/>
|
|
393
|
+
|
|
394
|
+
{/* Color picker */}
|
|
395
|
+
<div className="px-4 pb-3 flex gap-3 md:gap-2">
|
|
396
|
+
{PASTEL_COLORS.map((c) => (
|
|
397
|
+
<button
|
|
398
|
+
key={c}
|
|
399
|
+
onClick={() => changeModalColor(c)}
|
|
400
|
+
className={`h-9 w-9 md:h-7 md:w-7 rounded-full transition-all ${
|
|
401
|
+
editColor === c
|
|
402
|
+
? 'ring-2 ring-white ring-offset-2 scale-110'
|
|
403
|
+
: 'hover:scale-105 active:scale-95'
|
|
404
|
+
}`}
|
|
405
|
+
style={{ backgroundColor: c, ['--tw-ring-offset-color' as string]: editColor }}
|
|
406
|
+
/>
|
|
407
|
+
))}
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
{/* Delete button */}
|
|
411
|
+
<div className="px-4 pb-4">
|
|
412
|
+
<button
|
|
413
|
+
onClick={deleteAndClose}
|
|
414
|
+
className="text-xs font-medium transition-colors"
|
|
415
|
+
style={{ color: 'rgba(153, 27, 27, 0.6)' }}
|
|
416
|
+
onMouseEnter={(e) => (e.currentTarget.style.color = 'rgba(153, 27, 27, 0.9)')}
|
|
417
|
+
onMouseLeave={(e) => (e.currentTarget.style.color = 'rgba(153, 27, 27, 0.6)')}
|
|
418
|
+
>
|
|
419
|
+
Delete this note
|
|
420
|
+
</button>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useWallpaper } from './WallpaperContext';
|
|
2
|
+
|
|
3
|
+
export default function WallpaperBackground() {
|
|
4
|
+
const { wallpaper } = useWallpaper();
|
|
5
|
+
return (
|
|
6
|
+
<div
|
|
7
|
+
aria-hidden
|
|
8
|
+
className="fixed inset-0 -z-10 pointer-events-none"
|
|
9
|
+
style={{ background: wallpaper.background }}
|
|
10
|
+
/>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
export type WallpaperKind = 'gradient' | 'image';
|
|
5
|
+
|
|
6
|
+
export interface Wallpaper {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
kind: WallpaperKind;
|
|
10
|
+
/** CSS background value (used in `background` shorthand) */
|
|
11
|
+
background: string;
|
|
12
|
+
/** Smaller thumbnail-sized background (lighter for the picker) */
|
|
13
|
+
thumb: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const GRADIENT_WALLPAPERS: Wallpaper[] = [
|
|
17
|
+
{
|
|
18
|
+
id: 'sunset',
|
|
19
|
+
name: 'Sunset',
|
|
20
|
+
kind: 'gradient',
|
|
21
|
+
background:
|
|
22
|
+
'radial-gradient(ellipse at 20% 0%, #F2B5C7 0%, transparent 45%),' +
|
|
23
|
+
'radial-gradient(ellipse at 80% 10%, #C9A7E8 0%, transparent 50%),' +
|
|
24
|
+
'radial-gradient(ellipse at 50% 90%, #6B7BB8 0%, transparent 55%),' +
|
|
25
|
+
'linear-gradient(180deg, #E8B7C9 0%, #B49BD8 45%, #4A5A8C 100%)',
|
|
26
|
+
thumb:
|
|
27
|
+
'radial-gradient(ellipse at 30% 0%, #F2B5C7, transparent 60%),' +
|
|
28
|
+
'linear-gradient(180deg, #E8B7C9, #B49BD8 45%, #4A5A8C)',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'aurora',
|
|
32
|
+
name: 'Aurora',
|
|
33
|
+
kind: 'gradient',
|
|
34
|
+
background:
|
|
35
|
+
'radial-gradient(ellipse at 10% 20%, #5DE0E6 0%, transparent 50%),' +
|
|
36
|
+
'radial-gradient(ellipse at 90% 30%, #B95BFF 0%, transparent 55%),' +
|
|
37
|
+
'radial-gradient(ellipse at 60% 90%, #FF6BAA 0%, transparent 50%),' +
|
|
38
|
+
'linear-gradient(135deg, #0B1228 0%, #1F1244 50%, #2A0E3A 100%)',
|
|
39
|
+
thumb:
|
|
40
|
+
'radial-gradient(ellipse at 10% 20%, #5DE0E6, transparent 60%),' +
|
|
41
|
+
'radial-gradient(ellipse at 90% 30%, #B95BFF, transparent 60%),' +
|
|
42
|
+
'linear-gradient(135deg, #0B1228, #2A0E3A)',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'nebula',
|
|
46
|
+
name: 'Nebula',
|
|
47
|
+
kind: 'gradient',
|
|
48
|
+
background:
|
|
49
|
+
'radial-gradient(ellipse at 25% 25%, #FF7AB6 0%, transparent 45%),' +
|
|
50
|
+
'radial-gradient(ellipse at 75% 65%, #6B5BFF 0%, transparent 50%),' +
|
|
51
|
+
'radial-gradient(ellipse at 50% 50%, #FFB347 0%, transparent 30%),' +
|
|
52
|
+
'linear-gradient(180deg, #0A0518 0%, #16092B 60%, #2A0F3D 100%)',
|
|
53
|
+
thumb:
|
|
54
|
+
'radial-gradient(ellipse at 25% 25%, #FF7AB6, transparent 60%),' +
|
|
55
|
+
'radial-gradient(ellipse at 75% 65%, #6B5BFF, transparent 60%),' +
|
|
56
|
+
'linear-gradient(180deg, #0A0518, #2A0F3D)',
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const STORAGE_KEY = 'bloby:wallpaper';
|
|
61
|
+
const DEFAULT_ID = 'sunset';
|
|
62
|
+
|
|
63
|
+
interface WallpaperContextValue {
|
|
64
|
+
wallpaper: Wallpaper;
|
|
65
|
+
setWallpaperId: (id: string) => void;
|
|
66
|
+
wallpapers: Wallpaper[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const WallpaperContext = createContext<WallpaperContextValue | null>(null);
|
|
70
|
+
|
|
71
|
+
interface ApiWallpaper {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
url: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function WallpaperProvider({ children }: { children: ReactNode }) {
|
|
78
|
+
const [wallpaperId, setWallpaperIdState] = useState<string>(() => {
|
|
79
|
+
if (typeof window === 'undefined') return DEFAULT_ID;
|
|
80
|
+
try {
|
|
81
|
+
return localStorage.getItem(STORAGE_KEY) || DEFAULT_ID;
|
|
82
|
+
} catch {
|
|
83
|
+
return DEFAULT_ID;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const [imageWallpapers, setImageWallpapers] = useState<Wallpaper[]>([]);
|
|
88
|
+
|
|
89
|
+
// Auto-discover any image dropped into client/public/wallpapers/
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
let cancelled = false;
|
|
92
|
+
const load = () => {
|
|
93
|
+
fetch('/app/api/wallpapers')
|
|
94
|
+
.then((r) => (r.ok ? r.json() : []))
|
|
95
|
+
.then((items: ApiWallpaper[]) => {
|
|
96
|
+
if (cancelled || !Array.isArray(items)) return;
|
|
97
|
+
const mapped: Wallpaper[] = items.map((it) => ({
|
|
98
|
+
id: it.id,
|
|
99
|
+
name: it.name,
|
|
100
|
+
kind: 'image',
|
|
101
|
+
background: `url('${it.url}') center/cover no-repeat`,
|
|
102
|
+
thumb: `url('${it.url}') center/cover no-repeat`,
|
|
103
|
+
}));
|
|
104
|
+
setImageWallpapers(mapped);
|
|
105
|
+
})
|
|
106
|
+
.catch(() => {});
|
|
107
|
+
};
|
|
108
|
+
load();
|
|
109
|
+
// Refresh when the tab regains focus — picks up any image you just dropped
|
|
110
|
+
const onFocus = () => load();
|
|
111
|
+
window.addEventListener('focus', onFocus);
|
|
112
|
+
return () => {
|
|
113
|
+
cancelled = true;
|
|
114
|
+
window.removeEventListener('focus', onFocus);
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const wallpapers = useMemo<Wallpaper[]>(
|
|
119
|
+
() => [...GRADIENT_WALLPAPERS, ...imageWallpapers],
|
|
120
|
+
[imageWallpapers],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const wallpaper = useMemo(
|
|
124
|
+
() => wallpapers.find((w) => w.id === wallpaperId) || wallpapers[0],
|
|
125
|
+
[wallpapers, wallpaperId],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
try {
|
|
130
|
+
localStorage.setItem(STORAGE_KEY, wallpaperId);
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore quota / private mode
|
|
133
|
+
}
|
|
134
|
+
}, [wallpaperId]);
|
|
135
|
+
|
|
136
|
+
const value = useMemo<WallpaperContextValue>(
|
|
137
|
+
() => ({
|
|
138
|
+
wallpaper,
|
|
139
|
+
setWallpaperId: (id) => setWallpaperIdState(id),
|
|
140
|
+
wallpapers,
|
|
141
|
+
}),
|
|
142
|
+
[wallpaper, wallpapers],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return <WallpaperContext.Provider value={value}>{children}</WallpaperContext.Provider>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function useWallpaper(): WallpaperContextValue {
|
|
149
|
+
const ctx = useContext(WallpaperContext);
|
|
150
|
+
if (!ctx) {
|
|
151
|
+
// Safe fallback: return defaults instead of throwing — prevents crashes
|
|
152
|
+
// in transient render situations (e.g. sub-tree mounted outside provider).
|
|
153
|
+
return {
|
|
154
|
+
wallpaper: GRADIENT_WALLPAPERS[0],
|
|
155
|
+
setWallpaperId: () => {},
|
|
156
|
+
wallpapers: GRADIENT_WALLPAPERS,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return ctx;
|
|
160
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Check, X } from 'lucide-react';
|
|
2
|
+
import { useWallpaper } from './WallpaperContext';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
open: boolean;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function WallpaperPicker({ open, onClose }: Props) {
|
|
10
|
+
const { wallpaper, setWallpaperId, wallpapers } = useWallpaper();
|
|
11
|
+
if (!open) return null;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className="fixed inset-0 z-[120] flex items-center justify-center p-4"
|
|
16
|
+
onClick={onClose}
|
|
17
|
+
>
|
|
18
|
+
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
|
19
|
+
<div
|
|
20
|
+
onClick={(e) => e.stopPropagation()}
|
|
21
|
+
className="glass-card relative w-full max-w-2xl p-6"
|
|
22
|
+
style={{ borderRadius: 28 }}
|
|
23
|
+
>
|
|
24
|
+
<div className="flex items-center justify-between mb-5">
|
|
25
|
+
<div>
|
|
26
|
+
<h2 className="text-lg font-semibold tracking-tight">Wallpaper</h2>
|
|
27
|
+
<p className="text-xs text-white/60 mt-0.5">Pick a vibe for your workspace.</p>
|
|
28
|
+
</div>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={onClose}
|
|
32
|
+
className="h-9 w-9 rounded-full glass-pill flex items-center justify-center text-white/80 hover:text-white transition"
|
|
33
|
+
aria-label="Close"
|
|
34
|
+
>
|
|
35
|
+
<X className="h-4 w-4" />
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
40
|
+
{wallpapers.map((wp) => {
|
|
41
|
+
const active = wp.id === wallpaper.id;
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
key={wp.id}
|
|
45
|
+
type="button"
|
|
46
|
+
onClick={() => {
|
|
47
|
+
setWallpaperId(wp.id);
|
|
48
|
+
}}
|
|
49
|
+
className="group relative aspect-[4/3] rounded-2xl overflow-hidden border border-white/10 hover:border-white/30 transition"
|
|
50
|
+
style={{ background: wp.thumb }}
|
|
51
|
+
>
|
|
52
|
+
<div className="absolute inset-x-0 bottom-0 px-3 py-2 bg-gradient-to-t from-black/60 to-transparent">
|
|
53
|
+
<span className="text-xs font-medium text-white drop-shadow">{wp.name}</span>
|
|
54
|
+
</div>
|
|
55
|
+
{active && (
|
|
56
|
+
<div className="absolute top-2 right-2 h-7 w-7 rounded-full bg-white/90 flex items-center justify-center shadow">
|
|
57
|
+
<Check className="h-4 w-4 text-black" />
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|