Create monorepo from known-good production state

This commit is contained in:
makearmy 2026-07-09 21:07:34 -04:00
commit c034824338
651 changed files with 120469 additions and 0 deletions

19
app/.dockerignore Normal file
View file

@ -0,0 +1,19 @@
.git
.gitignore
node_modules
**/node_modules
.next
**/.next
files
app/files
**/venv
**/__pycache__
npm-debug.log*
yarn-debug.log*
yarn-error.log*
debug
*.bak
*.zip
.DS_Store

10
app/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
!public/svgnest/**
# Next.js build output
.next/
# Node
node_modules/
# dependencies
node_modules/

49
app/Dockerfile Normal file
View file

@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1
FROM node:20-bookworm-slim AS deps
WORKDIR /app
# Install dependencies needed to build
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-bookworm-slim AS build
WORKDIR /app
# Reuse installed dependencies
COPY --from=deps /app/node_modules ./node_modules
# Copy app source
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build the app
RUN npm run build
# After building, remove dev dependencies so runtime stays smaller
RUN npm prune --omit=dev
FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOME=/tmp
# Create writable directories
RUN mkdir -p /app/files /app/.next/cache /tmp && \
chown -R 1000:1000 /app /tmp
# Copy only runtime files
COPY --from=build --chown=1000:1000 /app/package.json ./package.json
COPY --from=build --chown=1000:1000 /app/next.config.js ./next.config.js
COPY --from=build --chown=1000:1000 /app/public ./public
COPY --from=build --chown=1000:1000 /app/.next ./.next
COPY --from=build --chown=1000:1000 /app/node_modules ./node_modules
USER 1000:1000
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "3000", "-H", "0.0.0.0"]

18
app/app/api/_lib/auth.ts Normal file
View file

@ -0,0 +1,18 @@
// app/api/_lib/auth.ts
import { getUserBearerFromRequest } from "@/lib/directus";
export function requireBearer(req: Request): string {
const bearer = getUserBearerFromRequest(req);
if (!bearer) {
const err: any = new Error("UNAUTHENTICATED");
err.status = 401;
throw err;
}
return bearer;
}
export function jsonError(e: any, fallback = 500) {
const status = e?.status ?? fallback;
const body = { error: e?.message || "ERROR", detail: e?.detail ?? String(e) };
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}

View file

@ -0,0 +1,60 @@
// app/api/account/avatar/route.ts
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth";
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const AVATAR_FOLDER_ID = process.env.DIRECTUS_AVATAR_FOLDER_ID || "";
function bad(msg: string, code = 400) {
return NextResponse.json({ error: msg }, { status: code });
}
export async function POST(req: Request) {
try {
if (!AVATAR_FOLDER_ID) {
return bad("Server misconfiguration: DIRECTUS_AVATAR_FOLDER_ID is not set", 500);
}
const bearer = requireBearer(req);
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof Blob)) return bad("Missing file");
// Upload directly into the configured avatars folder
const up = new FormData();
up.set("file", file, (file as any).name || "avatar.bin");
up.set("folder", AVATAR_FOLDER_ID);
const r1 = await fetch(`${API}/files`, {
method: "POST",
headers: { Authorization: `Bearer ${bearer}` },
body: up,
});
const j1 = await r1.json().catch(() => ({}));
if (!r1.ok) {
const msg = j1?.errors?.[0]?.message || "Upload failed";
return bad(msg, r1.status);
}
const fileId: string = j1?.data?.id ?? j1?.id;
// Link file to the current user
const r2 = await fetch(`${API}/users/me`, {
method: "PATCH",
headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
body: JSON.stringify({ avatar: fileId }),
});
const j2 = await r2.json().catch(() => ({}));
if (!r2.ok) {
const msg = j2?.errors?.[0]?.message || "Link avatar failed";
return bad(msg, r2.status);
}
// Respond with the updated avatar reference
return NextResponse.json({ ok: true, avatar: j2?.data?.avatar ?? { id: fileId } });
} catch (e: any) {
return bad(e?.message || "Upload error", e?.status || 500);
}
}

View file

@ -0,0 +1,110 @@
// app/api/account/password/route.ts
import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth";
import { loginDirectus } from "@/lib/directus";
export const runtime = "nodejs";
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const bad = (m: string, c = 400, extra?: Record<string, any>) =>
NextResponse.json(extra ? { error: m, ...extra } : { error: m }, { status: c });
function readCookie(req: Request, name: string): string | null {
const cookie = req.headers.get("cookie") || "";
const m = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
return m ? decodeURIComponent(m[1]) : null;
}
function jwtPayload(token: string | null): any | null {
if (!token) return null;
try {
const [, payload] = token.split(".");
if (!payload) return null;
const json = JSON.parse(
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
);
return json;
} catch {
return null;
}
}
async function handle(req: Request) {
const bearer = requireBearer(req);
const body = await req.json().catch(() => ({} as any));
const current = String(body?.current ?? body?.current_password ?? "").trim();
const next = String(body?.next ?? body?.new_password ?? "").trim();
// NEW: allow client to provide identifier explicitly
let identifier = String(body?.identifier ?? "").trim();
if (!current || !next) return bad("Missing current and/or new password");
if (next.length < 8) return bad("Password must be at least 8 characters");
// 1) Prefer client-provided identifier if present
// (email or username — whatever your Directus login accepts)
if (!identifier) {
// 2) Try to read from /users/me (email or username), honoring role perms
const meRes = await fetch(`${API}/users/me?fields=id,email,username,provider`, {
headers: { Authorization: `Bearer ${bearer}`, Accept: "application/json" },
cache: "no-store",
});
const me = await meRes.json().catch(() => ({}));
if (meRes.ok) {
const provider: string = me?.data?.provider ?? me?.provider ?? "local";
if (provider !== "local") {
return bad("Password managed by external provider", 400, { debug: `provider=${provider}` });
}
const email: string | undefined = me?.data?.email ?? me?.email ?? undefined;
const username: string | undefined = me?.data?.username ?? me?.username ?? undefined;
identifier = email || username || "";
} else {
// If we cant read user fields (permissions), keep going
}
}
if (!identifier) {
// 3) Try to decode JWT in ma_at — some Directus builds include email in JWT
const token = readCookie(req, "ma_at");
const claims = jwtPayload(token);
const fromJwt = (claims?.email as string) || (claims?.user?.email as string) || "";
identifier = fromJwt?.trim() || "";
}
if (!identifier) {
// Last resort: ask client to pass identifier next time
return bad("No login identifier available for this user", 400, {
debug: "missing email and username; pass `identifier` in request body",
});
}
// 4) Verify CURRENT password by logging in with identifier
const auth = await loginDirectus(identifier, current).catch(() => null);
const access = auth?.access_token ?? auth?.data?.access_token;
if (!access) {
return bad("Current password is incorrect", 401);
}
// 5) Update password using ONLY the 'password' field
const patchRes = await fetch(`${API}/users/me`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${bearer}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ password: next }),
});
const j = await patchRes.json().catch(() => ({}));
if (!patchRes.ok) {
const reason =
j?.errors?.[0]?.message || j?.error || (typeof j === "string" ? j : "") || "Password change failed";
return bad(reason, patchRes.status);
}
return NextResponse.json({ ok: true });
}
export async function POST(req: Request) { return handle(req); }
export async function PATCH(req: Request) { return handle(req); }

View file

@ -0,0 +1,75 @@
// app/api/account/profile/route.ts
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth";
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const bad = (m: string, c = 400) => NextResponse.json({ error: m }, { status: c });
export async function GET() {
try {
return NextResponse.json({ ok: true });
} catch (e: any) {
return bad(e?.message || "Failed to load profile", e?.status || 500);
}
}
export async function PATCH(req: Request) {
try {
const bearer = requireBearer(req);
const body = await req.json().catch(() => ({} as Record<string, unknown>));
// Enforce recent re-auth for sensitive fields
const SENSITIVE = new Set(["email", "username"]);
const wantsSensitive = Object.keys(body).some((k) => SENSITIVE.has(k));
if (wantsSensitive) {
const cookie = req.headers.get("cookie") || "";
const hasRecentAuth = /(?:^|;\s*)ma_ra=1(?:;|$)/.test(cookie);
if (!hasRecentAuth) {
return NextResponse.json({ error: "Re-authentication required" }, { status: 428 });
}
}
const payload: Record<string, any> = {};
if (typeof (body as any).first_name === "string")
payload.first_name = (body as any).first_name.trim();
if (typeof (body as any).last_name === "string")
payload.last_name = (body as any).last_name.trim();
if (typeof (body as any).location === "string")
payload.location = (body as any).location.trim();
if ("email" in body) {
const e = String((body as any).email ?? "").trim();
payload.email = e ? e : null; // optional; blank clears it
}
if (!Object.keys(payload).length) return bad("No changes");
const r = await fetch(`${API}/users/me`, {
method: "PATCH",
headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) return bad(j?.errors?.[0]?.message || "Update failed", r.status);
// Single-use recent-auth: clear ma_ra after sensitive success
if (wantsSensitive) {
const resp = NextResponse.json({ ok: true });
resp.cookies.set({
name: "ma_ra",
value: "",
httpOnly: false,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
return resp;
}
return NextResponse.json({ ok: true });
} catch (e: any) {
return bad(e?.message || "Unexpected error", e?.status || 500);
}
}

View file

@ -0,0 +1,101 @@
// app/api/account/route.ts
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth";
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const bad = (m: string, c = 400) => NextResponse.json({ error: m }, { status: c });
const secure = process.env.NODE_ENV === "production";
/** GET: current user's profile */
export async function GET(req: Request) {
try {
const bearer = requireBearer(req); // ← pass req
const fields =
"id,username,first_name,last_name,email,location,avatar.id,avatar.filename_download";
const res = await fetch(
`${API}/users/me?fields=${encodeURIComponent(fields)}`,
{ headers: { Authorization: `Bearer ${bearer}` }, cache: "no-store" }
);
const j = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = j?.errors?.[0]?.message || "Failed to load profile";
return NextResponse.json({ error: msg }, { status: res.status });
}
return NextResponse.json({ ok: true, user: j?.data ?? j });
} catch (e: any) {
const status = e?.status === 401 ? 401 : e?.status || 500;
return bad(e?.message || "Unexpected error", status);
}
}
/** PATCH: update editable fields for current user */
export async function PATCH(req: Request) {
try {
const bearer = requireBearer(req);
const body = await req.json().catch(() => ({} as Record<string, unknown>));
// Enforce recent re-auth for sensitive fields (email/username)
const SENSITIVE = new Set(["email", "username"]);
const wantsSensitive = Object.keys(body).some((k) => SENSITIVE.has(k));
if (wantsSensitive) {
const cookie = req.headers.get("cookie") || "";
const hasRecentAuth = /(?:^|;\s*)ma_ra=1(?:;|$)/.test(cookie);
if (!hasRecentAuth) {
return NextResponse.json(
{ error: "Re-authentication required" },
{ status: 428 } // Precondition Required
);
}
}
const payload: Record<string, any> = {};
if (typeof body.first_name === "string") payload.first_name = body.first_name.trim();
if (typeof body.last_name === "string") payload.last_name = body.last_name.trim();
if ("email" in body) {
const e = String((body as any).email ?? "").trim();
payload.email = e ? e : null; // optional; blank clears
}
if (typeof body.location === "string") payload.location = body.location.trim();
if (typeof body.avatar === "string") payload.avatar = body.avatar; // file id
// (username is read-only in UI; if you decide to allow it later, payload.username = ...)
if (!Object.keys(payload).length) return bad("No changes");
const res = await fetch(`${API}/users/me`, {
method: "PATCH",
headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = j?.errors?.[0]?.message || "Update failed";
return NextResponse.json({ error: msg }, { status: res.status });
}
// Success: if we just did a sensitive change, clear recent-auth cookie (single-use)
if (wantsSensitive) {
const resp = NextResponse.json({ ok: true });
resp.cookies.set({
name: "ma_ra",
value: "",
httpOnly: false,
sameSite: "lax",
secure,
path: "/",
maxAge: 0,
});
return resp;
}
return NextResponse.json({ ok: true });
} catch (e: any) {
const status = e?.status === 401 ? 401 : e?.status || 500;
return bad(e?.message || "Unexpected error", status);
}
}

View file

@ -0,0 +1,82 @@
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { emailForUsername, loginDirectus } from "@/lib/directus";
export const runtime = "nodejs";
const secure = process.env.NODE_ENV === "production";
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({} as any));
const identifier =
String(body?.identifier ?? body?.email ?? body?.username ?? "").trim();
const password = String(body?.password ?? "").trim();
if (!identifier || !password) {
return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
}
// 1) Try Directus directly with the identifier (email OR username)
// Directus expects the field name "email" for both.
const tryIds: string[] = [identifier];
// 2) Fallback: if it doesnt look like an email, try the canonical email (if any)
if (!identifier.includes("@")) {
try {
const em = await emailForUsername(identifier); // returns string|null
if (em && em !== identifier) tryIds.push(em);
} catch {
// ignore lookup errors, we'll just rely on the first attempt
}
}
let tokens: any = null;
let lastErr: any = null;
for (const id of tryIds) {
try {
tokens = await loginDirectus(id, password); // { access_token, refresh_token, expires? }
if (tokens) break;
} catch (e) {
lastErr = e;
}
}
if (!tokens?.access_token) {
const msg =
lastErr?.response?.data?.errors?.[0]?.message ||
lastErr?.response?.data?.error ||
lastErr?.message ||
"Invalid credentials.";
return NextResponse.json({ error: msg }, { status: 401 });
}
// Set HttpOnly cookies for your middleware
const maxAge = 60 * 60; // 1h
const res = NextResponse.json({ ok: true });
res.cookies.set("ma_at", tokens.access_token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure,
maxAge,
});
if (tokens.refresh_token) {
res.cookies.set("ma_rt", tokens.refresh_token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure,
maxAge: 60 * 60 * 24 * 30, // 30d
});
}
return res;
} catch (err: any) {
const message =
err?.response?.data?.errors?.[0]?.message ||
err?.response?.data?.error ||
err?.message ||
"Login failed";
const status = /unauth|invalid|credential/i.test(message) ? 401 : 400;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -0,0 +1,28 @@
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const secure = process.env.NODE_ENV === "production";
export async function POST(_req: NextRequest) {
const res = NextResponse.json({ ok: true });
res.cookies.set({
name: "ma_at",
value: "",
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
expires: new Date(0), // expire immediately
maxAge: 0,
});
return res;
}
// Optional: support GET if you ever link to /api/auth/logout directly
export async function GET(_req: NextRequest) {
return POST(_req);
}

View file

@ -0,0 +1,66 @@
// app/api/auth/reconfirm/route.ts
import { NextRequest, NextResponse } from "next/server";
import { emailForUsername, loginDirectus } from "@/lib/directus";
export const runtime = "nodejs";
const secure = process.env.NODE_ENV === "production";
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({} as any));
const identifier = String(body?.identifier ?? "").trim();
const password = String(body?.password ?? "").trim();
if (!identifier || !password) {
return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
}
// Resolve identifier -> email (username or email accepted)
const email = identifier.includes("@") ? identifier : await emailForUsername(identifier);
if (!email) return NextResponse.json({ error: "User not found" }, { status: 404 });
const auth = await loginDirectus(email, password);
const access = auth?.access_token ?? auth?.data?.access_token;
const expiresSec = auth?.expires ?? auth?.data?.expires;
if (!access) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const res = NextResponse.json({ ok: true });
// Refresh the access token cookie
const maxAge =
typeof expiresSec === "number" ? Math.max(0, Math.floor(expiresSec)) : 60 * 60 * 8;
res.cookies.set({
name: "ma_at",
value: access,
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
maxAge,
});
// Short-lived client-visible flag: “recently authenticated” (5 minutes)
res.cookies.set({
name: "ma_ra",
value: "1",
httpOnly: false,
sameSite: "lax",
secure,
path: "/",
maxAge: 5 * 60,
});
return res;
} catch (err: any) {
const msg =
err?.response?.data?.errors?.[0]?.message ||
err?.response?.data?.error ||
err?.message ||
"Re-auth failed";
const status = /invalid|credential/i.test(msg) ? 401 : 400;
return NextResponse.json({ error: msg }, { status });
}
}

View file

@ -0,0 +1,114 @@
// app/api/auth/register/route.ts
import { NextResponse } from "next/server";
const DIRECTUS = (process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const SERVICE_TOKEN =
process.env.DIRECTUS_SERVICE_TOKEN ||
process.env.DIRECTUS_ADMIN_TOKEN ||
process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || "";
const DEFAULT_ROLE = process.env.DIRECTUS_DEFAULT_ROLE || undefined;
const SECURE = process.env.NODE_ENV === "production";
function bad(message: string, status = 400) {
return NextResponse.json({ error: message }, { status });
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
async function directusLogin(email: string, password: string) {
const r = await fetch(`${DIRECTUS}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ email, password }),
cache: "no-store",
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j?.errors?.[0]?.message || j?.message || `Login failed (${r.status})`);
return j?.data || j;
}
export async function POST(req: Request) {
try {
if (!DIRECTUS) return bad("Missing DIRECTUS_URL/NEXT_PUBLIC_API_BASE_URL", 500);
if (!SERVICE_TOKEN) return bad("Missing DIRECTUS_SERVICE_TOKEN / admin token", 500);
const body = await req.json().catch(() => ({} as any));
const email = String(body?.email ?? "").trim().toLowerCase();
const username = String(body?.username ?? "").trim();
const password = String(body?.password ?? "").trim();
const confirm = String(body?.confirmPassword ?? body?.confirm ?? "").trim();
if (!email || !username || !password || !confirm) return bad("All fields are required");
if (!EMAIL_RE.test(email)) return bad("Enter a valid email address");
if (password.length < 8) return bad("Password must be at least 8 characters");
if (password !== confirm) return bad("Passwords do not match");
// Optional pre-check to return a friendly 409 instead of a generic Directus error
const existsRes = await fetch(
`${DIRECTUS}/users?filter[_or][0][email][_eq]=${encodeURIComponent(email)}` +
`&filter[_or][1][username][_eq]=${encodeURIComponent(username)}` +
`&fields=id,email,username&limit=1`,
{
headers: {
Authorization: `Bearer ${SERVICE_TOKEN}`,
Accept: "application/json",
},
cache: "no-store",
}
);
const existsJson = await existsRes.json().catch(() => ({}));
if (Array.isArray(existsJson?.data) && existsJson.data.length > 0) {
return bad("Email or username already in use", 409);
}
// Create user with sane defaults
const createPayload: any = {
email,
username,
password,
};
if (DEFAULT_ROLE) createPayload.role = DEFAULT_ROLE;
const createRes = await fetch(`${DIRECTUS}/users`, {
method: "POST",
headers: {
Authorization: `Bearer ${SERVICE_TOKEN}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(createPayload),
cache: "no-store",
});
const cj = await createRes.json().catch(() => ({}));
if (!createRes.ok) {
const msg = cj?.errors?.[0]?.message || cj?.message || `User create failed (${createRes.status})`;
return bad(msg, createRes.status || 500);
}
// Auto-login (email-based; directus expects "email" even though it's an identifier)
const tokens = await directusLogin(email, password);
const res = NextResponse.json({ ok: true, id: cj?.data?.id || null }, { status: 201 });
if (tokens?.access_token) {
res.cookies.set("ma_at", tokens.access_token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: SECURE,
maxAge: 60 * 60, // 1h
});
}
if (tokens?.refresh_token) {
res.cookies.set("ma_rt", tokens.refresh_token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: SECURE,
maxAge: 60 * 60 * 24 * 30, // 30d
});
}
return res;
} catch (e: any) {
return bad(e?.message || "Registration error", e?.status || 500);
}
}

View file

@ -0,0 +1,47 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
/**
* Proxies multipart POSTs (file + method) to the bgbye upstream.
* Set BG_BYE_UPSTREAM to the FULL endpoint that accepts the form:
* e.g. http://127.0.0.1:8010/process
* http://localhost:8010/api/removebg
*/
export async function POST(req: Request) {
const upstream = process.env.BG_BYE_UPSTREAM;
if (!upstream) {
return NextResponse.json(
{ error: "BG_BYE_UPSTREAM is not configured on the server" },
{ status: 500 }
);
}
try {
const form = await req.formData();
// Forward the form as-is to the upstream
const ures = await fetch(upstream, {
method: "POST",
body: form,
// Let undici set boundary; don't set Content-Type manually
});
const contentType = ures.headers.get("content-type") || "application/octet-stream";
const status = ures.status;
// Stream/buffer back to the client preserving content-type
const ab = await ures.arrayBuffer();
return new Response(Buffer.from(ab), {
status,
headers: {
"content-type": contentType,
// Allow the client to see error text if upstream returns text
"cache-control": "no-store",
},
});
} catch (err: any) {
const msg = err?.message || String(err);
return NextResponse.json({ error: `Proxy error: ${msg}` }, { status: 502 });
}
}

View file

@ -0,0 +1,52 @@
// app/api/claims/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API = process.env.NEXT_PUBLIC_API_BASE_URL!; // e.g. https://directus.your.tld
export async function POST(req: Request) {
try {
const { target_collection, target_id } = await req.json();
if (!target_collection || !target_id) {
return NextResponse.json(
{ error: 'Missing target_collection or target_id' },
{ status: 400 }
);
}
// Next 15: cookies() is async
const jar = await cookies();
const token =
jar.get('directus_access_token')?.value ??
jar.get('ma_at')?.value ??
'';
if (!token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const r = await fetch(`${API}/items/user_claims`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ target_collection, target_id }),
cache: 'no-store',
});
const data = await r.json().catch(() => ({} as any));
if (!r.ok) {
return NextResponse.json(
{ error: data?.errors?.[0]?.message ?? data?.message ?? 'Directus error' },
{ status: r.status }
);
}
return NextResponse.json({ ok: true, data }, { status: 200 });
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}

View file

@ -0,0 +1,17 @@
// app/api/debug/meta/route.ts
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
export async function GET() {
let buildId = null;
try {
buildId = (await fs.readFile(process.cwd() + "/.next/BUILD_ID", "utf8")).trim();
} catch {}
return NextResponse.json({
env: process.env.NEXT_PUBLIC_ENV || null,
nodeEnv: process.env.NODE_ENV || null,
buildId,
image: process.env.IMAGE_TAG || null,
commit: process.env.COMMIT_SHA || null,
time: new Date().toISOString(),
});
}

View file

@ -0,0 +1,14 @@
// app/api/debug/whoami/route.ts
import { NextResponse } from "next/server";
import { dxGET } from "@/lib/directus";
import { requireBearer } from "@/app/api/_lib/auth";
export async function GET(req: Request) {
try {
const bearer = requireBearer(req);
const me = await dxGET<any>("/users/me?fields=id,username", bearer);
return NextResponse.json(me, { status: 200 });
} catch (e: any) {
return NextResponse.json({ error: e?.message || "err" }, { status: e?.status ?? 500 });
}
}

View file

@ -0,0 +1,84 @@
// app/api/dx/[...path]/route.ts
import { NextResponse } from "next/server";
import { dxGET, dxPOST, dxPATCH, dxDELETE } from "@/lib/directus";
import { requireBearer } from "@/app/api/_lib/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic"; // these are true proxies, never prerender
function buildPath(req: Request, context: any) {
const search = new URL(req.url).search || "";
const params = (context?.params ?? {}) as { path?: string[] };
const pathParts = Array.isArray(params.path) ? params.path : [];
return `/${pathParts.join("/")}${search}`;
}
// GET /api/dx/<anything>?<query>
export async function GET(req: Request, context: any) {
try {
const bearer = requireBearer(req);
const p = buildPath(req, context);
const json = await dxGET<any>(p, bearer);
return NextResponse.json(json, { status: 200 });
} catch (e: any) {
const status = e?.status ?? 500;
return NextResponse.json(
{ errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] },
{ status }
);
}
}
// POST JSON → /api/dx/<path>
export async function POST(req: Request, context: any) {
try {
const bearer = requireBearer(req);
const p = buildPath(req, context);
const body = await req.json().catch(() => {
throw new Error("Invalid JSON body");
});
const json = await dxPOST<any>(p, bearer, body);
return NextResponse.json(json, { status: 200 });
} catch (e: any) {
const status = e?.status ?? 500;
return NextResponse.json(
{ errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] },
{ status }
);
}
}
// PATCH JSON → /api/dx/<path>
export async function PATCH(req: Request, context: any) {
try {
const bearer = requireBearer(req);
const p = buildPath(req, context);
const body = await req.json().catch(() => {
throw new Error("Invalid JSON body");
});
const json = await dxPATCH<any>(p, bearer, body);
return NextResponse.json(json, { status: 200 });
} catch (e: any) {
const status = e?.status ?? 500;
return NextResponse.json(
{ errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] },
{ status }
);
}
}
// DELETE → /api/dx/<path>
export async function DELETE(req: Request, context: any) {
try {
const bearer = requireBearer(req);
const p = buildPath(req, context);
const json = await dxDELETE<any>(p, bearer);
return NextResponse.json(json, { status: 200 });
} catch (e: any) {
const status = e?.status ?? 500;
return NextResponse.json(
{ errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] },
{ status }
);
}
}

View file

@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import fs from "fs";
import fsp from "fs/promises";
import path from "path";
import mime from "mime";
const ROOT = (process.env.FILES_ROOT || "/files").trim();
function safeJoin(root: string, reqPath: string) {
const clean = (reqPath || "").replace(/^\/+/, "");
const joined = path.normalize(path.join(root, clean));
const rootNorm = path.normalize(root.endsWith(path.sep) ? root : root + path.sep);
if (!joined.startsWith(rootNorm) && path.normalize(joined) !== path.normalize(root)) {
throw new Error("Path traversal blocked");
}
return joined;
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const reqPath = searchParams.get("path") || "";
if (!reqPath) return new NextResponse("Missing path", { status: 400 });
const abs = safeJoin(ROOT, reqPath);
const stat = await fsp.stat(abs).catch(() => null);
if (!stat || !stat.isFile()) return new NextResponse("Not found", { status: 404 });
const filename = path.basename(abs);
const ctype = mime.getType(abs) || "application/octet-stream";
const stream = fs.createReadStream(abs);
return new NextResponse(stream as any, {
headers: {
"Content-Type": ctype,
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
"Cache-Control": "no-store",
},
});
} catch (e: any) {
return new NextResponse(String(e?.message || e), { status: 400 });
}
}

View file

@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
const ROOT = (process.env.FILES_ROOT || "/files").trim();
function safeJoin(root: string, reqPath: string) {
const clean = (reqPath || "/").replace(/^\/+/, "");
const joined = path.normalize(path.join(root, clean));
const rootNorm = path.normalize(root.endsWith(path.sep) ? root : root + path.sep);
if (!joined.startsWith(rootNorm) && path.normalize(joined) !== path.normalize(root)) {
throw new Error("Path traversal blocked");
}
return joined;
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const reqPath = searchParams.get("path") || "/";
const abs = safeJoin(ROOT, reqPath);
const stat = await fs.stat(abs).catch(() => null);
if (!stat) return NextResponse.json({ path: reqPath, items: [] });
if (!stat.isDirectory()) {
const s = await fs.stat(abs);
return NextResponse.json({
path: reqPath,
items: [{
name: path.basename(abs),
type: "file" as const,
size: s.size,
mtimeMs: s.mtimeMs,
}],
});
}
const entries = await fs.readdir(abs, { withFileTypes: true });
const items = await Promise.all(entries.map(async (ent) => {
const p = path.join(abs, ent.name);
try {
const s = await fs.stat(p);
return {
name: ent.name,
type: ent.isDirectory() ? ("dir" as const) : ("file" as const),
size: ent.isDirectory() ? 0 : s.size,
mtimeMs: s.mtimeMs,
};
} catch {
return null;
}
}));
// Hide dotfiles by default; remove filter if you want to show them
const visible = (items.filter(Boolean) as any[]).filter(i => !i.name.startsWith("."));
return NextResponse.json({ path: reqPath, items: visible });
} catch (e: any) {
return NextResponse.json({ error: String(e?.message || e) }, { status: 400 });
}
}

View file

