Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions app/(dashboard)/activity/page.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<div className="space-y-2 animate-pulse">
{Array.from({ length: 5 }).map((_item, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl border border-border bg-surface-hover/50">
<div className="w-8 h-8 rounded-lg bg-white/5 shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 bg-white/5 rounded" />
<div className="h-3 w-1/2 bg-white/5 rounded" />
</div>
</div>
))}
</div>
),
});

export default function ActivityPage() {
const { publicKey, status, network, error } = useAutoStellarWallet();

Expand Down Expand Up @@ -57,7 +75,7 @@ export default function ActivityPage() {

{/* Transactions Skeleton List */}
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
{[...Array(5)].map((_item, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl border border-border bg-surface-hover/50">
<Skeleton className="w-8 h-8 rounded-lg shrink-0" />
<div className="flex-1 space-y-2">
Expand Down
14 changes: 13 additions & 1 deletion app/(dashboard)/earnings/EarningsPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => (
<div className="space-y-3 animate-pulse">
{Array.from({ length: 5 }).map((unused, i) => (
<div key={i} className="h-12 bg-white/5 rounded-xl" />
))}
</div>
),
});
import {
Download,
DollarSign,
Expand Down
79 changes: 12 additions & 67 deletions app/(dashboard)/transform/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -11,8 +12,6 @@ import {
Clock,
Download,
Loader2,
Pause,
Play,
RefreshCw,
Sparkles,
Upload,
Expand All @@ -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: () => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-pulse">
<div className="aspect-video bg-white/5 rounded-2xl" />
<div className="aspect-video bg-white/5 rounded-2xl" />
</div>
),
});

// ─── Helpers ──────────────────────────────────────────────────────────────────

function formatEta(seconds: number | null | undefined): string {
Expand All @@ -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<HTMLVideoElement>(null);
const transRef = useRef<HTMLVideoElement>(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 (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<span className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">Original</span>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={origRef}
src={originalSrc}
className="w-full rounded-2xl border border-white/10 bg-black aspect-video object-contain"
playsInline
loop
/>
</div>
<div className="space-y-2">
<span className="text-[11px] font-bold text-brand uppercase tracking-wider">Transformed</span>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={transRef}
src={transformedSrc}
className="w-full rounded-2xl border border-brand/20 bg-black aspect-video object-contain"
playsInline
loop
/>
</div>
</div>
<div className="flex justify-center">
<button
onClick={toggle}
className="flex items-center gap-2 px-6 py-2.5 rounded-full bg-brand text-black text-xs font-bold hover:bg-brand-hover transition-all"
>
{playing ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
{playing ? "Pause" : "Play"} Both
</button>
</div>
</div>
);
}

// ─── Page ──────────────────────────────────────────────────────────────────────

export default function TransformProgressPage() {
Expand Down
15 changes: 14 additions & 1 deletion app/(dashboard)/vault/page.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<div className="space-y-4 animate-pulse">
<div className="h-10 bg-white/5 rounded-xl" />
<div className="h-20 bg-white/5 rounded-xl" />
<div className="h-10 bg-white/5 rounded-xl" />
<div className="h-10 bg-white/5 rounded-xl" />
</div>
),
});

export default function VaultPage() {
const [loading, setLoading] = useState(true);
const [activeFilter, setActiveFilter] = useState<"pending" | "listed" | "history">("pending");
Expand Down
182 changes: 182 additions & 0 deletions app/api/batch/route.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> }) => Promise<NextResponse>;

interface RouteEntry {
pattern: RegExp;
paramNames: string[];
handlers: Partial<Record<string, RouteHandler>>;
}

async function buildRouteRegistry(): Promise<RouteEntry[]> {
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<string, string> } | 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<string, string> = {};
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<null>,
{ 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<null>,
{ 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<BatchResponseItem> => {
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<BatchResponseItem[]> = {
data: results,
error: null,
};

return NextResponse.json(body);
}
Loading
Loading