feat: add OCR endpoint using OpenRouter Sonoma Sky Alpha model

This commit is contained in:
2025-09-10 17:50:47 -03:00
parent 322c84b389
commit da25ea9b03
18 changed files with 1358 additions and 316 deletions

View File

@@ -32,5 +32,11 @@
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"cSpell.words": ["appwrite", "Reflecto", "Tldraw"]
"cSpell.words": [
"appwrite",
"OPENROUTER",
"Reflecto",
"sonoma",
"Tldraw"
]
}

View File

@@ -93,12 +93,8 @@
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -168,12 +164,8 @@
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -183,9 +175,7 @@
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
@@ -246,9 +236,7 @@
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@@ -316,4 +304,4 @@
"schemas": {},
"tables": {}
}
}
}

View File

@@ -10,4 +10,4 @@
"breakpoints": true
}
]
}
}

View File

@@ -3,6 +3,7 @@ import { execSync } from "node:child_process";
import { join } from "node:path";
import { trpcServer } from "@hono/trpc-server";
import { Hono } from "hono";
import { env } from "hono/adapter";
import { cors } from "hono/cors";
import { logger as honoLogger } from "hono/logger";
import { createContext } from "./lib/context";
@@ -44,6 +45,89 @@ app.use(
})
);
// Utilities for OpenRouter
async function openRouterChat(
apiKey: string,
body: unknown,
extra?: { referer?: string; title?: string }
) {
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
if (extra?.referer) headers["HTTP-Referer"] = extra.referer;
if (extra?.title) headers["X-Title"] = extra.title;
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers,
body: JSON.stringify(body),
});
return res;
}
function arrayBufferToBase64(buf: ArrayBuffer) {
const bytes = new Uint8Array(buf);
let binary = "";
for (let i = 0; i < bytes.length; i++)
binary += String.fromCharCode(bytes[i]);
return Buffer.from(binary, "binary").toString("base64");
}
// OCR via OpenRouter Sonoma Sky Alpha
app.post("/ai/ocr", async (c) => {
const { OPENROUTER_API_KEY, OPENROUTER_SITE_URL, OPENROUTER_SITE_NAME, MOCK } =
env<{
OPENROUTER_API_KEY?: string;
OPENROUTER_SITE_URL?: string;
OPENROUTER_SITE_NAME?: string;
MOCK?: string;
}>(c);
if (!OPENROUTER_API_KEY) {
return c.json({ error: "Missing OPENROUTER_API_KEY" }, 500);
}
const blob = await c.req.blob();
const contentType = c.req.header("content-type") || "image/png";
const base64 = arrayBufferToBase64(await blob.arrayBuffer());
const dataUrl = `data:${contentType};base64,${base64}`;
const body = {
model: "openrouter/sonoma-sky-alpha",
messages: [
{
role: "system",
content:
"You are an OCR engine. Extract all text visible in the provided image. Respond with plain text only, preserving line breaks and reading order. Do not add commentary.",
},
{
role: "user",
content: [
{
type: "text",
text: "Extract text from this image and return only the text.",
},
{ type: "image_url", image_url: { url: dataUrl } },
],
},
],
};
const res = await openRouterChat(OPENROUTER_API_KEY as string, body, {
referer: OPENROUTER_SITE_URL,
title: OPENROUTER_SITE_NAME,
});
if (!res.ok) {
const err = await res.text();
return c.json(
{ error: `OpenRouter error ${res.status}`, details: err },
502
);
}
const json = (await res.json()) as any;
const text: string = json?.choices?.[0]?.message?.content ?? "";
return c.json({ text });
});
app.get("/", (c) => {
return c.text("OK");
});

View File

@@ -1,4 +1,5 @@
VITE_APPWRITE_ENDPOINT=https://<REGION>.cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=<PROJECT_ID>
VITE_APPWRITE_DB_ID=<DATABASE_ID>
VITE_APPWRITE_COLLECTION_ID=<COLLECTION_ID>
VITE_APPWRITE_COLLECTION_ID=<COLLECTION_ID>
VITE_APPWRITE_BUCKET_ID=<BUCKET_ID>

View File