@ -0,0 +1,142 @@
import { NextResponse } from "next/server";
import fs from "fs";
import fsp from "fs/promises";
import path from "path";
import mime from "mime";
const ROOT = (process.env.FILES_ROOT || "/files").trim();
// Only allow file types that are reasonably safe to preview inline.
const ALLOWED_INLINE_EXTENSIONS = new Set([
".pdf",
".png",
".jpg",
".jpeg",
".webp",
".gif",
".txt",
".md",
".csv",
".json",
".log",
]);
const BLOCKED_EXTENSIONS = new Set([
".html",
".htm",
".svg",
".xml",
".js",
".mjs",
".cjs",
".css",
".ts",
".tsx",
".jsx",
".map",
".zip",
".rar",
".7z",
".exe",
".sh",
".bat",
".ps1",
]);
function safeJoin(root: string, reqPath: string) {
const clean = (reqPath || "").replace(/^\/+/, "");
const joined = path.normalize(path.join(root, clean));
const rootNorm = path.normalize(root.endsWith(path.sep) ? root : root + path.sep);
if (!joined.startsWith(rootNorm) && path.normalize(joined) !== path.normalize(root)) {
throw new Error("Path traversal blocked");
}
return joined;
}
function isPreviewAllowed(absPath: string, contentType: string) {
const ext = path.extname(absPath).toLowerCase();
if (BLOCKED_EXTENSIONS.has(ext)) return false;
if (ALLOWED_INLINE_EXTENSIONS.has(ext)) return true;
// Extra guard: allow common image types and PDF by MIME too.
if (contentType === "application/pdf") return true;
if (contentType.startsWith("image/")) return true;
if (contentType === "text/plain") return true;
if (contentType === "text/markdown") return true;
if (contentType === "text/csv") return true;
if (contentType === "application/json") return true;
return false;
}
function safeFilename(name: string) {
return name.replace(/[\r\n"]/g, "");
}
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const reqPath = searchParams.get("path") || "";
if (!reqPath) {
return new NextResponse("Missing path", {
status: 400,
headers: {
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
}
const abs = safeJoin(ROOT, reqPath);
const stat = await fsp.stat(abs).catch(() => null);
if (!stat || !stat.isFile()) {
return new NextResponse("Not found", {
status: 404,
headers: {
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
}
const contentType = mime.getType(abs) || "application/octet-stream";
if (!isPreviewAllowed(abs, contentType)) {
return new NextResponse("Preview not allowed for this file type", {
status: 403,
headers: {
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
}
const stream = fs.createReadStream(abs);
const filename = safeFilename(path.basename(abs));
return new NextResponse(stream as any, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${filename}"`,
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Cross-Origin-Resource-Policy": "same-origin",
},
});
} catch (e) {
console.error("files/raw error:", e);
return new NextResponse("Bad request", {
status: 400,
headers: {
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
}
}

22
app/app/api/me/route.ts Normal file
View file

@ -0,0 +1,22 @@
// app/api/me/route.ts
import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth";
import { dxGET } from "@/lib/directus";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
const bearer = requireBearer(req);
// Return only safe fields the UI needs
const res = await dxGET<any>(
"/users/me?fields=id,username,display_name,first_name,last_name,email",
bearer
);
const me = res?.data ?? res;
return NextResponse.json(me);
} catch (e: any) {
const status = e?.status ?? 500;
return NextResponse.json({ error: e?.message || "Failed to load user" }, { status });
}
}

View file

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { requireBearer } from "@/app/api/_lib/auth";
import { dxGET, directusAdminFetch } from "@/lib/directus";
export const runtime = "nodejs";
/**
* Deletes a settings row by submission_id, but only if the caller owns it.
* Body: { collection: "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv", submission_id: string|number }
*/
export async function POST(req: Request) {
try {
const { collection, submission_id } = await req.json();
if (
!collection ||
!["settings_co2gal", "settings_co2gan", "settings_fiber", "settings_uv"].includes(collection) ||
(submission_id === undefined || submission_id === null || String(submission_id) === "")
) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
// Who is calling?
const bearer = requireBearer(req);
const me = await dxGET<any>("/users/me?fields=id", bearer);
const meId = me?.data?.id ?? me?.id;
if (!meId) return NextResponse.json({ error: "Unable to resolve user" }, { status: 401 });
// Find the item by submission_id using admin token (we cannot read id with user perms)
const q = `/items/${collection}?filter[submission_id][_eq]=${encodeURIComponent(
String(submission_id)
)}&fields=id,owner.id&limit=1`;
const found = await directusAdminFetch<any>(q);
const row = Array.isArray(found?.data) ? found.data[0] : null;
if (!row?.id) return NextResponse.json({ error: "Not found" }, { status: 404 });
const ownerId = row?.owner?.id ?? row?.owner;
if (String(ownerId) !== String(meId)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Delete via admin
await directusAdminFetch(`/items/${collection}/${row.id}`, { method: "DELETE" });
return NextResponse.json({ ok: true });
} catch (e: any) {
return NextResponse.json({ error: e?.message || "Unknown error" }, { status: 500 });
}
}

View file

@ -0,0 +1,138 @@
// app/api/submit/project/route.ts
import { NextRequest, NextResponse } from "next/server";
import {
uploadFile,
createProjectRow,
patchProject,
bytesFromMB,
dxGET,
} from "@/lib/directus";
import { requireBearer } from "@/app/api/_lib/auth";
// Optional: tweak via env
const MAX_MB = Number(process.env.FILE_MAX_MB || 25);
const MAX_BYTES = bytesFromMB(MAX_MB);
// ultra-simple in-memory rate limiter (per server instance)
const BUCKET = new Map<string, { c: number; resetAt: number }>();
const WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW || 60) * 1000;
const MAX_REQ = Number(process.env.RATE_LIMIT_MAX || 15);
function rateLimitOk(ip: string) {
const now = Date.now();
const rec = BUCKET.get(ip);
if (!rec || now > rec.resetAt) {
BUCKET.set(ip, { c: 1, resetAt: now + WINDOW_MS });
return true;
}
if (rec.c >= MAX_REQ) return false;
rec.c += 1;
return true;
}
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "0.0.0.0";
if (!rateLimitOk(ip)) {
return NextResponse.json({ error: "Rate limited" }, { status: 429 });
}
// NEW: enforce user auth
const bearer = requireBearer(req);
const ct = req.headers.get("content-type") || "";
if (!ct.includes("multipart/form-data")) {
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
}
const form = await req.formData();
const title = String(form.get("title") || "").trim();
const category = String(form.get("category") || "").trim();
const body = String(form.get("body") || form.get("description") || "").trim();
if (!title || !body) {
return NextResponse.json({ error: "Missing required fields: title, body" }, { status: 400 });
}
// Derive uploader from the authenticated user (ignore any spoofed form value)
const me = await dxGET<any>("/users/me?fields=username,display_name,first_name,last_name,email", bearer);
const uploader =
me?.display_name ||
me?.username ||
[me?.first_name, me?.last_name].filter(Boolean).join(" ") ||
me?.email ||
"user";
// tags: allow comma-separated string or JSON array
let tags: string[] = [];
const rawTags = form.get("tags");
if (typeof rawTags === "string" && rawTags.trim()) {
try {
const maybeArray = JSON.parse(rawTags);
if (Array.isArray(maybeArray)) {
tags = maybeArray.map((t) => String(t).trim()).filter(Boolean);
} else {
tags = rawTags.split(",").map((t) => t.trim()).filter(Boolean);
}
} catch {
tags = rawTags.split(",").map((t) => t.trim()).filter(Boolean);
}
}
const license = (form.get("license") && String(form.get("license")).trim()) || undefined;
// Upload hero image (single)
const hero = form.get("image") as File | null;
let p_image_id: string | undefined;
if (hero && typeof hero === "object" && "size" in hero) {
if (hero.size > MAX_BYTES) {
return NextResponse.json({ error: `Hero image exceeds ${MAX_MB} MB` }, { status: 400 });
}
const up = await uploadFile(hero, (hero as File).name || "project-image", bearer);
p_image_id = up.id;
}
// Upload attachments (multiple)
const fileBlobs = form.getAll("files").filter(Boolean) as File[];
const attachIds: string[] = [];
for (const f of fileBlobs.slice(0, 20)) {
if (f.size > MAX_BYTES) {
return NextResponse.json({ error: `One of the files exceeds ${MAX_MB} MB` }, { status: 400 });
}
const up = await uploadFile(f, (f as File).name || "attachment", bearer);
attachIds.push(up.id);
}
// 1) Create the project row (as current user)
const { data: created } = await createProjectRow(
{
title,
body,
uploader, // server-derived
category,
tags,
...(license ? { license } : {}),
status: "pending",
submitted_via: "makearmy-app",
submitted_at: new Date().toISOString(),
},
bearer
);
// 2) Patch hero image + M2M attachments in one go
const patch: Record<string, any> = {};
if (p_image_id) patch.p_image = p_image_id;
if (attachIds.length) patch.p_files = attachIds.map((id) => ({ directus_files_id: id }));
if (Object.keys(patch).length) {
await patchProject(created.id, patch, bearer);
}
return NextResponse.json({ ok: true, id: created.id });
} catch (err: any) {
return NextResponse.json({ error: err?.message || "Unknown error" }, { status: err?.status ?? 500 });
}
}

122
app/app/api/rigs/route.ts Normal file
View file

@ -0,0 +1,122 @@
// app/api/rigs/route.ts
import { NextResponse } from "next/server";
import { dxGET, dxPOST, dxDELETE } from "@/lib/directus";
import { requireBearer } from "@/app/api/_lib/auth";
export const runtime = "nodejs";
function bad(msg: string, code = 400) {
return NextResponse.json({ error: msg }, { status: code });
}
export async function GET(req: Request) {
try {
const bearer = requireBearer(req);
const q = new URL(req.url).searchParams;
const limit = Math.min(parseInt(q.get("limit") || "50", 10), 100);
const fields = [
"id",
"name",
"notes",
"rig_type.id",
"rig_type.name",
"laser_source.submission_id",
"laser_source.make",
"laser_source.model",
"laser_scan_lens.id",
"laser_scan_lens.field_size",
"laser_scan_lens.focal_length",
"laser_focus_lens.id",
"laser_focus_lens.name",
"laser_scan_lens_apt.id", // NEW
"laser_scan_lens_apt.name", // NEW
"laser_scan_lens_exp.id", // NEW
"laser_scan_lens_exp.name", // NEW
"laser_software.id",
"laser_software.name",
"date_created",
"date_updated",
].join(",");
const path =
`/items/user_rigs?fields=${encodeURIComponent(fields)}` +
`&sort=-date_updated` +
`&limit=${limit}`;
const rows = await dxGET<any[]>(path, bearer);
return NextResponse.json(rows ?? []);
} catch (e: any) {
return bad(e?.message || "Failed to load rigs", e?.status || 500);
}
}
export async function POST(req: Request) {
try {
const bearer = requireBearer(req);
const body = await req.json().catch(() => ({}));
const name = (body?.name || "").trim();
const rig_type = body?.rig_type; // id
const laser_source = body?.laser_source; // submission_id
const laser_scan_lens = body?.laser_scan_lens || null;
const laser_focus_lens = body?.laser_focus_lens || null;
const laser_scan_lens_apt = body?.laser_scan_lens_apt || null; // NEW
const laser_scan_lens_exp = body?.laser_scan_lens_exp || null; // NEW
const laser_software = body?.laser_software || null;
const notes = (body?.notes || "").trim();
if (!name) return bad("Missing: name");
if (!rig_type) return bad("Missing: rig_type");
if (!laser_source) return bad("Missing: laser_source");
const me = await dxGET<{ id: string }>("/users/me?fields=id", bearer);
const payload: any = {
owner: me.id,
name,
rig_type,
laser_source,
laser_scan_lens,
laser_focus_lens,
laser_scan_lens_apt, // NEW
laser_scan_lens_exp, // NEW
laser_software,
notes,
};
const res = await dxPOST<{ data: { id: string } }>(
"/items/user_rigs",
bearer,
payload
);
return NextResponse.json({ ok: true, id: String(res?.data?.id) });
} catch (e: any) {
return bad(e?.message || "Failed to create rig", e?.status || 500);
}
}
export async function DELETE(req: Request) {
try {
const bearer = requireBearer(req);
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) return bad("Missing: id");
const me = await dxGET<{ id: string }>("/users/me?fields=id", bearer);
const rig = await dxGET<any>(
`/items/user_rigs/${encodeURIComponent(id)}?fields=id,owner`,
bearer
);
if (!rig || String(rig.owner) !== String(me.id)) {
return bad("Not your rig", 403);
}
await dxDELETE(`/items/user_rigs/${encodeURIComponent(id)}`, bearer);
return NextResponse.json({ ok: true });
} catch (e: any) {
return bad(e?.message || "Failed to delete rig", e?.status || 500);
}
}

View file

@ -0,0 +1,306 @@
// app/api/settings/route.ts
import { NextResponse } from "next/server";
/**
* Fresh, minimal Directus client (no external helpers).
* - Upload assets to /files with multipart/form-data.
* - Create and update records via /items/{collection}.
* - Auth is via user cookie (ma_at) or a submit token (DIRECTUS_TOKEN_SUBMIT).
*/
export const runtime = "nodejs";
// ─────────────────────────────────────────────────────────────
// Env
// ─────────────────────────────────────────────────────────────
const DX = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const SUBMIT_TOKEN = process.env.DIRECTUS_TOKEN_SUBMIT || "";
// Folder IDs from env (data sheet says fixed, not browsable)
const FOLDERS = {
settings_co2gal: {
photo: process.env.DX_FOLDER_GALVO_PHOTOS || "",
screen: process.env.DX_FOLDER_GALVO_SCREENS || "",
},
settings_co2gan: {
photo: process.env.DX_FOLDER_GANTRY_PHOTOS || "",
screen: process.env.DX_FOLDER_GANTRY_SCREENS || "",
},
settings_fiber: {
photo: process.env.DX_FOLDER_FIBER_PHOTOS || "",
screen: process.env.DX_FOLDER_FIBER_SCREENS || "",
},
settings_uv: {
photo: process.env.DX_FOLDER_UV_PHOTOS || "",
screen: process.env.DX_FOLDER_UV_SCREENS || "",
},
} as const;
type Target = "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv";
function bearerFrom(req: Request) {
// Prefer user cookie (session) else fall back to submit token for server ops.
const cookie = req.headers.get("cookie") || "";
const m = cookie.match(/(?:^|;\s*)ma_at=([^;]+)/);
const at = m?.[1];
return at ? `Bearer ${at}` : SUBMIT_TOKEN ? `Bearer ${SUBMIT_TOKEN}` : "";
}
async function dxUpload(file: File, folderId: string, bearer: string) {
const form = new FormData();
form.set("file", file, file.name || "upload");
if (folderId) form.set("folder", folderId);
const res = await fetch(`${DX}/files`, {
method: "POST",
headers: bearer ? { authorization: bearer } : undefined,
body: form,
});
const j = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = j?.errors?.[0]?.message || `Directus /files failed (HTTP ${res.status})`;
throw new Error(msg);
}
return j?.data?.id as string;
}
async function dxCreate(target: Target, data: any, bearer: string) {
const res = await fetch(`${DX}/items/${target}`, {
method: "POST",
headers: {
"content-type": "application/json",
...(bearer ? { authorization: bearer } : {}),
},
body: JSON.stringify(data),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = j?.errors?.[0]?.message || `Directus create failed (HTTP ${res.status})`;
throw new Error(msg);
}
return j?.data;
}
async function dxUpdate(target: Target, pk: string | number, data: any, bearer: string) {
const res = await fetch(`${DX}/items/${target}/${encodeURIComponent(String(pk))}`, {
method: "PATCH",
headers: {
"content-type": "application/json",
...(bearer ? { authorization: bearer } : {}),
},
body: JSON.stringify(data),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = j?.errors?.[0]?.message || `Directus update failed (HTTP ${res.status})`;
throw new Error(msg);
}
return j?.data;
}
// Guard numeric
const num = (v: any) => (v === "" || v == null || Number.isNaN(Number(v)) ? null : Number(v));
// Guard bool
const bool = (v: any) => !!v;
// Guard id string
const idOrNull = (v: any) => (v === "" || v == null ? null : String(v));
type ReadResult = {
mode: "json" | "multipart";
body: any;
photoFile: File | null;
screenFile: File | null;
};
async function readJsonOrMultipart(req: Request): Promise<ReadResult> {
const ct = (req.headers.get("content-type") || "").toLowerCase();
if (ct.includes("multipart/form-data")) {
const form = await (req as any).formData();
const payloadRaw = String(form.get("payload") ?? "{}");
let body: any = {};
try {
body = JSON.parse(payloadRaw);
} catch {
throw new Error("Invalid JSON in 'payload'");
}
const p = form.get("photo");
const s = form.get("screen");
return {
mode: "multipart",
body,
photoFile: p instanceof File && p.size > 0 ? (p as File) : null,
screenFile: s instanceof File && s.size > 0 ? (s as File) : null,
};
}
const body = await (req as any).json().catch(() => ({}));
return { mode: "json", body, photoFile: null, screenFile: null };
}
/**
* POST: create or update a settings_* record
* Body (JSON or multipart with { payload }):
* {
* target: "settings_co2gal" | ...,
* mode?: "edit",
* submission_id?: string|number,
* // fields per data sheet (CO2 Galvo shown)
* setting_title: string (required),
* setting_notes?: string,
* photo?: string (asset id) // if not provided in create, require file in multipart
* screen?: string (asset id) // optional
* // Material & Rig / Optics
* mat: string (id),
* mat_coat: string (id),
* mat_color: string (id),
* mat_opacity: string (id),
* mat_thickness?: number,
* laser_soft: string (id),
* source: string (submission_id of laser_source),
* lens: string (id),
* focus?: number,
* // CO2 Galvo Options (part of Rig & Optics per sheet)
* lens_conf: string (id),
* lens_apt: string (id),
* lens_exp: string (id),
* repeat_all?: number,
* // Repeaters
* fill_settings?: Array<...>,
* line_settings?: Array<...>,
* raster_settings?: Array<...>
* }
*/
export async function POST(req: Request) {
try {
const { body, photoFile, screenFile } = await readJsonOrMultipart(req);
const target = String(body?.target || "") as Target;
if (!target || !FOLDERS[target]) {
return NextResponse.json({ error: "Invalid or missing target." }, { status: 400 });
}
const isEdit = body?.mode === "edit";
const pk = isEdit ? body?.submission_id : null;
// Upload assets if files are present
const bearer = bearerFrom(req);
const folderCfg = FOLDERS[target];
let photoId = idOrNull(body.photo);
let screenId = idOrNull(body.screen);
if (photoFile) {
if (!folderCfg.photo) throw new Error("Photo folder not configured.");
photoId = await dxUpload(photoFile, folderCfg.photo, bearer);
}
if (screenFile) {
if (!folderCfg.screen) throw new Error("Screen folder not configured.");
screenId = await dxUpload(screenFile, folderCfg.screen, bearer);
}
// Enforce requireds (data sheet: title + result photo on create)
if (!body.setting_title || String(body.setting_title).trim() === "") {
return NextResponse.json({ error: "Missing required: setting_title" }, { status: 400 });
}
if (!isEdit && !photoId) {
return NextResponse.json({ error: "Result photo is required." }, { status: 400 });
}
// Build Directus payload strictly as the collection expects (ids + arrays)
const payload: any = {
setting_title: String(body.setting_title),
setting_notes: String(body.setting_notes || ""),
// Assets
...(photoId ? { photo: photoId } : {}),
...(screenId ? { screen: screenId } : {}),
// Material/Rig & Optics (M2O ids)
mat: idOrNull(body.mat),
mat_coat: idOrNull(body.mat_coat),
mat_color: idOrNull(body.mat_color),
mat_opacity: idOrNull(body.mat_opacity),
mat_thickness: num(body.mat_thickness),
laser_soft: idOrNull(body.laser_soft),
source: idOrNull(body.source), // note: this is submission_id for laser_source; schema should be configured accordingly
lens: idOrNull(body.lens),
focus: num(body.focus),
// CO2 Galvo option triplet (Rig & Optics)
lens_conf: idOrNull(body.lens_conf),
lens_apt: idOrNull(body.lens_apt),
lens_exp: idOrNull(body.lens_exp),
repeat_all: num(body.repeat_all),
// Repeaters (arrays of plain objects)
fill_settings: Array.isArray(body.fill_settings) ? body.fill_settings.map(mapFill) : [],
line_settings: Array.isArray(body.line_settings) ? body.line_settings.map(mapLine) : [],
raster_settings: Array.isArray(body.raster_settings) ? body.raster_settings.map(mapRaster) : [],
};
let saved;
if (isEdit) {
if (!pk) return NextResponse.json({ error: "Missing submission_id for edit mode." }, { status: 400 });
saved = await dxUpdate(target, pk, payload, bearer);
} else {
saved = await dxCreate(target, payload, bearer);
}
return NextResponse.json({ id: saved?.submission_id ?? saved?.id ?? null, data: saved }, { status: 200 });
} catch (e: any) {
return NextResponse.json({ error: e?.message || "Failed" }, { status: 500 });
}
}
// ─────────────────────────────────────────────────────────────
// Mappers (ensure numeric/bool normalization per sheet)
// ─────────────────────────────────────────────────────────────
function mapFill(r: any) {
return {
name: r?.name || "",
type: (r?.type || "").toString(), // uni|bi|offset
power: num(r?.power),
speed: num(r?.speed),
interval: num(r?.interval),
pass: num(r?.pass),
frequency: num(r?.frequency),
pulse: num(r?.pulse),
angle: num(r?.angle),
auto: bool(r?.auto),
increment: num(r?.increment),
cross: bool(r?.cross),
flood: bool(r?.flood),
air: bool(r?.air),
};
}
function mapLine(r: any) {
return {
name: r?.name || "",
power: num(r?.power),
speed: num(r?.speed),
perf: bool(r?.perf),
cut: bool(r?.cut),
skip: bool(r?.skip),
pass: num(r?.pass),
air: bool(r?.air),
frequency: num(r?.frequency),
pulse: num(r?.pulse),
wobble: bool(r?.wobble),
step: num(r?.step),
size: num(r?.size),
};
}
function mapRaster(r: any) {
return {
name: r?.name || "",
type: (r?.type || "").toString(), // uni|bi|offset
dither: (r?.dither || "").toString(), // threshold|ordered|...
halftone_cell: num(r?.halftone_cell),
halftone_angle: num(r?.halftone_angle),
inversion: bool(r?.inversion),
interval: num(r?.interval),
dot: num(r?.dot),
power: num(r?.power),
speed: num(r?.speed),
pass: num(r?.pass),
air: bool(r?.air),
frequency: num(r?.frequency),
pulse: num(r?.pulse),
cross: bool(r?.cross),
};
}

View file

@ -0,0 +1,36 @@
// /app/api/support/badges/route.ts
import { NextRequest, NextResponse } from "next/server";
import { fetchMembershipBadges } from "@/lib/memberships";
// Replace this with your real auth lookup if/when you wire it in
async function getCurrentUser(req: NextRequest): Promise<{ id?: string; email?: string } | null> {
const uid = req.headers.get("x-user-id") || undefined;
const email = req.headers.get("x-user-email") || undefined;
return uid || email ? { id: uid, email } : null;
}
export async function GET(req: NextRequest) {
try {
const me = await getCurrentUser(req);
// Accept explicit query params so the component can work without special headers
const emailParam = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase() || undefined;
const userIdParam = (req.nextUrl.searchParams.get("userId") || "").trim() || undefined;
if (!emailParam && !userIdParam && !me?.id && !me?.email) {
return NextResponse.json({ error: "Provide email or userId" }, { status: 400 });
}
const badges = await fetchMembershipBadges({
userId: userIdParam || me?.id,
email: emailParam || me?.email,
});
return NextResponse.json({ badges });
} catch (e: any) {
return NextResponse.json(
{ error: "badges_fetch_failed", detail: String(e?.message || e) },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,126 @@
// app/api/support/kofi/claim/start/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import nodemailer from "nodemailer";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
const COLLECTION = "user_memberships";
const APP_ORIGIN = process.env.APP_ORIGIN || "https://makearmy.io"; // your site base URL
// SMTP envs
const SMTP_HOST = process.env.SMTP_HOST || "";
const SMTP_PORT = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 587;
const SMTP_USER = process.env.SMTP_USER || "";
const SMTP_PASS = process.env.SMTP_PASS || "";
const SMTP_SECURE = process.env.SMTP_SECURE === "true";
const EMAIL_FROM = process.env.EMAIL_FROM || "MakeArmy Support <no-reply@makearmy.io>";
// TODO: replace with your actual auth/session resolver
async function getCurrentUserId(req: NextRequest): Promise<string | null> {
const uid = req.headers.get("x-user-id");
return uid && uid.trim() ? uid : null;
}
export async function POST(req: NextRequest) {
// Basic SMTP sanity check (fail fast)
if (!SMTP_HOST || !SMTP_PORT || !EMAIL_FROM) {
return NextResponse.json(
{ ok: false, error: "email_not_configured", detail: "SMTP_HOST/PORT/EMAIL_FROM must be set" },
{ status: 500 }
);
}
const userId = await getCurrentUserId(req);
if (!userId) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
let email = "";
try {
const body = await req.json();
email = String(body?.email || "").trim().toLowerCase();
} catch {
return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 });
}
if (!email) {
return NextResponse.json({ ok: false, error: "missing_email" }, { status: 400 });
}
// 1) find Ko-fi membership by email
const filter = encodeURIComponent(
JSON.stringify({
_and: [{ provider: { _eq: "kofi" } }, { email: { _eq: email } }],
})
);
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
cache: "no-store",
});
if (!res.ok) {
const t = await res.text().catch(() => "");
return NextResponse.json({ ok: false, error: "directus_read_failed", detail: t }, { status: 500 });
}
const json = await res.json();
const rec = json?.data?.[0];
if (!rec) {
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
}
if (rec.app_user) {
// Already linked
return NextResponse.json({ ok: true, alreadyLinked: true });
}
// 2) create a one-time token
const token = crypto.randomBytes(24).toString("base64url");
const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BOT_TOKEN}`,
},
body: JSON.stringify({
claim_token: token,
claim_expires_at: expires.toISOString(),
claim_user_id: userId,
}),
});
if (!patch.ok) {
const t = await patch.text().catch(() => "");
return NextResponse.json({ ok: false, error: "directus_write_failed", detail: t }, { status: 500 });
}
// 3) send verification email
const verifyUrl = `${APP_ORIGIN}/api/support/kofi/claim/verify?token=${encodeURIComponent(token)}`;
try {
const transporter = nodemailer.createTransport({
host: SMTP_HOST, // e.g. "mail.arrmail.net"
port: SMTP_PORT, // 465 in your case
secure: SMTP_SECURE, // true for 465, false for 587 STARTTLS
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
// If your server uses a self-signed certificate, uncomment the next line.
// Prefer installing a valid cert instead.
// tls: { rejectUnauthorized: false },
});
await transporter.sendMail({
from: EMAIL_FROM, // "MakeArmy Support <noreply@makearmy.io>" recommended
to: email,
subject: "Verify Ko-fi link to your MakeArmy account",
text: `Tap to verify your Ko-fi link: ${verifyUrl}\nThis link expires in 15 minutes.`,
html: `<p>Tap to verify your Ko-fi link:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>This link expires in 15 minutes.</p>`,
});
} catch (e: any) {
return NextResponse.json(
{ ok: false, error: "email_send_failed", detail: String(e?.message || e) },
{ status: 500 }
);
}
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,53 @@
// /app/api/support/kofi/claim/verify/route.ts
import { NextRequest, NextResponse } from "next/server";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
const COLLECTION = "user_memberships";
// Default redirects; can be overridden by env
const SUCCESS_REDIRECT = process.env.KOFI_LINK_SUCCESS_URL || "/portal/account?linked=kofi&ok=1";
const FAIL_REDIRECT = process.env.KOFI_LINK_FAIL_URL || "/portal/account?linked=kofi&error=1";
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token") || "";
const to = (path: string) => NextResponse.redirect(new URL(path, req.url));
if (!token) return to(FAIL_REDIRECT);
// Find the claim row by token
const filter = encodeURIComponent(JSON.stringify({ claim_token: { _eq: token } }));
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
cache: "no-store",
});
if (!res.ok) return to(FAIL_REDIRECT);
const json = await res.json().catch(() => ({} as any));
const rec = json?.data?.[0];
if (!rec) return to(FAIL_REDIRECT);
// Validate expiry and sanity
const exp = rec.claim_expires_at ? new Date(rec.claim_expires_at).getTime() : 0;
if (!exp || Date.now() > exp) return to(FAIL_REDIRECT);
if (!rec.claim_user_id) return to(FAIL_REDIRECT);
// Finalize: set app_user to claim_user_id; clear claim fields
const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BOT_TOKEN}`,
},
body: JSON.stringify({
app_user: rec.claim_user_id,
claim_token: null,
claim_expires_at: null,
claim_user_id: null,
last_event_at: new Date().toISOString(),
}),
});
if (!patch.ok) return to(FAIL_REDIRECT);
return to(SUCCESS_REDIRECT);
}

View file

@ -0,0 +1,61 @@
// app/api/support/kofi/unlink/route.ts
import { NextRequest, NextResponse } from "next/server";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
const COLLECTION = "user_memberships";
// Replace with your real auth/session
async function getCurrentUserId(req: NextRequest): Promise<string | null> {
const uid = req.headers.get("x-user-id");
return uid && uid.trim() ? uid : null;
}
export async function POST(req: NextRequest) {
const userId = await getCurrentUserId(req);
if (!userId) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Find linked Ko-fi rows
const filter = encodeURIComponent(
JSON.stringify({
_and: [{ provider: { _eq: "kofi" } }, { app_user: { _eq: userId } }],
})
);
const list = await fetch(
`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=500`,
{ headers: { Authorization: `Bearer ${BOT_TOKEN}` }, cache: "no-store" }
);
if (!list.ok) {
const t = await list.text().catch(() => "");
return NextResponse.json({ error: "directus_read_failed", detail: t }, { status: 500 });
}
const rows = (await list.json()).data || [];
if (!rows.length) return NextResponse.json({ ok: true, changed: 0 });
// Batch PATCH: clear app_user
const body = rows.map((r: any) => ({
id: r.id,
app_user: null,
last_event_at: new Date().toISOString(),
}));
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BOT_TOKEN}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const t = await res.text().catch(() => "");
return NextResponse.json({ error: "directus_write_failed", detail: t }, { status: 500 });
}
return NextResponse.json({ ok: true, changed: rows.length });
}

View file

