user auth bearer updates to multiple apis and scripts
This commit is contained in:
parent
eb1a97541e
commit
b54120d88e
4 changed files with 182 additions and 160 deletions
18
app/api/_lib/auth.ts
Normal file
18
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" } });
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,9 @@ import {
|
||||||
createProjectRow,
|
createProjectRow,
|
||||||
patchProject,
|
patchProject,
|
||||||
bytesFromMB,
|
bytesFromMB,
|
||||||
|
dxGET,
|
||||||
} from "@/lib/directus";
|
} from "@/lib/directus";
|
||||||
|
import { requireBearer } from "@/app/api/_lib/auth";
|
||||||
|
|
||||||
// Optional: tweak via env
|
// Optional: tweak via env
|
||||||
const MAX_MB = Number(process.env.FILE_MAX_MB || 25);
|
const MAX_MB = Number(process.env.FILE_MAX_MB || 25);
|
||||||
|
|
@ -32,127 +34,105 @@ export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const ip =
|
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "0.0.0.0";
|
||||||
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
||||||
"0.0.0.0";
|
|
||||||
if (!rateLimitOk(ip)) {
|
if (!rateLimitOk(ip)) {
|
||||||
return NextResponse.json({ error: "Rate limited" }, { status: 429 });
|
return NextResponse.json({ error: "Rate limited" }, { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NEW: enforce user auth
|
||||||
|
const bearer = requireBearer(req);
|
||||||
|
|
||||||
const ct = req.headers.get("content-type") || "";
|
const ct = req.headers.get("content-type") || "";
|
||||||
if (!ct.includes("multipart/form-data")) {
|
if (!ct.includes("multipart/form-data")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
|
||||||
{ error: "Expected multipart/form-data" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = await req.formData();
|
const form = await req.formData();
|
||||||
|
|
||||||
// Required read-side field names from your repo:
|
|
||||||
// title, body (markdown), uploader, category, tags[], p_image (file), p_files (M2M to files)
|
|
||||||
const title = String(form.get("title") || "").trim();
|
const title = String(form.get("title") || "").trim();
|
||||||
const uploader = String(form.get("uploader") || "").trim();
|
|
||||||
const category = String(form.get("category") || "").trim();
|
const category = String(form.get("category") || "").trim();
|
||||||
const body = String(form.get("body") || form.get("description") || "").trim();
|
const body = String(form.get("body") || form.get("description") || "").trim();
|
||||||
|
|
||||||
if (!title || !uploader || !body) {
|
if (!title || !body) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Missing required fields: title, body" }, { status: 400 });
|
||||||
{ error: "Missing required fields: title, uploader, body" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// tags: allow comma-separated string or JSON array
|
// Derive uploader from the authenticated user (ignore any spoofed form value)
|
||||||
let tags: string[] = [];
|
const me = await dxGET<any>("/users/me?fields=username,display_name,first_name,last_name,email", bearer);
|
||||||
const rawTags = form.get("tags");
|
const uploader =
|
||||||
if (typeof rawTags === "string" && rawTags.trim()) {
|
me?.display_name ||
|
||||||
try {
|
me?.username ||
|
||||||
// Accept JSON array
|
[me?.first_name, me?.last_name].filter(Boolean).join(" ") ||
|
||||||
const maybeArray = JSON.parse(rawTags);
|
me?.email ||
|
||||||
if (Array.isArray(maybeArray)) {
|
"user";
|
||||||
tags = maybeArray.map((t) => String(t).trim()).filter(Boolean);
|
|
||||||
} else {
|
// tags: allow comma-separated string or JSON array
|
||||||
// Fallback: comma list
|
let tags: string[] = [];
|
||||||
tags = rawTags
|
const rawTags = form.get("tags");
|
||||||
.split(",")
|
if (typeof rawTags === "string" && rawTags.trim()) {
|
||||||
.map((t) => t.trim())
|
try {
|
||||||
.filter(Boolean);
|
const maybeArray = JSON.parse(rawTags);
|
||||||
}
|
if (Array.isArray(maybeArray)) {
|
||||||
} catch {
|
tags = maybeArray.map((t) => String(t).trim()).filter(Boolean);
|
||||||
// Comma-separated
|
} else {
|
||||||
tags = rawTags
|
tags = rawTags.split(",").map((t) => t.trim()).filter(Boolean);
|
||||||
.split(",")
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
// Optional license (not shown on read pages, but harmless to store)
|
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; // input name="image"
|
|
||||||
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");
|
|
||||||
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");
|
|
||||||
attachIds.push(up.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Create the project row
|
|
||||||
const { data: created } = await createProjectRow({
|
|
||||||
title,
|
|
||||||
body, // you render `project.body` in detail page
|
|
||||||
uploader, // exact key used by your list/detail
|
|
||||||
category,
|
|
||||||
tags, // stored as array
|
|
||||||
...(license ? { license } : {}),
|
|
||||||
status: "pending",
|
|
||||||
submitted_via: "makearmy-app",
|
|
||||||
submitted_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) Patch hero image + M2M attachments in one go
|
|
||||||
// For M2M (p_files), Directus accepts nested objects to create junction rows
|
|
||||||
// e.g. [{ directus_files_id: "<file-id>" }, ...]
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, id: created.id });
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || "Unknown error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// app/api/submit/settings/route.ts
|
// app/api/submit/settings/route.ts
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { uploadFile, createSettingsItem, bytesFromMB } from "@/lib/directus";
|
import { uploadFile, createSettingsItem, bytesFromMB, dxGET } from "@/lib/directus";
|
||||||
|
import { requireBearer } from "@/app/api/_lib/auth";
|
||||||
|
|
||||||
/** ─────────────────────────────────────────────────────────────
|
/** ─────────────────────────────────────────────────────────────
|
||||||
* Accepts EITHER:
|
* Accepts EITHER:
|
||||||
|
|
@ -8,7 +9,7 @@ import { uploadFile, createSettingsItem, bytesFromMB } from "@/lib/directus";
|
||||||
* (photo/screen can be existing file ids on the body)
|
* (photo/screen can be existing file ids on the body)
|
||||||
* - multipart/form-data with:
|
* - multipart/form-data with:
|
||||||
* - payload = JSON string (same shape as JSON body)
|
* - payload = JSON string (same shape as JSON body)
|
||||||
* - photo = File (required)
|
* - photo = File (required if no photo id present)
|
||||||
* - screen = File (optional)
|
* - screen = File (optional)
|
||||||
*
|
*
|
||||||
* Targets (collections):
|
* Targets (collections):
|
||||||
|
|
@ -47,10 +48,6 @@ function num(v: any, fallback: number | null = null) {
|
||||||
return Number.isFinite(n) ? n : fallback;
|
return Number.isFinite(n) ? n : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bool(v: any) {
|
|
||||||
return !!v;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReadResult = {
|
type ReadResult = {
|
||||||
mode: "json" | "multipart";
|
mode: "json" | "multipart";
|
||||||
body: any;
|
body: any;
|
||||||
|
|
@ -64,7 +61,6 @@ async function readJsonOrMultipart(req: Request): Promise<ReadResult> {
|
||||||
if (ct.includes("multipart/form-data")) {
|
if (ct.includes("multipart/form-data")) {
|
||||||
const form = await (req as any).formData();
|
const form = await (req as any).formData();
|
||||||
|
|
||||||
// payload JSON (required for the structured fields)
|
|
||||||
const payloadRaw = String(form.get("payload") ?? "{}");
|
const payloadRaw = String(form.get("payload") ?? "{}");
|
||||||
let body: any = {};
|
let body: any = {};
|
||||||
try {
|
try {
|
||||||
|
|
@ -73,7 +69,6 @@ async function readJsonOrMultipart(req: Request): Promise<ReadResult> {
|
||||||
throw new Error("Invalid JSON in 'payload'");
|
throw new Error("Invalid JSON in 'payload'");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files (File or null)
|
|
||||||
const p = form.get("photo");
|
const p = form.get("photo");
|
||||||
const s = form.get("screen");
|
const s = form.get("screen");
|
||||||
const photoFile = p instanceof File && p.size > 0 ? (p as File) : null;
|
const photoFile = p instanceof File && p.size > 0 ? (p as File) : null;
|
||||||
|
|
@ -82,7 +77,6 @@ async function readJsonOrMultipart(req: Request): Promise<ReadResult> {
|
||||||
return { mode: "multipart", body, photoFile, screenFile };
|
return { mode: "multipart", body, photoFile, screenFile };
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON body
|
|
||||||
const body = await (req as any).json().catch(() => ({}));
|
const body = await (req as any).json().catch(() => ({}));
|
||||||
return { mode: "json", body, photoFile: null, screenFile: null };
|
return { mode: "json", body, photoFile: null, screenFile: null };
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +113,9 @@ export async function POST(req: Request) {
|
||||||
return NextResponse.json({ error: "Rate limited" }, { status: 429 });
|
return NextResponse.json({ error: "Rate limited" }, { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NEW: enforce user auth
|
||||||
|
const bearer = requireBearer(req);
|
||||||
|
|
||||||
const { mode, body, photoFile, screenFile } = await readJsonOrMultipart(req);
|
const { mode, body, photoFile, screenFile } = await readJsonOrMultipart(req);
|
||||||
|
|
||||||
const target: Target = body?.target;
|
const target: Target = body?.target;
|
||||||
|
|
@ -128,9 +125,16 @@ export async function POST(req: Request) {
|
||||||
|
|
||||||
// Required basics
|
// Required basics
|
||||||
const setting_title = String(body?.setting_title || "").trim();
|
const setting_title = String(body?.setting_title || "").trim();
|
||||||
const uploader = String(body?.uploader || "").trim();
|
|
||||||
if (!setting_title) return NextResponse.json({ error: "Missing required: setting_title" }, { status: 400 });
|
if (!setting_title) return NextResponse.json({ error: "Missing required: setting_title" }, { status: 400 });
|
||||||
if (!uploader) return NextResponse.json({ error: "Missing required: uploader" }, { status: 400 });
|
|
||||||
|
// Derive uploader from the authenticated user (ignore any spoofed value in body)
|
||||||
|
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";
|
||||||
|
|
||||||
// Relations & numerics
|
// Relations & numerics
|
||||||
const mat = body?.mat ?? null;
|
const mat = body?.mat ?? null;
|
||||||
|
|
@ -155,7 +159,7 @@ export async function POST(req: Request) {
|
||||||
if (!photo_id && photoFile) {
|
if (!photo_id && photoFile) {
|
||||||
if (photoFile.size > MAX_BYTES)
|
if (photoFile.size > MAX_BYTES)
|
||||||
return NextResponse.json({ error: `Photo exceeds ${MAX_MB} MB` }, { status: 400 });
|
return NextResponse.json({ error: `Photo exceeds ${MAX_MB} MB` }, { status: 400 });
|
||||||
const up = await uploadFile(photoFile, (photoFile as File).name, {
|
const up = await uploadFile(photoFile, (photoFile as File).name, bearer, {
|
||||||
folderNamePath: folderPathFor(target, "photo"),
|
folderNamePath: folderPathFor(target, "photo"),
|
||||||
title: setting_title,
|
title: setting_title,
|
||||||
});
|
});
|
||||||
|
|
@ -168,7 +172,7 @@ export async function POST(req: Request) {
|
||||||
if (!screen_id && screenFile) {
|
if (!screen_id && screenFile) {
|
||||||
if (screenFile.size > MAX_BYTES)
|
if (screenFile.size > MAX_BYTES)
|
||||||
return NextResponse.json({ error: `Screenshot exceeds ${MAX_MB} MB` }, { status: 400 });
|
return NextResponse.json({ error: `Screenshot exceeds ${MAX_MB} MB` }, { status: 400 });
|
||||||
const up = await uploadFile(screenFile, (screenFile as File).name, {
|
const up = await uploadFile(screenFile, (screenFile as File).name, bearer, {
|
||||||
folderNamePath: folderPathFor(target, "screen"),
|
folderNamePath: folderPathFor(target, "screen"),
|
||||||
title: `${setting_title} (screen)`,
|
title: `${setting_title} (screen)`,
|
||||||
});
|
});
|
||||||
|
|
@ -185,11 +189,11 @@ export async function POST(req: Request) {
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
setting_title,
|
setting_title,
|
||||||
uploader,
|
uploader, // ← server-derived from /users/me
|
||||||
setting_notes,
|
setting_notes,
|
||||||
|
|
||||||
submission_date: nowIso, // required by your schema
|
submission_date: nowIso, // required by your schema
|
||||||
last_modified_date: nowIso, // ← add this to satisfy required rule
|
last_modified_date: nowIso, // keep in sync
|
||||||
|
|
||||||
photo: photo_id,
|
photo: photo_id,
|
||||||
screen: screen_id ?? null,
|
screen: screen_id ?? null,
|
||||||
|
|
@ -217,11 +221,11 @@ export async function POST(req: Request) {
|
||||||
payload.repeat_all = repeat_all ?? null;
|
payload.repeat_all = repeat_all ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = await createSettingsItem(target, payload);
|
const { data } = await createSettingsItem(target, payload, bearer);
|
||||||
return NextResponse.json({ ok: true, id: data.id });
|
return NextResponse.json({ ok: true, id: data.id });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[submit/settings] error", err?.message || err);
|
console.error("[submit/settings] error", err?.message || err);
|
||||||
return NextResponse.json({ error: err?.message || "Unknown error" }, { status: 500 });
|
return NextResponse.json({ error: err?.message || "Unknown error" }, { status: err?.status ?? 500 });
|
||||||
} finally {
|
} finally {
|
||||||
const ms = Date.now() - started;
|
const ms = Date.now() - started;
|
||||||
if (ms) console.log(`[submit/settings] handled in ~${ms}ms`);
|
if (ms) console.log(`[submit/settings] handled in ~${ms}ms`);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,37 @@ type Me = {
|
||||||
|
|
||||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Local enums (no schema introspection)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
const FILL_TYPE_OPTIONS = [
|
||||||
|
{ label: "UniDirectional", value: "uni" },
|
||||||
|
{ label: "BiDirectional", value: "bi" },
|
||||||
|
{ label: "Offset Fill", value: "offset" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RASTER_TYPE_OPTIONS = [
|
||||||
|
{ label: "UniDirectional", value: "uni" },
|
||||||
|
{ label: "BiDirectional", value: "bi" },
|
||||||
|
{ label: "Offset Fill", value: "offset" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RASTER_DITHER_OPTIONS = [
|
||||||
|
{ label: "Threshold", value: "threshold" },
|
||||||
|
{ label: "Ordered", value: "ordered" },
|
||||||
|
{ label: "Atkinson", value: "atkinson" },
|
||||||
|
{ label: "Dither", value: "dither" },
|
||||||
|
{ label: "Stucki", value: "stucki" },
|
||||||
|
{ label: "Jarvis", value: "jarvis" },
|
||||||
|
{ label: "Newsprint", value: "newsprint" },
|
||||||
|
{ label: "Halftone", value: "halftone" },
|
||||||
|
{ label: "Sketch", value: "sketch" },
|
||||||
|
{ label: "Grayscale", value: "grayscale" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toOpts = (arr: { label: string; value: string }[]): Opt[] =>
|
||||||
|
arr.map((x) => ({ id: x.value, label: x.label }));
|
||||||
|
|
||||||
function shortId(s?: string) {
|
function shortId(s?: string) {
|
||||||
if (!s) return "";
|
if (!s) return "";
|
||||||
return s.length <= 12 ? s : `${s.slice(0, 8)}…${s.slice(-4)}`;
|
return s.length <= 12 ? s : `${s.slice(0, 8)}…${s.slice(-4)}`;
|
||||||
|
|
@ -108,25 +139,6 @@ function useOptions(path: string) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (rawPath === "repeater-choices") {
|
|
||||||
const group = params.get("group") || "";
|
|
||||||
const field = params.get("field") || "";
|
|
||||||
const collection = params.get("target") || "";
|
|
||||||
|
|
||||||
const proxy = `/api/directus/choices?collection=${encodeURIComponent(collection)}&group=${encodeURIComponent(group)}&field=${encodeURIComponent(field)}`;
|
|
||||||
const r = await fetch(proxy, { cache: "no-store" });
|
|
||||||
if (!r.ok) throw new Error(`Proxy ${r.status} fetching ${proxy}`);
|
|
||||||
const j = await r.json().catch(() => ({}));
|
|
||||||
const mapped: Opt[] = Array.isArray(j?.data) ? j.data : [];
|
|
||||||
|
|
||||||
if (alive) {
|
|
||||||
const needle = (q || "").trim().toLowerCase();
|
|
||||||
const filtered = needle ? mapped.filter((o) => o.label.toLowerCase().includes(needle)) : mapped;
|
|
||||||
setOpts(filtered);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// unknown path → empty
|
// unknown path → empty
|
||||||
setOpts([]);
|
setOpts([]);
|
||||||
|
|
@ -259,11 +271,11 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
|
||||||
fetch(`/api/auth/me`, { cache: "no-store", credentials: "include" })
|
// use our bearer-only API
|
||||||
|
fetch(`/api/me`, { cache: "no-store", credentials: "include" })
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
||||||
.then((j) => {
|
.then((j) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
// j is the user object directly (not wrapped in { data })
|
|
||||||
setMe(j || null);
|
setMe(j || null);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|
@ -276,10 +288,10 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const meLabel =
|
const meLabel =
|
||||||
|
me?.display_name?.trim() ||
|
||||||
|
[me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() ||
|
||||||
me?.username?.trim() ||
|
me?.username?.trim() ||
|
||||||
me?.email?.trim() ||
|
me?.email?.trim() ||
|
||||||
[me?.first_name, me?.last_name].filter(Boolean).join(" ").trim() ||
|
|
||||||
me?.display_name?.trim() ||
|
|
||||||
(me?.id ? `User ${me.id.slice(0, 8)}…${me.id.slice(-4)}` : "Unknown user");
|
(me?.id ? `User ${me.id.slice(0, 8)}…${me.id.slice(-4)}` : "Unknown user");
|
||||||
|
|
||||||
// Options
|
// Options
|
||||||
|
|
@ -293,10 +305,10 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
const srcs = useOptions(`laser_source?target=${typeForOptions}`);
|
const srcs = useOptions(`laser_source?target=${typeForOptions}`);
|
||||||
const lens = useOptions(`lens?target=${typeForOptions}`);
|
const lens = useOptions(`lens?target=${typeForOptions}`);
|
||||||
|
|
||||||
// Repeater choice options
|
// Repeater choice options (LOCAL now, no network)
|
||||||
const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`);
|
const fillType = { opts: toOpts(FILL_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} };
|
||||||
const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`);
|
const rasterType = { opts: toOpts(RASTER_TYPE_OPTIONS), loading: false, setQ: (_: string) => {} };
|
||||||
const rasterDither = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`);
|
const rasterDither = { opts: toOpts(RASTER_DITHER_OPTIONS), loading: false, setQ: (_: string) => {} };
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|
@ -361,7 +373,7 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
speed: num(r.speed),
|
speed: num(r.speed),
|
||||||
interval: num(r.interval),
|
interval: num(r.interval),
|
||||||
pass: num(r.pass),
|
pass: num(r.pass),
|
||||||
type: r.type || "",
|
type: r.type || "", // now driven by local enum
|
||||||
frequency: num(r.frequency),
|
frequency: num(r.frequency),
|
||||||
pulse: num(r.pulse),
|
pulse: num(r.pulse),
|
||||||
angle: num(r.angle),
|
angle: num(r.angle),
|
||||||
|
|
@ -390,8 +402,8 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
name: r.name || "",
|
name: r.name || "",
|
||||||
power: num(r.power),
|
power: num(r.power),
|
||||||
speed: num(r.speed),
|
speed: num(r.speed),
|
||||||
type: r.type || "",
|
type: r.type || "", // now driven by local enum
|
||||||
dither: r.dither || "",
|
dither: r.dither || "", // now driven by local enum
|
||||||
halftone_cell: num(r.halftone_cell),
|
halftone_cell: num(r.halftone_cell),
|
||||||
halftone_angle: num(r.halftone_angle),
|
halftone_angle: num(r.halftone_angle),
|
||||||
inversion: bool(r.inversion),
|
inversion: bool(r.inversion),
|
||||||
|
|
@ -651,7 +663,11 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
<fieldset className="border rounded p-3 space-y-2">
|
<fieldset className="border rounded p-3 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<legend className="font-semibold">Fill Settings</legend>
|
<legend className="font-semibold">Fill Settings</legend>
|
||||||
<button type="button" className="px-2 py-1 border rounded" onClick={() => fills.append({})}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-2 py-1 border rounded"
|
||||||
|
onClick={() => fills.append({ type: "uni" })} // default ensures value populated
|
||||||
|
>
|
||||||
+ Add
|
+ Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -663,8 +679,8 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
name={`fill_settings.${i}.type`}
|
name={`fill_settings.${i}.type`}
|
||||||
register={register}
|
register={register}
|
||||||
options={fillType.opts}
|
options={fillType.opts}
|
||||||
loading={fillType.loading}
|
loading={false}
|
||||||
onQuery={fillType.setQ}
|
onQuery={() => {}}
|
||||||
placeholder="Select type"
|
placeholder="Select type"
|
||||||
/>
|
/>
|
||||||
<input placeholder="Frequency (kHz)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.frequency`)} />
|
<input placeholder="Frequency (kHz)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`fill_settings.${i}.frequency`)} />
|
||||||
|
|
@ -726,7 +742,11 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
<fieldset className="border rounded p-3 space-y-2">
|
<fieldset className="border rounded p-3 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<legend className="font-semibold">Raster Settings</legend>
|
<legend className="font-semibold">Raster Settings</legend>
|
||||||
<button type="button" className="px-2 py-1 border rounded" onClick={() => rasters.append({})}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-2 py-1 border rounded"
|
||||||
|
onClick={() => rasters.append({ type: "uni", dither: "threshold" })} // defaults ensure values are set
|
||||||
|
>
|
||||||
+ Add
|
+ Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -740,8 +760,8 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
name={`raster_settings.${i}.type`}
|
name={`raster_settings.${i}.type`}
|
||||||
register={register}
|
register={register}
|
||||||
options={rasterType.opts}
|
options={rasterType.opts}
|
||||||
loading={rasterType.loading}
|
loading={false}
|
||||||
onQuery={rasterType.setQ}
|
onQuery={() => {}}
|
||||||
placeholder="Select type"
|
placeholder="Select type"
|
||||||
/>
|
/>
|
||||||
<FilterableSelect
|
<FilterableSelect
|
||||||
|
|
@ -749,8 +769,8 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ
|
||||||
name={`raster_settings.${i}.dither`}
|
name={`raster_settings.${i}.dither`}
|
||||||
register={register}
|
register={register}
|
||||||
options={rasterDither.opts}
|
options={rasterDither.opts}
|
||||||
loading={rasterDither.loading}
|
loading={false}
|
||||||
onQuery={rasterDither.setQ}
|
onQuery={() => {}}
|
||||||
placeholder="Select dither"
|
placeholder="Select dither"
|
||||||
/>
|
/>
|
||||||
<input placeholder="Power (%)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.power`)} />
|
<input placeholder="Power (%)" type="number" step="0.1" className="border rounded px-2 py-1" {...register(`raster_settings.${i}.power`)} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue