deleted forgotten implement files and readme
This commit is contained in:
@@ -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)
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user