diff --git a/vibes.diy/pkg/app/components/VibeCardCatalog.tsx b/vibes.diy/pkg/app/components/VibeCardCatalog.tsx new file mode 100644 index 000000000..a434673f5 --- /dev/null +++ b/vibes.diy/pkg/app/components/VibeCardCatalog.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { VibeCard } from "./VibeCard.js"; +import type { LocalVibe } from "../utils/vibeUtils.js"; +import { useVibes } from "../hooks/useVibes.js"; +import { DocFileMeta } from "use-fireproof"; + +interface VibeCardCatalogProps { + catalogVibe: LocalVibe; +} + +export function VibeCardCatalog({ catalogVibe }: VibeCardCatalogProps) { + const [confirmDelete, setConfirmDelete] = useState(null); + const navigate = useNavigate(); + const { toggleFavorite, deleteVibe } = useVibes(); + + // Debug logging for screenshot data + // console.log(`๐Ÿ› VibeCardCatalog:`, catalogVibe); + + // Navigation functions + const handleEditClick = (id: string, encodedTitle: string) => { + navigate(`/chat/${id}/${encodedTitle}/app`); + }; + + const handleRemixClick = ( + slug: string, + event: React.MouseEvent, + ) => { + event.stopPropagation(); + navigate(`/remix/${slug}`); + }; + + // Handle toggling the favorite status + const handleToggleFavorite = async (vibeId: string, e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + try { + await toggleFavorite(vibeId); + } catch (error) { + // Error handling is managed by the useVibes hook + } + }; + + // Handle deleting a vibe + const handleDeleteClick = async (vibeId: string, e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (confirmDelete === vibeId) { + try { + // Immediately set confirmDelete to null to prevent accidental clicks + setConfirmDelete(null); + // Delete the vibe + await deleteVibe(vibeId); + } catch (error) { + // Error handling is managed by the useVibes hook + } + } else { + setConfirmDelete(vibeId); + + // Prevent the global click handler from immediately clearing the confirmation + // by stopping the event from bubbling up + e.nativeEvent.stopImmediatePropagation(); + } + }; + + // Clear confirmation when clicking elsewhere + useEffect(() => { + const handlePageClick = (e: MouseEvent) => { + // Don't clear if the click originated from a delete button + if ( + confirmDelete && + !(e.target as Element).closest('button[data-action="delete"]') + ) { + setConfirmDelete(null); + } + }; + + // Use capture phase to handle document clicks before other handlers + document.addEventListener("click", handlePageClick, true); + return () => { + document.removeEventListener("click", handlePageClick, true); + }; + }, [confirmDelete]); + + return ( + + ); +} diff --git a/vibes.diy/pkg/app/components/VibeCardData.tsx b/vibes.diy/pkg/app/components/VibeCardData.tsx index eee71e8d2..98f977ea5 100644 --- a/vibes.diy/pkg/app/components/VibeCardData.tsx +++ b/vibes.diy/pkg/app/components/VibeCardData.tsx @@ -102,6 +102,7 @@ export function VibeCardData({ vibeId }: VibeCardDataProps) { const loadData = async () => { try { setIsLoading(true); + console.log(`Loading vibe document for ${vibeId}`); const vibeData = await loadVibeDocument(vibeId); if (isMounted) { setVibe(vibeData); @@ -171,6 +172,7 @@ export function VibeCardData({ vibeId }: VibeCardDataProps) { // If the vibe wasn't found (not loading and no data), return null to filter it out if (!vibe) { + console.log(`Vibe not found: ${vibeId}`); return null; } diff --git a/vibes.diy/pkg/app/components/VibespaceComponent.tsx b/vibes.diy/pkg/app/components/VibespaceComponent.tsx index ffc5e4150..5102d5bb9 100644 --- a/vibes.diy/pkg/app/components/VibespaceComponent.tsx +++ b/vibes.diy/pkg/app/components/VibespaceComponent.tsx @@ -7,7 +7,9 @@ import Wild from "./vibespace/Wild.js"; import ExplodingBrain from "./vibespace/ExplodingBrain.js"; import Cyberpunk from "./vibespace/Cyberpunk.js"; import type { ReactElement } from "react"; -import { useFireproof } from "use-fireproof"; +import { toCloud, useFireproof } from "use-fireproof"; +import { useUserSettings } from "../hooks/useUserSettings.js"; +import { useAuth } from "../contexts/AuthContext.js"; // Define the structure of our vibe documents interface VibeDocument { @@ -350,8 +352,15 @@ export default function VibespaceComponent({ return
Invalid user space
; } + // Get sync setting + const { isAuthenticated } = useAuth(); + const { isEnableSyncEnabled } = useUserSettings(); + // Use Fireproof with the user-specific database - const { useAllDocs } = useFireproof(`vu-${userId}`); + const { useAllDocs } = useFireproof( + `vu-${userId}`, + isEnableSyncEnabled && isAuthenticated ? { attach: toCloud() } : {}, + ); // Query all documents in the database const allDocsResult = useAllDocs() as { docs: VibeDocument[] }; diff --git a/vibes.diy/pkg/app/config/env.ts b/vibes.diy/pkg/app/config/env.ts index 9e79f27d0..5d87ff350 100644 --- a/vibes.diy/pkg/app/config/env.ts +++ b/vibes.diy/pkg/app/config/env.ts @@ -69,3 +69,21 @@ export const CALLAI_ENDPOINT = export const SETTINGS_DBNAME = (import.meta.env.VITE_VIBES_CHAT_HISTORY || "vibes-chats") + getVersionSuffix(); + +// Catalog Database +export const CATALOG_DBNAME = + import.meta.env.VITE_CATALOG_DBNAME || "v-catalog"; + +// Set up Fireproof debugging if in browser environment +if (typeof window !== "undefined") { + // Method 1: Using FP_ENV Symbol (direct approach) + // @ts-ignore - Setting up Fireproof debug environment + window[Symbol.for("FP_ENV")] = window[Symbol.for("FP_ENV")] || new Map(); + // @ts-ignore - Enable full Fireproof debugging + window[Symbol.for("FP_ENV")].set("FP_DEBUG", "*"); + + // Method 2: Using logger (alternative approach from Fireproof README) + // Uncomment this if you prefer using the logger method: + // import { logger } from 'use-fireproof'; + // logger.setDebug('*'); +} diff --git a/vibes.diy/pkg/app/hooks/VibeCatalog.tsx b/vibes.diy/pkg/app/hooks/VibeCatalog.tsx new file mode 100644 index 000000000..f92ceba78 --- /dev/null +++ b/vibes.diy/pkg/app/hooks/VibeCatalog.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { useCatalog } from "./useCatalog.js"; +import type { LocalVibe } from "../utils/vibeUtils.js"; + +/** + * Implementation component that contains all the hooks and logic. + * This is only rendered when userId and vibes are available. + */ +function VibeCatalogImpl({ + userId, + vibes, +}: { + userId?: string; + vibes: Array; +}) { + const { count } = useCatalog(userId, vibes); + + return ( + + Cataloging {count} vibes locally + + ); +} + +/** + * Public wrapper component that handles conditional rendering. + * Users can include this without checking for userId themselves. + */ +export function VibeCatalog({ + userId, + vibes, +}: { + userId?: string; + vibes?: Array; +}) { + if (!vibes) return null; + return ; +} diff --git a/vibes.diy/pkg/app/hooks/useCatalog.ts b/vibes.diy/pkg/app/hooks/useCatalog.ts new file mode 100644 index 000000000..4b65441c2 --- /dev/null +++ b/vibes.diy/pkg/app/hooks/useCatalog.ts @@ -0,0 +1,517 @@ +import { useFireproof, fireproof, toCloud } from "use-fireproof"; +import { useCallback, useEffect, useMemo } from "react"; +import type { LocalVibe } from "../utils/vibeUtils.js"; +import type { VibeDocument, ScreenshotDocument } from "../types/chat.js"; +import { getCatalogDbName, createCatalogDocId } from "../utils/catalogUtils.js"; + +// Helper function to get vibe document from session database +async function getVibeDocument(vibeId: string): Promise { + try { + const dbName = `vibe-${vibeId}`; + const sessionDb = fireproof(dbName); + await new Promise((resolve) => setTimeout(resolve, 50)); + const vibeDoc = (await sessionDb + .get("vibe") + .catch(() => null)) as VibeDocument | null; + + console.log(`๐Ÿ› getVibeDocument for ${vibeId}:`, { + dbName, + hasVibeDoc: !!vibeDoc, + vibeDocKeys: vibeDoc ? Object.keys(vibeDoc) : null, + vibeDocTitle: vibeDoc?.title, + }); + + return vibeDoc; + } catch (error) { + console.error(`Failed to get vibe document for ${vibeId}:`, error); + return null; + } +} + +// Helper function to get latest screenshot from session database +async function getLatestScreenshot( + vibeId: string, +): Promise { + try { + const dbName = `vibe-${vibeId}`; + const sessionDb = fireproof(dbName); + const sessionResult = await sessionDb.query("type", { + key: "screenshot", + includeDocs: true, + descending: true, + limit: 1, + }); + + console.log(`๐Ÿ› getLatestScreenshot for ${vibeId}:`, { + dbName, + queryResultsCount: sessionResult.rows.length, + queryResults: sessionResult.rows.map((row) => ({ + id: row.id, + key: row.key, + hasDoc: !!row.doc, + docType: (row.doc as ScreenshotDocument)?.type, + docCid: (row.doc as ScreenshotDocument)?.cid, + hasFiles: !!row.doc?._files, + filesKeys: row.doc?._files ? Object.keys(row.doc._files) : null, + hasScreenshotFile: !!row.doc?._files?.screenshot, + })), + }); + + if (sessionResult.rows.length > 0) { + const screenshot = sessionResult.rows[0].doc as ScreenshotDocument; + console.log(`๐Ÿ› Screenshot doc structure for ${vibeId}:`, { + docKeys: Object.keys(screenshot), + hasFiles: !!screenshot._files, + hasCid: !!screenshot.cid, + }); + + // Log the full file structure to find where CID is stored + console.log( + `๐Ÿ› File structure for ${vibeId}:`, + screenshot._files?.screenshot, + ); + + // Extract CID from file metadata + const fileCid = screenshot._files?.screenshot + ? (screenshot._files.screenshot as any)?.cid + : null; + console.log(`๐Ÿ› File CID for ${vibeId}:`, fileCid); + + if (screenshot._files?.screenshot && fileCid) { + // Add the file CID to the screenshot document for deduplication + screenshot.cid = fileCid.toString(); + return screenshot; + } + } + return null; + } catch (error) { + console.error(`Failed to get screenshot for ${vibeId}:`, error); + return null; + } +} + +// Helper function to create new catalog document +function createCatalogDocument( + vibe: LocalVibe, + vibeDoc: VibeDocument | null, + userId: string, +): any { + return { + _id: createCatalogDocId(vibe.id), + created: vibeDoc?.created_at || Date.now(), + userId, + vibeId: vibe.id, + title: vibeDoc?.title || "Untitled", + url: vibeDoc?.publishedUrl, + }; +} + +// Helper function to update existing catalog document +function updateCatalogDocument( + catalogDoc: any, + vibeDoc: VibeDocument | null, +): any { + const docToUpdate = { ...catalogDoc }; + + if (vibeDoc) { + docToUpdate.title = vibeDoc.title || "Untitled"; + docToUpdate.url = vibeDoc.publishedUrl; + docToUpdate.created = + vibeDoc.created_at || docToUpdate.created || Date.now(); + } + + return docToUpdate; +} + +// Helper function to add screenshot to catalog document using Uint8Array storage +async function addScreenshotToCatalogDoc( + docToUpdate: any, + sessionScreenshotDoc: ScreenshotDocument, +): Promise { + try { + // GET: Extract binary data from source database + const sourceFile = sessionScreenshotDoc._files!.screenshot; + const fileData = await (sourceFile as any).file(); + + console.log("๐Ÿ› Extracting screenshot data:", { + size: fileData.size, + type: fileData.type, + }); + + // Convert File to Uint8Array for direct storage + const arrayBuffer = await fileData.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + // Store as Uint8Array data directly in document (bypasses broken _files system) + docToUpdate.screenshot = { + data: uint8Array, + size: fileData.size, + type: fileData.type || "image/png", + }; + + docToUpdate.screenshotCid = sessionScreenshotDoc.cid; + docToUpdate.lastUpdated = Date.now(); + + console.log("๐Ÿ› Screenshot stored as Uint8Array:", { + dataLength: uint8Array.length, + size: fileData.size, + type: docToUpdate.screenshot.type, + }); + } catch (error) { + console.error("๐Ÿ› Screenshot storage failed:", error); + throw error; + } +} + +// Helper function to create File from stored Uint8Array data +function createFileFromUint8Array( + data: Uint8Array | any, + size: number, + type: string, +): File { + // Convert back to Uint8Array if it was serialized as an object + const uint8Array = + data instanceof Uint8Array ? data : new Uint8Array(Object.values(data)); + + console.log("๐Ÿ› createFileFromUint8Array:", { + originalDataType: typeof data, + originalDataConstructor: data.constructor.name, + isOriginalUint8Array: data instanceof Uint8Array, + convertedLength: uint8Array.length, + expectedSize: size, + lengthMatchesSize: uint8Array.length === size, + type, + first10Bytes: Array.from(uint8Array.slice(0, 10)), + }); + + const file = new File([uint8Array], "screenshot.png", { + type, + lastModified: Date.now(), + }); + + console.log("๐Ÿ› Created File:", { + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified, + sizeMatches: file.size === size, + }); + + return file; +} + +// Helper function to filter valid catalog documents +function filterValidCatalogDocs(docs: Array): Array { + return docs.filter((doc) => { + return ( + doc._id?.startsWith("catalog-") && doc.vibeId && doc.vibeId.length > 10 + ); + }); +} + +// Helper function to transform catalog document to LocalVibe format +function transformToLocalVibe(doc: any): LocalVibe { + // Debug logging for screenshot transformation + console.log(`๐Ÿ› transformToLocalVibe[${doc.vibeId}]:`, { + hasScreenshotData: !!doc.screenshot?.data, + screenshotSize: doc.screenshot?.size, + screenshotType: doc.screenshot?.type, + docKeys: Object.keys(doc), + }); + + // Create screenshot interface from Uint8Array data + let screenshot: { file: () => Promise; type: string } | undefined; + + if (doc.screenshot?.data && doc.screenshot?.size && doc.screenshot?.type) { + screenshot = { + file: () => + Promise.resolve( + createFileFromUint8Array( + doc.screenshot.data, + doc.screenshot.size, + doc.screenshot.type, + ), + ), + type: doc.screenshot.type, + }; + } + + return { + id: doc.vibeId, + title: doc.title, + encodedTitle: doc.title?.toLowerCase().replace(/\s+/g, "-") || "", + slug: doc.vibeId, + created: new Date(doc.created).toISOString(), + favorite: false, + publishedUrl: doc.url, + screenshot, + }; +} + +export function useCatalog( + userId: string | undefined, + vibes: Array, + sync: boolean = false, +) { + userId = userId || "local"; + + const dbName = getCatalogDbName(userId); + + const { database, useAllDocs } = useFireproof( + dbName, + sync && userId && userId !== "local" ? { attach: toCloud() } : {}, + ); + + // Get real-time count of cataloged vibes + const allDocsResult = useAllDocs() as { + docs: Array<{ _id: string }>; + rows?: Array<{ id: string }>; + }; + const count = allDocsResult?.rows?.length || 0; + + // Create a stable key based on vibe IDs to prevent unnecessary re-cataloging + const vibeKey = useMemo(() => { + return vibes + .map((v) => v.id) + .sort() + .join(","); + }, [vibes]); + + useEffect(() => { + if (!vibes || vibes.length === 0) return; + + let cancelled = false; + + const catalog = async () => { + // Wait 2000ms to allow database to be fully initialized after page load + await new Promise((resolve) => setTimeout(resolve, 500)); + if (cancelled) return; + + console.log(`๐Ÿ“‹ Catalog - ${vibes.length} vibes from useVibes/idb`); + + // Get all already cataloged vibe IDs using fireproof 0.23.0 API + const allDocsResult = await database.allDocs({ includeDocs: true }); + if (cancelled) return; + + console.log( + `๐Ÿ“‹ Catalog - ${allDocsResult.rows.length} already in "${database.name}"`, + ); + + const catalogedVibeIds = new Set( + allDocsResult.rows + .map((row) => row.key) + .filter((key) => key.startsWith("catalog-")) + .map((key) => key.replace("catalog-", "")), + ); + + // Pre-compute which vibes need screenshot updates by checking CIDs + const vibesNeedingUpdates = []; + + // Check all vibes for both new catalog entries and screenshot updates + for (const vibe of vibes) { + if (cancelled) break; + + try { + const catalogDocId = createCatalogDocId(vibe.id); + const catalogDoc = await database.get(catalogDocId).catch(() => null); + const isNewCatalogEntry = !catalogedVibeIds.has(vibe.id); + + // Get vibe document and latest screenshot using helper functions + const vibeDoc = await getVibeDocument(vibe.id); + + // Skip vibes without valid vibe documents + if (!vibeDoc) { + continue; + } + + const sessionScreenshotDoc = await getLatestScreenshot(vibe.id); + + console.log(`๐Ÿ› Screenshot check for ${vibe.id}:`, { + hasVibeDoc: !!vibeDoc, + hasSessionScreenshot: !!sessionScreenshotDoc, + sessionScreenshotCid: sessionScreenshotDoc?.cid, + sessionScreenshotFiles: sessionScreenshotDoc?._files + ? Object.keys(sessionScreenshotDoc._files) + : null, + catalogCurrentCid: (catalogDoc as any)?.screenshotCid, + }); + + // Check if screenshot needs updating + let needsScreenshotUpdate = false; + if (sessionScreenshotDoc) { + const catalogCurrentCid = (catalogDoc as any)?.screenshotCid; + needsScreenshotUpdate = + catalogCurrentCid !== sessionScreenshotDoc.cid; + } + + // Force update all entries to populate missing titles + const needsTitleUpdate = + !(catalogDoc as any)?.title || + (catalogDoc as any)?.title === undefined; + if (isNewCatalogEntry || needsScreenshotUpdate || needsTitleUpdate) { + vibesNeedingUpdates.push({ + vibe, + vibeDoc, + catalogDoc, + sessionScreenshotDoc, + isNewCatalogEntry, + needsScreenshotUpdate, + }); + } + } catch (error) { + console.error(`Failed to check vibe ${vibe.id}:`, error); + } + } + + // Prepare documents for single bulk operation + const docsToBulkUpdate = []; + + for (const { + vibe, + vibeDoc, + catalogDoc, + sessionScreenshotDoc, + isNewCatalogEntry, + needsScreenshotUpdate, + } of vibesNeedingUpdates) { + if (cancelled) break; + + try { + let docToUpdate: any; + + if (isNewCatalogEntry) { + docToUpdate = createCatalogDocument(vibe, vibeDoc, userId); + } else { + docToUpdate = updateCatalogDocument(catalogDoc, vibeDoc); + } + + // Add screenshot if needed + if (needsScreenshotUpdate && sessionScreenshotDoc) { + console.log(`๐Ÿ› Before addScreenshotToCatalogDoc for ${vibe.id}:`, { + sessionScreenshotCid: sessionScreenshotDoc.cid, + hasSessionFiles: !!sessionScreenshotDoc._files, + sessionScreenshotKeys: sessionScreenshotDoc._files + ? Object.keys(sessionScreenshotDoc._files) + : [], + }); + await addScreenshotToCatalogDoc(docToUpdate, sessionScreenshotDoc); + console.log(`๐Ÿ› After addScreenshotToCatalogDoc for ${vibe.id}:`, { + docHasFiles: !!docToUpdate._files, + docFilesKeys: docToUpdate._files + ? Object.keys(docToUpdate._files) + : [], + docScreenshotCid: docToUpdate.screenshotCid, + }); + console.log( + `๐Ÿ“ธ Preparing catalog screenshot update for vibe ${vibe.id} (CID: ${sessionScreenshotDoc.cid})`, + ); + } + + docsToBulkUpdate.push(docToUpdate); + } catch (error) { + console.error(`Failed to prepare update for vibe ${vibe.id}:`, error); + } + } + + // Do one big bulk operation + if (docsToBulkUpdate.length > 0 && !cancelled) { + await database.bulk(docsToBulkUpdate); + const screenshotUpdates = docsToBulkUpdate.filter( + (doc) => doc.screenshotCid, + ).length; + console.log( + `๐Ÿ“‹ Bulk updated ${docsToBulkUpdate.length} catalog documents (${screenshotUpdates} with screenshots)`, + ); + } + + // Get final count after processing + if (cancelled) return; + const finalDocsResult = await database.allDocs({ includeDocs: true }); + console.log( + `๐Ÿ“‹ Finished catalog - ${finalDocsResult.rows.length} total cataloged in allDocs (updated ${docsToBulkUpdate.length})`, + ); + }; + + catalog().catch((error) => { + console.error("โŒ Catalog failed:", error); + }); + return () => { + cancelled = true; + }; + }, [userId, vibeKey, database]); // Use vibeKey instead of vibes array + + // Add screenshot and source to catalog document + const addCatalogScreenshot = useCallback( + async ( + vibeId: string, + screenshotData: string | null, + sourceCode?: string, + ) => { + if (!vibeId) return; + + try { + const docId = createCatalogDocId(vibeId); + + // Get existing catalog document + const existingDoc = await database.get(docId).catch(() => null); + if (!existingDoc) { + console.warn("No catalog document found for vibe:", vibeId); + return; + } + + const updatedFiles: any = { ...existingDoc._files }; + + // Add screenshot if provided + if (screenshotData) { + const response = await fetch(screenshotData); + const blob = await response.blob(); + const screenshotFile = new File([blob], "screenshot.png", { + type: "image/png", + lastModified: Date.now(), + }); + updatedFiles.screenshot = screenshotFile; + } + + // Add source code if provided + if (sourceCode) { + const sourceFile = new File([sourceCode], "App.jsx", { + type: "text/javascript", + lastModified: Date.now(), + }); + updatedFiles.source = sourceFile; + } + + // Update catalog document with files + const updatedDoc = { + ...existingDoc, + _files: updatedFiles, + lastUpdated: Date.now(), + }; + + await database.put(updatedDoc); + } catch (error) { + console.error( + "Failed to update catalog with screenshot/source:", + error, + ); + } + }, + [database], + ); + + // Get catalog documents for display + const { docs: catalogDocs } = useAllDocs() as { + docs: Array; + }; + + // Transform catalog documents to LocalVibe format for compatibility + const catalogVibes = useMemo(() => { + return filterValidCatalogDocs(catalogDocs) + .map(transformToLocalVibe) + .sort( + (a, b) => new Date(b.created).getTime() - new Date(a.created).getTime(), + ); + }, [catalogDocs]); + + return { count, addCatalogScreenshot, catalogVibes }; +} diff --git a/vibes.diy/pkg/app/hooks/useSession.ts b/vibes.diy/pkg/app/hooks/useSession.ts index f192ed70a..30c13c4c1 100644 --- a/vibes.diy/pkg/app/hooks/useSession.ts +++ b/vibes.diy/pkg/app/hooks/useSession.ts @@ -1,4 +1,5 @@ import { useCallback, useState, useEffect, useRef, useMemo } from "react"; +import { useLocation } from "react-router"; import type { AiChatMessageDocument, UserChatMessageDocument, @@ -6,12 +7,15 @@ import type { ChatMessageDocument, } from "../types/chat.js"; import { getSessionDatabaseName } from "../utils/databaseManager.js"; -import { useFireproof } from "use-fireproof"; +import { toCloud, useFireproof } from "use-fireproof"; import { encodeTitle } from "../components/SessionSidebar/utils.js"; import { CATALOG_DEPENDENCY_NAMES, llmsCatalog } from "../llms/catalog.js"; import { resolveEffectiveModel, normalizeModelId } from "../prompts.js"; import { SETTINGS_DBNAME } from "../config/env.js"; import type { UserSettings } from "../types/settings.js"; +import { generateCid } from "../utils/cidUtils.js"; +import { useAuth } from "../contexts/AuthContext.js"; +import { useUserSettings } from "./useUserSettings.js"; export function useSession(routedSessionId?: string) { const [generatedSessionId] = useState( @@ -28,17 +32,30 @@ export function useSession(routedSessionId?: string) { // Update effectiveSessionId whenever routedSessionId changes useEffect(() => { if (routedSessionId) { + console.log("useSession: routedSessionId changed to", routedSessionId); setEffectiveSessionId(routedSessionId); } }, [routedSessionId]); const sessionId = effectiveSessionId; const sessionDbName = getSessionDatabaseName(sessionId); + const { isAuthenticated } = useAuth(); + const { isEnableSyncEnabled } = useUserSettings(); + const location = useLocation(); + + // Only attach toCloud() when we're actually on a chat route (not just home page with session) + const isOnChatRoute = location?.pathname?.startsWith("/chat/") || false; + const { database: sessionDatabase, useDocument: useSessionDocument, useLiveQuery: useSessionLiveQuery, - } = useFireproof(sessionDbName); + } = useFireproof( + sessionDbName, + isEnableSyncEnabled && isAuthenticated && routedSessionId && isOnChatRoute + ? { attach: toCloud() } + : {}, + ); // User message is stored in the session-specific database const { @@ -234,6 +251,9 @@ export function useSession(routedSessionId?: string) { if (!sessionId || !screenshotData) return; try { + // Generate CID for the screenshot + const cid = await generateCid(screenshotData); + const response = await fetch(screenshotData); const blob = await response.blob(); const file = new File([blob], "screenshot.png", { @@ -243,6 +263,7 @@ export function useSession(routedSessionId?: string) { const screenshot = { type: "screenshot", session_id: sessionId, + cid, // Store CID for deduplication _files: { screenshot: file, }, diff --git a/vibes.diy/pkg/app/hooks/useSimpleChat.ts b/vibes.diy/pkg/app/hooks/useSimpleChat.ts index 7dbbc411b..e5a62c4ff 100644 --- a/vibes.diy/pkg/app/hooks/useSimpleChat.ts +++ b/vibes.diy/pkg/app/hooks/useSimpleChat.ts @@ -7,7 +7,7 @@ import type { } from "../types/chat.js"; import type { UserSettings } from "../types/settings.js"; -import { useFireproof } from "use-fireproof"; +import { toCloud, useFireproof } from "use-fireproof"; import { SETTINGS_DBNAME } from "../config/env.js"; import { saveErrorAsSystemMessage } from "./saveErrorAsSystemMessage.js"; import { useApiKey } from "./useApiKey.js"; @@ -18,6 +18,7 @@ import { useRuntimeErrors, } from "./useRuntimeErrors.js"; import { useSession } from "./useSession.js"; +import { useUserSettings } from "./useUserSettings.js"; import { useMessageSelection } from "./useMessageSelection.js"; // Import our custom hooks @@ -66,8 +67,14 @@ export function useSimpleChat(sessionId: string | undefined): ChatState { updateSelectedModel, } = useSession(sessionId); + // Get user settings including sync preference + const { isEnableSyncEnabled } = useUserSettings(); + // Get main database directly for settings document - const { useDocument } = useFireproof(SETTINGS_DBNAME); + const { useDocument } = useFireproof( + SETTINGS_DBNAME, + isEnableSyncEnabled && isAuthenticated ? { attach: toCloud() } : {}, + ); // Function to save errors as system messages to the session database const saveErrorAsSystemMessageCb = useCallback( diff --git a/vibes.diy/pkg/app/hooks/useUserSettings.ts b/vibes.diy/pkg/app/hooks/useUserSettings.ts new file mode 100644 index 000000000..7657f7358 --- /dev/null +++ b/vibes.diy/pkg/app/hooks/useUserSettings.ts @@ -0,0 +1,32 @@ +import { useFireproof, toCloud } from "use-fireproof"; +import { SETTINGS_DBNAME } from "../config/env.js"; +import { useAuth } from "../contexts/AuthContext.js"; +import type { UserSettings } from "../types/settings.js"; +import { getSyncPreference } from "../utils/syncPreference.js"; + +/** + * Hook to access user settings from anywhere in the app + * Sync preference is stored in localStorage to avoid circular dependency + */ +export function useUserSettings() { + const { isAuthenticated } = useAuth(); + const isEnableSyncEnabled = getSyncPreference(); + + // Only sync settings when sync is enabled and user is authenticated + const { useDocument } = useFireproof( + SETTINGS_DBNAME, + isEnableSyncEnabled && isAuthenticated ? { attach: toCloud() } : {}, + ); + + const { doc: settings } = useDocument({ + _id: "user_settings", + stylePrompt: "", + userPrompt: "", + model: "", + }); + + return { + settings, + isEnableSyncEnabled, + }; +} diff --git a/vibes.diy/pkg/app/routes/mine.tsx b/vibes.diy/pkg/app/routes/mine.tsx index 894552efa..90ca46db8 100644 --- a/vibes.diy/pkg/app/routes/mine.tsx +++ b/vibes.diy/pkg/app/routes/mine.tsx @@ -4,11 +4,13 @@ import { useNavigate } from "react-router-dom"; import { StarIcon } from "../components/SessionSidebar/StarIcon.js"; import { EditIcon } from "../components/ChatHeaderIcons.js"; import SimpleAppLayout from "../components/SimpleAppLayout.js"; -import { VibeCardData } from "../components/VibeCardData.js"; +import { VibeCardCatalog } from "../components/VibeCardCatalog.js"; import VibesDIYLogo from "../components/VibesDIYLogo.js"; import { useAuth } from "../contexts/AuthContext.js"; -import { useSession } from "../hooks/useSession.js"; import { useVibes } from "../hooks/useVibes.js"; +import { useCatalog } from "../hooks/useCatalog.js"; +import { useUserSettings } from "../hooks/useUserSettings.js"; +// import { VibeCatalog } from '../hooks/VibeCatalog'; export function meta() { return [ @@ -19,8 +21,6 @@ export function meta() { export default function MyVibesRoute(): ReactElement { const navigate = useNavigate(); - // We need to call useSession() to maintain context but don't need its values yet - useSession(); // Use the new hook and get userId from payload const { userPayload } = useAuth(); @@ -28,15 +28,28 @@ export default function MyVibesRoute(): ReactElement { // Use our custom hook for vibes state management const { vibes, isLoading } = useVibes(); + const { isEnableSyncEnabled } = useUserSettings(); + const { catalogVibes } = useCatalog(userId || "", vibes, isEnableSyncEnabled); + + // Use catalog vibes if available, fallback to useVibes + const displayVibes = + catalogVibes.length > 0 + ? catalogVibes + : [ + { + publishedUrl: " ", + title: `Loading ${vibes.length} vibes...`, + }, + ]; const [showOnlyFavorites, setShowOnlyFavorites] = useState(false); // Filter vibes based on the showOnlyFavorites toggle const filteredVibes = useMemo(() => { if (showOnlyFavorites) { - return vibes.filter((vibe) => vibe.favorite); + return displayVibes.filter((vibe) => "favorite" in vibe && vibe.favorite); } - return vibes; - }, [vibes, showOnlyFavorites]); + return displayVibes; + }, [displayVibes, showOnlyFavorites]); // Simple state for how many vibes to show const [itemsToShow, setItemsToShow] = useState(9); @@ -100,6 +113,7 @@ export default function MyVibesRoute(): ReactElement {
+ {/* */}

