Refactor canvas storage URL handling and integrate adjustment presets
- Introduced a new `CanvasPresetsProvider` to manage adjustment presets for nodes, enhancing state management and reducing reactivity. - Updated storage URL resolution to utilize a mutation instead of a reactive query, improving performance and reducing unnecessary re-renders. - Refactored adjustment nodes (color-adjust, curves, detail-adjust, light-adjust) to use the new preset context for fetching user presets. - Improved overall canvas functionality by streamlining storage ID collection and URL resolution processes.
This commit is contained in:
@@ -29,6 +29,7 @@ app/(app)/canvas/[canvasId]/page.tsx
|
|||||||
| Datei | Zweck |
|
| Datei | Zweck |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| `canvas-helpers.ts` | Shared Utility-Layer (Optimistic IDs, Node-Merge, Compare-Resolution, Edge/Hit-Helpers, Konstante Defaults) |
|
| `canvas-helpers.ts` | Shared Utility-Layer (Optimistic IDs, Node-Merge, Compare-Resolution, Edge/Hit-Helpers, Konstante Defaults) |
|
||||||
|
| `canvas-presets-context.tsx` | Shared Preset-Provider für Adjustment-Nodes; bündelt `presets.list` zu einer einzigen Query |
|
||||||
| `canvas-node-change-helpers.ts` | Dimensions-/Resize-Transformationen für `asset` und `ai-image` Nodes |
|
| `canvas-node-change-helpers.ts` | Dimensions-/Resize-Transformationen für `asset` und `ai-image` Nodes |
|
||||||
| `canvas-generation-failures.ts` | Hook für AI-Generation-Error-Tracking mit Schwellenwert-Toast |
|
| `canvas-generation-failures.ts` | Hook für AI-Generation-Error-Tracking mit Schwellenwert-Toast |
|
||||||
| `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) |
|
| `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) |
|
||||||
@@ -53,7 +54,9 @@ Convex und React Flow verwenden unterschiedliche Datenmodelle. Das Mapping liegt
|
|||||||
|
|
||||||
**Status-Injection:** `convexNodeToRF` schreibt `_status`, `_statusMessage` und `retryCount` in `data`, damit Node-Komponenten darauf zugreifen können ohne das Node-Dokument direkt zu kennen.
|
**Status-Injection:** `convexNodeToRF` schreibt `_status`, `_statusMessage` und `retryCount` in `data`, damit Node-Komponenten darauf zugreifen können ohne das Node-Dokument direkt zu kennen.
|
||||||
|
|
||||||
**URL-Caching:** Images mit `storageId` werden über einen batch-Storage-URL-Query aufgelöst (`urlByStorage`-Map). Die vorherige URL wird in `previousDataByNodeId` gecacht, um Flackern beim Reload zu vermeiden.
|
**URL-Caching:** Images mit `storageId` werden im Canvas nicht mehr über eine reaktive Query aufgelöst. `canvas.tsx` sammelt die aktuellen `storageId`s aus `nodes.list` und ruft `storage.batchGetUrlsForCanvas` gezielt per Mutation auf, nur wenn sich das Set ändert. Die vorherige URL wird in `previousDataByNodeId` gecacht, um Flackern beim Reload zu vermeiden.
|
||||||
|
|
||||||
|
**Load-Shedding-Hot-Path:** Der Canvas-Hot-Path soll so wenig Convex-Abhängigkeiten wie möglich haben. Direkt reaktiv bleiben nur die Kernmodelle (`nodes.list`, `edges.list`, `canvases.get`). Nebenpfade wie Storage-URL-Auflösung, Adjustment-Presets und Toolbar-Credits sind bewusst entkoppelt oder zusammengefasst.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -146,7 +149,7 @@ Im **Light Mode** wird der eigentliche Edge-`stroke` ebenfalls aus dieser Akzent
|
|||||||
| `asset-browser-panel.tsx` | Freepik/Stock-Asset-Browser |
|
| `asset-browser-panel.tsx` | Freepik/Stock-Asset-Browser |
|
||||||
| `video-browser-panel.tsx` | Video-Asset-Browser |
|
| `video-browser-panel.tsx` | Video-Asset-Browser |
|
||||||
| `canvas-user-menu.tsx` | User-Avatar und Menü |
|
| `canvas-user-menu.tsx` | User-Avatar und Menü |
|
||||||
| `credit-display.tsx` | Credit-Balance Anzeige in der Toolbar |
|
| `credit-display.tsx` | Credit-Balance Anzeige in der Toolbar (nur `credits.getBalance`, kein Tier-Badge) |
|
||||||
| `export-button.tsx` | Export-Button mit Format-Auswahl |
|
| `export-button.tsx` | Export-Button mit Format-Auswahl |
|
||||||
| `connection-banner.tsx` | Offline-Banner bei Convex-Verbindungsverlust |
|
| `connection-banner.tsx` | Offline-Banner bei Convex-Verbindungsverlust |
|
||||||
| `custom-connection-line.tsx` | Angepasste temporäre Verbindungslinie |
|
| `custom-connection-line.tsx` | Angepasste temporäre Verbindungslinie |
|
||||||
@@ -168,6 +171,7 @@ Im **Light Mode** wird der eigentliche Edge-`stroke` ebenfalls aus dieser Akzent
|
|||||||
## Wichtige Gotchas
|
## Wichtige Gotchas
|
||||||
|
|
||||||
- **`data.url` vs `storageId`:** Node-Komponenten erhalten `data.url` (aufgelöste HTTP-URL), nicht `storageId` direkt. Die URL wird von `convexNodeDocWithMergedStorageUrl` injiziert. Bei neuen Node-Typen mit Bild immer diesen Flow prüfen.
|
- **`data.url` vs `storageId`:** Node-Komponenten erhalten `data.url` (aufgelöste HTTP-URL), nicht `storageId` direkt. Die URL wird von `convexNodeDocWithMergedStorageUrl` injiziert. Bei neuen Node-Typen mit Bild immer diesen Flow prüfen.
|
||||||
|
- **Adjustment-Presets:** `curves`, `color-adjust`, `light-adjust` und `detail-adjust` dürfen keine eigene `presets.list`-Query feuern. Immer `CanvasPresetsProvider` + `useCanvasAdjustmentPresets(...)` verwenden.
|
||||||
- **Min-Zoom:** `CANVAS_MIN_ZOOM = 0.5 / 3` — dreimal weiter raus als React-Flow-Default.
|
- **Min-Zoom:** `CANVAS_MIN_ZOOM = 0.5 / 3` — dreimal weiter raus als React-Flow-Default.
|
||||||
- **Parent-Nodes:** `parentId` zeigt auf einen Group- oder Frame-Node. React Flow erwartet, dass Parent-Nodes vor Child-Nodes in der `nodes`-Array stehen.
|
- **Parent-Nodes:** `parentId` zeigt auf einen Group- oder Frame-Node. React Flow erwartet, dass Parent-Nodes vor Child-Nodes in der `nodes`-Array stehen.
|
||||||
- **Bridge-Edges:** Beim Löschen eines mittleren Nodes werden Kanten automatisch neu verbunden (`computeBridgeCreatesForDeletedNodes` aus `lib/canvas-utils.ts`).
|
- **Bridge-Edges:** Beim Löschen eines mittleren Nodes werden Kanten automatisch neu verbunden (`computeBridgeCreatesForDeletedNodes` aus `lib/canvas-utils.ts`).
|
||||||
|
|||||||
56
components/canvas/canvas-presets-context.tsx
Normal file
56
components/canvas/canvas-presets-context.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import type { Doc } from "@/convex/_generated/dataModel";
|
||||||
|
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||||
|
|
||||||
|
type AdjustmentPresetDoc = Doc<"adjustmentPresets">;
|
||||||
|
type PresetsByNodeType = Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>;
|
||||||
|
|
||||||
|
const CanvasPresetsContext = createContext<PresetsByNodeType | null>(null);
|
||||||
|
|
||||||
|
type CanvasPresetsProviderProps = {
|
||||||
|
enabled?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasPresetsProvider({
|
||||||
|
enabled = true,
|
||||||
|
children,
|
||||||
|
}: CanvasPresetsProviderProps) {
|
||||||
|
const rawPresets = useAuthQuery(api.presets.list, enabled ? {} : "skip");
|
||||||
|
|
||||||
|
const presetsByNodeType = useMemo<PresetsByNodeType>(() => {
|
||||||
|
const next = new Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>();
|
||||||
|
|
||||||
|
for (const preset of (rawPresets ?? []) as AdjustmentPresetDoc[]) {
|
||||||
|
const existing = next.get(preset.nodeType);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(preset);
|
||||||
|
} else {
|
||||||
|
next.set(preset.nodeType, [preset]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}, [rawPresets]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CanvasPresetsContext.Provider value={presetsByNodeType}>
|
||||||
|
{children}
|
||||||
|
</CanvasPresetsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCanvasAdjustmentPresets(
|
||||||
|
nodeType: AdjustmentPresetDoc["nodeType"],
|
||||||
|
): AdjustmentPresetDoc[] {
|
||||||
|
const context = useContext(CanvasPresetsContext);
|
||||||
|
if (context === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.get(nodeType) ?? [];
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ import { api } from "@/convex/_generated/api";
|
|||||||
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import {
|
import {
|
||||||
|
isAdjustmentPresetNodeType,
|
||||||
isCanvasNodeType,
|
isCanvasNodeType,
|
||||||
type CanvasNodeType,
|
type CanvasNodeType,
|
||||||
} from "@/lib/canvas-node-types";
|
} from "@/lib/canvas-node-types";
|
||||||
@@ -95,6 +96,7 @@ import {
|
|||||||
type ConnectionDropMenuState,
|
type ConnectionDropMenuState,
|
||||||
} from "@/components/canvas/canvas-connection-drop-menu";
|
} from "@/components/canvas/canvas-connection-drop-menu";
|
||||||
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
||||||
|
import { CanvasPresetsProvider } from "@/components/canvas/canvas-presets-context";
|
||||||
import {
|
import {
|
||||||
AssetBrowserTargetContext,
|
AssetBrowserTargetContext,
|
||||||
type AssetBrowserTargetApi,
|
type AssetBrowserTargetApi,
|
||||||
@@ -203,11 +205,6 @@ function summarizeResizePayload(payload: unknown): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasStorageId(node: Doc<"nodes">): boolean {
|
|
||||||
const data = node.data as Record<string, unknown> | undefined;
|
|
||||||
return typeof data?.storageId === "string" && data.storageId.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCanvasConnection(
|
function validateCanvasConnection(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
nodes: RFNode[],
|
nodes: RFNode[],
|
||||||
@@ -261,10 +258,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
|
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
|
||||||
const shouldSkipCanvasQueries =
|
const shouldSkipCanvasQueries =
|
||||||
isSessionPending || isAuthLoading || !isAuthenticated;
|
isSessionPending || isAuthLoading || !isAuthenticated;
|
||||||
const convexAuthUserProbe = useQuery(
|
|
||||||
api.auth.safeGetAuthUser,
|
|
||||||
shouldSkipCanvasQueries ? "skip" : {},
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (process.env.NODE_ENV === "production") return;
|
if (process.env.NODE_ENV === "production") return;
|
||||||
@@ -282,8 +275,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
convex: {
|
convex: {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
shouldSkipCanvasQueries,
|
shouldSkipCanvasQueries,
|
||||||
probeUserId: convexAuthUserProbe?.userId ?? null,
|
|
||||||
probeRecordId: convexAuthUserProbe?._id ?? null,
|
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hasUser: Boolean(session?.user),
|
hasUser: Boolean(session?.user),
|
||||||
@@ -292,8 +283,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
canvasId,
|
canvasId,
|
||||||
convexAuthUserProbe?._id,
|
|
||||||
convexAuthUserProbe?.userId,
|
|
||||||
isAuthLoading,
|
isAuthLoading,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isSessionPending,
|
isSessionPending,
|
||||||
@@ -310,20 +299,71 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
api.edges.list,
|
api.edges.list,
|
||||||
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
||||||
);
|
);
|
||||||
const shouldSkipStorageUrlQuery = useMemo(() => {
|
const storageIdsForCanvas = useMemo(() => {
|
||||||
if (shouldSkipCanvasQueries) return true;
|
if (!convexNodes) {
|
||||||
if (convexNodes === undefined) return true;
|
return [] as Id<"_storage">[];
|
||||||
return !convexNodes.some(hasStorageId);
|
}
|
||||||
}, [convexNodes, shouldSkipCanvasQueries]);
|
|
||||||
const storageUrlsById = useQuery(
|
return [...new Set(
|
||||||
api.storage.batchGetUrlsForCanvas,
|
convexNodes.flatMap((node) => {
|
||||||
shouldSkipStorageUrlQuery ? "skip" : { canvasId },
|
const data = node.data as Record<string, unknown> | undefined;
|
||||||
);
|
return typeof data?.storageId === "string" && data.storageId.length > 0
|
||||||
|
? [data.storageId as Id<"_storage">]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
)].sort();
|
||||||
|
}, [convexNodes]);
|
||||||
|
const storageIdsForCanvasKey = storageIdsForCanvas.join(",");
|
||||||
|
const stableStorageIdsForCanvasRef = useRef(storageIdsForCanvas);
|
||||||
|
if (stableStorageIdsForCanvasRef.current.join(",") !== storageIdsForCanvasKey) {
|
||||||
|
stableStorageIdsForCanvasRef.current = storageIdsForCanvas;
|
||||||
|
}
|
||||||
|
const resolveStorageUrlsForCanvas = useMutation(api.storage.batchGetUrlsForCanvas);
|
||||||
|
const [storageUrlsById, setStorageUrlsById] = useState<Record<string, string | undefined>>();
|
||||||
const canvas = useQuery(
|
const canvas = useQuery(
|
||||||
api.canvases.get,
|
api.canvases.get,
|
||||||
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requestedStorageIds = stableStorageIdsForCanvasRef.current;
|
||||||
|
|
||||||
|
if (shouldSkipCanvasQueries || requestedStorageIds.length === 0) {
|
||||||
|
setStorageUrlsById(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void resolveStorageUrlsForCanvas({
|
||||||
|
canvasId,
|
||||||
|
storageIds: requestedStorageIds,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setStorageUrlsById(result);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn("[Canvas] failed to resolve storage URLs", {
|
||||||
|
canvasId,
|
||||||
|
storageIdCount: requestedStorageIds.length,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
canvasId,
|
||||||
|
resolveStorageUrlsForCanvas,
|
||||||
|
shouldSkipCanvasQueries,
|
||||||
|
storageIdsForCanvasKey,
|
||||||
|
]);
|
||||||
|
|
||||||
// ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ──
|
// ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ──
|
||||||
const moveNode = useMutation(api.nodes.move);
|
const moveNode = useMutation(api.nodes.move);
|
||||||
const resizeNode = useMutation(api.nodes.resize);
|
const resizeNode = useMutation(api.nodes.resize);
|
||||||
@@ -571,6 +611,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
const [nodes, setNodes] = useState<RFNode[]>([]);
|
const [nodes, setNodes] = useState<RFNode[]>([]);
|
||||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||||
|
const hasPresetAwareNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
nodes.some((node) => isAdjustmentPresetNodeType(node.type ?? "")) ||
|
||||||
|
(convexNodes ?? []).some((node) => isAdjustmentPresetNodeType(node.type)),
|
||||||
|
[convexNodes, nodes],
|
||||||
|
);
|
||||||
const edgesRef = useRef(edges);
|
const edgesRef = useRef(edges);
|
||||||
edgesRef.current = edges;
|
edgesRef.current = edges;
|
||||||
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||||
@@ -2970,6 +3016,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CanvasSyncProvider value={canvasSyncContextValue}>
|
<CanvasSyncProvider value={canvasSyncContextValue}>
|
||||||
|
<CanvasPresetsProvider enabled={hasPresetAwareNodes}>
|
||||||
<CanvasPlacementProvider
|
<CanvasPlacementProvider
|
||||||
canvasId={canvasId}
|
canvasId={canvasId}
|
||||||
createNode={runCreateNodeOnlineOnly}
|
createNode={runCreateNodeOnlineOnly}
|
||||||
@@ -3081,6 +3128,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</AssetBrowserTargetContext.Provider>
|
</AssetBrowserTargetContext.Provider>
|
||||||
</CanvasPlacementProvider>
|
</CanvasPlacementProvider>
|
||||||
|
</CanvasPresetsProvider>
|
||||||
</CanvasSyncProvider>
|
</CanvasSyncProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,6 @@ import { api } from "@/convex/_generated/api";
|
|||||||
import { Coins } from "lucide-react";
|
import { Coins } from "lucide-react";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
const TIER_LABELS: Record<string, string> = {
|
|
||||||
free: "Free",
|
|
||||||
starter: "Starter",
|
|
||||||
pro: "Pro",
|
|
||||||
max: "Max",
|
|
||||||
business: "Business",
|
|
||||||
};
|
|
||||||
|
|
||||||
const TIER_COLORS: Record<string, string> = {
|
|
||||||
free: "text-muted-foreground",
|
|
||||||
starter: "text-blue-500",
|
|
||||||
pro: "text-purple-500",
|
|
||||||
max: "text-amber-500",
|
|
||||||
business: "text-amber-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
const showTestCreditGrant =
|
const showTestCreditGrant =
|
||||||
typeof process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "string" &&
|
typeof process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "string" &&
|
||||||
process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true";
|
process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true";
|
||||||
@@ -30,10 +14,9 @@ const showTestCreditGrant =
|
|||||||
export function CreditDisplay() {
|
export function CreditDisplay() {
|
||||||
const t = useTranslations('toasts');
|
const t = useTranslations('toasts');
|
||||||
const balance = useAuthQuery(api.credits.getBalance);
|
const balance = useAuthQuery(api.credits.getBalance);
|
||||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
|
||||||
const grantTestCredits = useMutation(api.credits.grantTestCredits);
|
const grantTestCredits = useMutation(api.credits.grantTestCredits);
|
||||||
|
|
||||||
if (balance === undefined || subscription === undefined) {
|
if (balance === undefined) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5 animate-pulse">
|
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5 animate-pulse">
|
||||||
<Coins className="h-4 w-4 text-muted-foreground" />
|
<Coins className="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -43,9 +26,6 @@ export function CreditDisplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const available = balance.balance - balance.reserved;
|
const available = balance.balance - balance.reserved;
|
||||||
const tier = subscription.tier;
|
|
||||||
const tierLabel = TIER_LABELS[tier] ?? tier;
|
|
||||||
const tierColor = TIER_COLORS[tier] ?? "text-muted-foreground";
|
|
||||||
|
|
||||||
const isLow = available < 10;
|
const isLow = available < 10;
|
||||||
const isEmpty = available <= 0;
|
const isEmpty = available <= 0;
|
||||||
@@ -82,8 +62,6 @@ export function CreditDisplay() {
|
|||||||
({balance.reserved} reserved)
|
({balance.reserved} reserved)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground/70">·</span>
|
|
||||||
<span className={`text-xs font-medium ${tierColor}`}>{tierLabel}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{showTestCreditGrant && (
|
{showTestCreditGrant && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Palette } from "lucide-react";
|
|||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||||
@@ -47,7 +47,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
const tToasts = useTranslations("toasts");
|
const tToasts = useTranslations("toasts");
|
||||||
const { queueNodeDataUpdate } = useCanvasSync();
|
const { queueNodeDataUpdate } = useCanvasSync();
|
||||||
const savePreset = useMutation(api.presets.save);
|
const savePreset = useMutation(api.presets.save);
|
||||||
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "color-adjust" }) ?? []) as PresetDoc[];
|
const userPresets = useCanvasAdjustmentPresets("color-adjust") as PresetDoc[];
|
||||||
|
|
||||||
const [localData, setLocalData] = useState<ColorAdjustData>(() =>
|
const [localData, setLocalData] = useState<ColorAdjustData>(() =>
|
||||||
normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }),
|
normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TrendingUp } from "lucide-react";
|
|||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||||
@@ -47,7 +47,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
|||||||
const tToasts = useTranslations("toasts");
|
const tToasts = useTranslations("toasts");
|
||||||
const { queueNodeDataUpdate } = useCanvasSync();
|
const { queueNodeDataUpdate } = useCanvasSync();
|
||||||
const savePreset = useMutation(api.presets.save);
|
const savePreset = useMutation(api.presets.save);
|
||||||
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "curves" }) ?? []) as PresetDoc[];
|
const userPresets = useCanvasAdjustmentPresets("curves") as PresetDoc[];
|
||||||
|
|
||||||
const [localData, setLocalData] = useState<CurvesData>(() =>
|
const [localData, setLocalData] = useState<CurvesData>(() =>
|
||||||
normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }),
|
normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Focus } from "lucide-react";
|
|||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||||
@@ -47,7 +47,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
|||||||
const tToasts = useTranslations("toasts");
|
const tToasts = useTranslations("toasts");
|
||||||
const { queueNodeDataUpdate } = useCanvasSync();
|
const { queueNodeDataUpdate } = useCanvasSync();
|
||||||
const savePreset = useMutation(api.presets.save);
|
const savePreset = useMutation(api.presets.save);
|
||||||
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "detail-adjust" }) ?? []) as PresetDoc[];
|
const userPresets = useCanvasAdjustmentPresets("detail-adjust") as PresetDoc[];
|
||||||
|
|
||||||
const [localData, setLocalData] = useState<DetailAdjustData>(() =>
|
const [localData, setLocalData] = useState<DetailAdjustData>(() =>
|
||||||
normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }),
|
normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Sun } from "lucide-react";
|
|||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||||
@@ -47,7 +47,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
|||||||
const tToasts = useTranslations("toasts");
|
const tToasts = useTranslations("toasts");
|
||||||
const { queueNodeDataUpdate } = useCanvasSync();
|
const { queueNodeDataUpdate } = useCanvasSync();
|
||||||
const savePreset = useMutation(api.presets.save);
|
const savePreset = useMutation(api.presets.save);
|
||||||
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "light-adjust" }) ?? []) as PresetDoc[];
|
const userPresets = useCanvasAdjustmentPresets("light-adjust") as PresetDoc[];
|
||||||
|
|
||||||
const [localData, setLocalData] = useState<LightAdjustData>(() =>
|
const [localData, setLocalData] = useState<LightAdjustData>(() =>
|
||||||
normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }),
|
normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Convex ist das vollständige Backend von LemonSpace: Datenbank, Realtime-Subscri
|
|||||||
| `polar.ts` | Polar.sh Webhook-Handler (Subscriptions) |
|
| `polar.ts` | Polar.sh Webhook-Handler (Subscriptions) |
|
||||||
| `pexels.ts` | Pexels Stock-Bilder API |
|
| `pexels.ts` | Pexels Stock-Bilder API |
|
||||||
| `freepik.ts` | Freepik Asset-Browser API |
|
| `freepik.ts` | Freepik Asset-Browser API |
|
||||||
| `storage.ts` | Convex File Storage Helpers |
|
| `storage.ts` | Convex File Storage Helpers + gebündelte Canvas-URL-Auflösung |
|
||||||
| `export.ts` | Canvas-Export-Logik |
|
| `export.ts` | Canvas-Export-Logik |
|
||||||
| `http.ts` | HTTP-Endpunkte (Webhooks) |
|
| `http.ts` | HTTP-Endpunkte (Webhooks) |
|
||||||
|
|
||||||
@@ -148,6 +148,16 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Storage (`storage.ts`)
|
||||||
|
|
||||||
|
- `generateUploadUrl` bleibt eine normale Mutation für Upload-Start im Client.
|
||||||
|
- `batchGetUrlsForCanvas` ist absichtlich **keine reaktive Query** mehr, sondern eine Mutation. Der Canvas ruft sie gezielt an, wenn sich das aktuelle Set von `storageId`s geändert hat.
|
||||||
|
- Eingabe: `canvasId` + client-seitig ermittelte `storageIds`.
|
||||||
|
- Server-seitig werden die angefragten IDs gegen die aktuellen Nodes des Canvas verifiziert, bevor `ctx.storage.getUrl(...)` aufgerufen wird.
|
||||||
|
- Ziel der Änderung: weniger Query-Fanout und weniger Canvas-weite Requery-Last bei jedem Node-/Edge-Update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Konventionen
|
## Konventionen
|
||||||
|
|
||||||
- `internalMutation` / `internalAction` — Nur von anderen Convex-Funktionen aufrufbar, nicht direkt vom Client.
|
- `internalMutation` / `internalAction` — Nur von anderen Convex-Funktionen aufrufbar, nicht direkt vom Client.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mutation, query, type QueryCtx } from "./_generated/server";
|
import { mutation, type MutationCtx, type QueryCtx } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { requireAuth } from "./helpers";
|
import { requireAuth } from "./helpers";
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
@@ -31,7 +31,7 @@ type StorageUrlResult =
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function assertCanvasOwner(
|
async function assertCanvasOwner(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx | MutationCtx,
|
||||||
canvasId: Id<"canvases">,
|
canvasId: Id<"canvases">,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -41,29 +41,6 @@ async function assertCanvasOwner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listNodesForCanvas(ctx: QueryCtx, canvasId: Id<"canvases">) {
|
|
||||||
return await ctx.db
|
|
||||||
.query("nodes")
|
|
||||||
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectStorageIds(
|
|
||||||
nodes: Array<{ data: unknown }>,
|
|
||||||
): Array<Id<"_storage">> {
|
|
||||||
const ids = new Set<Id<"_storage">>();
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
const data = node.data as Record<string, unknown> | undefined;
|
|
||||||
const storageId = data?.storageId;
|
|
||||||
if (typeof storageId === "string" && storageId.length > 0) {
|
|
||||||
ids.add(storageId as Id<"_storage">);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...ids];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveStorageUrls(
|
async function resolveStorageUrls(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
storageIds: Array<Id<"_storage">>,
|
storageIds: Array<Id<"_storage">>,
|
||||||
@@ -141,33 +118,67 @@ export const generateUploadUrl = mutation({
|
|||||||
* Signierte URLs für alle Storage-Assets eines Canvas (gebündelt).
|
* Signierte URLs für alle Storage-Assets eines Canvas (gebündelt).
|
||||||
* `nodes.list` liefert keine URLs mehr, damit Node-Liste schnell bleibt.
|
* `nodes.list` liefert keine URLs mehr, damit Node-Liste schnell bleibt.
|
||||||
*/
|
*/
|
||||||
export const batchGetUrlsForCanvas = query({
|
export const batchGetUrlsForCanvas = mutation({
|
||||||
args: { canvasId: v.id("canvases") },
|
args: {
|
||||||
handler: async (ctx, { canvasId }) => {
|
canvasId: v.id("canvases"),
|
||||||
|
storageIds: v.array(v.id("_storage")),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { canvasId, storageIds }) => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
await assertCanvasOwner(ctx, canvasId, user.userId);
|
await assertCanvasOwner(ctx, canvasId, user.userId);
|
||||||
|
|
||||||
|
const uniqueSortedStorageIds = [...new Set(storageIds)].sort();
|
||||||
|
if (uniqueSortedStorageIds.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
const nodes = await listNodesForCanvas(ctx, canvasId);
|
const nodes = await listNodesForCanvas(ctx, canvasId);
|
||||||
const nodeCount = nodes.length;
|
const allowedStorageIds = new Set(collectStorageIds(nodes));
|
||||||
const storageIds = collectStorageIds(nodes);
|
const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) =>
|
||||||
const collectTimeMs = Date.now() - startedAt;
|
allowedStorageIds.has(storageId),
|
||||||
if (collectTimeMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
);
|
||||||
console.warn("[storage.batchGetUrlsForCanvas] slow node scan", {
|
const rejectedStorageIds = uniqueSortedStorageIds.length - verifiedStorageIds.length;
|
||||||
|
if (rejectedStorageIds > 0) {
|
||||||
|
console.warn("[storage.batchGetUrlsForCanvas] rejected unowned storage ids", {
|
||||||
canvasId,
|
canvasId,
|
||||||
nodeCount,
|
requestedCount: uniqueSortedStorageIds.length,
|
||||||
storageIdCount: storageIds.length,
|
rejectedStorageIds,
|
||||||
durationMs: collectTimeMs,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await resolveStorageUrls(ctx, storageIds);
|
const result = await resolveStorageUrls(ctx, verifiedStorageIds);
|
||||||
logSlowQuery("batchGetUrlsForCanvas::total", startedAt, {
|
logSlowQuery("batchGetUrlsForCanvas::total", startedAt, {
|
||||||
canvasId,
|
canvasId,
|
||||||
nodeCount,
|
storageIdCount: verifiedStorageIds.length,
|
||||||
storageIdCount: storageIds.length,
|
rejectedStorageIds,
|
||||||
resolvedCount: Object.keys(result).length,
|
resolvedCount: Object.keys(result).length,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
async function listNodesForCanvas(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
canvasId: Id<"canvases">,
|
||||||
|
) {
|
||||||
|
return await ctx.db
|
||||||
|
.query("nodes")
|
||||||
|
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectStorageIds(
|
||||||
|
nodes: Array<{ data: unknown }>,
|
||||||
|
): Array<Id<"_storage">> {
|
||||||
|
const ids = new Set<Id<"_storage">>();
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const data = node.data as Record<string, unknown> | undefined;
|
||||||
|
const storageId = data?.storageId;
|
||||||
|
if (typeof storageId === "string" && storageId.length > 0) {
|
||||||
|
ids.add(storageId as Id<"_storage">);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|||||||
33
docs/plans/2026-04-03-canvas-convex-load-shedding-design.md
Normal file
33
docs/plans/2026-04-03-canvas-convex-load-shedding-design.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Canvas Convex Load Shedding Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Reduce Convex query fanout and hot-path load on the canvas page so node placement and canvas interaction no longer collapse when the local Convex runtime is under pressure.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
- `nodes:create` itself is fast when the runtime is healthy.
|
||||||
|
- The canvas page issues multiple concurrent reactive queries: `nodes:list`, `edges:list`, `canvases:get`, `credits:getBalance`, `credits:getSubscription`, `storage:batchGetUrlsForCanvas`, and several `presets:list` variants.
|
||||||
|
- During failure windows, many unrelated queries time out together, which points to runtime saturation rather than a single broken query.
|
||||||
|
|
||||||
|
## Chosen Approach
|
||||||
|
|
||||||
|
Use a broad load-shedding refactor on the canvas hot path:
|
||||||
|
|
||||||
|
1. Remove debug-only and non-essential queries from the core canvas render path.
|
||||||
|
2. Eliminate the server-side batch storage URL query by deriving stable fallback URLs client-side from `storageId`.
|
||||||
|
3. Collapse multiple adjustment preset queries into one shared presets query and distribute the data through React context.
|
||||||
|
4. Drop the subscription-tier query from the canvas toolbar and keep the toolbar credit widget on the cheaper balance path only.
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
- Fewer unique Convex subscriptions on the canvas page.
|
||||||
|
- Less full-canvas invalidation work after every node or edge mutation.
|
||||||
|
- Lower pressure on `auth:safeGetAuthUser` because fewer canvas-adjacent queries depend on it.
|
||||||
|
- Better resilience when Convex dev runtime is temporarily slow.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Client-side fallback storage URLs must continue to work with the current Convex deployment setup.
|
||||||
|
- The toolbar will no longer show the subscription tier on the canvas page.
|
||||||
|
- Shared preset context must preserve current node behavior and save/remove flows.
|
||||||
63
docs/plans/2026-04-03-canvas-convex-load-shedding.md
Normal file
63
docs/plans/2026-04-03-canvas-convex-load-shedding.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Canvas Convex Load Shedding Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Reduce Convex canvas-page load by removing non-essential hot-path queries and collapsing redundant read models.
|
||||||
|
|
||||||
|
**Architecture:** The canvas keeps `nodes:list`, `edges:list`, and `canvases:get` as the primary real-time model while shedding secondary reads. Storage URLs move to a client-derived fallback path, presets move to a shared context backed by one query, and the toolbar stops depending on the subscription query.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16, React 19, Convex, React Flow, TypeScript
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Remove unnecessary canvas hot-path queries
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `components/canvas/canvas.tsx`
|
||||||
|
- Modify: `components/canvas/credit-display.tsx`
|
||||||
|
- Modify: `components/canvas/canvas-toolbar.tsx`
|
||||||
|
|
||||||
|
**Step 1:** Remove the debug-only `api.auth.safeGetAuthUser` query from `components/canvas/canvas.tsx`.
|
||||||
|
|
||||||
|
**Step 2:** Remove `api.credits.getSubscription` dependency from `components/canvas/credit-display.tsx` and keep the widget on the balance query only.
|
||||||
|
|
||||||
|
**Step 3:** Update toolbar rendering only as needed to match the leaner credit widget.
|
||||||
|
|
||||||
|
### Task 2: Remove server-side storage URL batching from the canvas hot path
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `components/canvas/canvas.tsx`
|
||||||
|
- Modify: `lib/canvas-utils.ts`
|
||||||
|
|
||||||
|
**Step 1:** Stop subscribing to `api.storage.batchGetUrlsForCanvas` from the canvas page.
|
||||||
|
|
||||||
|
**Step 2:** Extend `convexNodeDocWithMergedStorageUrl` to synthesize a stable fallback URL from `storageId` when no cached URL exists.
|
||||||
|
|
||||||
|
**Step 3:** Preserve previous merged URLs when available so existing anti-flicker behavior remains intact.
|
||||||
|
|
||||||
|
### Task 3: Collapse preset reads into one shared query
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `components/canvas/canvas-presets-context.tsx`
|
||||||
|
- Modify: `components/canvas/canvas.tsx`
|
||||||
|
- Modify: `components/canvas/nodes/curves-node.tsx`
|
||||||
|
- Modify: `components/canvas/nodes/color-adjust-node.tsx`
|
||||||
|
- Modify: `components/canvas/nodes/light-adjust-node.tsx`
|
||||||
|
- Modify: `components/canvas/nodes/detail-adjust-node.tsx`
|
||||||
|
|
||||||
|
**Step 1:** Add a shared presets provider that executes one `api.presets.list` query.
|
||||||
|
|
||||||
|
**Step 2:** Filter presets client-side by node type via a small hook/helper.
|
||||||
|
|
||||||
|
**Step 3:** Replace per-node `useAuthQuery(api.presets.list, { nodeType })` calls with the shared presets hook.
|
||||||
|
|
||||||
|
### Task 4: Verify the refactor
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify only
|
||||||
|
|
||||||
|
**Step 1:** Run lint on touched files.
|
||||||
|
|
||||||
|
**Step 2:** Run targeted type-check or full type-check if feasible.
|
||||||
|
|
||||||
|
**Step 3:** Reproduce canvas placement flow and confirm Convex hot-path query count is reduced.
|
||||||
@@ -37,12 +37,14 @@ Alle Adapter-Funktionen zwischen Convex-Datenmodell und React Flow. Details in `
|
|||||||
|
|
||||||
**Kritische Exports:**
|
**Kritische Exports:**
|
||||||
- `convexNodeToRF`, `convexEdgeToRF`, `convexEdgeToRFWithSourceGlow`
|
- `convexNodeToRF`, `convexEdgeToRF`, `convexEdgeToRFWithSourceGlow`
|
||||||
- `convexNodeDocWithMergedStorageUrl` — URL-Injection für Storage-Bilder
|
- `convexNodeDocWithMergedStorageUrl` — URL-Injection für Storage-Bilder aus serverseitig aufgelöster URL-Map oder gecachtem Vorgängerzustand
|
||||||
- `NODE_DEFAULTS` — Default-Größen und Daten per Node-Typ
|
- `NODE_DEFAULTS` — Default-Größen und Daten per Node-Typ
|
||||||
- `NODE_HANDLE_MAP` — Handle-IDs pro Node-Typ
|
- `NODE_HANDLE_MAP` — Handle-IDs pro Node-Typ
|
||||||
- `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung
|
- `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung
|
||||||
- `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen
|
- `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen
|
||||||
|
|
||||||
|
**Wichtig:** `canvas-utils.ts` erzeugt keine Storage-Fallback-URLs mehr selbst. Die URL-Auflösung kommt aus dem Canvas-Layer (`storage.batchGetUrlsForCanvas`) und wird hier nur noch gemerged/cached.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `canvas-node-catalog.ts` — Node-Taxonomie
|
## `canvas-node-catalog.ts` — Node-Taxonomie
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Reichert Node-Dokumente mit `data.url` an (aus gebündelter Storage-URL-Map).
|
* Reichert Node-Dokumente mit `data.url` an (aus gebündelter Storage-URL-Map).
|
||||||
* Behält eine zuvor gemappte URL bei, solange die Batch-Query noch lädt.
|
* Behält eine zuvor gemappte URL bei, solange die URL-Auflösung noch lädt.
|
||||||
*/
|
*/
|
||||||
export function convexNodeDocWithMergedStorageUrl(
|
export function convexNodeDocWithMergedStorageUrl(
|
||||||
node: Doc<"nodes">,
|
node: Doc<"nodes">,
|
||||||
|
|||||||
Reference in New Issue
Block a user