@ -0,0 +1,186 @@
// app/api/webhooks/kofi/route.ts
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
const VERIFY = process.env.KOFI_VERIFY_TOKEN || "";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER || "";
const COLLECTION = "user_memberships";
// TEMP healthcheck: prove code + envs are live (remove after testing)
export async function GET() {
return NextResponse.json({
ok: true,
env: {
hasVerifyToken: Boolean(VERIFY),
hasDirectusUrl: Boolean(DIRECTUS),
hasBotToken: Boolean(BOT_TOKEN),
collection: COLLECTION,
},
});
}
type KofiPayload = {
verification_token: string;
message_id: string;
timestamp?: string;
type: "Donation" | "Subscription" | "Commission" | "Shop Order" | string;
is_public?: boolean;
from_name?: string;
message?: string | null;
amount?: string;
url?: string;
email?: string | null;
currency?: string;
is_subscription_payment?: boolean;
is_first_subscription_payment?: boolean;
kofi_transaction_id?: string;
shop_items?: Array<{ direct_link_code: string; variation_name?: string; quantity?: number }> | null;
tier_name?: string | null;
shipping?: Record<string, unknown> | null;
discord_username?: string | null;
discord_userid?: string | null;
};
function log(o: unknown) {
process.stdout.write(`[kofi] ${JSON.stringify(o)}\n`);
}
function json(res: unknown, status = 200) {
return NextResponse.json(res, { status });
}
function addOneMonthPlusOneDay(d: Date) {
const nd = new Date(d);
const m = nd.getMonth();
nd.setMonth(m + 1);
if (nd.getMonth() !== ((m + 1) % 12)) nd.setDate(0);
nd.setDate(nd.getDate() + 1);
return nd;
}
function safeStringify(v: unknown) {
try {
return JSON.stringify(v);
} catch {
return JSON.stringify({ _unstringifiable: true });
}
}
export async function POST(req: NextRequest) {
const ct = req.headers.get("content-type") || "";
log({ step: "recv", ct });
if (!VERIFY || !DIRECTUS || !BOT_TOKEN) {
log({ step: "env-missing", hasVerify: !!VERIFY, hasDirectus: !!DIRECTUS, hasBot: !!BOT_TOKEN });
return json({ ok: false, error: "server_misconfigured" }, 500);
}
let data: KofiPayload | null = null;
try {
if (ct.includes("application/x-www-form-urlencoded")) {
const form = await req.formData();
const raw = form.get("data");
if (typeof raw !== "string") throw new Error("missing data field");
data = JSON.parse(raw) as KofiPayload;
log({ step: "parsed-formdata" });
} else {
// allow JSON for local tests
data = (await req.json()) as KofiPayload;
log({ step: "parsed-json" });
}
} catch (e: any) {
log({ step: "parse-error", err: String(e?.message || e) });
return json({ ok: false, error: "invalid_payload" }, 400);
}
if (!data?.verification_token || data.verification_token !== VERIFY) {
log({ step: "verify-fail", got: data?.verification_token, expectSet: Boolean(VERIFY) });
return json({ ok: false, error: "unauthorized" }, 401);
}
log({ step: "verify-ok", type: data.type, sub: data.is_subscription_payment });
// Compute values
const t = (data.type || "").toLowerCase();
const isRecurring = Boolean(data.is_subscription_payment) || t.includes("subscription");
const status = isRecurring ? "active" : "one_time";
const ts = data.timestamp ? new Date(data.timestamp) : null;
const emailId = (data.email && data.email.trim().toLowerCase()) || null;
const fallbackId =
(data.discord_userid && data.discord_userid.trim()) ||
(data.from_name && data.from_name.trim()) ||
"unknown";
const filter = encodeURIComponent(
JSON.stringify({
_and: [{ provider: { _eq: "kofi" } }],
_or: [
...(emailId ? [{ email: { _eq: emailId } }, { external_user_id: { _eq: emailId } }] : []),
{ external_user_id: { _eq: fallbackId } },
],
})
);
// Read existing
const existingRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
cache: "no-store",
});
if (!existingRes.ok) {
const body = await existingRes.text().catch(() => "");
log({ step: "directus-read-fail", status: existingRes.status, body });
return json({ ok: false, error: "directus_error_read" }, 500);
}
const existingJson = await existingRes.json().catch(() => ({} as any));
const existing = existingJson?.data?.[0] ?? null;
log({ step: "directus-read-ok", hasExisting: Boolean(existing) });
const external_user_id = emailId || (existing?.external_user_id ?? fallbackId);
const started_at =
isRecurring && ts
? existing?.started_at
? existing.started_at
: ts.toISOString()
: existing?.started_at ?? null;
const renews_at = isRecurring && ts ? addOneMonthPlusOneDay(ts).toISOString() : null;
const record: Record<string, any> = {
provider: "kofi",
external_user_id,
email: emailId || data.email || null,
username: data.from_name || null,
status,
tier: data.tier_name || null,
started_at,
renews_at,
last_event_at: new Date().toISOString(),
raw: safeStringify(data),
};
const url = existing?.id
? `${DIRECTUS}/items/${COLLECTION}/${existing.id}`
: `${DIRECTUS}/items/${COLLECTION}`;
const method = existing?.id ? "PATCH" : "POST";
// Avoid clobbering start/renews when payload has no sub timestamp
if (existing?.id && (!isRecurring || !ts)) {
delete record.started_at;
delete record.renews_at;
}
const writeRes = await fetch(url, {
method,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${BOT_TOKEN}` },
body: JSON.stringify(record),
});
if (!writeRes.ok) {
const body = await writeRes.text().catch(() => "");
log({ step: "directus-write-fail", status: writeRes.status, body });
return json({ ok: false, error: "directus_error_write" }, 500);
}
const written = await writeRes.json().catch(() => ({} as any));
log({ step: "directus-write-ok", method, id: written?.data?.id || existing?.id || "?" });
return json({ ok: true });
}

View file

@ -0,0 +1,30 @@
// app/auth/sign-in/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import SignIn from "./sign-in";
import { isJwtValid } from "@/lib/jwt";
export default async function SignInPage({
searchParams,
}: {
searchParams?: Record<string, string | string[] | undefined>;
}) {
const sp = searchParams ?? {};
const nextParam = Array.isArray(sp.next) ? sp.next[0] : sp.next;
const nextPath =
nextParam && String(nextParam).startsWith("/") ? String(nextParam) : "/portal";
const reauthParam = Array.isArray(sp.reauth) ? sp.reauth[0] : sp.reauth;
const forceParam = Array.isArray(sp.force) ? sp.force[0] : sp.force;
const reauth = reauthParam === "1" || forceParam === "1";
// If reauth is requested, always render the form (no redirect).
if (!reauth) {
const ck = await cookies();
const at = ck.get("ma_at")?.value;
if (isJwtValid(at)) redirect("/portal");
}
return <SignIn nextPath={nextPath} reauth={reauth} />;
}

View file

@ -0,0 +1,138 @@
// app/auth/sign-in/sign-in.tsx
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
type Props = { nextPath?: string; reauth?: boolean };
export default function SignIn({ nextPath = "/portal", reauth = false }: Props) {
const router = useRouter();
const [identifier, setIdentifier] = useState(""); // email OR username
const [password, setPassword] = useState("");
const [showPassword, setShow] = useState(false);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const onSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErr(null);
setLoading(true);
try {
const body = {
identifier: identifier.trim(),
password,
};
const res = await fetch("/api/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
const txt = await res.text();
let j: any = null;
try {
j = txt ? JSON.parse(txt) : null;
} catch {}
if (!res.ok) {
// surface server-provided message when available
const msg =
j?.error || j?.message || (res.status === 401 ? "Invalid credentials." : `Sign-in failed (${res.status})`);
throw new Error(msg);
}
// If this sign-in is being used as a re-auth step, set a short-lived 'recent auth' marker.
if (reauth && typeof document !== "undefined") {
document.cookie = `ma_ra=1; Max-Age=300; Path=/; SameSite=Lax${
process.env.NODE_ENV === "production" ? "; Secure" : ""
}`;
}
// Land where caller requested
router.replace(nextPath);
router.refresh();
} catch (e: any) {
setErr(e?.message || "Unable to sign in.");
} finally {
setLoading(false);
}
},
[identifier, password, nextPath, router, reauth]
);
const createHref = `/auth/sign-up?next=${encodeURIComponent(nextPath)}`;
return (
<div className="mx-auto max-w-md rounded-lg border p-6 space-y-4">
<div>
<h1 className="mb-1 text-2xl font-semibold">{reauth ? "Re-authenticate" : "Sign In"}</h1>
<p className="text-sm opacity-70">
{reauth ? "Please sign in again to continue." : <>Use your email <em>or</em> username with your password.</>}
</p>
</div>
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-1">
<label className="text-sm font-medium">Email or Username</label>
<input
type="text"
autoComplete="username"
className="w-full rounded-md border px-3 py-2"
placeholder="you@example.com or your-handle"
value={identifier}
onChange={(e) => setIdentifier(e.currentTarget.value)}
required
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Password</label>
<button
type="button"
className="text-xs opacity-70 hover:opacity-100"
onClick={() => setShow((s) => !s)}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
<input
type={showPassword ? "text" : "password"}
autoComplete="current-password"
className="w-full rounded-md border px-3 py-2"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
/>
</div>
{err && (
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{err}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
>
{loading ? "Signing in…" : reauth ? "Re-authenticate" : "Sign In"}
</button>
</form>
{/* Always show a sign-up path, even on reauth, to avoid dead-ends for first-time visitors */}
<div className="mt-2 text-center text-sm">
<span className="opacity-70">{reauth ? "Don't have an account?" : "New here?"}</span>{" "}
<a className="underline" href={createHref}>
Create an account
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
// app/auth/sign-up/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import SignUp from "./sign-up";
import { isJwtValid } from "@/lib/jwt";
export default async function SignUpPage({
searchParams,
}: {
searchParams?: Record<string, string | string[] | undefined>;
}) {
const ck = await cookies();
const at = ck.get("ma_at")?.value;
if (isJwtValid(at)) redirect("/portal");
const sp = searchParams ?? {};
const nextParam = Array.isArray(sp.next) ? sp.next[0] : sp.next;
const nextPath =
nextParam && String(nextParam).startsWith("/") ? String(nextParam) : "/portal";
return <SignUp nextPath={nextPath} />;
}

View file

@ -0,0 +1,175 @@
// app/auth/sign-up/sign-up.tsx
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
type Props = { nextPath?: string };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function SignUp({ nextPath = "/portal" }: Props) {
const router = useRouter();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const onSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErr(null);
const em = email.trim().toLowerCase();
const un = username.trim();
if (!em || !un || !password || !confirmPassword) {
setErr("All fields are required.");
return;
}
if (!EMAIL_RE.test(em)) {
setErr("Please enter a valid email address.");
return;
}
if (password.length < 8) {
setErr("Password must be at least 8 characters.");
return;
}
if (password !== confirmPassword) {
setErr("Passwords do not match.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ email: em, username: un, password, confirmPassword }),
});
const txt = await res.text();
let j: any = null;
try {
j = txt ? JSON.parse(txt) : null;
} catch {
j = null;
}
if (!res.ok) {
const message =
j?.error ||
j?.message ||
(res.status === 409 ? "Email or username already in use." : `Sign-up failed (${res.status})`);
throw new Error(message);
}
router.replace(nextPath);
router.refresh();
} catch (e: any) {
setErr(e?.message || "Unable to sign up.");
} finally {
setLoading(false);
}
},
[email, username, password, confirmPassword, nextPath, router]
);
return (
<div className="mx-auto max-w-md rounded-lg border p-6">
<h1 className="mb-1 text-2xl font-semibold">Create Account</h1>
<p className="mb-6 text-sm opacity-70">Join MakeArmy to manage rigs, settings, and projects.</p>
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-1">
<label className="text-sm font-medium">Email</label>
<input
type="email"
autoComplete="email"
className="w-full rounded-md border px-3 py-2"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Username</label>
<input
type="text"
autoComplete="username"
className="w-full rounded-md border px-3 py-2"
placeholder="your-handle"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
required
minLength={3}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Password</label>
<button
type="button"
className="text-xs opacity-70 hover:opacity-100"
onClick={() => setShowPassword((s) => !s)}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
<input
type={showPassword ? "text" : "password"}
autoComplete="new-password"
className="w-full rounded-md border px-3 py-2"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
minLength={8}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Confirm Password</label>
<input
type={showPassword ? "text" : "password"}
autoComplete="new-password"
className="w-full rounded-md border px-3 py-2"
placeholder="Re-enter your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
minLength={8}
/>
</div>
{err && (
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{err}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
>
{loading ? "Creating account…" : "Sign Up"}
</button>
</form>
<div className="mt-4 text-center text-sm">
<span className="opacity-70">Already have an account?</span>{" "}
<a className="underline" href={`/auth/sign-in?next=${encodeURIComponent(nextPath)}`}>
Sign in
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,59 @@
// app/lasers/[id]/LaserDetailsClient.tsx
"use client";
import Link from "next/link";
type Group = { title: string; fields: Record<string, string> };
type Laser = Record<string, any>;
const CHOICE_LABELS: Record<string, Record<string, string>> = {
op: { pm: "MOPA", pq: "Q-Switch" },
cooling: { aa: "Air, Active", ap: "Air, Passive", w: "Water" },
};
function resolveLabel(field: string, value: any) {
if (value == null || value === "") return "—";
const map = CHOICE_LABELS[field];
if (map && typeof value === "string" && map[value]) return map[value];
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "—";
return String(value);
}
export default function LaserDetailsClient({
laser,
fieldGroups,
}: {
laser: Laser;
fieldGroups: Group[];
}) {
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-4">
{(laser.make as string) || "—"} {laser.model || ""}
</h1>
<div className="space-y-6">
{fieldGroups.map(({ title, fields }) => (
<section key={title} className="bg-card border border-border rounded-xl p-4">
<h2 className="text-xl font-semibold mb-2">{title}</h2>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
{Object.entries(fields).map(([key, label]) => (
<div key={key}>
<dt className="font-medium text-muted-foreground">{label}</dt>
<dd className="text-base break-words">{resolveLabel(key, laser[key])}</dd>
</div>
))}
</dl>
</section>
))}
</div>
<div className="mt-8">
<Link href="/lasers" className="text-blue-600 underline">
Back to Laser Sources
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
// app/lasers/[id]/page.tsx
import { cookies } from "next/headers";
import { redirect, notFound } from "next/navigation";
import { dxGET } from "@/lib/directus";
import LaserDetailsClient from "./LaserDetailsClient";
const FIELD_GROUPS = [
{
title: "General Information",
fields: {
make: "Make",
model: "Model",
op: "Pulse Operation Mode",
notes: "Notes",
},
},
{
title: "Optical Specifications",
fields: {
w: "Laser Wattage (W)",
mj: "milliJoule Max (mJ)",
nm: "Wavelength (nm)",
kHz: "Pulse Repetition Rate (kHz)", // NOTE: schema uses kHz (not k_hz)
ns: "Pulse Width (ns)",
d: "Beam Diameter (mm)",
m2: "M² - Quality",
instability: "Instability",
polarization: "Polarization",
band: "Band (nm)",
anti: "Anti-Reflection Coating",
mw: "Red Dot Wattage (mW)",
},
},
{
title: "Electrical & Timing",
fields: {
v: "Operating Voltage (V)",
temp_op: "Operating Temperature (°C)",
temp_store: "Storage Temperature (°C)",
l_on: "l_on",
l_off: "l_off",
mj_c: "mj_c",
ns_c: "ns_c",
d_c: "d_c",
on_c: "on_c",
off_c: "off_c",
},
},
{
title: "Integration & Physical",
fields: {
cable: "Cable Length (m)",
cooling: "Cooling Method",
weight: "Weight (kg)",
dimensions: "Dimensions (cm)",
},
},
] as const;
export default async function Page(
{ params }: { params: Promise<{ id: string }> } // Next 15: params is Promise
) {
const { id: submissionId } = await params;
const jar = await cookies(); // Next 15: cookies() is async
const token = jar.get("ma_at")?.value;
if (!token) {
redirect(`/auth/sign-in?next=${encodeURIComponent(`/lasers/${submissionId}`)}`);
}
const bearer = `Bearer ${token}`;
// Pull EVERYTHING the role can see. (No fragile field list.)
const res = await dxGET<any>(
`/items/laser_source/${encodeURIComponent(submissionId)}?fields=*`,
bearer
);
const laser = res?.data ?? res ?? null;
if (!laser) notFound();
return <LaserDetailsClient laser={laser} fieldGroups={FIELD_GROUPS as any} />;
}

276
app/app/lasers/page.tsx Normal file
View file

@ -0,0 +1,276 @@
// app/lasers/page.tsx
'use client';
import { useEffect, useState, useMemo } from 'react';
import Link from 'next/link';
type LaserRow = {
id: string | number;
submission_id?: string | number;
make?: string;
model?: string;
w?: string;
mj?: string;
nm?: string;
kHz?: string;
ns?: string;
v?: string;
op?: { label?: string; name?: string } | string | null;
};
export default function LaserSourcesPage() {
const [sources, setSources] = useState<LaserRow[]>([]);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [wavelengthFilters, setWavelengthFilters] = useState<Record<string, number | null>>({});
const [sortKey, setSortKey] = useState<keyof LaserRow | 'op' | 'model'>('model');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/$/, '');
// canonical href builder (prefers submission_id if present)
const detailHref = (row: LaserRow) => `/lasers/${row.submission_id ?? row.id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
fetch(`${API}/items/laser_source?limit=-1&fields=*`, { cache: 'no-store' })
.then((res) => res.json())
.then((data) => setSources(data?.data || []))
.catch(() => setSources([]));
}, [API]);
const highlightMatch = (text?: string, q?: string) => {
const safeText = String(text ?? '');
const query = String(q ?? '');
if (!query) return safeText;
const parts = safeText.split(new RegExp(`(${query})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
);
};
const opText = (row: LaserRow) => {
const v = row.op as any;
if (v && typeof v === 'object') return String(v.label ?? v.name ?? '—');
return v == null || v === '' ? '—' : String(v);
};
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return sources.filter((src) => {
const matchesQuery = [src.make, src.model]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(q));
return matchesQuery;
});
}, [sources, debouncedQuery]);
const grouped = useMemo<Record<string, LaserRow[]>>(
() =>
filtered.reduce((acc, src) => {
const key = src.make || 'Unknown Make';
(acc[key] = acc[key] || []).push(src);
return acc;
}, {} as Record<string, LaserRow[]>),
[filtered]
);
const toggleFilter = (make: string, value: number) => {
setWavelengthFilters((prev) => ({
...prev,
[make]: prev[make] === value ? null : value,
}));
};
const toggleSort = (key: keyof LaserRow | 'op' | 'model') => {
setSortKey(key);
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
};
const getSortableValue = (row: LaserRow, key: keyof LaserRow | 'op' | 'model') => {
const val = key === 'op' ? opText(row) : (row as any)[key];
if (val == null) return '';
const k = String(key).toLowerCase();
if (k === 'w') return parseFloat(String(val).replace(/[^\d.]/g, '')) || 0;
if (['mj', 'nm', 'khz', 'ns', 'v'].includes(k)) return parseFloat(String(val)) || 0;
return String(val).toLowerCase();
};
const sortArrow = (key: keyof LaserRow | 'op' | 'model') =>
sortKey === key ? (sortOrder === 'asc' ? ' ▲' : ' ▼') : '';
const summaryStats = useMemo(() => {
const makes = new Set<string>();
const nmCounts: Record<string, number> = {};
for (const src of sources) {
if (src.make) makes.add(src.make);
if (src.nm) {
const nm = String(src.nm);
nmCounts[nm] = (nmCounts[nm] || 0) + 1;
}
}
const mostCommonNm =
Object.entries(nmCounts).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || '—';
return { total: sources.length, uniqueMakes: makes.size, commonNm: mostCommonNm };
}, [sources]);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="grid gap-4 md:grid-cols-3 mb-6">
<div className="card p-4">
<h2 className="text-lg font-semibold mb-2">Database Summary</h2>
<p>Total Sources: {summaryStats.total}</p>
<p>Unique Makes: {summaryStats.uniqueMakes}</p>
<p>Most Common Wavelength: {summaryStats.commonNm}</p>
</div>
<div className="card p-4">
<h2 className="text-lg font-semibold mb-2">Recent Additions</h2>
<ul className="text-sm list-disc pl-4">
{[...sources]
.filter((s) => s.submission_id != null)
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
.slice(0, 5)
.map((src) => (
<li key={src.id}>
<Link className="text-accent underline" href={detailHref(src)}>
{src.make} {src.model}
</Link>
</li>
))}
</ul>
</div>
<div className="card p-4">
<h2 className="text-lg font-semibold mb-2">Feedback</h2>
<p className="text-sm mb-2">See something wrong or want to suggest an improvement?</p>
<Link href="#" className="btn-primary inline-block">
Submit Feedback
</Link>
</div>
</div>
<div className="mb-6 card bg-card text-card-foreground">
<h1 className="text-3xl font-bold mb-2">Laser Source Database</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by make or model..."
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground">
Browse laser source specifications collected from community-submitted and verified sources.
</p>
</div>
{Object.entries(grouped).length === 0 ? (
<p className="text-muted">No laser sources found.</p>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([make, items]) => {
const filteredItems =
wavelengthFilters[make] != null
? items.filter((item) => Number(item.nm) === wavelengthFilters[make])
: items;
const sortedItems = [...filteredItems].sort((a, b) => {
const aVal = getSortableValue(a, sortKey);
const bVal = getSortableValue(b, sortKey);
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return (
<details key={make} className="border border-border rounded-md">
<summary className="bg-card px-4 py-2 font-semibold cursor-pointer flex justify-between items-center">
<span>
{make} <span className="text-sm text-muted">({filteredItems.length})</span>
</span>
<div className="space-x-2">
{[10600, 1064, 455, 355].map((w) => (
<button
key={w}
onClick={(e) => {
e.stopPropagation();
toggleFilter(make, w);
}}
className={`px-2 py-1 text-xs rounded-md border ${
wavelengthFilters[make] === w ? 'bg-accent text-white' : 'bg-muted text-muted-foreground'
}`}
>
{w}
</button>
))}
</div>
</summary>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-sm whitespace-nowrap">
<thead>
<tr>
<th className="px-2 py-2 text-left">Make</th>
<th className="px-2 py-2 text-left w-64">
<button onClick={() => toggleSort('model')}>Model{sortArrow('model')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('w')}>W{sortArrow('w')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('mj')}>mJ{sortArrow('mj')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('op')}>OP{sortArrow('op')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('nm')}>nm{sortArrow('nm')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('kHz')}>kHz{sortArrow('kHz')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('ns')}>ns{sortArrow('ns')}</button>
</th>
<th className="px-2 py-2 text-left">
<button onClick={() => toggleSort('v')}>V{sortArrow('v')}</button>
</th>
</tr>
</thead>
<tbody>
{sortedItems.map((src) => (
<tr key={src.id} className="border-t border-border">
<td className="px-2 py-2 truncate max-w-[10rem]">
{highlightMatch(src.make || '—', debouncedQuery)}
</td>
<td className="px-2 py-2 truncate max-w-[16rem]">
<Link href={detailHref(src)} className="text-accent underline">
{highlightMatch(src.model || '—', debouncedQuery)}
</Link>
</td>
<td className="px-2 py-2 whitespace-nowrap">{src.w || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.mj || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{opText(src)}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.nm || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.kHz || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.ns || '—'}</td>
<td className="px-2 py-2 whitespace-nowrap">{src.v || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
);
})}
</div>
)}
</div>
);
}

25
app/app/layout.tsx Normal file
View file

@ -0,0 +1,25 @@
// app/layout.tsx
import "./styles/globals.css";
import { Toaster } from "@/components/ui/toaster";
export const metadata = {
title: "MakeArmy",
description: "Laser Everything community tools",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// Force dark theme (the simplest way to restore your previous look).
// If you later want system / toggle support, we can swap this for next-themes.
<html lang="en" className="dark" suppressHydrationWarning>
<body>
{children}
<Toaster />
</body>
</html>
);
}

View file

@ -0,0 +1,58 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
export default function MaterialCoatingDetailsPage() {
const { id } = useParams();
const [material, setMaterial] = useState(null);
useEffect(() => {
if (!id) return;
fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material_coating/${id}?fields=id,name,abbreviation,technical_name,composition,notes,override_reason,coating_status.name,coating_status_override,hazard_tags.hazard_tags_id.hazard_source.source,hazard_tags.hazard_tags_id.hazard_danger.danger,hazard_tags.hazard_tags_id.hazard_severity.severity`
)
.then((res) => res.json())
.then((data) => setMaterial(data.data || null));
}, [id]);
if (!material) return <div className="p-6">Loading...</div>;
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-4">{material.name}</h1>
<div className="space-y-2">
<p><strong>Status:</strong> {material.coating_status?.name || '—'}</p>
<p><strong>Abbreviation:</strong> {material.abbreviation || '—'}</p>
<p><strong>Technical Name:</strong> {material.technical_name || '—'}</p>
<p><strong>Composition:</strong> {material.composition || '—'}</p>
<p><strong>Notes:</strong> {material.notes || '—'}</p>
<p><strong>Override Reason:</strong> {material.override_reason || '—'}</p>
<div>
<strong>Hazard Tags</strong>
<ul className="list-disc pl-6">
{Array.isArray(material.hazard_tags) && material.hazard_tags.length > 0 ? (
material.hazard_tags.map((tag, index) => (
<li key={index}>
{tag.hazard_tags_id?.hazard_source?.source || '—'} |{' '}
{tag.hazard_tags_id?.hazard_danger?.danger || '—'} |{' '}
{tag.hazard_tags_id?.hazard_severity?.severity || '—'}
</li>
))
) : (
<li>None</li>
)}
</ul>
</div>
</div>
<div className="mt-8">
<Link href="/materials-coatings" className="text-blue-600 underline"> Back to Coatings</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,2 @@
// app/materials/materials-coatings/[id]/page.tsx
export { default } from "./materials-coatings";

View file

@ -0,0 +1,146 @@
'use client';
import Link from 'next/link';
import { useEffect, useState, useMemo } from 'react';
function highlightMatch(text?: string, query?: string) {
const safeText = String(text ?? '');
const q = String(query ?? '');
if (!q) return safeText;
const parts = safeText.split(new RegExp(`(${q})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === q.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
);
}
export default function CoatingsPage() {
const [coatings, setCoatings] = useState<any[]>([]);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
// canonical detail href (no modal yet)
const detailHref = (id: string | number) => `/materials/materials-coatings/${id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material_coating?fields=id,name,abbreviation,technical_name,composition,coating_status.name&limit=-1`
)
.then((res) => res.json())
.then((data) => setCoatings(data.data || []));
}, []);
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return coatings.filter((coat) =>
[
coat.name,
coat.technical_name,
coat.abbreviation,
coat.composition,
coat.coating_status?.name,
]
.filter(Boolean)
.some((field: string) => String(field).toLowerCase().includes(q))
);
}, [coatings, debouncedQuery]);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6 flex flex-col lg:flex-row gap-4">
<div className="flex-1 card bg-card text-card-foreground relative pb-16">
<h1 className="text-3xl font-bold mb-2">Laser Material Coatings</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search coatings..."
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<h2 className="font-semibold mb-1">📌 Disclaimer</h2>
<p className="mb-6">
The following coatings are provided for educational purposes only and are not intended to be used as your
sole or primary source of information when assessing the safety of any particular coating. It is your
responsibility alone to ensure your safety when operating your equipment. This resource is provided as-is,
free of charge under the assumption you are exercising all other relevant safety precautions.
</p>
<div className="absolute bottom-4 left-4">
<Link href="/" className="btn-primary">
Back to Main Menu
</Link>
</div>
</div>
<div className="flex-1 card bg-[#422c17] text-white">
<h2 className="font-bold text-base mb-2"> Safety Level Definitions</h2>
<ul className="space-y-2">
<li>
<strong>Safe</strong> Materials marked as safe are widely considered to be generally safe by the laser
community at large. This does not mean normal safety protocols should not be observed.
</li>
<li>
<strong>Level I Caution</strong> | These materials are typically safe when normal safety protocol
observed. This includes skin, eye and respiratory protection, regulated marking parameters, and proper
exhaust and filtration.
</li>
<li>
<strong>Level II Dangerous</strong> | These materials can be harmful even if normal safety protocol
observed. Strict adherence to safety protocols required at all times. Exercise extreme caution and
mindfulness.
</li>
<li>
<strong>Level III Critical Hazard</strong> | These materials pose an imminent threat of bodily harm or
death. Materials marked Critical Hazard should not be processed by lasers in any environment for any
reason.
</li>
</ul>
</div>
</div>
{filtered.length === 0 ? (
<p className="text-muted">No coatings found.</p>
) : (
<div className="overflow-x-auto">
<table className="table-fixed min-w-full border border-border text-sm">
<thead>
<tr>
<th className="px-4 py-2 text-left w-48">Name</th>
<th className="px-4 py-2 text-left w-32 whitespace-nowrap">Status</th>
<th className="px-4 py-2 text-left w-32">Abbreviation</th>
<th className="px-4 py-2 text-left w-64">Technical Name</th>
<th className="px-4 py-2 text-left w-64">Composition</th>
</tr>
</thead>
<tbody>
{filtered.map((coat) => (
<tr key={coat.id} className="border-t border-border align-top">
<td className="px-4 py-2 truncate max-w-[12rem]">
<Link href={detailHref(coat.id)} className="text-accent underline">
{highlightMatch(coat.name, debouncedQuery)}
</Link>
</td>
<td className="px-4 py-2 whitespace-nowrap">
{highlightMatch(coat.coating_status?.name || '—', debouncedQuery)}
</td>
<td className="px-4 py-2 truncate max-w-[8rem]">
{highlightMatch(coat.abbreviation || '—', debouncedQuery)}
</td>
<td className="px-4 py-2 truncate max-w-[16rem]">
{highlightMatch(coat.technical_name || '—', debouncedQuery)}
</td>
<td className="px-4 py-2 truncate max-w-[16rem]">
{highlightMatch(coat.composition || '—', debouncedQuery)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,60 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
export default function MaterialDetailsPage() {
const { id } = useParams();
const [material, setMaterial] = useState(null);
useEffect(() => {
if (!id) return;
fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material/${id}?fields=id,name,abbreviation,common_names,technical_name,composition,material_cat.name,material_status.name,notes,override_reason,hazard_tags.hazard_tags_id.hazard_source.source,hazard_tags.hazard_tags_id.hazard_danger.danger,hazard_tags.hazard_tags_id.hazard_severity.severity`
)
.then((res) => res.json())
.then((data) => setMaterial(data.data || null));
}, [id]);
if (!material) return <div className="p-6">Loading...</div>;
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-4">{material.name}</h1>
<div className="space-y-2">
<p><strong>Category:</strong> {material.material_cat?.name || '—'}</p>
<p><strong>Status:</strong> {material.material_status?.name || '—'}</p>
<p><strong>Abbreviation:</strong> {material.abbreviation || '—'}</p>
<p><strong>Common Names:</strong> {material.common_names || '—'}</p>
<p><strong>Technical Name:</strong> {material.technical_name || '—'}</p>
<p><strong>Composition:</strong> {material.composition || '—'}</p>
<p><strong>Notes:</strong> {material.notes || '—'}</p>
<p><strong>Override Reason:</strong> {material.override_reason || '—'}</p>
<div>
<strong>Hazard Tags</strong>
<ul className="list-disc pl-6">
{Array.isArray(material.hazard_tags) && material.hazard_tags.length > 0 ? (
material.hazard_tags.map((tag, index) => (
<li key={index}>
{tag.hazard_tags_id?.hazard_source?.source || '—'} |{' '}
{tag.hazard_tags_id?.hazard_danger?.danger || '—'} |{' '}
{tag.hazard_tags_id?.hazard_severity?.severity || '—'}
</li>
))
) : (
<li>None</li>
)}
</ul>
</div>
</div>
<div className="mt-8">
<Link href="/materials" className="text-blue-600 underline"> Back to Materials</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,2 @@
// app/materials/materials/[id]/page.tsx
export { default } from "./materials";

View file

@ -0,0 +1,161 @@
'use client';
import Link from 'next/link';
import { useEffect, useState, useMemo } from 'react';
function highlightMatch(text: string, query: string) {
if (!query) return text;
const parts = String(text).split(new RegExp(`(${query})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
);
}
export default function MaterialsPage() {
const [materials, setMaterials] = useState<any[]>([]);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
// canonical detail href builder (no modal yet)
const detailHref = (id: string | number) => `/materials/materials/${id}`;
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material?fields=id,name,abbreviation,common_names,technical_name,material_cat.name,material_status.name&limit=-1`
)
.then((res) => res.json())
.then((data) => setMaterials(data.data || []));
}, []);
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return materials.filter((mat) =>
[
mat.name,
mat.technical_name,
mat.common_names,
mat.abbreviation,
mat.material_status?.name,
]
.filter(Boolean)
.some((field: string) => String(field).toLowerCase().includes(q))
);
}, [materials, debouncedQuery]);
const grouped = useMemo<Record<string, typeof filtered>>(() => {
return filtered.reduce((acc, mat) => {
const key = mat.material_cat?.name || 'Uncategorized';
(acc[key] = acc[key] || []).push(mat);
return acc;
}, {} as Record<string, typeof filtered>);
}, [filtered]);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6 flex flex-col lg:flex-row gap-4">
<div className="flex-1 card bg-card text-card-foreground relative pb-16">
<h1 className="text-3xl font-bold mb-2">Laser Material Reference</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search materials..."
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<h2 className="font-semibold mb-1">📌 Disclaimer</h2>
<p className="mb-6">
The following materials are provided for educational purposes only and are not intended to be used as your
sole or primary source of information when assessing the safety of any particular material. It is your
responsibility alone to ensure your safety when operating your equipment. This resource is provided as-is,
free of charge under the assumption you are exercising all other relevant safety precautions.
</p>
<div className="absolute bottom-4 left-4">
<Link href="/" className="btn-primary">
Back to Main Menu
</Link>
</div>
</div>
<div className="flex-1 card bg-[#422c17] text-white">
<h2 className="font-bold text-base mb-2"> Safety Level Definitions</h2>
<ul className="space-y-2">
<li>
<strong>Safe</strong> Materials marked as safe are widely considered to be generally safe by the laser
community at large. This does not mean normal safety protocols should not be observed.
</li>
<li>
<strong>Level I Caution</strong> | These materials are typically safe when normal safety protocol
observed. This includes skin, eye and respiratory protection, regulated marking parameters, and proper
exhaust and filtration.
</li>
<li>
<strong>Level II Dangerous</strong> | These materials can be harmful even if normal safety protocol
observed. Strict adherence to safety protocols required at all times. Exercise extreme caution and
mindfulness.
</li>
<li>
<strong>Level III Critical Hazard</strong> | These materials pose an imminent threat of bodily harm or
death. Materials marked Critical Hazard should not be processed by lasers in any environment for any
reason.
</li>
</ul>
</div>
</div>
{Object.entries(grouped).length === 0 ? (
<p className="text-muted">No materials found.</p>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([category, items]) => (
<details key={category} className="border border-border rounded-md">
<summary className="bg-card px-4 py-2 font-semibold cursor-pointer">
{category} <span className="text-sm text-muted">({items.length})</span>
</summary>
<div className="overflow-x-auto">
<table className="table-fixed">
<thead>
<tr>
<th className="w-48">Name</th>
<th className="w-32 whitespace-nowrap">Status</th>
<th className="w-32">Abbreviation</th>
<th className="w-64">Common Names</th>
<th className="w-64">Technical Name</th>
</tr>
</thead>
<tbody>
{items.map((material) => (
<tr key={material.id} className="border-t border-border align-top">
<td className="truncate max-w-[12rem]">
<Link href={detailHref(material.id)} className="text-accent underline">
{highlightMatch(material.name, debouncedQuery)}
</Link>
</td>
<td className="whitespace-nowrap">
{highlightMatch(material.material_status?.name || '—', debouncedQuery)}
</td>
<td className="truncate max-w-[8rem]">
{highlightMatch(material.abbreviation || '—', debouncedQuery)}
</td>
<td className="truncate max-w-[16rem]">
{highlightMatch(material.common_names || '—', debouncedQuery)}
</td>
<td className="truncate max-w-[16rem]">
{highlightMatch(material.technical_name || '—', debouncedQuery)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
))}
</div>
)}
</div>
);
}

