1 Commits

Author SHA1 Message Date
dstrawsb d9498f8aca upgrading nextjs to 15
CI / test (push) Successful in 6s
2026-01-24 23:32:58 -05:00
24 changed files with 1927 additions and 1169 deletions
@@ -1,5 +1,6 @@
import PartsBrowseClient from "@/components/parts/PartsBrowseClient"; import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
export default function Page({ params }: { params: { platform: string; partRole: string } }) { export default async function Page(props: { params: Promise<{ platform: string; partRole: string }> }) {
const params = await props.params;
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />; return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
} }
+6 -10
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import MailingAddressComponent from "@/components/MailingAddressComponent";
export const metadata = { export const metadata = {
title: "Privacy Policy | Battl Builder", title: "Privacy Policy | Battl Builder",
@@ -7,6 +8,8 @@ export const metadata = {
"Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.", "Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.",
}; };
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const lastUpdated = "January 9, 2026"; const lastUpdated = "January 9, 2026";
@@ -22,7 +25,8 @@ export default function PrivacyPolicy() {
<section> <section>
<h2 className="mb-4 text-xl font-semibold">1. Introduction</h2> <h2 className="mb-4 text-xl font-semibold">1. Introduction</h2>
<p> <p>
Battl Builder (&quot;we,&quot; &quot;us,&quot; &quot;our,&quot; or &quot;Company&quot;) respects your privacy Battl Builder (&quot;we,&quot; &quot;us,&quot; &quot;our,&quot; or &quot;Company&quot;) respects
your privacy
and is committed to protecting it through this Privacy Policy. This and is committed to protecting it through this Privacy Policy. This
policy explains our practices regarding the collection, use, and policy explains our practices regarding the collection, use, and
protection of your personal information when you access our website protection of your personal information when you access our website
@@ -412,15 +416,7 @@ export default function PrivacyPolicy() {
<strong>Email:</strong>{" "} <strong>Email:</strong>{" "}
<span className="text-amber-300">privacy@battlbuilder.com</span> <span className="text-amber-300">privacy@battlbuilder.com</span>
</p> </p>
<p> <MailingAddressComponent/>
<strong>Mailing Address:</strong>
<br />
Battl Builder, LLC.
<br />
[Your Address]
<br />
[City, State, ZIP]
</p>
</div> </div>
<p className="mt-4 text-sm"> <p className="mt-4 text-sm">
We will respond to all privacy inquiries within 30 days of receipt. We will respond to all privacy inquiries within 30 days of receipt.
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import {JSX, useEffect, useRef, useState} from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail"; import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form"; import { Button, Field, Input, Textarea } from "@/components/ui/form";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import {JSX, useCallback, useMemo, useState} from "react";
import type { CaliberDto, PlatformDto } from "../_lib/types"; import type { CaliberDto, PlatformDto } from "../_lib/types";
import { RightDrawer } from "./RightDrawer"; import { RightDrawer } from "./RightDrawer";
+1 -1
View File
@@ -6,7 +6,7 @@ const API_BASE_URL =
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30 // POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
export async function POST(req: Request) { export async function POST(req: Request) {
const token = cookies().get("bb_access_token")?.value; const token = (await cookies()).get("bb_access_token")?.value;
if (!token) { if (!token) {
return NextResponse.json({ error: "Missing admin token" }, { status: 401 }); return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
+2 -4
View File
@@ -4,10 +4,8 @@ import type {
MerchantAdminUpdateRequest, MerchantAdminUpdateRequest,
} from "@/types/merchant-admin"; } from "@/types/merchant-admin";
export async function PUT( export async function PUT(req: Request, props: { params: Promise<{ id: string }> }) {
req: Request, const params = await props.params;
{ params }: { params: { id: string } }
) {
const body = (await req.json()) as MerchantAdminUpdateRequest; const body = (await req.json()) as MerchantAdminUpdateRequest;
return springProxy<MerchantAdminResponse, MerchantAdminUpdateRequest>( return springProxy<MerchantAdminResponse, MerchantAdminUpdateRequest>(
+382
View File
@@ -0,0 +1,382 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA";
type Caliber = string;
type BuildCard = {
id: string;
title: string;
slug: string;
creator: string;
caliber: Caliber;
buildClass: BuildClass;
price: number;
votes: number;
tags: string[];
coverImageUrl?: string | null;
};
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
];
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
interface BuildsClientProps {
initialBuilds: BuildCard[];
initialError: string | null;
}
/**
* Client-side component that keeps all the previous interactivity:
* - filters
* - optimistic votes
*
* It receives the builds from the server but can optionally re-fetch on mount
* if you want truly live data (kept here as an example but can be removed).
*/
export default function BuildsClient({
initialBuilds,
initialError,
}: BuildsClientProps) {
// --- Remote data state (initialized from server) ---
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(initialError);
const [builds, setBuilds] = useState<BuildCard[]>(initialBuilds);
// --- UI state (filters/votes) ---
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
const [priceFilterId, setPriceFilterId] = useState<string>("all");
const [votes, setVotes] = useState<Record<string, number>>(() =>
Object.fromEntries(initialBuilds.map((b) => [b.id, b.votes])),
);
const activePriceFilter =
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
// Optional: client re-fetch to keep data fresh (can be removed if not needed)
useEffect(() => {
// If you don't want client refetching, just return early here.
if (!API_BASE_URL) return;
let cancelled = false;
async function run() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
method: "GET",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load builds (${res.status})`);
}
const data = (await res.json()) as BuildCard[];
if (cancelled) return;
setBuilds(data);
setVotes(Object.fromEntries(data.map((b) => [b.id, b.votes])));
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds feed");
} finally {
if (!cancelled) setLoading(false);
}
}
// Comment this out if you want *only* server-side fetching:
// run();
return () => {
cancelled = true;
};
}, []);
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
const uniq = new Set<string>();
for (const b of builds) {
const c = (b.caliber ?? "").trim();
if (c && c !== "—") uniq.add(c);
}
return ["all", ...Array.from(uniq).sort()];
}, [builds]);
const filteredBuilds = useMemo(() => {
return builds.filter((build) => {
const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter;
const matchesClass =
classFilter === "all" || build.buildClass === classFilter;
const matchesPrice =
build.price >= activePriceFilter.min &&
build.price < activePriceFilter.max;
return matchesCaliber && matchesClass && matchesPrice;
});
}, [builds, caliberFilter, classFilter, activePriceFilter]);
const handleVote = useCallback((id: string, delta: 1 | -1) => {
setVotes((prev) => ({
...prev,
[id]: (prev[id] ?? 0) + delta,
}));
}, []);
return (
<>
{/* Loading / Error (uses same UI as before) */}
{loading && (
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
Loading community builds
</div>
)}
{error && !loading && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
{error}
<div className="mt-2 text-xs text-red-200/80">
Dev tip: confirm backend route{" "}
<span className="font-mono">GET /api/v1/builds</span> is
implemented + CORS is configured.
</div>
</div>
)}
{/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
Filter Builds
</h2>
<p className="text-xs text-zinc-500 mt-1">
Filter by caliber, class, and price range. (Client-side for MVP.)
</p>
</div>
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredBuilds.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
builds
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{/* Caliber filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Caliber
</div>
<div className="flex flex-wrap gap-2">
{CALIBER_FILTERS.map((caliber) => (
<button
key={caliber}
type="button"
onClick={() =>
setCaliberFilter(caliber === "all" ? "all" : caliber)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
caliberFilter === caliber
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{caliber === "all" ? "All" : caliber}
</button>
))}
</div>
</div>
{/* Class filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Class
</div>
<div className="flex flex-wrap gap-2">
{CLASS_FILTERS.map((cls) => (
<button
key={cls}
type="button"
onClick={() =>
setClassFilter(cls === "all" ? "all" : cls)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{cls === "all" ? "All" : cls}
</button>
))}
</div>
</div>
{/* Price filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Price Range
</div>
<div className="flex flex-wrap gap-2">
{PRICE_FILTERS.map((range) => (
<button
key={range.id}
type="button"
onClick={() => setPriceFilterId(range.id)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
priceFilterId === range.id
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{range.label}
</button>
))}
</div>
</div>
</div>
</section>
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Upvote"
title="Upvote"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Downvote"
title="Downvote"
>
</button>
</div>
{/* Thumbnail */}
<div className="hidden sm:block">
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">b/{build.creator}</span>{" "}
· Build detail page will show the parts list +
comments.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
{build.price > 0 ? (
<span>
{`$${Math.floor(build.price / 100).toLocaleString()}`}
</span>
) : (
"—"
)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments + save coming soon.
</span>
</div>
</div>
</article>
))}
{!loading && !error && filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
No builds match those filters yet.
</div>
)}
</div>
</section>
</>
);
}
+1
View File
@@ -0,0 +1 @@
the page-use-client.tsx is the old client side page, this is a separation of the server side page and the client side
+8 -8
View File
@@ -26,11 +26,12 @@ function timeAgo(iso?: string | null) {
return `${days}d ago`; return `${days}d ago`;
} }
export default async function BuildBreakdownPage({ export default async function BuildBreakdownPage(
params, props: {
}: { params: Promise<{ buildId: string }>;
params: { buildId: string }; }
}) { ) {
const params = await props.params;
// Public detail endpoint // Public detail endpoint
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, { const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
cache: "no-store", cache: "no-store",
@@ -73,7 +74,6 @@ export default async function BuildBreakdownPage({
)} )}
</div> </div>
</div> </div>
{/* Layout */} {/* Layout */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]"> <div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
{/* Post column */} {/* Post column */}
@@ -168,11 +168,11 @@ export default async function BuildBreakdownPage({
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950"> <div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
{it.productImageUrl ? ( {it.productImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img (<img
src={it.productImageUrl} src={it.productImageUrl}
alt={it.productName ?? "Part image"} alt={it.productName ?? "Part image"}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />)
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600"> <div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
IMG IMG
+481
View File
@@ -0,0 +1,481 @@
"use client";
/**
* app/builds/page.tsx
* Community Builds feed (public)
*
* Dev Notes:
* - We are moving to Option B:
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
*
* Backend (target contract):
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
*/
import { useEffect, useMemo, useState, useCallback } from "react";
import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA";
/**
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
* We can tighten once the controlled vocab is finalized.
*/
type Caliber = string;
/**
* This matches what the UI needs (card format).
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
*/
type BuildCard = {
id: string; // we use uuid here (public identifier)
title: string;
slug: string; // can be uuid for now; keep property so UI does not change
creator: string; // placeholder until user profiles are real
caliber: Caliber;
buildClass: BuildClass;
price: number; // optional server-side later; for now allow 0 fallback
votes: number;
tags: string[];
coverImageUrl?: string | null;
};
type BuildFeedCardDto = {
uuid: string;
title?: string | null;
slug?: string | null; // optional (we can generate from title later)
creator?: string | null; // optional
caliber?: string | null;
buildClass?: BuildClass | null;
price?: number | null;
votes?: number | null;
tags?: string[] | null;
coverImageUrl?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// --- UI Filter Sets (keep; well generate options from data too) ---
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
];
function safeArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
}
function fallbackSlug(dto: BuildFeedCardDto) {
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
return dto.slug?.trim() || dto.uuid;
}
function normalizeCard(dto: BuildFeedCardDto): BuildCard {
const title = (dto.title ?? "Untitled Build").trim() || "Untitled Build";
return {
id: dto.uuid,
title,
slug: fallbackSlug(dto),
// Until we wire real users: keep a placeholder creator string.
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
caliber: (dto.caliber ?? "—").trim() || "—",
// Default to Rifle for display if missing; backend should set this via profile.
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
// Price is optional (we can compute later from BuildItems + offers).
price: typeof dto.price === "number" ? dto.price : 0,
votes: typeof dto.votes === "number" ? dto.votes : 0,
tags: safeArray(dto.tags),
coverImageUrl: dto.coverImageUrl ?? null,
};
}
export default function BuildsPage() {
// --- Remote data state ---
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [builds, setBuilds] = useState<BuildCard[]>([]);
// --- UI state (filters/votes) ---
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
const [priceFilterId, setPriceFilterId] = useState<string>("all");
// Local vote state for optimistic UI
const [votes, setVotes] = useState<Record<string, number>>({});
const activePriceFilter =
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
// --- Fetch public builds feed ---
useEffect(() => {
let cancelled = false;
async function run() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
method: "GET",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load builds (${res.status})`);
}
const data = (await res.json()) as BuildFeedCardDto[];
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
if (cancelled) return;
setBuilds(cards);
// Initialize vote UI from payload totals
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds feed");
} finally {
if (!cancelled) setLoading(false);
}
}
run();
return () => {
cancelled = true;
};
}, []);
// Build the caliber filters dynamically from data, but keep "all" first.
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
const uniq = new Set<string>();
for (const b of builds) {
const c = (b.caliber ?? "").trim();
if (c && c !== "—") uniq.add(c);
}
return ["all", ...Array.from(uniq).sort()];
}, [builds]);
const filteredBuilds = useMemo(() => {
return builds.filter((build) => {
const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter;
const matchesClass =
classFilter === "all" || build.buildClass === classFilter;
const matchesPrice =
build.price >= activePriceFilter.min &&
build.price < activePriceFilter.max;
return matchesCaliber && matchesClass && matchesPrice;
});
}, [builds, caliberFilter, classFilter, activePriceFilter]);
const handleVote = useCallback((id: string, delta: 1 | -1) => {
// MVP: optimistic local vote only.
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
setVotes((prev) => ({
...prev,
[id]: (prev[id] ?? 0) + delta,
}));
}, []);
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Battl Builder · Build Book
</p>
<h1
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
>
Community Builds
</h1>
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
Browse community builds, vote them up or down, and steal good
ideas without shame. This feed is backed by public builds
(isPublic=true).
</p>
</div>
<div className="flex gap-2 md:gap-3">
<Link
href="/builder"
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/60 px-4 py-2 text-xs md:text-sm font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Open Builder
</Link>
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
<Link
href="/vault"
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
+ Submit Build
</Link>
</div>
</header>
{/* Loading / Error */}
{loading && (
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
Loading community builds
</div>
)}
{error && !loading && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
{error}
<div className="mt-2 text-xs text-red-200/80">
Dev tip: confirm backend route{" "}
<span className="font-mono">GET /api/v1/builds</span> is
implemented + CORS is configured.
</div>
</div>
)}
{/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
Filter Builds
</h2>
<p className="text-xs text-zinc-500 mt-1">
Filter by caliber, class, and price range. (Client-side for
MVP.)
</p>
</div>
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredBuilds.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
builds
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{/* Caliber filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Caliber
</div>
<div className="flex flex-wrap gap-2">
{CALIBER_FILTERS.map((caliber) => (
<button
key={caliber}
type="button"
onClick={() =>
setCaliberFilter(caliber === "all" ? "all" : caliber)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
caliberFilter === caliber
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{caliber === "all" ? "All" : caliber}
</button>
))}
</div>
</div>
{/* Class filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Class
</div>
<div className="flex flex-wrap gap-2">
{CLASS_FILTERS.map((cls) => (
<button
key={cls}
type="button"
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{cls === "all" ? "All" : cls}
</button>
))}
</div>
</div>
{/* Price filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Price Range
</div>
<div className="flex flex-wrap gap-2">
{PRICE_FILTERS.map((range) => (
<button
key={range.id}
type="button"
onClick={() => setPriceFilterId(range.id)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
priceFilterId === range.id
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{range.label}
</button>
))}
</div>
</div>
</div>
</section>
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => {
const dollars = Math.floor((build.price ?? 0) / 100);
return (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Upvote"
title="Upvote"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Downvote"
title="Downvote"
>
</button>
</div>
{/* Thumbnail */}
<div className="hidden sm:block">
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Build detail page will show the parts list +
comments.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
{build.price > 0 ? (
<span>
${Math.floor(build.price / 100).toLocaleString()}
</span>
) : (
"—"
)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments + save coming soon.
</span>
</div>
</div>
</article>
);
})}
{!loading && !error && filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
No builds match those filters yet.
</div>
)}
</div>
</section>
</div>
</main>
);
}
+31 -382
View File
@@ -1,43 +1,17 @@
"use client"; // app/builds/page.tsx
/**
* app/builds/page.tsx
* Community Builds feed (public)
*
* Dev Notes:
* - We are moving to Option B:
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
*
* Backend (target contract):
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
*/
import { useEffect, useMemo, useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA"; type BuildClass = "Rifle" | "Pistol" | "NFA";
/**
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
* We can tighten once the controlled vocab is finalized.
*/
type Caliber = string; type Caliber = string;
/**
* This matches what the UI needs (card format).
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
*/
type BuildCard = { type BuildCard = {
id: string; // we use uuid here (public identifier) id: string;
title: string; title: string;
slug: string; // can be uuid for now; keep property so UI does not change slug: string;
creator: string; // placeholder until user profiles are real creator: string;
caliber: Caliber; caliber: Caliber;
buildClass: BuildClass; buildClass: BuildClass;
price: number; // optional server-side later; for now allow 0 fallback price: number;
votes: number; votes: number;
tags: string[]; tags: string[];
coverImageUrl?: string | null; coverImageUrl?: string | null;
@@ -46,8 +20,8 @@ type BuildCard = {
type BuildFeedCardDto = { type BuildFeedCardDto = {
uuid: string; uuid: string;
title?: string | null; title?: string | null;
slug?: string | null; // optional (we can generate from title later) slug?: string | null;
creator?: string | null; // optional creator?: string | null;
caliber?: string | null; caliber?: string | null;
buildClass?: BuildClass | null; buildClass?: BuildClass | null;
price?: number | null; price?: number | null;
@@ -56,26 +30,13 @@ type BuildFeedCardDto = {
coverImageUrl?: string | null; coverImageUrl?: string | null;
}; };
const API_BASE_URL = const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// --- UI Filter Sets (keep; well generate options from data too) ---
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
];
function safeArray(v: unknown): string[] { function safeArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter(Boolean).map(String) : []; return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
} }
function fallbackSlug(dto: BuildFeedCardDto) { function fallbackSlug(dto: BuildFeedCardDto) {
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
return dto.slug?.trim() || dto.uuid; return dto.slug?.trim() || dto.uuid;
} }
@@ -86,119 +47,41 @@ function normalizeCard(dto: BuildFeedCardDto): BuildCard {
id: dto.uuid, id: dto.uuid,
title, title,
slug: fallbackSlug(dto), slug: fallbackSlug(dto),
// Until we wire real users: keep a placeholder creator string.
creator: (dto.creator ?? "anonymous").trim() || "anonymous", creator: (dto.creator ?? "anonymous").trim() || "anonymous",
caliber: (dto.caliber ?? "—").trim() || "—", caliber: (dto.caliber ?? "—").trim() || "—",
// Default to Rifle for display if missing; backend should set this via profile.
buildClass: (dto.buildClass ?? "Rifle") as BuildClass, buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
// Price is optional (we can compute later from BuildItems + offers).
price: typeof dto.price === "number" ? dto.price : 0, price: typeof dto.price === "number" ? dto.price : 0,
votes: typeof dto.votes === "number" ? dto.votes : 0, votes: typeof dto.votes === "number" ? dto.votes : 0,
tags: safeArray(dto.tags), tags: safeArray(dto.tags),
coverImageUrl: dto.coverImageUrl ?? null, coverImageUrl: dto.coverImageUrl ?? null,
}; };
} }
export default function BuildsPage() { // Import the client component that will handle filters & voting
// --- Remote data state --- import BuildsClient from "./BuildsClient";
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [builds, setBuilds] = useState<BuildCard[]>([]);
// --- UI state (filters/votes) --- export default async function BuildsPage() {
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all"); let cards: BuildCard[] = [];
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all"); let error: string | null = null;
const [priceFilterId, setPriceFilterId] = useState<string>("all");
// Local vote state for optimistic UI try {
const [votes, setVotes] = useState<Record<string, number>>({}); const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
method: "GET",
const activePriceFilter = // Disable full-page static caching; you can adjust this per your needs:
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0]; cache: "no-store",
headers: { "Content-Type": "application/json" },
// --- Fetch public builds feed ---
useEffect(() => {
let cancelled = false;
async function run() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
method: "GET",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load builds (${res.status})`);
}
const data = (await res.json()) as BuildFeedCardDto[];
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
if (cancelled) return;
setBuilds(cards);
// Initialize vote UI from payload totals
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds feed");
} finally {
if (!cancelled) setLoading(false);
}
}
run();
return () => {
cancelled = true;
};
}, []);
// Build the caliber filters dynamically from data, but keep "all" first.
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
const uniq = new Set<string>();
for (const b of builds) {
const c = (b.caliber ?? "").trim();
if (c && c !== "—") uniq.add(c);
}
return ["all", ...Array.from(uniq).sort()];
}, [builds]);
const filteredBuilds = useMemo(() => {
return builds.filter((build) => {
const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter;
const matchesClass =
classFilter === "all" || build.buildClass === classFilter;
const matchesPrice =
build.price >= activePriceFilter.min &&
build.price < activePriceFilter.max;
return matchesCaliber && matchesClass && matchesPrice;
}); });
}, [builds, caliberFilter, classFilter, activePriceFilter]);
const handleVote = useCallback((id: string, delta: 1 | -1) => { if (!res.ok) {
// MVP: optimistic local vote only. const txt = await res.text().catch(() => "");
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile. throw new Error(txt || `Failed to load builds (${res.status})`);
setVotes((prev) => ({ }
...prev,
[id]: (prev[id] ?? 0) + delta, const data = (await res.json()) as BuildFeedCardDto[];
})); cards = (Array.isArray(data) ? data : []).map(normalizeCard);
}, []); } catch (e: any) {
error = e?.message || "Failed to load builds feed";
}
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
@@ -230,8 +113,6 @@ export default function BuildsPage() {
> >
Open Builder Open Builder
</Link> </Link>
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
<Link <Link
href="/vault" href="/vault"
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors" className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
@@ -241,240 +122,8 @@ export default function BuildsPage() {
</div> </div>
</header> </header>
{/* Loading / Error */} {/* Pass initial data + any load error into the client component */}
{loading && ( <BuildsClient initialBuilds={cards} initialError={error} />
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
Loading community builds
</div>
)}
{error && !loading && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
{error}
<div className="mt-2 text-xs text-red-200/80">
Dev tip: confirm backend route{" "}
<span className="font-mono">GET /api/v1/builds</span> is
implemented + CORS is configured.
</div>
</div>
)}
{/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
Filter Builds
</h2>
<p className="text-xs text-zinc-500 mt-1">
Filter by caliber, class, and price range. (Client-side for
MVP.)
</p>
</div>
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredBuilds.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
builds
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{/* Caliber filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Caliber
</div>
<div className="flex flex-wrap gap-2">
{CALIBER_FILTERS.map((caliber) => (
<button
key={caliber}
type="button"
onClick={() =>
setCaliberFilter(caliber === "all" ? "all" : caliber)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
caliberFilter === caliber
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{caliber === "all" ? "All" : caliber}
</button>
))}
</div>
</div>
{/* Class filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Class
</div>
<div className="flex flex-wrap gap-2">
{CLASS_FILTERS.map((cls) => (
<button
key={cls}
type="button"
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{cls === "all" ? "All" : cls}
</button>
))}
</div>
</div>
{/* Price filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Price Range
</div>
<div className="flex flex-wrap gap-2">
{PRICE_FILTERS.map((range) => (
<button
key={range.id}
type="button"
onClick={() => setPriceFilterId(range.id)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
priceFilterId === range.id
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{range.label}
</button>
))}
</div>
</div>
</div>
</section>
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => {
const dollars = Math.floor((build.price ?? 0) / 100);
return (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Upvote"
title="Upvote"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Downvote"
title="Downvote"
>
</button>
</div>
{/* Thumbnail */}
<div className="hidden sm:block">
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Build detail page will show the parts list +
comments.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
{build.price > 0 ? (
<span>
${Math.floor(build.price / 100).toLocaleString()}
</span>
) : (
"—"
)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments + save coming soon.
</span>
</div>
</div>
</article>
);
})}
{!loading && !error && filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
No builds match those filters yet.
</div>
)}
</div>
</section>
</div> </div>
</main> </main>
); );
+11
View File
@@ -0,0 +1,11 @@
export default function MailingAddressComponent() {
return <p>
<strong>Mailing Address:</strong>
<br/>
Battl Builder, LLC.
<br/>
[Your Address]
<br/>
[City, State, ZIP]
</p>;
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import {JSX, useEffect, useRef, useState} from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail"; import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form"; import { Button, Field, Input, Textarea } from "@/components/ui/form";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import {JSX, useEffect, useRef, useState} from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail"; import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input } from "@/components/ui/form"; import { Button, Field, Input } from "@/components/ui/form";
import RichTextEditor from "@/components/ui/RichTextEditor"; import RichTextEditor from "@/components/ui/RichTextEditor";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import {JSX, useEffect, useRef, useState} from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail"; import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input } from "@/components/ui/form"; import { Button, Field, Input } from "@/components/ui/form";
import RichTextEditor from "@/components/ui/RichTextEditor"; import RichTextEditor from "@/components/ui/RichTextEditor";
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "eslint/config";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig([{
extends: [...nextCoreWebVitals],
}]);
+33 -23
View File
@@ -1,23 +1,33 @@
FROM node:20-alpine AS deps # ---------- deps ----------
WORKDIR /app FROM node:20-alpine AS deps
COPY package.json package-lock.json ./ WORKDIR /app
RUN npm ci --only=production COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app # ---------- build ----------
COPY package.json package-lock.json ./ FROM node:20-alpine AS builder
RUN npm ci WORKDIR /app
COPY . . COPY --from=deps /app/node_modules ./node_modules
ENV NODE_ENV=production COPY . .
ENV NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080 ENV NODE_ENV=production
RUN npm run build # Build-time public env (baked into client bundle). You can override by rebuilding.
ARG NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
FROM node:20-alpine AS runner ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
WORKDIR /app RUN npm run build
COPY --from=deps /app/node_modules ./node_modules # ---------- runner ----------
COPY --from=builder /app/.next ./.next FROM node:20-alpine AS runner
COPY --from=builder /app/public ./public WORKDIR /app
COPY --from=builder /app/package.json ./package.json ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"] # If you want slightly better security:
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
# Standalone output includes server + minimal node_modules
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-alpine AS deps
RUN apk add curl
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV NODE_ENV=production
ENV NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10 -8
View File
@@ -3,6 +3,7 @@ const nextConfig = {
experimental: { experimental: {
appDir: true, appDir: true,
}, },
output: "standalone",
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
@@ -10,14 +11,15 @@ const nextConfig = {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
/* async rewrites() { // If you want rewrites, uncomment this block:
return [ // async rewrites() {
{ // return [
source: "/api/:path*", // {
destination: "http://battlbuilder-api:8080/api/:path*", // source: "/api/:path*",
}, // destination: "http://battlbuilder-api:8080/api/:path*",
]; // },
},*/ // ];
// },
}; };
export default nextConfig; export default nextConfig;
+863 -691
View File
File diff suppressed because it is too large Load Diff
+31 -11
View File
@@ -1,34 +1,54 @@
{ {
"name": "gunbuilder-prototype", "name": "battle-builder-webui",
"version": "0.1.0", "version": "0.9.0",
"private": true, "private": true,
"author": {
"name": "Forward Group, LLC",
"email": "info@goforward.group",
"url": "https://goforward.group"
},
"contributors": [
{
"name": "Sean Strawsburg",
"email": "sean@goforward.group"
},
{
"name": "Don Strawsburg",
"email": "don@goforward.group"
}
],
"license": "UNLICENSED",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.9", "@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"next": "14.2.3", "next": "15.5.9",
"quill": "^2.0.3", "quill": "^2.0.3",
"quill-image-resize-module": "^3.0.0", "quill-image-resize-module": "^3.0.0",
"react": "18.3.1", "react": "19.2.3",
"react-dom": "18.3.1", "react-dom": "19.2.3",
"react-quill": "^2.0.0" "react-quill": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.12.7", "@types/node": "20.12.7",
"@types/react": "18.3.3", "@types/react": "19.2.9",
"@types/react-dom": "18.3.0", "@types/react-dom": "19.2.3",
"autoprefixer": "10.4.19", "autoprefixer": "10.4.19",
"eslint": "8.57.0", "eslint": "^9",
"eslint-config-next": "14.2.3", "eslint-config-next": "15.5.9",
"postcss": "8.4.38", "postcss": "8.4.38",
"tailwindcss": "3.4.3", "tailwindcss": "3.4.3",
"typescript": "5.4.5" "typescript": "5.4.5"
},
"overrides": {
"@types/react": "19.2.9",
"@types/react-dom": "19.2.3"
} }
} }
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -e
REGISTRY="gitea.gofwd.group"
OWNER="sean/shadow-gunbuilder-ai-proto"
IMAGE="webui"
TAG=$(git rev-parse --short HEAD)
FULL_IMAGE="$REGISTRY/$OWNER/$IMAGE"
echo "Building $FULL_IMAGE:$TAG"
docker build -f frontend/Dockerfile --no-cache -t $FULL_IMAGE:$TAG .
echo "Tagging latest"
docker tag $FULL_IMAGE:$TAG $FULL_IMAGE:latest
echo "Pushing $TAG"
docker push $FULL_IMAGE:$TAG
echo "Pushing latest"
docker push $FULL_IMAGE:latest
echo "Done!"
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
REGISTRY="gitea.gofwd.group"
OWNER="sean/shadow-gunbuilder-ai-proto"
IMAGE="webui"
TAG="$(git rev-parse --short HEAD)"
FULL_IMAGE="$REGISTRY/$OWNER/$IMAGE"
EXTRA_ARGS=()
if [[ "${NO_CACHE:-0}" == "1" ]]; then
EXTRA_ARGS+=(--no-cache)
fi
docker buildx build \
-f frontend/Dockerfile \
--platform linux/amd64,linux/arm64 \
-t "$FULL_IMAGE:$TAG" \
-t "$FULL_IMAGE:latest" \
--push \
"${EXTRA_ARGS[@]}" \
.