My Vibes

{userId && (

@@ -158,9 +172,18 @@ export default function MyVibesRoute(): ReactElement { ) : (

{/* Render vibes with simple slicing */} - {filteredVibes.slice(0, itemsToShow).map((vibe) => ( - - ))} + {filteredVibes.slice(0, itemsToShow).map((vibe, index) => + "id" in vibe ? ( + + ) : ( +
+

{vibe.title}

+
+ ), + )} {/* Invisible loading trigger for infinite scroll */} {itemsToShow < filteredVibes.length && ( diff --git a/vibes.diy/pkg/app/routes/settings.tsx b/vibes.diy/pkg/app/routes/settings.tsx index 96abf7f69..0d00bf824 100644 --- a/vibes.diy/pkg/app/routes/settings.tsx +++ b/vibes.diy/pkg/app/routes/settings.tsx @@ -1,13 +1,17 @@ import type { ChangeEvent } from "react"; import React, { useCallback, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useFireproof } from "use-fireproof"; +import { toCloud, useFireproof } from "use-fireproof"; import { HomeIcon } from "../components/SessionSidebar/HomeIcon.js"; import SimpleAppLayout from "../components/SimpleAppLayout.js"; import { SETTINGS_DBNAME } from "../config/env.js"; import { useAuth } from "../contexts/AuthContext.js"; import modelsList from "../data/models.json" with { type: "json" }; import type { UserSettings } from "../types/settings.js"; +import { + getSyncPreference, + setSyncPreference, +} from "../utils/syncPreference.js"; // Dependency chooser moved to perโ€‘vibe App Settings view export function meta() { @@ -20,9 +24,16 @@ export function meta() { export default function Settings() { const navigate = useNavigate(); // Use the main database directly instead of through useSession - const { useDocument } = useFireproof(SETTINGS_DBNAME); const { isAuthenticated, checkAuthStatus } = useAuth(); + // Sync preference from localStorage + const [enableSync, setEnableSync] = useState(() => getSyncPreference()); + + const { useDocument } = useFireproof( + SETTINGS_DBNAME, + enableSync && isAuthenticated ? { attach: toCloud() } : {}, + ); + const { doc: settings, merge: mergeSettings, @@ -121,6 +132,17 @@ Secretly name this theme โ€œViridian Pulseโ€, capturing Sterlingโ€™s original p [mergeSettings], ); + const handleEnableSyncChange = useCallback( + (e: ChangeEvent) => { + const isEnabled = e.target.checked; + setEnableSync(isEnabled); + setSyncPreference(isEnabled); + // Force a page reload to reinitialize databases with new sync setting + window.location.reload(); + }, + [], + ); + const handleSubmit = useCallback(async () => { setSaveError(null); setSaveSuccess(false); @@ -318,6 +340,27 @@ Secretly name this theme โ€œViridian Pulseโ€, capturing Sterlingโ€™s original p />
+ + {isAuthenticated && ( +
+

Cloud Sync

+

+ Enable cloud synchronization for your vibes and chat data +

+ + +
+ )}
diff --git a/vibes.diy/pkg/app/types/chat.ts b/vibes.diy/pkg/app/types/chat.ts index 5fe60de8f..f510f05f1 100644 --- a/vibes.diy/pkg/app/types/chat.ts +++ b/vibes.diy/pkg/app/types/chat.ts @@ -49,6 +49,14 @@ export interface VibeDocument { * When undefined, use LLM decision. */ demoDataOverride?: boolean; + /** + * Screenshot and source code file attachments for catalog storage. + * Stored in the catalog database for persistent vibe screenshots and source. + */ + _files?: { + screenshot: File; + source: File; + }; } // ===== Content Segment Types ===== @@ -100,8 +108,9 @@ export interface DocBase { export interface ScreenshotDocument extends DocBase { type: "screenshot"; session_id: string; + cid?: string; // Content identifier for deduplication _files?: { - screenshot: { file: () => Promise; type: string }; + screenshot: File; }; } diff --git a/vibes.diy/pkg/app/utils/auth.ts b/vibes.diy/pkg/app/utils/auth.ts index bb88f8bf0..33773070d 100644 --- a/vibes.diy/pkg/app/utils/auth.ts +++ b/vibes.diy/pkg/app/utils/auth.ts @@ -160,7 +160,7 @@ export async function pollForAuthToken( mock: { fetch: typeof fetch; toast: { success: (s: string) => void }; - } = { fetch, toast }, + } = { fetch: window.fetch.bind(window), toast }, ): Promise { const endpoint = `${CONNECT_API_URL}/token/${resultId}`; const start = Date.now(); @@ -262,7 +262,7 @@ export async function verifyToken( */ export async function extendToken( currentToken: string, - mock = { fetch }, + mock = { fetch: window.fetch.bind(window) }, ): Promise { try { const endpoint = CONNECT_API_URL; diff --git a/vibes.diy/pkg/app/utils/catalogUtils.ts b/vibes.diy/pkg/app/utils/catalogUtils.ts new file mode 100644 index 000000000..c31b7f392 --- /dev/null +++ b/vibes.diy/pkg/app/utils/catalogUtils.ts @@ -0,0 +1,97 @@ +/** + * Shared utilities for catalog operations + * Used by both useCatalog hook and standalone functions like publishUtils + */ + +import { fireproof } from "use-fireproof"; +import { CATALOG_DBNAME } from "../config/env.js"; + +/** + * Get the standardized catalog database name for a user + * @param userId The user ID + * @returns The catalog database name + */ +export function getCatalogDbName(userId: string): string { + const cleanUserId = userId || "local"; + return `${CATALOG_DBNAME}-${cleanUserId}`; +} + +/** + * Create a catalog document ID for a vibe + * @param vibeId The vibe/session ID + * @returns The catalog document ID + */ +export function createCatalogDocId(vibeId: string): string { + return `catalog-${vibeId}`; +} + +/** + * Get a catalog database instance for a user + * @param userId The user ID + * @returns The Fireproof database instance + */ +export function getCatalogDatabase(userId: string) { + return fireproof(getCatalogDbName(userId)); +} + +/** + * Standalone function to add screenshot and source code to catalog document + * This can be used outside of React components (e.g., in publishUtils) + * @param userId The user ID + * @param vibeId The vibe/session ID + * @param screenshotData Screenshot data URL or null + * @param sourceCode Source code string (optional) + */ +export async function addCatalogScreenshotStandalone( + userId: string, + vibeId: string, + screenshotData: string | null, + sourceCode?: string, +): Promise { + if (!vibeId) return; + + try { + const database = getCatalogDatabase(userId); + const docId = createCatalogDocId(vibeId); + + // Get existing catalog document + const existingDoc = await database.get(docId).catch(() => null); + if (!existingDoc) { + console.warn("No catalog document found for vibe:", vibeId); + return; + } + + const updatedFiles: any = { ...existingDoc._files }; + + // Add screenshot if provided + if (screenshotData) { + const response = await fetch(screenshotData); + const blob = await response.blob(); + const screenshotFile = new File([blob], "screenshot.png", { + type: "image/png", + lastModified: Date.now(), + }); + updatedFiles.screenshot = screenshotFile; + } + + // Add source code if provided + if (sourceCode) { + const sourceFile = new File([sourceCode], "App.jsx", { + type: "text/javascript", + lastModified: Date.now(), + }); + updatedFiles.source = sourceFile; + } + + // Update catalog document with files + const updatedDoc = { + ...existingDoc, + _files: updatedFiles, + lastUpdated: Date.now(), + }; + + await database.put(updatedDoc); + } catch (error) { + console.error("Failed to update catalog with screenshot/source:", error); + } +} diff --git a/vibes.diy/pkg/app/utils/cidUtils.ts b/vibes.diy/pkg/app/utils/cidUtils.ts new file mode 100644 index 000000000..d8b38b206 --- /dev/null +++ b/vibes.diy/pkg/app/utils/cidUtils.ts @@ -0,0 +1,45 @@ +/** + * Utilities for generating content identifiers (CIDs) for deduplication + */ + +/** + * Generate a simple content identifier from a data URL + * Uses a hash of the base64 content for fast comparison + */ +export async function generateCid(dataUrl: string): Promise { + // Extract base64 content from data URL + const base64Content = dataUrl.split(",")[1]; + if (!base64Content) { + throw new Error("Invalid data URL format"); + } + + // Convert to Uint8Array for hashing + const binaryString = atob(base64Content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Generate SHA-256 hash + const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return `sha256-${hashHex}`; +} + +/** + * Generate CID from a File object + */ +export async function generateCidFromFile(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return `sha256-${hashHex}`; +} diff --git a/vibes.diy/pkg/app/utils/publishUtils.ts b/vibes.diy/pkg/app/utils/publishUtils.ts index 64ddb3ee0..5f5c1d8d1 100644 --- a/vibes.diy/pkg/app/utils/publishUtils.ts +++ b/vibes.diy/pkg/app/utils/publishUtils.ts @@ -10,6 +10,7 @@ import { } from "./databaseManager.js"; import { normalizeComponentExports } from "./normalizeComponentExports.js"; import { VibeDocument } from "../types/chat.js"; +import { addCatalogScreenshotStandalone } from "./catalogUtils.js"; /** * Publish an app to the server @@ -183,6 +184,43 @@ export async function publishApp({ await updateFirehoseShared(shareToFirehose); } + // Store screenshot in catalog database for this published vibe + if (sessionId && userId) { + try { + // Get the most recent screenshot from session database + if (result.rows.length > 0) { + const screenshotDoc = result.rows[0].doc as any; + if (screenshotDoc._files && screenshotDoc._files.screenshot) { + try { + // Get the File and convert to data URL + const screenshotFile = + await screenshotDoc._files.screenshot.file(); + const screenshotDataUrl = await new Promise( + (resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(screenshotFile); + }, + ); + + // Use shared catalog utility function + await addCatalogScreenshotStandalone( + userId, + sessionId, + screenshotDataUrl, + transformedCode, + ); + } catch (err) { + console.error("Failed to store catalog screenshot:", err); + } + } + } + } catch (error) { + console.error("Failed to update catalog:", error); + } + } + return appUrl; } diff --git a/vibes.diy/pkg/app/utils/syncPreference.ts b/vibes.diy/pkg/app/utils/syncPreference.ts new file mode 100644 index 000000000..d81ce46b6 --- /dev/null +++ b/vibes.diy/pkg/app/utils/syncPreference.ts @@ -0,0 +1,25 @@ +/** + * Utilities for managing sync preference in localStorage + * This avoids the circular dependency of needing to sync settings to read sync preference + */ + +const SYNC_PREFERENCE_KEY = "vibes_enable_sync"; + +export function getSyncPreference(): boolean { + if (typeof window === "undefined") return false; // SSR safety + try { + const stored = localStorage.getItem(SYNC_PREFERENCE_KEY); + return stored === "true"; + } catch { + return false; // Default to false if localStorage fails + } +} + +export function setSyncPreference(enabled: boolean): void { + if (typeof window === "undefined") return; // SSR safety + try { + localStorage.setItem(SYNC_PREFERENCE_KEY, enabled.toString()); + } catch { + // Fail silently if localStorage is not available + } +} diff --git a/vibes.diy/tests/ChatInterface.test.tsx b/vibes.diy/tests/ChatInterface.test.tsx index 4efe39f93..38c574c6c 100644 --- a/vibes.diy/tests/ChatInterface.test.tsx +++ b/vibes.diy/tests/ChatInterface.test.tsx @@ -16,6 +16,7 @@ vi.mock("use-fireproof", () => ({ database: {}, useLiveQuery: () => ({ docs: [] }), }), + toCloud: vi.fn().mockReturnValue({}), })); // Prepare mock data diff --git a/vibes.diy/tests/VibespaceComponent.test.tsx b/vibes.diy/tests/VibespaceComponent.test.tsx index cca288d4c..946ea53b3 100644 --- a/vibes.diy/tests/VibespaceComponent.test.tsx +++ b/vibes.diy/tests/VibespaceComponent.test.tsx @@ -8,6 +8,26 @@ import VibespaceComponent from "~/vibes.diy/app/components/VibespaceComponent.js vi.mock("use-fireproof", () => ({ useFireproof: vi.fn(() => ({ useAllDocs: vi.fn(() => ({ docs: [] })), + useDocument: vi.fn(() => ({ + doc: { _id: "user_settings", stylePrompt: "", userPrompt: "", model: "" }, + merge: vi.fn(), + save: vi.fn(), + })), + })), + toCloud: vi.fn().mockReturnValue({}), +})); + +// Mock the AuthContext +vi.mock("~/vibes.diy/app/contexts/AuthContext.js", () => ({ + useAuth: vi.fn(() => ({ + token: null, + isAuthenticated: false, + isLoading: false, + userPayload: null, + needsLogin: false, + setNeedsLogin: vi.fn(), + checkAuthStatus: vi.fn().mockResolvedValue(undefined), + processToken: vi.fn().mockResolvedValue(undefined), })), })); diff --git a/vibes.diy/tests/__mocks__/use-fireproof.ts b/vibes.diy/tests/__mocks__/use-fireproof.ts index 2293c1d5c..5bf7e5d40 100644 --- a/vibes.diy/tests/__mocks__/use-fireproof.ts +++ b/vibes.diy/tests/__mocks__/use-fireproof.ts @@ -48,6 +48,9 @@ const getQueryResult = (queryType: string) => { // Mock the fireproof function - this is imported directly in databaseManager.ts const fireproof = vi.fn().mockImplementation(() => mockDb); +// Mock toCloud function +const toCloud = vi.fn().mockReturnValue({}); + // Mock the useFireproof hook - this is used in components const useFireproof = vi.fn().mockImplementation(() => ({ database: mockDb, @@ -67,7 +70,8 @@ const useFireproof = vi.fn().mockImplementation(() => ({ export default { fireproof, useFireproof, + toCloud, }; // Named exports for ESM compatibility -export { fireproof, useFireproof }; +export { fireproof, useFireproof, toCloud }; diff --git a/vibes.diy/tests/publishUtils.test.ts b/vibes.diy/tests/publishUtils.test.ts index 994eb32ba..6595e64e6 100644 --- a/vibes.diy/tests/publishUtils.test.ts +++ b/vibes.diy/tests/publishUtils.test.ts @@ -6,10 +6,12 @@ import { publishApp } from "~/vibes.diy/app/utils/publishUtils.js"; vi.mock("use-fireproof"); vi.mock("~/vibes.diy/app/utils/databaseManager.js"); vi.mock("~/vibes.diy/app/utils/normalizeComponentExports.js"); +vi.mock("~/vibes.diy/app/utils/catalogUtils.js"); // Import mocked modules import { fireproof } from "use-fireproof"; import { getSessionDatabaseName } from "~/vibes.diy/app/utils/databaseManager.js"; +import { addCatalogScreenshotStandalone } from "~/vibes.diy/app/utils/catalogUtils.js"; // We need to mock the import.meta.env vi.stubGlobal("import", { @@ -96,6 +98,7 @@ describe("publishApp", () => { (normalizeComponentExports as Mock).mockImplementation( (code: string) => code, ); + (addCatalogScreenshotStandalone as Mock).mockResolvedValue(undefined); // Re-setup fetch mock after reset mockFetch.mockImplementation(async () => ({ @@ -254,4 +257,33 @@ describe("publishApp", () => { expect(options.headers).not.toHaveProperty("Authorization"); expect(options.headers).toHaveProperty("Content-Type", "application/json"); }); + + it("calls addCatalogScreenshotStandalone with correct parameters when publishing", async () => { + // Arrange + const sessionId = "test-session-id"; + const userId = "test-user-id"; + const testCode = + "const App = () =>
Test App
; export default App;"; + const normalizedCode = "normalized-code"; + + // Mock normalized exports to return a specific value + (normalizeComponentExports as Mock).mockReturnValue(normalizedCode); + + // Act: Call the publishApp function + await publishApp({ + sessionId, + code: testCode, + userId, + prompt: "Create a test app", + token: "test-token", + }); + + // Assert: Check that addCatalogScreenshotStandalone was called with correct parameters + expect(addCatalogScreenshotStandalone).toHaveBeenCalledWith( + userId, + sessionId, + expect.stringMatching(/^data:image\/png;base64,/), // Screenshot data URL + normalizedCode, // Transformed source code + ); + }); }); diff --git a/vibes.diy/tests/root.test.tsx b/vibes.diy/tests/root.test.tsx index 6a6bd42f9..8799ca065 100644 --- a/vibes.diy/tests/root.test.tsx +++ b/vibes.diy/tests/root.test.tsx @@ -102,6 +102,7 @@ vi.mock("use-fireproof", () => ({ useDocument: () => [{ _id: "mock-doc" }, vi.fn()], useLiveQuery: () => [[]], }), + toCloud: vi.fn().mockReturnValue({}), })); // Mock the useSimpleChat hook diff --git a/vibes.diy/tests/settings-route.test.tsx b/vibes.diy/tests/settings-route.test.tsx index 92545f7c1..007482c09 100644 --- a/vibes.diy/tests/settings-route.test.tsx +++ b/vibes.diy/tests/settings-route.test.tsx @@ -44,6 +44,7 @@ const mockUseFireproof = vi.fn().mockReturnValue({ // Mock Fireproof vi.mock("use-fireproof", () => ({ useFireproof: () => mockUseFireproof(), + toCloud: vi.fn().mockReturnValue({}), })); // Create mock implementations for react-router-dom diff --git a/vibes.diy/tests/settings.test.tsx b/vibes.diy/tests/settings.test.tsx index 6b6dca724..67693fdb8 100644 --- a/vibes.diy/tests/settings.test.tsx +++ b/vibes.diy/tests/settings.test.tsx @@ -34,6 +34,7 @@ vi.mock("use-fireproof", () => ({ save: vi.fn(), }), }), + toCloud: vi.fn().mockReturnValue({}), })); // Mock localStorage diff --git a/vibes.diy/tests/useCatalog.test.tsx b/vibes.diy/tests/useCatalog.test.tsx new file mode 100644 index 000000000..458623539 --- /dev/null +++ b/vibes.diy/tests/useCatalog.test.tsx @@ -0,0 +1,265 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useCatalog } from "../pkg/app/hooks/useCatalog.js"; +import type { LocalVibe } from "../pkg/app/utils/vibeUtils.js"; +import { useFireproof } from "use-fireproof"; + +// Mock use-fireproof using the simple pattern from working tests +const mockDatabase = { + name: "vibez-catalog-test", + allDocs: vi.fn().mockResolvedValue({ rows: [] }), + get: vi.fn().mockResolvedValue({ _id: "test-doc" }), + put: vi.fn().mockResolvedValue({ ok: true }), + bulk: vi.fn().mockResolvedValue({ ok: true }), +}; + +const mockSessionDb = { + get: vi + .fn() + .mockResolvedValue({ title: "Test Vibe", created_at: Date.now() }), + query: vi.fn().mockResolvedValue({ rows: [] }), +}; + +const mockUseAllDocs = vi.fn(() => ({ docs: [] as any[], rows: [] as any[] })); + +vi.mock("use-fireproof", () => ({ + useFireproof: vi.fn(() => ({ + database: mockDatabase, + useAllDocs: mockUseAllDocs, + })), + fireproof: vi.fn(() => mockSessionDb), + toCloud: vi.fn().mockReturnValue({}), +})); + +describe("useCatalog", () => { + const mockVibes: LocalVibe[] = [ + { + id: "vibe1", + title: "Test Vibe 1", + encodedTitle: "test-vibe-1", + slug: "vibe1", + created: "2024-01-01T00:00:00.000Z", + favorite: false, + }, + { + id: "vibe2", + title: "Test Vibe 2", + encodedTitle: "test-vibe-2", + slug: "vibe2", + created: "2024-01-02T00:00:00.000Z", + favorite: true, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset mocks to default empty state + mockUseAllDocs.mockReturnValue({ docs: [], rows: [] }); + }); + + it("should return correct interface", () => { + const { result } = renderHook(() => useCatalog("user123", [])); + + // Verify the hook returns the expected interface + expect(result.current).toHaveProperty("count"); + expect(result.current).toHaveProperty("catalogVibes"); + expect(result.current).toHaveProperty("addCatalogScreenshot"); + + expect(typeof result.current.count).toBe("number"); + expect(Array.isArray(result.current.catalogVibes)).toBe(true); + expect(typeof result.current.addCatalogScreenshot).toBe("function"); + }); + + it("should handle empty vibes array", () => { + const { result } = renderHook(() => useCatalog("user123", [])); + + expect(result.current.count).toBe(0); + expect(result.current.catalogVibes).toEqual([]); + }); + + it("should use local as default userId when empty", () => { + renderHook(() => useCatalog("", mockVibes)); + + expect(useFireproof).toHaveBeenCalledWith( + expect.stringMatching(/-local$/), + expect.any(Object), + ); + }); + + it("should create correct database name with userId", () => { + renderHook(() => useCatalog("user123", mockVibes)); + + expect(useFireproof).toHaveBeenCalledWith( + expect.stringMatching(/-user123$/), + expect.any(Object), + ); + }); + + it("should transform catalog docs to LocalVibe format", () => { + // Mock catalog documents with vibeIds longer than 10 characters + const mockCatalogDocs = [ + { + _id: "catalog-vibe1234567890", + vibeId: "vibe1234567890", + title: "Catalog Vibe 1", + created: Date.now() - 1000, + url: "https://example.com/vibe1", + }, + { + _id: "catalog-vibe0987654321", + vibeId: "vibe0987654321", + title: "Catalog Vibe 2", + created: Date.now() - 2000, + url: "https://example.com/vibe2", + screenshot: { + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAzGNrpgAAAABJRU5ErkJggg==", + size: 95, + type: "image/png", + }, + }, + ]; + + // Set up the mock to return our test data for both useAllDocs calls + mockUseAllDocs.mockReturnValue({ + docs: mockCatalogDocs, + rows: mockCatalogDocs.map((doc) => ({ id: doc._id, key: doc._id })), + }); + + const { result } = renderHook(() => useCatalog("user123", mockVibes)); + + expect(result.current.catalogVibes).toHaveLength(2); + + // Check first vibe transformation (newest first) + expect(result.current.catalogVibes[0]).toMatchObject({ + id: "vibe1234567890", // Newer vibe (created: Date.now() - 1000) + title: "Catalog Vibe 1", + encodedTitle: "catalog-vibe-1", + slug: "vibe1234567890", + favorite: false, + publishedUrl: "https://example.com/vibe1", + }); + + // Check that screenshot is properly handled + expect(result.current.catalogVibes[1].screenshot).toBeDefined(); + expect(result.current.catalogVibes[0].screenshot).toBeUndefined(); + }); + + it("should filter out corrupted catalog documents", () => { + const mockCatalogDocs = [ + { + _id: "catalog-validvibe12345", + vibeId: "validvibe12345", + title: "Valid Vibe", + created: Date.now(), + }, + { + _id: "catalog-anothervibe67890", // Valid entry (long vibeId) + vibeId: "anothervibe67890", + title: "Another Valid Vibe", + created: Date.now(), + }, + { + _id: "catalog-short", + vibeId: "short", // Too short + title: "Short ID", + created: Date.now(), + }, + { + _id: "not-catalog-doc", // Wrong prefix + vibeId: "vibethree123456", + title: "Not Catalog", + created: Date.now(), + }, + ]; + + // Set up the mock to return our test data for both useAllDocs calls + mockUseAllDocs.mockReturnValue({ + docs: mockCatalogDocs, + rows: mockCatalogDocs.map((doc) => ({ id: doc._id, key: doc._id })), + }); + + const { result } = renderHook(() => useCatalog("user123", mockVibes)); + + // Should include both valid vibes (long vibeIds with catalog- prefix) + expect(result.current.catalogVibes).toHaveLength(2); + expect(result.current.catalogVibes.map((v: LocalVibe) => v.id)).toContain( + "validvibe12345", + ); + expect(result.current.catalogVibes.map((v: LocalVibe) => v.id)).toContain( + "anothervibe67890", + ); + }); + + it("should sort catalog vibes by creation date (newest first)", () => { + const oldDate = Date.now() - 10000; + const newDate = Date.now() - 1000; + + const mockCatalogDocs = [ + { + _id: "catalog-oldervibe1234", + vibeId: "oldervibe1234", + title: "Older Vibe", + created: oldDate, + }, + { + _id: "catalog-newervibe5678", + vibeId: "newervibe5678", + title: "Newer Vibe", + created: newDate, + }, + ]; + + // Set up the mock to return our test data for both useAllDocs calls + mockUseAllDocs.mockReturnValue({ + docs: mockCatalogDocs, + rows: mockCatalogDocs.map((doc) => ({ id: doc._id, key: doc._id })), + }); + + const { result } = renderHook(() => useCatalog("user123", mockVibes)); + + expect(result.current.catalogVibes).toHaveLength(2); + expect(result.current.catalogVibes[0].title).toBe("Newer Vibe"); + expect(result.current.catalogVibes[1].title).toBe("Older Vibe"); + }); + + it("should handle addCatalogScreenshot function", async () => { + // Set up the mock database for this test + mockDatabase.get = vi.fn().mockResolvedValue({ + _id: "catalog-vibe1", + vibeId: "vibe1", + title: "Test Vibe", + _files: {}, + }); + mockDatabase.put = vi.fn().mockResolvedValue({ ok: true }); + + // Mock fetch for screenshot data + global.fetch = vi.fn(() => + Promise.resolve({ + blob: () => + Promise.resolve(new Blob(["screenshot"], { type: "image/png" })), + }), + ) as any; + + const { result } = renderHook(() => useCatalog("user123", mockVibes)); + + await result.current.addCatalogScreenshot( + "vibe1", + "data:image/png;base64,test", + 'console.log("test")', + ); + + expect(mockDatabase.get).toHaveBeenCalledWith("catalog-vibe1"); + expect(mockDatabase.put).toHaveBeenCalledWith( + expect.objectContaining({ + _id: "catalog-vibe1", + vibeId: "vibe1", + title: "Test Vibe", + _files: expect.objectContaining({ + screenshot: expect.any(File), + source: expect.any(File), + }), + lastUpdated: expect.any(Number), + }), + ); + }); +}); diff --git a/vibes.diy/tests/useSession.test.ts b/vibes.diy/tests/useSession.test.ts index d4dd71934..dd5c6160a 100644 --- a/vibes.diy/tests/useSession.test.ts +++ b/vibes.diy/tests/useSession.test.ts @@ -23,6 +23,7 @@ vi.mock("use-fireproof", () => { return { useFireproof: mockUseFireproof, + toCloud: vi.fn().mockReturnValue({}), }; }); @@ -32,6 +33,19 @@ vi.mock("~/vibes.diy/app/utils/databaseManager", () => ({ .mockImplementation((id) => `session-${id || "default"}`), })); +vi.mock("~/vibes.diy/app/contexts/AuthContext", () => ({ + useAuth: () => ({ + userPayload: null, + isAuthenticated: false, + isLoading: false, + token: null, + needsLogin: false, + setNeedsLogin: vi.fn(), + checkAuthStatus: vi.fn(), + processToken: vi.fn(), + }), +})); + // Tests focused on eager database initialization behavior describe("useSession", () => { const mockUseFireproof = vi.mocked(useFireproof); @@ -42,7 +56,7 @@ describe("useSession", () => { it("should initialize database eagerly with provided sessionId", () => { renderHook(() => useSession("test-id")); - expect(mockUseFireproof).toHaveBeenCalledWith("session-test-id"); + expect(mockUseFireproof).toHaveBeenCalledWith("session-test-id", {}); }); it("should initialize database eagerly even when sessionId is not provided", () => { @@ -50,12 +64,13 @@ describe("useSession", () => { // Verify we have a session ID generated (in the session document) expect(result.current.session._id).toBeTruthy(); // Verify the database is initialized eagerly on first render - // Called for the session DB and the settings DB - expect(mockUseFireproof).toHaveBeenCalledTimes(2); + // Called for the session DB, settings DB, and useUserSettings DB + expect(mockUseFireproof).toHaveBeenCalledTimes(3); expect(mockUseFireproof).toHaveBeenCalledWith( expect.stringMatching(/^session-/), + {}, ); - expect(mockUseFireproof).toHaveBeenCalledWith("test-chat-history"); + expect(mockUseFireproof).toHaveBeenCalledWith("test-chat-history", {}); }); /** @@ -69,8 +84,8 @@ describe("useSession", () => { const { result } = renderHook(() => useSession(undefined)); // Step 1: Database should be initialized immediately on first render - // One call for the session DB and one for the settings DB - expect(mockUseFireproof).toHaveBeenCalledTimes(2); + // One call for the session DB, one for settings DB, and one for useUserSettings DB + expect(mockUseFireproof).toHaveBeenCalledTimes(3); // Step 2: Session document and functions should be available expect(result.current.session._id).toBeTruthy(); @@ -95,8 +110,12 @@ describe("useSession", () => { // Get the initial call count const initialCallCount = mockUseFireproof.mock.calls.length; - const initialCall = mockUseFireproof.mock.calls[0][0]; - expect(initialCall).toMatch(/^session-/); + // The first call is now to settings DB, second call is to session DB + const sessionCall = mockUseFireproof.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].startsWith("session-"), + ); + expect(sessionCall).toBeTruthy(); + expect(sessionCall![0]).toMatch(/^session-/); // Simulate URL update with new session ID (after first message response) rerender({ id: "new-session-id" as any }); @@ -106,6 +125,6 @@ describe("useSession", () => { expect(mockUseFireproof.mock.calls.length).toBeGreaterThan( initialCallCount, ); - expect(mockUseFireproof).toHaveBeenCalledWith("session-new-session-id"); + expect(mockUseFireproof).toHaveBeenCalledWith("session-new-session-id", {}); }); }); diff --git a/vibes.diy/tests/useSession.test.tsx b/vibes.diy/tests/useSession.test.tsx index 216fce700..4bd60b102 100644 --- a/vibes.diy/tests/useSession.test.tsx +++ b/vibes.diy/tests/useSession.test.tsx @@ -86,9 +86,24 @@ vi.mock("use-fireproof", () => { ], })), })), + toCloud: vi.fn().mockReturnValue({}), }; }); +// Mock AuthContext +vi.mock("~/vibes.diy/app/contexts/AuthContext", () => ({ + useAuth: () => ({ + userPayload: null, + isAuthenticated: false, + isLoading: false, + token: null, + needsLogin: false, + setNeedsLogin: vi.fn(), + checkAuthStatus: vi.fn(), + processToken: vi.fn(), + }), +})); + describe("useSession", () => { // Reset mocks before each test beforeEach(() => { diff --git a/vibes.diy/tests/useSimpleChat-codeReady.test.ts b/vibes.diy/tests/useSimpleChat-codeReady.test.ts index a65cac43e..a61414a1a 100644 --- a/vibes.diy/tests/useSimpleChat-codeReady.test.ts +++ b/vibes.diy/tests/useSimpleChat-codeReady.test.ts @@ -45,6 +45,7 @@ vi.mock("use-fireproof", () => ({ delete: vi.fn().mockResolvedValue({ ok: true }), }, }), + toCloud: vi.fn().mockReturnValue({}), })); // Define shared state and reset function *outside* the mock factory diff --git a/vibes.diy/tests/useSimpleChat/setup.tsx b/vibes.diy/tests/useSimpleChat/setup.tsx index 59640ddde..026971d85 100644 --- a/vibes.diy/tests/useSimpleChat/setup.tsx +++ b/vibes.diy/tests/useSimpleChat/setup.tsx @@ -118,6 +118,7 @@ vi.mock("use-fireproof", () => ({ delete: vi.fn().mockResolvedValue({ ok: true }), }, }), + toCloud: vi.fn().mockReturnValue({}), })); // Define shared state and reset function *outside* the mock factory