From 55502e9e438c65d6684ccaf0be1c835e22df473a Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 25 Sep 2026 13:09:02 +0200 Subject: [PATCH] added review database admin page --- website/src/Admin.jsx | 178 +++++++++++++++++++++++++++++++++++ website/src/Admin.module.css | 140 +++++++++++++++++++++++++++ website/src/App.jsx | 3 + 3 files changed, 321 insertions(+) create mode 100644 website/src/Admin.jsx create mode 100644 website/src/Admin.module.css 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 && ( +
+ + + + + + + + + + + + {reviews.map((r) => ( + + + + + + + + ))} + +
IDCreatedWebsiteRatingText
{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. */} + } /> } />