@gigamusic/admin 0.2.0 → 1.0.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.
@@ -1,187 +0,0 @@
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
- }
@@ -1,12 +0,0 @@
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
- }
@@ -1,127 +0,0 @@
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
- }
@@ -1,87 +0,0 @@
1
- "use client";
2
-
3
- import { useEffect, useState } from "react";
4
- import Link from "next/link";
5
- import { useSlot } from "@gigamusic/ui/client";
6
-
7
- interface ReleaseSummary {
8
- id: number;
9
- name: string;
10
- slug: string;
11
- price: number;
12
- type: string;
13
- isPublished: boolean;
14
- tracks: Array<{ id: number }>;
15
- }
16
-
17
- /** Lists every release with quick edit + delete. Fetches from `/api/admin/releases`. */
18
- export function AdminReleasesPage() {
19
- const Button = useSlot("Button");
20
- const [releases, setReleases] = useState<ReleaseSummary[] | null>(null);
21
- const [error, setError] = useState("");
22
-
23
- useEffect(() => {
24
- let cancelled = false;
25
- fetch("/api/admin/releases")
26
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Load failed"))))
27
- .then((data) => {
28
- if (!cancelled) setReleases(data as ReleaseSummary[]);
29
- })
30
- .catch((err) => !cancelled && setError(String(err)));
31
- return () => {
32
- cancelled = true;
33
- };
34
- }, []);
35
-
36
- return (
37
- <div>
38
- <div className="mb-6 flex items-center justify-between">
39
- <h1 className="text-2xl font-semibold">Releases</h1>
40
- <Button href="/admin/releases/new">New Release</Button>
41
- </div>
42
-
43
- {error && <p className="text-sm text-destructive">{error}</p>}
44
-
45
- <table className="w-full text-sm">
46
- <thead>
47
- <tr className="border-b border-border text-left">
48
- <th className="py-2">Name</th>
49
- <th className="py-2 text-right">Type</th>
50
- <th className="py-2 text-right">Price</th>
51
- <th className="py-2 text-right">Tracks</th>
52
- <th className="py-2 text-right">Status</th>
53
- </tr>
54
- </thead>
55
- <tbody>
56
- {releases?.map((release) => (
57
- <tr key={release.id} className="border-b border-border/40">
58
- <td className="py-2">
59
- <Link
60
- href={`/admin/releases/${release.id}/edit`}
61
- className="hover:underline"
62
- >
63
- {release.name}
64
- </Link>
65
- </td>
66
- <td className="py-2 text-right">{release.type}</td>
67
- <td className="py-2 text-right">
68
- ${(release.price / 100).toFixed(2)}
69
- </td>
70
- <td className="py-2 text-right">{release.tracks.length}</td>
71
- <td className="py-2 text-right">
72
- {release.isPublished ? "Published" : "Draft"}
73
- </td>
74
- </tr>
75
- ))}
76
- {releases && releases.length === 0 && (
77
- <tr>
78
- <td colSpan={5} className="py-8 text-center text-muted-foreground">
79
- No releases yet.
80
- </td>
81
- </tr>
82
- )}
83
- </tbody>
84
- </table>
85
- </div>
86
- );
87
- }
@@ -1,84 +0,0 @@
1
- "use client";
2
-
3
- import { useEffect, useState } from "react";
4
- import { useSlot } from "@gigamusic/ui/client";
5
-
6
- const DISCOUNT_KEY = "catalog_discount_percent";
7
-
8
- /**
9
- * Site-wide settings editor. Currently exposes the catalog-discount percent;
10
- * any other consumer-specific tunables should get their own settings pages
11
- * so each editor can render the right form for its value shape.
12
- */
13
- export function AdminSettingsPage() {
14
- const Button = useSlot("Button");
15
- const [discount, setDiscount] = useState("");
16
- const [loading, setLoading] = useState(true);
17
- const [saving, setSaving] = useState(false);
18
- const [saved, setSaved] = useState(false);
19
-
20
- useEffect(() => {
21
- let cancelled = false;
22
- fetch(`/api/admin/settings?key=${encodeURIComponent(DISCOUNT_KEY)}`)
23
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Load failed"))))
24
- .then((data: { value: unknown }) => {
25
- if (cancelled) return;
26
- const value =
27
- typeof data.value === "number"
28
- ? String(data.value)
29
- : typeof data.value === "string"
30
- ? data.value
31
- : "15";
32
- setDiscount(value);
33
- setLoading(false);
34
- })
35
- .catch(() => !cancelled && setLoading(false));
36
- return () => {
37
- cancelled = true;
38
- };
39
- }, []);
40
-
41
- async function handleSave() {
42
- setSaving(true);
43
- setSaved(false);
44
- await fetch("/api/admin/settings", {
45
- method: "PUT",
46
- headers: { "Content-Type": "application/json" },
47
- body: JSON.stringify({ key: DISCOUNT_KEY, value: Number(discount) }),
48
- });
49
- setSaving(false);
50
- setSaved(true);
51
- setTimeout(() => setSaved(false), 3000);
52
- }
53
-
54
- if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
55
-
56
- return (
57
- <div className="max-w-md space-y-6">
58
- <h1 className="text-2xl font-semibold">Settings</h1>
59
- <div className="space-y-2">
60
- <label htmlFor="discount" className="text-sm font-medium">
61
- Catalog Discount (%)
62
- </label>
63
- <div className="flex items-center gap-3">
64
- <input
65
- id="discount"
66
- type="number"
67
- min="0"
68
- max="100"
69
- value={discount}
70
- onChange={(e) => setDiscount(e.target.value)}
71
- className="w-24 rounded border border-border bg-background px-3 py-2 text-sm"
72
- />
73
- <Button onClick={handleSave} disabled={saving}>
74
- {saving ? "Saving..." : "Save"}
75
- </Button>
76
- {saved && <span className="text-sm">Saved</span>}
77
- </div>
78
- <p className="text-xs text-muted-foreground">
79
- Discount applied when customers buy the entire catalog.
80
- </p>
81
- </div>
82
- </div>
83
- );
84
- }