49
app/app/page.tsx Normal file
View file

@ -0,0 +1,49 @@
// app/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import SignIn from "@/app/auth/sign-in/sign-in";
import SignUp from "@/app/auth/sign-up/sign-up";
import { isJwtValid } from "@/lib/jwt";
type SearchParams = { [key: string]: string | string[] | undefined };
export default async function HomePage({
searchParams,
}: {
searchParams?: SearchParams;
}) {
// If already signed in with a VALID token, go straight to the app
const ck = await cookies();
const at = ck.get("ma_at")?.value;
if (isJwtValid(at)) redirect("/portal");
const reauth = searchParams?.reauth === "1";
return (
<main className="mx-auto max-w-5xl px-4 py-12">
{reauth && (
<p className="mb-6 rounded-md border bg-yellow-50 p-3 text-sm text-yellow-900">
Your session expired. Please sign in again.
</p>
)}
<section className="mb-10 text-center">
<h1 className="text-3xl font-bold tracking-tight">MakeArmy</h1>
<p className="mt-2 text-base text-muted-foreground">
Free to use. Manage laser rigs, settings, and projectsall in one
place.
</p>
</section>
<section className="grid gap-6 md:grid-cols-2">
<SignUp nextPath="/portal" />
<SignIn nextPath="/portal" reauth={reauth} />
</section>
<section className="mt-8 text-center text-xs text-muted-foreground">
<p>This is the beta build v0.1.1 - this site is an active BETA. Some features may not be available or work as intended. If you're experiencing issues please report them here: https://forge.makearmy.io/makearmy/makearmy-app/issues </p>
<p>PRIVACY: We only use cookies strictly necessary to operate the site (e.g., your sign-in session). We do not store user data or telemetry other than what you provide and never share or sell data to third parties. Ever.</p>
</section>
</main>
);
}

View file

