diff --git a/packages/web/src/lib/jazz/schema.ts b/packages/web/src/lib/jazz/schema.ts index 69387919..1c648653 100644 --- a/packages/web/src/lib/jazz/schema.ts +++ b/packages/web/src/lib/jazz/schema.ts @@ -73,6 +73,26 @@ export type SavedUrl = z.infer */ export const SavedUrlList = co.list(SavedUrl) +/** + * A Glide canvas item (PDF screenshot, web capture, etc.) + */ +export const GlideCanvasItem = z.object({ + id: z.string(), + type: z.enum(["pdf", "web", "image"]), + title: z.string(), + sourceUrl: z.string().nullable(), + imageData: z.string().nullable(), // Base64 encoded image + position: z.object({ x: z.number(), y: z.number() }).nullable(), + createdAt: z.number(), + metadata: z.record(z.unknown()).nullable(), +}) +export type GlideCanvasItem = z.infer + +/** + * List of Glide canvas items + */ +export const GlideCanvasList = co.list(GlideCanvasItem) + /** * Viewer account root - stores any viewer-specific data */ @@ -81,6 +101,8 @@ export const ViewerRoot = co.map({ version: z.number(), /** User's saved URLs */ savedUrls: SavedUrlList, + /** Glide browser canvas items */ + glideCanvas: GlideCanvasList, }) /** @@ -101,6 +123,7 @@ export const ViewerAccount = co account.$jazz.set("root", { version: 1, savedUrls: SavedUrlList.create([]), + glideCanvas: GlideCanvasList.create([]), }) } }) diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index f6c22c2e..5548694a 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as SettingsRouteImport } from './routes/settings' import { Route as SessionsRouteImport } from './routes/sessions' import { Route as MarketplaceRouteImport } from './routes/marketplace' import { Route as LoginRouteImport } from './routes/login' +import { Route as GlideRouteImport } from './routes/glide' import { Route as ChatRouteImport } from './routes/chat' import { Route as CanvasRouteImport } from './routes/canvas' import { Route as BlocksRouteImport } from './routes/blocks' @@ -33,6 +34,7 @@ import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replay import { Route as ApiStreamCommentsRouteImport } from './routes/api/stream-comments' import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiProfileRouteImport } from './routes/api/profile' +import { Route as ApiGlideCanvasRouteImport } from './routes/api/glide-canvas' import { Route as ApiContextItemsRouteImport } from './routes/api/context-items' import { Route as ApiCheckHlsRouteImport } from './routes/api/check-hls' import { Route as ApiChatThreadsRouteImport } from './routes/api/chat-threads' @@ -105,6 +107,11 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const GlideRoute = GlideRouteImport.update({ + id: '/glide', + path: '/glide', + getParentRoute: () => rootRouteImport, +} as any) const ChatRoute = ChatRouteImport.update({ id: '/chat', path: '/chat', @@ -195,6 +202,11 @@ const ApiProfileRoute = ApiProfileRouteImport.update({ path: '/api/profile', getParentRoute: () => rootRouteImport, } as any) +const ApiGlideCanvasRoute = ApiGlideCanvasRouteImport.update({ + id: '/api/glide-canvas', + path: '/api/glide-canvas', + getParentRoute: () => rootRouteImport, +} as any) const ApiContextItemsRoute = ApiContextItemsRouteImport.update({ id: '/api/context-items', path: '/api/context-items', @@ -416,6 +428,7 @@ export interface FileRoutesByFullPath { '/blocks': typeof BlocksRoute '/canvas': typeof CanvasRouteWithChildren '/chat': typeof ChatRoute + '/glide': typeof GlideRoute '/login': typeof LoginRoute '/marketplace': typeof MarketplaceRoute '/sessions': typeof SessionsRoute @@ -429,6 +442,7 @@ export interface FileRoutesByFullPath { '/api/chat-threads': typeof ApiChatThreadsRoute '/api/check-hls': typeof ApiCheckHlsRoute '/api/context-items': typeof ApiContextItemsRoute + '/api/glide-canvas': typeof ApiGlideCanvasRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRouteWithChildren '/api/stream-comments': typeof ApiStreamCommentsRoute @@ -482,6 +496,7 @@ export interface FileRoutesByTo { '/auth': typeof AuthRoute '/blocks': typeof BlocksRoute '/chat': typeof ChatRoute + '/glide': typeof GlideRoute '/login': typeof LoginRoute '/marketplace': typeof MarketplaceRoute '/sessions': typeof SessionsRoute @@ -495,6 +510,7 @@ export interface FileRoutesByTo { '/api/chat-threads': typeof ApiChatThreadsRoute '/api/check-hls': typeof ApiCheckHlsRoute '/api/context-items': typeof ApiContextItemsRoute + '/api/glide-canvas': typeof ApiGlideCanvasRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRouteWithChildren '/api/stream-comments': typeof ApiStreamCommentsRoute @@ -550,6 +566,7 @@ export interface FileRoutesById { '/blocks': typeof BlocksRoute '/canvas': typeof CanvasRouteWithChildren '/chat': typeof ChatRoute + '/glide': typeof GlideRoute '/login': typeof LoginRoute '/marketplace': typeof MarketplaceRoute '/sessions': typeof SessionsRoute @@ -563,6 +580,7 @@ export interface FileRoutesById { '/api/chat-threads': typeof ApiChatThreadsRoute '/api/check-hls': typeof ApiCheckHlsRoute '/api/context-items': typeof ApiContextItemsRoute + '/api/glide-canvas': typeof ApiGlideCanvasRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRouteWithChildren '/api/stream-comments': typeof ApiStreamCommentsRoute @@ -619,6 +637,7 @@ export interface FileRouteTypes { | '/blocks' | '/canvas' | '/chat' + | '/glide' | '/login' | '/marketplace' | '/sessions' @@ -632,6 +651,7 @@ export interface FileRouteTypes { | '/api/chat-threads' | '/api/check-hls' | '/api/context-items' + | '/api/glide-canvas' | '/api/profile' | '/api/stream' | '/api/stream-comments' @@ -685,6 +705,7 @@ export interface FileRouteTypes { | '/auth' | '/blocks' | '/chat' + | '/glide' | '/login' | '/marketplace' | '/sessions' @@ -698,6 +719,7 @@ export interface FileRouteTypes { | '/api/chat-threads' | '/api/check-hls' | '/api/context-items' + | '/api/glide-canvas' | '/api/profile' | '/api/stream' | '/api/stream-comments' @@ -752,6 +774,7 @@ export interface FileRouteTypes { | '/blocks' | '/canvas' | '/chat' + | '/glide' | '/login' | '/marketplace' | '/sessions' @@ -765,6 +788,7 @@ export interface FileRouteTypes { | '/api/chat-threads' | '/api/check-hls' | '/api/context-items' + | '/api/glide-canvas' | '/api/profile' | '/api/stream' | '/api/stream-comments' @@ -820,6 +844,7 @@ export interface RootRouteChildren { BlocksRoute: typeof BlocksRoute CanvasRoute: typeof CanvasRouteWithChildren ChatRoute: typeof ChatRoute + GlideRoute: typeof GlideRoute LoginRoute: typeof LoginRoute MarketplaceRoute: typeof MarketplaceRoute SessionsRoute: typeof SessionsRoute @@ -833,6 +858,7 @@ export interface RootRouteChildren { ApiChatThreadsRoute: typeof ApiChatThreadsRoute ApiCheckHlsRoute: typeof ApiCheckHlsRoute ApiContextItemsRoute: typeof ApiContextItemsRoute + ApiGlideCanvasRoute: typeof ApiGlideCanvasRoute ApiProfileRoute: typeof ApiProfileRoute ApiStreamRoute: typeof ApiStreamRouteWithChildren ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute @@ -908,6 +934,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/glide': { + id: '/glide' + path: '/glide' + fullPath: '/glide' + preLoaderRoute: typeof GlideRouteImport + parentRoute: typeof rootRouteImport + } '/chat': { id: '/chat' path: '/chat' @@ -1034,6 +1067,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiProfileRouteImport parentRoute: typeof rootRouteImport } + '/api/glide-canvas': { + id: '/api/glide-canvas' + path: '/api/glide-canvas' + fullPath: '/api/glide-canvas' + preLoaderRoute: typeof ApiGlideCanvasRouteImport + parentRoute: typeof rootRouteImport + } '/api/context-items': { id: '/api/context-items' path: '/api/context-items' @@ -1481,6 +1521,7 @@ const rootRouteChildren: RootRouteChildren = { BlocksRoute: BlocksRoute, CanvasRoute: CanvasRouteWithChildren, ChatRoute: ChatRoute, + GlideRoute: GlideRoute, LoginRoute: LoginRoute, MarketplaceRoute: MarketplaceRoute, SessionsRoute: SessionsRoute, @@ -1494,6 +1535,7 @@ const rootRouteChildren: RootRouteChildren = { ApiChatThreadsRoute: ApiChatThreadsRoute, ApiCheckHlsRoute: ApiCheckHlsRoute, ApiContextItemsRoute: ApiContextItemsRoute, + ApiGlideCanvasRoute: ApiGlideCanvasRoute, ApiProfileRoute: ApiProfileRoute, ApiStreamRoute: ApiStreamRouteWithChildren, ApiStreamCommentsRoute: ApiStreamCommentsRoute, diff --git a/packages/web/src/routes/api/glide-canvas.ts b/packages/web/src/routes/api/glide-canvas.ts new file mode 100644 index 00000000..474df8dd --- /dev/null +++ b/packages/web/src/routes/api/glide-canvas.ts @@ -0,0 +1,115 @@ +import { createFileRoute } from "@tanstack/react-router" +import { promises as fs } from "fs" +import { join } from "path" +import type { GlideCanvasItem } from "@/lib/jazz/schema" + +// Local storage for pending Glide canvas items (to be synced to Jazz by client) +const STORAGE_PATH = "/Users/nikiv/fork-i/garden-co/jazz/glide-storage/pending-canvas-items.json" + +async function readPendingItems(): Promise { + try { + const data = await fs.readFile(STORAGE_PATH, "utf-8") + return JSON.parse(data) + } catch (error) { + // File doesn't exist or is invalid - return empty array + return [] + } +} + +async function writePendingItems(items: GlideCanvasItem[]): Promise { + await fs.writeFile(STORAGE_PATH, JSON.stringify(items, null, 2), "utf-8") +} + +// POST add canvas item (from Glide browser) +const addCanvasItem = async ({ request }: { request: Request }) => { + try { + const body = await request.json() + const item = body as GlideCanvasItem + + // Validate required fields + if (!item.id || !item.type || !item.title) { + return new Response( + JSON.stringify({ error: "Missing required fields: id, type, title" }), + { status: 400, headers: { "content-type": "application/json" } } + ) + } + + // Read current pending items + const items = await readPendingItems() + + // Check if item already exists (by id) + const existingIndex = items.findIndex((i) => i.id === item.id) + if (existingIndex >= 0) { + // Update existing item + items[existingIndex] = item + } else { + // Add new item + items.push(item) + } + + // Write back to file + await writePendingItems(items) + + console.log("[glide-canvas] Stored item:", { + id: item.id, + type: item.type, + title: item.title, + hasImage: !!item.imageData, + }) + + return new Response(JSON.stringify({ success: true, id: item.id }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } catch (error) { + console.error("Glide canvas POST error:", error) + return new Response(JSON.stringify({ error: "Internal server error" }), { + status: 500, + headers: { "content-type": "application/json" }, + }) + } +} + +// GET retrieve pending canvas items (for client-side Jazz sync) +const getCanvasItems = async ({ request }: { request: Request }) => { + try { + const items = await readPendingItems() + return new Response(JSON.stringify({ items }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } catch (error) { + console.error("Glide canvas GET error:", error) + return new Response(JSON.stringify({ error: "Internal server error" }), { + status: 500, + headers: { "content-type": "application/json" }, + }) + } +} + +// DELETE clear pending items (after sync to Jazz) +const clearCanvasItems = async ({ request }: { request: Request }) => { + try { + await writePendingItems([]) + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } catch (error) { + console.error("Glide canvas DELETE error:", error) + return new Response(JSON.stringify({ error: "Internal server error" }), { + status: 500, + headers: { "content-type": "application/json" }, + }) + } +} + +export const Route = createFileRoute("/api/glide-canvas")({ + server: { + handlers: { + GET: getCanvasItems, + POST: addCanvasItem, + DELETE: clearCanvasItems, + }, + }, +}) diff --git a/packages/web/src/routes/glide.tsx b/packages/web/src/routes/glide.tsx new file mode 100644 index 00000000..c6681161 --- /dev/null +++ b/packages/web/src/routes/glide.tsx @@ -0,0 +1,214 @@ +import { useState, useEffect, useCallback } from "react" +import { createFileRoute } from "@tanstack/react-router" +import { useAccount } from "jazz-tools/react" +import { ViewerAccount, type GlideCanvasItem } from "@/lib/jazz/schema" +import { Image, Trash2, ExternalLink, RefreshCw } from "lucide-react" + +export const Route = createFileRoute("/glide")({ + component: GlidePage, + ssr: false, +}) + +function GlidePage() { + const me = useAccount(ViewerAccount) + const [syncing, setSyncing] = useState(false) + const [lastSync, setLastSync] = useState(null) + + const root = me.$isLoaded ? me.root : null + const canvasList = root?.$isLoaded ? root.glideCanvas : null + + // Auto-sync pending items every 5 seconds + useEffect(() => { + const interval = setInterval(() => { + void syncPendingItems() + }, 5000) + return () => clearInterval(interval) + }, [root]) + + const syncPendingItems = useCallback(async () => { + if (!root?.glideCanvas?.$isLoaded || syncing) return + + setSyncing(true) + try { + // Fetch pending items from API + const response = await fetch("/api/glide-canvas") + if (!response.ok) { + console.error("[glide] Failed to fetch pending items") + return + } + + const data = (await response.json()) as { items: GlideCanvasItem[] } + const pendingItems = data.items + + if (pendingItems.length === 0) { + return + } + + console.log(`[glide] Syncing ${pendingItems.length} pending items to Jazz...`) + + // Get existing IDs to avoid duplicates + const existingIds = new Set( + root.glideCanvas.$isLoaded + ? [...root.glideCanvas].map((item) => item.id) + : [] + ) + + // Push new items to Jazz + let addedCount = 0 + for (const item of pendingItems) { + if (!existingIds.has(item.id)) { + root.glideCanvas.$jazz.push(item) + addedCount++ + } + } + + if (addedCount > 0) { + console.log(`[glide] Added ${addedCount} new items to Jazz`) + + // Clear pending items after successful sync + await fetch("/api/glide-canvas", { method: "DELETE" }) + setLastSync(new Date()) + } + } catch (error) { + console.error("[glide] Sync error:", error) + } finally { + setSyncing(false) + } + }, [root, syncing]) + + const handleManualSync = () => { + void syncPendingItems() + } + + const handleDeleteItem = (index: number) => { + if (!root?.glideCanvas?.$isLoaded) return + root.glideCanvas.$jazz.splice(index, 1) + } + + if (!me.$isLoaded || !root?.$isLoaded) { + return ( +
+

Loading Jazz...

+
+ ) + } + + const canvasItems: GlideCanvasItem[] = canvasList?.$isLoaded ? [...canvasList] : [] + + return ( +
+
+
+
+ +

Glide Canvas

+
+
+ {lastSync && ( + + Last sync: {lastSync.toLocaleTimeString()} + + )} + +
+
+ + {canvasItems.length === 0 ? ( +
+ +

No canvas items yet

+

+ Capture screenshots from Glide browser (Ctrl+F) to see them here +

+
+ ) : ( +
+ {canvasItems.map((item, index) => ( + + ))} +
+ )} +
+
+ ) +} + +function CanvasItemCard({ + item, + index, + onDelete, +}: { + item: GlideCanvasItem + index: number + onDelete: (index: number) => void +}) { + const imageUrl = item.imageData ? `data:image/png;base64,${item.imageData}` : null + const createdAt = new Date(item.createdAt) + + return ( +
+ {imageUrl && ( +
+ {item.title} +
+
+ )} +
+
+
+

{item.title}

+

+ {item.type} ยท {createdAt.toLocaleDateString()} +

+
+
+ {item.sourceUrl && ( + + + + )} + +
+
+ {item.metadata?.from && ( +

+ From: {item.metadata.from as string} +

+ )} + {item.position && ( +

+ Position: ({Math.round(item.position.x)}, {Math.round(item.position.y)}) +

+ )} +
+
+ ) +} diff --git a/packages/web/src/routes/settings.tsx b/packages/web/src/routes/settings.tsx index 60fcae48..365fb83c 100644 --- a/packages/web/src/routes/settings.tsx +++ b/packages/web/src/routes/settings.tsx @@ -8,7 +8,6 @@ import { LogOut, Sparkles, UserRoundPen, - Lock, MessageCircle, HelpCircle, Copy, @@ -237,12 +236,10 @@ function ProfileSection({ profile: sessionProfile, onLogout, onChangeEmail, - onChangePassword, }: { profile: { name?: string | null; email: string; username?: string | null; image?: string | null; bio?: string | null; website?: string | null } | null | undefined onLogout: () => Promise onChangeEmail: () => void - onChangePassword: () => void }) { const [loading, setLoading] = useState(true) const [profile, setProfile] = useState(sessionProfile) @@ -462,23 +459,6 @@ function ProfileSection({
- - - - Change - - } - /> - -
@@ -878,10 +858,7 @@ function SettingsPage() { const { data: session, isPending } = authClient.useSession() const [activeSection, setActiveSection] = useState("preferences") const [showEmailModal, setShowEmailModal] = useState(false) - const [showPasswordModal, setShowPasswordModal] = useState(false) const [emailInput, setEmailInput] = useState("") - const [currentPassword, setCurrentPassword] = useState("") - const [newPassword, setNewPassword] = useState("") const handleLogout = async () => { await authClient.signOut() @@ -893,24 +870,11 @@ function SettingsPage() { setShowEmailModal(true) } - const openPasswordModal = () => { - setCurrentPassword("") - setNewPassword("") - setShowPasswordModal(true) - } - const handleEmailSubmit = (event: FormEvent) => { event.preventDefault() setShowEmailModal(false) } - const handlePasswordSubmit = (event: FormEvent) => { - event.preventDefault() - setShowPasswordModal(false) - setCurrentPassword("") - setNewPassword("") - } - if (isPending) { return (
@@ -936,7 +900,6 @@ function SettingsPage() { profile={session?.user} onLogout={handleLogout} onChangeEmail={openEmailModal} - onChangePassword={openPasswordModal} /> ) : activeSection === "streaming" ? ( @@ -981,53 +944,6 @@ function SettingsPage() { ) : null} - {showPasswordModal ? ( - setShowPasswordModal(false)} - > -
- - -
- - -
-
-
- ) : null} ) }