your-ai-workflow-firebase-os 1.2.20 → 1.2.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "your-ai-workflow-firebase-os",
3
- "version": "1.2.20",
3
+ "version": "1.2.21",
4
4
  "description": "A complete Firebase-powered admin OS — one React component.",
5
5
  "type": "module",
6
6
  "main": "dist/your-ai-workflow-firebase-os.cjs.js",
@@ -4,7 +4,7 @@ import { DashboardNav } from '../components/DashboardNav';
4
4
  import { useAuth } from '../lib/AuthContext';
5
5
  import { Shield, Eye, EyeOff, Lock, Globe, Plus, Trash2, Power, ChevronUp, ChevronDown, Layers, Loader2, Save, Copy, RotateCcw, CheckCircle2 } from 'lucide-react';
6
6
  import { Button } from '../components/Button';
7
- import { db } from '../lib/firebase';
7
+ import { db, storage } from '../lib/firebase';
8
8
  import { doc, onSnapshot, setDoc, collection, getDocs, getDoc } from 'firebase/firestore';
9
9
  import { ConfirmModal } from '../components/ConfirmModal';
10
10
  import { JsonNode } from './ThemeAdmin';
@@ -50,7 +50,10 @@ export function PagesAdmin() {
50
50
  const location = useLocation();
51
51
  const config = useConfig();
52
52
  const [pages, setPages] = useState<PageItem[]>(initialPages);
53
- const [expandedPageId, setExpandedPageId] = useState<string | null>(null);
53
+ const [expandedPageId, setExpandedPageId] = useState<string | null>(() => {
54
+ try { return sessionStorage.getItem('fbos_pages_expandedId') || null; } catch { return null; }
55
+ });
56
+ const expandedPageIdRef = useRef(expandedPageId);
54
57
  const [isAddingPage, setIsAddingPage] = useState(false);
55
58
  const [draftConfigs, setDraftConfigs] = useState<Record<string, any>>({});
56
59
  const [savingConfigMap, setSavingConfigMap] = useState<Record<string, boolean>>({});
@@ -102,6 +105,32 @@ export function PagesAdmin() {
102
105
  }
103
106
  }, [location.pathname, pages]);
104
107
 
108
+ // Persist expanded page ID to sessionStorage and keep ref in sync
109
+ useEffect(() => {
110
+ expandedPageIdRef.current = expandedPageId;
111
+ try {
112
+ if (expandedPageId) sessionStorage.setItem('fbos_pages_expandedId', expandedPageId);
113
+ else sessionStorage.removeItem('fbos_pages_expandedId');
114
+ } catch {}
115
+ }, [expandedPageId]);
116
+
117
+ // Persist scroll position
118
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
119
+ useEffect(() => {
120
+ const el = scrollContainerRef.current;
121
+ if (!el) return;
122
+ // Restore scroll position on mount
123
+ try {
124
+ const saved = sessionStorage.getItem('fbos_pages_scrollY');
125
+ if (saved) el.scrollTop = parseInt(saved, 10);
126
+ } catch {}
127
+ const handleScroll = () => {
128
+ try { sessionStorage.setItem('fbos_pages_scrollY', String(el.scrollTop)); } catch {}
129
+ };
130
+ el.addEventListener('scroll', handleScroll, { passive: true });
131
+ return () => el.removeEventListener('scroll', handleScroll);
132
+ }, []);
133
+
105
134
 
106
135
 
