Skip to content

Commit 72c8e2d

Browse files
authored
Merge pull request #23 from hackclub/backend_setup
Backend setup
2 parents 2069082 + 5d0c495 commit 72c8e2d

16 files changed

Lines changed: 2082 additions & 258 deletions

client/src/App.jsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { MainPage } from "./components/MainPage.jsx";
77
import { ProjectsPage } from "./components/ProjectsPage.jsx";
88
import { ShopPage } from "./components/ShopPage.jsx";
99
import { TestPage } from "./components/TestPage.jsx";
10+
import { AdminAirtableSyncPage } from "./components/AdminAirtableSyncPage.jsx";
1011
import { AdminPage } from "./components/AdminPage.jsx";
1112
import { AdminShopPage } from "./components/AdminShopPage.jsx";
1213
import { AdminShopOrdersPage } from "./components/AdminShopOrdersPage.jsx";
14+
import { AdminUsersPage } from "./components/AdminUsersPage.jsx";
1315
import { UserAreaPage } from "./components/UserAreaPage.jsx";
1416

1517
const PROTECTED = new Set(["/main", "/shop", "/projects", "/faq", "/user", "/test", "/admin"]);
@@ -96,14 +98,24 @@ export default function App() {
9698
case "/admin":
9799
page = <AdminPage />;
98100
break;
101+
case "/admin/users":
102+
page = <AdminUsersPage />;
103+
break;
104+
case "/admin/airtable_sync":
105+
page = <AdminAirtableSyncPage />;
106+
break;
99107
case "/admin/shop":
100108
page = <AdminShopPage />;
101109
break;
102110
case "/admin/shop/orders":
103111
page = <AdminShopOrdersPage />;
104112
break;
105113
default:
106-
page = <Hero />;
114+
if (pathname.startsWith("/admin/users/")) {
115+
page = <AdminUsersPage userId={pathname.split("/").pop()} />;
116+
} else {
117+
page = <Hero />;
118+
}
107119
}
108120