@@ -1,9 +1,9 @@
'use client';
import { useRef, useEffect, useCallback, useMemo } from 'react';
import { gsap } from 'gsap';
import { InertiaPlugin } from 'gsap/InertiaPlugin';
"use client";
import { gsap } from "gsap";
import { InertiaPlugin } from "gsap/InertiaPlugin";
import { useCallback, useEffect, useMemo, useRef } from "react";
import './DotGrid.css';
import "./DotGrid.css";
gsap.registerPlugin(InertiaPlugin);
@@ -22,17 +22,17 @@ function hexToRgb(hex) {
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (!m) return { r: 0, g: 0, b: 0 };
return {
r: parseInt(m[1], 16),
g: parseInt(m[2], 16),
b: parseInt(m[3], 16)
r: Number.parseInt(m[1], 16),
g: Number.parseInt(m[2], 16),
b: Number.parseInt(m[3], 16),
};
}
const DotGrid = ({
dotSize = 16,
gap = 32,
baseColor = '#5227FF',
activeColor = '#5227FF',
baseColor = "#5227FF",
activeColor = "#5227FF",
proximity = 150,
speedTrigger = 100,
shockRadius = 250,
@@ -40,8 +40,8 @@ const DotGrid = ({
maxSpeed = 5000,
resistance = 750,
returnDuration = 1.5,
className = '',
style
className = "",
style,
}) => {
const wrapperRef = useRef(null);
const canvasRef = useRef(null);
@@ -54,14 +54,14 @@ const DotGrid = ({
speed: 0,
lastTime: 0,
lastX: 0,
lastY: 0
lastY: 0,
});
const baseRgb = useMemo(() => hexToRgb(baseColor), [baseColor]);
const activeRgb = useMemo(() => hexToRgb(activeColor), [activeColor]);
const circlePath = useMemo(() => {
if (typeof window === 'undefined' || !window.Path2D) return null;
if (typeof window === "undefined" || !window.Path2D) return null;
const p = new window.Path2D();
p.arc(0, 0, dotSize / 2, 0, Math.PI * 2);
@@ -71,7 +71,7 @@ const DotGrid = ({
const buildGrid = useCallback(() => {
const wrap = wrapperRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
if (!(wrap && canvas)) return;
const { width, height } = wrap.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
@@ -80,7 +80,7 @@ const DotGrid = ({
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (ctx) ctx.scale(dpr, dpr);
const cols = Math.floor((width + gap) / (dotSize + gap));
@@ -116,7 +116,7 @@ const DotGrid = ({
const draw = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
@@ -156,20 +156,20 @@ const DotGrid = ({
useEffect(() => {
buildGrid();
let ro = null;
if ('ResizeObserver' in window) {
if ("ResizeObserver" in window) {
ro = new ResizeObserver(buildGrid);
wrapperRef.current && ro.observe(wrapperRef.current);
} else {
window.addEventListener('resize', buildGrid);
window.addEventListener("resize", buildGrid);
}
return () => {
if (ro) ro.disconnect();
else window.removeEventListener('resize', buildGrid);
else window.removeEventListener("resize", buildGrid);
};
}, [buildGrid]);
useEffect(() => {
const onMove = e => {
const onMove = (e) => {
const now = performance.now();
const pr = pointerRef.current;
const dt = pr.lastTime ? now - pr.lastTime : 16;
@@ -209,16 +209,16 @@ const DotGrid = ({
xOffset: 0,
yOffset: 0,
duration: returnDuration,
ease: 'elastic.out(1,0.75)'
ease: "elastic.out(1,0.75)",
});
dot._inertiaApplied = false;
}
},
});
}
}
};
const onClick = e => {
const onClick = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
@@ -237,29 +237,37 @@ const DotGrid = ({
xOffset: 0,
yOffset: 0,
duration: returnDuration,
ease: 'elastic.out(1,0.75)'
ease: "elastic.out(1,0.75)",
});
dot._inertiaApplied = false;
}
},
});
}
}
};
const throttledMove = throttle(onMove, 50);
window.addEventListener('mousemove', throttledMove, { passive: true });
window.addEventListener('click', onClick);
window.addEventListener("mousemove", throttledMove, { passive: true });
window.addEventListener("click", onClick);
return () => {
window.removeEventListener('mousemove', throttledMove);
window.removeEventListener('click', onClick);
window.removeEventListener("mousemove", throttledMove);
window.removeEventListener("click", onClick);
};
}, [maxSpeed, speedTrigger, proximity, resistance, returnDuration, shockRadius, shockStrength]);
}, [
maxSpeed,
speedTrigger,
proximity,
resistance,
returnDuration,
shockRadius,
shockStrength,
]);
return (
<section className={`dot-grid ${className}`} style={style}>
<div ref={wrapperRef} className="dot-grid__wrap">
<canvas ref={canvasRef} className="dot-grid__canvas" />
<div className="dot-grid__wrap" ref={wrapperRef}>
<canvas className="dot-grid__canvas" ref={canvasRef} />
</div>
</section>
);

View File

@@ -53,7 +53,7 @@ export default function SignInForm({
.string()
.min(
MIN_PASSWORD_LENGTH,
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`
),
}),
},
@@ -136,12 +136,12 @@ export default function SignInForm({
<div className="mt-4 space-y-2 text-center">
<div className="flex items-center justify-between">
<Button asChild variant="link" className="px-0">
<Link to="/password" search={{ mode: "recover" }}>
<Button asChild className="px-0" variant="link">
<Link search={{ mode: "recover" }} to="/password">
Forgot password?
</Link>
</Button>
<Button asChild variant="link" className="px-0">
<Button asChild className="px-0" variant="link">
<Link to="/verify-email">Verify your email</Link>
</Button>
</div>

View File

@@ -55,7 +55,7 @@ export default function SignUpForm({
.string()
.min(
MIN_PASSWORD_LENGTH,
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`
),
}),
},
@@ -160,7 +160,7 @@ export default function SignUpForm({
<div className="mt-4 space-y-2 text-center">
<div className="flex items-center justify-between">
<Button asChild variant="link" className="px-0">
<Button asChild className="px-0" variant="link">
<Link to="/verify-email">Verify your email</Link>
</Button>
<Button

View File

@@ -1,278 +1,280 @@
import * as React from "react"
import { Menu as BaseMenu } from "@base-ui-components/react/menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { Menu as BaseMenu } from "@base-ui-components/react/menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
...props
}: React.ComponentProps<typeof BaseMenu.Root>) {
return <BaseMenu.Root data-slot="dropdown-menu" {...props} />
return <BaseMenu.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
...props
}: React.ComponentProps<typeof BaseMenu.Portal>) {
return <BaseMenu.Portal data-slot="dropdown-menu-portal" {...props} />
return <BaseMenu.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
...props
}: React.ComponentProps<typeof BaseMenu.Trigger>) {
return <BaseMenu.Trigger data-slot="dropdown-menu-trigger" {...props} />
return <BaseMenu.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuPositioner({
...props
...props
}: React.ComponentProps<typeof BaseMenu.Positioner>) {
return <BaseMenu.Positioner data-slot="dropdown-menu-positioner" {...props} />
return (
<BaseMenu.Positioner data-slot="dropdown-menu-positioner" {...props} />
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
align = "center",
...props
className,
sideOffset = 4,
align = "center",
...props
}: React.ComponentProps<typeof BaseMenu.Popup> & {
align?: BaseMenu.Positioner.Props["align"]
sideOffset?: BaseMenu.Positioner.Props["sideOffset"]
align?: BaseMenu.Positioner.Props["align"];
sideOffset?: BaseMenu.Positioner.Props["sideOffset"];
}) {
return (
<DropdownMenuPortal>
<DropdownMenuPositioner
className="max-h-[var(--available-height)]"
sideOffset={sideOffset}
align={align}
>
<BaseMenu.Popup
data-slot="dropdown-menu-content"
className={cn(
"bg-popover data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-[var(--transform-origin)] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPositioner>
</DropdownMenuPortal>
)
return (
<DropdownMenuPortal>
<DropdownMenuPositioner
align={align}
className="max-h-[var(--available-height)]"
sideOffset={sideOffset}
>
<BaseMenu.Popup
className={cn(
"data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-[var(--transform-origin)] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-closed:animate-out data-open:animate-in",
className
)}
data-slot="dropdown-menu-content"
{...props}
/>
</DropdownMenuPositioner>
</DropdownMenuPortal>
);
}
function DropdownMenuGroup({
...props
...props
}: React.ComponentProps<typeof BaseMenu.Group>) {
return <BaseMenu.Group data-slot="dropdown-menu-group" {...props} />
return <BaseMenu.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof BaseMenu.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<BaseMenu.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive focus:data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-all select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:transition-all [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
return (
<BaseMenu.Item
className={cn(
"data-[variant=destructive]:*:[svg]:!text-destructive focus:data-[variant=destructive]:*:[svg]:!text-destructive-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-all focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:transition-all",
className
)}
data-inset={inset}
data-slot="dropdown-menu-item"
data-variant={variant}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
return (
<span
className={cn(
"ml-auto text-muted-foreground text-xs tracking-widest",
className
)}
data-slot="dropdown-menu-shortcut"
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
className,
...props
}: React.ComponentProps<typeof BaseMenu.Separator>) {
return (
<BaseMenu.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
return (
<BaseMenu.Separator
className={cn("-mx-1 my-1 h-px bg-border", className)}
data-slot="dropdown-menu-separator"
{...props}
/>
);
}
function DropdownMenuLabel({
className,
inset,
...props
className,
inset,
...props
}: React.ComponentProps<typeof BaseMenu.GroupLabel> & {
inset?: boolean
inset?: boolean;
}) {
return (
<BaseMenu.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
return (
<BaseMenu.GroupLabel
className={cn(
"px-2 py-1.5 font-medium text-xs data-[inset]:pl-8",
className
)}
data-inset={inset}
data-slot="dropdown-menu-label"
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
className,
children,
checked,
...props
}: React.ComponentProps<typeof BaseMenu.CheckboxItem>) {
return (
<BaseMenu.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<BaseMenu.CheckboxItemIndicator>
<CheckIcon className="size-4" />
</BaseMenu.CheckboxItemIndicator>
</span>
{children}
</BaseMenu.CheckboxItem>
)
return (
<BaseMenu.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="dropdown-menu-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<BaseMenu.CheckboxItemIndicator>
<CheckIcon className="size-4" />
</BaseMenu.CheckboxItemIndicator>
</span>
{children}
</BaseMenu.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
...props
}: React.ComponentProps<typeof BaseMenu.RadioGroup>) {
return (
<BaseMenu.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
)
return (
<BaseMenu.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
);
}
function DropdownMenuRadioItem({
className,
children,
...props
className,
children,
...props
}: React.ComponentProps<typeof BaseMenu.RadioItem>) {
return (
<BaseMenu.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<BaseMenu.RadioItemIndicator>
<CircleIcon className="size-2 fill-current" />
</BaseMenu.RadioItemIndicator>
</span>
{children}
</BaseMenu.RadioItem>
)
return (
<BaseMenu.RadioItem
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="dropdown-menu-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<BaseMenu.RadioItemIndicator>
<CircleIcon className="size-2 fill-current" />
</BaseMenu.RadioItemIndicator>
</span>
{children}
</BaseMenu.RadioItem>
);
}
function DropdownMenuSub({
...props
...props
}: React.ComponentProps<typeof BaseMenu.SubmenuRoot>) {
return (
<BaseMenu.SubmenuRoot
closeDelay={0}
delay={0}
data-slot="dropdown-menu-sub"
{...props}
/>
)
return (
<BaseMenu.SubmenuRoot
closeDelay={0}
data-slot="dropdown-menu-sub"
delay={0}
{...props}
/>
);
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
className,
inset,
children,
...props
}: React.ComponentProps<typeof BaseMenu.SubmenuTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<BaseMenu.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</BaseMenu.SubmenuTrigger>
)
return (
<BaseMenu.SubmenuTrigger
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-popup-open:bg-accent data-[inset]:pl-8 data-popup-open:text-accent-foreground",
className
)}
data-inset={inset}
data-slot="dropdown-menu-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</BaseMenu.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
className,
sideOffset = 0,
align = "start",
...props
className,
sideOffset = 0,
align = "start",
...props
}: React.ComponentProps<typeof BaseMenu.Popup> & {
align?: BaseMenu.Positioner.Props["align"]
sideOffset?: BaseMenu.Positioner.Props["sideOffset"]
align?: BaseMenu.Positioner.Props["align"];
sideOffset?: BaseMenu.Positioner.Props["sideOffset"];
}) {
return (
<DropdownMenuPortal>
<DropdownMenuPositioner
className="max-h-[var(--available-height)]"
sideOffset={sideOffset}
align={align}
>
<BaseMenu.Popup
data-slot="dropdown-menu-content"
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-[var(--transform-origin)] overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPositioner>
</DropdownMenuPortal>
)
return (
<DropdownMenuPortal>
<DropdownMenuPositioner
align={align}
className="max-h-[var(--available-height)]"
sideOffset={sideOffset}
>
<BaseMenu.Popup
className={cn(
"data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-[var(--transform-origin)] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-closed:animate-out data-open:animate-in",
className
)}
data-slot="dropdown-menu-content"
{...props}
/>
</DropdownMenuPositioner>
</DropdownMenuPortal>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -1,13 +1,13 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
@@ -15,7 +15,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
);
}
function DropdownMenuTrigger({
@@ -26,7 +26,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -37,16 +37,16 @@ function DropdownMenuContent({
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@@ -54,7 +54,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
@@ -63,21 +63,21 @@ function DropdownMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-inset={inset}
data-slot="dropdown-menu-item"
data-variant={variant}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -88,12 +88,12 @@ function DropdownMenuCheckboxItem({
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
checked={checked}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
data-slot="dropdown-menu-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
@@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -124,11 +124,11 @@ function DropdownMenuRadioItem({
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="dropdown-menu-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
@@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -146,19 +146,19 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
"px-2 py-1.5 font-medium text-sm data-[inset]:pl-8",
className
)}
data-inset={inset}
data-slot="dropdown-menu-label"
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -167,11 +167,11 @@ function DropdownMenuSeparator({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-border", className)}
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
@@ -180,20 +180,20 @@ function DropdownMenuShortcut({
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
"ml-auto text-muted-foreground text-xs tracking-widest",
className
)}
data-slot="dropdown-menu-shortcut"
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -202,22 +202,22 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground",
className
)}
data-inset={inset}
data-slot="dropdown-menu-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -226,14 +226,14 @@ function DropdownMenuSubContent({
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
data-slot="dropdown-menu-sub-content"
{...props}
/>
)
);
}
export {
@@ -252,4 +252,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { Tooltip as BaseTooltip } from "@base-ui-components/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
closeDelay = 0,
...props
}: React.ComponentProps<typeof BaseTooltip.Provider>) {
return (
<BaseTooltip.Provider
data-slot="tooltip-provider"
delay={delay}
closeDelay={closeDelay}
{...props}
/>
)
}
function Tooltip({ ...props }: React.ComponentProps<typeof BaseTooltip.Root>) {
return (
<TooltipProvider>
<BaseTooltip.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof BaseTooltip.Trigger>) {
return <BaseTooltip.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipPortal({
...props
}: React.ComponentProps<typeof BaseTooltip.Portal>) {
return <BaseTooltip.Portal data-slot="tooltip-portal" {...props} />
}
function TooltipPositioner({
...props
}: React.ComponentProps<typeof BaseTooltip.Positioner>) {
return <BaseTooltip.Positioner data-slot="tooltip-positioner" {...props} />
}
function TooltipArrow({
...props
}: React.ComponentProps<typeof BaseTooltip.Arrow>) {
return <BaseTooltip.Arrow data-slot="tooltip-arrow" {...props} />
}
function TooltipContent({
className,
align = "center",
sideOffset = 8,
side = "top",
children,
...props
}: React.ComponentProps<typeof BaseTooltip.Popup> & {
align?: BaseTooltip.Positioner.Props["align"]
side?: BaseTooltip.Positioner.Props["side"]
sideOffset?: BaseTooltip.Positioner.Props["sideOffset"]
}) {
return (
<TooltipPortal>
<TooltipPositioner sideOffset={sideOffset} align={align} side={side}>
<BaseTooltip.Popup
data-slot="tooltip-content"
className={cn(
"bg-popover text-popover-foreground outline-border z-50 w-fit origin-[var(--transform-origin)] rounded-md px-3 py-1.5 text-xs text-balance shadow-sm outline -outline-offset-1 transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0",
className
)}
{...props}
>
{children}
<TooltipArrow className="data-[side=bottom]:top-[-8px] data-[side=left]:right-[-13px] data-[side=left]:rotate-90 data-[side=right]:left-[-13px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-8px] data-[side=top]:rotate-180">
<svg width="20" height="10" viewBox="0 0 20 10" fill="none">
<path
d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V9H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
className="fill-popover"
/>
<path
d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
className="fill-border"
/>
</svg>
</TooltipArrow>
</BaseTooltip.Popup>
</TooltipPositioner>
</TooltipPortal>
)
}
export {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipPortal,
TooltipPositioner,
TooltipArrow,
TooltipProvider,
}

View File

@@ -1,14 +1,16 @@
import type { Models } from "appwrite";
import { Databases, ID, Permission, Query, Role } from "appwrite";
import { Databases, ID, Permission, Query, Role, Storage } from "appwrite";
import type { TLEditorSnapshot } from "tldraw";
import { account, appwriteClient } from "@/lib/auth-client";
// Environment-configured IDs for Appwrite
const DATABASE_ID = import.meta.env.VITE_APPWRITE_DB_ID as string;
const COLLECTION_ID = import.meta.env.VITE_APPWRITE_COLLECTION_ID as string;
const BUCKET_ID = import.meta.env.VITE_APPWRITE_BUCKET_ID as string;
// Initialize TablesDB once per app
const databases = new Databases(appwriteClient);
const storage = new Storage(appwriteClient);
export type SpaceSnapshotRow = {
$id: string;
@@ -34,6 +36,14 @@ function ensureEnv() {
}
}
function ensureStorageEnv() {
if (!BUCKET_ID) {
throw new Error(
"Missing Appwrite Storage config. Please set VITE_APPWRITE_BUCKET_ID."
);
}
}
export async function getCurrentUserId(): Promise<string | null> {
try {
const me = await account.get();
@@ -267,3 +277,37 @@ export async function deleteSpace(args: {
await databases.deleteDocument(DATABASE_ID, COLLECTION_ID, existing.$id);
}
/**
* Upload an image file to Appwrite Storage and return its identifiers.
* Applies user-level read/update/delete permissions so only the owner can access it by default.
*/
export async function uploadImageToStorage(file: File, userId?: string): Promise<{
fileId: string;
fileUrl: string;
}> {
ensureStorageEnv();
const uid = userId ?? (await getCurrentUserId());
if (!uid) {
throw new Error("Not authenticated");
}
const permissions = [
Permission.read(Role.user(uid)),
Permission.update(Role.user(uid)),
Permission.delete(Role.user(uid)),
];
const created = await storage.createFile(
BUCKET_ID,
ID.unique(),
file,
permissions
);
// Generate a view URL (works in browser with Appwrite session)
const fileId = (created as Models.File).$id;
const fileUrl = storage.getFileView(BUCKET_ID, fileId).toString();
return { fileId, fileUrl };
}

View File

@@ -0,0 +1,369 @@
import type { RecordProps, TLBaseShape, TLResizeInfo } from "tldraw";
import {
createShapeId,
HTMLContainer,
Rectangle2d,
resizeBox,
ShapeUtil,
T,
} from "tldraw";
import { useEffect, useRef, useState } from "react";
import { stopEventPropagation } from "tldraw";
// Types
export type AIImageShape = TLBaseShape<
"ai-image",
{
imageUrl: string;
extractedText: string;
processingStatus: "idle" | "processing" | "completed" | "error";
filename: string;
uploadDate: number;
w: number;
h: number;
}
>;
export type AITextResultShape = TLBaseShape<
"ai-text-result",
{
content: string;
sourceType: "image" | "analysis";
sourceShapeId: string;
createdDate: number;
w: number;
h: number;
}
>;
// Utils
export class AIImageShapeUtil extends ShapeUtil<AIImageShape> {
static override type = "ai-image" as const;
static override props: RecordProps<AIImageShape> = {
imageUrl: T.string,
extractedText: T.string,
processingStatus: T.literalEnum("idle", "processing", "completed", "error"),
filename: T.string,
uploadDate: T.number,
w: T.number,
h: T.number,
};
getDefaultProps(): AIImageShape["props"] {
return {
imageUrl: "",
extractedText: "",
processingStatus: "idle",
filename: "",
uploadDate: Date.now(),
w: 300,
h: 200,
};
}
override canEdit() {
return false;
}
override canResize() {
return true;
}
override onResize(shape: AIImageShape, info: TLResizeInfo<AIImageShape>) {
return resizeBox(shape, info);
}
getGeometry(shape: AIImageShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
});
}
component(shape: AIImageShape) {
const [showStatus, setShowStatus] = useState(true);
const status = shape.props.processingStatus;
useEffect(() => {
// Always show while idle/processing; hide after 2s when completed/error
if (status === "completed" || status === "error") {
setShowStatus(true);
const t = setTimeout(() => setShowStatus(false), 5000);
return () => clearTimeout(t);
}
setShowStatus(true);
return undefined;
}, [status]);
return (
<HTMLContainer>
<div
style={{
width: "100%",
height: "100%",
border: "2px solid #e1e5e9",
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#f8f9fa",
display: "flex",
flexDirection: "column",
}}
>
<div style={{ flex: "1 1 0%", minHeight: 0 }}>
{shape.props.imageUrl ? (
<img
alt={shape.props.filename || "Upload preview"}
src={shape.props.imageUrl}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
) : null}
</div>
{showStatus ? (
<div
style={{
padding: 8,
backgroundColor:
status === "processing"
? "#b08500" // dark amber
: status === "completed"
? "#0b6b3a" // dark green
: status === "error"
? "#a4000f" // dark red
: "#495057", // neutral dark
color: "#ffffff",
fontSize: 12,
fontWeight: 700,
}}
>
{status === "processing" && "Processing with OCR..."}
{status === "completed" && "Text extracted"}
{status === "error" && "Processing failed"}
{status === "idle" && "Ready for processing"}
</div>
) : null}
</div>
</HTMLContainer>
);
}
indicator(shape: AIImageShape) {
return <rect height={shape.props.h} width={shape.props.w} />;
}
}
export class AITextResultShapeUtil extends ShapeUtil<AITextResultShape> {
static override type = "ai-text-result" as const;
static override props: RecordProps<AITextResultShape> = {
content: T.string,
sourceType: T.literalEnum("image", "analysis"),
sourceShapeId: T.string,
createdDate: T.number,
w: T.number,
h: T.number,
};
getDefaultProps(): AITextResultShape["props"] {
return {
content: "",
sourceType: "image",
sourceShapeId: "",
createdDate: Date.now(),
w: 280,
h: 150,
};
}
override canEdit() {
return true;
}
override canResize() {
return true;
}
override onResize(shape: AITextResultShape, info: TLResizeInfo<AITextResultShape>) {
return resizeBox(shape, info);
}
getGeometry(shape: AITextResultShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
});
}
component(shape: AITextResultShape) {
// Follow editable-shape pattern: rely on editor's editing state
const isEditing = this.editor.getEditingShapeId() === shape.id;
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (isEditing && textareaRef.current) textareaRef.current.focus();
}, [isEditing]);
// Auto-size to content: measure and adjust w/h
useEffect(() => {
const headerEl = headerRef.current;
const contentEl = isEditing ? (textareaRef.current as HTMLElement | null) : contentRef.current;
console.log({
headerEl,
contentEl
})
if (!headerEl || !contentEl) return;
// Measure desired sizes
const headerH = Math.ceil(headerEl.getBoundingClientRect().height);
const contentH = Math.ceil((contentEl as HTMLElement).scrollHeight);
const contentW = Math.ceil((contentEl as HTMLElement).scrollWidth);
if (!contentH || !contentW) return;
const padding = 24; // root padding 12 top + 12 bottom
const marginBetween = 8; // header bottom margin
const minW = 220;
const minH = 120;
const desiredW = Math.max(minW, contentW + padding);
const desiredH = Math.max(minH, headerH + marginBetween + contentH + padding);
// Avoid tight loops: only update if significant delta
const dw = Math.abs(desiredW - shape.props.w);
const dh = Math.abs(desiredH - shape.props.h);
if (dw > 1 || dh > 1) {
this.editor.updateShape({
id: shape.id,
type: "ai-text-result",
props: { w: desiredW, h: desiredH },
});
}
}, [isEditing, shape.id, shape.props.content, shape.props.w, shape.props.h]);
const header =
shape.props.sourceType === "image"
? "📄 OCR Result"
: "🧠 AI Analysis";
return (
<HTMLContainer
id={shape.id}
onPointerDown={isEditing ? stopEventPropagation : undefined}
style={{ pointerEvents: isEditing ? "all" : "none" }}
>
<div
style={{
width: "100%",
height: "100%",
border: "2px solid #28a745",
borderRadius: 8,
padding: 12,
backgroundColor: "#fff",
fontSize: 12,
display: "flex",
flexDirection: "column",
}}
>
<div
ref={headerRef}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
paddingBottom: 4,
borderBottom: "1px solid #e9ecef",
}}
>
<span style={{ fontWeight: 700, color: "#28a745" }}>{header}</span>
</div>
{isEditing ? (
<textarea
ref={textareaRef}
value={shape.props.content}
onChange={(e) =>
this.editor.updateShape({
id: shape.id,
type: "ai-text-result",
props: { content: e.target.value },
})
}
aria-label="Edit extracted text"
style={{
flex: "1 1 0%",
minHeight: 0,
resize: "none",
width: "100%",
border: "1px solid #ced4da",
borderRadius: 6,
padding: 8,
lineHeight: 1.4,
color: "#212529",
outline: "none",
overflow: "hidden"
}}
placeholder="Edit extracted content..."
/>
) : (
<div
ref={contentRef}
style={{
flex: "1 1 0%",
minHeight: 0,
lineHeight: 1.4,
color: "#495057",
whiteSpace: "pre-wrap",
cursor: "text",
overflow: "hidden"
}}
>
{shape.props.content || "No content extracted"}
</div>
)}
</div>
</HTMLContainer>
);
}
indicator(shape: AITextResultShape) {
return <rect height={shape.props.h} width={shape.props.w} />;
}
}
export function createAITextResult(
editor: any,
opts: {
fromShapeId: string;
sourceType: "image" | "analysis";
content: string;
x: number;
y: number;
}
) {
const id = createShapeId();
const baseW = 280;
const baseH = 150;
const extra = Math.min(600, Math.floor(opts.content.length / 6));
const dynW = Math.min(640, baseW + Math.floor(extra * 0.6));
const dynH = Math.min(480, baseH + Math.floor(extra * 0.4));
editor.createShape({
id,
type: "ai-text-result",
x: opts.x,
y: opts.y,
props: {
content: opts.content,
sourceType: opts.sourceType,
sourceShapeId: opts.fromShapeId,
createdDate: Date.now(),
w: dynW,
h: dynH,
},
});
return id;
}

View File

@@ -0,0 +1,185 @@
import { createShapeId } from "tldraw";
export class KnowledgeGraphManager {
constructor(private editor: any) {}
createConnection(
fromShapeId: string,
toShapeId: string,
relationshipType: string,
) {
const arrowId = createShapeId();
// Compute centers to place arrow endpoints
const fromBounds = this.editor.getShapePageBounds(fromShapeId);
const toBounds = this.editor.getShapePageBounds(toShapeId);
const fromShape = this.editor.getShape(fromShapeId);
const toShape = this.editor.getShape(toShapeId);
const fcx = fromBounds
? fromBounds.x + fromBounds.w / 2
: (fromShape?.x ?? 0);
const fcy = fromBounds
? fromBounds.y + fromBounds.h / 2
: (fromShape?.y ?? 0);
const tcx = toBounds ? toBounds.x + toBounds.w / 2 : (toShape?.x ?? 0);
const tcy = toBounds ? toBounds.y + toBounds.h / 2 : (toShape?.y ?? 0);
const dx = tcx - fcx;
const dy = tcy - fcy;
// Decide which sides to attach based on angle
const fromPoint = (() => {
if (!fromBounds) return { x: fcx, y: fcy };
const adx = Math.abs(dx);
const ady = Math.abs(dy);
if (adx > ady) {
// horizontal connect
return dx >= 0
? { x: fromBounds.x + fromBounds.w, y: fcy }
: { x: fromBounds.x, y: fcy };
}
// vertical connect
return dy >= 0
? { x: fcx, y: fromBounds.y + fromBounds.h }
: { x: fcx, y: fromBounds.y };
})();
const toPoint = (() => {
if (!toBounds) return { x: tcx, y: tcy };
const adx = Math.abs(dx);
const ady = Math.abs(dy);
if (adx > ady) {
return dx >= 0
? { x: toBounds.x, y: tcy }
: { x: toBounds.x + toBounds.w, y: tcy };
}
return dy >= 0
? { x: tcx, y: toBounds.y }
: { x: tcx, y: toBounds.y + toBounds.h };
})();
this.editor.createShape({
id: arrowId,
type: "arrow",
props: { start: fromPoint, end: toPoint },
meta: {
relationshipType,
aiGenerated: true,
createdAt: Date.now(),
},
});
// Bind arrow ends using normalized anchors for side-specific attachment
const fromAnchor = (() => {
if (!fromBounds) return { x: 0.5, y: 0.5 };
const adx = Math.abs(dx);
const ady = Math.abs(dy);
if (adx > ady) return dx >= 0 ? { x: 1, y: 0.5 } : { x: 0, y: 0.5 };
return dy >= 0 ? { x: 0.5, y: 1 } : { x: 0.5, y: 0 };
})();
const toAnchor = (() => {
if (!toBounds) return { x: 0.5, y: 0.5 };
const adx = Math.abs(dx);
const ady = Math.abs(dy);
if (adx > ady) return dx >= 0 ? { x: 0, y: 0.5 } : { x: 1, y: 0.5 };
return dy >= 0 ? { x: 0.5, y: 0 } : { x: 0.5, y: 1 };
})();
try {
this.editor.createBindings([
{
fromId: arrowId,
toId: fromShapeId,
type: "arrow",
props: {
terminal: "start",
normalizedAnchor: fromAnchor,
isPrecise: true,
isExact: false,
},
},
{
fromId: arrowId,
toId: toShapeId,
type: "arrow",
props: {
terminal: "end",
normalizedAnchor: toAnchor,
isPrecise: true,
isExact: false,
},
},
]);
} catch {
// Fallback to simple bind if available
try {
this.editor.bindArrow(arrowId, "start", fromShapeId);
this.editor.bindArrow(arrowId, "end", toShapeId);
} catch {}
}
return arrowId;
}
async analyzeConnections(shapeId: string) {
const shape = this.editor.getShape(shapeId);
if (!shape) return;
const allShapes = this.editor.getCurrentPageShapes();
const textShapes = allShapes.filter(
(s: any) =>
(s.type === "ai-text-result" || s.type === "text" || s.type === "note") && s.id !== shapeId
);
const currentContent = this.extractTextContent(shape);
if (!currentContent) return;
for (const otherShape of textShapes) {
const otherContent = this.extractTextContent(otherShape);
if (!otherContent) continue;
const similarity = this.calculateTextSimilarity(
currentContent,
otherContent
);
if (similarity > 0.3) {
this.createConnection(
shapeId,
otherShape.id,
"related_content",
);
}
}
}
private extractTextContent(shape: any): string {
if (shape.type === "ai-text-result") return shape.props.content;
if (shape.type === "ai-image") return shape.props.extractedText;
if (shape.type === "text" || shape.type === "note") return shape.props.text;
return "";
}
private calculateTextSimilarity(text1: string, text2: string): number {
const words1 = text1.toLowerCase().split(/\s+/);
const words2 = text2.toLowerCase().split(/\s+/);
const set2 = new Set(words2);
const intersection = words1.filter((w) => set2.has(w));
const union = new Set([...words1, ...words2]);
return intersection.length / union.size;
}
createAnalysisShape(
relatedShapeIds: string[],
analysisContent: string,
position: { x: number; y: number }
) {
const analysisId = createShapeId();
this.editor.createShape({
id: analysisId,
type: "ai-text-result",
x: position.x,
y: position.y,
props: {
content: analysisContent,
sourceType: "analysis",
sourceShapeId: relatedShapeIds.join(","),
createdDate: Date.now(),
},
});
relatedShapeIds.forEach((sid) =>
this.createConnection(sid, analysisId, "analyzes",)
);
return analysisId;
}
}

View File

@@ -0,0 +1,141 @@
import { createShapeId } from "tldraw";
import { getApiBaseUrl } from "@/lib/utils";
import { uploadImageToStorage } from "@/lib/appwrite-db";
import { KnowledgeGraphManager } from "./knowledge-graph";
import { createAITextResult } from "./ai-shapes";
const API_BASE = getApiBaseUrl();
async function postBinary(url: string, file: File): Promise<{ text: string }> {
const res = await fetch(url, {
method: "POST",
body: file,
headers: { "Content-Type": file.type },
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<{ text: string }>;
}
export async function processImageWithOCR(
editor: any,
shapeId: string,
imageFile: File
): Promise<void> {
editor.updateShape({
id: shapeId,
type: "ai-image",
props: { processingStatus: "processing" },
});
try {
const { text } = await postBinary(`${API_BASE}/ai/ocr`, imageFile);
editor.updateShape({
id: shapeId,
type: "ai-image",
props: { extractedText: text, processingStatus: "completed" },
});
const imageShape = editor.getShape(shapeId);
// Place to the right of the image with a proportional gap
const imgW = Math.max(1, imageShape?.props?.w ?? 300);
const gap = Math.min(80, Math.max(24, Math.floor(imgW * 0.12)));
const textShapeId = createAITextResult(editor, {
fromShapeId: shapeId,
sourceType: "image",
content: text,
x: imageShape.x + imgW + gap,
y: imageShape.y,
});
const kgManager = new KnowledgeGraphManager(editor);
kgManager.createConnection(shapeId, textShapeId, "extracts_text",);
await kgManager.analyzeConnections(textShapeId);
} catch (e) {
editor.updateShape({
id: shapeId,
type: "ai-image",
props: { processingStatus: "error" },
});
}
}
export function setupFileDropHandler(editor: any) {
const container = editor.getContainer();
const handleDragOver = (e: DragEvent) => e.preventDefault();
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
const files = Array.from(event.dataTransfer?.files || []);
const dropPoint = editor.screenToPage({
x: event.clientX,
y: event.clientY,
});
for (const file of files) {
if (file.type.startsWith("image/")) {
await createImageShapeFromFile(editor, file, dropPoint);
}
}
};
container.addEventListener("dragover", handleDragOver);
container.addEventListener("drop", handleDrop);
return () => {
container.removeEventListener("dragover", handleDragOver);
container.removeEventListener("drop", handleDrop);
};
}
export async function createImageShapeFromFile(
editor: any,
file: File,
position: any
) {
// First upload to Appwrite Storage for persistence
let persistentUrl = "";
let storageFileId = "";
try {
const { fileUrl, fileId } = await uploadImageToStorage(file);
persistentUrl = fileUrl;
storageFileId = fileId;
} catch (e) {
// Fallback to blob URL if upload fails, but mark as error later
persistentUrl = URL.createObjectURL(file);
}
// Load to get natural dimensions
const dims = await new Promise<{ w: number; h: number }>((resolve) => {
const img = new Image();
img.onload = () => {
const maxW = 640;
const maxH = 480;
const w = img.naturalWidth;
const h = img.naturalHeight;
const target = 0.5; // scale to 50% of natural size
const scale = Math.min(target, maxW / w, maxH / h);
resolve({
w: Math.max(180, Math.floor(w * scale)),
h: Math.max(120, Math.floor(h * scale)),
});
};
img.onerror = () => resolve({ w: 300, h: 200 });
img.src = persistentUrl;
});
const shapeId = createShapeId();
editor.createShape({
id: shapeId,
type: "ai-image",
x: position.x,
y: position.y,
props: {
imageUrl: persistentUrl,
filename: file.name,
uploadDate: Date.now(),
processingStatus: storageFileId ? "idle" : "error",
extractedText: "",
w: dims.w,
h: dims.h,
},
});
await processImageWithOCR(editor, shapeId, file);
}

View File

@@ -4,3 +4,9 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getApiBaseUrl() {
// Prefer Vite env; fallback to relative proxy (same origin)
const base = import.meta.env.VITE_SERVER_URL as string | undefined;
return base?.replace(/\/$/, "") ?? "";
}

View File

@@ -2,20 +2,30 @@ import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useMemo, useRef, useState } from "react";
import {
createTLStore,
defaultShapeUtils,
getSnapshot,
loadSnapshot,
type TLStoreWithStatus,
Tldraw,
type TLComponents,
DefaultToolbar,
DefaultToolbarContent,
useEditor,
} from "tldraw";
import { z } from "zod";
import "tldraw/tldraw.css";
import Loader from "@/components/loader";
import { Button } from "@/components/ui/button";
import {
getLatestSpaceSnapshot,
type RemoteSnapshot,
upsertSpaceSnapshot,
} from "@/lib/appwrite-db";
import { authClient } from "@/lib/auth-client";
import { AIImageShapeUtil, AITextResultShapeUtil } from "@/lib/tldraw/ai-shapes";
import { createImageShapeFromFile, setupFileDropHandler } from "@/lib/tldraw/processing";
import { Camera } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export const Route = createFileRoute("/space")({
validateSearch: z.object({
@@ -38,7 +48,13 @@ function SpaceRoute() {
}, [session, isPending, navigate, id]);
// Create a stable store instance once
const store = useMemo(() => createTLStore(), []);
const store = useMemo(
() =>
createTLStore({
shapeUtils: [...defaultShapeUtils, AIImageShapeUtil, AITextResultShapeUtil],
}),
[]
);
// TL Store with status for remote-loaded snapshots
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
@@ -83,11 +99,82 @@ function SpaceRoute() {
return <Loader />;
}
// Provide a custom Toolbar that injects our upload buttons into the bottom toolbar
const components: TLComponents = {
Toolbar: (props) => {
const editor = useEditor();
async function pickImageFile(): Promise<File | null> {
try {
if ("showOpenFilePicker" in window) {
const picker = await (window as any).showOpenFilePicker({
multiple: false,
types: [{ description: "Images", accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"] } }],
excludeAcceptAllOption: false,
});
const file = await picker[0]?.getFile();
return file ?? null;
}
} catch { }
return new Promise<File | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.style.position = "fixed";
input.style.left = "-10000px";
document.body.appendChild(input);
const cleanup = () => input.remove();
input.addEventListener(
"change",
() => {
const f = input.files?.[0] ?? null;
cleanup();
resolve(f);
},
{ once: true }
);
input.click();
});
}
const handlePickImage = async () => {
const file = await pickImageFile();
if (!file) return;
const { x, y, w, h } = editor.getViewportPageBounds();
await createImageShapeFromFile(editor, file, { x: x + w / 2, y: y + h / 2 });
};
return (
<DefaultToolbar {...props}>
{/* Custom actions group at the start (left) of the toolbar */}
<div className="mx-1" style={{ display: "flex", alignItems: "center", gap: 4 }}>
<Tooltip>
<TooltipTrigger>
<Button type="button" onClick={handlePickImage}>
{/* Upload Image (OCR) */}
<Camera />
</Button>
</TooltipTrigger >
<TooltipContent >
Upload Image (OCR)
</TooltipContent >
</Tooltip>
</div>
{/* Separator */}
<div style={{ borderRight: "1px solid #888", height: "30px", margin: "0 8px" }} />
<DefaultToolbarContent />
</DefaultToolbar>
);
},
};
return (
<div className="mx-4 mt-4" style={{ position: "relative", inset: 0 }}>
<Tldraw
onMount={(editor) => {
editor.user.updateUserPreferences({ colorScheme: "dark" });
// Expose editor for helper UI
(window as any).editor = editor;
// Debounced save on document changes (user-originated)
const debounceMs = 1200;
@@ -119,14 +206,23 @@ function SpaceRoute() {
{ scope: "document", source: "user" }
);
// Setup drag & drop for images/audio -> AI shapes
const teardownDrop = setupFileDropHandler(editor);
// Cleanup listener on unmount
return () => {
unlisten();
window.clearTimeout(timeout);
// Remove DnD handlers
if (typeof teardownDrop === "function") teardownDrop();
(window as any).editor = undefined;
};
}}
shapeUtils={[...defaultShapeUtils, AIImageShapeUtil, AITextResultShapeUtil]}
store={storeWithStatus}
components={components}
/>
</div>
);
}
// Removed absolute-positioned overlay; actions are now inside the bottom toolbar via components.Toolbar

9
apps/web/src/types/ambient.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module "@/components/DotGrid" {
const Component: React.ComponentType<any>;
export default Component;
}
declare module "@/components/ShinyText" {
const Component: React.ComponentType<any>;
export default Component;
}