deleted forgotten implement files and readme

This commit is contained in:
Matthias
2026-03-25 18:00:44 +01:00
parent ca40f5cb13
commit 2f4d8a7172
4 changed files with 0 additions and 341 deletions

View File

@@ -1,103 +0,0 @@
# Bild-Upload via Convex Storage — Einbau-Anleitung
## Konzept
Der Upload-Flow nutzt Convex File Storage in 3 Schritten:
1. **generateUploadUrl** → kurzlebige Upload-URL vom Backend
2. **fetch(POST)** → Datei direkt an Convex Storage senden
3. **updateData**`storageId` im Node speichern
Die **URL wird serverseitig** in der `nodes.list` Query aufgelöst — nicht
am Client. Das heißt: der Node speichert nur die `storageId`, und bei
jedem Query-Aufruf wird `ctx.storage.getUrl(storageId)` aufgerufen und
als `data.url` zurückgegeben.
## Dateien
```
upload-files/
convex/
storage.ts → convex/storage.ts (NEU)
nodes-list-patch.ts → PATCH für convex/nodes.ts (NUR die list Query ersetzen)
components/canvas/nodes/
image-node.tsx → ERSETZT alte Version
Gesamt: 3 Dateien (1 neu, 1 Patch, 1 Ersatz)
```
## Einbau-Schritte
### 1. `convex/storage.ts` anlegen
Kopiere die Datei direkt. Sie enthält eine einzige Mutation: `generateUploadUrl`.
### 2. `convex/nodes.ts` — `list` Query patchen
Ersetze **nur die `list` Query** in deiner bestehenden `convex/nodes.ts`
mit der Version aus `nodes-list-patch.ts`. Der Rest der Datei
(create, move, resize, etc.) bleibt unverändert.
Die Änderung: Nach dem `collect()` wird über alle Nodes iteriert.
Wenn ein Node `data.storageId` hat, wird `ctx.storage.getUrl()` aufgerufen
und das Ergebnis als `data.url` eingefügt.
**Wichtig:** Du brauchst den `Id` Import oben in der Datei:
```ts
import type { Doc, Id } from "./_generated/dataModel";
```
(Du hast `Doc` wahrscheinlich schon importiert — füge `Id` hinzu falls nötig.)
### 3. `image-node.tsx` ersetzen
Die neue Version hat:
- **Click-to-Upload**: Klick auf den leeren Node öffnet File-Picker
- **Drag & Drop**: Bilder direkt auf den Node ziehen (Files vom OS)
- **Ersetzen-Button**: Wenn bereits ein Bild vorhanden, oben rechts "Ersetzen"
- **Upload-Spinner**: Während des Uploads dreht sich ein Spinner
- **Dateiname**: Wird unter dem Bild angezeigt
## Upload-Flow im Detail
```
User zieht Bild auf Image-Node
├─ handleDrop() → uploadFile(file)
├─ 1. generateUploadUrl() → Convex Mutation
│ ← postUrl (kurzlebig)
├─ 2. fetch(postUrl, { body: file })
│ ← { storageId: "kg..." }
├─ 3. updateData({ nodeId, data: { storageId, filename, mimeType } })
│ → Convex speichert storageId im Node
└─ 4. nodes.list Query feuert automatisch neu (Realtime)
→ ctx.storage.getUrl(storageId) → data.url
→ Image-Node rendert das Bild
```
## Testing
### Test 1: Click-to-Upload
- Erstelle einen Image-Node (Sidebar oder Toolbar)
- Klicke auf "Klicken oder hierhin ziehen"
- ✅ File-Picker öffnet sich
- Wähle ein Bild (PNG/JPG/WebP)
- ✅ Spinner erscheint kurz, dann wird das Bild angezeigt
- ✅ Convex Dashboard: `data.storageId` ist gesetzt
### Test 2: Drag & Drop (File vom OS)
- Ziehe ein Bild aus dem Finder/Explorer direkt auf den Image-Node
- ✅ Drop-Zone wird blau hervorgehoben
- ✅ Bild wird hochgeladen und angezeigt
### Test 3: Bild ersetzen
- Klicke "Ersetzen" oben rechts am Image-Node
- Wähle ein neues Bild
- ✅ Altes Bild wird ersetzt, neue storageId in Convex
### Test 4: URL wird serverseitig aufgelöst
- Lade die Seite neu
- ✅ Bild wird weiterhin angezeigt (URL wird bei jedem Query neu aufgelöst)
### Test 5: Nicht-Bild-Dateien werden ignoriert
- Versuche eine .txt oder .pdf auf den Node zu ziehen
- ✅ Nichts passiert (nur image/* wird akzeptiert)

View File

@@ -1,187 +0,0 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
type ImageNodeData = {
storageId?: string;
url?: string;
filename?: string;
mimeType?: string;
_status?: string;
_statusMessage?: string;
};
export type ImageNode = Node<ImageNodeData, "image">;
export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>) {
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const updateData = useMutation(api.nodes.updateData);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
if (!file.type.startsWith("image/")) return;
setIsUploading(true);
try {
// 1. Upload-URL generieren
const uploadUrl = await generateUploadUrl();
// 2. Datei hochladen
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
const { storageId } = await result.json();
// 3. Node-Data mit storageId aktualisieren
// Die URL wird serverseitig in der nodes.list Query aufgelöst
await updateData({
nodeId: id as Id<"nodes">,
data: {
storageId,
filename: file.name,
mimeType: file.type,
},
});
} catch (err) {
console.error("Upload failed:", err);
} finally {
setIsUploading(false);
}
},
[id, generateUploadUrl, updateData],
);
// Click-to-Upload
const handleClick = useCallback(() => {
if (!data.url && !isUploading) {
fileInputRef.current?.click();
}
}, [data.url, isUploading]);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
},
[uploadFile],
);
// Drag & Drop auf den Node
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true);
e.dataTransfer.dropEffect = "copy";
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith("image/")) {
uploadFile(file);
}
},
[uploadFile],
);
// Bild ersetzen
const handleReplace = useCallback(() => {
fileInputRef.current?.click();
}, []);
return (
<BaseNodeWrapper selected={selected} status={data._status}>
<div className="p-2">
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-medium text-muted-foreground">
🖼 Bild
</div>
{data.url && (
<button
onClick={handleReplace}
className="nodrag text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Ersetzen
</button>
)}
</div>
{isUploading ? (
<div className="flex h-36 w-56 items-center justify-center rounded-lg bg-muted">
<div className="flex flex-col items-center gap-2">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-xs text-muted-foreground">
Wird hochgeladen
</span>
</div>
</div>
) : data.url ? (
<img
src={data.url}
alt={data.filename ?? "Bild"}
className="rounded-lg object-cover max-w-[260px]"
draggable={false}
/>
) : (
<div
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
nodrag flex h-36 w-56 cursor-pointer flex-col items-center justify-center
rounded-lg border-2 border-dashed text-sm transition-colors
${isDragOver ? "border-primary bg-primary/5 text-primary" : "text-muted-foreground hover:border-primary/50 hover:text-foreground"}
`}
>
<span className="text-lg mb-1">📁</span>
<span>Klicken oder hierhin ziehen</span>
<span className="text-xs mt-0.5">PNG, JPG, WebP</span>
</div>
)}
{data.filename && data.url && (
<p className="mt-1 text-xs text-muted-foreground truncate max-w-[260px]">
{data.filename}
</p>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleFileChange}
className="hidden"
/>
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
/>
</BaseNodeWrapper>
);
}

View File

@@ -1,37 +0,0 @@
/**
* PATCH für convex/nodes.ts
*
* Ersetze die bestehende `list` Query mit dieser Version.
* Der einzige Unterschied: Für Nodes mit einem `storageId` im data-Objekt
* wird die Storage-URL aufgelöst und als `data.url` zurückgegeben.
*/
export const list = query({
args: { canvasId: v.id("canvases") },
handler: async (ctx, { canvasId }) => {
const user = await requireAuth(ctx);
await getCanvasOrThrow(ctx, canvasId, user.userId);
const nodes = await ctx.db
.query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect();
// Storage-URLs für Nodes mit storageId auflösen
return Promise.all(
nodes.map(async (node) => {
const data = node.data as Record<string, unknown> | undefined;
if (data?.storageId) {
const url = await ctx.storage.getUrl(
data.storageId as Id<"_storage">
);
return {
...node,
data: { ...data, url: url ?? undefined },
};
}
return node;
})
);
},
});

View File

@@ -1,14 +0,0 @@
import { mutation } from "./_generated/server";
import { requireAuth } from "./helpers";
/**
* Generiert eine kurzlebige Upload-URL für Convex File Storage.
* Der Client POSTet die Datei direkt an diese URL.
*/
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
await requireAuth(ctx);
return await ctx.storage.generateUploadUrl();
},
});