Create monorepo from known-good production state
This commit is contained in:
commit
c034824338
651 changed files with 120469 additions and 0 deletions
19
app/.dockerignore
Normal file
19
app/.dockerignore
Normal 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
10
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
!public/svgnest/**
|
||||
|
||||
# Next.js build output
|
||||
.next/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
49
app/Dockerfile
Normal file
49
app/Dockerfile
Normal 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
18
app/app/api/_lib/auth.ts
Normal 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" } });
|
||||
}
|
||||
60
app/app/api/account/avatar/route.ts
Normal file
60
app/app/api/account/avatar/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
110
app/app/api/account/password/route.ts
Normal file
110
app/app/api/account/password/route.ts
Normal 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 can’t 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); }
|
||||
75
app/app/api/account/profile/route.ts
Normal file
75
app/app/api/account/profile/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
101
app/app/api/account/route.ts
Normal file
101
app/app/api/account/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
82
app/app/api/auth/login/route.ts
Normal file
82
app/app/api/auth/login/route.ts
Normal 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 doesn’t 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 });
|
||||
}
|
||||
}
|
||||
28
app/app/api/auth/logout/route.ts
Normal file
28
app/app/api/auth/logout/route.ts
Normal 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);
|
||||
}
|
||||
66
app/app/api/auth/reconfirm/route.ts
Normal file
66
app/app/api/auth/reconfirm/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
114
app/app/api/auth/register/route.ts
Normal file
114
app/app/api/auth/register/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
app/app/api/bgbye/process/route.ts
Normal file
47
app/app/api/bgbye/process/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
52
app/app/api/claims/route.ts
Normal file
52
app/app/api/claims/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
17
app/app/api/debug/meta/route.ts
Normal file
17
app/app/api/debug/meta/route.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
14
app/app/api/debug/whoami/route.ts
Normal file
14
app/app/api/debug/whoami/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
84
app/app/api/dx/[...path]/route.ts
Normal file
84
app/app/api/dx/[...path]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/app/api/files/download/route.ts
Normal file
42
app/app/api/files/download/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
62
app/app/api/files/list/route.ts
Normal file
62
app/app/api/files/list/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
142
app/app/api/files/raw/route.ts
Normal file
142
app/app/api/files/raw/route.ts
Normal 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
22
app/app/api/me/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
48
app/app/api/my-settings/delete/route.ts
Normal file
48
app/app/api/my-settings/delete/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
138
app/app/api/project/route.ts
Normal file
138
app/app/api/project/route.ts
Normal 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
122
app/app/api/rigs/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
306
app/app/api/settings/route.ts
Normal file
306
app/app/api/settings/route.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
36
app/app/api/support/badges/route.ts
Normal file
36
app/app/api/support/badges/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
app/app/api/support/kofi/claim/start/route.ts
Normal file
126
app/app/api/support/kofi/claim/start/route.ts
Normal 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 });
|
||||
}
|
||||
53
app/app/api/support/kofi/claim/verify/route.ts
Normal file
53
app/app/api/support/kofi/claim/verify/route.ts
Normal 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);
|
||||
}
|
||||
61
app/app/api/support/kofi/unlink/route.ts
Normal file
61
app/app/api/support/kofi/unlink/route.ts
Normal 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 });
|
||||
}
|
||||
186
app/app/api/webhooks/kofi/route.ts
Normal file
186
app/app/api/webhooks/kofi/route.ts
Normal 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 });
|
||||
}
|
||||
30
app/app/auth/sign-in/page.tsx
Normal file
30
app/app/auth/sign-in/page.tsx
Normal 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} />;
|
||||
}
|
||||
138
app/app/auth/sign-in/sign-in.tsx
Normal file
138
app/app/auth/sign-in/sign-in.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
app/app/auth/sign-up/page.tsx
Normal file
22
app/app/auth/sign-up/page.tsx
Normal 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} />;
|
||||
}
|
||||
175
app/app/auth/sign-up/sign-up.tsx
Normal file
175
app/app/auth/sign-up/sign-up.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
app/app/lasers/[id]/LaserDetailsClient.tsx
Normal file
59
app/app/lasers/[id]/LaserDetailsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
app/app/lasers/[id]/page.tsx
Normal file
82
app/app/lasers/[id]/page.tsx
Normal 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
276
app/app/lasers/page.tsx
Normal 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
25
app/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
2
app/app/materials/materials-coatings/[id]/page.tsx
Normal file
2
app/app/materials/materials-coatings/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/materials/materials-coatings/[id]/page.tsx
|
||||
export { default } from "./materials-coatings";
|
||||
146
app/app/materials/materials-coatings/page.tsx
Normal file
146
app/app/materials/materials-coatings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
app/app/materials/materials/[id]/materials.tsx
Normal file
60
app/app/materials/materials/[id]/materials.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
2
app/app/materials/materials/[id]/page.tsx
Normal file
2
app/app/materials/materials/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/materials/materials/[id]/page.tsx
|
||||
export { default } from "./materials";
|
||||
161
app/app/materials/materials/page.tsx
Normal file
161
app/app/materials/materials/page.tsx
Normal 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
49
app/app/page.tsx
Normal 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 projects—all 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>
|
||||
);
|
||||
}
|
||||
128
app/app/portal/account/AccountPanel.tsx
Normal file
128
app/app/portal/account/AccountPanel.tsx
Normal 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 can’t 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>
|
||||
);
|
||||
}
|
||||
16
app/app/portal/account/page.tsx
Normal file
16
app/app/portal/account/page.tsx
Normal 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 />;
|
||||
}
|
||||
16
app/app/portal/buying-guide/page.tsx
Normal file
16
app/app/portal/buying-guide/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
app/app/portal/laser-settings/Client.tsx
Normal file
58
app/app/portal/laser-settings/Client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/laser-settings/page.tsx
Normal file
8
app/app/portal/laser-settings/page.tsx
Normal 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 />;
|
||||
}
|
||||
38
app/app/portal/laser-sources/Client.tsx
Normal file
38
app/app/portal/laser-sources/Client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/laser-sources/page.tsx
Normal file
8
app/app/portal/laser-sources/page.tsx
Normal 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
30
app/app/portal/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
app/app/portal/materials/Client.tsx
Normal file
57
app/app/portal/materials/Client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/materials/page.tsx
Normal file
8
app/app/portal/materials/page.tsx
Normal 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 />;
|
||||
}
|
||||
248
app/app/portal/my-settings/page.tsx
Normal file
248
app/app/portal/my-settings/page.tsx
Normal 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">
|
||||
Couldn’t 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
275
app/app/portal/page.tsx
Normal 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 help—pick 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">We’d 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 problem—here’s 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>
|
||||
);
|
||||
}
|
||||
38
app/app/portal/projects/Client.tsx
Normal file
38
app/app/portal/projects/Client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/projects/page.tsx
Normal file
8
app/app/portal/projects/page.tsx
Normal 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 />;
|
||||
}
|
||||
46
app/app/portal/rigs/page.tsx
Normal file
46
app/app/portal/rigs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/app/portal/utilities/Client.tsx
Normal file
19
app/app/portal/utilities/Client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/utilities/page.tsx
Normal file
8
app/app/portal/utilities/page.tsx
Normal 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 />;
|
||||
}
|
||||
2
app/app/projects/[id]/page.tsx
Normal file
2
app/app/projects/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/projects/[id]/page.tsx
|
||||
export { default } from "./projects";
|
||||
153
app/app/projects/[id]/projects.tsx
Normal file
153
app/app/projects/[id]/projects.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
app/app/projects/layout.tsx
Normal file
4
app/app/projects/layout.tsx
Normal 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
364
app/app/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
372
app/app/rigs/RigBuilderClient.tsx
Normal file
372
app/app/rigs/RigBuilderClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/app/rigs/RigBuilderServer.tsx
Normal file
43
app/app/rigs/RigBuilderServer.tsx
Normal 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} />;
|
||||
}
|
||||
89
app/app/rigs/RigsListClient.tsx
Normal file
89
app/app/rigs/RigsListClient.tsx
Normal 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
30
app/app/rigs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
368
app/app/settings/co2-gantry/[id]/co2-gantry.tsx
Normal file
368
app/app/settings/co2-gantry/[id]/co2-gantry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
app/app/settings/co2-gantry/[id]/page.tsx
Normal file
2
app/app/settings/co2-gantry/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/settings/co2-gantry/[id]/page.tsx
|
||||
export { default } from "./co2-gantry";
|
||||
4
app/app/settings/co2-gantry/layout.tsx
Normal file
4
app/app/settings/co2-gantry/layout.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Suspense } from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
312
app/app/settings/co2-gantry/page.tsx
Normal file
312
app/app/settings/co2-gantry/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
402
app/app/settings/fiber/[id]/fiber.tsx
Normal file
402
app/app/settings/fiber/[id]/fiber.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
app/app/settings/fiber/[id]/page.tsx
Normal file
2
app/app/settings/fiber/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/settings/fiber/[id]/page.tsx
|
||||
export { default } from "./fiber";
|
||||
4
app/app/settings/fiber/layout.tsx
Normal file
4
app/app/settings/fiber/layout.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Suspense } from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
315
app/app/settings/fiber/page.tsx
Normal file
315
app/app/settings/fiber/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
app/app/settings/uv/[id]/page.tsx
Normal file
2
app/app/settings/uv/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/settings/uv/[id]/page.tsx
|
||||
export { default } from "./uv";
|
||||
365
app/app/settings/uv/[id]/uv.tsx
Normal file
365
app/app/settings/uv/[id]/uv.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
app/app/settings/uv/layout.tsx
Normal file
4
app/app/settings/uv/layout.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Suspense } from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
278
app/app/settings/uv/page.tsx
Normal file
278
app/app/settings/uv/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
app/app/styles/globals.css
Normal file
66
app/app/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
20
app/app/submit/settings/page.tsx
Normal file
20
app/app/submit/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
72
app/app/submit/settings/success/page.tsx
Normal file
72
app/app/submit/settings/success/page.tsx
Normal 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
21
app/components.json
Normal 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"
|
||||
}
|
||||
47
app/components/PortalTabs.tsx
Normal file
47
app/components/PortalTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
app/components/SignOutButton.tsx
Normal file
53
app/components/SignOutButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
app/components/account/AvatarUploader.tsx
Normal file
86
app/components/account/AvatarUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
app/components/account/ConfirmIdentity.tsx
Normal file
63
app/components/account/ConfirmIdentity.tsx
Normal 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 it’s 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 it’s 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
app/components/account/ConnectKofi.tsx
Normal file
137
app/components/account/ConnectKofi.tsx
Normal 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 don’t have any Ko-fi records for that email yet. If you’re sure it’s 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. We’ll send a one-time verification link to confirm it’s 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 don’t see a badge yet, it’ll appear the next time a Ko-fi payment webhook arrives (or after backfill).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
app/components/account/LinkStatus.tsx
Normal file
28
app/components/account/LinkStatus.tsx
Normal 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." : "Couldn’t verify that Ko-fi link. Please try again."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
app/components/account/PasswordChange.tsx
Normal file
148
app/components/account/PasswordChange.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
app/components/account/ProfileEditor.tsx
Normal file
211
app/components/account/ProfileEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
app/components/account/SupporterBadges.tsx
Normal file
111
app/components/account/SupporterBadges.tsx
Normal 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">
|
||||
Couldn’t 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>
|
||||
);
|
||||
}
|
||||
332
app/components/buying-guide/BuyingGuideList.tsx
Normal file
332
app/components/buying-guide/BuyingGuideList.tsx
Normal 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 you’re 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>
|
||||
);
|
||||
}
|
||||
188
app/components/buying-guide/BuyingGuideProduct.tsx
Normal file
188
app/components/buying-guide/BuyingGuideProduct.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
256
app/components/buying-guide/BuyingGuideProductClient.tsx
Normal file
256
app/components/buying-guide/BuyingGuideProductClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
app/components/buying-guide/LaserFinderPanel.tsx
Normal file
322
app/components/buying-guide/LaserFinderPanel.tsx
Normal 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 we’ll suggest the best laser <em>types</em> for your work
|
||||
with clear use-cases, materials, and cautions. No product pitches—just 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 (> 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>
|
||||
);
|
||||
}
|
||||
12
app/components/buying-guide/dx.ts
Normal file
12
app/components/buying-guide/dx.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue