firebase-os 1.1.4 → 1.1.6

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 (124) hide show
  1. package/dist/FirebaseOS.d.ts +15 -0
  2. package/dist/firebase-os.cjs.js +5 -20
  3. package/dist/firebase-os.es.js +95 -90
  4. package/dist/index.d.ts +3 -0
  5. package/dist/lib/ConfigContext.d.ts +12 -0
  6. package/package.json +3 -2
  7. package/scripts/postinstall.js +86 -15
  8. package/src/App.css +184 -0
  9. package/src/App.tsx +214 -0
  10. package/src/FirebaseOS.tsx +81 -0
  11. package/src/assets/hero.png +0 -0
  12. package/src/assets/react.svg +1 -0
  13. package/src/assets/vite.svg +1 -0
  14. package/src/components/AdminNotifications.test.tsx +98 -0
  15. package/src/components/AdminNotifications.tsx +194 -0
  16. package/src/components/Button.test.tsx +22 -0
  17. package/src/components/Button.tsx +53 -0
  18. package/src/components/ConfirmModal.test.tsx +98 -0
  19. package/src/components/ConfirmModal.tsx +73 -0
  20. package/src/components/ContactPopup.test.tsx +98 -0
  21. package/src/components/ContactPopup.tsx +437 -0
  22. package/src/components/CustomSelect.test.tsx +47 -0
  23. package/src/components/CustomSelect.tsx +89 -0
  24. package/src/components/DashboardNav.test.tsx +98 -0
  25. package/src/components/DashboardNav.tsx +281 -0
  26. package/src/components/Input.test.tsx +33 -0
  27. package/src/components/Input.tsx +61 -0
  28. package/src/components/JsonEditor.tsx +579 -0
  29. package/src/components/Navbar.test.tsx +98 -0
  30. package/src/components/Navbar.tsx +563 -0
  31. package/src/configs/forms/contactForm.config.ts +15 -0
  32. package/src/configs/forms/index.ts +29 -0
  33. package/src/configs/forms/pubForm.config.ts +11 -0
  34. package/src/configs/forms/supportForm.config.ts +14 -0
  35. package/src/configs/forms/userForm.config.ts +11 -0
  36. package/src/configs/pages/admin.config.ts +29 -0
  37. package/src/configs/pages/contact.config.ts +6 -0
  38. package/src/configs/pages/home.config.ts +18 -0
  39. package/src/configs/pages/mem.config.ts +2 -0
  40. package/src/configs/pages/menuOrders.config.ts +11 -0
  41. package/src/configs/pages/pub.config.ts +11 -0
  42. package/src/configs/pages/shared.config.ts +29 -0
  43. package/src/configs/pages/support.config.ts +7 -0
  44. package/src/configs/pages/tabOrders.config.ts +33 -0
  45. package/src/configs/pages/user.config.ts +29 -0
  46. package/src/configs/theme.config.ts +93 -0
  47. package/src/index.css +403 -0
  48. package/src/index.ts +22 -0
  49. package/src/lib/AuthContext.test.tsx +88 -0
  50. package/src/lib/AuthContext.tsx +191 -0
  51. package/src/lib/ConfigContext.tsx +45 -0
  52. package/src/lib/ThemeContext.tsx +233 -0
  53. package/src/lib/firebase.ts +91 -0
  54. package/src/main.tsx +22 -0
  55. package/src/microcomponents/AdminExampleContent.tsx +44 -0
  56. package/src/microcomponents/PrivateExampleContent.tsx +39 -0
  57. package/src/microcomponents/Public.tsx +126 -0
  58. package/src/microcomponents/SharedExampleContent.tsx +53 -0
  59. package/src/pages/Dashboard.test.tsx +98 -0
  60. package/src/pages/Dashboard.tsx +60 -0
  61. package/src/pages/DynamicPage.tsx +237 -0
  62. package/src/pages/FormsAdmin.test.tsx +98 -0
  63. package/src/pages/FormsAdmin.tsx +459 -0
  64. package/src/pages/Home.test.tsx +98 -0
  65. package/src/pages/Home.tsx +144 -0
  66. package/src/pages/Login.test.tsx +98 -0
  67. package/src/pages/Login.tsx +108 -0
  68. package/src/pages/PagesAdmin.test.tsx +98 -0
  69. package/src/pages/PagesAdmin.tsx +1022 -0
  70. package/src/pages/Profile.test.tsx +98 -0
  71. package/src/pages/Profile.tsx +319 -0
  72. package/src/pages/Register.test.tsx +98 -0
  73. package/src/pages/Register.tsx +116 -0
  74. package/src/pages/Requests.test.tsx +95 -0
  75. package/src/pages/Requests.tsx +422 -0
  76. package/src/pages/ResetPassword.test.tsx +98 -0
  77. package/src/pages/ResetPassword.tsx +92 -0
  78. package/src/pages/Settings.test.tsx +98 -0
  79. package/src/pages/Settings.tsx +393 -0
  80. package/src/pages/Setup.tsx +407 -0
  81. package/src/pages/StorageAdmin.test.tsx +150 -0
  82. package/src/pages/StorageAdmin.tsx +769 -0
  83. package/src/pages/Submissions.test.tsx +95 -0
  84. package/src/pages/Submissions.tsx +378 -0
  85. package/src/pages/Templates.test.tsx +98 -0
  86. package/src/pages/Templates.tsx +103 -0
  87. package/src/pages/ThemeAdmin.test.tsx +144 -0
  88. package/src/pages/ThemeAdmin.tsx +1000 -0
  89. package/src/pages/Users.test.tsx +95 -0
  90. package/src/pages/Users.tsx +334 -0
  91. package/src/pages/Verify.test.tsx +98 -0
  92. package/src/pages/Verify.tsx +95 -0
  93. package/src/prompts/index.ts +13 -0
  94. package/src/prompts/pages/publicPage.ts +44 -0
  95. package/src/prompts/sharedConstants.ts +12 -0
  96. package/src/prompts/tabs/board/adminboard.ts +32 -0
  97. package/src/prompts/tabs/board/privateboard.ts +36 -0
  98. package/src/prompts/tabs/board/publicboard.ts +36 -0
  99. package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
  100. package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
  101. package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
  102. package/src/prompts/tabs/crud/admin.ts +54 -0
  103. package/src/prompts/tabs/crud/private.ts +55 -0
  104. package/src/prompts/tabs/crud/shared.ts +53 -0
  105. package/src/prompts/tabs/table/admintable.ts +32 -0
  106. package/src/prompts/tabs/table/privatetable.ts +36 -0
  107. package/src/prompts/tabs/table/publictable.ts +36 -0
  108. package/src/setupTests.ts +1 -0
  109. package/src/templates/AdminPageTemplate.tsx +678 -0
  110. package/src/templates/PrivatePageTemplate.tsx +594 -0
  111. package/src/templates/PublicPageTemplate.tsx +92 -0
  112. package/src/templates/SharedPageTemplate.tsx +551 -0
  113. package/src/templates/TemplateBoard.test.tsx +106 -0
  114. package/src/templates/TemplateBoard.tsx +642 -0
  115. package/src/templates/TemplateCalendar.test.tsx +106 -0
  116. package/src/templates/TemplateCalendar.tsx +848 -0
  117. package/src/templates/TemplateConfirmation.test.tsx +106 -0
  118. package/src/templates/TemplateConfirmation.tsx +145 -0
  119. package/src/templates/TemplateInlineForm.test.tsx +106 -0
  120. package/src/templates/TemplateInlineForm.tsx +129 -0
  121. package/src/templates/TemplatePopupForm.test.tsx +106 -0
  122. package/src/templates/TemplatePopupForm.tsx +174 -0
  123. package/src/templates/TemplateTable.test.tsx +106 -0
  124. package/src/templates/TemplateTable.tsx +675 -0
