diff --git a/website/src/Admin.jsx b/website/src/Admin.jsx
new file mode 100644
index 0000000..33890fb
--- /dev/null
+++ b/website/src/Admin.jsx
@@ -0,0 +1,178 @@
+import { useState, useEffect } from 'react';
+import styles from './Admin.module.css';
+
+// Hidden admin page: only reachable by typing /admin into the address bar.
+// It is intentionally not linked from the navbar. The API key is kept in
+// memory only and is gone after a reload.
+function Admin() {
+ const apiUrl = "https://api.marvinkrausser.com";
+
+ const [apiKey, setApiKey] = useState("");
+ const [limit, setLimit] = useState(100);
+ const [offset, setOffset] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [response, setResponse] = useState(null);
+
+ // Keep search engines away from this page.
+ useEffect(() => {
+ const meta = document.createElement("meta");
+ meta.name = "robots";
+ meta.content = "noindex, nofollow";
+ document.head.appendChild(meta);
+ return () => meta.remove();
+ }, []);
+
+ const request = async (label, method, path) => {
+ if (!apiKey) {
+ setResponse({ label, status: "No API key entered", body: null, ok: false });
+ return;
+ }
+
+ setLoading(true);
+ setConfirmDelete(false);
+
+ try {
+ const res = await fetch(`${apiUrl}${path}`, {
+ method,
+ headers: { "Authorization": `Bearer ${apiKey}` },
+ });
+
+ const text = await res.text();
+ let body;
+ try {
+ body = JSON.parse(text);
+ } catch {
+ body = text;
+ }
+
+ setResponse({ label, status: `${res.status} ${res.statusText}`, body, ok: res.ok });
+ } catch (e) {
+ setResponse({ label, status: "Network error", body: String(e), ok: false });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const getReviews = () =>
+ request("Get reviews", "GET", `/review?limit=${limit}&offset=${offset}`);
+
+ const deleteAll = () => {
+ // Two-step confirmation instead of window.confirm.
+ if (!confirmDelete) {
+ setConfirmDelete(true);
+ return;
+ }
+ request("Delete all reviews", "DELETE", "/review");
+ };
+
+ const reviews = Array.isArray(response?.body?.data) ? response.body.data : null;
+
+ return (
+
+
Admin
+
+
+
+
+
+ Response
+ {loading && Loading…}
+ {!loading && response && (
+
+ {response.label}: {response.status}
+
+ )}
+
+
+ {reviews && reviews.length > 0 && (
+
+
+
+
+ | ID |
+ Created |
+ Website |
+ Rating |
+ Text |
+
+
+
+ {reviews.map((r) => (
+
+ | {r.id} |
+ {r.created_at ? new Date(r.created_at).toLocaleString() : ""} |
+ {r.website} |
+ {r.rating} |
+ {r.text} |
+
+ ))}
+
+
+
+ )}
+
+ {reviews && reviews.length === 0 &&
No reviews.
}
+
+ {response && (
+
+ {typeof response.body === "string"
+ ? response.body
+ : JSON.stringify(response.body, null, 2)}
+
+ )}
+
+ {!response &&
No request sent yet.
}
+
+
+ );
+}
+
+export default Admin;
diff --git a/website/src/Admin.module.css b/website/src/Admin.module.css
new file mode 100644
index 0000000..b5d0f73
--- /dev/null
+++ b/website/src/Admin.module.css
@@ -0,0 +1,140 @@
+.panel {
+ width: 100%;
+ max-width: 800px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+}
+
+.row .field {
+ flex: 1;
+ min-width: 140px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ color: var(--text-dim);
+ font-size: 0.9rem;
+}
+
+.field input {
+ font: inherit;
+ font-size: 1rem;
+ color: var(--text);
+ background: var(--bg-elevated);
+ border: 1px solid var(--surface-line);
+ border-radius: 10px;
+ padding: 10px 12px;
+ min-width: 0;
+}
+
+.field input:focus {
+ outline: 2px solid var(--accent-2);
+ outline-offset: 1px;
+}
+
+.buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 15px;
+}
+
+.buttons button:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.danger {
+ background: rgb(220, 70, 70);
+ border-color: rgb(220, 70, 70);
+}
+
+.output {
+ width: 100%;
+ max-width: 800px;
+ margin: 40px 0 60px 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.output-head {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 10px;
+ font-weight: 600;
+ border-bottom: 1px solid var(--surface-line);
+ padding-bottom: 8px;
+}
+
+.dim {
+ color: var(--text-dim);
+ font-weight: 400;
+}
+
+.ok {
+ color: rgb(110, 220, 140);
+}
+
+.fail {
+ color: rgb(240, 110, 110);
+}
+
+.table-wrap {
+ width: 100%;
+ overflow-x: auto;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+}
+
+.table th,
+.table td {
+ text-align: left;
+ vertical-align: top;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--surface-dim);
+}
+
+.table th {
+ color: var(--surface);
+ white-space: nowrap;
+}
+
+.table td {
+ color: var(--text-dim);
+}
+
+.table .text {
+ min-width: 200px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--text);
+}
+
+.raw {
+ margin: 0;
+ max-height: 400px;
+ overflow: auto;
+ padding: 12px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--surface-line);
+ border-radius: 10px;
+ font-family: 'Space Mono', monospace;
+ font-size: 0.8rem;
+ color: var(--text-dim);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
diff --git a/website/src/App.jsx b/website/src/App.jsx
index 5b7b58f..f5efb62 100644
--- a/website/src/App.jsx
+++ b/website/src/App.jsx
@@ -4,6 +4,7 @@ import Homepage from './Homepage';
import Bird_CNN from './Bird_CNN';
import Reviews from './Reviews.jsx';
import Object_Detection from './Object_Detection.jsx';
+import Admin from './Admin.jsx';
function App() {
@@ -15,6 +16,8 @@ function App() {
} />
} />
} />
+ {/* Not linked in the navbar on purpose: reachable only via the URL. */}
+ } />
} />
>