diff --git a/app/(dashboard)/earnings/EarningsPageClient.tsx b/app/(dashboard)/earnings/EarningsPageClient.tsx
index 6b46aa00..b4bdc4ea 100644
--- a/app/(dashboard)/earnings/EarningsPageClient.tsx
+++ b/app/(dashboard)/earnings/EarningsPageClient.tsx
@@ -9,8 +9,20 @@
*/
import React, { useState, useEffect, useRef, useCallback } from "react";
-import EarningsTable from "@/components/dashboard/EarningsTable";
+import dynamic from "next/dynamic";
import StatCard from "@/components/dashboard/StatCard";
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+const EarningsTable = dynamic(() => import("@/components/dashboard/EarningsTable"), {
+ ssr: false,
+ loading: () => (
+
+ {Array.from({ length: 5 }).map((unused, i) => (
+
+ ))}
+
+ ),
+});
import {
Download,
DollarSign,
diff --git a/app/(dashboard)/transform/[id]/page.tsx b/app/(dashboard)/transform/[id]/page.tsx
index 40f7a620..b21299f5 100644
--- a/app/(dashboard)/transform/[id]/page.tsx
+++ b/app/(dashboard)/transform/[id]/page.tsx
@@ -3,6 +3,7 @@
import React, { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
+import dynamic from "next/dynamic";
import { useParams, useRouter } from "next/navigation";
import {
AlertCircle,
@@ -11,8 +12,6 @@ import {
Clock,
Download,
Loader2,
- Pause,
- Play,
RefreshCw,
Sparkles,
Upload,
@@ -28,6 +27,17 @@ import {
SIZES_TRANSFORM_PREVIEW,
} from "@/app/lib/imageUtils";
+// eslint-disable-next-line @typescript-eslint/naming-convention
+const ComparisonPlayer = dynamic(() => import("@/components/transform/ComparisonPlayer"), {
+ ssr: false,
+ loading: () => (
+
+ ),
+});
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatEta(seconds: number | null | undefined): string {
@@ -42,71 +52,6 @@ function styleLabel(style: string): string {
return style.charAt(0).toUpperCase() + style.slice(1);
}
-// ─── Side-by-side video player ────────────────────────────────────────────────
-
-interface ComparisonPlayerProps {
- originalSrc: string;
- transformedSrc: string;
-}
-
-function ComparisonPlayer({ originalSrc, transformedSrc }: ComparisonPlayerProps) {
- const origRef = useRef
(null);
- const transRef = useRef(null);
- const [playing, setPlaying] = useState(false);
-
- const toggle = () => {
- const orig = origRef.current;
- const trans = transRef.current;
- if (!orig || !trans) return;
- if (playing) {
- orig.pause();
- trans.pause();
- } else {
- orig.play().catch(() => {});
- trans.play().catch(() => {});
- }
- setPlaying(!playing);
- };
-
- return (
-
-
-
- Original
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
-
-
-
- Transformed
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
-
-
-
-
-
-
-
- );
-}
-
// ─── Page ──────────────────────────────────────────────────────────────────────
export default function TransformProgressPage() {
diff --git a/app/(dashboard)/vault/page.tsx b/app/(dashboard)/vault/page.tsx
index f3fde029..9fa4492c 100644
--- a/app/(dashboard)/vault/page.tsx
+++ b/app/(dashboard)/vault/page.tsx
@@ -1,11 +1,24 @@
"use client";
import React, { useState, useEffect } from "react";
+import dynamic from "next/dynamic";
import VaultSidebar from "@/components/vault/VaultSidebar";
import NFTGrid from "@/components/vault/NFTGrid";
-import MintConfigForm from "@/components/projects/MintConfigForm";
import { ChevronRight } from "lucide-react";
+// eslint-disable-next-line @typescript-eslint/naming-convention
+const MintConfigForm = dynamic(() => import("@/components/projects/MintConfigForm"), {
+ ssr: false,
+ loading: () => (
+
+ ),
+});
+
export default function VaultPage() {
const [loading, setLoading] = useState(true);
const [activeFilter, setActiveFilter] = useState<"pending" | "listed" | "history">("pending");
diff --git a/app/api/batch/route.ts b/app/api/batch/route.ts
new file mode 100644
index 00000000..9dddf767
--- /dev/null
+++ b/app/api/batch/route.ts
@@ -0,0 +1,182 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/app/lib/auth";
+import { checkCsrf } from "@/app/lib/csrf";
+import { applyRateLimit } from "@/app/lib/serverRateLimit";
+import { getEndpointRateLimit } from "@/app/lib/endpointRateLimits";
+import { batchRequestSchema } from "../schemas/index";
+import type { ApiResponse } from "../types";
+import type { BatchResponseItem } from "../schemas/batch.schema";
+
+type RouteHandler = (req: NextRequest, ctx?: { params?: Promise> }) => Promise;
+
+interface RouteEntry {
+ pattern: RegExp;
+ paramNames: string[];
+ handlers: Partial>;
+}
+
+async function buildRouteRegistry(): Promise {
+ const clipsRoute = await import("@/app/api/clips/route");
+ const projectsRoute = await import("@/app/api/projects/route");
+ const dashboardRoute = await import("@/app/api/dashboard/route");
+ const notificationsRoute = await import("@/app/api/notifications/route");
+ const earningsRoute = await import("@/app/api/earnings/route");
+ const userRoute = await import("@/app/api/user/route");
+
+ return [
+ {
+ pattern: /^\/api\/clips$/,
+ paramNames: [],
+ handlers: { GET: clipsRoute.GET, DELETE: clipsRoute.DELETE },
+ },
+ {
+ pattern: /^\/api\/projects$/,
+ paramNames: [],
+ handlers: { GET: projectsRoute.GET },
+ },
+ {
+ pattern: /^\/api\/dashboard$/,
+ paramNames: [],
+ handlers: { GET: dashboardRoute.GET },
+ },
+ {
+ pattern: /^\/api\/notifications$/,
+ paramNames: [],
+ handlers: { GET: notificationsRoute.GET },
+ },
+ {
+ pattern: /^\/api\/earnings$/,
+ paramNames: [],
+ handlers: { GET: earningsRoute.GET },
+ },
+ {
+ pattern: /^\/api\/user$/,
+ paramNames: [],
+ handlers: { GET: userRoute.GET },
+ },
+ ];
+}
+
+function resolveRoute(
+ registry: RouteEntry[],
+ method: string,
+ path: string
+): { handler: RouteHandler; params: Record } | null {
+ const [pathname] = path.split("?");
+ for (const entry of registry) {
+ const match = pathname.match(entry.pattern);
+ if (match) {
+ const handler = entry.handlers[method];
+ if (!handler) return null;
+ const params: Record = {};
+ entry.paramNames.forEach((name, i) => {
+ params[name] = match[i + 1];
+ });
+ return { handler, params };
+ }
+ }
+ return null;
+}
+
+function buildInternalRequest(
+ method: string,
+ path: string,
+ body: unknown,
+ originalHeaders: Headers
+): NextRequest {
+ const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
+ const url = new URL(path, baseUrl);
+
+ const headers = new Headers();
+ const cookie = originalHeaders.get("cookie");
+ if (cookie) headers.set("cookie", cookie);
+ const auth = originalHeaders.get("authorization");
+ if (auth) headers.set("authorization", auth);
+ headers.set("content-type", "application/json");
+
+ const init: RequestInit = {
+ method,
+ headers,
+ };
+
+ if (body !== undefined && method !== "GET") {
+ init.body = JSON.stringify(body);
+ }
+
+ return new NextRequest(url.toString(), init);
+}
+
+export async function POST(request: NextRequest) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { data: null, error: "Unauthorized" } satisfies ApiResponse,
+ { status: 401 }
+ );
+ }
+
+ const csrfError = checkCsrf(request);
+ if (csrfError) return csrfError;
+
+ const rateLimited = await applyRateLimit(request, getEndpointRateLimit("/api/batch"));
+ if (rateLimited) return rateLimited;
+
+ let payload: unknown;
+ try {
+ payload = await request.json();
+ } catch {
+ return NextResponse.json(
+ { data: null, error: "Invalid JSON body" } satisfies ApiResponse,
+ { status: 400 }
+ );
+ }
+
+ const parsed = batchRequestSchema.safeParse(payload);
+ if (!parsed.success) {
+ return NextResponse.json(
+ {
+ data: null,
+ error: "Validation failed",
+ issues: parsed.error.issues,
+ },
+ { status: 400 }
+ );
+ }
+
+ const registry = await buildRouteRegistry();
+
+ const results = await Promise.all(
+ parsed.data.requests.map(async (item): Promise => {
+ const resolved = resolveRoute(registry, item.method, item.path);
+
+ if (!resolved) {
+ return {
+ status: 400,
+ body: { data: null, error: `Unsupported batch path or method: ${item.method} ${item.path}` },
+ };
+ }
+
+ try {
+ const internalReq = buildInternalRequest(item.method, item.path, item.body, request.headers);
+ const ctx = Object.keys(resolved.params).length > 0
+ ? { params: Promise.resolve(resolved.params) }
+ : undefined;
+ const response = await resolved.handler(internalReq, ctx);
+ const responseBody = await response.json().catch(() => null);
+ return { status: response.status, body: responseBody };
+ } catch {
+ return {
+ status: 500,
+ body: { data: null, error: "Internal batch request error" },
+ };
+ }
+ })
+ );
+
+ const body: ApiResponse = {
+ data: results,
+ error: null,
+ };
+
+ return NextResponse.json(body);
+}
diff --git a/app/api/clips/bulk/route.ts b/app/api/clips/bulk/route.ts
new file mode 100644
index 00000000..71888379
--- /dev/null
+++ b/app/api/clips/bulk/route.ts
@@ -0,0 +1,114 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/app/lib/auth";
+import { checkCsrf } from "@/app/lib/csrf";
+import { clipsStore } from "../clipsStore";
+import type { ApiResponse } from "../../types";
+import { bulkUpdateTagsBodySchema, bulkUpdateStatusBodySchema } from "../../schemas/index";
+
+export async function PATCH(request: NextRequest) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const csrfError = checkCsrf(request);
+ if (csrfError) return csrfError;
+
+ const { searchParams } = new URL(request.url);
+ const operation = searchParams.get("operation");
+
+ if (!operation) {
+ return NextResponse.json(
+ { error: "Missing required query parameter: operation (tags|status)" },
+ { status: 400 }
+ );
+ }
+
+ let payload: unknown;
+ try {
+ payload = await request.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ clipsStore.getClipsForUser(session.user.id);
+
+ if (operation === "tags") {
+ const parsed = bulkUpdateTagsBodySchema.safeParse(payload);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Validation failed", issues: parsed.error.issues },
+ { status: 400 }
+ );
+ }
+
+ const { clipIds, tags, mode } = parsed.data;
+
+ const unowned = clipsStore.findUnownedClipIds(session.user.id, clipIds);
+ if (unowned.length > 0) {
+ return NextResponse.json(
+ { error: "One or more clips do not belong to you", unownedClipIds: unowned },
+ { status: 403 }
+ );
+ }
+
+ const result = clipsStore.bulkUpdateTags(session.user.id, clipIds, tags, mode);
+
+ const body: ApiResponse<{
+ success: boolean;
+ updatedCount: number;
+ errors: Array<{ clipId: string; error: string }>;
+ }> = {
+ data: {
+ success: result.errors.length === 0,
+ updatedCount: result.updatedCount,
+ errors: result.errors,
+ },
+ error: null,
+ };
+
+ return NextResponse.json(body);
+ }
+
+ if (operation === "status") {
+ const parsed = bulkUpdateStatusBodySchema.safeParse(payload);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Validation failed", issues: parsed.error.issues },
+ { status: 400 }
+ );
+ }
+
+ const { clipIds, status } = parsed.data;
+
+ const unowned = clipsStore.findUnownedClipIds(session.user.id, clipIds);
+ if (unowned.length > 0) {
+ return NextResponse.json(
+ { error: "One or more clips do not belong to you", unownedClipIds: unowned },
+ { status: 403 }
+ );
+ }
+
+ const result = clipsStore.bulkUpdateStatus(session.user.id, clipIds, status);
+
+ const body: ApiResponse<{
+ success: boolean;
+ updatedCount: number;
+ errors: Array<{ clipId: string; error: string }>;
+ }> = {
+ data: {
+ success: result.errors.length === 0,
+ updatedCount: result.updatedCount,
+ errors: result.errors,
+ },
+ error: null,
+ };
+
+ return NextResponse.json(body);
+ }
+
+ return NextResponse.json(
+ { error: `Unknown operation: ${operation}. Supported: tags, status` },
+ { status: 400 }
+ );
+}
diff --git a/app/api/clips/clipsStore.ts b/app/api/clips/clipsStore.ts
index 282b2d75..877c8e43 100644
--- a/app/api/clips/clipsStore.ts
+++ b/app/api/clips/clipsStore.ts
@@ -249,6 +249,67 @@ class ClipsStore {
.map((c) => c.id);
return this.softDeleteClips(userId, clipIds);
}
+
+ bulkUpdateTags(
+ userId: string,
+ clipIds: string[],
+ tags: string[],
+ mode: "set" | "add" | "remove"
+ ): { updatedCount: number; errors: Array<{ clipId: string; error: string }> } {
+ const errors: Array<{ clipId: string; error: string }> = [];
+ let updatedCount = 0;
+
+ this.clips = this.clips.map(clip => {
+ if (clip.userId !== userId || !clipIds.includes(clip.id) || clip.deletedAt) {
+ return clip;
+ }
+
+ let newTags: string[];
+ const existing = clip.tags ?? [];
+
+ switch (mode) {
+ case "set":
+ newTags = [...new Set(tags)];
+ break;
+ case "add":
+ newTags = [...new Set([...existing, ...tags])];
+ break;
+ case "remove":
+ newTags = existing.filter(t => !tags.includes(t));
+ break;
+ }
+
+ const MAX_TAGS = 10;
+ if (newTags.length > MAX_TAGS) {
+ errors.push({ clipId: clip.id, error: "Tag limit exceeded (max 10)" });
+ return clip;
+ }
+
+ updatedCount++;
+ return { ...clip, tags: newTags };
+ });
+
+ return { updatedCount, errors };
+ }
+
+ bulkUpdateStatus(
+ userId: string,
+ clipIds: string[],
+ status: string
+ ): { updatedCount: number; errors: Array<{ clipId: string; error: string }> } {
+ const errors: Array<{ clipId: string; error: string }> = [];
+ let updatedCount = 0;
+
+ this.clips = this.clips.map(clip => {
+ if (clip.userId !== userId || !clipIds.includes(clip.id) || clip.deletedAt) {
+ return clip;
+ }
+ updatedCount++;
+ return { ...clip, status };
+ });
+
+ return { updatedCount, errors };
+ }
}
export const clipsStore = new ClipsStore();
diff --git a/app/api/clips/route.ts b/app/api/clips/route.ts
index 75aaffa2..4f63deec 100644
--- a/app/api/clips/route.ts
+++ b/app/api/clips/route.ts
@@ -3,6 +3,20 @@ import { auth } from "@/app/lib/auth";
import { clipsStore } from "./clipsStore";
import type { ApiResponse } from "../types";
import { getClipsQuerySchema, bulkClipIdsBodySchema } from "../schemas/index";
+import { parseFieldSelection, pickFields } from "@/app/lib/fieldSelection";
+import type { Clip } from "./clipsStore";
+
+const CLIP_FIELD_CONFIG = {
+ allowedFields: [
+ "id", "userId", "projectId", "title", "thumbnail", "score", "scoreKey",
+ "duration", "style", "status", "resolution", "videoUrl", "createdAt",
+ "scoreBreakdown", "tags", "shareId",
+ ] as (keyof Clip & string)[],
+ defaultFields: [
+ "id", "title", "thumbnail", "score", "scoreKey", "duration",
+ "style", "status", "createdAt", "tags",
+ ] as (keyof Clip & string)[],
+};
export async function GET(request: NextRequest) {
const session = await auth();
@@ -30,6 +44,14 @@ export async function GET(request: NextRequest) {
const { page, pageSize, status, style, virality } = queryValidation.data;
+ const fieldResult = parseFieldSelection(searchParams.get("fields"), CLIP_FIELD_CONFIG);
+ if (!fieldResult.ok) {
+ return NextResponse.json(
+ { error: fieldResult.error },
+ { status: 400 }
+ );
+ }
+
// 1. Fetch user's clips. "archived" is a lifecycle state, not a clip status,
// so it selects a different set rather than filtering the default one.
let userClips =
@@ -57,9 +79,11 @@ export async function GET(request: NextRequest) {
const endIndex = startIndex + pageSize;
const paginatedClips = userClips.slice(startIndex, endIndex);
- const body: ApiResponse<{ clips: typeof paginatedClips, total: number }> = {
+ const selectedClips = paginatedClips.map(clip => pickFields(clip, fieldResult.fields));
+
+ const body: ApiResponse<{ clips: typeof selectedClips, total: number }> = {
data: {
- clips: paginatedClips,
+ clips: selectedClips,
total
},
error: null
diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts
index 461cd283..84cd64eb 100644
--- a/app/api/projects/route.ts
+++ b/app/api/projects/route.ts
@@ -1,39 +1,63 @@
-import { NextResponse } from "next/server";
+import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/app/api/jobs/shared/authGuard";
import { clipsStore } from "@/app/api/clips/clipsStore";
import { projectsStore } from "./projectsStore";
import type { ApiResponse } from "../types";
+import { parseFieldSelection, pickFields } from "@/app/lib/fieldSelection";
+
+type ProjectResponse = {
+ id: string;
+ name: string;
+ thumbnailUrl: string;
+ videoUrl: string;
+ clipCount: number;
+ createdAt: string;
+};
+
+const PROJECT_FIELD_CONFIG = {
+ allowedFields: [
+ "id", "name", "thumbnailUrl", "videoUrl", "clipCount", "createdAt",
+ ] as (keyof ProjectResponse & string)[],
+ defaultFields: [
+ "id", "name", "thumbnailUrl", "clipCount", "createdAt",
+ ] as (keyof ProjectResponse & string)[],
+};
/**
* GET /api/projects — list all projects for the authenticated user.
+ * Supports `?fields=id,name,clipCount` for sparse fieldsets.
*/
-export async function GET() {
+export async function GET(request: NextRequest) {
const authResult = await requireAuth();
if (authResult instanceof NextResponse) return authResult;
const { userId } = authResult;
+ const { searchParams } = new URL(request.url);
+ const fieldResult = parseFieldSelection(searchParams.get("fields"), PROJECT_FIELD_CONFIG);
+ if (!fieldResult.ok) {
+ return NextResponse.json(
+ { error: fieldResult.error },
+ { status: 400 }
+ );
+ }
+
const projects = projectsStore.getProjectsForUser(userId);
clipsStore.getClipsForUser(userId);
- const body: ApiResponse<{
- projects: Array<{
- id: string;
- name: string;
- thumbnailUrl: string;
- videoUrl: string;
- clipCount: number;
- createdAt: string;
- }>;
- }> = {
+ const allProjects: ProjectResponse[] = projects.map((p) => ({
+ id: p.id,
+ name: p.name,
+ thumbnailUrl: p.thumbnailUrl,
+ videoUrl: p.videoUrl,
+ clipCount: clipsStore.getClipsForProject(userId, p.id).length,
+ createdAt: p.createdAt,
+ }));
+
+ const selectedProjects = allProjects.map((p) => pickFields(p, fieldResult.fields));
+
+ const body: ApiResponse<{ projects: typeof selectedProjects }> = {
data: {
- projects: projects.map((p) => ({
- id: p.id,
- name: p.name,
- thumbnailUrl: p.thumbnailUrl,
- videoUrl: p.videoUrl,
- clipCount: clipsStore.getClipsForProject(userId, p.id).length,
- createdAt: p.createdAt,
- })),
+ projects: selectedProjects,
},
error: null,
};
diff --git a/app/api/schemas/batch.schema.ts b/app/api/schemas/batch.schema.ts
new file mode 100644
index 00000000..92e76d84
--- /dev/null
+++ b/app/api/schemas/batch.schema.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+
+const BATCH_MAX_REQUESTS = 20;
+
+export const batchRequestItemSchema = z.object({
+ method: z.enum(["GET", "POST", "PATCH", "DELETE"]),
+ path: z.string().min(1).startsWith("/api/"),
+ body: z.unknown().optional(),
+});
+
+export const batchRequestSchema = z.object({
+ requests: z
+ .array(batchRequestItemSchema)
+ .min(1, "At least one request is required")
+ .max(BATCH_MAX_REQUESTS, `At most ${BATCH_MAX_REQUESTS} requests per batch`),
+});
+
+export type BatchRequestItem = z.infer;
+export type BatchRequest = z.infer;
+
+export interface BatchResponseItem {
+ status: number;
+ body: unknown;
+}
diff --git a/app/api/schemas/clips.schema.ts b/app/api/schemas/clips.schema.ts
index d9742c88..ee43ba79 100644
--- a/app/api/schemas/clips.schema.ts
+++ b/app/api/schemas/clips.schema.ts
@@ -80,9 +80,30 @@ export const createClipBodySchema = z.object({
virality: z.enum(["high", "medium", "low"]).optional(),
});
+export const bulkUpdateTagsBodySchema = z.object({
+ clipIds: z
+ .array(z.string().min(1))
+ .min(1, "At least one clip ID is required")
+ .max(100, "At most 100 clips can be modified in one request"),
+ tags: z
+ .array(tagSchema)
+ .max(TAGS_MAX_PER_CLIP, `Maximum ${TAGS_MAX_PER_CLIP} tags per clip`),
+ mode: z.enum(["set", "add", "remove"]).default("set"),
+});
+
+export const bulkUpdateStatusBodySchema = z.object({
+ clipIds: z
+ .array(z.string().min(1))
+ .min(1, "At least one clip ID is required")
+ .max(100, "At most 100 clips can be modified in one request"),
+ status: z.enum(["pending", "listed", "history"]),
+});
+
export type GetClipsQuery = z.infer;
export type UpdateClipBody = z.infer;
export type BulkClipIdsBody = z.infer;
export type PostClipBody = z.infer;
export type MintClipBody = z.infer;
export type CreateClipBody = z.infer;
+export type BulkUpdateTagsBody = z.infer;
+export type BulkUpdateStatusBody = z.infer;
diff --git a/app/api/schemas/index.ts b/app/api/schemas/index.ts
index 0e0f6e91..2f0c0cfd 100644
--- a/app/api/schemas/index.ts
+++ b/app/api/schemas/index.ts
@@ -11,3 +11,4 @@ export * from "./user.schema";
export * from "./transform.schema";
export * from "./billing.schema";
export * from "./projects.schema";
+export * from "./batch.schema";
diff --git a/app/hooks/usePreloadComponent.ts b/app/hooks/usePreloadComponent.ts
new file mode 100644
index 00000000..2c5c3599
--- /dev/null
+++ b/app/hooks/usePreloadComponent.ts
@@ -0,0 +1,19 @@
+"use client";
+
+import { useCallback, useRef } from "react";
+
+type DynamicLoader = () => Promise;
+
+export function usePreloadComponent(loader: DynamicLoader) {
+ const preloaded = useRef(false);
+
+ const preload = useCallback(() => {
+ if (preloaded.current) return;
+ preloaded.current = true;
+ loader().catch(() => {
+ preloaded.current = false;
+ });
+ }, [loader]);
+
+ return preload;
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index a67a1dd7..74b58ca3 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
+import dynamic from "next/dynamic";
import "./globals.css";
import { AuthProvider } from "@/components/auth/AuthProvider";
import { WalletProvider } from "@/components/wallet/WalletProvider";
@@ -8,9 +9,7 @@ import { NetworkProvider } from "@/app/context/NetworkContext";
import { ThemeProvider } from "@/components/theme-provider";
import { ToastProvider } from "@/components/ToastProvider";
import { I18nProvider } from "@/app/lib/i18n/I18nProvider";
-import CookieConsent from "@/components/CookieConsent";
import RateLimitToast from "@/components/RateLimitToast";
-import KeyboardShortcuts from "@/components/KeyboardShortcuts";
import ErrorBoundary from "@/components/ErrorBoundary";
import AnalyticsProvider from "@/components/AnalyticsProvider";
import ResourceHints from "@/components/ResourceHints";
@@ -19,6 +18,16 @@ import PerformanceMonitor from "@/components/PerformanceMonitor";
import FontPreload from "@/components/FontPreload";
import DataSyncProvider from "@/components/DataSyncProvider";
+// eslint-disable-next-line @typescript-eslint/naming-convention
+const KeyboardShortcuts = dynamic(() => import("@/components/KeyboardShortcuts"), {
+ ssr: false,
+});
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+const CookieConsent = dynamic(() => import("@/components/CookieConsent"), {
+ ssr: false,
+});
+
/**
* Inter font configuration with performance optimizations:
* - Subsets: latin + latin-ext for European languages
diff --git a/app/lib/fieldSelection.ts b/app/lib/fieldSelection.ts
new file mode 100644
index 00000000..2cbc0a57
--- /dev/null
+++ b/app/lib/fieldSelection.ts
@@ -0,0 +1,48 @@
+export interface FieldSelectionConfig> {
+ allowedFields: (keyof T & string)[];
+ defaultFields: (keyof T & string)[];
+}
+
+export function parseFieldSelection>(
+ fieldsParam: string | null,
+ config: FieldSelectionConfig
+): { ok: true; fields: (keyof T & string)[] } | { ok: false; error: string; invalid: string[] } {
+ if (!fieldsParam) {
+ return { ok: true, fields: config.defaultFields };
+ }
+
+ const requested = fieldsParam
+ .split(",")
+ .map((f) => f.trim())
+ .filter(Boolean);
+
+ if (requested.length === 0) {
+ return { ok: true, fields: config.defaultFields };
+ }
+
+ const allowedSet = new Set(config.allowedFields);
+ const invalid = requested.filter((f) => !allowedSet.has(f));
+
+ if (invalid.length > 0) {
+ return {
+ ok: false,
+ error: `Invalid fields: ${invalid.join(", ")}. Allowed: ${config.allowedFields.join(", ")}`,
+ invalid,
+ };
+ }
+
+ return { ok: true, fields: requested };
+}
+
+export function pickFields>(
+ obj: T,
+ fields: string[]
+): Partial {
+ const result: Partial = {};
+ for (const field of fields) {
+ if (field in obj) {
+ (result as Record)[field] = obj[field];
+ }
+ }
+ return result;
+}
diff --git a/components/transform/ComparisonPlayer.tsx b/components/transform/ComparisonPlayer.tsx
new file mode 100644
index 00000000..aebf4b32
--- /dev/null
+++ b/components/transform/ComparisonPlayer.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import React, { useRef, useState } from "react";
+import { Pause, Play } from "lucide-react";
+
+interface ComparisonPlayerProps {
+ originalSrc: string;
+ transformedSrc: string;
+}
+
+export default function ComparisonPlayer({ originalSrc, transformedSrc }: ComparisonPlayerProps) {
+ const origRef = useRef(null);
+ const transRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+
+ const toggle = () => {
+ const orig = origRef.current;
+ const trans = transRef.current;
+ if (!orig || !trans) return;
+ if (playing) {
+ orig.pause();
+ trans.pause();
+ } else {
+ orig.play().catch(() => {});
+ trans.play().catch(() => {});
+ }
+ setPlaying(!playing);
+ };
+
+ return (
+
+
+
+ Original
+
+
+
+ Transformed
+
+
+
+
+
+
+
+ );
+}