@@ -0,0 +1,642 @@
1
+ import { motion, AnimatePresence } from 'framer-motion';
2
+ import { ArrowLeft, GripVertical, Trash, LayoutList, Columns, Plus, X, User, Edit2, Loader2, Search, Filter, ChevronDown, Clock, Bot, Check } from 'lucide-react';
3
+ import { adminBoardPrompt, privateBoardPrompt, publicBoardPrompt } from '../prompts';
4
+ import { Link } from 'react-router-dom';
5
+ import { Button } from '../components/Button';
6
+ import { Input } from '../components/Input';
7
+ import { ConfirmModal } from '../components/ConfirmModal';
8
+ import { db } from '../lib/firebase';
9
+ import { collection, onSnapshot, doc, setDoc, deleteDoc, getDocs, query, where } from 'firebase/firestore';
10
+ import { useState, useEffect } from 'react';
11
+ import { useAuth } from '../lib/AuthContext';
12
+ import { DashboardNav } from '../components/DashboardNav';
13
+
14
+ interface Task {
15
+ id: string;
16
+ categoryId: string;
17
+ name: string;
18
+ fields?: { key: string; value: string }[];
19
+ order?: number;
20
+ }
21
+
22
+ interface Category {
23
+ id: string;
24
+ title: string;
25
+ color?: string;
26
+ createdAt?: number;
27
+ }
28
+
29
+ const pastelColors = [
30
+ { text: 'text-red-400', bgCircle: 'bg-red-400', bgCard: 'bg-red-500/[0.08]', borderCard: 'border-red-500/30', bgLight: 'bg-red-500/15', bgHover: 'hover:bg-red-500', bgAlt: 'bg-red-500/[0.04]', name: 'Red' },
31
+ { text: 'text-orange-400', bgCircle: 'bg-orange-400', bgCard: 'bg-orange-500/[0.08]', borderCard: 'border-orange-500/30', bgLight: 'bg-orange-500/15', bgHover: 'hover:bg-orange-500', bgAlt: 'bg-orange-500/[0.04]', name: 'Orange' },
32
+ { text: 'text-yellow-400', bgCircle: 'bg-yellow-400', bgCard: 'bg-yellow-500/[0.08]', borderCard: 'border-yellow-500/30', bgLight: 'bg-yellow-500/15', bgHover: 'hover:bg-yellow-500', bgAlt: 'bg-yellow-500/[0.04]', name: 'Yellow' },
33
+ { text: 'text-green-400', bgCircle: 'bg-green-400', bgCard: 'bg-green-500/[0.08]', borderCard: 'border-green-500/30', bgLight: 'bg-green-500/15', bgHover: 'hover:bg-green-500', bgAlt: 'bg-green-500/[0.04]', name: 'Green' },
34
+ { text: 'text-blue-400', bgCircle: 'bg-blue-400', bgCard: 'bg-blue-500/[0.08]', borderCard: 'border-blue-500/30', bgLight: 'bg-blue-500/15', bgHover: 'hover:bg-blue-500', bgAlt: 'bg-blue-500/[0.04]', name: 'Blue' },
35
+ { text: 'text-accent', bgCircle: 'bg-accent', bgCard: 'bg-accent/[0.08]', borderCard: 'border-accent/30', bgLight: 'bg-accent/15', bgHover: 'hover:bg-accent', bgAlt: 'bg-accent/[0.04]', name: 'Purple' },
36
+ { text: 'text-pink-400', bgCircle: 'bg-pink-400', bgCard: 'bg-pink-500/[0.08]', borderCard: 'border-pink-500/30', bgLight: 'bg-pink-500/15', bgHover: 'hover:bg-pink-500', bgAlt: 'bg-pink-500/[0.04]', name: 'Pink' },
37
+ { text: 'text-foreground/60', bgCircle: 'bg-foreground/30', bgCard: 'bg-foreground/[0.04]', borderCard: 'border-[var(--panel-border)]/50', bgLight: 'bg-foreground/15', bgHover: 'hover:bg-foreground/30', bgAlt: 'bg-foreground/[0.02]', name: 'Default' },
38
+ ];
39
+
40
+ export interface BoardProps {
41
+ title?: string;
42
+ subtitle?: string;
43
+ backUrl?: string;
44
+ tasksCollection?: string;
45
+ categoriesCollection?: string;
46
+ config?: any;
47
+ }
48
+
49
+ export function TemplateBoard({
50
+ title = 'Board View',
51
+ subtitle = '/templates/board',
52
+ backUrl = '/templates',
53
+ tasksCollection = 'pub_tasks',
54
+ categoriesCollection = 'pub_categories',
55
+ config
56
+ }: BoardProps) {
57
+ const { user } = useAuth();
58
+ const isTabMode = !!config;
59
+ const isPrivate = config?.isPrivate && !config?.isAdmin && !config?.isShared;
60
+ const isAdminOrShared = config?.isAdmin || config?.isShared;
61
+ const showCreator = isTabMode ? isAdminOrShared : true;
62
+ const showAddButton = config?.showButton !== false;
63
+ const addButtonText = config?.buttonText || '+';
64
+ const addButtonStyle = config?.buttonStyle || 'primary';
65
+ const keyLabel = config?.keyName || 'key';
66
+ const valueLabel = config?.valueName || 'value';
67
+ const [tasks, setTasks] = useState<Task[]>([]);
68
+ const [categories, setCategories] = useState<Category[]>([]);
69
+ const [availableForms, setAvailableForms] = useState<string[]>([]);
70
+
71
+
72
+ const [addingItemTo, setAddingItemTo] = useState<string | null>(null);
73
+ const [editingItemId, setEditingItemId] = useState<string | null>(null);
74
+ const [inlineData, setInlineData] = useState<{ id?: string, name: string }>({ name: '' });
75
+ const [inlineFields, setInlineFields] = useState<{ key: string, value: string }[]>([]);
76
+
77
+ const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
78
+ const [dragOverTaskId, setDragOverTaskId] = useState<string | null>(null);
79
+ const [dragOverCategoryId, setDragOverCategoryId] = useState<string | null>(null);
80
+
81
+ const [isSavingItem, setIsSavingItem] = useState<string | null>(null);
82
+ const [isDeleting, setIsDeleting] = useState(false);
83
+ const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
84
+ const [categoryEditTitle, setCategoryEditTitle] = useState('');
85
+ const [confirmDelete, setConfirmDelete] = useState<{ type: 'item' | 'category', id: string, title: string } | null>(null);
86
+ const [copied, setCopied] = useState(false);
87
+
88
+ // Prompt text
89
+ const getPrompt = () => {
90
+ if (config?.isAdmin) return adminBoardPrompt(config, title, tasksCollection, categoriesCollection);
91
+ if (config?.isPrivate) return privateBoardPrompt(config, title, tasksCollection, categoriesCollection, availableForms);
92
+ return publicBoardPrompt(config, title, tasksCollection, categoriesCollection, availableForms);
93
+ };
94
+ const promptText = getPrompt();
95
+
96
+ const handleCopyPrompt = () => {
97
+ navigator.clipboard.writeText(promptText);
98
+ setCopied(true);
99
+ setTimeout(() => setCopied(false), 2000);
100
+ };
101
+
102
+ useEffect(() => {
103
+ // Fetch forms
104
+ const isShared = config?.isShared;
105
+ const isAdmin = config?.isAdmin;
106
+ const isPrivate = config?.isPrivate && !isAdmin && !isShared;
107
+ const isPublic = !isAdmin && !isShared && !isPrivate;
108
+
109
+ if (isPrivate || isPublic) {
110
+ getDocs(collection(db, 'sys_forms')).then(snap => {
111
+ const type = isPublic ? 'public' : 'private';
112
+ setAvailableForms(snap.docs.filter(d => d.data().formType === type).map(doc => doc.id));
113
+ });
114
+ } else {
115
+ setAvailableForms([]);
116
+ }
117
+
118
+ const isTaskPrivate = tasksCollection.startsWith('user_');
119
+ const isCatPrivate = categoriesCollection.startsWith('user_');
120
+
121
+ if (isTaskPrivate && !user) return; // Prevent unauthorized run
122
+
123
+ const tasksQuery = (isTaskPrivate && user)
124
+ ? query(collection(db, tasksCollection), where('uid', '==', user.uid))
125
+ : collection(db, tasksCollection);
126
+
127
+ const unsubTasks = onSnapshot(tasksQuery as any, (snapshot: any) => {
128
+ const fetchedTasks: Task[] = [];
129
+ snapshot.forEach((docSnap: any) => {
130
+ const d = docSnap.data();
131
+ fetchedTasks.push({
132
+ id: docSnap.id,
133
+ categoryId: d.categoryId,
134
+ name: d.name || d.text || 'Unnamed Item',
135
+ fields: d.fields || (d.value ? [{ key: 'Label', value: d.value }] : []),
136
+ order: d.order || 0
137
+ } as Task);
138
+ });
139
+ setTasks(fetchedTasks.sort((a, b) => (a.order || 0) - (b.order || 0)));
140
+ });
141
+
142
+ const catsQuery = (isCatPrivate && user)
143
+ ? query(collection(db, categoriesCollection), where('uid', '==', user.uid))
144
+ : collection(db, categoriesCollection);
145
+
146
+ const unsubCats = onSnapshot(catsQuery as any, (snapshot: any) => {
147
+ const fetchedCats: Category[] = [];
148
+ snapshot.forEach((docSnap: any) => {
149
+ fetchedCats.push({ id: docSnap.id, ...docSnap.data() } as Category);
150
+ });
151
+
152
+ if (fetchedCats.length === 0) {
153
+ const initCats = [
154
+ { id: 'todo', title: 'To Do', createdAt: Date.now() - 2000 },
155
+ { id: 'in_progress', title: 'In Progress', createdAt: Date.now() - 1000 },
156
+ { id: 'review', title: 'Review', createdAt: Date.now() }
157
+ ];
158
+
159
+ const basePayload = user ? { uid: user.uid } : {};
160
+ initCats.forEach(c => setDoc(doc(db, categoriesCollection, c.id), { ...basePayload, title: c.title, createdAt: c.createdAt }));
161
+ } else {
162
+ setCategories(fetchedCats);
163
+ }
164
+ });
165
+
166
+ return () => { unsubTasks(); unsubCats(); };
167
+ }, [tasksCollection, categoriesCollection, user]);
168
+
169
+ const openInlineEditor = (categoryId: string, item?: Task) => {
170
+ if (item) {
171
+ setEditingItemId(item.id);
172
+ setInlineData({ id: item.id, name: item.name });
173
+ setInlineFields(item.fields?.length ? [...item.fields, { key: '', value: '' }] : [{ key: '', value: '' }]);
174
+ } else {
175
+ setAddingItemTo(categoryId);
176
+ setInlineData({ name: '' });
177
+ setInlineFields([{ key: '', value: '' }]);
178
+ }
179
+ };
180
+
181
+ const updateField = (index: number, key: string, value: string) => {
182
+ const newFields = [...inlineFields];
183
+ newFields[index] = { key, value };
184
+ if (index === inlineFields.length - 1 && (key.trim() !== '' || value.trim() !== '')) {
185
+ newFields.push({ key: '', value: '' });
186
+ }
187
+ setInlineFields(newFields);
188
+ };
189
+
190
+ const handleDragStart = (e: React.DragEvent, taskId: string) => {
191
+ e.dataTransfer.effectAllowed = 'move';
192
+ e.dataTransfer.setData('taskId', taskId);
193
+ setDraggedTaskId(taskId);
194
+ };
195
+
196
+ const handleDragOverItem = (e: React.DragEvent, taskId: string) => {
197
+ e.preventDefault(); e.stopPropagation();
198
+ if (taskId !== draggedTaskId) setDragOverTaskId(taskId);
199
+ };
200
+
201
+ const handleDragOverColumn = (e: React.DragEvent, categoryId: string) => {
202
+ e.preventDefault(); e.stopPropagation();
203
+ setDragOverCategoryId(categoryId);
204
+ };
205
+
206
+ const handleDragLeave = (e: React.DragEvent) => {
207
+ e.preventDefault();
208
+ setDragOverTaskId(null);
209
+ setDragOverCategoryId(null);
210
+ };
211
+
212
+ const handleDrop = async (e: React.DragEvent, targetCategoryId: string, targetTaskId?: string) => {
213
+ e.preventDefault(); e.stopPropagation();
214
+ setDragOverTaskId(null);
215
+ setDragOverCategoryId(null);
216
+
217
+ const draggedId = e.dataTransfer.getData('taskId');
218
+ if (!draggedId || draggedId === targetTaskId) {
219
+ setDraggedTaskId(null);
220
+ return;
221
+ }
222
+
223
+ const draggedTask = tasks.find(t => t.id === draggedId);
224
+ if (!draggedTask) return;
225
+
226
+ let targetTasks = tasks.filter(t => t.categoryId === targetCategoryId).sort((a, b) => (a.order || 0) - (b.order || 0));
227
+
228
+ if (draggedTask.categoryId !== targetCategoryId) {
229
+ targetTasks.push({ ...draggedTask, categoryId: targetCategoryId });
230
+ } else {
231
+ targetTasks = targetTasks.filter(t => t.id !== draggedId);
232
+ targetTasks.push(draggedTask);
233
+ }
234
+
235
+ if (targetTaskId) {
236
+ const dropIndex = targetTasks.findIndex(t => t.id === targetTaskId);
237
+ const draggedIndex = targetTasks.findIndex(t => t.id === draggedId);
238
+ const [moved] = targetTasks.splice(draggedIndex, 1);
239
+ targetTasks.splice(dropIndex, 0, moved);
240
+ }
241
+
242
+ targetTasks.forEach((t, i) => {
243
+ const updatePayload: any = { categoryId: targetCategoryId, order: i };
244
+ if (user) updatePayload.uid = user.uid;
245
+ setDoc(doc(db, tasksCollection, t.id), updatePayload, { merge: true });
246
+ });
247
+ setDraggedTaskId(null);
248
+ };
249
+
250
+ const handleSaveInline = async (categoryId: string) => {
251
+ const newId = inlineData.id || `item_${Date.now()}`;
252
+ const finalName = inlineData.name.trim();
253
+ const finalNameOrUntitled = finalName || 'Untitled Item';
254
+ const cleanedFields = inlineFields.filter(f => f.key.trim() !== '' || f.value.trim() !== '');
255
+
256
+ // Change detection!
257
+ let hasChanges = true;
258
+ const existingTask = inlineData.id ? tasks.find(t => t.id === inlineData.id) : null;
259
+
260
+ if (existingTask) {
261
+ const existingFieldsStr = JSON.stringify(existingTask.fields || []);
262
+ const newFieldsStr = JSON.stringify(cleanedFields);
263
+ if (existingTask.name === finalNameOrUntitled && existingFieldsStr === newFieldsStr) {
264
+ hasChanges = false;
265
+ }
266
+ } else {
267
+ // If totally clean new item with no data, treat as no changes
268
+ if (!finalName && cleanedFields.length === 0) {
269
+ hasChanges = false;
270
+ }
271
+ }
272
+
273
+ if (!hasChanges) {
274
+ setAddingItemTo(null);
275
+ setEditingItemId(null);
276
+ return;
277
+ }
278
+
279
+ setIsSavingItem(inlineData.id || 'new');
280
+
281
+ const payload: any = {
282
+ categoryId: categoryId,
283
+ name: finalNameOrUntitled,
284
+ fields: cleanedFields
285
+ };
286
+ if (user && !inlineData.id) payload.uid = user.uid;
287
+
288
+ await setDoc(doc(db, tasksCollection, newId), payload, { merge: true });
289
+
290
+ setTimeout(() => {
291
+ setAddingItemTo(null);
292
+ setEditingItemId(null);
293
+ setIsSavingItem(null);
294
+ }, 400);
295
+ };
296
+
297
+ const handleDeleteItem = (taskId: string, titleStr: string) => {
298
+ setConfirmDelete({ type: 'item', id: taskId, title: titleStr });
299
+ };
300
+
301
+ const handleAddCategoryClick = async () => {
302
+ const newId = `cat_${Date.now()}`;
303
+ const payload: any = { title: '', createdAt: Date.now() };
304
+ if (user) payload.uid = user.uid;
305
+ await setDoc(doc(db, categoriesCollection, newId), payload);
306
+ setEditingCategoryId(newId);
307
+ setCategoryEditTitle('');
308
+ setTimeout(() => {
309
+ const board = document.getElementById('board-container');
310
+ if (board) board.scrollTo({ left: board.scrollWidth, behavior: 'smooth' });
311
+ }, 150);
312
+ };
313
+
314
+ const saveEditedCategory = async (catId: string) => {
315
+ if (categoryEditTitle.trim()) {
316
+ await setDoc(doc(db, categoriesCollection, catId), { title: categoryEditTitle }, { merge: true });
317
+ } else {
318
+ await deleteDoc(doc(db, categoriesCollection, catId));
319
+ }
320
+ setEditingCategoryId(null);
321
+ };
322
+
323
+ const handleDeleteCategory = (catId: string, titleStr: string) => {
324
+ setConfirmDelete({ type: 'category', id: catId, title: titleStr });
325
+ };
326
+
327
+ const executeDelete = async () => {
328
+ if (!confirmDelete) return;
329
+ setIsDeleting(true);
330
+ try {
331
+ if (confirmDelete.type === 'item') {
332
+ await deleteDoc(doc(db, tasksCollection, confirmDelete.id));
333
+ setEditingItemId(null);
334
+ } else {
335
+ const catTasks = tasks.filter(t => t.categoryId === confirmDelete.id);
336
+ for (const t of catTasks) await deleteDoc(doc(db, tasksCollection, t.id));
337
+ await deleteDoc(doc(db, categoriesCollection, confirmDelete.id));
338
+ }
339
+ } catch (e) { }
340
+ setIsDeleting(false);
341
+ setConfirmDelete(null);
342
+ };
343
+
344
+ const sortedCategories = [...categories].sort((a, b) => {
345
+ return (a.createdAt || 0) - (b.createdAt || 0);
346
+ });
347
+
348
+ return (
349
+ <main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col pt-12 min-h-screen overflow-x-hidden">
350
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mb-4">
351
+ <h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
352
+ {title}
353
+ </h1>
354
+ {subtitle && (
355
+ <div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
356
+ <span className="w-8 h-[1px] bg-foreground/10" />
357
+ {subtitle}
358
+ </div>
359
+ )}
360
+ </motion.div>
361
+
362
+ {isTabMode && <div className="mb-6"><DashboardNav /></div>}
363
+
364
+ <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 border-b border-[var(--panel-border)] pb-6 gap-4 relative z-20 shrink-0">
365
+ {!isTabMode && backUrl && (
366
+ <Link to={backUrl} className="px-5 py-2 hover:bg-foreground/5 transition-all flex items-center gap-2" style={{ borderRadius: 'var(--btn-radius, 0.75rem)' }}>
367
+ <ArrowLeft className="w-4 h-4" /> Go Back
368
+ </Link>
369
+ )}
370
+ {isTabMode && <div />}
371
+ <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
372
+ {config?.showPrompt && (
373
+ <motion.button
374
+ whileHover={{ scale: 1.05 }}
375
+ whileTap={{ scale: 0.95 }}
376
+ onClick={handleCopyPrompt}
377
+ title={copied ? 'Copied!' : 'Copy Developer Prompt'}
378
+ className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-300 cursor-pointer ${copied
379
+ ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20'
380
+ : 'btn-secondary shadow-lg'
381
+ }`}
382
+ >
383
+ {copied ? <Check className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
384
+ </motion.button>
385
+ )}
386
+ {showAddButton && (
387
+ <Button variant={addButtonStyle === 'secondary' ? 'secondary' : undefined} onClick={handleAddCategoryClick} className="flex-1 sm:flex-none px-3 py-2.5 text-[14px] font-bold rounded-xl shadow-lg hover:-translate-y-0.5 transition-all shrink-0">
388
+ {addButtonText === '+' ? <Plus className="w-4 h-4" /> : addButtonText}
389
+ </Button>
390
+ )}
391
+ </div>
392
+ </div>
393
+
394
+
395
+
396
+ <div className="flex-1 w-full relative z-40 min-h-0">
397
+ <div id="board-container" className="w-full h-full flex flex-row gap-4 md:gap-6 overflow-x-auto pb-4 snap-x snap-mandatory styled-scrollbars items-start">
398
+ {sortedCategories.map((col, colIndex) => {
399
+ let colTasks = tasks.filter(t => t.categoryId === col.id);
400
+ colTasks.sort((a, b) => (a.order || 0) - (b.order || 0));
401
+ const themeDef = pastelColors.find(p => p.text === col.color) || pastelColors.find(p => p.text === 'text-accent')!;
402
+ const txtColor = themeDef.text;
403
+ const bgLight = themeDef.bgLight;
404
+ const bgHover = themeDef.bgHover;
405
+
406
+ return (
407
+ <motion.div
408
+ key={col.id}
409
+ initial={{ opacity: 0, y: 20 }}
410
+ animate={{ opacity: 1, y: 0 }}
411
+ transition={{ delay: colIndex * 0.1 }}
412
+ className={`w-[300px] md:w-[320px] shrink-0 flex flex-col max-h-full rounded-3xl p-4 snap-center relative border transition-colors ${pastelColors.find(p => p.text === col.color)?.bgCard || 'bg-foreground/[0.02]'} ${pastelColors.find(p => p.text === col.color)?.borderCard || 'border-[var(--panel-border)]/50'}`}
413
+ >
414
+ <div className="flex items-center justify-between mb-4 px-2 relative z-[200]">
415
+ <h3 className="font-bold text-[14px] flex items-center w-full text-foreground/80 group relative z-[200]">
416
+ {editingCategoryId === col.id ? (
417
+ <div className="relative flex-1 w-full flex items-center pr-8 z-[200]">
418
+ <div className="fixed inset-0 z-[-1]" onClick={(e) => { e.stopPropagation(); saveEditedCategory(col.id); }} />
419
+ <input
420
+ value={categoryEditTitle}
421
+ onChange={e => setCategoryEditTitle(e.target.value)}
422
+ onBlur={(e) => {
423
+ if (!e.relatedTarget?.closest('.category-color-picker')) saveEditedCategory(col.id);
424
+ }}
425
+ onKeyDown={e => e.key === 'Enter' && saveEditedCategory(col.id)}
426
+ className={`bg-transparent border-none outline-none font-bold text-[14px] w-full p-0 ${col.color || 'text-foreground/80'}`}
427
+ placeholder="Category name"
428
+ />
429
+ <div className="absolute top-8 left-0 flex gap-1.5 bg-background/95 backdrop-blur-xl border border-[var(--panel-border)] p-2 rounded-xl shadow-xl z-[100] category-color-picker items-center" tabIndex={-1}>
430
+ <button
431
+ onClick={async (e) => {
432
+ e.preventDefault(); e.stopPropagation();
433
+ await setDoc(doc(db, 'pub_categories', col.id), { color: '' }, { merge: true });
434
+ setEditingCategoryId(null);
435
+ }}
436
+ className="w-4 h-4 rounded-full flex items-center justify-center bg-foreground/10 hover:bg-red-500/20 hover:text-red-500 text-foreground/40 transition-colors border border-[var(--panel-border)]/50 shrink-0"
437
+ title="Reset Category Color"
438
+ >
439
+ <X className="w-3 h-3" />
440
+ </button>
441
+ <div className="h-4 w-px bg-[var(--panel-border)]/50 mx-0.5" />
442
+ {pastelColors.map(c => (
443
+ <button
444
+ key={c.name}
445
+ onMouseDown={async (e) => {
446
+ e.preventDefault();
447
+ await setDoc(doc(db, 'pub_categories', col.id), { color: c.text }, { merge: true });
448
+ }}
449
+ className={`w-4 h-4 rounded-full ${c.bgCircle} hover:scale-125 transition-transform border border-[var(--panel-border)]/50 shrink-0`}
450
+ />
451
+ ))}
452
+ </div>
453
+ </div>
454
+ ) : (
455
+ <div className="flex items-center gap-2 w-full cursor-pointer hover:text-foreground transition-colors pr-8" onClick={() => { setEditingCategoryId(col.id); setCategoryEditTitle(col.title); }}>
456
+ <span className={`w-2.5 h-2.5 rounded-full ${pastelColors.find(p => p.text === col.color)?.bgCircle || 'bg-foreground/20'} transition-colors shrink-0`} />
457
+ <span className={`truncate flex-1 max-w-[200px] ${col.color || ''}`}>{col.title || 'Untitled Category'}</span>
458
+ <span className="text-[11px] bg-foreground/10 text-foreground/60 px-1.5 py-0.5 rounded-md font-medium shrink-0">{colTasks.length}</span>
459
+ </div>
460
+ )}
461
+
462
+ {categories.length > 1 && (
463
+ <button onClick={() => handleDeleteCategory(col.id, col.title)} className="absolute right-0 opacity-0 group-hover:opacity-100 text-foreground/30 hover:text-red-500 transition-all shrink-0"><Trash className="w-3.5 h-3.5" /></button>
464
+ )}
465
+ </h3>
466
+ </div>
467
+
468
+ <div
469
+ className={`flex flex-col gap-2.5 flex-1 overflow-y-auto styled-scrollbars pr-1 relative min-h-[150px] rounded-xl transition-all ${dragOverCategoryId === col.id && !dragOverTaskId ? 'bg-foreground/5 scale-[1.01] border-dashed border border-accent/20' : ''}`}
470
+ onDragOver={(e) => handleDragOverColumn(e, col.id)}
471
+ onDragLeave={handleDragLeave}
472
+ onDrop={(e) => handleDrop(e, col.id)}
473
+ >
474
+ <AnimatePresence>
475
+ {colTasks.map((item) => (
476
+ editingItemId === item.id ? (
477
+ <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} key={item.id} className="bg-background/80 rounded-xl p-3 shadow-md border border-accent/40 transition-all flex flex-col gap-2 relative z-[100]">
478
+ <div className="fixed inset-0 z-[-1]" onClick={(e) => { e.stopPropagation(); handleSaveInline(col.id); }} />
479
+ <textarea
480
+ placeholder="Write an item name..."
481
+ className="no-glow bg-transparent border-none outline-none text-[13px] text-foreground/90 font-medium leading-snug resize-none w-full min-h-[40px] relative z-10"
482
+ value={inlineData.name}
483
+ onChange={e => setInlineData({ ...inlineData, name: e.target.value })}
484
+ onKeyDown={e => {
485
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveInline(col.id); }
486
+ }}
487
+ />
488
+ <div className="flex flex-col gap-2 mt-1 relative z-10 w-full">
489
+ {inlineFields.map((f, i) => (
490
+ <div key={i} className="flex items-start bg-foreground/[0.04] border border-[var(--panel-border)]/50 rounded-[8px] px-2.5 py-1.5 w-full">
491
+ <input
492
+ placeholder={keyLabel}
493
+ style={{ width: `${Math.max(25, f.key.length * 7)}px` }}
494
+ value={f.key}
495
+ onChange={e => updateField(i, e.target.value, f.value)}
496
+ onKeyDown={e => e.key === 'Enter' && handleSaveInline(col.id)}
497
+ className="no-glow bg-transparent outline-none text-[11px] text-foreground/50 font-bold placeholder:text-foreground/30 relative top-[1px]"
498
+ />
499
+ <span className="text-[11px] text-foreground/50 font-bold mr-1.5 relative top-[1px]">:</span>
500
+ <textarea
501
+ rows={1}
502
+ placeholder={valueLabel}
503
+ value={f.value}
504
+ onChange={e => updateField(i, f.key, e.target.value)}
505
+ onInput={(e) => { const tgt = e.target as HTMLTextAreaElement; tgt.style.height = 'auto'; tgt.style.height = tgt.scrollHeight + 'px'; }}
506
+ onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveInline(col.id); } }}
507
+ className="no-glow bg-transparent outline-none text-[12px] text-foreground/90 font-medium flex-1 min-w-[40px] placeholder:opacity-40 resize-none overflow-hidden leading-tight relative top-[1px]"
508
+ />
509
+ </div>
510
+ ))}
511
+ </div>
512
+ {isSavingItem === item.id && (
513
+ <div className="flex items-center justify-end pt-1">
514
+ <div className="w-3 h-3 border-2 border-accent border-t-transparent rounded-full animate-spin" />
515
+ </div>
516
+ )}
517
+ </motion.div>
518
+ ) : (
519
+ <motion.div
520
+ draggable
521
+ onDragStart={(e: any) => handleDragStart(e, item.id)}
522
+ onDragOver={(e: any) => handleDragOverItem(e, item.id)}
523
+ onDragLeave={handleDragLeave}
524
+ onDrop={(e: any) => handleDrop(e, col.id, item.id)}
525
+ initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
526
+ key={item.id}
527
+ className={`bg-background/80 rounded-xl p-3 shadow-sm hover:shadow-md cursor-grab active:cursor-grabbing hover:border-accent/40 transition-all group flex flex-col gap-2 relative z-10 border ${dragOverTaskId === item.id ? 'border-accent border-t-2 translate-y-1' : 'border-[var(--panel-border)]'} ${draggedTaskId === item.id ? 'opacity-30 scale-95 border-dashed border-2' : ''}`}
528
+ >
529
+ <div className="absolute top-2 right-2 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity bg-background/90 backdrop-blur-md border border-[var(--panel-border)] rounded-lg p-1 z-20 shadow-md">
530
+ <button onClick={(e) => { e.stopPropagation(); openInlineEditor(col.id, item); }} className="p-1 hover:bg-foreground/5 rounded-md text-foreground/60 hover:text-accent transition-colors"><Edit2 className="w-3.5 h-3.5" /></button>
531
+ <button onClick={(e) => { e.stopPropagation(); handleDeleteItem(item.id, item.name); }} className="p-1 hover:bg-red-500/10 rounded-md text-foreground/60 hover:text-red-500 transition-colors"><Trash className="w-3.5 h-3.5" /></button>
532
+ </div>
533
+
534
+ <p className="text-[13px] text-foreground/90 font-medium leading-snug break-words pr-12">{item.name}</p>
535
+
536
+ {item.fields && item.fields.length > 0 && (
537
+ <div className="flex flex-row flex-wrap gap-2 mt-2">
538
+ {item.fields.map((f, i) => (
539
+ <div key={i} className="inline-block bg-foreground/[0.04] border border-[var(--panel-border)]/50 rounded-[8px] px-2.5 py-1.5 max-w-full">
540
+ <span className="text-[11px] text-foreground/50 font-bold mr-1.5 relative -top-[1px]">{f.key}:</span>
541
+ <span className="text-[12px] text-foreground/90 font-medium break-words leading-tight">{f.value}</span>
542
+ </div>
543
+ ))}
544
+ </div>
545
+ )}
546
+
547
+ <div className="flex items-end justify-between mt-3 pt-3 border-t border-[var(--panel-border)]/50 w-full gap-2">
548
+ {showCreator && isTabMode && user ? (
549
+ <div className="flex items-center gap-1.5 pr-2 border-r border-[var(--panel-border)] border-dashed mr-auto">
550
+ {user.photoURL ? (
551
+ <img src={user.photoURL} className="w-4 h-4 rounded-full shrink-0 object-cover" />
552
+ ) : (
553
+ <div className="w-4 h-4 rounded-full bg-accent/20 text-accent flex items-center justify-center text-[9px] font-bold shrink-0">
554
+ {(user.displayName || user.email || 'U')[0].toUpperCase()}
555
+ </div>
556
+ )}
557
+ <span className="text-[10px] font-bold text-foreground/60 truncate max-w-[80px]">{user.displayName || user.email?.split('@')[0] || 'User'}</span>
558
+ </div>
559
+ ) : (
560
+ <div className="flex items-center gap-2 pr-2 border-r border-[var(--panel-border)] border-dashed mr-auto"></div>
561
+ )}
562
+ <span className="text-[10px] text-foreground/40 font-bold text-right pt-0.5 shrink-0 max-w-[50%] truncate flex-1">
563
+ {item.id.startsWith('item_') ? new Date(parseInt(item.id.split('_')[1])).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Recently Added'}
564
+ </span>
565
+ </div>
566
+ </motion.div>
567
+ )
568
+ ))}
569
+ </AnimatePresence>
570
+
571
+ {addingItemTo === col.id ? (
572
+ <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="bg-background/80 rounded-xl p-3 shadow-md border border-accent/40 transition-all flex flex-col gap-2 relative z-[100] mt-1">
573
+ <div className="fixed inset-0 z-[-1]" onClick={(e) => { e.stopPropagation(); handleSaveInline(col.id); }} />
574
+ <textarea
575
+ placeholder="Write an item name..."
576
+ className="no-glow bg-transparent border-none outline-none text-[13px] text-foreground/90 font-medium leading-snug resize-none w-full min-h-[40px] relative z-10"
577
+ value={inlineData.name}
578
+ onChange={e => setInlineData({ ...inlineData, name: e.target.value })}
579
+ onKeyDown={e => {
580
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveInline(col.id); }
581
+ }}
582
+ autoFocus
583
+ />
584
+ <div className="flex flex-col gap-2 mt-1 relative z-10 w-full">
585
+ {inlineFields.map((f, i) => (
586
+ <div key={i} className="flex items-start bg-foreground/[0.04] border border-[var(--panel-border)]/50 rounded-[8px] px-2.5 py-1.5 w-full">
587
+ <input
588
+ placeholder={keyLabel}
589
+ style={{ width: `${Math.max(25, f.key.length * 7)}px` }}
590
+ value={f.key}
591
+ onChange={e => updateField(i, e.target.value, f.value)}
592
+ onKeyDown={e => e.key === 'Enter' && handleSaveInline(col.id)}
593
+ className="no-glow bg-transparent outline-none text-[11px] text-foreground/50 font-bold placeholder:text-foreground/30 relative top-[1px]"
594
+ />
595
+ <span className="text-[11px] text-foreground/50 font-bold mr-1.5 relative top-[1px]">:</span>
596
+ <textarea
597
+ rows={1}
598
+ placeholder={valueLabel}
599
+ value={f.value}
600
+ onChange={e => updateField(i, f.key, e.target.value)}
601
+ onInput={(e) => { const tgt = e.target as HTMLTextAreaElement; tgt.style.height = 'auto'; tgt.style.height = tgt.scrollHeight + 'px'; }}
602
+ onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveInline(col.id); } }}
603
+ className="no-glow bg-transparent outline-none text-[12px] text-foreground/90 font-medium flex-1 min-w-[40px] placeholder:opacity-40 resize-none overflow-hidden leading-tight relative top-[1px]"
604
+ />
605
+ </div>
606
+ ))}
607
+ </div>
608
+ {isSavingItem === 'new' && (
609
+ <div className="flex items-center justify-end pt-1">
610
+ <div className="w-3 h-3 border-2 border-accent border-t-transparent rounded-full animate-spin" />
611
+ </div>
612
+ )}
613
+ </motion.div>
614
+ ) : (
615
+ <button onClick={() => openInlineEditor(col.id)} className="flex items-center gap-2 justify-center w-full py-2.5 mt-1 rounded-xl border border-transparent text-[12px] font-bold text-foreground/40 hover:text-foreground hover:bg-foreground/[0.03] transition-all shrink-0">
616
+ <Plus className="w-4 h-4" /> Add Item
617
+ </button>
618
+ )}
619
+ </div>
620
+ </motion.div>
621
+ );
622
+ })}
623
+ {/* Empty Ghost padding allows easy visual scroll */}
624
+ <div className="w-[10px] shrink-0 pointer-events-none" />
625
+ </div>
626
+ </div>
627
+
628
+ <style>{`.styled-scrollbars::-webkit-scrollbar { width: 6px; height: 6px; } .styled-scrollbars::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--accent-color) 20%, transparent); border-radius: 4px; } .styled-scrollbars::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--accent-color) 40%, transparent); }`}</style>
629
+
630
+ <ConfirmModal
631
+ isOpen={!!confirmDelete}
632
+ title="Confirm Deletion"
633
+ message={<>Are you entirely sure you wish to delete <strong className="text-foreground">{confirmDelete?.title}</strong>? This action is permanent.</>}
634
+ onConfirm={executeDelete}
635
+ onCancel={() => setConfirmDelete(null)}
636
+ isProcessing={isDeleting}
637
+ confirmText="Yes, Delete"
638
+ />
639
+
640
+ </main>
641
+ );
642
+ }