mirror of
https://github.com/FranP-code/Reflecto.git
synced 2025-10-13 00:43:31 +00:00
feat: add OCR endpoint using OpenRouter Sonoma Sky Alpha model
This commit is contained in:
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -32,5 +32,11 @@
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"cSpell.words": ["appwrite", "Reflecto", "Tldraw"]
|
||||
"cSpell.words": [
|
||||
"appwrite",
|
||||
"OPENROUTER",
|
||||
"Reflecto",
|
||||
"sonoma",
|
||||
"Tldraw"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,4 @@
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
103
apps/web/src/components/ui/tooltip.tsx
Normal file
103
apps/web/src/components/ui/tooltip.tsx
Normal 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,
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
369
apps/web/src/lib/tldraw/ai-shapes.tsx
Normal file
369
apps/web/src/lib/tldraw/ai-shapes.tsx
Normal 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;
|
||||
}
|
||||
185
apps/web/src/lib/tldraw/knowledge-graph.ts
Normal file
185
apps/web/src/lib/tldraw/knowledge-graph.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
141
apps/web/src/lib/tldraw/processing.ts
Normal file
141
apps/web/src/lib/tldraw/processing.ts
Normal 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);
|
||||
}
|
||||
@@ -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(/\/$/, "") ?? "";
|
||||
}
|
||||
|
||||
@@ -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
9
apps/web/src/types/ambient.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user