Compare commits

..

12 commits
main ... prod

25 changed files with 355 additions and 22479 deletions

View file

@ -2,17 +2,14 @@
# Public (used by client-side dropdown fetches) # Public (used by client-side dropdown fetches)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net
APP_ORIGIN=https://beta.makearmy.io
KOFI_VERIFY_TOKEN=baa0aa53-7269-4119-a94f-e1380383e05e
BG_BYE_UPSTREAM=http://bgbye:7001/remove_background/ BG_BYE_UPSTREAM=http://bgbye:7001/remove_background/
FILES_ROOT=/files FILES_ROOT=/app/files
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Server-side Directus # Server-side Directus
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
DIRECTUS_URL=https://forms.lasereverything.net DIRECTUS_URL=https://forms.lasereverything.net
DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7 DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7
DIRECTUS_TOKEN_ADMIN_SUPPORTER=tvd64Ex5OWLEdH8EEM0rjH-gM1p-ZwfY
DIRECTUS_DEFAULT_ROLE=296a28bc-60ab-4251-8bef-27f6dfb67948 DIRECTUS_DEFAULT_ROLE=296a28bc-60ab-4251-8bef-27f6dfb67948
DIRECTUS_ROLE_MEMBER_NAME=Users DIRECTUS_ROLE_MEMBER_NAME=Users
@ -45,14 +42,3 @@ DX_FOLDER_UV_SCREENS=a84f54b1-0e92-4ea6-8fbe-37a3a74bd49c
DX_FOLDER_PROJECTS_FILES=f264f066-5b38-4335-bb10-5b014bfa62cb DX_FOLDER_PROJECTS_FILES=f264f066-5b38-4335-bb10-5b014bfa62cb
DX_FOLDER_PROJECTS_IMAGES=da11b876-2ede-4e19-ad3a-76fc9db449a8 DX_FOLDER_PROJECTS_IMAGES=da11b876-2ede-4e19-ad3a-76fc9db449a8
DX_FOLDER_PROJECTS_INSTRUCTIONS=905a4259-0c8e-489b-b810-c27186a2f266 DX_FOLDER_PROJECTS_INSTRUCTIONS=905a4259-0c8e-489b-b810-c27186a2f266
# ─────────────────────────────────────────────
# Mail Server Settings (SMTP)
# ─────────────────────────────────────────────
SMTP_HOST=mail.arrmail.net
SMTP_PORT=465
SMTP_USER=noreply@makearmy.io
SMTP_PASS=TZhXn4yQQ92XEf
SMTP_SECURE=true
EMAIL_FROM=MakeArmy Support <noreply@makearmy.io>

7
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,7 +41,7 @@ export default async function HomePage({
</section> </section>
<section className="mt-8 text-center text-xs text-muted-foreground"> <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>This is the production 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> <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> </section>
</main> </main>

View file

@ -0,0 +1,62 @@
// app/portal/account/AccountPanel.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 AccountPanel() {
const [me, setMe] = useState<Me | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
(async () => {
try {
setErr(null);
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();
if (alive) setMe(j);
} catch (e: any) {
if (alive) setErr(e?.message || "Failed to load account");
} finally {
if (alive) setLoading(false);
}
})();
return () => { alive = false; };
}, []);
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;
return (
<div className="space-y-6">
<div className="rounded-md border p-4">
<h2 className="text-lg font-semibold mb-2">Account</h2>
<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>
<p className="mt-3 text-xs opacity-70">Usernames cant be changed.</p>
</div>
{/* Place your edit forms here; they should only call the update APIs on submit,
never during initial render. For password/avatar we can prompt for password
inline or send to /auth/sign-in?reauth=1&next=/portal/account#security. */}
</div>
);
}

View file