109121
return <AuthContext.Provider value={contextValue}>{page}</AuthContext.Provider>;
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { useEffect, useState } from "react";
2+
import "./AdminPage.css";
3+
4+
export function AdminAirtableSyncPage() {
5+
const [rows, setRows] = useState([]);
6+
const [status, setStatus] = useState("Loading test rows...");
7+
const [error, setError] = useState("");
8+
const [syncStatus, setSyncStatus] = useState(null);
9+
const [syncError, setSyncError] = useState("");
10+
const [syncMessage, setSyncMessage] = useState("");
11+
const [isSyncing, setIsSyncing] = useState(false);
12+
13+
useEffect(() => {
14+
let isMounted = true;
15+
16+
async function bootstrap() {
17+
try {
18+
await Promise.all([loadRows(), loadSyncStatus()]);
19+
if (isMounted) setStatus("");
20+
} catch (err) {
21+
if (isMounted) {
22+
setError(err.message);
23+
setStatus("");
24+
}
25+
}
26+
}
27+
28+
bootstrap();
29+
return () => {
30+
isMounted = false;
31+
};
32+
}, []);
33+
34+
async function loadRows() {
35+
const response = await fetch("/api/test");
36+
const isJson = response.headers.get("content-type")?.includes("application/json");
37+
const data = isJson ? await response.json() : {};
38+
if (!response.ok) {
39+
throw new Error(data.error || "Failed to load test rows.");
40+
}
41+
setRows(data.rows || []);
42+
}
43+
44+
async function loadSyncStatus() {
45+
const response = await fetch("/api/airtable/status");
46+
const isJson = response.headers.get("content-type")?.includes("application/json");
47+
const data = isJson ? await response.json() : {};
48+
if (!response.ok) {
49+
throw new Error(data.error || "Failed to load sync status.");
50+
}
51+
setSyncStatus(data);
52+
setSyncError("");
53+
return data;
54+
}
55+
56+
async function handleSyncNow() {
57+
setIsSyncing(true);
58+
setSyncError("");
59+
setSyncMessage("");
60+
try {
61+
const response = await fetch("/api/airtable/sync", { method: "POST" });
62+
const isJson = response.headers.get("content-type")?.includes("application/json");
63+
const data = isJson ? await response.json() : {};
64+
if (!response.ok) {
65+
throw new Error(data.error || data.message || "Failed to sync Airtable.");
66+
}
67+
setSyncMessage("Sync finished.");
68+
await Promise.all([loadRows(), loadSyncStatus()]);
69+
} catch (err) {
70+
setSyncError(err.message);
71+
} finally {
72+
setIsSyncing(false);
73+
}
74+
}
75+
76+
const lastSyncTime = syncStatus?.lastSync?.syncedAt
77+
? new Date(syncStatus.lastSync.syncedAt).toLocaleString()
78+
: "Never synced";
79+
const syncTables = syncStatus?.lastSync?.tables || [];
80+
const lastSyncError = syncStatus?.lastSync?.ok === false ? syncStatus.lastSync.error : "";
81+
82+
return (
83+
<main className="admin-page" aria-label="Airtable sync admin page">
84+
<section className="admin-content">
85+
<a className="admin-back-link" href="/admin">
86+
← Admin home
87+
</a>
88+
<h1>Airtable Sync</h1>
89+
90+
<section className="admin-airtable">
91+
<h2>Airtable Sync</h2>
92+
<p className="admin-airtable-subtitle">Re-sync Stack Airtable from the latest database state.</p>
93+
94+
<div className="admin-airtable-sync-row">
95+
<div>
96+
<strong>Airtable sync</strong>
97+
<span>Last update: {lastSyncTime}</span>
98+
</div>
99+
<button type="button" onClick={handleSyncNow} disabled={isSyncing}>
100+
{isSyncing ? "Syncing..." : "Sync now"}
101+
</button>
102+
</div>
103+
104+
{syncMessage ? <p className="admin-airtable-success">{syncMessage}</p> : null}
105+
{syncError ? <p className="admin-airtable-error">{syncError}</p> : null}
106+
{lastSyncError ? <p className="admin-airtable-error">Last sync error: {lastSyncError}</p> : null}
107+
{status ? <p>{status}</p> : null}
108+
{error ? <p className="admin-airtable-error">{error}</p> : null}
109+
110+
{syncTables.length > 0 ? (
111+
<div className="admin-airtable-details">
112+
<strong>Sync details</strong>
113+
{syncTables.map((table) => (
114+
<p key={table.table}>
115+
<code>{table.table}</code>:{" "}
116+
{table.skipped
117+
? `Skipped (${table.reason})`
118+
: `Synced ${table.synced}/${table.sourceRows} rows using ${table.mergeFields.join(", ")}`}
119+
</p>
120+
))}
121+
</div>
122+
) : null}
123+
124+
{rows.length > 0 ? (
125+
<div className="admin-airtable-rows">
126+
{rows.map((row, index) => (
127+
<pre key={row.id ?? index}>{JSON.stringify(row, null, 2)}</pre>
128+
))}
129+
</div>
130+
) : null}
131+
</section>
132+
</section>
133+
</main>
134+
);
135+
}

client/src/components/AdminPage.jsx

Lines changed: 2 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,19 @@
1-
import { useEffect, useState } from "react";
21
import { useAuth } from "../auth/AuthContext.jsx";
32
import "./AdminPage.css";
43