@ -0,0 +1,128 @@
// app/portal/account/AccountPanel.tsx
"use client";
import { useEffect, useMemo, useState, useCallback } from "react";
// Use relative paths to avoid alias resolution issues in this route
import ProfileEditor from "../../../components/account/ProfileEditor";
import PasswordChange from "../../../components/account/PasswordChange";
import AvatarUploader from "../../../components/account/AvatarUploader";
import SupporterBadges from "../../../components/account/SupporterBadges";
import ConnectKofi from "../../../components/account/ConnectKofi";
import LinkStatus from "../../../components/account/LinkStatus";
type Avatar = { id: string; filename_download?: string } | null;
type Me = {
id: string;
username: string;
first_name?: string | null;
last_name?: string | null;
email?: string | null;
location?: string | null;
avatar?: Avatar;
};
export default function AccountPanel() {
const [me, setMe] = useState<Me | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Precompute API base once on the client
const API_BASE = useMemo(
() => (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""),
[]
);
const refetchMe = useCallback(async () => {
try {
const r = await fetch("/api/account", {
credentials: "include",
cache: "no-store",
});
if (!r.ok) throw new Error(`Load failed (${r.status})`);
const j = await r.json();
const user: Me | undefined = j?.user ?? j?.data ?? undefined;
if (!user) throw new Error("Malformed response");
setMe(user);
} catch (e: any) {
setErr(e?.message || "Failed to load account");
}
}, []);
useEffect(() => {
let alive = true;
(async () => {
try {
setErr(null);
await refetchMe();
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, [refetchMe]);
if (loading) return <div className="rounded-md border p-6 text-sm opacity-70">Loading</div>;
if (err) return <div className="rounded-md border p-6 text-red-600">Error: {err}</div>;
if (!me) return null;
const avatarUrl = me.avatar?.id ? `${API_BASE}/assets/${me.avatar.id}` : null;
return (
<div className="space-y-6">
<div className="rounded-md border p-4">
<h2 className="text-lg font-semibold mb-2">Account</h2>
{/* Shows success/failure after Ko-fi verify redirect */}
<LinkStatus />
<div className="flex items-center gap-4 mb-4">
<div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={avatarUrl} alt="Avatar" className="h-full w-full object-cover" />
) : (
<span className="text-xs opacity-60">No Avatar</span>
)}
</div>
<div className="text-xs text-muted-foreground">Usernames cant be changed.</div>
</div>
<div className="grid sm:grid-cols-2 gap-3 text-sm">
<div>
<div className="opacity-60">Username</div>
<div className="font-medium">{me.username}</div>
</div>
<div>
<div className="opacity-60">First Name</div>
<div>{me.first_name || "—"}</div>
</div>
<div>
<div className="opacity-60">Last Name</div>
<div>{me.last_name || "—"}</div>
</div>
<div>
<div className="opacity-60">Email</div>
<div>{me.email || "—"}</div>
</div>
<div>
<div className="opacity-60">Location</div>
<div>{me.location || "—"}</div>
</div>
</div>
{/* Badges (derive-on-read, no cron) */}
<SupporterBadges email={me.email ?? undefined} userId={me.id} />
</div>
{/* Link Ko-fi */}
<ConnectKofi email={me.email} userId={me.id} />
{/* Editable sections */}
<AvatarUploader avatarId={me.avatar?.id || null} onUpdated={refetchMe} />
<ProfileEditor me={me} onUpdated={refetchMe} />
<PasswordChange />
</div>
);
}

View file

@ -0,0 +1,16 @@
// app/portal/account/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import AccountPanel from "./AccountPanel";
export const dynamic = "force-dynamic";
export default async function AccountPage() {
const at = (await cookies()).get("ma_at")?.value;
if (!at) {
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal/account")}`);
}
// No reauth gating here; the panel fetches and renders profile,
// and only asks for reauth when the user submits sensitive changes.
return <AccountPanel />;
}

View file

@ -0,0 +1,16 @@
"use client";
import dynamic from "next/dynamic";
const BuyingGuideSwitcher = dynamic(
() => import("@/components/portal/BuyingGuideSwitcher"),
{ ssr: false }
);
export default function PortalBuyingGuidePage() {
return (
<div className="p-6">
<BuyingGuideSwitcher />
</div>
);
}

View file

@ -0,0 +1,58 @@
// app/portal/laser-settings/Client.tsx
"use client";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
const SettingsSwitcher = dynamic(
() => import("@/components/portal/SettingsSwitcher"),
{ ssr: false }
);
function DetailsFrame({ src }: { src: string }) {
return (
<iframe
key={src}
src={src}
className="w-full h-[70vh] rounded-md border"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
/>
);
}
export default function LaserSettingsClient() {
const search = useSearchParams();
const t = (search.get("t") || "fiber").toLowerCase();
const id = search.get("id");
const view = (search.get("view") || "list").toLowerCase();
// Only use legacy detail pages for tabs we haven't migrated yet.
// CO₂ Galvo renders its detail INSIDE the panel, so never iFrame it here.
const detailSrc =
id && view === "detail" && (t === "fiber" || t === "uv" || t === "co2-gantry")
? t === "fiber"
? `/settings/fiber/${id}`
: t === "uv"
? `/settings/uv/${id}`
: `/settings/co2-gantry/${id}`
: null;
return (
<div className="space-y-4">
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">Laser Settings</h2>
<SettingsSwitcher />
</div>
{/* Only show legacy iframe for non-CO₂ Galvo tabs, and only when explicitly in detail view */}
{detailSrc && (
<div className="rounded-lg border p-4">
<div className="mb-2 text-sm opacity-70">
Viewing detail #{id} ({t})
</div>
<DetailsFrame src={detailSrc} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,8 @@
// app/portal/laser-settings/page.tsx
import LaserSettingsClient from "./Client";
export const metadata = { title: "MakerDash • Laser Settings" };
export default function Page() {
return <LaserSettingsClient />;
}

View file

@ -0,0 +1,38 @@
"use client";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
const LasersList = dynamic(() => import("@/app/lasers/page"), { ssr: false });
function DetailsFrame({ src }: { src: string }) {
return (
<iframe
key={src}
src={src}
className="w-full h-[70vh] rounded-md border"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
/>
);
}
export default function LaserSourcesClient() {
const search = useSearchParams();
const id = search.get("id");
return (
<div className="space-y-4">
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">Laser Sources</h2>
<LasersList />
</div>
{id && (
<div className="rounded-lg border p-4">
<div className="mb-2 text-sm opacity-70">Viewing source #{id}</div>
<DetailsFrame src={`/lasers/${id}`} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,8 @@
// app/portal/laser-sources/page.tsx
import LaserSourcesClient from "./Client";
export const metadata = { title: "MakerDash • Laser Sources" };
export default function Page() {
return <LaserSourcesClient />;
}

30
app/app/portal/layout.tsx Normal file
View file

@ -0,0 +1,30 @@
// app/portal/layout.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import PortalTabs from "@/components/PortalTabs";
import SignOutButton from "@/components/SignOutButton";
export const metadata = { title: "MakerDash" };
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
// Auth gate: require user access token cookie
const store = await cookies();
const at = store.get("ma_at")?.value;
if (!at) {
// preserve deep-link by defaulting to /portal
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal")}`);
}
return (
<main className="mx-auto max-w-6xl px-6 py-6">
<header className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Welcome to MakerDash</h1>
<SignOutButton className="text-sm opacity-75 hover:opacity-100" />
</header>
<PortalTabs />
<section className="mt-6">{children}</section>
</main>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
const MaterialsList = dynamic(
() => import("@/app/materials/materials/page"),
{ ssr: false }
);
const CoatingsList = dynamic(
() => import("@/app/materials/materials-coatings/page"),
{ ssr: false }
);
function DetailsFrame({ src }: { src: string }) {
return (
<iframe
key={src}
src={src}
className="w-full h-[70vh] rounded-md border"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
/>
);
}
export default function MaterialsClient() {
const search = useSearchParams();
const t = (search.get("t") || "materials").toLowerCase();
const id = search.get("id");
const detailSrc =
!id
? null
: t === "materials"
? `/materials/materials/${id}`
: t === "materials-coatings"
? `/materials/materials-coatings/${id}`
: null;
return (
<div className="space-y-4">
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">Materials</h2>
{t === "materials" ? <MaterialsList /> : <CoatingsList />}
</div>
{detailSrc && (
<div className="rounded-lg border p-4">
<div className="mb-2 text-sm opacity-70">
Viewing {t.replace("-", " ")} #{id}
</div>
<DetailsFrame src={detailSrc} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,8 @@
// app/portal/materials/page.tsx
import MaterialsClient from "./Client";
export const metadata = { title: "MakerDash • Materials" };
export default function Page() {
return <MaterialsClient />;
}

View file

@ -0,0 +1,248 @@
// app/portal/my-settings/page.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
type Coll = "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv";
type Row = {
submission_id: string | number;
setting_title?: string | null;
last_modified_date?: string | null;
// NOTE: we intentionally do NOT request id or status due to permissions
};
const COLLECTIONS: Coll[] = ["settings_co2gal", "settings_co2gan", "settings_fiber", "settings_uv"];
const LABEL: Record<Coll, string> = {
settings_co2gal: "CO₂ Galvo",
settings_co2gan: "CO₂ Gantry",
settings_fiber: "Fiber",
settings_uv: "UV",
};
function detailHref(coll: Coll, submissionId: string | number | null | undefined) {
const sid = submissionId ?? "";
switch (coll) {
case "settings_co2gal": return `/settings/co2-galvo/${sid}?edit=1`;
case "settings_co2gan": return `/settings/co2-gantry/${sid}?edit=1`;
case "settings_fiber": return `/settings/fiber/${sid}?edit=1`;
case "settings_uv": return `/settings/uv/${sid}?edit=1`;
}
}
export default function MySettingsPage() {
const [loading, setLoading] = useState(true);
const [meId, setMeId] = useState<string | null>(null);
const [meUsername, setMeUsername] = useState<string | null>(null); // used only for display
const [q, setQ] = useState("");
const [byColl, setByColl] = useState<Record<Coll, Row[]>>({
settings_co2gal: [],
settings_co2gan: [],
settings_fiber: [],
settings_uv: [],
});
const [errs, setErrs] = useState<Record<Coll | "me", string | null>>({
me: null,
settings_co2gal: null,
settings_co2gan: null,
settings_fiber: null,
settings_uv: null,
});
async function readJson(res: Response) {
const text = await res.text();
try {
return text ? JSON.parse(text) : null;
} catch {
throw new Error(`Unexpected response (HTTP ${res.status})`);
}
}
// 1) Load current user id + username
useEffect(() => {
let dead = false;
(async () => {
try {
const r = await fetch(`/api/dx/users/me?fields=id,username`, { credentials: "include", cache: "no-store" });
if (!r.ok) {
const j = await readJson(r).catch(() => null);
throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`);
}
const j = await readJson(r);
const id = j?.data?.id ?? j?.id ?? null;
const un = j?.data?.username ?? j?.username ?? null;
if (!dead) {
setMeId(id ? String(id) : null);
setMeUsername(un ? String(un) : null);
setErrs((e) => ({ ...e, me: null }));
}
} catch (e: any) {
if (!dead) setErrs((er) => ({ ...er, me: e?.message || String(e) }));
}
})();
return () => { dead = true; };
}, []);
// 2) Load my items per collection (STRICT owner-only)
useEffect(() => {
if (!meId) return; // we require the user id to filter by owner
let dead = false;
setLoading(true);
setErrs((e) => ({ ...e, settings_co2gal: null, settings_co2gan: null, settings_fiber: null, settings_uv: null }));
(async () => {
const acc: Record<Coll, Row[]> = { settings_co2gal: [], settings_co2gan: [], settings_fiber: [], settings_uv: [] };
for (const coll of COLLECTIONS) {
const qs = new URLSearchParams();
qs.set("limit", "-1");
qs.set("sort", "-last_modified_date");
qs.set("fields", "submission_id,setting_title,last_modified_date"); // minimal, safe fields
// STRICT owner filter. We OR the two shapes:
// - owner stored as primitive id
// - owner stored as relation object { id: ... }
qs.set(`filter[_or][0][owner][_eq]`, meId);
qs.set(`filter[_or][1][owner][id][_eq]`, meId);
const url = `/api/dx/items/${coll}?${qs.toString()}`;
try {
const r = await fetch(url, { credentials: "include", cache: "no-store" });
if (!r.ok) {
const j = await readJson(r).catch(() => null);
throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`);
}
const j = await readJson(r);
const rows: Row[] = Array.isArray(j?.data) ? j.data : [];
acc[coll] = rows;
} catch (e: any) {
acc[coll] = [];
setErrs((er) => ({ ...er, [coll]: e?.message || String(e) }));
}
}
if (!dead) {
setByColl(acc);
setLoading(false);
}
})();
return () => { dead = true; };
}, [meId]);
// 3) Filter client-side
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return byColl;
const out: Record<Coll, Row[]> = { settings_co2gal: [], settings_co2gan: [], settings_fiber: [], settings_uv: [] };
for (const coll of COLLECTIONS) {
out[coll] = (byColl[coll] || []).filter(r =>
[r.setting_title, r.last_modified_date]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(needle))
);
}
return out;
}, [byColl, q]);
async function onDelete(coll: Coll, row: Row) {
if (!row.submission_id) return alert("Missing submission id.");
if (!confirm(`Delete "${row.setting_title || "Untitled"}" from ${LABEL[coll]}?`)) return;
try {
const r = await fetch(`/api/my-settings/delete`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collection: coll, submission_id: row.submission_id }),
});
const j = await readJson(r).catch(() => null);
if (!r.ok || !j?.ok) {
throw new Error(j?.error || `HTTP ${r.status}`);
}
setByColl(prev => ({
...prev,
[coll]: prev[coll].filter(x => String(x.submission_id) !== String(row.submission_id))
}));
} catch (e: any) {
alert(`Delete failed: ${e?.message || e}`);
}
}
const total = COLLECTIONS.reduce((n, c) => n + (filtered[c]?.length || 0), 0);
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<h1 className="text-2xl font-semibold mb-4">My Settings</h1>
{!!errs.me && (
<div className="mb-4 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
Couldnt load your profile: {errs.me}
</div>
)}
<div className="mb-4 flex items-center gap-3">
<input
className="border rounded px-3 py-2 w-full max-w-md"
placeholder="Search my settings…"
value={q}
onChange={(e) => setQ(e.currentTarget.value)}
/>
<span className="text-sm opacity-70">{total} total</span>
</div>
{loading ? (
<p>Loading</p>
) : (
COLLECTIONS.map((coll) => {
const rows = filtered[coll] || [];
return (
<section key={coll} className="mb-8">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-lg font-semibold">
{LABEL[coll]} <span className="text-xs opacity-70">({rows.length})</span>
</h2>
{!!errs[coll] && (
<span className="text-xs text-red-600">Error: {errs[coll]}</span>
)}
</div>
{rows.length === 0 ? (
<p className="opacity-70">No items.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-muted">
<th className="px-2 py-2 text-left">Title</th>
<th className="px-2 py-2 text-left">Updated</th>
<th className="px-2 py-2 text-left">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${coll}:${r.submission_id}`} className="border-b hover:bg-muted/30">
<td className="px-2 py-2">{r.setting_title || "Untitled"}</td>
<td className="px-2 py-2">
{r.last_modified_date ? new Date(r.last_modified_date).toLocaleString() : "—"}
</td>
<td className="px-2 py-2">
<div className="flex items-center gap-3">
<Link href={detailHref(coll, r.submission_id)} className="underline">Edit</Link>
<button className="text-red-600 underline" onClick={() => onDelete(coll, r)}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
})
)}
</main>
);
}

275
app/app/portal/page.tsx Normal file
View file

@ -0,0 +1,275 @@
"use client";
import Link from "next/link";
import {
ArrowRight,
CheckCircle2,
CircleX,
HeartHandshake,
HandCoins,
Coffee,
ShieldCheck,
Users,
Video,
Sparkles,
} from "lucide-react";
// shadcn/ui
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
/**
* App Dashboard: Support CTA (polished)
* - Stable custom buttons (no hover flicker)
* - Perks on their own lines
* - Copy + outline adjustments per request
*/
const perks = [
{ icon: <ShieldCheck className="h-5 w-5" aria-hidden="true" />, text: "No ads. No sponsors. No influence." },
{ icon: <Users className="h-5 w-5" aria-hidden="true" />, text: "Community-funded, community-first priorities." },
{ icon: <Video className="h-5 w-5" aria-hidden="true" />, text: "Videos, tools, and guides stay open for all." },
];
const supportTiers = [
{
name: "Laser Master Academy",
href: "https://masters.lasereverything.net",
icon: <Sparkles className="h-6 w-6" aria-hidden="true" />,
blurb:
"Our flagship learning community on MightyNetworks: structured courses, AMAs, and deeper mentorship.",
perks: ["Parameter Packs", "Support Forums", "Bonus Content Archives"],
cta: "Join the Academy",
},
{
name: "Patreon",
href: "https://www.patreon.com/c/LaserEverything",
icon: <HeartHandshake className="h-6 w-6" aria-hidden="true" />,
blurb:
"Flexible monthly support to underwrite videos, research, and open tools without monetization strings.",
perks: ["Directly Support Us", "Behind-the-Scenes Notes", "Community Polls"],
cta: "Back on Patreon",
},
{
name: "Ko-Fi",
href: "https://ko-fi.com/lasereverything",
icon: <Coffee className="h-6 w-6" aria-hidden="true" />,
blurb:
"One-time tips that go straight to hosting, development, and production costs — no paywall, just fuel.",
perks: ["Say thanks once", "Help cover a bill", "Keep it free for others"],
cta: "Tip on Ko-Fi",
},
];
const adVsCommunity = {
ads: [
"Algorithm-shaped content",
"Sponsor talking points",
"Influencer bias risk",
"Tracking and interruptions",
],
community: [
"Curriculum set by makers",
"Honest reviews & hard truths",
"Open tools without strings",
"Privacy-respecting experience",
],
};
// Reusable button styles (anchors styled as buttons to avoid shadcn hover conflicts)
const btn =
"inline-flex items-center justify-center rounded-2xl px-5 py-3 text-sm font-medium text-white shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2";
const btnTeal = `${btn} bg-teal-600 hover:bg-teal-700 focus-visible:ring-teal-500`;
const btnRose = `${btn} bg-rose-600 hover:bg-rose-700 focus-visible:ring-rose-500`;
const btnSky = `${btn} bg-sky-600 hover:bg-sky-700 focus-visible:ring-sky-500`;
export default function Page() {
return (
<div className="min-h-[calc(100dvh-4rem)] w-full">
{/* Hero */}
<section className="mx-auto w-full max-w-6xl px-6 py-14 sm:py-16">
<div className="text-center">
<Badge variant="secondary" className="mb-4">
Community-Funded Ad-Free Open Resources
</Badge>
<h1 className="text-3xl font-extrabold tracking-tight sm:text-4xl">
Keep Laser Everything & MakeArmy FREE for Everyone
</h1>
<p className="mx-auto mt-3 max-w-3xl text-balance text-sm text-muted-foreground sm:text-base">
The videos, tools, and docs you use today exist because previous supporters paid it forward.
We want to stay independent no ads, no sponsors, no strings and that only works if we fund it together.
</p>
{/* CTA row (custom anchors = stable hover) */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<Link
href="https://masters.lasereverything.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Join Laser Master Academy"
className={btnTeal}
>
Join the LMA <ArrowRight className="ml-2 h-4 w-4" />
</Link>
<Link
href="https://www.patreon.com/c/LaserEverything"
target="_blank"
rel="noopener noreferrer"
aria-label="Back Laser Everything on Patreon"
className={btnRose}
>
Back on Patreon
</Link>
<Link
href="https://ko-fi.com/lasereverything"
target="_blank"
rel="noopener noreferrer"
aria-label="Contribute on Ko-Fi"
className={btnSky}
>
Tip on Ko-Fi
</Link>
</div>
{/* Perks: each on its own line, centered */}
<ul className="mx-auto mt-5 max-w-xl space-y-2 text-muted-foreground">
{perks.map((p, i) => (
<li key={i} className="flex items-center justify-center gap-2 text-sm">
{p.icon}
<span>{p.text}</span>
</li>
))}
</ul>
</div>
</section>
{/* Support Options */}
<section className="mx-auto -mt-2 max-w-6xl px-6 pb-12">
<div className="rounded-3xl border bg-card p-6 shadow-sm sm:p-8">
<div className="mx-auto max-w-3xl text-center">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">How you can helppick what fits</h2>
<p className="mt-2 text-muted-foreground">
Whether you join the Academy, pledge monthly, or tip once, every contribution sustains the whole community.
</p>
</div>
<div className="mt-8 grid grid-cols-1 gap-6 md:grid-cols-3">
{supportTiers.map((tier) => (
<Card key={tier.name} className="group rounded-2xl transition hover:shadow-lg">
<CardHeader>
<div className="mb-2 flex items-center gap-2 text-teal-600 dark:text-teal-400">
{tier.icon}
<CardTitle className="text-lg">{tier.name}</CardTitle>
</div>
<p className="text-sm text-muted-foreground">{tier.blurb}</p>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{tier.perks.map((perk) => (
<li key={perk} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> {perk}
</li>
))}
</ul>
<Link
href={tier.href}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex w-full items-center justify-center rounded-xl bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-colors hover:bg-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
>
{tier.cta}
</Link>
</CardContent>
</Card>
))}
</div>
{/* Philosophy: Ads vs Community */}
<div className="mt-10">
<div className="mx-auto max-w-3xl text-center">
<h3 className="text-xl font-semibold">Why not just run ads & sponsors?</h3>
<p className="mt-2 text-sm text-muted-foreground">
Because it quietly changes what we make and who we serve.
</p>
<p className="mt-1 text-sm text-muted-foreground">Wed rather be accountable to you.</p>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Dim outline for Advertising box */}
<Card className="rounded-2xl border border-foreground/10 dark:border-foreground/15">
<CardHeader>
<div className="flex items-center gap-2 text-rose-600 dark:text-rose-400">
<CircleX className="h-5 w-5" aria-hidden="true" />
<CardTitle className="text-base">Advertising / Sponsorship Model</CardTitle>
</div>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
{adVsCommunity.ads.map((t) => (
<li key={t} className="flex items-center gap-2">
<CircleX className="h-4 w-4" aria-hidden="true" /> {t}
</li>
))}
</ul>
</CardContent>
</Card>
{/* Brighter outline for Community box */}
<Card className="rounded-2xl border border-teal-500/50 shadow-sm">
<CardHeader>
<div className="flex items-center gap-2 text-teal-700 dark:text-teal-300">
<CheckCircle2 className="h-5 w-5" aria-hidden="true" />
<CardTitle className="text-base">Community-Funded Model</CardTitle>
</div>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{adVsCommunity.community.map((t) => (
<li key={t} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> {t}
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</div>
{/* Divider */}
<div className="my-10 h-px w-full bg-border" />
{/* Other ways */}
<div className="mt-10">
<h3 className="text-center text-xl font-semibold">No budget? No problemheres how to help for free</h3>
<div className="mx-auto mt-4 max-w-3xl">
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{[
"Share our videos with a friend",
"Star + share our repos/tools",
"Submit laser settings for others",
"Report bugs and suggest features",
].map((item) => (
<li key={item} className="flex items-center gap-2 text-sm text-muted-foreground">
<HandCoins className="h-4 w-4" aria-hidden="true" /> {item}
</li>
))}
</ul>
</div>
</div>
{/* Closing statement */}
<div className="mx-auto mt-10 max-w-3xl text-center">
<p className="text-balance text-sm text-muted-foreground">
Laser Everything exists because makers before you chose to keep the ladder down. If we each do a small part
<span className="font-medium text-foreground"> we never need a paywall</span>.
</p>
</div>
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
const ProjectsList = dynamic(() => import("@/app/projects/page"), { ssr: false });
function DetailsFrame({ src }: { src: string }) {
return (
<iframe
key={src}
src={src}
className="w-full h-[70vh] rounded-md border"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
/>
);
}
export default function ProjectsClient() {
const search = useSearchParams();
const id = search.get("id");
return (
<div className="space-y-4">
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">Projects</h2>
<ProjectsList />
</div>
{id && (
<div className="rounded-lg border p-4">
<div className="mb-2 text-sm opacity-70">Viewing project #{id}</div>
<DetailsFrame src={`/projects/${id}`} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,8 @@
// app/portal/projects/page.tsx
import ProjectsClient from "./Client";
export const metadata = { title: "MakerDash • Projects" };
export default function Page() {
return <ProjectsClient />;
}

View file

@ -0,0 +1,46 @@
// app/portal/rigs/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import RigsSwitcher from "@/components/portal/RigsSwitcher";
type Opt = { id: string | number; label: string };
export default async function Page() {
const jar = await cookies(); // Next 15: async cookies()
const ma_at = jar.get("ma_at")?.value;
if (!ma_at) redirect("/auth/sign-in?next=/portal/rigs");
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
let rigTypes: Opt[] = [];
try {
const res = await fetch(
`${DIRECTUS}/items/user_rig_type?fields=id,name&sort=sort`,
{
headers: {
Authorization: `Bearer ${ma_at}`,
Accept: "application/json",
"Cache-Control": "no-store",
},
cache: "no-store",
}
);
if (res.ok) {
const json = await res.json();
rigTypes = (json?.data ?? []).map((r: any) => ({
id: r.id,
label: r.name ?? String(r.id),
}));
}
} catch {
// fall back to empty
}
return (
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">Rigs</h2>
<RigsSwitcher rigTypes={rigTypes} />
</div>
);
}

View file

@ -0,0 +1,19 @@
// app/portal/utilities/Client.tsx
"use client";
import dynamic from "next/dynamic";
const UtilitySwitcher = dynamic(() => import("@/components/portal/UtilitySwitcher"), {
ssr: false,
});
export default function UtilitiesClient() {
return (
<div className="space-y-4">
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">Utilities</h2>
<UtilitySwitcher />
</div>
</div>
);
}

View file

@ -0,0 +1,8 @@
// app/portal/utilities/page.tsx
import UtilitiesClient from "./Client";
export const metadata = { title: "MakerDash • Utilities" };
export default function Page() {
return <UtilitiesClient />;
}

View file

@ -0,0 +1,2 @@
// app/projects/[id]/page.tsx
export { default } from "./projects";

View file

@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
export default function ProjectDetailPage() {
const { id } = useParams();
const [project, setProject] = useState(null);
useEffect(() => {
if (!id) return;
const url = new URL(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/projects/${id}`);
url.searchParams.set(
"fields",
"submission_id,title,uploader,category,tags,p_image.filename_disk,p_image.title,p_files.directus_files_id.filename_disk"
);
url.searchParams.set("limit", "1");
fetch(url.toString())
.then((res) => res.json())
.then((data) => setProject(data.data))
.catch(() => setProject(null));
}, [id]);
if (!project) {
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="mb-4">
<Link href="/projects" className="text-accent underline">
Back to Projects
</Link>
</div>
<p className="text-muted-foreground">Loading project</p>
</div>
);
}
const imageSrc = project.p_image?.filename_disk
? `${process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net"}/assets/${project.p_image.filename_disk}`
: null;
const fileList: string[] = Array.isArray(project.p_files)
? project.p_files
.map((f: any) => f?.directus_files_id?.filename_disk)
.filter(Boolean)
: [];
return (
<div className="p-6 max-w-5xl mx-auto">
<style jsx global>{`
.file-pill {
display: inline-block;
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
background-color: var(--muted);
color: var(--foreground);
border-radius: 0.25rem;
margin-right: 0.25rem;
margin-bottom: 0.25rem;
transition: background-color 0.2s ease;
}
.file-pill:hover {
background-color: #ffde59;
color: #000;
}
`}</style>
<div className="mb-4">
<Link href="/projects" className="text-accent underline">
Back to Projects
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-1">
<div className="border rounded overflow-hidden bg-card">
{imageSrc ? (
<Image
src={imageSrc}
alt={project.p_image?.title || "Project image"}
width={800}
height={800}
className="w-full h-auto object-cover"
/>
) : (
<div className="p-6 text-sm text-muted-foreground">No preview image</div>
)}
</div>
<div className="mt-4">
<h3 className="text-sm font-semibold mb-2">Files</h3>
{fileList.length > 0 ? (
<div className="flex flex-wrap">
{fileList.map((fname, i) => (
<a
key={i}
className="file-pill"
href={`${process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net"}/assets/${fname}`}
target="_blank"
rel="noopener noreferrer"
title={fname}
>
{fname}
</a>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No files attached.</p>
)}
</div>
</div>
<div className="md:col-span-2">
<h1 className="text-2xl font-bold mb-2">{project.title}</h1>
<p className="text-sm text-muted-foreground mb-1">
Uploaded by: {project.uploader || "—"}
</p>
<p className="text-sm text-muted-foreground mb-2">
Category: {project.category || "—"}
</p>
<div className="flex flex-wrap gap-1">
{Array.isArray(project.tags) && project.tags.length > 0 ? (
project.tags.map((tag, i) => (
<a
key={i}
href={`/projects?query=${encodeURIComponent(tag)}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs bg-muted text-foreground rounded px-2 py-0.5 hover:bg-accent hover:text-background transition-colors"
title={`Search for ${tag}`}
>
{tag}
</a>
))
) : (
<span className="text-xs text-muted-foreground">No tags</span>
)}
</div>
{/* Optional long description if you add one later */}
{project.body ? (
<div className="prose dark:prose-invert mt-6">
<ReactMarkdown>{project.body}</ReactMarkdown>
</div>
) : null}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,4 @@
import { Suspense } from "react";
export default function Layout({ children }: { children: React.ReactNode }) {
return <Suspense fallback={null}>{children}</Suspense>;
}

364
app/app/projects/page.tsx Normal file
View file

@ -0,0 +1,364 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
type ProjectRow = {
id?: string | number;
submission_id?: string | number;
title?: string;
uploader?: string;
category?: string;
tags?: string[] | string | null;
p_image?: { filename_disk?: string; title?: string } | null;
};
export default function ProjectsPage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [projects, setProjects] = useState<ProjectRow[]>([]);
const [loading, setLoading] = useState(true);
const [categories] = useState([
"assets",
"documents",
"fixtures",
"projects",
"templates",
"test files",
"tools",
]);
// simple debounce
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
// fetch EXACTLY like the working pre-patch version
useEffect(() => {
const url = new URL(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/projects`);
url.searchParams.set(
"fields",
"submission_id,title,uploader,category,tags,p_image.filename_disk,p_image.title"
);
url.searchParams.set("limit", "-1");
fetch(url.toString(), { cache: "no-store" })
.then((res) => res.json())
.then((data) => {
const list = Array.isArray(data?.data) ? (data.data as ProjectRow[]) : [];
setProjects(list);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const highlight = (text: string) => {
if (!debouncedQuery) return text;
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return text?.replace(regex, "<mark>$1</mark>");
};
const normalize = (str: unknown) =>
String(str ?? "").toLowerCase().replace(/[_\s]/g, "");
const filtered = useMemo(() => {
const q = normalize(debouncedQuery);
return projects.filter((entry) => {
const tags =
Array.isArray(entry.tags)
? entry.tags
: typeof entry.tags === "string"
? entry.tags.split(",").map((t) => t.trim()).filter(Boolean)
: [];
const fieldsToSearch = [
entry.title ?? "",
entry.uploader ?? "",
entry.category ?? "",
tags.join(" "),
];
return fieldsToSearch
.filter(Boolean)
.some((field) => normalize(field).includes(q));
});
}, [projects, debouncedQuery]);
const tagCounts = useMemo(() => {
const counts: Record<string, number> = {};
projects.forEach((p) => {
const tags =
Array.isArray(p.tags)
? p.tags
: typeof p.tags === "string"
? p.tags.split(",").map((t) => t.trim()).filter(Boolean)
: [];
tags.forEach((tag) => {
counts[tag] = (counts[tag] || 0) + 1;
});
});
return counts;
}, [projects]);
const popularTags = Object.entries(tagCounts)
.sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))
.slice(0, 10)
.map(([tag]) => tag);
const recentTags = [...projects]
.sort(
(a, b) =>
Number(b.submission_id ?? 0) - Number(a.submission_id ?? 0)
)
.slice(0, 10)
.flatMap((p) => {
const tags =
Array.isArray(p.tags)
? p.tags
: typeof p.tags === "string"
? p.tags.split(",").map((t) => t.trim()).filter(Boolean)
: [];
return tags;
})
.filter((tag, i, self) => self.indexOf(tag) === i)
.slice(0, 10);
const uniqueUploaders = new Set(
projects.map((p) => p.uploader).filter(Boolean)
).size;
const totalTags = Object.keys(tagCounts).length;
const detailHref = (p: ProjectRow) => `/projects/${p.submission_id}`;
const imageSrc = (p: ProjectRow) =>
p.p_image?.filename_disk
? `https://forms.lasereverything.net/assets/${p.p_image.filename_disk}`
: null;
return (
<div className="p-6 max-w-7xl mx-auto">
<style jsx global>{`
mark {
background: #ffde59;
color: #242424;
padding: 0 2px;
border-radius: 2px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
gap: 1rem;
}
.project-card {
display: flex;
background-color: #242424;
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
height: 150px;
}
.project-image {
width: 150px;
height: 150px;
object-fit: cover;
background: #111;
}
.project-content {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.project-tags {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-tags span,
.flex.flex-wrap span {
display: inline-block;
margin-right: 0.25rem;
margin-bottom: 0.25rem;
padding: 0.25rem 0.5rem;
background-color: var(--muted);
color: var(--foreground);
border-radius: 0.25rem;
font-size: 0.7rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.project-tags span:hover,
.flex.flex-wrap span:hover {
background-color: #ffde59;
color: #000;
}
`}</style>
<div className="grid md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<div className="card bg-card text-card-foreground p-4">
<h1 className="text-xl font-bold mb-2">Community Projects</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search projects..."
className="w-full dark:bg-background border border-border rounded-md p-2"
/>
<Link
href="/"
className="inline-block mt-4 px-4 py-2 bg-accent text-background rounded-md text-sm"
>
Back to Main Menu
</Link>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Popular Tags</h2>
{popularTags.length > 0 ? (
<div className="flex flex-wrap">
{popularTags.map((tag, i) => (
<span key={i} onClick={() => setQuery(tag)} title={tag}>
{tag}
</span>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No tags yet.</p>
)}
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Recent Tags</h2>
{recentTags.length > 0 ? (
<div className="flex flex-wrap">
{recentTags.map((tag, i) => (
<span key={i} onClick={() => setQuery(tag)} title={tag}>
{tag}
</span>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No recent tags.</p>
)}
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Project Stats</h2>
<p className="text-sm text-muted-foreground">Total Projects: {projects.length}</p>
<p className="text-sm text-muted-foreground">Unique Uploaders: {uniqueUploaders}</p>
<p className="text-sm text-muted-foreground">Total Tags: {totalTags}</p>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Browse by Category</h2>
<div className="flex flex-wrap">
{categories.map((cat, i) => (
<span key={i} onClick={() => setQuery(cat)}>
{cat === "test_files" ? "test files" : cat}
</span>
))}
</div>
</div>
<div className="card bg-card text-card-foreground p-4 flex flex-col justify-between">
<div>
<h2 className="text-md font-semibold mb-2">Submit a Project</h2>
<p className="text-sm text-muted-foreground mb-2">
Have a cool design, tool, or jig to share? Submit it to the community database.
</p>
</div>
<button
disabled
className="bg-muted text-foreground text-sm px-4 py-2 rounded opacity-50 cursor-not-allowed"
>
Coming Soon
</button>
</div>
</div>
<hr className="my-8 border-border" />
{loading ? (
<p className="text-muted">Loading projects...</p>
) : filtered.length === 0 ? (
<p className="text-muted">No projects found.</p>
) : (
<div className="card-grid">
{filtered.map((project) => {
const key = String(project.submission_id ?? project.id ?? Math.random());
const href = detailHref(project);
const src = imageSrc(project);
return (
<div key={key} className="project-card">
{src ? (
<Image
src={src}
alt={project.p_image?.title || "Project image"}
width={150}
height={150}
className="project-image"
/>
) : (
<div className="project-image flex items-center justify-center text-xs text-muted-foreground">
No image
</div>
)}
<div className="project-content">
<div>
<Link href={href} className="text-base font-semibold text-accent underline">
<span
dangerouslySetInnerHTML={{
__html: highlight(project.title || "Untitled"),
}}
/>
</Link>
<p className="text-xs text-muted-foreground">
Uploaded by:{" "}
<span
dangerouslySetInnerHTML={{
__html: highlight(project.uploader || "—"),
}}
/>
</p>
<p className="text-xs text-muted-foreground">
Category:{" "}
<span
dangerouslySetInnerHTML={{
__html: highlight(project.category || "—"),
}}
/>
</p>
</div>
<div className="project-tags">
{(() => {
const tags =
Array.isArray(project.tags)
? project.tags
: typeof project.tags === "string"
? project.tags.split(",").map((t) => t.trim()).filter(Boolean)
: [];
return tags.length
? tags.map((tag, i) => (
<span key={i} onClick={() => setQuery(tag)} title={tag}>
{tag}
</span>
))
: "";
})()}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,372 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
type Opt = { id: string | number; label: string };
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
// helper: render multipliers like "06x", "8x", "1.5x"; avoid double "x"
function formatMultiplier(raw: any) {
const s = String(raw ?? "").trim();
if (!s) return "—";
if (/x$/i.test(s)) return s; // already has x
// pure number?
if (/^\d+(\.\d+)?$/.test(s)) {
if (/^\d$/.test(s)) return `0${s}x`;
return `${s}x`;
}
// otherwise, extract first number if present
const m = s.match(/(\d+(?:\.\d+)?)/);
if (m) {
const n = m[1];
if (/^\d$/.test(n)) return `0${n}x`;
return `${n}x`;
}
return `${s}x`;
}
// tiny helper to ensure we always iterate an array
function toArray<T = any>(v: any): T[] {
return Array.isArray(v) ? (v as T[]) : [];
}
function useOptions(
kind: "laser_software" | "laser_source" | "lens" | "scan_lens_apt" | "scan_lens_exp",
targetKey?: string
) {
const [opts, setOpts] = useState<Opt[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let alive = true;
setLoading(true);
(async () => {
try {
let url = "";
// default normalize
let normalize = (rows: any[]): Opt[] =>
rows.map((r) => ({
id: String(r.id ?? r.submission_id ?? ""),
label: String(r.name ?? r.label ?? r.title ?? r.model ?? r.id ?? ""),
}));
if (kind === "laser_software") {
url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
} else if (kind === "laser_source") {
// fetch all sources; client filter by nm band from targetKey
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
const parseNum = (v: any) => {
if (v == null) return null;
const m = String(v).match(/(\d+(\.\d+)?)/);
return m ? Number(m[1]) : null;
};
const nmRange = (t?: string | null): [number, number] | null => {
if (!t) return null;
const s = t.toLowerCase();
if (s.includes("fiber")) return [1000, 9000];
if (s.includes("uv")) return [300, 400];
if (s.includes("gantry") || s.includes("co2 gantry") || s.includes("co₂ gantry"))
return [10000, 11000];
if (s.includes("galvo") || s.includes("co2 galvo") || s.includes("co₂ galvo"))
return [10000, 11000];
return null;
};
const range = nmRange(targetKey);
normalize = (rows) => {
const filtered = range
? rows.filter((r: any) => {
const nm = parseNum(r.nm);
return nm != null && nm >= range[0] && nm <= range[1];
})
: rows;
return filtered.map((r: any) => ({
id: String(r.submission_id ?? ""),
label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id ?? ""),
}));
};
} else if (kind === "lens") {
if (targetKey && targetKey.toLowerCase().includes("gantry")) {
url = `${API}/items/laser_focus_lens?fields=id,name&limit=1000&sort=name`;
} else {
url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`;
normalize = (rows) => {
const toNum = (v: any) => {
const m = String(v ?? "").match(/-?\d+(\.\d+)?/);
return m ? parseFloat(m[0]) : Number.POSITIVE_INFINITY;
};
return [...rows]
.sort((a, b) => toNum(a.focal_length) - toNum(b.focal_length))
.map((r) => ({
id: String(r.id ?? ""),
label:
[r.field_size && `${r.field_size} mm`, r.focal_length && `${r.focal_length} mm`]
.filter(Boolean)
.join(" — ") || String(r.id ?? ""),
}));
};
}
} else if (kind === "scan_lens_apt") {
url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`;
} else if (kind === "scan_lens_exp") {
url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`;
normalize = (rows) =>
rows.map((r: any) => ({
id: String(r.id ?? ""),
label: formatMultiplier(r.name ?? r.label ?? r.id ?? ""),
}));
}
const res = await fetch(url, { credentials: "include", cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${kind}`);
const json = await res.json().catch(() => ({} as any));
const rows = toArray(json?.data);
let mapped: Opt[] = [];
try {
mapped = toArray(normalize(rows));
} catch {
// Safe fallback: minimal id/label mapping to prevent render crashes
mapped = rows.map((r: any) => ({
id: String(r?.id ?? r?.submission_id ?? ""),
label: String(r?.name ?? r?.label ?? r?.title ?? r?.model ?? r?.id ?? ""),
}));
}
if (alive) setOpts(mapped);
} catch (_err) {
if (alive) setOpts([]); // swallow errors → empty list; form still renders
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, [kind, targetKey]);
return { opts, loading };
}
export default function RigBuilderClient({ rigTypes }: { rigTypes: Opt[] }) {
const router = useRouter();
const [name, setName] = useState("");
const [notes, setNotes] = useState("");
const [rigType, setRigType] = useState<string>("");
const [laserSource, setLaserSource] = useState<string>("");
const [scanLens, setScanLens] = useState<string>("");
const [scanLensApt, setScanLensApt] = useState<string>(""); // galvo-only
const [scanLensExp, setScanLensExp] = useState<string>(""); // galvo-only
const [focusLens, setFocusLens] = useState<string>("");
const [software, setSoftware] = useState<string>("");
// rigTypes come from the SERVER as props.
const targetKey = useMemo(() => {
const rt = rigTypes.find((o) => String(o.id) === String(rigType))?.label || "";
return rt;
}, [rigTypes, rigType]);
const sources = useOptions("laser_source", targetKey);
const lens = useOptions("lens", targetKey);
const lensApt = useOptions("scan_lens_apt");
const lensExp = useOptions("scan_lens_exp");
const softwares = useOptions("laser_software");
const isGantry = (targetKey || "").toLowerCase().includes("gantry");
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const body: any = {
name,
rig_type: rigType ? Number(rigType) : null,
laser_source: laserSource ? Number(laserSource) : null,
notes: notes || "",
laser_software: software ? Number(software) : null,
};
if (isGantry) {
body.laser_focus_lens = focusLens ? Number(focusLens) : null;
body.laser_scan_lens = null;
body.laser_scan_lens_apt = null;
body.laser_scan_lens_exp = null;
} else {
body.laser_scan_lens = scanLens ? Number(scanLens) : null;
body.laser_focus_lens = null;
body.laser_scan_lens_apt = scanLensApt ? Number(scanLensApt) : null;
body.laser_scan_lens_exp = scanLensExp ? Number(scanLensExp) : null;
}
const res = await fetch("/api/rigs", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) {
alert(j?.error || "Failed to create rig");
return;
}
router.replace("/portal/rigs?t=my", { scroll: false });
router.refresh();
}
return (
<form onSubmit={onSubmit} className="space-y-4 max-w-xl">
<div>
<label className="block text-sm mb-1">
Rig Name <span className="text-red-600">*</span>
</label>
<input
className="w-full border rounded px-2 py-1"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1">
Rig Type <span className="text-red-600">*</span>
</label>
<select
className="w-full border rounded px-2 py-1"
value={rigType}
onChange={(e) => setRigType(e.target.value)}
required
>
<option value=""></option>
{rigTypes.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm mb-1">
Laser Source <span className="text-red-600">*</span>
</label>
<select
className="w-full border rounded px-2 py-1"
value={laserSource}
onChange={(e) => setLaserSource(e.target.value)}
required
>
<option value="">{sources.loading ? "Loading…" : "—"}</option>
{sources.opts.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
</div>
{/* Lens (focus for gantry, scan + apt/exp for others) */}
{isGantry ? (
<div>
<label className="block text-sm mb-1">Focus Lens</label>
<select
className="w-full border rounded px-2 py-1"
value={focusLens}
onChange={(e) => setFocusLens(e.target.value)}
>
<option value="">{lens.loading ? "Loading…" : "—"}</option>
{lens.opts.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
) : (
<>
<div>
<label className="block text-sm mb-1">Scan Lens</label>
<select
className="w-full border rounded px-2 py-1"
value={scanLens}
onChange={(e) => setScanLens(e.target.value)}
>
<option value="">{lens.loading ? "Loading…" : "—"}</option>
{lens.opts.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1">Scan Lens Apt</label>
<select
className="w-full border rounded px-2 py-1"
value={scanLensApt}
onChange={(e) => setScanLensApt(e.target.value)}
>
<option value="">{lensApt.loading ? "Loading…" : "—"}</option>
{lensApt.opts.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm mb-1">Scan Lens Exp</label>
<select
className="w-full border rounded px-2 py-1"
value={scanLensExp}
onChange={(e) => setScanLensExp(e.target.value)}
>
<option value="">{lensExp.loading ? "Loading…" : "—"}</option>
{lensExp.opts.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
</div>
</>
)}
<div>
<label className="block text-sm mb-1">Software</label>
<select
className="w-full border rounded px-2 py-1"
value={software}
onChange={(e) => setSoftware(e.target.value)}
>
<option value="">{softwares.loading ? "Loading…" : "—"}</option>
{softwares.opts.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm mb-1">Notes</label>
<textarea
rows={4}
className="w-full border rounded px-2 py-1"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<button className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90">
Create Rig
</button>
</form>
);
}

View file

@ -0,0 +1,43 @@
// app/rigs/RigBuilderServer.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import RigBuilderClient from "./RigBuilderClient";
type Opt = { id: string | number; label: string };
export default async function RigBuilderServer() {
const jar = await cookies(); // <-- await in Next 15
const ma_at = jar.get("ma_at")?.value;
if (!ma_at) {
const next = encodeURIComponent("/rigs");
redirect(`/auth/sign-in?next=${next}`);
}
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const res = await fetch(
`${DIRECTUS}/items/user_rig_type?fields=id,name&sort=sort`,
{
headers: {
Authorization: `Bearer ${ma_at}`,
Accept: "application/json",
"Cache-Control": "no-store",
},
cache: "no-store",
}
);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Failed to load user_rig_type (${res.status}): ${text || res.statusText}`
);
}
const json = await res.json().catch(() => ({ data: [] as any[] }));
const rigTypes: Opt[] = (json?.data ?? []).map((r: any) => ({
id: r.id,
label: r.name ?? String(r.id),
}));
return <RigBuilderClient rigTypes={rigTypes} />;
}

View file

@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
type Rig = {
id: string | number;
name: string;
notes?: string;
rig_type?: { id: number; name: string };
laser_source?: { submission_id: number; make?: string; model?: string };
laser_scan_lens?: { id: number; field_size?: string; focal_length?: string };
laser_focus_lens?: { id: number; name?: string };
laser_scan_lens_apt?: { id: number; name?: string };
laser_scan_lens_exp?: { id: number; name?: string };
laser_software?: { id: number; name?: string };
};
function fmtX(v?: string) {
if (!v) return "";
const s = String(v).trim();
if (/x$/i.test(s)) return s;
if (/^\d+(\.\d+)?$/.test(s)) {
if (/^\d$/.test(s)) return `0${s}x`;
return `${s}x`;
}
const m = s.match(/(\d+(?:\.\d+)?)/);
if (m) {
const n = m[1];
if (/^\d$/.test(n)) return `0${n}x`;
return `${n}x`;
}
return `${s}x`;
}
export default function RigsListClient() {
const [rows, setRows] = useState<Rig[]>([]);
const [loading, setLoading] = useState(false);
async function load() {
setLoading(true);
try {
const res = await fetch("/api/rigs", { credentials: "include", cache: "no-store" });
const data = await res.json();
setRows(Array.isArray(data) ? data : data?.data ?? []);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
async function remove(id: string | number) {
if (!confirm("Delete this rig?")) return;
const res = await fetch(`/api/rigs?id=${encodeURIComponent(String(id))}`, {
method: "DELETE",
credentials: "include",
});
if (res.ok) load();
else alert("Failed to delete");
}
if (loading) return <div className="text-sm opacity-70">Loading</div>;
if (!rows.length) return <div className="text-sm opacity-70">No rigs yet.</div>;
return (
<div className="space-y-3">
{rows.map((r) => (
<div key={r.id} className="rounded border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{r.name}</div>
<button onClick={() => remove(r.id)} className="text-sm px-2 py-1 border rounded hover:bg-muted">
Delete
</button>
</div>
<div className="text-sm text-muted-foreground mt-1">
{r.rig_type?.name ? <>Type: {r.rig_type.name}. </> : null}
{r.laser_source ? <>Source: {[r.laser_source.make, r.laser_source.model].filter(Boolean).join(" ") || r.laser_source.submission_id}. </> : null}
{r.laser_focus_lens?.name ? <>Focus Lens: {r.laser_focus_lens.name}. </> : null}
{r.laser_scan_lens ? <>Scan Lens: {[r.laser_scan_lens.field_size && `${r.laser_scan_lens.field_size}mm`, r.laser_scan_lens.focal_length && `${r.laser_scan_lens.focal_length}mm`].filter(Boolean).join(" / ")}. </> : null}
{r.laser_scan_lens_apt?.name ? <>Scan Lens Apt: {r.laser_scan_lens_apt.name}. </> : null}
{r.laser_scan_lens_exp?.name ? <>Scan Lens Exp: {fmtX(r.laser_scan_lens_exp.name)}. </> : null}
{r.laser_software?.name ? <>Software: {r.laser_software.name}. </> : null}
</div>
{r.notes ? <div className="text-sm mt-2">{r.notes}</div> : null}
</div>
))}
</div>
);
}

30
app/app/rigs/page.tsx Normal file
View file

@ -0,0 +1,30 @@
// app/rigs/page.tsx
import RigBuilderServer from "./RigBuilderServer";
import RigsListClient from "./RigsListClient";
export default function Page() {
return (
<div className="p-4 space-y-6">
<header>
<h1 className="text-2xl font-bold mb-1">Rigs</h1>
<p className="text-sm text-muted-foreground">
Manage rigs used when submitting settings.
</p>
</header>
<div className="grid gap-6 md:grid-cols-2">
{/* Left: existing rigs */}
<section>
<h2 className="text-lg font-semibold mb-2">My Rigs</h2>
<RigsListClient />
</section>
{/* Right: create a new rig (server-provided rig types) */}
<section>
<h2 className="text-lg font-semibold mb-2">Create Rig</h2>
<RigBuilderServer />
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,368 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Image from "next/image";
import Markdown from "react-markdown";
export default function CO2GantrySettingDetailPage() {
const { id } = useParams();
const [setting, setSetting] = useState<any>(null);
const [loading, setLoading] = useState(true);
// claim UI state
const [claimBusy, setClaimBusy] = useState(false);
const [claimMsg, setClaimMsg] = useState<string | null>(null);
const [claimErr, setClaimErr] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gan/${id}` +
`?fields=` +
[
"submission_id",
"setting_title",
"uploader",
// owner (M2O) — include username explicitly
"owner.id",
"owner.username",
"owner.first_name",
"owner.last_name",
"owner.email",
// content & assets
"setting_notes",
"photo.filename_disk",
"photo.title",
"screen.filename_disk",
"screen.title",
// denormalized relations
"mat.name",
"mat_coat.name",
"mat_color.name",
"mat_opacity.opacity",
"mat_thickness",
"source.make",
"source.model",
// NOTE: laser_soft is a STRING field (not relation)
"laser_soft",
// gantry uses lens.name (not field_size)
"lens.name",
"lens_conf.name",
// misc fields
"focus",
"repeat_all",
"fill_settings",
"line_settings",
"raster_settings",
].join(",");
fetch(url, { cache: "no-store" })
.then((res) => {
if (!res.ok) throw new Error("Failed to load");
return res.json();
})
.then((data) => {
setSetting(data.data);
})
.catch(() => setSetting(null))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <div className="p-6 max-w-7xl mx-auto">Loading</div>;
if (!setting) return <div className="p-6 max-w-7xl mx-auto">Setting not found.</div>;
// Prefer owner's username per schema; return null when absent so claim UI shows
const ownerName = (row: any) => {
const o = row?.owner;
if (!o) return null;
return o.username || null;
};
const formatBoolean = (val: any) =>
val ? "Enabled" : val === false ? "Disabled" : "—";
const renderRepeaterCard = (title: string, fields: any[], data: any[]) => {
if (!data || !Array.isArray(data) || data.length === 0) return null;
const filtered = data.filter((item) =>
Object.values(item).some((v) => v !== null && v !== "")
);
if (filtered.length === 0) return null;
return (
<div className="mt-6">
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
{filtered.map((item, i) => (
<div key={i} className="border border-border rounded-lg p-4 bg-card">
{fields.map(({ key, label, condition }: any) => {
if (condition && !condition(item)) return null;
const value = item[key];
return (
<p key={key} className="text-sm">
<strong>{label}:</strong>{" "}
{typeof value === "boolean" ? formatBoolean(value) : value ?? "—"}
</p>
);
})}
</div>
))}
</div>
</div>
);
};
const openSearchInNewTab = (value: string) => {
if (!value || typeof window === "undefined") return;
const url = new URL("/settings/co2-gantry", window.location.origin);
url.searchParams.set("query", value);
window.open(url.toString(), "_blank", "noopener,noreferrer");
};
const onClaim = async () => {
setClaimBusy(true);
setClaimErr(null);
setClaimMsg(null);
try {
const r = await fetch("/api/claims", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target_collection: "settings_co2gan",
target_id: id,
}),
cache: "no-store",
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(
data?.error || data?.errors?.[0]?.message || "Failed to submit claim"
);
}
setClaimMsg("Claim request submitted for review.");
} catch (e: any) {
setClaimErr(e?.message || "Failed to submit claim");
} finally {
setClaimBusy(false);
}
};
const owner = ownerName(setting);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{/* Title / Meta / Ownership */}
<div className="card bg-card p-4 flex flex-col justify-between">
<div>
<h1 className="text-3xl font-bold mb-1">{setting.setting_title}</h1>
<div className="space-y-1 text-sm text-muted-foreground mb-3">
<p>
<strong>Owner:</strong>{" "}
{owner ? <span>{owner}</span> : <span></span>}
</p>
<p>
<strong>Uploader:</strong> {setting.uploader || "—"}
</p>
</div>
{!owner && (
<div className="flex items-center gap-2">
<button
onClick={onClaim}
disabled={claimBusy}
className="px-3 py-1.5 rounded bg-accent text-background text-sm disabled:opacity-60"
>
{claimBusy ? "Submitting…" : "Claim this setting"}
</button>
{claimMsg && (
<span className="text-green-500 text-sm">{claimMsg}</span>
)}
{claimErr && (
<span className="text-red-500 text-sm">{claimErr}</span>
)}
</div>
)}
</div>
<a
href="/settings/co2-gantry"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
>
Back to CO Gantry Settings
</a>
</div>
{/* Result Photo + Material */}
<div className="card bg-card p-4 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
<div className="flex justify-center">
{setting.photo?.filename_disk && (
<a
href={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
target="_blank"
rel="noopener noreferrer"
>
<Image
src={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
alt={setting.photo?.title || "Preview"}
width={250}
height={250}
className="rounded object-contain max-w-[250px] max-h-[250px]"
/>
</a>
)}
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Material</h2>
<p>
<strong>Material:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.mat?.name)}
>
{setting.mat?.name || "—"}
</span>
</p>
<p>
<strong>Coating:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.mat_coat?.name)}
>
{setting.mat_coat?.name || "—"}
</span>
</p>
<p><strong>Color:</strong> {setting.mat_color?.name || "—"}</p>
<p><strong>Opacity:</strong> {setting.mat_opacity?.opacity || "—"}</p>
<p>
<strong>Thickness:</strong>{" "}
{setting.mat_thickness ? `${setting.mat_thickness} mm` : "—"}
</p>
</div>
</div>
{/* Setup */}
<div className="card bg-card p-4">
<h2 className="text-xl font-semibold mb-2">Setup</h2>
{/* laser_soft is a string field */}
<p><strong>Software:</strong> {setting.laser_soft || "—"}</p>
<p><strong>Repeat All (global):</strong> {setting.repeat_all ?? "—"}</p>
<p className="mt-4"><strong>Focus:</strong> {setting.focus ?? "—"} mm</p>
<small>-Values Focus Closer | +Values Focus Further</small>
</div>
{/* Laser (screenshot left, specs right; heading aligned above specs) */}
<div className="card bg-card p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
<div className="flex justify-center">
{setting.screen?.filename_disk ? (
<a
href={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
target="_blank"
rel="noopener noreferrer"
title={setting.screen?.title || "Screenshot"}
>
<Image
src={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
alt={setting.screen?.title || "Screenshot"}
width={250}
height={250}
className="rounded object-contain max-w-[250px] max-h-[250px]"
/>
</a>
) : (
<div className="text-sm text-muted-foreground">No screenshot</div>
)}
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Laser</h2>
<p><strong>Source Make:</strong> {setting.source?.make || "—"}</p>
<p>
<strong>Source Model:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.source?.model)}
>
{setting.source?.model || "—"}
</span>
</p>
<p>
<strong>Lens:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.lens?.name)}
>
{setting.lens?.name || "—"}
</span>
</p>
<p><strong>Lens Config:</strong> {setting.lens_conf?.name || "—"}</p>
</div>
</div>
</div>
</div>
{/* Notes */}
<div className="card bg-card p-4 mt-6">
<h2 className="text-xl font-semibold mb-2">Notes</h2>
<div className="prose dark:prose-invert">
<Markdown>{setting.setting_notes || "—"}</Markdown>
</div>
</div>
<hr className="my-6 border-muted" />
{/* Repeaters (unchanged fields per your original) */}
{renderRepeaterCard(
"Fill Settings",
[
{ key: "name", label: "Fill Name" },
{ key: "power", label: "Power (%)" },
{ key: "speed", label: "Speed (mm/s)" },
{ key: "interval", label: "Interval (mm)" },
{ key: "pass", label: "Passes" },
{ key: "type", label: "Type" },
{ key: "flood", label: "Flood Fill" },
{ key: "air", label: "Air Assist" },
],
setting.fill_settings
)}
{renderRepeaterCard(
"Line Settings",
[
{ key: "name", label: "Line Name" },
{ key: "power", label: "Power (%)" },
{ key: "speed", label: "Speed (mm/s)" },
{ key: "perf", label: "Perforation" },
{ key: "cut", label: "Cut Power Override" },
{ key: "skip", label: "Skip Pass" },
{ key: "pass", label: "Passes" },
{ key: "air", label: "Air Assist" },
],
setting.line_settings
)}
{renderRepeaterCard(
"Raster Settings",
[
{ key: "name", label: "Raster Name" },
{ key: "power", label: "Power (%)" },
{ key: "speed", label: "Speed (mm/s)" },
{ key: "type", label: "Type" },
{ key: "dither", label: "Dither" },
{ key: "halftone_cell", label: "Halftone Cell" },
{ key: "halftone_angle", label: "Angle" },
{ key: "inversion", label: "Inversion" },
{ key: "interval", label: "Interval" },
{ key: "dot", label: "Dot Size" },
{ key: "pass", label: "Passes" },
{ key: "air", label: "Air Assist" },
],
setting.raster_settings
)}
</div>
);
}

View file

@ -0,0 +1,2 @@
// app/settings/co2-gantry/[id]/page.tsx
export { default } from "./co2-gantry";

View file

@ -0,0 +1,4 @@
import { Suspense } from "react";
export default function Layout({ children }: { children: React.ReactNode }) {
return <Suspense fallback={null}>{children}</Suspense>;
}

View file

@ -0,0 +1,312 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
type Owner = {
username?: string | null;
id?: string | number;
// keep extras harmlessly in case API returns them
first_name?: string | null;
last_name?: string | null;
email?: string | null;
};
export default function CO2GantrySettingsPage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const detailHref = (id: string | number) => `/settings/co2-gantry/${id}`;
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(t);
}, [query]);
useEffect(() => {
const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gan` +
`?fields=` +
[
"submission_id",
"setting_title",
"uploader",
// owner (M2O) ensure username is requested
"owner.id",
"owner.username",
// assets / denorms
"photo.id",
"photo.title",
"mat.name",
"mat_coat.name",
"source.model",
// NOTE: gantry uses lens.name (not field_size)
"lens.name",
].join(",") +
`&limit=-1`;
fetch(url, { cache: "no-store" })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setSettings(data?.data || []))
.catch((e) => {
console.error("CO2 Gantry settings fetch failed:", e);
setSettings([]);
})
.finally(() => setLoading(false));
}, []);
const ownerLabel = (o?: Owner) => (o?.username ?? "—");
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return settings.filter((entry) => {
const fieldsToSearch = [
entry.setting_title,
ownerLabel(entry.owner),
entry.uploader, // keep legacy column visible for now
entry.mat?.name,
entry.mat_coat?.name,
entry.source?.model,
entry.lens?.name,
];
return fieldsToSearch
.filter(Boolean)
.some((field: string) => String(field).toLowerCase().includes(q));
});
}, [settings, debouncedQuery]);
const total = settings.length;
const uniqueMaterials = new Set(
settings.map((s) => s.mat?.name).filter(Boolean)
).size;
const lensCounts = settings.reduce((acc: Record<string, number>, cur) => {
const v = cur.lens?.name;
if (!v) return acc;
acc[v] = (acc[v] || 0) + 1;
return acc;
}, {});
const mostCommonLens =
Object.entries(lensCounts).sort(
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
)[0]?.[0] || "—";
const srcCounts = settings.reduce((acc: Record<string, number>, cur) => {
const v = cur.source?.model;
if (!v) return acc;
acc[v] = (acc[v] || 0) + 1;
return acc;
}, {});
const mostCommonSource =
Object.entries(srcCounts).sort(
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
)[0]?.[0] || "—";
const recent = [...settings]
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
.slice(0, 5);
return (
<div className="p-6 max-w-7xl mx-auto">
<style jsx global>{`
mark {
background: #ffde59;
color: #242424;
padding: 0 2px;
border-radius: 2px;
}
`}</style>
{/* Header / Search */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<div className="card bg-card text-card-foreground p-4">
<h1 className="text-2xl font-bold mb-2">CO Gantry Settings</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by material, owner, uploader, model, lens…"
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground">
Explore curated CO gantry settings. Click any row to see full
details.
</p>
</div>
{/* Stats */}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
<ul className="text-sm space-y-1">
<li>Total Settings: {total}</li>
<li>Unique Materials: {uniqueMaterials}</li>
<li>Most Common Lens: {mostCommonLens}</li>
<li>Most Used Source: {mostCommonSource}</li>
</ul>
</div>
{/* Recently Added */}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Recently Added</h2>
<ul className="text-sm space-y-1">
{recent.map((s) => (
<li key={s.submission_id}>
<Link
href={detailHref(s.submission_id)}
className="underline text-accent"
>
{s.setting_title || "Untitled"}
</Link>{" "}
<span className="text-muted-foreground">
by {ownerLabel(s.owner)}
{s.uploader ? ` (uploader: ${s.uploader})` : ""}
</span>
</li>
))}
</ul>
</div>
{/* Resources */}
<div className="card bg-card text-card-foreground p-4 xl:col-span-3">
<h2 className="text-lg font-semibold mb-2">Resources</h2>
<ul className="text-sm space-y-1">
<li>
<a
href="/materials"
target="_blank"
rel="noopener noreferrer"
className="underline text-accent"
>
Material Safety Guide
</a>
</li>
<li>
<a
href="https://lasereverything.net/scripts/laspwrconvert.php"
target="_blank"
rel="noopener noreferrer"
className="underline text-accent"
>
Laser Parameter Calculator
</a>
</li>
<li>
<a
href="https://jptoe.com/downloads"
target="_blank"
rel="noopener noreferrer"
className="underline text-accent"
>
JPT Datasheets
</a>
</li>
</ul>
</div>
</div>
{/* Table */}
{loading ? (
<p className="text-muted">Loading settings...</p>
) : filtered.length === 0 ? (
<p className="text-muted">No gantry settings found.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className="px-2 py-2 text-left">Photo</th>
<th className="px-2 py-2 text-left">Title</th>
<th className="px-2 py-2 text-left">Owner</th>
<th className="px-2 py-2 text-left">Uploader</th>
<th className="px-2 py-2 text-left">Material</th>
<th className="px-2 py-2 text-left">Coating</th>
<th className="px-2 py-2 text-left">Source</th>
<th className="px-2 py-2 text-left">Lens</th>
</tr>
</thead>
<tbody>
{filtered.map((s) => (
<tr key={s.submission_id} className="border-t border-border">
<td className="px-2 py-2">
{s.photo?.id ? (
<Image
src={`https://forms.lasereverything.net/assets/${s.photo.id}`}
alt={s.photo.title || "laser preview"}
width={64}
height={64}
className="rounded-md"
/>
) : (
"—"
)}
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Link
href={detailHref(s.submission_id)}
className="text-accent underline"
dangerouslySetInnerHTML={{
__html: highlight(s.setting_title || "—"),
}}
/>
</td>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(ownerLabel(s.owner)),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.uploader || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat_coat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.source?.model || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.lens?.name || "—"),
}}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,402 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Image from "next/image";
import Markdown from "react-markdown";
export default function FiberSettingDetailPage() {
const { id } = useParams();
const [setting, setSetting] = useState<any>(null);
const [loading, setLoading] = useState(true);
// claim UI state
const [claimBusy, setClaimBusy] = useState(false);
const [claimMsg, setClaimMsg] = useState<string | null>(null);
const [claimErr, setClaimErr] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber/${id}` +
`?fields=` +
[
"submission_id",
"setting_title",
"uploader",
// Owner (M2O): prefer username
"owner.id",
"owner.username",
// Content & assets
"setting_notes",
"photo.filename_disk",
"photo.title",
"screen.filename_disk",
"screen.title",
// Relations / denorms
"mat.name",
"mat_coat.name",
"mat_color.name",
"mat_opacity.opacity",
"mat_thickness",
"source.make",
"source.model",
"lens.field_size",
"lens.focal_length",
// laser_soft is a STRING field
"laser_soft",
"focus",
"repeat_all",
"fill_settings",
"line_settings",
"raster_settings",
].join(",");
fetch(url, { cache: "no-store" })
.then((res) => {
if (!res.ok) throw new Error("Failed to load");
return res.json();
})
.then((data) => setSetting(data.data))
.catch(() => setSetting(null))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <p className="p-6">Loading setting...</p>;
if (!setting) return <p className="p-6">Setting not found.</p>;
// Prefer owner's username per schema
const ownerName = (row: any) => {
const o = row?.owner;
if (!o) return null;
return o.username || null;
};
const formatBoolean = (val: any) =>
val ? "Enabled" : val === false ? "Disabled" : "—";
const renderRepeaterCard = (title: string, fields: any[], items: any[]) => {
const filtered = (items || []).filter((item) =>
Object.values(item).some((v) => v !== null && v !== "")
);
if (filtered.length === 0) return null;
return (
<div className="mt-6">
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
{filtered.map((item, i) => (
<div key={i} className="border border-border rounded-lg p-4 bg-card">
{fields.map(({ key, label, condition }: any) => {
const value = item[key];
if (condition && !condition(item)) return null;
return (
<p key={key} className="text-sm">
<strong>{label}:</strong>{" "}
{typeof value === "boolean"
? formatBoolean(value)
: value || "—"}
</p>
);
})}
</div>
))}
</div>
</div>
);
};
const openSearchInNewTab = (value: string) => {
if (!value || typeof window === "undefined") return;
const url = new URL("/settings/fiber", window.location.origin);
url.searchParams.set("query", value);
const a = document.createElement("a");
a.href = url.toString();
a.target = "_blank";
a.rel = "noopener noreferrer";
a.click();
};
const onClaim = async () => {
setClaimBusy(true);
setClaimErr(null);
setClaimMsg(null);
try {
const r = await fetch("/api/claims", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target_collection: "settings_fiber",
target_id: id,
}),
cache: "no-store",
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(
data?.error || data?.errors?.[0]?.message || "Failed to submit claim"
);
}
setClaimMsg("Claim request submitted for review.");
} catch (e: any) {
setClaimErr(e?.message || "Failed to submit claim");
} finally {
setClaimBusy(false);
}
};
const owner = ownerName(setting);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{/* Title / Meta / Ownership */}
<div className="card bg-card p-4 flex flex-col justify-between">
<div>
<h1 className="text-3xl font-bold mb-1">{setting.setting_title}</h1>
<div className="space-y-1 text-sm text-muted-foreground mb-3">
<p>
<strong>Owner:</strong>{" "}
{owner ? <span>{owner}</span> : <span></span>}
</p>
<p>
<strong>Uploader:</strong> {setting.uploader || "—"}
</p>
</div>
{!owner && (
<div className="flex items-center gap-2">
<button
onClick={onClaim}
disabled={claimBusy}
className="px-3 py-1.5 rounded bg-accent text-background text-sm disabled:opacity-60"
>
{claimBusy ? "Submitting…" : "Claim this setting"}
</button>
{claimMsg && (
<span className="text-green-500 text-sm">{claimMsg}</span>
)}
{claimErr && (
<span className="text-red-500 text-sm">{claimErr}</span>
)}
</div>
)}
</div>
<a
href="/settings/fiber"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
>
Back to Fiber Settings
</a>
</div>
{/* Result Photo + Material */}
<div className="card bg-card p-4 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
<div className="flex justify-center">
{setting.photo?.filename_disk && (
<a
href={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
target="_blank"
rel="noopener noreferrer"
>
<Image
src={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
alt={setting.photo?.title || "Laser preview"}
width={250}
height={250}
className="rounded object-contain max-w-[250px] max-h-[250px]"
/>
</a>
)}
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Material</h2>
<p>
<strong>Material:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.mat?.name)}
>
{setting.mat?.name || "—"}
</span>
</p>
<p>
<strong>Coating:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.mat_coat?.name)}
>
{setting.mat_coat?.name || "—"}
</span>
</p>
<p>
<strong>Color:</strong> {setting.mat_color?.name || "—"}
</p>
<p>
<strong>Opacity:</strong> {setting.mat_opacity?.opacity || "—"}
</p>
<p>
<strong>Thickness:</strong>{" "}
{setting.mat_thickness ? `${setting.mat_thickness} mm` : "Not Applicable"}
</p>
</div>
</div>
{/* Setup */}
<div className="card bg-card p-4">
<h2 className="text-xl font-semibold mb-2">Setup</h2>
{/* laser_soft is a string field */}
<p>
<strong>Software:</strong> {setting.laser_soft || "—"}
</p>
<p>
<strong>Repeat All (global):</strong> {setting.repeat_all ?? "—"}
</p>
<p className="mt-4">
<strong>Focus:</strong> {setting.focus ?? "—"} mm
</p>
<small>-Values Focus Closer | +Values Focus Further</small>
</div>
{/* Laser (heading above specs; screen image same size as result) */}
<div className="card bg-card p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
{/* Screen image */}
<div className="flex justify-center">
{setting.screen?.filename_disk ? (
<a
href={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
target="_blank"
rel="noopener noreferrer"
title={setting.screen?.title || "Screenshot"}
>
<Image
src={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
alt={setting.screen?.title || "Screenshot"}
width={250}
height={250}
className="rounded object-contain max-w-[250px] max-h-[250px]"
/>
</a>
) : (
<div className="text-sm text-muted-foreground">No screenshot</div>
)}
</div>
{/* Specs + heading */}
<div>
<h2 className="text-xl font-semibold mb-2">Laser</h2>
<p>
<strong>Source Make:</strong> {setting.source?.make || "—"}
</p>
<p>
<strong>Source Model:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.source?.model)}
>
{setting.source?.model || "—"}
</span>
</p>
<p>
<strong>Lens:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.lens?.field_size)}
>
{setting.lens?.field_size || "—"}
</span>{" "}
mm | {setting.lens?.focal_length || "—"}
</p>
</div>
</div>
</div>
</div>
{/* Notes */}
{setting.setting_notes && (
<div className="prose dark:prose-invert mt-6">
<h2>Notes</h2>
<Markdown>{setting.setting_notes}</Markdown>
</div>
)}
<hr className="my-6 border-muted" />
{/* Fill / Line / Raster */}
{renderRepeaterCard(
"Fill Settings",
[
{ key: "fill_name", label: "Fill Name" },
{ key: "power", label: "Power (%)" },
{ key: "speed", label: "Speed (mm/s)" },
{ key: "frequency", label: "Frequency (kHz)" },
{ key: "pulse", label: "Pulse Width (ns)" },
{ key: "interval", label: "Interval (mm)" },
{ key: "pass", label: "Passes" },
{ key: "type", label: "Type" },
{ key: "angle", label: "Angle (°)" },
{ key: "auto", label: "Auto-Rotate" },
{ key: "increment", label: "Increment (°)", condition: (e: any) => e.auto },
{ key: "cross", label: "Crosshatch" },
{ key: "flood", label: "Flood Fill" },
{ key: "air", label: "Air Assist" },
],
setting.fill_settings
)}
{renderRepeaterCard(
"Line Settings",
[
{ key: "name", label: "Line Name" },
{ key: "power", label: "Power (%)" },
{ key: "speed", label: "Speed (mm/s)" },
{ key: "frequency", label: "Frequency (kHz)" },
{ key: "pulse", label: "Pulse Width (ns)" },
{ key: "perf", label: "Perforation Mode" },
{ key: "cut", label: "Cut (mm)", condition: (e: any) => e.perf },
{ key: "skip", label: "Skip (mm)", condition: (e: any) => e.perf },
{ key: "wobble", label: "Wobble Mode" },
{ key: "step", label: "Step (mm)", condition: (e: any) => e.wobble },
{ key: "size", label: "Size (mm)", condition: (e: any) => e.wobble },
{ key: "pass", label: "Passes" },
{ key: "air", label: "Air Assist" },
],
setting.line_settings
)}
{renderRepeaterCard(
"Raster Settings",
[
{ key: "name", label: "Raster Name" },
{ key: "power", label: "Power (%)" },
{ key: "speed", label: "Speed (mm/s)" },
{ key: "frequency", label: "Frequency (kHz)" },
{ key: "pulse", label: "Pulse Width (ns)" },
{ key: "type", label: "Type" },
{ key: "dither", label: "Dither" },
{
key: "halftone_cell",
label: "Cell Size (mm)",
condition: (e: any) => e.dither === "halftone",
},
{
key: "halftone_angle",
label: "Halftone Angle",
condition: (e: any) => e.dither === "halftone",
},
{ key: "inversion", label: "Image Inverted" },
{ key: "interval", label: "Interval (mm)" },
{ key: "dot", label: "Dot-width Adjustment (mm)" },
{ key: "pass", label: "Passes" },
{ key: "cross", label: "Crosshatch" },
{ key: "air", label: "Air Assist" },
],
setting.raster_settings
)}
</div>
);
}

View file

@ -0,0 +1,2 @@
// app/settings/fiber/[id]/page.tsx
export { default } from "./fiber";

View file

@ -0,0 +1,4 @@
import { Suspense } from "react";
export default function Layout({ children }: { children: React.ReactNode }) {
return <Suspense fallback={null}>{children}</Suspense>;
}

View file

@ -0,0 +1,315 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
export default function FiberSettingsPage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// canonical detail href builder (no modal yet)
const detailHref = (id: string | number) => `/settings/fiber/${id}`;
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(t);
}, [query]);
useEffect(() => {
// Include owner fields now that settings have an M2O "owner"
const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber` +
`?fields=` +
[
"submission_id",
"setting_title",
"uploader",
"owner.username",
"owner.first_name",
"owner.last_name",
"owner.email",
"photo.id",
"photo.title",
"mat.name",
"mat_coat.name",
"source.model",
"lens.field_size",
].join(",") +
`&limit=-1`;
fetch(url, { cache: "no-store" })
.then((res) => res.json())
.then((data) => {
setSettings(data?.data || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
// ── Owner display helper: prefer username ────────────────────────────────────
const ownerName = (row: any) => {
const o = row?.owner || {};
return o?.username || "—";
};
// ── Highlight helper for search matches ─────────────────────────────────────
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return settings.filter((entry) => {
const fieldsToSearch = [
entry.setting_title,
entry.uploader,
ownerName(entry),
entry.mat?.name,
entry.mat_coat?.name,
entry.source?.model,
entry.lens?.field_size,
];
return fieldsToSearch
.filter(Boolean)
.some((field: string) => String(field).toLowerCase().includes(q));
});
}, [settings, debouncedQuery]);
const totalSettings = settings.length;
const uniqueMaterials = new Set(
settings.map((s) => s.mat?.name).filter(Boolean)
).size;
const commonLens = settings.reduce((acc: Record<string, number>, cur) => {
const lens = cur.lens?.field_size;
if (!lens) return acc;
acc[lens] = (acc[lens] || 0) + 1;
return acc;
}, {});
const mostCommonLens =
Object.entries(commonLens).sort(
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
)[0]?.[0] || "—";
const sourceModels = settings.reduce((acc: Record<string, number>, cur) => {
const model = cur.source?.model;
if (!model) return acc;
acc[model] = (acc[model] || 0) + 1;
return acc;
}, {});
const mostCommonSource =
Object.entries(sourceModels).sort(
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
)[0]?.[0] || "—";
const recentSettings = [...settings]
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
.slice(0, 5);
return (
<div className="p-6 max-w-7xl mx-auto">
<style jsx global>{`
mark {
background: #ffde59;
color: #242424;
padding: 0 2px;
border-radius: 2px;
}
`}</style>
{/* Header + Search */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<div className="card bg-card text-card-foreground p-4">
<h1 className="text-2xl font-bold mb-2">Fiber Laser Settings</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by material, owner, uploader, model, etc…"
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground">
View and explore detailed fiber laser settings with context.
</p>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">How to Use</h2>
<p className="text-sm">
Browse community fiber laser settings. Use the search to narrow
results. Click any setting title to view its full configuration,
notes, and photos.
</p>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
<ul className="text-sm space-y-1">
<li>Total Settings: {totalSettings}</li>
<li>Unique Materials: {uniqueMaterials}</li>
<li>Most Common Lens: {mostCommonLens}</li>
<li>Most Used Source: {mostCommonSource}</li>
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Recently Added</h2>
<ul className="text-sm space-y-1">
{recentSettings.map((s) => (
<li key={s.submission_id}>
<Link
href={detailHref(s.submission_id)}
className="underline text-accent"
>
{s.setting_title || "Untitled"}
</Link>{" "}
{/* keep uploader for now while claims/owners migrate */}
<span className="text-muted-foreground">
by {ownerName(s) !== "—" ? ownerName(s) : s.uploader || "—"}
</span>
</li>
))}
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Resources</h2>
<ul className="text-sm space-y-1">
<li>
<a
href="/materials"
target="_blank"
rel="noopener noreferrer"
className="underline text-accent"
>
Material Safety Guide
</a>
</li>
<li>
<a
href="https://lasereverything.net/scripts/laspwrconvert.php"
target="_blank"
rel="noopener noreferrer"
className="underline text-accent"
>
Laser Parameter Calculator
</a>
</li>
<li>
<a
href="https://jptoe.com/downloads"
target="_blank"
rel="noopener noreferrer"
className="underline text-accent"
>
JPT Datasheets
</a>
</li>
</ul>
</div>
</div>
{/* Table */}
{loading ? (
<p className="text-muted">Loading settings...</p>
) : filtered.length === 0 ? (
<p className="text-muted">No fiber settings found.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className="px-2 py-2 text-left">Photo</th>
<th className="px-2 py-2 text-left">Title</th>
<th className="px-2 py-2 text-left">Owner</th>
<th className="px-2 py-2 text-left">Uploader</th>
<th className="px-2 py-2 text-left">Material</th>
<th className="px-2 py-2 text-left">Coating</th>
<th className="px-2 py-2 text-left">Source</th>
<th className="px-2 py-2 text-left">Lens</th>
</tr>
</thead>
<tbody>
{filtered.map((setting) => (
<tr key={setting.submission_id} className="border-t border-border">
<td className="px-2 py-2">
{setting.photo?.id ? (
<Image
src={`https://forms.lasereverything.net/assets/${setting.photo.id}`}
alt={setting.photo.title || "laser preview"}
width={64}
height={64}
className="rounded-md"
/>
) : (
"—"
)}
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Link
href={detailHref(setting.submission_id)}
className="text-accent underline"
dangerouslySetInnerHTML={{
__html: highlight(setting.setting_title || "—"),
}}
/>
</td>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{ __html: highlight(ownerName(setting)) }}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.uploader || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.mat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.mat_coat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.source?.model || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(setting.lens?.field_size || "—"),
}}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,2 @@
// app/settings/uv/[id]/page.tsx
export { default } from "./uv";

View file

@ -0,0 +1,365 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Image from "next/image";
import Markdown from "react-markdown";
export default function UVSettingDetailPage() {
const { id } = useParams();
const [setting, setSetting] = useState<any>(null);
const [loading, setLoading] = useState(true);
// claim UI state
const [claimBusy, setClaimBusy] = useState(false);
const [claimMsg, setClaimMsg] = useState<string | null>(null);
const [claimErr, setClaimErr] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_uv/${id}` +
`?fields=` +
[
"submission_id",
"setting_title",
"uploader",
// ✅ Owner (M2O) — use username (string)
"owner.id",
"owner.username",
// content & assets
"setting_notes",
"photo.filename_disk",
"photo.title",
"screen.filename_disk",
"screen.title",
// relations / denorms
"mat.name",
"mat_coat.name",
"mat_color.name",
"mat_opacity.opacity",
"mat_thickness",
"source.model",
"lens.field_size",
"lens.focal_length",
// misc
"focus",
"fill_settings",
"line_settings",
"raster_settings",
].join(",");
fetch(url, { cache: "no-store" })
.then((res) => {
if (!res.ok) throw new Error("Failed to load");
return res.json();
})
.then((data) => setSetting(data.data))
.catch(() => setSetting(null))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <p className="p-6">Loading setting...</p>;
if (!setting) return <p className="p-6">Setting not found.</p>;
// ✅ Prefer owner's username per schema
const ownerName = (row: any) => row?.owner?.username ?? null;
const formatBoolean = (val: any) =>
val ? "Enabled" : val === false ? "Disabled" : "—";
const renderRepeaterCard = (title: string, fields: any[], items: any[]) => {
const filtered = (items || []).filter((item) =>
Object.values(item).some((v) => v !== null && v !== "")
);
if (filtered.length === 0) return null;
return (
<div className="mt-6">
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
{filtered.map((item, i) => (
<div key={i} className="border border-border rounded-lg p-4 bg-card">
{fields.map(({ key, label, condition }: any) => {
const value = item[key];
if (condition && !condition(item)) return null;
return (
<p key={key} className="text-sm">
<strong>{label}:</strong>{" "}
{typeof value === "boolean" ? formatBoolean(value) : value ?? "—"}
</p>
);
})}
</div>
))}
</div>
</div>
);
};
// ✅ Point searches/back link to /settings/uv
const openSearchInNewTab = (value: string) => {
if (!value || typeof window === "undefined") return;
const url = new URL("/settings/uv", window.location.origin);
url.searchParams.set("query", value);
const a = document.createElement("a");
a.href = url.toString();
a.target = "_blank";
a.rel = "noopener noreferrer";
a.click();
};
const onClaim = async () => {
setClaimBusy(true);
setClaimErr(null);
setClaimMsg(null);
try {
const r = await fetch("/api/claims", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target_collection: "settings_uv",
target_id: id,
}),
cache: "no-store",
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(
data?.error || data?.errors?.[0]?.message || "Failed to submit claim"
);
}
setClaimMsg("Claim request submitted for review.");
} catch (e: any) {
setClaimErr(e?.message || "Failed to submit claim");
} finally {
setClaimBusy(false);
}
};
const owner = ownerName(setting);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{/* Title / Meta / Ownership */}
<div className="card bg-card p-4 flex flex-col justify-between">
<div>
<h1 className="text-3xl font-bold mb-1">{setting.setting_title}</h1>
<div className="space-y-1 text-sm text-muted-foreground mb-3">
<p>
<strong>Owner:</strong>{" "}
{owner ? <span>{owner}</span> : <span></span>}
</p>
<p>
<strong>Uploader:</strong> {setting.uploader || "—"}
</p>
</div>
{!owner && (
<div className="flex items-center gap-2">
<button
onClick={onClaim}
disabled={claimBusy}
className="px-3 py-1.5 rounded bg-accent text-background text-sm disabled:opacity-60"
>
{claimBusy ? "Submitting…" : "Claim this setting"}
</button>
{claimMsg && (
<span className="text-green-500 text-sm">{claimMsg}</span>
)}
{claimErr && (
<span className="text-red-500 text-sm">{claimErr}</span>
)}
</div>
)}
</div>
<a
href="/settings/uv"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
>
Back to UV Settings
</a>
</div>
{/* Result Photo + Material */}
<div className="card bg-card p-4 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
<div className="flex justify-center">
{setting.photo?.filename_disk && (
<a
href={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
target="_blank"
rel="noopener noreferrer"
>
<Image
src={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
alt={setting.photo?.title || "Preview"}
width={250}
height={250}
className="rounded object-contain max-w-[250px] max-h-[250px]"
/>
</a>
)}
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Material</h2>
<p>
<strong>Material:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.mat?.name)}
>
{setting.mat?.name || "—"}
</span>
</p>
<p>
<strong>Coating:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.mat_coat?.name)}
>
{setting.mat_coat?.name || "—"}
</span>
</p>
<p>
<strong>Color:</strong> {setting.mat_color?.name || "—"}
</p>
<p>
<strong>Opacity:</strong> {setting.mat_opacity?.opacity || "—"}
</p>
<p>
<strong>Thickness:</strong>{" "}
{setting.mat_thickness ? `${setting.mat_thickness} mm` : "Not Applicable"}
</p>
</div>
</div>
{/* Setup */}
<div className="card bg-card p-4">
<h2 className="text-xl font-semibold mb-2">Setup</h2>
<p>
<strong>Focus:</strong> {setting.focus ?? "—"} mm
</p>
<small>-Values Focus Closer | +Values Focus Further</small>
</div>
{/* Laser (screenshot on left, specs on right; heading above specs) */}
<div className="card bg-card p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
<div className="flex justify-center">
{setting.screen?.filename_disk ? (
<a
href={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
target="_blank"
rel="noopener noreferrer"
title={setting.screen?.title || "Screenshot"}
>
<Image
src={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
alt={setting.screen?.title || "Screenshot"}
width={250}
height={250}
className="rounded object-contain max-w-[250px] max-h-[250px]"
/>
</a>
) : (
<div className="text-sm text-muted-foreground">No screenshot</div>
)}
</div>
<div>
<h2 className="text-xl font-semibold mb-2">Laser</h2>
<p>
<strong>Source Model:</strong>{" "}
<span
className="cursor-pointer underline hover:text-accent"
onClick={() => openSearchInNewTab(setting.source?.model)}
>
{setting.source?.model || "—"}
</span>
</p>
<p>
<strong>Lens:</strong> {setting.lens?.field_size || "—"} mm |{" "}
{setting.lens?.focal_length || "—"}
</p>
</div>
</div>
</div>
</div>
{/* Notes */}
{setting.setting_notes && (
<div className="prose dark:prose-invert mt-6">
<h2>Notes</h2>
<Markdown>{setting.setting_notes}</Markdown>
</div>
)}
<hr className="my-6 border-muted" />
{/* Repeaters (UV-specific fields kept intact) */}
{renderRepeaterCard(
"Fill Settings",
[
{ key: "name", label: "Fill Name" },
{ key: "speed", label: "Speed (mm/s)" },
{ key: "frequency", label: "Frequency (kHz)" },
{ key: "pulse", label: "Pulse Width (ns)" },
{ key: "interval", label: "Interval (mm)" },
{ key: "pass", label: "Passes" },
{ key: "type", label: "Type" },
{ key: "angle", label: "Angle (°)" },
{ key: "auto", label: "Auto-Rotate" },
{ key: "increment", label: "Increment (°)", condition: (e: any) => e.auto },
{ key: "cross", label: "Crosshatch" },
{ key: "flood", label: "Flood Fill" },
{ key: "air", label: "Air Assist" },
],
setting.fill_settings
)}
{renderRepeaterCard(
"Line Settings",
[
{ key: "name", label: "Line Name" },
{ key: "speed", label: "Speed (mm/s)" },
{ key: "frequency", label: "Frequency (kHz)" },
{ key: "pulse", label: "Pulse Width (ns)" },
{ key: "perf", label: "Perforation Mode" },
{ key: "cut", label: "Cut Override" },
{ key: "skip", label: "Skip Pass" },
{ key: "wobble", label: "Wobble Enabled" },
{ key: "step", label: "Wobble Step" },
{ key: "size", label: "Wobble Size" },
{ key: "pass", label: "Passes" },
{ key: "air", label: "Air Assist" },
],
setting.line_settings
)}
{renderRepeaterCard(
"Raster Settings",
[
{ key: "name", label: "Raster Name" },
{ key: "speed", label: "Speed (mm/s)" },
{ key: "frequency", label: "Frequency (kHz)" },
{ key: "pulse", label: "Pulse Width (ns)" },
{ key: "type", label: "Type" },
{ key: "dither", label: "Dither" },
{ key: "halftone_cell", label: "Halftone Cell" },
{ key: "halftone_angle", label: "Halftone Angle" },
{ key: "inversion", label: "Invert Colors" },
{ key: "interval", label: "Interval (mm)" },
{ key: "dot", label: "Dot Size" },
{ key: "pass", label: "Passes" },
{ key: "cross", label: "Crosshatch" },
{ key: "air", label: "Air Assist" },
],
setting.raster_settings
)}
</div>
);
}

View file

@ -0,0 +1,4 @@
import { Suspense } from "react";
export default function Layout({ children }: { children: React.ReactNode }) {
return <Suspense fallback={null}>{children}</Suspense>;
}

View file

@ -0,0 +1,278 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
type Owner = {
id?: string | number;
username?: string | null;
};
export default function UVSettingsPage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const detailHref = (id: string | number) => `/settings/uv/${id}`;
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(t);
}, [query]);
useEffect(() => {
const url =
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_uv` +
`?fields=` +
[
"submission_id",
"setting_title",
"uploader",
// owner (M2O) ask for username explicitly
"owner.id",
"owner.username",
// assets / denorms
"photo.id",
"photo.title",
"mat.name",
"mat_coat.name",
"source.model",
"lens.field_size",
].join(",") +
`&limit=-1`;
fetch(url, { cache: "no-store" })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setSettings(data?.data || []))
.catch((e) => {
console.error("UV settings fetch failed:", e);
setSettings([]);
})
.finally(() => setLoading(false));
}, []);
const ownerLabel = (o?: Owner) => (o?.username ?? "—");
const highlight = (text?: string) => {
if (!debouncedQuery) return text || "";
const regex = new RegExp(`(${debouncedQuery})`, "gi");
return (text || "").replace(regex, "<mark>$1</mark>");
};
const filtered = useMemo(() => {
const q = debouncedQuery.toLowerCase();
return settings.filter((entry) => {
const fieldsToSearch = [
entry.setting_title,
ownerLabel(entry.owner),
entry.uploader,
entry.mat?.name,
entry.mat_coat?.name,
entry.source?.model,
entry.lens?.field_size,
];
return fieldsToSearch
.filter(Boolean)
.some((field: string) => String(field).toLowerCase().includes(q));
});
}, [settings, debouncedQuery]);
const total = settings.length;
const uniqueMaterials = new Set(
settings.map((s) => s.mat?.name).filter(Boolean)
).size;
const lensCounts = settings.reduce((acc: Record<string, number>, cur) => {
const v = cur.lens?.field_size;
if (!v) return acc;
acc[v] = (acc[v] || 0) + 1;
return acc;
}, {});
const mostCommonLens =
Object.entries(lensCounts).sort(
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
)[0]?.[0] || "—";
const srcCounts = settings.reduce((acc: Record<string, number>, cur) => {
const v = cur.source?.model;
if (!v) return acc;
acc[v] = (acc[v] || 0) + 1;
return acc;
}, {});
const mostCommonSource =
Object.entries(srcCounts).sort(
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
)[0]?.[0] || "—";
const recent = [...settings]
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
.slice(0, 5);
return (
<div className="p-6 max-w-7xl mx-auto">
<style jsx global>{`
mark {
background: #ffde59;
color: #242424;
padding: 0 2px;
border-radius: 2px;
}
`}</style>
{/* Header / Search */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<div className="card bg-card text-card-foreground p-4">
<h1 className="text-2xl font-bold mb-2">UV Laser Settings</h1>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by material, owner, uploader, model, lens…"
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground">
View and explore detailed UV laser settings with context.
</p>
</div>
{/* How to use */}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">How to Use</h2>
<p className="text-sm">
Browse community UV settings. Use search to narrow results. Click a
row to view full configuration, notes, and photos.
</p>
</div>
{/* Stats */}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
<ul className="text-sm space-y-1">
<li>Total Settings: {total}</li>
<li>Unique Materials: {uniqueMaterials}</li>
<li>Most Common Lens: {mostCommonLens}</li>
<li>Most Used Source: {mostCommonSource}</li>
</ul>
</div>
{/* Recently Added */}
<div className="card bg-card text-card-foreground p-4 xl:col-span-3">
<h2 className="text-lg font-semibold mb-2">Recently Added</h2>
<ul className="text-sm space-y-1">
{recent.map((s) => (
<li key={s.submission_id}>
<Link
href={detailHref(s.submission_id)}
className="underline text-accent"
>
{s.setting_title || "Untitled"}
</Link>{" "}
<span className="text-muted-foreground">
by {ownerLabel(s.owner)}
{s.uploader ? ` (uploader: ${s.uploader})` : ""}
</span>
</li>
))}
</ul>
</div>
</div>
{/* Table */}
{loading ? (
<p className="text-muted">Loading settings...</p>
) : filtered.length === 0 ? (
<p className="text-muted">No UV settings found.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className="px-2 py-2 text-left">Photo</th>
<th className="px-2 py-2 text-left">Title</th>
<th className="px-2 py-2 text-left">Owner</th>
<th className="px-2 py-2 text-left">Uploader</th>
<th className="px-2 py-2 text-left">Material</th>
<th className="px-2 py-2 text-left">Coating</th>
<th className="px-2 py-2 text-left">Source</th>
<th className="px-2 py-2 text-left">Lens</th>
</tr>
</thead>
<tbody>
{filtered.map((s) => (
<tr key={s.submission_id} className="border-t border-border">
<td className="px-2 py-2">
{s.photo?.id ? (
<Image
src={`https://forms.lasereverything.net/assets/${s.photo.id}`}
alt={s.photo.title || "laser preview"}
width={64}
height={64}
className="rounded-md"
/>
) : (
"—"
)}
</td>
<td className="px-2 py-2 whitespace-nowrap">
<Link
href={detailHref(s.submission_id)}
className="text-accent underline"
dangerouslySetInnerHTML={{
__html: highlight(s.setting_title || "—"),
}}
/>
</td>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(ownerLabel(s.owner)),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.uploader || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.mat_coat?.name || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.source?.model || "—"),
}}
/>
<td
className="px-2 py-2 whitespace-nowrap"
dangerouslySetInnerHTML={{
__html: highlight(s.lens?.field_size || "—"),
}}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
}
.dark {
--background: 0 0% 13%;
--foreground: 0 0% 85%;
--card: 0 0% 15%;
--card-foreground: 0 0% 90%;
--primary: 0 0% 20%;
--primary-foreground: 0 0% 100%;
--accent: 35 100% 60%;
--muted: 0 0% 40%;
--muted-foreground: 0 0% 60%;
--border: 0 0% 30%;
--input: 0 0% 20%;
--ring: 0 0% 50%;
}
body {
@apply bg-background text-foreground font-mono text-sm leading-relaxed;
}
a {
@apply text-accent underline hover:brightness-125;
}
input, select, textarea {
@apply bg-card text-card-foreground border border-border rounded-md px-3 py-2;
}
table {
@apply border-separate border-spacing-y-1 text-left w-full;
}
th, td {
@apply px-4 py-2;
}
th {
@apply text-card-foreground font-semibold;
}
tr {
@apply border-b border-border;
}
.card {
@apply bg-card text-card-foreground p-4 rounded-md shadow;
}
.btn-primary {
@apply bg-primary text-primary-foreground px-4 py-2 rounded hover:brightness-110;
}
}

View file

@ -0,0 +1,20 @@
import { Suspense } from "react";
import SettingsSubmit from "@/components/forms/SettingsSubmit";
export const dynamic = "force-dynamic"; // keeps this page from being statically prerendered
export default function Page() {
return (
<div className="px-4 py-8 max-w-5xl mx-auto">
<h1 className="text-2xl font-bold mb-2">Community Laser Settings Submission</h1>
<p className="text-muted-foreground mb-6">
Contribute tested settings. Submissions are reviewed before publishing.
</p>
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading form</div>}>
<SettingsSubmit />
</Suspense>
</div>
);
}

View file

@ -0,0 +1,72 @@
// app/submit/settings/success/page.tsx
import Link from "next/link";
type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv";
const TARGET_TO_LIST: Record<Target, string> = {
settings_fiber: "/fiber-settings",
settings_co2gan: "/co2-gantry-settings",
settings_co2gal: "/co2-galvo-settings",
settings_uv: "/uv-settings",
};
const TARGET_LABEL: Record<Target, string> = {
settings_fiber: "Fiber",
settings_co2gan: "CO₂ Gantry",
settings_co2gal: "CO₂ Galvo",
settings_uv: "UV",
};
export default async function SuccessPage({
searchParams,
}: {
searchParams?: Record<string, string | string[] | undefined>;
}) {
const sp = searchParams ?? {};
const rawTarget = sp?.target;
const rawId = sp?.id;
const valid: Target[] = ["settings_fiber", "settings_co2gan", "settings_co2gal", "settings_uv"];
const t: Target = valid.includes(rawTarget as Target)
? (rawTarget as Target)
: "settings_fiber";
const id = Array.isArray(rawId) ? rawId[0] : rawId || "";
const listHref = TARGET_TO_LIST[t];
const label = TARGET_LABEL[t];
return (
<div className="max-w-2xl mx-auto py-10 space-y-6">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-green-600 text-white flex items-center justify-center"></div>
<h1 className="text-xl font-semibold">Submission received</h1>
</div>
<p className="text-base">
Your {label} submission has been received.
{id ? (
<>
{" "}
Reference ID: <span className="font-mono">{id}</span>.
</>
) : null}
</p>
<div className="flex flex-wrap gap-3">
<Link className="px-3 py-2 rounded border" href={listHref}>
View {label} database
</Link>
<Link
className="px-3 py-2 rounded border"
href={`/submit/settings?target=${encodeURIComponent(t)}`}
>
Submit another
</Link>
<Link className="px-3 py-2 rounded border" href="/">
Home
</Link>
</div>
</div>
);
}

21
app/components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View file

@ -0,0 +1,47 @@
// components/PortalTabs.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const tabs = [
{ href: "/portal", label: "Home" },
{ href: "/portal/rigs", label: "Rigs" },
{ href: "/portal/laser-settings", label: "Laser Settings" },
{ href: "/portal/laser-sources", label: "Laser Sources" },
{ href: "/portal/materials", label: "Materials" },
{ href: "/portal/projects", label: "Projects" },
{ href: "/portal/buying-guide", label: "Buying Guide" }, // ⬅️ NEW
{ href: "/portal/utilities", label: "Utilities" },
{ href: "/portal/account", label: "Account" },
];
export default function PortalTabs() {
const pathname = usePathname() || "/portal";
return (
<nav className="flex flex-wrap items-center gap-1 rounded-md border bg-background p-1">
{tabs.map((t) => {
const active =
pathname === t.href ||
(t.href !== "/portal" && pathname.startsWith(t.href));
return (
<Link
key={t.href}
href={t.href}
className={cn(
"px-3 py-1.5 text-sm rounded-md transition",
active ? "bg-primary text-primary-foreground" : "hover:bg-muted"
)}
>
{t.label}
</Link>
);
})}
<div className="ml-auto px-3 py-1.5 text-xs opacity-60">MakerDash</div>
</nav>
);
}

View file

@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
type Props = {
className?: string;
/** Where to send users after logout */
redirectTo?: string; // default /auth/sign-in
};
export default function SignOutButton({
className,
redirectTo = "/auth/sign-in",
}: Props) {
const [pending, setPending] = useState(false);
const router = useRouter();
const pathname = usePathname();
async function onClick() {
if (pending) return;
setPending(true);
try {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "include", // make sure cookies are cleared
});
// include ?next= so they can land back here after re-auth if desired
const next = pathname ? `?next=${encodeURIComponent(pathname)}` : "?next=/portal";
router.push(redirectTo + next);
router.refresh();
} catch {
router.push(redirectTo);
} finally {
setPending(false);
}
}
return (
<Button
type="button"
onClick={onClick}
className={className}
variant="ghost"
size="sm"
disabled={pending}
>
{pending ? "Signing out…" : "Sign out"}
</Button>
);
}

View file

@ -0,0 +1,86 @@
// components/account/AvatarUploader.tsx
"use client";
import { useMemo, useState } from "react";
export default function AvatarUploader({
avatarId,
onUpdated,
}: {
avatarId?: string | null;
onUpdated?: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const API_BASE = useMemo(
() => (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""),
[]
);
const currentUrl = avatarId ? `${API_BASE}/assets/${avatarId}` : null;
const onUpload = async () => {
setMsg(null);
if (!file) {
setMsg("Choose a file first.");
return;
}
setBusy(true);
try {
const fd = new FormData();
fd.set("file", file, file.name);
const r = await fetch("/api/account/avatar", { method: "POST", body: fd });
if (r.status === 401) {
location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent("/portal/account")}`);
return;
}
const j = await r.json().catch(() => ({}));
if (!r.ok) {
setMsg(j?.error || "Upload failed");
return;
}
setMsg("Avatar updated.");
setFile(null);
onUpdated?.();
} catch (e: any) {
setMsg(e?.message || "Upload failed");
} finally {
setBusy(false);
}
};
return (
<div className="rounded-md border p-4 space-y-3">
<h3 className="font-semibold">Avatar</h3>
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
{currentUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={currentUrl} alt="Avatar" className="h-full w-full object-cover" />
) : (
<span className="text-xs opacity-60">No Avatar</span>
)}
</div>
<input
className="text-sm"
type="file"
accept="image/*"
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
/>
<button
onClick={onUpload}
disabled={busy || !file}
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
>
{busy ? "Uploading…" : "Upload"}
</button>
</div>
{msg && <div className="text-sm opacity-80">{msg}</div>}
</div>
);
}

View file

@ -0,0 +1,63 @@
// components/account/ConfirmIdentity.tsx
"use client";
import { useState } from "react";
export default function ConfirmIdentity({
defaultIdentifier,
onSuccess,
}: {
defaultIdentifier: string; // prefill with username or email you show on the page
onSuccess: () => void; // called after re-auth succeeds
}) {
const [open, setOpen] = useState(false);
const [identifier, setIdentifier] = useState(defaultIdentifier);
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function submit() {
setBusy(true);
setErr(null);
try {
const res = await fetch("/api/auth/reconfirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ identifier, password }),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j?.error || "Failed");
setOpen(false);
setPassword("");
onSuccess(); // now do the sensitive call
} catch (e: any) {
setErr(e?.message || "Re-auth failed");
} finally {
setBusy(false);
}
}
return (
<>
{/* wherever you need the step-up, render a button that opens this */}
<button className="btn" onClick={() => setOpen(true)}>Confirm its you</button>
{open && (
<div className="fixed inset-0 bg-black/50 grid place-items-center">
<div className="bg-card border rounded-lg p-4 w-full max-w-sm">
<h3 className="font-semibold mb-2">Confirm its you</h3>
<label className="block text-sm mb-1">Email or Username</label>
<input className="input w-full mb-2" value={identifier} onChange={e=>setIdentifier(e.target.value)} />
<label className="block text-sm mb-1">Password</label>
<input className="input w-full mb-3" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
{err && <div className="text-red-600 text-sm mb-2">{err}</div>}
<div className="flex gap-2 justify-end">
<button className="btn" onClick={()=>setOpen(false)} disabled={busy}>Cancel</button>
<button className="btn-primary" onClick={submit} disabled={busy}>{busy ? "Checking…" : "Continue"}</button>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,137 @@
// components/account/ConnectKofi.tsx
"use client";
import { useCallback, useMemo, useState } from "react";
export default function ConnectKofi({
email,
userId,
}: {
email?: string | null;
userId?: string | null;
}) {
const [value, setValue] = useState(email || "");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const canSubmit = useMemo(
() => !!value && /\S+@\S+\.\S+/.test(value),
[value]
);
const startClaim = useCallback(async () => {
setErr(null);
setMsg(null);
setBusy(true);
try {
const res = await fetch("/api/support/kofi/claim/start", {
method: "POST",
headers: {
"Content-Type": "application/json",
// if your auth middleware expects anything, set it here; otherwise cookies suffice
"x-user-id": userId ?? "",
"x-user-email": email ?? "",
},
credentials: "include",
body: JSON.stringify({ email: value }),
});
const j = await res.json().catch(() => ({} as any));
if (!res.ok) {
// Show specific errors where helpful
const detail = j?.detail || j?.error || res.statusText;
throw new Error(
j?.error === "not_found"
? "We dont have any Ko-fi records for that email yet. If youre sure its correct, try again after your next Ko-fi payment or after we run the backfill."
: String(detail || "Failed to start verification")
);
}
if (j?.alreadyLinked) {
setMsg("This Ko-fi email is already linked to your account.");
} else {
setMsg(
"Verification email sent! Check your inbox and click the link to finish linking Ko-fi."
);
}
} catch (e: any) {
setErr(e?.message || "Something went wrong.");
} finally {
setBusy(false);
}
}, [value, userId, email]);
const unlink = useCallback(async () => {
setErr(null);
setMsg(null);
setBusy(true);
try {
const res = await fetch("/api/support/kofi/unlink", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-user-id": userId ?? "",
},
credentials: "include",
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(t || "Unlink failed");
}
setMsg("Ko-fi has been unlinked from your account.");
} catch (e: any) {
setErr(e?.message || "Unlink failed.");
} finally {
setBusy(false);
}
}, [userId]);
return (
<div className="rounded-md border p-4">
<h3 className="mb-2 text-base font-semibold">Link Ko-fi</h3>
<p className="mb-3 text-sm opacity-80">
Enter the email you use on Ko-fi. Well send a one-time verification link to confirm its you.
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="email"
className="w-full rounded border px-3 py-2 text-sm"
placeholder="you@kofi-email.com"
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={busy}
/>
<button
type="button"
onClick={startClaim}
disabled={!canSubmit || busy}
className="inline-flex items-center justify-center rounded bg-black px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black"
>
{busy ? "Sending…" : "Send Verify Link"}
</button>
<button
type="button"
onClick={unlink}
disabled={busy}
className="inline-flex items-center justify-center rounded border px-4 py-2 text-sm"
title="Remove the Ko-fi link from your account"
>
Unlink
</button>
</div>
{msg && (
<div className="mt-3 rounded-md border border-emerald-300/50 bg-emerald-50 p-2 text-sm text-emerald-900">
{msg}
</div>
)}
{err && (
<div className="mt-3 rounded-md border border-red-300/50 bg-red-50 p-2 text-sm text-red-900">
{err}
</div>
)}
<p className="mt-3 text-xs opacity-70">
Tip: after you verify, badges update automatically. If you dont see a badge yet, itll appear the next time a Ko-fi payment webhook arrives (or after backfill).
</p>
</div>
);
}

View file

@ -0,0 +1,28 @@
// /components/account/LinkStatus.tsx
"use client";
import { useSearchParams } from "next/navigation";
export default function LinkStatus() {
const sp = useSearchParams();
const linked = sp.get("linked");
if (linked !== "kofi") return null;
const isOk = sp.get("ok") === "1";
const isErr = sp.get("error") === "1";
if (!isOk && !isErr) return null;
return (
<div
className={[
"mb-3 rounded-md border p-3 text-sm",
isOk
? "border-emerald-300/50 bg-emerald-50 text-emerald-900"
: "border-red-300/50 bg-red-50 text-red-900",
].join(" ")}
>
{isOk ? "Ko-fi successfully linked to your account." : "Couldnt verify that Ko-fi link. Please try again."}
</div>
);
}

View file

@ -0,0 +1,148 @@
// components/account/PasswordChange.tsx
"use client";
import { useEffect, useState } from "react";
type Me = {
id: string;
username?: string | null;
email?: string | null;
};
export default function PasswordChange() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [next2, setNext2] = useState("");
const [identifier, setIdentifier] = useState(""); // email OR username (sent to API)
const [needIdentifier, setNeedIdentifier] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
// Try to auto-fill identifier from /api/account
useEffect(() => {
(async () => {
try {
const r = await fetch("/api/account", { credentials: "include", cache: "no-store" });
const j = await r.json().catch(() => ({}));
const me: Me | undefined = j?.user ?? j?.data ?? undefined;
const id = (me?.email || me?.username || "").trim();
if (id) {
setIdentifier(id);
setNeedIdentifier(false);
} else {
setNeedIdentifier(true);
}
} catch {
// If it fails, we'll let the user type it
setNeedIdentifier(true);
}
})();
}, []);
const onSave = async () => {
setMsg(null);
if (next !== next2) {
setMsg("New passwords do not match.");
return;
}
if (next.length < 8) {
setMsg("Password must be at least 8 characters.");
return;
}
if (!identifier.trim()) {
setNeedIdentifier(true);
setMsg("Please enter your email or username.");
return;
}
setBusy(true);
try {
const r = await fetch("/api/account/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current, next, identifier: identifier.trim() }),
});
const j = await r.json().catch(() => ({} as any));
if (!r.ok) {
setMsg(j?.error ? (j?.debug ? `${j.error} (${j.debug})` : j.error) : "Password change failed");
return;
}
setMsg("Password updated.");
setCurrent("");
setNext("");
setNext2("");
} catch (e: any) {
setMsg(e?.message || "Password change failed");
} finally {
setBusy(false);
}
};
return (
<div id="security" className="rounded-md border p-4 space-y-3">
<h3 className="font-semibold">Change Password</h3>
<div className="grid sm:grid-cols-2 gap-3 text-sm">
{needIdentifier && (
<label className="grid gap-1 sm:col-span-2">
<span className="opacity-60">Email or Username</span>
<input
className="rounded-md border px-2 py-1"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="you@example.com or your-handle"
autoComplete="username"
/>
</label>
)}
<label className="grid gap-1 sm:col-span-2">
<span className="opacity-60">Current Password</span>
<input
className="rounded-md border px-2 py-1"
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
/>
</label>
<label className="grid gap-1">
<span className="opacity-60">New Password</span>
<input
className="rounded-md border px-2 py-1"
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
autoComplete="new-password"
/>
</label>
<label className="grid gap-1">
<span className="opacity-60">Confirm New Password</span>
<input
className="rounded-md border px-2 py-1"
type="password"
value={next2}
onChange={(e) => setNext2(e.target.value)}
autoComplete="new-password"
/>
</label>
</div>
<div className="flex items-center gap-3">
<button
onClick={onSave}
disabled={busy}
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
>
{busy ? "Saving…" : "Update Password"}
</button>
{msg && <div className="text-sm opacity-80">{msg}</div>}
</div>
</div>
);
}

View file

@ -0,0 +1,211 @@
// components/account/ProfileEditor.tsx
"use client";
import { useEffect, useState } from "react";
type Me = {
id: string;
username: string;
first_name?: string | null;
last_name?: string | null;
email?: string | null;
location?: string | null;
avatar?: { id: string; filename_download?: string } | null;
};
export default function ProfileEditor({
me: meProp,
onUpdated,
}: {
me?: Me | null;
onUpdated?: () => void;
}) {
const [me, setMe] = useState<Me | null>(meProp ?? null);
const [first_name, setFirst] = useState("");
const [last_name, setLast] = useState("");
const [email, setEmail] = useState("");
const [profileLocation, setProfileLocation] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(!meProp);
const nextAccount = "/portal/account";
// Load profile if not provided via props
useEffect(() => {
if (meProp) {
setMe(meProp);
setLoading(false);
return;
}
let alive = true;
(async () => {
try {
const r = await fetch("/api/account", { credentials: "include", cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
const j = await r.json();
const user: Me | undefined = j?.user ?? j?.data ?? undefined;
if (!user) throw new Error("Bad response");
if (alive) setMe(user);
} catch (e: any) {
if (alive) setMsg(`Failed to load: ${e?.message || e}`);
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, [meProp]);
useEffect(() => {
if (!me) return;
setFirst(me.first_name || "");
setLast(me.last_name || "");
setEmail(me.email || "");
setProfileLocation(me.location || "");
}, [me]);
// Auto-retry a pending sensitive update after coming back from reauth
useEffect(() => {
const raw = typeof window !== "undefined" ? sessionStorage.getItem("pendingProfileUpdate") : null;
if (!raw) return;
let pending: Record<string, any> | null = null;
try {
pending = JSON.parse(raw);
} catch {
pending = null;
}
if (!pending) {
sessionStorage.removeItem("pendingProfileUpdate");
return;
}
(async () => {
try {
const r = await fetch("/api/account/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pending),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
setMsg(j?.error || "Update after re-auth failed");
} else {
setMsg("Saved after re-authentication.");
onUpdated?.();
}
} finally {
sessionStorage.removeItem("pendingProfileUpdate");
}
})();
}, [onUpdated]);
const onSave = async () => {
setMsg(null);
setBusy(true);
try {
const payload: Record<string, any> = {
first_name: first_name.trim(),
last_name: last_name.trim(),
email: email.trim() || null, // allow clearing email
location: profileLocation.trim(),
};
const r = await fetch("/api/account/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (r.status === 428) {
// Need reauth for sensitive change (email)
if (typeof window !== "undefined") {
// Stash the pending payload so we can retry after reauth
sessionStorage.setItem("pendingProfileUpdate", JSON.stringify(payload));
window.location.assign(
`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount + "#security")}`
);
}
return;
}
if (r.status === 401) {
if (typeof window !== "undefined") {
window.location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount)}`);
}
return;
}
const j = await r.json().catch(() => ({}));
if (!r.ok) {
setMsg(j?.error || "Update failed");
return;
}
setMsg("Saved.");
onUpdated?.();
} catch (e: any) {
setMsg(e?.message || "Update failed");
} finally {
setBusy(false);
}
};
if (loading) return <div className="rounded-md border p-4 text-sm opacity-70">Loading editor</div>;
return (
<div className="rounded-md border p-4 space-y-3">
<h3 className="font-semibold">Edit Profile</h3>
<div className="grid sm:grid-cols-2 gap-3 text-sm">
<label className="grid gap-1">
<span className="opacity-60">First Name</span>
<input
className="rounded-md border px-2 py-1"
value={first_name}
onChange={(e) => setFirst(e.target.value)}
/>
</label>
<label className="grid gap-1">
<span className="opacity-60">Last Name</span>
<input
className="rounded-md border px-2 py-1"
value={last_name}
onChange={(e) => setLast(e.target.value)}
/>
</label>
<label className="grid gap-1 sm:col-span-2">
<span className="opacity-60">Email (reauth required)</span>
<input
className="rounded-md border px-2 py-1"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
</label>
<label className="grid gap-1 sm:col-span-2">
<span className="opacity-60">Location</span>
<input
className="rounded-md border px-2 py-1"
value={profileLocation}
onChange={(e) => setProfileLocation(e.target.value)}
placeholder="City, Country"
/>
</label>
</div>
<div className="flex items-center gap-3">
<button
onClick={onSave}
disabled={busy}
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
>
{busy ? "Saving…" : "Save Changes"}
</button>
{msg && <div className="text-sm opacity-80">{msg}</div>}
</div>
</div>
);
}

View file

@ -0,0 +1,111 @@
// components/account/SupporterBadges.tsx
"use client";
import { useEffect, useState } from "react";
type SupportBadge = {
provider: "kofi" | "patreon" | "mighty" | string;
active: boolean; // currently entitled (status==="active" && renews_at >= now)
kind: "member" | "one_time" | "inactive";
label: string; // e.g., "Ko-fi • Bronze" or "Ko-fi Supporter"
tier?: string | null;
renews_at?: string | null;
started_at?: string | null;
};
function providerIcon(p: string) {
// Swap these for real icons later if you want
if (p === "kofi") return "☕";
if (p === "patreon") return "🅿️";
if (p === "mighty") return "💬";
return "🎖️";
}
export default function SupporterBadges({
email,
userId,
}: {
email?: string | null;
userId?: string | null;
}) {
const [badges, setBadges] = useState<SupportBadge[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let url = "/api/support/badges";
const params = new URLSearchParams();
if (email) params.set("email", String(email));
if (userId) params.set("userId", String(userId));
const qs = params.toString();
if (qs) url += `?${qs}`;
fetch(url, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.then((json) => setBadges(Array.isArray(json?.badges) ? json.badges : []))
.catch(async (e) => {
try {
const t = await e.text();
setError(t || String(e));
} catch {
setError(String(e));
}
});
}, [email, userId]);
if (error) {
return (
<div className="mt-4 rounded-lg border border-red-300/50 bg-red-50 p-3 text-sm text-red-800">
Couldnt load supporter badges.
</div>
);
}
if (!badges) {
return (
<div className="mt-4 animate-pulse rounded-lg border p-3 text-sm opacity-60">
Loading supporter badges
</div>
);
}
if (badges.length === 0) {
return (
<div className="mt-4 rounded-lg border p-3 text-sm opacity-70">
No supporter badges yet.
</div>
);
}
return (
<div className="mt-4 space-y-2">
<div className="text-sm font-medium opacity-80">Supporter Badges</div>
<div className="flex flex-wrap gap-2">
{badges.map((b, i) => (
<span
key={`${b.provider}-${i}`}
className={[
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm",
b.active
? "border-green-300 bg-green-50 text-green-900"
: b.kind === "one_time"
? "border-amber-300 bg-amber-50 text-amber-900"
: "border-slate-300 bg-slate-50 text-slate-700",
].join(" ")}
title={
b.active
? b.renews_at
? `Active • renews by ${new Date(b.renews_at).toLocaleDateString()}`
: "Active"
: b.kind === "one_time"
? "One-time support"
: "Inactive"
}
>
<span>{providerIcon(b.provider)}</span>
<span>{b.label}</span>
</span>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,332 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
interface Entry {
id: number | string;
product_make: string;
product_model: string;
product_price?: string;
review_overview_text?: string;
bg_entry_sub_cat?: number;
bg_entry_cat?: number;
index?: {
id: string;
filename_disk?: string;
type?: string;
};
header?: {
id: string;
filename_disk?: string;
type?: string;
};
}
interface SubCategory {
id: number;
name: string;
bg_entry_cat?: number;
}
interface Category {
id: number;
name: string;
}
export default function BuyingGuidePage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [entries, setEntries] = useState<Entry[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [subcategories, setSubcategories] = useState<SubCategory[]>([]);
const [selectedCat, setSelectedCat] = useState("");
const [selectedSubCat, setSelectedSubCat] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
const fetchData = async () => {
try {
const [entriesRes, catRes, subCatRes] = await Promise.all([
fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_entries?fields=id,index.id,index.filename_disk,index.type,header.id,header.filename_disk,product_make,product_model,product_price,review_overview_text,bg_entry_cat,bg_entry_sub_cat&limit=-1&sort[]=sort`
),
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_cat?fields=id,name&limit=-1`),
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_sub_cat?fields=id,name,bg_entry_cat&limit=-1`),
]);
const [entriesData, catData, subCatData] = await Promise.all([
entriesRes.json(),
catRes.json(),
subCatRes.json(),
]);
setEntries(entriesData?.data || []);
setCategories(catData?.data || []);
setSubcategories(subCatData?.data || []);
setLoading(false);
} catch (err) {
console.error("Error fetching data:", err);
setLoading(false);
}
};
fetchData();
}, []);
const normalize = (str: string) => str?.toLowerCase().replace(/[_\s]/g, "");
const filtered = useMemo(() => {
const q = normalize(debouncedQuery);
return entries.filter((entry) => {
const catMatch = selectedCat ? entry.bg_entry_cat === parseInt(selectedCat) : true;
const subCatMatch = selectedSubCat ? entry.bg_entry_sub_cat === parseInt(selectedSubCat) : true;
const searchMatch = q
? [entry.product_make, entry.product_model, entry.review_overview_text].some((field) =>
normalize(field || "").includes(q)
)
: true;
return catMatch && subCatMatch && searchMatch;
});
}, [entries, debouncedQuery, selectedCat, selectedSubCat]);
const filteredSubcategories = useMemo(() => {
return selectedCat ? subcategories.filter((sub) => sub.bg_entry_cat === parseInt(selectedCat)) : subcategories;
}, [subcategories, selectedCat]);
const featuredEntry = useMemo(() => {
if (!entries.length) return null;
const randomIndex = Math.floor(Math.random() * entries.length);
return entries[randomIndex];
}, [entries]);
const secondFeaturedEntry = useMemo(() => {
if (entries.length < 2) return null;
let secondIndex = Math.floor(Math.random() * entries.length);
while (entries[secondIndex].id === featuredEntry?.id) {
secondIndex = Math.floor(Math.random() * entries.length);
}
return entries[secondIndex];
}, [entries, featuredEntry]);
// Build a URL that sets ?product=<id> while preserving any existing params.
const makeProductHref = (id: number | string) => {
const sp = new URLSearchParams(Array.from(searchParams.entries()));
sp.set("product", String(id));
return `?${sp.toString()}`;
};
return (
<div className="p-6 max-w-7xl mx-auto">
<style jsx global>{`
mark {
background: #ffde59;
color: #242424;
padding: 0 2px;
border-radius: 2px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
gap: 1rem;
}
.entry-card {
display: flex;
background-color: #242424;
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
height: 150px;
}
.entry-image {
width: 150px;
height: 150px;
object-fit: cover;
}
.entry-content {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.truncate-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`}</style>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<div className="card bg-card text-card-foreground p-4">
<h1 className="text-2xl font-bold mb-2">Buying Guide</h1>
<select
className="w-full border rounded px-3 py-2 mb-2"
value={selectedCat}
onChange={(e) => {
setSelectedCat(e.target.value);
setSelectedSubCat("");
}}
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id.toString()}>
{cat.name}
</option>
))}
</select>
<select
className="w-full border rounded px-3 py-2 mb-2"
value={selectedSubCat}
onChange={(e) => setSelectedSubCat(e.target.value)}
>
<option value="">All Subcategories</option>
{filteredSubcategories.map((sub) => (
<option key={sub.id} value={sub.id.toString()}>
{sub.name}
</option>
))}
</select>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products by make, model, etc..."
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground mb-2">Discover reviewed laser products and accessories.</p>
<a href="/" className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm">
Back to Main Menu
</a>
</div>
{[featuredEntry, secondFeaturedEntry].map(
(entry, idx) =>
entry && (
<div key={idx} className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Featured Product</h2>
{entry.header?.filename_disk ? (
<Image
src={`https://forms.lasereverything.net/assets/${entry.header.filename_disk}`}
alt="Header image"
width={800}
height={100}
className="w-full h-[100px] object-cover mb-2 rounded-md"
unoptimized
/>
) : (
<div className="w-full h-[100px] bg-zinc-800 flex items-center justify-center text-zinc-400 text-sm rounded-md mb-2">
No Header
</div>
)}
<Link href={makeProductHref(entry.id)} className="text-accent font-semibold text-lg hover:underline">
{entry.product_make} {entry.product_model}
</Link>
{entry.product_price && <p className="text-sm text-white">Starting at {entry.product_price}</p>}
<p className="text-sm text-muted-foreground mt-1">{entry.review_overview_text?.slice(0, 140)}...</p>
</div>
)
)}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Popular Categories</h2>
<ul className="text-sm space-y-1">
{categories.slice(0, 5).map((cat) => (
<li key={cat.id}>
<button
onClick={() => {
setSelectedCat(cat.id.toString());
setSelectedSubCat("");
}}
className="text-accent hover:underline"
>
{cat.name}
</button>
</li>
))}
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Recently Added</h2>
<ul className="text-sm space-y-1">
{entries.slice(0, 3).map((e) => (
<li key={e.id}>
<Link href={makeProductHref(e.id)} className="text-accent hover:underline">
{e.product_make} {e.product_model}
</Link>
</li>
))}
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">What Is This?</h2>
<p className="text-sm text-muted-foreground">
This Buying Guide helps you compare laser-related gear with hands-on reviews, scores, and recommendations.
Use the filters and search to find what youre looking for!
</p>
</div>
</div>
<hr className="my-8 border-border" />
{loading ? (
<p className="text-muted">Loading entries...</p>
) : filtered.length === 0 ? (
<p className="text-muted">No entries found.</p>
) : (
<div className="card-grid">
{filtered.map((entry) => {
const filename = entry.index?.filename_disk;
return (
<div key={entry.id} className="entry-card">
{filename ? (
<Image
src={`https://forms.lasereverything.net/assets/${filename}`}
alt={`${entry.product_make} ${entry.product_model}`}
width={150}
height={150}
className="entry-image"
unoptimized
/>
) : (
<div className="entry-image bg-zinc-800 flex items-center justify-center text-zinc-400">No Image</div>
)}
<div className="entry-content">
<div>
<p className="text-sm font-medium text-muted-foreground truncate-title">{entry.product_make}</p>
<Link
href={makeProductHref(entry.id)}
className="text-lg font-semibold text-accent underline truncate-title"
title={entry.product_model}
>
{entry.product_model}
</Link>
{entry.product_price !== undefined && (
<p className="text-sm text-foreground mt-1 font-medium">Starting at {entry.product_price}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{entry.review_overview_text?.slice(0, 120)}...
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,188 @@
// app/buying-guide/product/[id]/page.tsx
import Link from "next/link";
import ReactMarkdown from "react-markdown";
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
const ASSET_URL = process.env.NEXT_PUBLIC_ASSET_URL;
async function getEntry(id: string) {
const res = await fetch(
`${API_URL}/items/bg_entries/${id}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,date_updated`,
{
cache: "no-store",
}
);
if (!res.ok) {
const error = await res.text();
console.error(`Failed to fetch entry: ${error}`);
throw new Error(`Error fetching entry ${id}`);
}
const { data } = await res.json();
return data;
}
export default async function ProductDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const id = (await params).id;
const entry = await getEntry(id);
const avgScore =
entry?.scores?.length > 0
? (
entry.scores.reduce((sum: number, s: any) => sum + Number(s.value), 0) /
entry.scores.length
).toFixed(1)
: "N/A";
const headerUrl = entry.header?.id
? `${ASSET_URL}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
: null;
return (
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header Banner */}
{headerUrl && (
<div className="w-full h-64 relative overflow-hidden rounded-xl shadow">
<img
src={headerUrl}
alt="Header Image"
className="object-cover w-full h-full rounded-xl"
/>
</div>
)}
{/* Title */}
<div className="flex justify-between items-start">
<div>
<h2 className="text-xl font-semibold text-white">{entry.product_make}</h2>
<h1 className="text-4xl font-bold text-yellow-500 mt-2">{entry.product_model}</h1>
</div>
<div className="text-right">
{entry.product_price && (
<p className="text-lg text-white font-medium mt-1">
{entry.product_price.startsWith("Starting at") ? entry.product_price : `Starting at ${entry.product_price}`}
</p>
)}
<Link
href="/buying-guide"
className="text-sm text-blue-500 underline mt-2 inline-block"
>
Back to Buying Guide
</Link>
</div>
</div>
{/* Links & Score Summary */}
{(Array.isArray(entry.links) || Array.isArray(entry.scores)) && (
<div className="flex flex-col md:flex-row gap-8">
{Array.isArray(entry.links) && entry.links.length > 0 && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Links</h3>
<ul className="list-disc ml-6 space-y-1">
{entry.links.map((link: any, idx: number) => (
<li key={idx}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 underline"
>
{link.text || link.url}
</a>
{link.target && (
<span className="text-sm text-gray-500"> ({link.target})</span>
)}
</li>
))}
</ul>
</div>
)}
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Score Summary</h3>
<ul className="space-y-1">
{entry.scores.map((s: any, idx: number) => (
<li key={idx} className="flex justify-between">
<span>{s.cat}</span>
<span className="font-semibold">{s.value}/10</span>
</li>
))}
</ul>
<p className="mt-2 font-bold text-right">Total: {avgScore}</p>
</div>
)}
</div>
)}
{/* Overview */}
{entry.review_overview_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Overview</h3>
<ReactMarkdown>{entry.review_overview_text}</ReactMarkdown>
</div>
)}
{/* Intro */}
{entry.review_intro_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">{`${entry.product_make}, ${entry.product_model} Review by ${entry.author}`}</h3>
<ReactMarkdown>{entry.review_intro_text}</ReactMarkdown>
</div>
)}
{/* Detailed Scores */}
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
<div className="space-y-4">
{entry.scores.map((s: any, idx: number) => (
<div key={idx} className="p-4 rounded border">
<p className="text-xl font-semibold">
{s.cat} <span className="text-blue-600">{s.value}/10</span>
</p>
<div className="text-sm text-gray-400">
<ReactMarkdown>{s.body}</ReactMarkdown>
</div>
</div>
))}
</div>
)}
{/* Recommendation */}
{entry.rec_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Recommendation</h3>
<ReactMarkdown>{entry.rec_text}</ReactMarkdown>
</div>
)}
{/* Updates */}
{entry.updates && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Updates</h3>
<ReactMarkdown>{entry.updates}</ReactMarkdown>
</div>
)}
{/* Video */}
{entry.video_review_url && (
<div>
<h3 className="text-xl font-semibold mb-2">Video Review</h3>
<div className="aspect-w-16 aspect-h-9">
<iframe
src={entry.video_review_url.replace("watch?v=", "embed/")}
className="w-full h-96 rounded"
frameBorder="0"
allowFullScreen
/>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,256 @@
"use client";
import { useMemo, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import ReactMarkdown from "react-markdown";
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL!;
const ASSET_BASE =
process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net";
type Score = { id: string | number; cat: string; value: number | string; body?: string };
type LinkItem = { id?: string | number; text?: string; url: string; target?: string };
type Entry = {
id: number | string;
product_make?: string;
product_model?: string;
product_price?: string;
review_overview_text?: string;
review_intro_text?: string;
author?: string;
rec_text?: string;
updates?: string;
video_review_url?: string;
header?: { id?: string; filename_disk?: string };
date_updated?: string | number;
links?: LinkItem[];
scores?: Score[];
};
export default function BuyingGuideProductClient() {
const searchParams = useSearchParams();
const router = useRouter();
const productId = searchParams.get("product");
const [entry, setEntry] = useState<Entry | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Fetch the entry when productId changes
useEffect(() => {
let abort = false;
async function run() {
if (!productId) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`${API_URL}/items/bg_entries/${productId}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,header.filename_disk,date_updated`,
{ cache: "no-store" }
);
if (!res.ok) throw new Error(await res.text());
const { data } = await res.json();
if (!abort) setEntry(data);
} catch (e: any) {
if (!abort) setError(e?.message || "Failed to load product");
} finally {
if (!abort) setLoading(false);
}
}
run();
return () => {
abort = true;
};
}, [productId]);
const avgScore = useMemo(() => {
if (!entry?.scores?.length) return "N/A";
const sum = entry.scores.reduce((s, it) => s + Number(it.value ?? 0), 0);
return (sum / entry.scores.length).toFixed(1);
}, [entry]);
// Prefer the public filename_disk path (like the list view).
const headerUrl =
entry?.header?.filename_disk
? `${ASSET_BASE}/assets/${entry.header.filename_disk}`
: entry?.header?.id
? `${ASSET_BASE}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
: null;
if (!productId) return null; // switcher guards this, but be defensive
if (loading) {
return <div className="max-w-4xl mx-auto px-4 py-8">Loading</div>;
}
if (error) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<p className="text-red-500 text-sm">Error: {error}</p>
<button
className="text-blue-500 underline mt-2"
onClick={() => {
const sp = new URLSearchParams(Array.from(searchParams.entries()));
sp.delete("product");
router.push(`?${sp.toString()}`, { scroll: false });
}}
>
Back to Buying Guide
</button>
</div>
);
}
if (!entry) return null;
return (
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header Banner */}
{headerUrl && (
<div className="w-full h-64 relative overflow-hidden rounded-xl shadow">
<img
src={headerUrl}
alt="Header Image"
className="object-cover w-full h-full rounded-xl"
/>
</div>
)}
{/* Title */}
<div className="flex justify-between items-start">
<div>
<h2 className="text-xl font-semibold text-white">{entry.product_make}</h2>
<h1 className="text-4xl font-bold text-yellow-500 mt-2">{entry.product_model}</h1>
</div>
<div className="text-right">
{entry.product_price && (
<p className="text-lg text-white font-medium mt-1">
{entry.product_price.startsWith("Starting at")
? entry.product_price
: `Starting at ${entry.product_price}`}
</p>
)}
<button
onClick={() => {
const sp = new URLSearchParams(Array.from(searchParams.entries()));
sp.delete("product");
router.push(`?${sp.toString()}`, { scroll: false });
}}
className="text-sm text-blue-500 underline mt-2 inline-block"
>
Back to Buying Guide
</button>
</div>
</div>
{/* Links & Score Summary */}
{(Array.isArray(entry.links) || Array.isArray(entry.scores)) && (
<div className="flex flex-col md:flex-row gap-8">
{Array.isArray(entry.links) && entry.links.length > 0 && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Links</h3>
<ul className="list-disc ml-6 space-y-1">
{entry.links.map((link: any, idx: number) => (
<li key={idx}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 underline"
>
{link.text || link.url}
</a>
{link.target && (
<span className="text-sm text-gray-500"> ({link.target})</span>
)}
</li>
))}
</ul>
</div>
)}
{Array.isArray(entry.scores) && entry.scores.length > 0 && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Score Summary</h3>
<ul className="space-y-1">
{entry.scores.map((s: any, idx: number) => (
<li key={idx} className="flex justify-between">
<span>{s.cat}</span>
<span className="font-semibold">{s.value}/10</span>
</li>
))}
</ul>
<p className="mt-2 font-bold text-right">Total: {avgScore}</p>
</div>
)}
</div>
)}
{/* Overview */}
{entry.review_overview_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Overview</h3>
<ReactMarkdown>{entry.review_overview_text}</ReactMarkdown>
</div>
)}
{/* Intro */}
{entry.review_intro_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">
{`${entry.product_make}, ${entry.product_model} Review by ${entry.author}`}
</h3>
<ReactMarkdown>{entry.review_intro_text}</ReactMarkdown>
</div>
)}
{/* Detailed Scores */}
{Array.isArray(entry.scores) && entry.scores.length > 0 && (
<div className="space-y-4">
{entry.scores.map((s: any, idx: number) => (
<div key={idx} className="p-4 rounded border">
<p className="text-xl font-semibold">
{s.cat} <span className="text-blue-600">{s.value}/10</span>
</p>
<div className="text-sm text-gray-400">
<ReactMarkdown>{s.body}</ReactMarkdown>
</div>
</div>
))}
</div>
)}
{/* Recommendation */}
{entry.rec_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Recommendation</h3>
<ReactMarkdown>{entry.rec_text}</ReactMarkdown>
</div>
)}
{/* Updates */}
{entry.updates && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Updates</h3>
<ReactMarkdown>{entry.updates}</ReactMarkdown>
</div>
)}
{/* Video */}
{entry.video_review_url && (
<div>
<h3 className="text-xl font-semibold mb-2">Video Review</h3>
<div className="aspect-w-16 aspect-h-9">
<iframe
src={entry.video_review_url.replace("watch?v=", "embed/")}
className="w-full h-96 rounded"
frameBorder="0"
allowFullScreen
/>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,322 @@
// app/buying-guide/finder/page.tsx
"use client";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import type { Answers, LaserType } from "@/lib/laser-finder";
import { scoreAnswers, LASER_LABEL, TYPE_INFO } from "@/lib/laser-finder";
import Link from "next/link";
export default function LaserFinderPage() {
const [result, setResult] = useState<{
top: LaserType[];
score: Record<LaserType, number>;
why: Record<LaserType, string[]>;
} | null>(null);
const { register, handleSubmit, reset, watch } = useForm<Answers>({
defaultValues: {
materials: [],
operations: [],
part_size: "medium",
detail: "medium",
throughput: "medium",
budget: "mid",
},
});
const onSubmit = (vals: Answers) => {
const { ranked, score, why } = scoreAnswers(vals);
setResult({ top: ranked.slice(0, 2), score, why });
// (Optional) later: POST vals to Directus for analytics
};
const selectedMaterials = watch("materials");
const selectedOps = watch("operations");
return (
<div className="max-w-4xl mx-auto py-8 space-y-6">
<h1 className="text-2xl font-semibold">Laser Type Finder</h1>
<p className="text-sm text-muted-foreground">
Answer a few questions and well suggest the best laser <em>types</em> for your work
with clear use-cases, materials, and cautions. No product pitchesjust guidance.
</p>
{!result && (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Materials */}
<fieldset className="border rounded p-4">
<legend className="font-medium">Materials (select all that apply)</legend>
<div className="grid sm:grid-cols-2 gap-2 mt-2">
{[
["metals_bare", "Bare metals"],
["metals_coated", "Coated/painted metals"],
["plastics", "Plastics"],
["wood_paper_leather", "Wood, paper, leather"],
["glass_ceramic", "Glass / ceramic"],
["stone", "Stone"],
["textiles", "Textiles"],
].map(([val, label]) => (
<label key={val} className="flex items-center gap-2 text-sm">
<input type="checkbox" value={val} {...register("materials")} /> {label}
</label>
))}
</div>
{selectedMaterials?.length === 0 && (
<p className="text-xs text-amber-600 mt-2">Tip: choose at least one material for a better match.</p>
)}
</fieldset>
{/* Operations */}
<fieldset className="border rounded p-4">
<legend className="font-medium">Typical operations (select all that apply)</legend>
<div className="grid sm:grid-cols-2 gap-2 mt-2">
{[
["deep_mark_metal", "Deep mark on metal"],
["color_mark_stainless", "Color mark stainless"],
["fine_engraving", "Fine engraving (small features)"],
["photo_engrave", "Photo engraving"],
["cut_nonmetals_thick", "Cut thick non-metals (e.g., 6+ mm acrylic/wood)"],
["cut_nonmetals_thin", "Cut thin non-metals"],
["mark_coated", "Mark coated items"],
].map(([val, label]) => (
<label key={val} className="flex items-center gap-2 text-sm">
<input type="checkbox" value={val} {...register("operations")} /> {label}
</label>
))}
</div>
{selectedOps?.length === 0 && (
<p className="text-xs text-amber-600 mt-2">Tip: pick one or more to sharpen the recommendation.</p>
)}
</fieldset>
{/* Size / Detail / Speed / Budget */}
<div className="grid sm:grid-cols-4 gap-4">
<div>
<label className="block text-sm mb-1">Part size</label>
<select className="w-full border rounded px-2 py-1" {...register("part_size")}>
<option value="small">Small ( 200 mm)</option>
<option value="medium">Medium ( 600 mm)</option>
<option value="large">Large (&gt; 600 mm)</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Detail</label>
<select className="w-full border rounded px-2 py-1" {...register("detail")}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="micro">Micro</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Throughput</label>
<select className="w-full border rounded px-2 py-1" {...register("throughput")}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Budget</label>
<select className="w-full border rounded px-2 py-1" {...register("budget")}>
<option value="low">Lower</option>
<option value="mid">Mid</option>
<option value="high">Higher</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<button className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90" type="submit">
Get recommendations
</button>
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
Back to Buying Guide
</Link>
</div>
</form>
)}
{!!result && (
<div className="space-y-6">
<ResultCard
title="Top recommendation"
type={result.top[0]}
why={result.why[result.top[0]]}
/>
{result.top[1] && (
<ResultCard
title="Alternative to consider"
type={result.top[1]}
why={result.why[result.top[1]]}
secondary
/>
)}
<CompareMatrix />
<div className="flex items-center gap-3">
<button className="px-3 py-2 border rounded hover:bg-muted" onClick={() => setResult(null)}>
Start over
</button>
<button
className="px-3 py-2 border rounded hover:bg-muted"
onClick={() => { reset(); setResult(null); }}
>
New answers
</button>
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
Back to Buying Guide
</Link>
</div>
</div>
)}
</div>
);
}
function ResultCard({
title,
type,
why,
secondary,
}: {
title: string;
type: LaserType;
why?: string[];
secondary?: boolean;
}) {
const info = TYPE_INFO[type];
return (
<div className="rounded border p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{title}</h2>
{!secondary && <span className="text-xs rounded bg-muted px-2 py-0.5">Best match</span>}
</div>
<div className="text-base font-medium">{LASER_LABEL[type]}</div>
<p className="text-sm text-muted-foreground">{info.summary}</p>
{!!why?.length && (
<div>
<div className="text-sm font-medium mb-1">Why this fits</div>
<ul className="list-disc pl-5 text-sm space-y-1">
{why.slice(0, 5).map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
<div className="grid sm:grid-cols-3 gap-3 text-sm">
<TagList label="Best for" items={info.bestFor} />
<TagList label="Materials" items={info.materials} />
<TagList label="Cautions" items={info.cautions} tone="warn" />
</div>
<div className="flex items-center gap-3 pt-1">
<Link className="text-sm underline hover:no-underline" href={info.learnLink}>
See community settings
</Link>
<Link className="text-sm underline hover:no-underline" href="/submit/settings?target=settings_fiber">
Suggest new settings
</Link>
</div>
</div>
);
}
function TagList({
label,
items,
tone,
}: {
label: string;
items: string[];
tone?: "warn";
}) {
return (
<div>
<div className="text-sm font-medium mb-1">{label}</div>
<div className="flex flex-wrap gap-2">
{items.map((t, i) => (
<span
key={i}
className={`text-xs px-2 py-1 rounded border ${
tone === "warn" ? "bg-amber-50 border-amber-200" : "bg-muted"
}`}
>
{t}
</span>
))}
</div>
</div>
);
}
function CompareMatrix() {
const rows: Array<{
k: LaserType;
label: string;
best: string[];
ok: string[];
avoid: string[];
}> = [
{
k: "fiber",
label: LASER_LABEL.fiber,
best: ["Bare metals", "Deep metal engrave", "Color marking stainless"],
ok: ["Some coated items", "Some plastics w/ additives"],
avoid: ["Thick organics cutting"],
},
{
k: "co2_gantry",
label: LASER_LABEL.co2_gantry,
best: ["Acrylic cutting", "Wood cutting/engraving", "Large panels"],
ok: ["Leathers, textiles, rubber"],
avoid: ["Bare metals (no coat)"],
},
{
k: "co2_galvo",
label: LASER_LABEL.co2_galvo,
best: ["Fast marking organics", "Photo engraving organics"],
ok: ["Coated metals/non-metals"],
avoid: ["Thick sheet cutting", "Large panels"],
},
{
k: "uv",
label: LASER_LABEL.uv,
best: ["Micro features", "Glass/ceramic/plastics marking"],
ok: ["Fine logos on coated metals"],
avoid: ["Thick cutting"],
},
];
return (
<div className="rounded border p-4">
<div className="text-sm font-medium mb-2">Compare laser types</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="py-2 pr-3">Type</th>
<th className="py-2 pr-3">Best for</th>
<th className="py-2 pr-3">Okay for</th>
<th className="py-2">Not ideal</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.k} className="border-b last:border-0 align-top">
<td className="py-2 pr-3 font-medium">{r.label}</td>
<td className="py-2 pr-3">{r.best.join(", ")}</td>
<td className="py-2 pr-3">{r.ok.join(", ")}</td>
<td className="py-2">{r.avoid.join(", ")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,12 @@
// components/utilities/buying-guide/dx.ts
export type Q = Record<string, any>;
export async function dxGet<T>(path: string, query?: Q): Promise<T> {
const qs = query ? "?" + new URLSearchParams(Object.entries(query).flatMap(([k, v]) =>
Array.isArray(v) ? v.map(x => [k, String(x)]) : [[k, String(v)]]
)).toString() : "";
const res = await fetch(`/api/dx/${path}${qs}`, { credentials: "include" });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const json = await res.json();
return json?.data ?? json;
}

Some files were not shown because too many files have changed in this diff Show more