@ -2,14 +2,9 @@
"use client"; "use client";
import { useEffect, useMemo, useState, useCallback } from "react"; import { useEffect, useMemo, useState, useCallback } from "react";
import ProfileEditor from "@/components/account/ProfileEditor";
// Use relative paths to avoid alias resolution issues in this route import PasswordChange from "@/components/account/PasswordChange";
import ProfileEditor from "../../../components/account/ProfileEditor"; import AvatarUploader from "@/components/account/AvatarUploader";
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 Avatar = { id: string; filename_download?: string } | null;
type Me = { type Me = {
@ -74,8 +69,6 @@ export default function AccountPanel() {
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-md border p-4"> <div className="rounded-md border p-4">
<h2 className="text-lg font-semibold mb-2">Account</h2> <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="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"> <div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
@ -111,17 +104,19 @@ export default function AccountPanel() {
<div>{me.location || "—"}</div> <div>{me.location || "—"}</div>
</div> </div>
</div> </div>
{/* Badges (derive-on-read, no cron) */}
<SupporterBadges email={me.email ?? undefined} userId={me.id} />
</div> </div>
{/* Link Ko-fi */}
<ConnectKofi email={me.email} userId={me.id} />
{/* Editable sections */} {/* Editable sections */}
<AvatarUploader avatarId={me.avatar?.id || null} onUpdated={refetchMe} /> <AvatarUploader
<ProfileEditor me={me} onUpdated={refetchMe} /> avatarId={me.avatar?.id || null}
onUpdated={refetchMe}
/>
<ProfileEditor
me={me}
onUpdated={refetchMe}
/>
<PasswordChange /> <PasswordChange />
</div> </div>
); );

Binary file not shown.

View file

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

View file

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

View file

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

View file

@ -19,9 +19,10 @@ const BackgroundRemoverPanel = dynamic(
() => import("@/components/utilities/BackgroundRemoverPanel"), () => import("@/components/utilities/BackgroundRemoverPanel"),
{ ssr: false } { ssr: false }
); );
const SVGNestPanel = dynamic(() => import("@/components/utilities/SVGNestPanel"), { const SVGNestPanel = dynamic(
ssr: false, () => import("@/components/utilities/SVGNestPanel"),
}); { ssr: false }
);
const LaserToolkitSwitcher = dynamic( const LaserToolkitSwitcher = dynamic(
() => import("@/components/portal/LaserToolkitSwitcher"), () => import("@/components/portal/LaserToolkitSwitcher"),
{ ssr: false } { ssr: false }
@ -33,99 +34,33 @@ const FileBrowserPanel = dynamic(
); );
const ITEMS: Item[] = [ const ITEMS: Item[] = [
{ { key: "laser-toolkit", label: "Laser Toolkit", note: "convert laser settings, interval and more", icon: "toolkit.png", component: LaserToolkitSwitcher, href: "https://makearmy.io/laser-toolkit" },
key: "laser-toolkit", { key: "files", label: "File Server", note: "download from our file explorer", icon: "fs.png", component: FileBrowserPanel, href: "https://makearmy.io/files" },
label: "Laser Toolkit", { key: "svgnest", label: "SVGnest", note: "automatically nests parts and exports svg", icon: "nest.png", component: SVGNestPanel, href: "https://makearmy.io/svgnest" },
note: "convert laser settings, interval and more", { key: "background-remover", label: "BG Remover", note: "open source background remover", icon: "bgrm.png", component: BackgroundRemoverPanel, href: "https://makearmy.io/background-remover" },
icon: "toolkit.png", { key: "picsur", label: "Picsur", note: "Simple Image Host", icon: "picsur.png", href: "https://images.makearmy.io" },
component: LaserToolkitSwitcher, { key: "privatebin", label: "PrivateBin", note: "Encrypted internet clipboard", icon: "privatebin.png", href: "https://paste.makearmy.io/" },
href: "/laser-toolkit", { key: "forgejo", label: "Forgejo", note: "git for our community members", icon: "forge.png", href: "https://forge.makearmy.io" },
},
{
key: "files",
label: "File Server",
note: "download from our file explorer",
icon: "fs.png",
component: FileBrowserPanel,
href: "/files",
},
{
key: "svgnest",
label: "SVGnest",
note: "automatically nests parts and exports svg",
icon: "nest.png",
component: SVGNestPanel,
href: "/svgnest",
},
{
key: "background-remover",
label: "BG Remover",
note: "open source background remover",
icon: "bgrm.png",
component: BackgroundRemoverPanel,
href: "/background-remover",
},
// These stay on makearmy (external services)
{
key: "picsur",
label: "Picsur",
note: "Simple Image Host",
icon: "picsur.png",
href: "https://images.makearmy.io",
},
{
key: "privatebin",
label: "PrivateBin",
note: "Encrypted internet clipboard",
icon: "privatebin.png",
href: "https://paste.makearmy.io/",
},
{
key: "forgejo",
label: "Forgejo",
note: "git for our community members",
icon: "forge.png",
href: "https://forge.makearmy.io",
},
]; ];
function isAbsoluteUrl(href: string) { function isExternal(urlStr: string | undefined) {
return /^https?:\/\//i.test(href); if (!urlStr) return false;
}
/**
* External if it's an absolute URL and NOT same-origin as the current site.
* Relative paths are always internal.
*/
function isExternalHref(href?: string) {
if (!href) return false;
if (!isAbsoluteUrl(href)) return false;
try { try {
const u = new URL(href); const u = new URL(urlStr);
if (typeof window === "undefined") return true; return u.hostname !== "makearmy.io";
return u.origin !== window.location.origin;
} catch { } catch {
return true; return true;
} }
} }
/** function toOnsitePath(urlStr: string): string {
* If href is absolute AND same-origin, convert it to a site-relative path.
* Otherwise return as-is.
*/
function toSameOriginPath(href: string) {
if (!isAbsoluteUrl(href)) return href;
try { try {
const u = new URL(href); const u = new URL(urlStr);
if (typeof window === "undefined") return href; if (u.hostname === "makearmy.io") {
if (u.origin === window.location.origin) {
return `${u.pathname}${u.search}${u.hash}`; return `${u.pathname}${u.search}${u.hash}`;
} }
} catch {} } catch {}
return href; return urlStr;
} }
function Panel({ item }: { item: Item }) { function Panel({ item }: { item: Item }) {
@ -139,25 +74,18 @@ function Panel({ item }: { item: Item }) {
); );
} }
const href = item.href || "/"; const external = isExternal(item.href);
const external = isExternalHref(href);
if (external) { if (external) {
return ( return (
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<a <a href={item.href} target="_blank" rel="noopener noreferrer" className="underline">
href={href}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Open {item.label} Open {item.label}
</a> </a>
</div> </div>
); );
} }
const src = toSameOriginPath(href); const src = toOnsitePath(item.href || "/");
return ( return (
<iframe <iframe
key={src} key={src}
@ -177,11 +105,11 @@ export default function UtilitySwitcher() {
const activeKey = useMemo(() => { const activeKey = useMemo(() => {
const t = (sp.get("t") || ITEMS[0].key).toLowerCase(); const t = (sp.get("t") || ITEMS[0].key).toLowerCase();
return ITEMS.some((i) => i.key === t) ? t : ITEMS[0].key; return ITEMS.some(i => i.key === t) ? t : ITEMS[0].key;
}, [sp]); }, [sp]);
const activeItem = useMemo( const activeItem = useMemo(
() => ITEMS.find((i) => i.key === activeKey) || ITEMS[0], () => ITEMS.find(i => i.key === activeKey) || ITEMS[0],
[activeKey] [activeKey]
); );
@ -191,25 +119,22 @@ export default function UtilitySwitcher() {
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false }); router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
} }
/**
* Optional auto-open behavior for external tools when selected via URL (?t=...).
* RECOMMENDED: keep this off to avoid popup blockers / surprise tabs.
*/
useEffect(() => { useEffect(() => {
const item = activeItem; const item = activeItem;
if (!item?.href) return; if (!item) return;
if (item.component) return; if (item.component) return;
if (!isExternalHref(item.href)) return; const external = isExternal(item.href);
if (!external) return;
if (openedRef.current === item.key) return; if (openedRef.current === item.key) return;
openedRef.current = item.key; openedRef.current = item.key;
const AUTO_OPEN_ON_FIRST_PAINT = false; const AUTO_OPEN_ON_FIRST_PAINT = true;
if (AUTO_OPEN_ON_FIRST_PAINT || !firstPaint) { if (AUTO_OPEN_ON_FIRST_PAINT || !firstPaint) {
window.open(item.href, "_blank", "noopener,noreferrer"); window.open(item.href!, "_blank", "noopener,noreferrer");
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeItem?.key]); }, [activeItem?.key, activeItem?.href]);
useEffect(() => { useEffect(() => {
setFirstPaint(false); setFirstPaint(false);
@ -221,10 +146,9 @@ export default function UtilitySwitcher() {
<div className="mb-4 flex flex-wrap items-center gap-2"> <div className="mb-4 flex flex-wrap items-center gap-2">
{ITEMS.map((it) => { {ITEMS.map((it) => {
const isInline = Boolean(it.component); const isInline = Boolean(it.component);
const external = !isInline && isExternalHref(it.href); const external = !isInline && isExternal(it.href);
const iconSrc = it.icon ? `/images/utils/${it.icon}` : null; const iconSrc = it.icon ? `/images/utils/${it.icon}` : null;
const isActive = it.key === activeKey; const isActive = it.key === activeKey;
return ( return (
<button <button
key={it.key} key={it.key}
@ -236,9 +160,7 @@ export default function UtilitySwitcher() {
}} }}
className={cn( className={cn(
"flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition", "flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition",
isActive isActive ? "bg-primary text-primary-foreground" : "hover:bg-muted"
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)} )}
title={it.note || it.label} title={it.note || it.label}
> >
@ -255,9 +177,7 @@ export default function UtilitySwitcher() {
}} }}
/> />
) : null} ) : null}
<span className="truncate">{it.label}</span> <span className="truncate">{it.label}</span>
{!isInline && external && ( {!isInline && external && (
<span className="rounded bg-muted px-1 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground"> <span className="rounded bg-muted px-1 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
new tab new tab

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View file

@ -78,13 +78,9 @@ export default function BackgroundRemoverPage() {
const [sourceUrl, setSourceUrl] = useState<string | null>(null); const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
const [status, setStatus] = useState<Record<Canonical, Status>>(() => { const [status, setStatus] = useState<Record<Canonical, Status>>(
return Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as Record< () => Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as Record<Canonical, Status>
Canonical, );
Status
>;
});
const [results, setResults] = useState<ResultMap>({}); const [results, setResults] = useState<ResultMap>({});
const resultsRef = useRef<ResultMap>({}); const resultsRef = useRef<ResultMap>({});
useEffect(() => { useEffect(() => {
@ -186,9 +182,7 @@ export default function BackgroundRemoverPage() {
const runOne = async (key: Canonical) => { const runOne = async (key: Canonical) => {
// When GPU-safe is on, try progressively smaller long-edge previews. // When GPU-safe is on, try progressively smaller long-edge previews.
const sizes = gpuSafe const sizes = gpuSafe ? BATCH_SIZES : [Math.max(natural?.w || 0, natural?.h || 0) || 4096];
? BATCH_SIZES
: [Math.max(natural?.w || 0, natural?.h || 0) || 4096];
let lastErr: string | null = null; let lastErr: string | null = null;
const t0 = performance.now(); const t0 = performance.now();
@ -201,15 +195,10 @@ export default function BackgroundRemoverPage() {
fd.append("method", key); fd.append("method", key);
// CHANGED: call local proxy instead of a hardcoded service // CHANGED: call local proxy instead of a hardcoded service
const res = await fetch("/api/bgbye/process", { const res = await fetch("/api/bgbye/process", { method: "POST", body: fd });
method: "POST",
body: fd,
});
if (!res.ok) { if (!res.ok) {
const txt = await res.text().catch(() => ""); const txt = await res.text().catch(() => "");
const retryable = const retryable = /out of memory|onnxruntime|cuda|allocate|500/i.test(txt);
/out of memory|onnxruntime|cuda|allocate|500/i.test(txt);
if (gpuSafe && retryable) { if (gpuSafe && retryable) {
lastErr = txt || `HTTP ${res.status}`; lastErr = txt || `HTTP ${res.status}`;
continue; // try next smaller size continue; // try next smaller size
@ -272,16 +261,12 @@ export default function BackgroundRemoverPage() {
const f = e.dataTransfer.files?.[0]; const f = e.dataTransfer.files?.[0];
if (f) onPick(f); if (f) onPick(f);
}; };
const onSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const onSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0] ?? null; const f = e.target.files?.[0] ?? null;
onPick(f); onPick(f);
}; };
const aspect = useMemo( const aspect = useMemo(() => (!natural ? 16 / 9 : natural.w / natural.h || 16 / 9), [natural]);
() => (!natural ? 16 / 9 : natural.w / natural.h || 16 / 9),
[natural]
);
const updateByClientX = useCallback((clientX: number) => { const updateByClientX = useCallback((clientX: number) => {
const el = frameRef.current; const el = frameRef.current;
@ -290,7 +275,6 @@ export default function BackgroundRemoverPage() {
const pct = ((clientX - rect.left) / rect.width) * 100; const pct = ((clientX - rect.left) / rect.width) * 100;
setReveal(Math.min(100, Math.max(0, pct))); setReveal(Math.min(100, Math.max(0, pct)));
}, []); }, []);
const onMouseDown = (e: React.MouseEvent) => { const onMouseDown = (e: React.MouseEvent) => {
draggingRef.current = true; draggingRef.current = true;
updateByClientX(e.clientX); updateByClientX(e.clientX);
@ -300,7 +284,6 @@ export default function BackgroundRemoverPage() {
updateByClientX(e.clientX); updateByClientX(e.clientX);
}; };
const onMouseUp = () => (draggingRef.current = false); const onMouseUp = () => (draggingRef.current = false);
const onTouchStart = (e: React.TouchEvent) => { const onTouchStart = (e: React.TouchEvent) => {
draggingRef.current = true; draggingRef.current = true;
updateByClientX(e.touches[0].clientX); updateByClientX(e.touches[0].clientX);
@ -338,36 +321,24 @@ export default function BackgroundRemoverPage() {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
fd.append("method", active); fd.append("method", active);
// CHANGED: call local proxy instead of a hardcoded service // CHANGED: call local proxy instead of a hardcoded service
const res = await fetch("/api/bgbye/process", { method: "POST", body: fd }); const res = await fetch("/api/bgbye/process", { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const outBlob = await res.blob(); const outBlob = await res.blob();
const ms = performance.now() - t0; const ms = performance.now() - t0;
const previewBlob = await makePreview(outBlob); const previewBlob = await makePreview(outBlob);
const previewUrl = URL.createObjectURL(previewBlob); const previewUrl = URL.createObjectURL(previewBlob);
const prev = resultsRef.current[active]; const prev = resultsRef.current[active];
if (prev) revoke(prev.previewUrl); if (prev) revoke(prev.previewUrl);
setResults((r) => ({ ...r, [active]: { fullBlob: outBlob, previewUrl, bytes: outBlob.size, ms } }));
setResults((r) => ({
...r,
[active]: { fullBlob: outBlob, previewUrl, bytes: outBlob.size, ms },
}));
setStatus((s) => ({ ...s, [active]: "ok" })); setStatus((s) => ({ ...s, [active]: "ok" }));
} catch { } catch {
setStatus((s) => ({ ...s, [active]: "error" })); setStatus((s) => ({ ...s, [active]: "error" }));
} }
}, [file, active]); }, [file, active]);
const doneCount = useMemo( const doneCount = useMemo(() => METHODS.filter((m) => status[m.key] === "ok").length, [status]);
() => METHODS.filter((m) => status[m.key] === "ok").length, const pendingCount = useMemo(() => METHODS.filter((m) => status[m.key] === "pending").length, [status]);
[status]
);
const pendingCount = useMemo(
() => METHODS.filter((m) => status[m.key] === "pending").length,
[status]
);
function StatusDot({ s }: { s: Status }) { function StatusDot({ s }: { s: Status }) {
const cls = const cls =
@ -391,7 +362,7 @@ export default function BackgroundRemoverPage() {
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Background Remover</h1> <h1 className="text-2xl font-semibold">Background Remover</h1>
<a <a
href="/" href="https://makearmy.io"
className="px-3 py-1 rounded-md border border-zinc-700 hover:bg-zinc-800/60 text-sm" className="px-3 py-1 rounded-md border border-zinc-700 hover:bg-zinc-800/60 text-sm"
> >
Back to main Back to main
@ -408,12 +379,7 @@ export default function BackgroundRemoverPage() {
<div <div
ref={frameRef} ref={frameRef}
className="app-frame checkerboard relative w-full rounded-2xl shadow-inner" className="app-frame checkerboard relative w-full rounded-2xl shadow-inner"
style={{ style={{ aspectRatio: `${aspect}`, maxWidth: "1200px", maxHeight: "80vh", marginInline: "auto" }}
aspectRatio: `${aspect}`,
maxWidth: "1200px",
maxHeight: "80vh",
marginInline: "auto",
}}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={onDrop} onDrop={onDrop}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
@ -471,9 +437,7 @@ export default function BackgroundRemoverPage() {
{/* Divider & Thumb */} {/* Divider & Thumb */}
<div <div
className="slider-handle" className="slider-handle"
style={ style={{ "--reveal": `${Math.min(100, Math.max(0, reveal))}` } as React.CSSProperties}
{ "--reveal": `${Math.min(100, Math.max(0, reveal))}` } as React.CSSProperties
}
> >
<div className="slider-thumb"> <div className="slider-thumb">
<div className="w-1.5 h-4 bg-white/80 rounded" /> <div className="w-1.5 h-4 bg-white/80 rounded" />
@ -489,9 +453,7 @@ export default function BackgroundRemoverPage() {
<button <button
key={key} key={key}
className={`w-full justify-center px-3 py-2 rounded-md border flex items-center gap-2 ${ className={`w-full justify-center px-3 py-2 rounded-md border flex items-center gap-2 ${
active === key active === key ? "border-blue-400 bg-blue-500/20" : "border-zinc-700 hover:bg-zinc-800/60"
? "border-blue-400 bg-blue-500/20"
: "border-zinc-700 hover:bg-zinc-800/60"
}`} }`}
onClick={() => setActive(key)} onClick={() => setActive(key)}
disabled={!file} disabled={!file}
@ -517,20 +479,13 @@ export default function BackgroundRemoverPage() {
{/* GPU-safe toggle */} {/* GPU-safe toggle */}
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer select-none order-1"> <label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer select-none order-1">
<input <input type="checkbox" checked={gpuSafe} onChange={(e) => setGpuSafe(e.target.checked)} /> GPU-safe mode
type="checkbox"
checked={gpuSafe}
onChange={(e) => setGpuSafe(e.target.checked)}
/>{" "}
GPU-safe mode
</label> </label>
<div className="text-zinc-400 text-sm order-2"> <div className="text-zinc-400 text-sm order-2">
{file ? ( {file ? (
pendingCount > 0 ? ( pendingCount > 0 ? (
<span> <span>Processing {doneCount}/{METHODS.length} finished</span>
Processing {doneCount}/{METHODS.length} finished
</span>
) : doneCount > 0 ? ( ) : doneCount > 0 ? (
<span>Done: {doneCount} methods succeeded</span> <span>Done: {doneCount} methods succeeded</span>
) : ( ) : (
@ -561,13 +516,7 @@ export default function BackgroundRemoverPage() {
? "border-sky-600 bg-sky-600/20 hover:bg-sky-600/30" ? "border-sky-600 bg-sky-600/20 hover:bg-sky-600/30"
: "border-zinc-700 text-zinc-400 cursor-not-allowed" : "border-zinc-700 text-zinc-400 cursor-not-allowed"
}`} }`}
title={ title={!file ? "Select a file first" : !active ? "Choose a method" : "Render selected method at full resolution"}
!file
? "Select a file first"
: !active
? "Choose a method"
: "Render selected method at full resolution"
}
> >
Full-res render Full-res render
</button> </button>

View file

@ -1,4 +0,0 @@
"Name","Email","IsActive","SinceDateUTC","CurrentPledge","Total","PaymentProvider","Tier","DiscordUsername"
"Rhett Peterson","todaviaice213@hotmail.com","True","2025-04-30 04:17","3.00","18.00","PayPal","MakeArmy Recruit","stillice213_76057#0"
"lordkoka","lordkoka@gmail.com","True","2025-04-27 09:17","3.00","18.00","PayPal","MakeArmy Recruit","Not connected"
"RicK Porter","rickport73@gmail.com","True","2025-03-11 05:36","3.00","24.00","PayPal","MakeArmy Recruit","Not connected"
1 Name Email IsActive SinceDateUTC CurrentPledge Total PaymentProvider Tier DiscordUsername
2 Rhett Peterson todaviaice213@hotmail.com True 2025-04-30 04:17 3.00 18.00 PayPal MakeArmy Recruit stillice213_76057#0
3 lordkoka lordkoka@gmail.com True 2025-04-27 09:17 3.00 18.00 PayPal MakeArmy Recruit Not connected
4 RicK Porter rickport73@gmail.com True 2025-03-11 05:36 3.00 24.00 PayPal MakeArmy Recruit Not connected

View file

@ -1,109 +0,0 @@
// /lib/support/memberships.ts
export type MembershipRow = {
id: string | number;
provider: "kofi" | "patreon" | "mighty" | string;
status: "active" | "one_time" | "canceled" | "inactive" | string;
tier?: string | null;
started_at?: string | null;
renews_at?: string | null;
email?: string | null;
username?: string | null;
app_user?: string | null;
};
export type SupportBadge = {
provider: "kofi" | "patreon" | "mighty" | string;
active: boolean; // true if 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;
};
const DIRECTUS = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
/** Active if status==="active" and renews_at >= now */
function computeActive(row: MembershipRow): boolean {
if (row.status !== "active") return false;
if (!row.renews_at) return false;
const due = new Date(row.renews_at).getTime();
return Number.isFinite(due) && due >= Date.now();
}
function providerLabel(p: string) {
if (p === "kofi") return "Ko-fi";
if (p === "patreon") return "Patreon";
if (p === "mighty") return "Mighty";
return p[0]?.toUpperCase() + p.slice(1);
}
/** Convert a membership row → displayable badge */
function rowToBadge(row: MembershipRow): SupportBadge {
const active = computeActive(row);
const prov = providerLabel(row.provider);
let kind: SupportBadge["kind"] = "inactive";
let label = prov;
if (active) {
kind = "member";
label = row.tier ? `${prov}${row.tier}` : `${prov} Member`;
} else if (row.status === "one_time") {
kind = "one_time";
label = `${prov} Supporter`;
} else {
kind = "inactive";
label = `${prov} (inactive)`;
}
return {
provider: row.provider,
active,
kind,
label,
tier: row.tier ?? null,
renews_at: row.renews_at ?? null,
started_at: row.started_at ?? null,
};
}
/**
* Fetch all memberships for a user by app_user (preferred) or by email.
* Pass either/both: { userId?, email? }
*/
export async function fetchMembershipBadges(opts: { userId?: string; email?: string }): Promise<SupportBadge[]> {
const clauses: any[] = [{ provider: { _in: ["kofi", "patreon", "mighty"] } }];
if (opts.userId) clauses.push({ app_user: { _eq: opts.userId } });
if (opts.email) clauses.push({ email: { _eq: opts.email.toLowerCase() } });
// Build filter: provider IN (...) AND (app_user = userId OR email = email)
const filter = encodeURIComponent(
JSON.stringify({
_and: [
clauses[0],
{ _or: clauses.slice(1).length ? clauses.slice(1) : [{ id: { _neq: null } }] },
],
})
);
const res = await fetch(`${DIRECTUS}/items/user_memberships?filter=${filter}&limit=500`, { cache: "no-store" });
const json = await res.json().catch(() => ({} as any));
const rows: MembershipRow[] = json?.data || [];
const badges = rows.map(rowToBadge);
// Sort: active first, then provider name asc
badges.sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1;
return a.provider.localeCompare(b.provider);
});
return badges;
}
/** Quick boolean if any active membership exists */
export async function hasActiveMembership(opts: { userId?: string; email?: string }): Promise<boolean> {
const list = await fetchMembershipBadges(opts);
return list.some(b => b.kind === "member" && b.active);
}

View file

@ -6,29 +6,25 @@ import { NextResponse, NextRequest } from "next/server";
* Everything else is considered protected (including most /api/*). * Everything else is considered protected (including most /api/*).
*/ */
const PUBLIC_PAGES = new Set<string>([ const PUBLIC_PAGES = new Set<string>([
"/", // splash page is public "/", // splash page is public
"/auth/sign-in", "/auth/sign-in",
"/auth/sign-up", "/auth/sign-up",
]); ]);
/** /**
* API paths that are explicitly allowed without auth. * API paths that are explicitly allowed without auth.
* Keep this list tiny; add broad /api/webhooks to allow ALL webhook endpoints. * Keep this list tiny. If you don't need any public APIs, leave it empty.
*/ */
const PUBLIC_API_PREFIXES: string[] = [ const PUBLIC_API_PREFIXES: string[] = [
"/api/auth", // login/refresh/callback endpoints "/api/auth", // login/refresh/callback endpoints
"/api/files/list", // read-only file endpoints // 🔹 Allow the file server endpoints (read-only)
"/api/files/list",
"/api/files/raw", "/api/files/raw",
"/api/files/download", "/api/files/download",
"/api/webhooks", // allow ALL webhook endpoints
]; ];
/** Directus base (used to remotely validate the token after restarts). */ /** Directus base (used to remotely validate the token after restarts). */
const DIRECTUS = ( const DIRECTUS = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, "");
process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.DIRECTUS_URL ||
""
).replace(/\/$/, "");
type MapResult = { pathname: string; query?: Record<string, string> }; type MapResult = { pathname: string; query?: Record<string, string> };
@ -77,9 +73,11 @@ import { NextResponse, NextRequest } from "next/server";
const res = NextResponse.redirect(url); const res = NextResponse.redirect(url);
// Only clear auth markers in true re-auth scenarios
if (wantReauth) { if (wantReauth) {
res.cookies.set("ma_at", "", { maxAge: 0, path: "/" }); res.cookies.set("ma_at", "", { maxAge: 0, path: "/" });
res.cookies.set("ma_v", "", { maxAge: 0, path: "/" }); res.cookies.set("ma_v", "", { maxAge: 0, path: "/" }); // throttle marker
// res.cookies.set("ma_rt", "", { maxAge: 0, path: "/" }); // if you use refresh tokens
} }
return res; return res;
@ -89,12 +87,7 @@ import { NextResponse, NextRequest } from "next/server";
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
const { pathname } = url; const { pathname } = url;
// ── -1) Always allow ALL webhook endpoints // ── 0) Root must never redirect (no mapping, no gating).
if (pathname === "/api/webhooks" || pathname.startsWith("/api/webhooks/")) {
return NextResponse.next();
}
// ── 0) Root must never redirect
if (pathname === "/") return NextResponse.next(); if (pathname === "/") return NextResponse.next();
// ── 1) Legacy → Portal mapping (before auth gating) // ── 1) Legacy → Portal mapping (before auth gating)
@ -114,8 +107,7 @@ import { NextResponse, NextRequest } from "next/server";
const forceAuth = const forceAuth =
isAuthRoute && isAuthRoute &&
(url.searchParams.get("reauth") === "1" || (url.searchParams.get("reauth") === "1" || url.searchParams.get("force") === "1");
url.searchParams.get("force") === "1");
if (!token && isProtected) { if (!token && isProtected) {
return kickToSignIn(req, { reauth: false }); return kickToSignIn(req, { reauth: false });
@ -176,9 +168,10 @@ import { NextResponse, NextRequest } from "next/server";
function legacyMap(pathname: string): MapResult | null { function legacyMap(pathname: string): MapResult | null {
if (pathname === "/" || pathname.startsWith("/portal")) return null; if (pathname === "/" || pathname.startsWith("/portal")) return null;
// detail mappings elided for brevity…
const listRules: Array<[RegExp, MapResult]> = [ const listRules: Array<[RegExp, MapResult]> = [
[/^\/background-remover\/?$/i, { pathname: "/portal/utilities", query: { t: "background-remover" } }], [/^\/background-remover\/?$/i, { pathname: "/portal/utilities", query: { t: "background-remover" } }],
[/^\/svgnest\/?$/i, { pathname: "/portal/utilities", query: { t: "svgnest" } }],
[/^\/laser-toolkit\/?$/i, { pathname: "/portal/utilities", query: { t: "laser-toolkit" } }], [/^\/laser-toolkit\/?$/i, { pathname: "/portal/utilities", query: { t: "laser-toolkit" } }],
[/^\/files\/?$/i, { pathname: "/portal/utilities", query: { t: "files" } }], [/^\/files\/?$/i, { pathname: "/portal/utilities", query: { t: "files" } }],
[/^\/buying-guide\/?$/i, { pathname: "/portal/buying-guide" }], [/^\/buying-guide\/?$/i, { pathname: "/portal/buying-guide" }],
@ -186,11 +179,9 @@ import { NextResponse, NextRequest } from "next/server";
[/^\/projects\/?$/i, { pathname: "/portal/projects" }], [/^\/projects\/?$/i, { pathname: "/portal/projects" }],
[/^\/my\/rigs\/?$/i, { pathname: "/portal/rigs", query: { t: "my" } }], [/^\/my\/rigs\/?$/i, { pathname: "/portal/rigs", query: { t: "my" } }],
]; ];
for (const [re, dest] of listRules) { for (const [re, dest] of listRules) {
if (re.test(pathname)) return dest; if (re.test(pathname)) return dest;
} }
return null; return null;
} }
@ -211,11 +202,9 @@ import { NextResponse, NextRequest } from "next/server";
if (pathname.startsWith("/api/")) { if (pathname.startsWith("/api/")) {
return startsWithAny(pathname, PUBLIC_API_PREFIXES); return startsWithAny(pathname, PUBLIC_API_PREFIXES);
} }
return false; return false;
} }
// Match all except the usual static assets
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"],
}; };

161
package-lock.json generated
View file

@ -11,14 +11,15 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"csv-parse": "^6.1.0", "framer-motion": "12.23.24",
"lucide-react": "^0.507.0", "lucide-react": "^0.507.0",
"mime": "^4.0.7", "mime": "^4.0.7",
"next": "^15.3.2", "next": "^15.3.2",
"nodemailer": "^7.0.9",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.63.0", "react-hook-form": "^7.63.0",
@ -1311,6 +1312,53 @@
} }
} }
}, },
"node_modules/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": { "node_modules/@radix-ui/react-select": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
@ -1409,6 +1457,52 @@
} }
} }
}, },
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@ -1875,6 +1969,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==", "integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@ -2068,6 +2163,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001716", "caniuse-lite": "^1.0.30001716",
"electron-to-chromium": "^1.5.149", "electron-to-chromium": "^1.5.149",
@ -2350,12 +2446,6 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/csv-parse": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz",
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
"license": "MIT"
},
"node_modules/d3-array": { "node_modules/d3-array": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@ -2721,6 +2811,33 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3765,6 +3882,21 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3890,15 +4022,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -4063,6 +4186,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.8", "nanoid": "^3.3.8",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -4210,6 +4334,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -4219,6 +4344,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@ -4231,6 +4357,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz",
"integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },

View file

@ -10,14 +10,15 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"csv-parse": "^6.1.0", "framer-motion": "12.23.24",
"lucide-react": "^0.507.0", "lucide-react": "^0.507.0",
"mime": "^4.0.7", "mime": "^4.0.7",
"next": "^15.3.2", "next": "^15.3.2",
"nodemailer": "^7.0.9",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.63.0", "react-hook-form": "^7.63.0",

21132
schema.yaml

File diff suppressed because it is too large Load diff

View file

@ -1,224 +0,0 @@
// scripts/backfill-kofi.mjs
// Usage (inside container):
// node scripts/backfill-kofi.mjs --file /data/kofi.csv [--dry] [--concurrency=5]
//
// CSV expected headers (case-insensitive; unknown columns ignored):
// email, from_name, type, timestamp, is_subscription_payment, is_first_subscription_payment,
// tier_name, discord_userid, discord_username, amount, currency, kofi_transaction_id
//
// Upsert strategy: find existing where provider="kofi" and (email == email || external_user_id == email || external_user_id == discord_userid || external_user_id == from_name)
import fs from "node:fs";
import { parse } from "csv-parse/sync";
// --- envs from the running container ---
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER || "";
const COLLECTION = "user_memberships";
if (!DIRECTUS || !BOT_TOKEN) {
console.error("[backfill] Missing DIRECTUS_URL or DIRECTUS_TOKEN_ADMIN_SUPPORTER in env.");
process.exit(2);
}
// ---- args ----
const args = new Map(
process.argv.slice(2).flatMap((a) => {
if (!a.startsWith("--")) return [];
const [k, v = "true"] = a.replace(/^--/, "").split("=");
return [[k, v]];
})
);
const file = args.get("file");
const dry = args.get("dry") === "true" || args.has("dry");
const concurrency = Number(args.get("concurrency") || 5);
if (!file) {
console.error("Usage: node scripts/backfill-kofi.mjs --file /path/to/kofi.csv [--dry] [--concurrency=5]");
process.exit(1);
}
// ---- helpers ----
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function addOneMonthPlusOneDay(d) {
const nd = new Date(d);
const m = nd.getMonth();
nd.setMonth(m + 1);
// handle month overflow (e.g., Jan 31 -> Mar 2, fix by going last day of prev month)
if (nd.getMonth() !== ((m + 1) % 12)) nd.setDate(0);
nd.setDate(nd.getDate() + 1);
return nd;
}
function normBool(v) {
if (typeof v === "boolean") return v;
if (v == null) return false;
const s = String(v).trim().toLowerCase();
return s === "true" || s === "1" || s === "yes" || s === "y";
}
function lowerOrNull(v) {
const s = (v ?? "").toString().trim();
return s ? s.toLowerCase() : null;
}
function strOrNull(v) {
const s = (v ?? "").toString().trim();
return s || null;
}
async function upsertOne(rec) {
const email = lowerOrNull(rec.email);
const from_name = strOrNull(rec.from_name);
const discord_userid = strOrNull(rec.discord_userid);
const type = strOrNull(rec.type) || "";
const isSub = normBool(rec.is_subscription_payment) || type.toLowerCase().includes("subscription");
const ts = rec.timestamp ? new Date(rec.timestamp) : null;
const extId =
email ||
discord_userid ||
from_name ||
"unknown";
const started_at =
isSub && ts ? ts.toISOString() : null;
const renews_at =
isSub && ts ? addOneMonthPlusOneDay(ts).toISOString() : null;
const status = isSub ? "active" : "one_time";
const payload = {
provider: "kofi",
external_user_id: extId,
email,
username: from_name,
status,
tier: strOrNull(rec.tier_name),
started_at,
renews_at,
last_event_at: new Date().toISOString(),
// Keep a compact raw for backfill; webhook now stores full JSON in TEXT
raw: JSON.stringify({
src: "backfill",
type,
email,
from_name,
is_subscription_payment: isSub,
is_first_subscription_payment: normBool(rec.is_first_subscription_payment),
timestamp: rec.timestamp || null,
discord_userid: discord_userid || null,
discord_username: strOrNull(rec.discord_username),
amount: strOrNull(rec.amount),
currency: strOrNull(rec.currency),
kofi_transaction_id: strOrNull(rec.kofi_transaction_id),
tier_name: strOrNull(rec.tier_name),
}),
};
// Find existing (same criteria as webhook)
const filter = encodeURIComponent(
JSON.stringify({
_and: [{ provider: { _eq: "kofi" } }],
_or: [
...(email ? [{ email: { _eq: email } }, { external_user_id: { _eq: email } }] : []),
...(discord_userid ? [{ external_user_id: { _eq: discord_userid } }] : []),
...(from_name ? [{ external_user_id: { _eq: from_name } }] : []),
],
})
);
if (dry) {
return { dry: true, will: payload };
}
// read existing
const exRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
cache: "no-store",
});
if (!exRes.ok) {
const errText = await exRes.text().catch(() => "");
throw new Error(`directus_read_failed(${exRes.status}): ${errText}`);
}
const exJson = await exRes.json().catch(() => ({}));
const existing = exJson?.data?.[0] || null;
// For updates with no ts/isSub we won't clobber start/renew — mirror webhook behavior.
const body = { ...payload };
if (existing && (!isSub || !ts)) {
delete body.started_at;
delete body.renews_at;
}
const url = existing
? `${DIRECTUS}/items/${COLLECTION}/${existing.id}`
: `${DIRECTUS}/items/${COLLECTION}`;
const method = existing ? "PATCH" : "POST";
const wr = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BOT_TOKEN}`,
},
body: JSON.stringify(body),
});
if (!wr.ok) {
const t = await wr.text().catch(() => "");
throw new Error(`directus_write_failed(${wr.status}): ${t}`);
}
const wj = await wr.json().catch(() => ({}));
return { ok: true, method, id: wj?.data?.id || existing?.id || null };
}
async function run() {
const csvBuf = fs.readFileSync(file);
const records = parse(csvBuf, {
columns: true,
skip_empty_lines: true,
bom: true,
relax_column_count: true,
trim: true,
});
console.log(`[backfill] Loaded ${records.length} rows from ${file}`);
console.log(`[backfill] Directus=${DIRECTUS} collection=${COLLECTION} dry=${dry} concurrency=${concurrency}`);
let ok = 0, fail = 0;
let idx = 0;
const pool = new Array(concurrency).fill(0).map(async () => {
while (idx < records.length) {
const i = idx++;
const rec = records[i];
try {
const res = await upsertOne(rec);
if (res.dry) {
console.log(`[dry] row#${i + 1}: would upsert ext="${(res.will.external_user_id)}" email="${res.will.email}" status=${res.will.status}`);
} else {
console.log(`[ok] row#${i + 1}: ${res.method} id=${res.id}`);
}
ok++;
} catch (e) {
console.error(`[err] row#${i + 1}: ${(e && e.message) || e}`);
fail++;
// small backoff to avoid hammering if there's a transient issue
await sleep(150);
}
}
});
await Promise.all(pool);
console.log(`[backfill] done. ok=${ok} fail=${fail}`);
if (fail > 0) process.exitCode = 1;
}
run().catch((e) => {
console.error("[backfill] fatal:", e);
process.exit(1);
});