diff --git a/app/(dashboard)/activity/page.tsx b/app/(dashboard)/activity/page.tsx index 39a08094..5c5cd511 100644 --- a/app/(dashboard)/activity/page.tsx +++ b/app/(dashboard)/activity/page.tsx @@ -1,10 +1,28 @@ "use client"; -import ActivityFeed from "@/components/wallet/ActivityFeed"; +import dynamic from "next/dynamic"; import { useAutoStellarWallet } from "@/app/hooks/useAutoStellarWallet"; import { AlertCircle, Activity } from "lucide-react"; import Skeleton from "@/components/ui/Skeleton"; +// eslint-disable-next-line @typescript-eslint/naming-convention +const ActivityFeed = dynamic(() => import("@/components/wallet/ActivityFeed"), { + ssr: false, + loading: () => ( +
+ {Array.from({ length: 5 }).map((_item, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ), +}); + export default function ActivityPage() { const { publicKey, status, network, error } = useAutoStellarWallet(); @@ -57,7 +75,7 @@ export default function ActivityPage() { {/* Transactions Skeleton List */}
- {[...Array(5)].map((_, i) => ( + {[...Array(5)].map((_item, i) => (
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 +
+
+
+ +
+
+ ); +}