54
const adminLinks = [
65
{ href: "/admin/stats", icon: "📊", title: "Platform Statistics", desc: "Overview of projects, hours, users, and shop activity" },
76
{ href: "/admin/users", icon: "👥", title: "Users", desc: "Bolts, projects, hours, journals, and shop activity per participant" },
8-
{ href: "/admin/journals", icon: "🧾", title: "Journal entries (CSV)", desc: "All journal rows in one spreadsheet, sorted by project then time" },
7+
{ href: "/api/admin/journals.csv", icon: "🧾", title: "Journal entries (CSV)", desc: "All journal rows in one spreadsheet, sorted by project then time" },
98
{ href: "/admin/review", icon: "📋", title: "Project Review", desc: "Review shipped projects and approve hours" },
109
{ href: "/admin/shop", icon: "🛒", title: "Shop Admin", desc: "Manage catalog, categories, and pricing" },
1110
{ href: "/admin/shop/orders", icon: "📦", title: "Shop orders", desc: "Fulfillment queue, group by user or item, refunds" },
12-
{ href: "/admin/items_request", icon: "📝", title: "Items Request", desc: "View Exchange Desk item requests" },
13-
{ href: "/admin/blazer", icon: "🔍", title: "Blazer", desc: "Database queries and dashboards" },
14-
{ href: "/admin/flipper", icon: "🚩", title: "Flipper", desc: "Feature flags" },
15-
{ href: "/admin/jobs", icon: "⚙️", title: "Solid Queue", desc: "Background job queue" },
16-
{ href: "#admin-airtable-sync", icon: "🔄", title: "Airtable Sync", desc: "Sync status, logs, and diagnostics" },
17-
{ href: "/admin/console", icon: "💎", title: "Ruby Console", desc: "Execute Ruby code on the server" },
11+
{ href: "/admin/airtable_sync", icon: "🔄", title: "Airtable Sync", desc: "Sync status, logs, and diagnostics" },
1812
];
1913

