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:
2026-04-03 14:52:34 +02:00
parent ef98acd0de
commit 1fb8fd2863
14 changed files with 322 additions and 117 deletions

View File

@@ -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`).

View 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) ?? [];
}

View File

@@ -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>
); );
} }

View File

@@ -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

View File

@@ -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 }),

View File

@@ -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 }),

View File

@@ -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 }),

View File

@@ -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 }),

View File

@@ -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.

View File

@@ -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];
}

View 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.

View 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.

View File

@@ -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

View File

@@ -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">,