@gigamusic/admin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/package.json +69 -0
- package/src/client.ts +18 -0
- package/src/components/AdminLoginForm.tsx +63 -0
- package/src/components/AdminNav.tsx +50 -0
- package/src/components/ReleaseForm.tsx +556 -0
- package/src/handlers/auth.ts +58 -0
- package/src/handlers/links.ts +117 -0
- package/src/handlers/orders.ts +21 -0
- package/src/handlers/releases.ts +209 -0
- package/src/handlers/settings.ts +54 -0
- package/src/handlers/upload.ts +286 -0
- package/src/index.ts +24 -0
- package/src/lib/rate-limit.ts +31 -0
- package/src/lib/responses.ts +19 -0
- package/src/lib/session.ts +89 -0
- package/src/lib/types.ts +45 -0
- package/src/pages/AdminEditReleasePage.tsx +39 -0
- package/src/pages/AdminHomePage.tsx +38 -0
- package/src/pages/AdminLayout.tsx +20 -0
- package/src/pages/AdminLinksPage.tsx +187 -0
- package/src/pages/AdminLoginPage.tsx +11 -0
- package/src/pages/AdminNewReleasePage.tsx +12 -0
- package/src/pages/AdminOrdersPage.tsx +127 -0
- package/src/pages/AdminReleasesPage.tsx +87 -0
- package/src/pages/AdminSettingsPage.tsx +84 -0
- package/src/server.ts +36 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Default entry — page components + shared form components. Server-only
|
|
2
|
+
// handler factories live in `./server`; the consumer imports them separately
|
|
3
|
+
// to keep "use client" boundaries clean.
|
|
4
|
+
|
|
5
|
+
export { AdminLayout } from "./pages/AdminLayout";
|
|
6
|
+
export { AdminHomePage } from "./pages/AdminHomePage";
|
|
7
|
+
export { AdminLoginPage } from "./pages/AdminLoginPage";
|
|
8
|
+
export { AdminReleasesPage } from "./pages/AdminReleasesPage";
|
|
9
|
+
export { AdminNewReleasePage } from "./pages/AdminNewReleasePage";
|
|
10
|
+
export { AdminEditReleasePage } from "./pages/AdminEditReleasePage";
|
|
11
|
+
export { AdminLinksPage } from "./pages/AdminLinksPage";
|
|
12
|
+
export { AdminSettingsPage } from "./pages/AdminSettingsPage";
|
|
13
|
+
export { AdminOrdersPage } from "./pages/AdminOrdersPage";
|
|
14
|
+
|
|
15
|
+
export { ReleaseForm } from "./components/ReleaseForm";
|
|
16
|
+
export type { ReleaseFormProps } from "./components/ReleaseForm";
|
|
17
|
+
export { AdminLoginForm } from "./components/AdminLoginForm";
|
|
18
|
+
export { AdminNav } from "./components/AdminNav";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
AdminDeps,
|
|
22
|
+
AdminLogger,
|
|
23
|
+
RouteHandler,
|
|
24
|
+
} from "./lib/types";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Queries } from "@gigamusic/db";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract a best-effort client IP for rate-limit bucketing. Honours common
|
|
5
|
+
* Vercel/Cloudflare forward headers and falls back to a constant so the
|
|
6
|
+
* handler still rate-limits the entire route when no IP is available.
|
|
7
|
+
*/
|
|
8
|
+
export function clientIp(req: Request): string {
|
|
9
|
+
const forwarded = req.headers.get("x-forwarded-for");
|
|
10
|
+
if (forwarded) {
|
|
11
|
+
const first = forwarded.split(",")[0]?.trim();
|
|
12
|
+
if (first) return first;
|
|
13
|
+
}
|
|
14
|
+
const real = req.headers.get("x-real-ip");
|
|
15
|
+
if (real) return real;
|
|
16
|
+
return "unknown";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LOGIN_MAX_ATTEMPTS = 10;
|
|
20
|
+
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
/** Consume one slot in the admin-login bucket for `ip`. Returns false when over the limit. */
|
|
23
|
+
export function consumeAdminLoginAttempt(
|
|
24
|
+
queries: Queries,
|
|
25
|
+
ip: string,
|
|
26
|
+
): Promise<{ ok: boolean; remaining: number; resetAt: Date }> {
|
|
27
|
+
return queries.consumeRateLimit(`admin-login:${ip}`, {
|
|
28
|
+
max: LOGIN_MAX_ATTEMPTS,
|
|
29
|
+
windowMs: LOGIN_WINDOW_MS,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny JSON response helpers. Avoids the package depending on `NextResponse`
|
|
3
|
+
* — every handler factory returns plain `Response` objects, which Next.js's
|
|
4
|
+
* App Router accepts identically.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function json(body: unknown, init?: ResponseInit): Response {
|
|
8
|
+
return new Response(JSON.stringify(body), {
|
|
9
|
+
...init,
|
|
10
|
+
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const unauthorized = () => json({ error: "Unauthorized" }, { status: 401 });
|
|
15
|
+
export const badRequest = (error: string, extra: Record<string, unknown> = {}) =>
|
|
16
|
+
json({ error, ...extra }, { status: 400 });
|
|
17
|
+
export const notFound = (error = "Not found") => json({ error }, { status: 404 });
|
|
18
|
+
export const serverError = (error: string, extra: Record<string, unknown> = {}) =>
|
|
19
|
+
json({ error, ...extra }, { status: 500 });
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { cookies } from "next/headers";
|
|
2
|
+
import {
|
|
3
|
+
createAdminSessionToken,
|
|
4
|
+
verifyAdminSessionToken,
|
|
5
|
+
} from "@gigamusic/core";
|
|
6
|
+
|
|
7
|
+
export const ADMIN_SESSION_COOKIE = "admin_session";
|
|
8
|
+
const SESSION_DURATION_SECONDS = 60 * 60 * 24;
|
|
9
|
+
|
|
10
|
+
/** Default cookie options consumed by every Set-Cookie path. */
|
|
11
|
+
function cookieOptions(maxAge: number) {
|
|
12
|
+
return {
|
|
13
|
+
httpOnly: true,
|
|
14
|
+
secure: true,
|
|
15
|
+
sameSite: "lax" as const,
|
|
16
|
+
maxAge,
|
|
17
|
+
path: "/",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mint a fresh admin session token and write the httpOnly cookie. Called
|
|
23
|
+
* after a successful password check from `createAdminLoginHandler`.
|
|
24
|
+
*/
|
|
25
|
+
export async function writeAdminSessionCookie(
|
|
26
|
+
adminSessionSecret: string,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const token = await createAdminSessionToken({ secret: adminSessionSecret });
|
|
29
|
+
const jar = await cookies();
|
|
30
|
+
jar.set(ADMIN_SESSION_COOKIE, token, cookieOptions(SESSION_DURATION_SECONDS));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Clear the admin session cookie (logout). */
|
|
34
|
+
export async function clearAdminSessionCookie(): Promise<void> {
|
|
35
|
+
const jar = await cookies();
|
|
36
|
+
jar.set(ADMIN_SESSION_COOKIE, "", cookieOptions(0));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check the current request's cookie jar for a valid admin session.
|
|
41
|
+
*
|
|
42
|
+
* Returns `true` if the cookie verifies; `false` for missing or invalid tokens.
|
|
43
|
+
* Never throws — callers can branch on the boolean.
|
|
44
|
+
*/
|
|
45
|
+
export async function isAdminAuthenticated(
|
|
46
|
+
adminSessionSecret: string,
|
|
47
|
+
): Promise<boolean> {
|
|
48
|
+
const jar = await cookies();
|
|
49
|
+
const token = jar.get(ADMIN_SESSION_COOKIE)?.value;
|
|
50
|
+
if (!token) return false;
|
|
51
|
+
try {
|
|
52
|
+
await verifyAdminSessionToken(token, adminSessionSecret);
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Stateless verification for handlers that already have a `Request`. Reads the
|
|
61
|
+
* cookie out of the request's `Cookie` header so the function works in
|
|
62
|
+
* middleware-like contexts where `next/headers` is unavailable.
|
|
63
|
+
*/
|
|
64
|
+
export async function verifyAdminSession(
|
|
65
|
+
req: Request,
|
|
66
|
+
adminSessionSecret: string,
|
|
67
|
+
): Promise<boolean> {
|
|
68
|
+
const header = req.headers.get("cookie");
|
|
69
|
+
if (!header) return false;
|
|
70
|
+
const token = parseCookieHeader(header, ADMIN_SESSION_COOKIE);
|
|
71
|
+
if (!token) return false;
|
|
72
|
+
try {
|
|
73
|
+
await verifyAdminSessionToken(token, adminSessionSecret);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Tiny single-cookie reader. Avoids pulling in a cookie parser dependency. */
|
|
81
|
+
function parseCookieHeader(header: string, name: string): string | null {
|
|
82
|
+
const parts = header.split(";");
|
|
83
|
+
for (const raw of parts) {
|
|
84
|
+
const trimmed = raw.trim();
|
|
85
|
+
if (!trimmed.startsWith(`${name}=`)) continue;
|
|
86
|
+
return decodeURIComponent(trimmed.slice(name.length + 1));
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Queries } from "@gigamusic/db";
|
|
2
|
+
import type { StorageProvider } from "@gigamusic/storage";
|
|
3
|
+
import type * as Audio from "@gigamusic/audio";
|
|
4
|
+
import type { EmailBranding } from "@gigamusic/email";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Next.js App Router route-handler signature. Defined locally so the admin
|
|
8
|
+
* package doesn't have to depend on Next.js's internal `NextRequest` re-export
|
|
9
|
+
* — handler factories return ordinary `Request → Promise<Response>` callables.
|
|
10
|
+
*/
|
|
11
|
+
export type RouteHandler = (
|
|
12
|
+
req: Request,
|
|
13
|
+
context?: { params: Promise<Record<string, string>> },
|
|
14
|
+
) => Promise<Response>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Aggregate dependency bag for every admin route handler factory. The consumer
|
|
18
|
+
* builds this once at startup and passes a slice (or the whole bag) into each
|
|
19
|
+
* factory; nothing inside the admin package reads `process.env` directly.
|
|
20
|
+
*/
|
|
21
|
+
export interface AdminDeps {
|
|
22
|
+
queries: Queries;
|
|
23
|
+
storage: StorageProvider;
|
|
24
|
+
audio: typeof Audio;
|
|
25
|
+
adminPasswordHash: string;
|
|
26
|
+
adminSessionSecret: string;
|
|
27
|
+
branding: EmailBranding;
|
|
28
|
+
/**
|
|
29
|
+
* Optional logger. Defaults to a no-op so the package never writes to
|
|
30
|
+
* console on the consumer's behalf.
|
|
31
|
+
*/
|
|
32
|
+
logger?: AdminLogger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AdminLogger {
|
|
36
|
+
info(message: string, meta?: Record<string, unknown>): void;
|
|
37
|
+
warn(message: string, meta?: Record<string, unknown>): void;
|
|
38
|
+
error(message: string, meta?: Record<string, unknown>): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const noopLogger: AdminLogger = {
|
|
42
|
+
info() {},
|
|
43
|
+
warn() {},
|
|
44
|
+
error() {},
|
|
45
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ReleaseForm, type ReleaseFormProps } from "../components/ReleaseForm";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Edit-release page. The consumer's route handler hands us the `id` from
|
|
8
|
+
* `params`; we fetch the full record from `/api/admin/releases/{id}` and
|
|
9
|
+
* mount `<ReleaseForm>` once it's loaded.
|
|
10
|
+
*/
|
|
11
|
+
export function AdminEditReleasePage({ id }: { id: string }) {
|
|
12
|
+
const [release, setRelease] = useState<ReleaseFormProps["release"] | null>(
|
|
13
|
+
null,
|
|
14
|
+
);
|
|
15
|
+
const [error, setError] = useState("");
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let cancelled = false;
|
|
19
|
+
fetch(`/api/admin/releases/${id}`)
|
|
20
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("Load failed"))))
|
|
21
|
+
.then((data) => {
|
|
22
|
+
if (!cancelled) setRelease(data as ReleaseFormProps["release"]);
|
|
23
|
+
})
|
|
24
|
+
.catch((err) => !cancelled && setError(String(err)));
|
|
25
|
+
return () => {
|
|
26
|
+
cancelled = true;
|
|
27
|
+
};
|
|
28
|
+
}, [id]);
|
|
29
|
+
|
|
30
|
+
if (error) return <p className="text-sm text-destructive">{error}</p>;
|
|
31
|
+
if (!release) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="max-w-2xl">
|
|
35
|
+
<h1 className="mb-6 text-2xl font-semibold">{release.name}</h1>
|
|
36
|
+
<ReleaseForm release={release} />
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default admin landing page. Just links into the major sections. Consumers
|
|
7
|
+
* who want to drop directly to releases can simply re-export
|
|
8
|
+
* `AdminReleasesPage` from `/admin/page.tsx` instead.
|
|
9
|
+
*/
|
|
10
|
+
export function AdminHomePage() {
|
|
11
|
+
return (
|
|
12
|
+
<div className="space-y-6">
|
|
13
|
+
<h1 className="text-2xl font-semibold">Admin</h1>
|
|
14
|
+
<ul className="space-y-2 text-sm">
|
|
15
|
+
<li>
|
|
16
|
+
<Link href="/admin/releases" className="hover:underline">
|
|
17
|
+
Releases
|
|
18
|
+
</Link>
|
|
19
|
+
</li>
|
|
20
|
+
<li>
|
|
21
|
+
<Link href="/admin/orders" className="hover:underline">
|
|
22
|
+
Orders
|
|
23
|
+
</Link>
|
|
24
|
+
</li>
|
|
25
|
+
<li>
|
|
26
|
+
<Link href="/admin/links" className="hover:underline">
|
|
27
|
+
Links
|
|
28
|
+
</Link>
|
|
29
|
+
</li>
|
|
30
|
+
<li>
|
|
31
|
+
<Link href="/admin/settings" className="hover:underline">
|
|
32
|
+
Settings
|
|
33
|
+
</Link>
|
|
34
|
+
</li>
|
|
35
|
+
</ul>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { AdminNav } from "../components/AdminNav";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Visual chrome for the admin section: nav bar + content container. This
|
|
6
|
+
* component does **not** auth-gate by itself — consumers protect the route
|
|
7
|
+
* via a `proxy.ts` that calls `verifyAdminSession` and redirects to
|
|
8
|
+
* `/admin/login` on failure.
|
|
9
|
+
*
|
|
10
|
+
* Keeping the layout free of auth state lets it stay synchronous and
|
|
11
|
+
* matches the locked contract (`children`-only).
|
|
12
|
+
*/
|
|
13
|
+
export function AdminLayout({ children }: { children: ReactNode }) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="gm-admin-shell mx-auto max-w-5xl px-4 py-6">
|
|
16
|
+
<AdminNav />
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useSlot } from "@gigamusic/ui/client";
|
|
5
|
+
|
|
6
|
+
interface LinkItem {
|
|
7
|
+
id: number;
|
|
8
|
+
title: string;
|
|
9
|
+
url: string;
|
|
10
|
+
position: number;
|
|
11
|
+
isVisible: boolean;
|
|
12
|
+
showOnHero: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Editor for the global `/links` page link list. Optimistic updates with a
|
|
17
|
+
* 500ms debounced PUT; reordering uses the bulk `orderedIds` endpoint so two
|
|
18
|
+
* swapped neighbors land in a single round-trip.
|
|
19
|
+
*/
|
|
20
|
+
export function AdminLinksPage() {
|
|
21
|
+
const Button = useSlot("Button");
|
|
22
|
+
const [links, setLinks] = useState<LinkItem[]>([]);
|
|
23
|
+
const [newTitle, setNewTitle] = useState("");
|
|
24
|
+
const [newUrl, setNewUrl] = useState("");
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
fetch("/api/admin/links")
|
|
30
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("Load failed"))))
|
|
31
|
+
.then((data: LinkItem[]) => {
|
|
32
|
+
if (!cancelled) {
|
|
33
|
+
setLinks(data);
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
.catch(() => !cancelled && setLoading(false));
|
|
38
|
+
return () => {
|
|
39
|
+
cancelled = true;
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
async function addLink() {
|
|
44
|
+
if (!newTitle || !newUrl) return;
|
|
45
|
+
const res = await fetch("/api/admin/links", {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "Content-Type": "application/json" },
|
|
48
|
+
body: JSON.stringify({ title: newTitle, url: newUrl }),
|
|
49
|
+
});
|
|
50
|
+
if (res.ok) {
|
|
51
|
+
const link = (await res.json()) as LinkItem;
|
|
52
|
+
setLinks((prev) => [...prev, link]);
|
|
53
|
+
setNewTitle("");
|
|
54
|
+
setNewUrl("");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function saveLink(link: LinkItem) {
|
|
59
|
+
await fetch("/api/admin/links", {
|
|
60
|
+
method: "PUT",
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify(link),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function deleteLink(id: number) {
|
|
67
|
+
await fetch(`/api/admin/links?id=${id}`, { method: "DELETE" });
|
|
68
|
+
setLinks((prev) => prev.filter((l) => l.id !== id));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function updateField<K extends keyof LinkItem>(
|
|
72
|
+
id: number,
|
|
73
|
+
field: K,
|
|
74
|
+
value: LinkItem[K],
|
|
75
|
+
) {
|
|
76
|
+
setLinks((prev) => {
|
|
77
|
+
const next = prev.map((l) => (l.id === id ? { ...l, [field]: value } : l));
|
|
78
|
+
const updated = next.find((l) => l.id === id);
|
|
79
|
+
if (updated) void saveLink(updated);
|
|
80
|
+
return next;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function moveLink(index: number, direction: "up" | "down") {
|
|
85
|
+
const swap = direction === "up" ? index - 1 : index + 1;
|
|
86
|
+
if (swap < 0 || swap >= links.length) return;
|
|
87
|
+
const next = [...links];
|
|
88
|
+
[next[index]!, next[swap]!] = [next[swap]!, next[index]!];
|
|
89
|
+
setLinks(next);
|
|
90
|
+
await fetch("/api/admin/links", {
|
|
91
|
+
method: "PUT",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify({ orderedIds: next.map((l) => l.id) }),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="space-y-6">
|
|
101
|
+
<h1 className="text-2xl font-semibold">Links</h1>
|
|
102
|
+
<p className="text-sm text-muted-foreground">
|
|
103
|
+
Edits the global <code>/links</code> page and hero links on the homepage.
|
|
104
|
+
</p>
|
|
105
|
+
|
|
106
|
+
<div className="space-y-3">
|
|
107
|
+
{links.map((link, index) => (
|
|
108
|
+
<div
|
|
109
|
+
key={link.id}
|
|
110
|
+
className="flex items-start gap-3 rounded border border-border p-3"
|
|
111
|
+
>
|
|
112
|
+
<div className="flex flex-col gap-1">
|
|
113
|
+
<Button
|
|
114
|
+
variant="ghost"
|
|
115
|
+
size="icon-xs"
|
|
116
|
+
disabled={index === 0}
|
|
117
|
+
onClick={() => moveLink(index, "up")}
|
|
118
|
+
>
|
|
119
|
+
▲
|
|
120
|
+
</Button>
|
|
121
|
+
<Button
|
|
122
|
+
variant="ghost"
|
|
123
|
+
size="icon-xs"
|
|
124
|
+
disabled={index === links.length - 1}
|
|
125
|
+
onClick={() => moveLink(index, "down")}
|
|
126
|
+
>
|
|
127
|
+
▼
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex flex-1 flex-wrap items-center gap-2">
|
|
131
|
+
<input
|
|
132
|
+
value={link.title}
|
|
133
|
+
onChange={(e) => updateField(link.id, "title", e.target.value)}
|
|
134
|
+
placeholder="Title"
|
|
135
|
+
className="min-w-0 flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
|
|
136
|
+
/>
|
|
137
|
+
<input
|
|
138
|
+
value={link.url}
|
|
139
|
+
onChange={(e) => updateField(link.id, "url", e.target.value)}
|
|
140
|
+
placeholder="URL"
|
|
141
|
+
className="min-w-0 flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
|
|
142
|
+
/>
|
|
143
|
+
<Button
|
|
144
|
+
variant="ghost"
|
|
145
|
+
size="sm"
|
|
146
|
+
onClick={() => updateField(link.id, "isVisible", !link.isVisible)}
|
|
147
|
+
>
|
|
148
|
+
{link.isVisible ? "Visible" : "Hidden"}
|
|
149
|
+
</Button>
|
|
150
|
+
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
151
|
+
<input
|
|
152
|
+
type="checkbox"
|
|
153
|
+
checked={link.showOnHero}
|
|
154
|
+
onChange={() =>
|
|
155
|
+
updateField(link.id, "showOnHero", !link.showOnHero)
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
homepage
|
|
159
|
+
</label>
|
|
160
|
+
<Button variant="destructive" size="sm" onClick={() => deleteLink(link.id)}>
|
|
161
|
+
Delete
|
|
162
|
+
</Button>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="flex flex-wrap items-center gap-2 rounded border border-dashed border-border p-3">
|
|
169
|
+
<input
|
|
170
|
+
value={newTitle}
|
|
171
|
+
onChange={(e) => setNewTitle(e.target.value)}
|
|
172
|
+
placeholder="Link title"
|
|
173
|
+
className="min-w-0 flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
|
|
174
|
+
/>
|
|
175
|
+
<input
|
|
176
|
+
value={newUrl}
|
|
177
|
+
onChange={(e) => setNewUrl(e.target.value)}
|
|
178
|
+
placeholder="https://..."
|
|
179
|
+
className="min-w-0 flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
|
|
180
|
+
/>
|
|
181
|
+
<Button onClick={addLink} disabled={!newTitle || !newUrl}>
|
|
182
|
+
Add Link
|
|
183
|
+
</Button>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { AdminLoginForm } from "../components/AdminLoginForm";
|
|
2
|
+
|
|
3
|
+
/** Standalone login page — useful when the consumer wants `/admin/login` separate from layout-driven gating. */
|
|
4
|
+
export function AdminLoginPage() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="gm-admin-shell mx-auto max-w-sm px-4 py-20">
|
|
7
|
+
<h1 className="mb-6 text-center text-2xl font-semibold">Admin Login</h1>
|
|
8
|
+
<AdminLoginForm />
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReleaseForm } from "../components/ReleaseForm";
|
|
4
|
+
|
|
5
|
+
export function AdminNewReleasePage() {
|
|
6
|
+
return (
|
|
7
|
+
<div className="max-w-2xl">
|
|
8
|
+
<h1 className="mb-6 text-2xl font-semibold">New Release</h1>
|
|
9
|
+
<ReleaseForm />
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useSlot } from "@gigamusic/ui/client";
|
|
5
|
+
import { formatCurrency } from "@gigamusic/ui";
|
|
6
|
+
|
|
7
|
+
interface OrderItem {
|
|
8
|
+
id: number;
|
|
9
|
+
release?: { name: string } | null;
|
|
10
|
+
track?: { name: string } | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Order {
|
|
14
|
+
id: number;
|
|
15
|
+
email: string;
|
|
16
|
+
amountTotal: number;
|
|
17
|
+
status: "pending" | "completed" | "failed";
|
|
18
|
+
createdAt: string;
|
|
19
|
+
stripePaymentId: string | null;
|
|
20
|
+
items: OrderItem[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Email-lookup orders view. The consumer can swap this for a paginated table
|
|
25
|
+
* backed by a custom `/api/admin/orders` endpoint that supports filtering by
|
|
26
|
+
* date range; the minimum surface here is "find a customer's orders to
|
|
27
|
+
* troubleshoot a missing download."
|
|
28
|
+
*/
|
|
29
|
+
export function AdminOrdersPage() {
|
|
30
|
+
const Button = useSlot("Button");
|
|
31
|
+
const [email, setEmail] = useState("");
|
|
32
|
+
const [orders, setOrders] = useState<Order[] | null>(null);
|
|
33
|
+
const [loading, setLoading] = useState(false);
|
|
34
|
+
const [error, setError] = useState("");
|
|
35
|
+
|
|
36
|
+
async function search(e: React.FormEvent) {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
if (!email) return;
|
|
39
|
+
setLoading(true);
|
|
40
|
+
setError("");
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(
|
|
43
|
+
`/api/admin/orders?email=${encodeURIComponent(email)}`,
|
|
44
|
+
);
|
|
45
|
+
if (!res.ok) throw new Error(`Search failed (${res.status})`);
|
|
46
|
+
setOrders((await res.json()) as Order[]);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
49
|
+
} finally {
|
|
50
|
+
setLoading(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div>
|
|
56
|
+
<h1 className="mb-6 text-2xl font-semibold">Orders</h1>
|
|
57
|
+
<form onSubmit={search} className="mb-6 flex gap-2">
|
|
58
|
+
<input
|
|
59
|
+
type="email"
|
|
60
|
+
value={email}
|
|
61
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
62
|
+
placeholder="Search by email..."
|
|
63
|
+
className="flex-1 rounded border border-border bg-background px-3 py-2 text-sm"
|
|
64
|
+
/>
|
|
65
|
+
<Button type="submit" disabled={loading}>
|
|
66
|
+
{loading ? "Searching..." : "Search"}
|
|
67
|
+
</Button>
|
|
68
|
+
</form>
|
|
69
|
+
|
|
70
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
71
|
+
|
|
72
|
+
{orders && (
|
|
73
|
+
<table className="w-full text-sm">
|
|
74
|
+
<thead>
|
|
75
|
+
<tr className="border-b border-border text-left">
|
|
76
|
+
<th className="py-2">ID</th>
|
|
77
|
+
<th className="py-2">Date</th>
|
|
78
|
+
<th className="py-2">Items</th>
|
|
79
|
+
<th className="py-2 text-right">Amount</th>
|
|
80
|
+
<th className="py-2 text-right">Status</th>
|
|
81
|
+
<th className="py-2 text-right">Stripe</th>
|
|
82
|
+
</tr>
|
|
83
|
+
</thead>
|
|
84
|
+
<tbody>
|
|
85
|
+
{orders.map((order) => (
|
|
86
|
+
<tr key={order.id} className="border-b border-border/40">
|
|
87
|
+
<td className="py-2 font-mono text-xs">#{order.id}</td>
|
|
88
|
+
<td className="py-2">
|
|
89
|
+
{new Date(order.createdAt).toLocaleDateString()}
|
|
90
|
+
</td>
|
|
91
|
+
<td className="py-2 max-w-[200px] truncate text-muted-foreground">
|
|
92
|
+
{order.items
|
|
93
|
+
.map((i) => i.release?.name ?? i.track?.name ?? "—")
|
|
94
|
+
.join(", ")}
|
|
95
|
+
</td>
|
|
96
|
+
<td className="py-2 text-right">
|
|
97
|
+
{formatCurrency(order.amountTotal)}
|
|
98
|
+
</td>
|
|
99
|
+
<td className="py-2 text-right">{order.status}</td>
|
|
100
|
+
<td className="py-2 text-right">
|
|
101
|
+
{order.stripePaymentId ? (
|
|
102
|
+
<a
|
|
103
|
+
href={`https://dashboard.stripe.com/payments/${order.stripePaymentId}`}
|
|
104
|
+
rel="noopener noreferrer"
|
|
105
|
+
className="text-xs hover:underline"
|
|
106
|
+
>
|
|
107
|
+
View
|
|
108
|
+
</a>
|
|
109
|
+
) : (
|
|
110
|
+
<span className="text-xs text-muted-foreground">—</span>
|
|
111
|
+
)}
|
|
112
|
+
</td>
|
|
113
|
+
</tr>
|
|
114
|
+
))}
|
|
115
|
+
{orders.length === 0 && (
|
|
116
|
+
<tr>
|
|
117
|
+
<td colSpan={6} className="py-8 text-center text-muted-foreground">
|
|
118
|
+
No orders found.
|
|
119
|
+
</td>
|
|
120
|
+
</tr>
|
|
121
|
+
)}
|
|
122
|
+
</tbody>
|
|
123
|
+
</table>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|