107
136
  useEffect(() => {
@@ -234,7 +263,13 @@ export function PagesAdmin() {
234
263
  } catch (e) { }
235
264
  await setDoc(doc(db, collectionName, targetId), val);
236
265
 
266
+ // Batch pages + expandedPageId update together to prevent collapse
237
267
  setPages(prev => prev.map(p => p.id === id ? { ...p, id: targetId, _uiKey: p._uiKey || id, path: val.route, title: val.pageName || val.tabName || val.title || p.title } : p));
268
+ // Use ref to read the LATEST expandedPageId (closure value is stale after async await)
269
+ if (expandedPageIdRef.current === id) {
270
+ setExpandedPageId(targetId);
271
+ window.history.replaceState(null, '', `/pages/${targetId}`);
272
+ }
238
273
  setDraftConfigs(prev => {
239
274
  const next = { ...prev };
240
275
  next[targetId] = val;
@@ -242,11 +277,6 @@ export function PagesAdmin() {
242
277
  return next;
243
278
  });
244
279
 
245
- if (expandedPageId === id) {
246
- setExpandedPageId(targetId);
247
- window.history.replaceState(null, '', `/pages/${targetId}`);
248
- }
249
-
250
280
  // Append new tab to the TOP of the order list
251
281
  const isPublicPage = pageType === 'public';
252
282
  const orderDocId = isPublicPage ? 'page_order' : 'tab_order';
@@ -306,19 +336,27 @@ export function PagesAdmin() {
306
336
  const newId = 'draft_' + Math.floor(Math.random() * 10000);
307
337
  setPages([...pages, { id: newId, title: 'New Tab', path: '/draft', type, isSystem: false, enabled: true }]);
308
338
 
309
- let defaultPayload = {};
339
+ // Count existing pages of this type to generate a unique numbered route
340
+ const existingCount = pages.filter(p => p.type === type && !p.id.startsWith('draft_')).length;
341
+ const routeNum = existingCount + 1;
342
+
343
+ let defaultPayload: any = {};
310
344
  if (type === 'public') {
311
- defaultPayload = { ...defaultPublicConfig };
345
+ defaultPayload = { ...defaultPublicConfig, route: `/page-${routeNum}` };
312
346
  } else if (type === 'private') {
313
- defaultPayload = { ...defaultPrivateConfig };
347
+ defaultPayload = { ...defaultPrivateConfig, route: `/private-tab-${routeNum}` };
314
348
  } else if (type === 'admin') {
315
- defaultPayload = { ...defaultAdminConfig };
349
+ defaultPayload = { ...defaultAdminConfig, route: `/admin-tab-${routeNum}` };
316
350
  } else if (type === 'shared') {
317
- defaultPayload = { ...defaultSharedConfig };
351
+ defaultPayload = { ...defaultSharedConfig, route: `/shared-tab-${routeNum}` };
318
352
  }
319
353
  setDraftConfigs(p => ({ ...p, [newId]: defaultPayload }));
320
354
  setExpandedPageId(newId);
321
355
  setIsAddingPage(false);
356
+
357
+ // Immediately save the draft to Firestore so the route is resolvable
358
+ handleSaveConfig(newId, { ...defaultPayload, pageType: type !== 'public' ? type : undefined });
359
+
322
360
  setTimeout(() => {
323
361
  document.getElementById(`page-${newId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
324
362
  }, 100);
@@ -367,30 +405,110 @@ export function PagesAdmin() {
367
405
  if (!deleteConfirmId) return;
368
406
  setIsDeleting(true);
369
407
  try {
370
- const { deleteDoc, collection, getDocs, doc } = await import('firebase/firestore');
408
+ const { deleteDoc, collection, getDocs, doc, getDoc: getDocFn, query, where } = await import('firebase/firestore');
409
+ const { ref, deleteObject } = await import('firebase/storage');
371
410
  const page = pages.find(p => p.id === deleteConfirmId);
372
411
  const collectionName = page?.type === 'public' ? 'sys_pages' : 'sys_tabs';
373
- await deleteDoc(doc(db, collectionName, deleteConfirmId));
374
412
 
375
413
  if (collectionName === 'sys_tabs' && page) {
376
- const tabName = page.id;
377
- try {
378
- const collectionsClean = [
379
- `admin_${tabName}_records`,
380
- `mem_${tabName}_records`,
381
- `user_${tabName}_records`
382
- ];
414
+ // Read the tab config to derive the correct collection names
415
+ // (templates use config.tabName || config.pageId, lowercased + sanitized)
416
+ let tabConfig = draftConfigs[deleteConfirmId];
417
+ if (!tabConfig) {
418
+ try {
419
+ const tabDoc = await getDocFn(doc(db, 'sys_tabs', deleteConfirmId));
420
+ if (tabDoc.exists()) tabConfig = tabDoc.data();
421
+ } catch {}
422
+ }
423
+
424
+ const parsedTabName = ((tabConfig?.tabName || tabConfig?.pageId || deleteConfirmId) as string)
425
+ .toLowerCase().replace(/[^a-z0-9]+/g, '_');
426
+
427
+ const collectionsClean = [
428
+ `admin_${parsedTabName}_records`,
429
+ `mem_${parsedTabName}_records`,
430
+ `user_${parsedTabName}_records`
431
+ ];
383
432
 
384
- for (const col of collectionsClean) {
433
+ // Storage folder prefixes and their Drive collections
434
+ const storageMap: Record<string, { prefix: string; driveCollection: string }> = {
435
+ [`admin_${parsedTabName}_records`]: { prefix: 'admin_files', driveCollection: 'admin_files' },
436
+ [`mem_${parsedTabName}_records`]: { prefix: 'mem_files', driveCollection: 'mem_files' },
437
+ [`user_${parsedTabName}_records`]: { prefix: 'user_files', driveCollection: 'user_files' },
438
+ };
439
+
440
+ for (const col of collectionsClean) {
441
+ try {
385
442
  const recordsSnap = await getDocs(collection(db, col));
443
+ const mapping = storageMap[col];
444
+
445
+ // Delete files from Storage and Drive for file-type records
446
+ for (const d of recordsSnap.docs) {
447
+ const data = d.data();
448
+ if (data.recordType === 'file' && data.downloadURL && mapping) {
449
+ try {
450
+ const encodedPrefix = `${mapping.prefix}%2F`;
451
+ const fileName = data.downloadURL.split(encodedPrefix)[1]?.split('?')[0];
452
+ if (fileName) {
453
+ const fileRef = ref(storage, `${mapping.prefix}/${decodeURIComponent(fileName)}`);
454
+ await deleteObject(fileRef).catch(() => {});
455
+ }
456
+ // Remove matching Drive metadata doc by downloadURL
457
+ const driveSnap = await getDocs(query(
458
+ collection(db, mapping.driveCollection),
459
+ where('downloadURL', '==', data.downloadURL)
460
+ ));
461
+ for (const driveDoc of driveSnap.docs) {
462
+ await deleteDoc(driveDoc.ref);
463
+ }
464
+ } catch (fileErr) {
465
+ console.error('Error deleting storage file:', fileErr);
466
+ }
467
+ }
468
+ }
469
+
470
+ // Delete all record documents in this collection
386
471
  const deletePromises = recordsSnap.docs.map(d => deleteDoc(d.ref));
387
472
  await Promise.all(deletePromises);
473
+
474
+ // Clear localStorage cache for this collection
475
+ try { localStorage.removeItem(`fbos_records_${col}`); } catch {}
476
+ } catch (colErr) {
477
+ console.error(`Error cleaning up collection ${col}:`, colErr);
388
478
  }
389
- } catch (cleanupErr) {
390
- console.error("Error cleaning up tab records:", cleanupErr);
479
+ }
480
+
481
+ // Also clean Drive collections by sourceTab (catches any orphaned file docs)
482
+ const driveCollections = ['admin_files', 'mem_files', 'user_files'];
483
+ for (const driveColl of driveCollections) {
484
+ try {
485
+ const orphanedSnap = await getDocs(query(
486
+ collection(db, driveColl),
487
+ where('sourceTab', '==', parsedTabName)
488
+ ));
489
+ for (const orphanDoc of orphanedSnap.docs) {
490
+ const data = orphanDoc.data();
491
+ // Also delete the actual file from Storage if we haven't already
492
+ if (data.downloadURL) {
493
+ try {
494
+ const prefix = driveColl.replace('_files', '_files');
495
+ const encodedPrefix = `${prefix}%2F`;
496
+ const fName = data.downloadURL.split(encodedPrefix)[1]?.split('?')[0];
497
+ if (fName) {
498
+ const fRef = ref(storage, `${prefix}/${decodeURIComponent(fName)}`);
499
+ await deleteObject(fRef).catch(() => {});
500
+ }
501
+ } catch {}
502
+ }
503
+ await deleteDoc(orphanDoc.ref);
504
+ }
505
+ } catch {}
391
506
  }
392
507
  }
393
508
 
509
+ // Delete the tab/page document itself
510
+ await deleteDoc(doc(db, collectionName, deleteConfirmId));
511
+
394
512
  const orderDocId = collectionName === 'sys_pages' ? 'page_order' : 'tab_order';
395
513
  const currentOrderItems = collectionName === 'sys_pages' ? pageOrderItems : tabOrderItems;
396
514
  const { setDoc } = await import('firebase/firestore');
@@ -545,7 +663,7 @@ export function PagesAdmin() {
545
663
  </div>
546
664
  </div>
547
665
  </div>
548
- <div className="px-6 md:px-10 pb-6 md:pb-10 flex flex-col pt-0 gap-10 overflow-y-auto mt-2 relative min-h-[400px]">
666
+ <div ref={scrollContainerRef} className="px-6 md:px-10 pb-6 md:pb-10 flex flex-col pt-0 gap-10 overflow-y-auto mt-2 relative min-h-[400px]">
549
667
  <AnimatePresence>
550
668
  {isResettingAll && (
551
669
  <motion.div
@@ -185,7 +185,7 @@ export function StorageAdmin() {
185
185
 
186
186
  return onSnapshot(q, (snap) => {
187
187
  const newData = (snap.docs || []).map(d => ({ _id: d.id, accessPrefix: pfx as any, ...d.data() }) as StorageRecord);
188
-
188
+
189
189
  setRecords(prev => {
190
190
  // Filter out existing records for this prefix and add new ones
191
191
  const otherPrefixes = prev.filter(r => r.accessPrefix !== pfx);
@@ -381,7 +381,7 @@ export function StorageAdmin() {
381
381
  </div>
382
382
  </motion.div>
383
383
 
384
- <div
384
+ <div
385
385
  className="flex flex-col gap-6 flex-1 pb-16"
386
386
  onDragOver={handleDragOver}
387
387
  onDragLeave={handleDragLeave}
@@ -515,7 +515,7 @@ export function StorageAdmin() {
515
515
  onBlur={() => executeRename()}
516
516
  onKeyDown={e => { if (e.key === 'Enter') executeRename(); if (e.key === 'Escape') setRenameFile(null); }}
517
517
  onClick={e => e.stopPropagation()}
518
- className="text-[12.5px] font-bold text-foreground bg-transparent border-b border-accent/30 outline-none pb-0.5 w-full"
518
+ className="no-glow text-[12.5px] font-bold text-foreground bg-transparent border-b border-accent/30 outline-none pb-0.5 w-full"
519
519
  />
520
520
  ) : (
521
521
  <span className="text-[12.5px] font-bold text-foreground truncate select-none group-hover:text-accent transition-colors" title={rec.fileName}>{rec.fileName}</span>
@@ -624,7 +624,7 @@ export function StorageAdmin() {
624
624
  value={stagedName}
625
625
  onChange={e => setStagedName(e.target.value)}
626
626
  disabled={isUploading}
627
- className="w-full bg-foreground/[0.02] border border-[var(--panel-border)] rounded-xl px-4 py-3.5 text-[14px] font-bold text-foreground outline-none focus:border-accent transition-colors shadow-sm"
627
+ className="no-glow w-full bg-foreground/[0.02] border border-[var(--panel-border)] rounded-xl px-4 py-3.5 text-[14px] font-bold text-foreground outline-none focus:border-accent transition-colors shadow-sm"
628
628
  />
629
629
  </div>
630
630
 
@@ -731,7 +731,7 @@ export function StorageAdmin() {
731
731
  <ConfirmModal
732
732
  isOpen={!!confirmDelete}
733
733
  title="Confirm Deletion"
734
- message={<>Are you entirely sure you wish to delete <strong className="text-foreground truncate block my-0.5">{confirmDelete?.name}</strong> This action is permanent.</>}
734
+ message={<>Are you sure you want to delete <strong className="text-foreground block my-0.5 text-left overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{confirmDelete?.name}</strong> This action is permanent.</>}
735
735
  onConfirm={executeDelete}
736
736
  onCancel={() => setConfirmDelete(null)}
737
737
  isProcessing={isDeleting}
@@ -740,13 +740,13 @@ export function StorageAdmin() {
740
740
 
741
741
  <AnimatePresence>
742
742
  {isDragging && (
743
- <motion.div
743
+ <motion.div
744
744
  initial={{ opacity: 0 }}
745
745
  animate={{ opacity: 1 }}
746
746
  exit={{ opacity: 0 }}
747
747
  className="fixed inset-0 z-[1000] bg-background/60 backdrop-blur-md pointer-events-none flex items-center justify-center p-6"
748
748
  >
749
- <motion.div
749
+ <motion.div
750
750
  initial={{ scale: 0.9, opacity: 0 }}
751
751
  animate={{ scale: 1, opacity: 1 }}
752
752
  exit={{ scale: 0.9, opacity: 0 }}
@@ -754,7 +754,7 @@ export function StorageAdmin() {
754
754
  >
755
755
  <div className="absolute inset-0 bg-accent/5 rounded-[38px] animate-pulse" />
756
756
  <div className="w-20 h-20 rounded-full bg-accent/10 flex items-center justify-center text-accent shadow-[0_0_40px_rgba(var(--accent-rgb),0.2)]">
757
- <UploadCloud className="w-10 h-10 animate-bounce" />
757
+ <UploadCloud className="w-10 h-10 animate-bounce" />
758
758
  </div>
759
759
  <div className="flex flex-col items-center text-center gap-2 relative z-10">
760
760
  <span className="text-[24px] font-extrabold text-foreground tracking-tight">Drop files to upload</span>
@@ -607,6 +607,21 @@ export function ThemeAdmin() {
607
607
  const [copiedStatus, setCopiedStatus] = useState<boolean>(false);
608
608
  const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
609
609
 
610
+ // Persist scroll position
611
+ useEffect(() => {
612
+ try {
613
+ const saved = sessionStorage.getItem('fbos_theme_scrollY');
614
+ if (saved) {
615
+ requestAnimationFrame(() => window.scrollTo(0, parseInt(saved, 10)));
616
+ }
617
+ } catch {}
618
+ const handleScroll = () => {
619
+ try { sessionStorage.setItem('fbos_theme_scrollY', String(window.scrollY)); } catch {}
620
+ };
621
+ window.addEventListener('scroll', handleScroll, { passive: true });
622
+ return () => window.removeEventListener('scroll', handleScroll);
623
+ }, []);
624
+
610
625
  // Auto-Save: only fires when localConfig actually changes compared to
611
626
  // what we last saved. Does NOT depend on activeConfig to avoid the
612
627
  // Firestore snapshot echo loop.