2014
export function AdminPage() {
2115
const { user } = useAuth();
2216
const showSuperAdmin = user?.role === "super_admin";
23-
const [rows, setRows] = useState([]);
24-
const [status, setStatus] = useState("Loading test rows...");
25-
const [error, setError] = useState("");
26-
const [syncStatus, setSyncStatus] = useState(null);
27-
const [syncError, setSyncError] = useState("");
28-
const [syncMessage, setSyncMessage] = useState("");
29-
const [isSyncing, setIsSyncing] = useState(false);
30-
31-
useEffect(() => {
32-
let isMounted = true;
33-
34-
async function bootstrap() {
35-
try {
36-
await Promise.all([loadRows(), loadSyncStatus()]);
37-
if (isMounted) setStatus("");
38-
} catch (err) {
39-
if (isMounted) {
40-
setError(err.message);
41-
setStatus("");
42-
}
43-
}
44-
}
45-
46-
bootstrap();
47-
return () => {
48-
isMounted = false;
49-
};
50-
}, []);
51-
52-
async function loadRows() {
53-
const response = await fetch("/api/test");
54-
const isJson = response.headers.get("content-type")?.includes("application/json");
55-
const data = isJson ? await response.json() : {};
56-
if (!response.ok) {
57-
throw new Error(data.error || "Failed to load test rows.");
58-
}
59-
setRows(data.rows || []);
60-
}
61-
62-
async function loadSyncStatus() {
63-
const response = await fetch("/api/airtable/status");
64-
const isJson = response.headers.get("content-type")?.includes("application/json");
65-
const data = isJson ? await response.json() : {};
66-
if (!response.ok) {
67-
throw new Error(data.error || "Failed to load sync status.");
68-
}
69-
setSyncStatus(data);
70-
setSyncError("");
71-
return data;
72-
}
73-
74-
async function handleSyncNow() {
75-
setIsSyncing(true);
76-
setSyncError("");
77-
setSyncMessage("");
78-
try {
79-
const response = await fetch("/api/airtable/sync", { method: "POST" });
80-
const isJson = response.headers.get("content-type")?.includes("application/json");
81-
const data = isJson ? await response.json() : {};
82-
if (!response.ok) {
83-
throw new Error(data.error || data.message || "Failed to sync Airtable.");
84-
}
85-
setSyncMessage("Sync finished.");
86-
await Promise.all([loadRows(), loadSyncStatus()]);
87-
} catch (err) {
88-
setSyncError(err.message);
89-
} finally {
90-
setIsSyncing(false);
91-
}
92-
}
93-
94-
const lastSyncTime = syncStatus?.lastSync?.syncedAt
95-
? new Date(syncStatus.lastSync.syncedAt).toLocaleString()
96-
: "Never synced";
97-
const syncTables = syncStatus?.lastSync?.tables || [];
98-
const lastSyncError = syncStatus?.lastSync?.ok === false ? syncStatus.lastSync.error : "";
9917

10018
return (
10119
<main className="admin-page" aria-label="Admin page">
@@ -129,49 +47,6 @@ export function AdminPage() {
12947
</section>
13048
) : null}
13149

132-
<section id="admin-airtable-sync" className="admin-airtable">
133-
<h2>Airtable Sync</h2>
134-
<p className="admin-airtable-subtitle">Re-sync Stack Airtable from the latest database state.</p>
135-
136-
<div className="admin-airtable-sync-row">
137-
<div>
138-
<strong>Airtable sync</strong>
139-
<span>Last update: {lastSyncTime}</span>
140-
</div>
141-
<button type="button" onClick={handleSyncNow} disabled={isSyncing}>
142-
{isSyncing ? "Syncing..." : "Sync now"}
143-
</button>
144-
</div>
145-
146-
{syncMessage ? <p className="admin-airtable-success">{syncMessage}</p> : null}
147-
{syncError ? <p className="admin-airtable-error">{syncError}</p> : null}
148-
{lastSyncError ? <p className="admin-airtable-error">Last sync error: {lastSyncError}</p> : null}
149-
{status ? <p>{status}</p> : null}
150-
{error ? <p className="admin-airtable-error">{error}</p> : null}
151-
152-
{syncTables.length > 0 ? (
153-
<div className="admin-airtable-details">
154-
<strong>Sync details</strong>
155-
{syncTables.map((table) => (
156-
<p key={table.table}>
157-
<code>{table.table}</code>:{" "}
158-
{table.skipped
159-
? `Skipped (${table.reason})`
160-
: `Synced ${table.synced}/${table.sourceRows} rows using ${table.mergeFields.join(", ")}`}
161-
</p>
162-
))}
163-
</div>
164-
) : null}
165-
166-
{rows.length > 0 ? (
167-
<div className="admin-airtable-rows">
168-
{rows.map((row, index) => (
169-
<pre key={row.id ?? index}>{JSON.stringify(row, null, 2)}</pre>
170-
))}
171-
</div>
172-
) : null}
173-
</section>
174-
17550
<section className="admin-silly" aria-hidden="true">
17651
<pre>{`(\\_/)\n(o.o)\n(> <)`}</pre>
17752
<p className="admin-emojis">🦄🌈🔮💎🎪🎭🎨🎬🎤🎧🎼🎹🥁🎷🎺🎸🪕🎻🎲🎯🎳🎮🎰🎱🔮💎🌈🦄</p>

client/src/components/AdminShopPage.jsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ const emptyForm = {
1010
itemLink: "",
1111
imageUrl: "",
1212
description: "",
13-
position: "",
1413
active: true,
1514
};
1615

@@ -251,7 +250,7 @@ export function AdminShopPage() {
251250
}
252251

253252
function ShopItemForm({ value, submitLabel, onChange, onSubmit }) {
254-
const coinValue = value.priceUsd ? Number(value.priceUsd) * 10 : 0;
253+
const coinValue = value.priceUsd ? Math.ceil(Number(value.priceUsd) * 10) : 0;
255254

256255
return (
257256
<form className="admin-shop-form" onSubmit={onSubmit}>
@@ -279,7 +278,7 @@ function ShopItemForm({ value, submitLabel, onChange, onSubmit }) {
279278
</label>
280279
<label>
281280
Coins
282-
<input type="number" step="0.01" value={coinValue || ""} readOnly />
281+
<input type="number" value={coinValue || ""} readOnly />
283282
</label>
284283
<label>
285284
Max purchases per person
@@ -293,10 +292,6 @@ function ShopItemForm({ value, submitLabel, onChange, onSubmit }) {
293292
Image URL
294293
<input value={value.imageUrl || ""} onChange={(event) => onChange("imageUrl", event.target.value)} />
295294
</label>
296-
<label>
297-
Position
298-
<input type="number" value={value.position || ""} onChange={(event) => onChange("position", event.target.value)} />
299-
</label>
300295
<label>
301296
Active
302297
<input type="checkbox" checked={Boolean(value.active)} onChange={(event) => onChange("active", event.target.checked)} />

0 commit comments

Comments
 (0)