Compare commits

...

2 Commits

Author SHA1 Message Date
johnruina 393c8c41d4 cache 2026-07-10 22:53:00 -04:00
johnruina b0f2094a30 jack shite 2026-07-10 12:26:42 -04:00
9 changed files with 967 additions and 671 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<link rel="icon" href="/favicon.ico?v=1" sizes="any" />
<link rel="shortcut icon" href="/favicon.ico?v=1" type="image/x-icon" />
<title>John Ruina's PM Shit</title>
<script type="module" crossorigin src="/assets/index-CeR9gGY5.js"></script>
<script type="module" crossorigin src="/assets/index-B0CTKjjv.js"></script>
</head>
<body>
<div id="root"></div>
+14
View File
@@ -19,6 +19,7 @@ function getCookie(name: string) {
function AppInner() {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [rateLimited, setRateLimited] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const sidebarPinned = getCookie("sidebarPinned") !== "false";
@@ -75,6 +76,12 @@ function AppInner() {
navigate("/");
}
useEffect(() => {
const handler = () => setRateLimited(true);
window.addEventListener("ratelimited", handler);
return () => window.removeEventListener("ratelimited", handler);
}, []);
if (loading) return null;
return (
@@ -103,6 +110,13 @@ function AppInner() {
<Route path="*" element={<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 12, paddingTop: 80 }}><div style={{ color: "#888", fontSize: 18, fontWeight: 600 }}>this page doesnt exist</div><img src="/nothingthere.webp" style={{ width: 240, opacity: 0.5 }} /></div>} />
</Routes>
</div>
{rateLimited && (
<div style={{ position: "fixed", inset: 0, background: "#111", display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", zIndex: 99999, gap: 16, color: "#e0e0e0" }}>
<div style={{ fontSize: 28, fontWeight: 700 }}>Rate Limited</div>
<div style={{ fontSize: 14, color: "#999", textAlign: "center" }}>You're sending too many requests.<br />Please wait a moment and try again.</div>
<button onClick={() => { setRateLimited(true); fetch("/api/ratings").then(() => setRateLimited(false)).catch(() => {}); }} style={{ padding: "8px 20px", fontSize: 14, background: "#424e88", color: "#fff", border: "none", cursor: "pointer", borderRadius: 4 }}>Retry</button>
</div>
)}
{showLoginModal && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 9999 }} onClick={() => setShowLoginModal(false)}>
<div style={{ background: "#2a2a2a", padding: 24, borderRadius: 8, width: 320, display: "flex", flexDirection: "column", gap: 12 }} onClick={(e) => e.stopPropagation()}>
+10
View File
@@ -2,6 +2,16 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const origFetch = window.fetch;
window.fetch = (input, init) =>
origFetch(input, init).then((r) => {
if (r.status === 429) {
window.dispatchEvent(new CustomEvent("ratelimited"));
throw new Error("Rate limited");
}
return r;
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
+48 -44
View File
@@ -58,12 +58,15 @@ type SiteUpdate = {
};
type Props = {
user: { email: string } | null;
user: { id: string; email: string } | null;
};
function AdminPage({ user }: Props) {
const { dark } = useTheme();
const adminLoggedIn = user?.email === "invoictoan@gmail.com";
function ua(url: string) { const sep = url.includes("?") ? "&" : "?"; return `${url}${sep}userId=${user?.id}`; }
const [tab, setTab] = useState<"ratings" | "reports" | "bets" | "assets" | "updates">("ratings");
const [ratings, setRatings] = useState<Rating[]>([]);
const [ratingTypes, setRatingTypes] = useState<{ id: string; name: string; hasAttachments: boolean; categories: { id: string; name: string }[] }[]>([]);
@@ -153,8 +156,8 @@ function AdminPage({ user }: Props) {
}
useEffect(() => {
fetch("/api/ratings").then((r) => r.json()).then(setRatings).catch(() => {});
fetch("/api/admin/reports").then((r) => r.json()).then(setReports).catch(() => {});
fetch("/api/ratings").then((r) => r.json()).then((d) => setRatings(d.items)).catch(() => {});
fetch(ua("/api/admin/reports")).then((r) => r.json()).then((d) => { if (Array.isArray(d)) setReports(d); }).catch(() => {});
fetch("/api/bets").then((r) => r.json()).then(setBets).catch(() => {});
loadRatingTypes();
loadTags();
@@ -171,7 +174,7 @@ function AdminPage({ user }: Props) {
async function handleRatingSubmit(e: FormEvent) {
e.preventDefault();
if (!type) return;
await fetch("/api/admin/ratings", {
await fetch(ua("/api/admin/ratings"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, name, description, picture, icon, attachments }),
@@ -182,13 +185,13 @@ function AdminPage({ user }: Props) {
}
async function removeRating(id: string) {
await fetch(`/api/admin/ratings/${id}`, { method: "DELETE" });
await fetch(ua(`/api/admin/ratings/${id}`), { method: "DELETE" });
setRatings((prev) => prev.filter((r) => r.id !== id));
}
async function createRatingType() {
if (!newTypeName) return;
await fetch("/api/admin/rating-types", {
await fetch(ua("/api/admin/rating-types"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newTypeName }),
@@ -198,13 +201,13 @@ function AdminPage({ user }: Props) {
}
async function deleteRatingType(id: string) {
await fetch(`/api/admin/rating-types/${id}`, { method: "DELETE" });
await fetch(ua(`/api/admin/rating-types/${id}`), { method: "DELETE" });
loadRatingTypes();
}
async function createCategory(typeId: string) {
if (!newCategoryName) return;
await fetch(`/api/admin/rating-types/${typeId}/categories`, {
await fetch(ua(`/api/admin/rating-types/${typeId}/categories`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newCategoryName }),
@@ -214,13 +217,13 @@ function AdminPage({ user }: Props) {
}
async function deleteCategory(typeId: string, categoryId: string) {
await fetch(`/api/admin/rating-types/${typeId}/categories/${categoryId}`, { method: "DELETE" });
await fetch(ua(`/api/admin/rating-types/${typeId}/categories/${categoryId}`), { method: "DELETE" });
loadRatingTypes();
}
async function saveCategory(typeId: string, categoryId: string) {
if (!editCategoryName) return;
await fetch(`/api/admin/rating-types/${typeId}/categories/${categoryId}`, {
await fetch(ua(`/api/admin/rating-types/${typeId}/categories/${categoryId}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editCategoryName }),
@@ -231,7 +234,7 @@ function AdminPage({ user }: Props) {
}
async function removeReport(id: string) {
await fetch(`/api/admin/reports/${id}`, { method: "DELETE" });
await fetch(ua(`/api/admin/reports/${id}`), { method: "DELETE" });
setReports((prev) => prev.filter((r) => r.id !== id));
}
@@ -240,11 +243,11 @@ function AdminPage({ user }: Props) {
}
function loadAssets() {
fetch("/api/admin/assets").then((r) => r.json()).then(setAssets).catch(() => {});
fetch(ua("/api/admin/assets")).then((r) => r.json()).then((d) => { if (Array.isArray(d)) setAssets(d); }).catch(() => {});
}
function loadBetCategories() {
fetch("/api/admin/bet-categories").then((r) => r.json()).then(setBetCategories).catch(() => {});
fetch(ua("/api/admin/bet-categories")).then((r) => r.json()).then((d) => { if (Array.isArray(d)) setBetCategories(d); }).catch(() => {});
}
function loadBetTags() {
@@ -253,7 +256,7 @@ function AdminPage({ user }: Props) {
async function createBetTag() {
if (!newBetTagName) return;
await fetch("/api/admin/bet-tags", {
await fetch(ua("/api/admin/bet-tags"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newBetTagName, color: newBetTagColor, image: newBetTagImage }),
@@ -263,13 +266,13 @@ function AdminPage({ user }: Props) {
}
async function deleteBetTag(id: string) {
await fetch(`/api/admin/bet-tags/${id}`, { method: "DELETE" });
await fetch(ua(`/api/admin/bet-tags/${id}`), { method: "DELETE" });
loadBetTags();
setBets((prev) => prev.map((b) => ({ ...b, tags: b.tags?.filter((t) => t.tag.id !== id) })));
}
async function addTagToBet(betId: string, tagId: string) {
await fetch(`/api/admin/bets/${betId}/tags`, {
await fetch(ua(`/api/admin/bets/${betId}/tags`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tagId }),
@@ -279,14 +282,14 @@ function AdminPage({ user }: Props) {
}
async function removeTagFromBet(betId: string, tagId: string) {
await fetch(`/api/admin/bets/${betId}/tags/${tagId}`, { method: "DELETE" });
await fetch(ua(`/api/admin/bets/${betId}/tags/${tagId}`), { method: "DELETE" });
const res = await fetch("/api/bets").then((r) => r.json());
setBets(res);
}
async function createTag() {
if (!newTagName || !newTagTypeId) return;
await fetch("/api/admin/tags", {
await fetch(ua("/api/admin/tags"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newTagName, color: newTagColor, ratingTypeId: newTagTypeId }),
@@ -297,7 +300,7 @@ function AdminPage({ user }: Props) {
}
async function deleteTag(id: string) {
await fetch(`/api/admin/tags/${id}`, { method: "DELETE" });
await fetch(ua(`/api/admin/tags/${id}`), { method: "DELETE" });
loadTags();
const res = await fetch("/api/ratings").then((r) => r.json());
setRatings(res);
@@ -305,7 +308,7 @@ function AdminPage({ user }: Props) {
async function updateTag(id: string) {
if (!editTagName || !editTagTypeId) return;
await fetch(`/api/admin/tags/${id}`, {
await fetch(ua(`/api/admin/tags/${id}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editTagName, color: editTagColor, ratingTypeId: editTagTypeId }),
@@ -318,7 +321,7 @@ function AdminPage({ user }: Props) {
async function addTagToRating(ratingId: string, tagId: string) {
if (!tagId) return;
await fetch(`/api/admin/ratings/${ratingId}/tags`, {
await fetch(ua(`/api/admin/ratings/${ratingId}/tags`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tagId }),
@@ -330,14 +333,14 @@ function AdminPage({ user }: Props) {
}
async function removeTagFromRating(ratingId: string, tagId: string) {
await fetch(`/api/admin/ratings/${ratingId}/tags/${tagId}`, { method: "DELETE" });
await fetch(ua(`/api/admin/ratings/${ratingId}/tags/${tagId}`), { method: "DELETE" });
const res = await fetch("/api/ratings").then((r) => r.json());
setRatings(res);
}
async function createBet() {
if (!betName) return;
await fetch("/api/admin/bets", {
await fetch(ua("/api/admin/bets"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: betName, description: betDesc, image: betImage, categoryId: selectedBetCategoryId || undefined }),
@@ -348,7 +351,7 @@ function AdminPage({ user }: Props) {
}
async function updateBet(id: string) {
await fetch(`/api/admin/bets/${id}`, {
await fetch(ua(`/api/admin/bets/${id}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editBetName, description: editBetDesc, image: editBetImage }),
@@ -360,13 +363,13 @@ function AdminPage({ user }: Props) {
}
async function deleteBet(id: string) {
await fetch(`/api/admin/bets/${id}`, { method: "DELETE" });
await fetch(ua(`/api/admin/bets/${id}`), { method: "DELETE" });
setBets((prev) => prev.filter((b) => b.id !== id));
}
async function addEntry(betId: string) {
if (!newEntryOption) return;
await fetch(`/api/admin/bets/${betId}/entries`, {
await fetch(ua(`/api/admin/bets/${betId}/entries`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ option: newEntryOption }),
@@ -378,12 +381,12 @@ function AdminPage({ user }: Props) {
}
async function deleteEntry(betId: string, entryId: string) {
await fetch(`/api/admin/bets/${betId}/entries/${entryId}`, { method: "DELETE" });
await fetch(ua(`/api/admin/bets/${betId}/entries/${entryId}`), { method: "DELETE" });
setBets((prev) => prev.map((b) => b.id === betId ? { ...b, entries: b.entries.filter((e) => e.id !== entryId) } : b));
}
async function completeBet(betId: string, resultEntryId: string) {
await fetch(`/api/admin/bets/${betId}/complete`, {
await fetch(ua(`/api/admin/bets/${betId}/complete`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resultEntryId }),
@@ -395,7 +398,7 @@ function AdminPage({ user }: Props) {
async function createBetCategory() {
if (!newBetCategoryName) return;
await fetch("/api/admin/bet-categories", {
await fetch(ua("/api/admin/bet-categories"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newBetCategoryName }),
@@ -406,7 +409,7 @@ function AdminPage({ user }: Props) {
async function renameBetCategory(id: string, name: string) {
if (!name) return;
await fetch(`/api/admin/bet-categories/${id}`, {
await fetch(ua(`/api/admin/bet-categories/${id}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
@@ -417,14 +420,14 @@ function AdminPage({ user }: Props) {
}
async function deleteBetCategory(id: string) {
await fetch(`/api/admin/bet-categories/${id}`, { method: "DELETE" });
await fetch(ua(`/api/admin/bet-categories/${id}`), { method: "DELETE" });
loadBetCategories();
const res = await fetch("/api/bets").then((r) => r.json());
setBets(res);
}
async function assignCategory(betId: string, categoryId: string) {
await fetch(`/api/admin/bets/${betId}`, {
await fetch(ua(`/api/admin/bets/${betId}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ categoryId: categoryId || null }),
@@ -443,7 +446,8 @@ function AdminPage({ user }: Props) {
if (!adminLoggedIn) {
return (
<div style={{ maxWidth: 320, margin: "40px auto", color: textClr, fontSize: 16, textAlign: "center" }}>
Not authorized
<div style={{ marginBottom: 16 }}>Not authorized</div>
<button onClick={() => { document.cookie = "userId=; path=/; max-age=0"; location.reload(); }} style={{ padding: "6px 14px", fontSize: 13, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>Refresh Login</button>
</div>
);
}
@@ -501,7 +505,7 @@ function AdminPage({ user }: Props) {
draggable
onDragStart={() => setDragIdx(i)}
onDragOver={(e) => { e.preventDefault(); if (dragIdx === null || dragIdx === i) return; const next = [...ratingTypes]; const [moved] = next.splice(dragIdx, 1); next.splice(i, 0, moved); setRatingTypes(next); setDragIdx(i); }}
onDragEnd={() => { setDragIdx(null); fetch("/api/admin/rating-types/reorder", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: ratingTypes.map((rt) => rt.id) }) }).catch(() => {}); }}
onDragEnd={() => { setDragIdx(null); fetch(ua("/api/admin/rating-types/reorder"), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: ratingTypes.map((rt) => rt.id) }) }).catch(() => {}); }}
style={{ borderTop: `1px solid ${borderClr}`, padding: "8px 0", opacity: dragIdx === i ? 0.4 : 1, cursor: "grab" }}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
@@ -515,7 +519,7 @@ function AdminPage({ user }: Props) {
<div style={{ marginTop: 8, paddingLeft: 12 }}>
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: textClr, cursor: "pointer", marginBottom: 8 }}>
<input type="checkbox" checked={t.hasAttachments} onChange={async () => {
const res = await fetch(`/api/admin/rating-types/${t.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hasAttachments: !t.hasAttachments }) });
const res = await fetch(ua(`/api/admin/rating-types/${t.id}`), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hasAttachments: !t.hasAttachments }) });
if (res.ok) loadRatingTypes();
}} style={{ cursor: "pointer" }} /> attachments (file upload / audio player UI)
</label>
@@ -623,7 +627,7 @@ function AdminPage({ user }: Props) {
<button onClick={async () => {
if (!massTagId) return;
for (const rid of selectedRatingIds) {
await fetch(`/api/admin/ratings/${rid}/tags`, {
await fetch(ua(`/api/admin/ratings/${rid}/tags`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tagId: massTagId }),
@@ -702,7 +706,7 @@ function AdminPage({ user }: Props) {
const fd = new FormData();
fd.append("type", ratingTypeFilter || ratingTypes[0]?.name || "music");
for (const f of musicUploads) fd.append("files", f, f.name);
const r = await fetch("/api/admin/upload-attachments", { method: "POST", body: fd });
const r = await fetch(ua("/api/admin/upload-attachments"), { method: "POST", body: fd });
if (!r.ok) { const err = await r.json(); console.error("Upload failed:", err); return; }
setMusicUploads([]);
const res = await fetch("/api/ratings").then((r) => r.json());
@@ -900,14 +904,14 @@ function AdminPage({ user }: Props) {
<h3 style={{ margin: "0 0 8px" }}>Upload Asset</h3>
<input ref={assetImgRef} type="file" accept="image/*" onChange={(e) => { const f = e.target.files?.[0]; if (f) { const r = new FileReader(); r.onload = () => setNewAssetImage(r.result as string); r.readAsDataURL(f); } }} style={{ display: "none" }} />
<button type="button" onClick={() => assetImgRef.current?.click()} style={{ display: "block", width: "100%", padding: "8px 12px", fontSize: 14, marginBottom: 8, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left", boxSizing: "border-box" }}>{newAssetImage ? "Selected" : "Choose image to upload"}</button>
<button onClick={async () => { if (!newAssetImage) return; await fetch("/api/admin/assets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: newAssetImage }) }); setNewAssetImage(""); loadAssets(); }} style={{ padding: "8px 16px", fontSize: 14, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }} disabled={!newAssetImage}>Upload</button>
<button onClick={async () => { if (!newAssetImage) return; await fetch(ua("/api/admin/assets"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: newAssetImage }) }); setNewAssetImage(""); loadAssets(); }} style={{ padding: "8px 16px", fontSize: 14, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }} disabled={!newAssetImage}>Upload</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(100px, 1fr))", gap: 8 }}>
{assets.map((a) => (
<div key={a.name} style={{ border: `1px solid ${borderClr}`, overflow: "hidden", position: "relative" }}>
<img src={a.url} alt="" style={{ width: "100%", height: 80, objectFit: "cover", display: "block" }} />
<div style={{ fontSize: 10, color: mutedClr, padding: "2px 4px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{a.name}</div>
<button onClick={async () => { await fetch(`/api/admin/assets/${encodeURIComponent(a.name)}`, { method: "DELETE" }); loadAssets(); }} style={{ position: "absolute", top: 2, right: 2, padding: "2px 5px", fontSize: 10, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer", lineHeight: 1 }}>x</button>
<button onClick={async () => { await fetch(ua(`/api/admin/assets/${encodeURIComponent(a.name)}`), { method: "DELETE" }); loadAssets(); }} style={{ position: "absolute", top: 2, right: 2, padding: "2px 5px", fontSize: 10, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer", lineHeight: 1 }}>x</button>
</div>
))}
</div>
@@ -920,7 +924,7 @@ function AdminPage({ user }: Props) {
<textarea placeholder="Text" value={newUpdateText} onChange={(e) => setNewUpdateText(e.target.value)} rows={4} style={{ display: "block", width: "100%", padding: "8px 12px", fontSize: 14, marginBottom: 8, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box", resize: "vertical" }} />
<button onClick={async () => {
if (!newUpdateTitle) return;
await fetch("/api/admin/site-updates", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newUpdateTitle, text: newUpdateText }) });
await fetch(ua("/api/admin/site-updates"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newUpdateTitle, text: newUpdateText }) });
setNewUpdateTitle(""); setNewUpdateText("");
loadSiteUpdates();
}} style={{ padding: "10px 0", fontSize: 16, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer", width: "100%" }}>Create Update</button>
@@ -933,7 +937,7 @@ function AdminPage({ user }: Props) {
<textarea value={editingUpdate.text} onChange={(e) => setEditingUpdate({ ...editingUpdate, text: e.target.value })} rows={4} style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, resize: "vertical" }} />
<div style={{ display: "flex", gap: 6 }}>
<button onClick={async () => {
await fetch(`/api/admin/site-updates/${u.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: editingUpdate.title, text: editingUpdate.text }) });
await fetch(ua(`/api/admin/site-updates/${u.id}`), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: editingUpdate.title, text: editingUpdate.text }) });
setEditingUpdate(null);
loadSiteUpdates();
}} style={{ padding: "6px 12px", fontSize: 13, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Save</button>
@@ -949,7 +953,7 @@ function AdminPage({ user }: Props) {
</div>
<div style={{ display: "flex", gap: 4 }}>
<button onClick={() => setEditingUpdate(u)} style={{ padding: "4px 10px", fontSize: 12, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Edit</button>
<button onClick={async () => { await fetch(`/api/admin/site-updates/${u.id}`, { method: "DELETE" }); loadSiteUpdates(); }} style={{ padding: "4px 10px", fontSize: 12, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>Delete</button>
<button onClick={async () => { await fetch(ua(`/api/admin/site-updates/${u.id}`), { method: "DELETE" }); loadSiteUpdates(); }} style={{ padding: "4px 10px", fontSize: 12, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>Delete</button>
</div>
</div>
{u.text && <div style={{ fontSize: 14, color: textClr, marginTop: 6, whiteSpace: "pre-wrap" }}>{u.text}</div>}
@@ -1001,7 +1005,7 @@ function AdminPage({ user }: Props) {
)}
<div style={{ display: "flex", gap: 8 }}>
<button onClick={async () => {
await fetch(`/api/admin/ratings/${editingRating.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(editRatingData) });
await fetch(ua(`/api/admin/ratings/${editingRating.id}`), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(editRatingData) });
setEditingRating(null);
const res = await fetch("/api/ratings").then((r) => r.json());
setRatings(res);
@@ -1021,7 +1025,7 @@ function AdminPage({ user }: Props) {
if (!f) return;
const r = new FileReader();
r.onload = async () => {
const res = await fetch("/api/admin/assets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: r.result }) });
const res = await fetch(ua("/api/admin/assets"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: r.result }) });
const data = await res.json();
pickerCbRef.current?.(data.url);
setShowPicker(false);
+156 -172
View File
@@ -2,6 +2,13 @@ import { useEffect, useRef, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useTheme } from "../context/ThemeContext";
type Tag = {
id: string;
name: string;
color: string;
ratingTypeId: string;
};
type RatingCategory = {
id: string;
name: string;
@@ -12,13 +19,6 @@ type RatingCategory = {
userStarred: boolean;
};
type Tag = {
id: string;
name: string;
color: string;
ratingTypeId: string;
};
type Rating = {
id: string;
type: string;
@@ -27,10 +27,14 @@ type Rating = {
description: string;
icon: string;
attachments: string;
tags: Tag[];
userVotes: Record<string, number>;
};
type RatingDetail = Rating & {
categories: RatingCategory[];
average: number;
totalVotes: number;
categories: RatingCategory[];
tags: Tag[];
};
type Props = {
@@ -40,12 +44,13 @@ type Props = {
function RatingsPage({ user }: Props) {
const { dark } = useTheme();
const [ratings, setRatings] = useState<Rating[]>([]);
const [detailMap, setDetailMap] = useState<Record<string, RatingDetail>>({});
const [ratingTypes, setRatingTypes] = useState<{ id: string; name: string; hasAttachments: boolean; categories: { id: string; name: string }[] }[]>([]);
const { category: urlCategory } = useParams();
const navigate = useNavigate();
const category = urlCategory || (ratingTypes.length > 0 ? ratingTypes[0].name : "");
const [selectedCatId, setSelectedCatId] = useState<string | null>(null);
const [selected, setSelected] = useState<Rating | null>(null);
const [selected, setSelected] = useState<RatingDetail | null>(null);
const [search, setSearch] = useState("");
const [distributions, setDistributions] = useState<Record<string, { accountRatings: number[]; anonRatings: number[]; average: number; totalVotes: number }>>({});
const [loadingDist, setLoadingDist] = useState<Record<string, boolean>>({});
@@ -56,9 +61,9 @@ function RatingsPage({ user }: Props) {
const [showUnvoted, setShowUnvoted] = useState(true);
const [period, setPeriod] = useState("all");
const [sortOpen, setSortOpen] = useState(false);
const [sortMode, setSortMode] = useState<"default" | "alpha" | "cat" | "stars" | "avg">("default");
const [sortMode, setSortMode] = useState<"default" | "alpha">("default");
const playingRef = useRef<HTMLAudioElement | null>(null);
const musicSortCache = useRef<{ key: string; ids: string[] } | null>(null);
function handlePlay(el: HTMLAudioElement | null) {
if (playingRef.current && playingRef.current !== el) {
playingRef.current.pause();
@@ -83,21 +88,45 @@ function RatingsPage({ user }: Props) {
function fetchRatings() {
const params = new URLSearchParams();
if (user) params.set("userId", user.id);
if (period !== "all") params.set("period", period);
if (category) params.set("type", category);
params.set("limit", "10000");
const q = params.toString();
return fetch(`/api/ratings${q ? `?${q}` : ""}`)
.then((r) => r.json())
.then(setRatings)
.then((data) => { setRatings(data.items); })
.catch(() => {});
}
useEffect(() => { fetchRatings(); }, [user, period]);
function fetchDetail(ratingId: string) {
const params = new URLSearchParams();
if (user) params.set("userId", user.id);
const q = params.toString();
return fetch(`/api/rating-detail/${ratingId}${q ? `?${q}` : ""}`)
.then((r) => r.json())
.then((data: RatingDetail) => { setDetailMap((prev) => ({ ...prev, [ratingId]: data })); })
.catch(() => {});
}
useEffect(() => {
fetchRatings();
}, [user, category]);
useEffect(() => {
if (user) return;
fetch("/api/my-anonymous-votes").then((r) => r.json()).then(setAnonVotes).catch(() => {});
}, [user]);
async function handleCardClick(rating: Rating) {
const params = new URLSearchParams();
if (user) params.set("userId", user.id);
try {
const res = await fetch(`/api/rating-detail/${rating.id}?${params.toString()}`);
const data: RatingDetail = await res.json();
setDetailMap((prev) => ({ ...prev, [rating.id]: data }));
setSelected(data);
} catch {}
}
useEffect(() => {
fetch("/api/rating-types").then((r) => r.json()).then((types) => {
setRatingTypes(types);
@@ -111,7 +140,7 @@ function RatingsPage({ user }: Props) {
async function vote(ratingId: string, ratingCategoryId: string, score: number) {
const current = user
? ratings.find((r) => r.id === ratingId)?.categories.find((c) => c.id === ratingCategoryId)?.userVote
? ratings.find((r) => r.id === ratingId)?.userVotes[ratingCategoryId]
: anonVotes[`${ratingId}_${ratingCategoryId}`];
if (current === score) {
const res = await fetch("/api/unvote", {
@@ -123,14 +152,15 @@ function RatingsPage({ user }: Props) {
if (user) {
setRatings((prev) => prev.map((r) => {
if (r.id !== ratingId) return r;
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, userVote: null, average: data.average, totalVotes: data.totalVotes }) };
const n = { ...r.userVotes }; delete n[ratingCategoryId];
return { ...r, userVotes: n };
}));
setDetailMap((prev) => {
const d = prev[ratingId]; if (!d) return prev;
return { ...prev, [ratingId]: { ...d, categories: d.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, userVote: null, average: data.average, totalVotes: data.totalVotes }) } };
});
} else {
setAnonVotes((prev) => { const n = { ...prev }; delete n[`${ratingId}_${ratingCategoryId}`]; return n; });
setRatings((prev) => prev.map((r) => {
if (r.id !== ratingId) return r;
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, average: data.average, totalVotes: data.totalVotes }) };
}));
}
} else {
const body: Record<string, unknown> = { ratingId, ratingCategoryId, score };
@@ -144,14 +174,14 @@ function RatingsPage({ user }: Props) {
if (user) {
setRatings((prev) => prev.map((r) => {
if (r.id !== ratingId) return r;
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, userVote: score, average: data.average, totalVotes: data.totalVotes }) };
return { ...r, userVotes: { ...r.userVotes, [ratingCategoryId]: score } };
}));
setDetailMap((prev) => {
const d = prev[ratingId]; if (!d) return prev;
return { ...prev, [ratingId]: { ...d, categories: d.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, userVote: score, average: data.average, totalVotes: data.totalVotes }) } };
});
} else {
setAnonVotes((prev) => ({ ...prev, [`${ratingId}_${ratingCategoryId}`]: score }));
setRatings((prev) => prev.map((r) => {
if (r.id !== ratingId) return r;
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, average: data.average, totalVotes: data.totalVotes }) };
}));
}
}
const distKey = `${ratingId}_${ratingCategoryId}`;
@@ -171,45 +201,28 @@ function RatingsPage({ user }: Props) {
});
if (!res.ok) return;
const { starred } = await res.json();
setRatings((prev) => prev.map((r) => {
if (r.id !== ratingId) return r;
const newCount = (r.categories[0]?.starCount ?? 0) + (starred ? 1 : -1);
return { ...r, categories: r.categories.map((c) => ({ ...c, userStarred: starred, starCount: newCount })) };
}));
setDetailMap((prev) => {
const d = prev[ratingId]; if (!d) return prev;
const delta = starred ? 1 : -1;
return { ...prev, [ratingId]: { ...d, categories: d.categories.map((c) => ({ ...c, userStarred: starred, starCount: c.starCount + delta })) } };
});
}
useEffect(() => {
if (!selected) return;
const updated = ratings.find((r) => r.id === selected.id);
if (updated) setSelected(updated);
}, [ratings]);
const filtered = ratings.filter((r) => r.type === category && (r.name.toLowerCase().includes(search.toLowerCase()) || (r.description && r.description.toLowerCase().includes(search.toLowerCase()))));
const tagFiltered = selectedTags.length === 0
? filtered
: filtered.filter((r) => selectedTags.every((tagId) => r.tags?.some((t) => t.id === tagId)));
const voteFiltered = tagFiltered.filter((r) => {
const voted = r.categories.some((c) => user ? c.userVote != null : anonVotes[`${r.id}_${c.id}`] != null);
const voted = Object.keys(r.userVotes).length > 0;
if (showVoted && voted) return true;
if (showUnvoted && !voted) return true;
return false;
});
const sorted = sortMode === "default" ? voteFiltered : [...voteFiltered].sort((a, b) => {
if (sortMode === "stars") {
const aStars = selectedCatId ? a.categories.find((c) => c.id === selectedCatId)?.starCount ?? 0 : a.categories.reduce((s, c) => s + c.starCount, 0);
const bStars = selectedCatId ? b.categories.find((c) => c.id === selectedCatId)?.starCount ?? 0 : b.categories.reduce((s, c) => s + c.starCount, 0);
return bStars - aStars;
}
if (sortMode === "avg") return b.average - a.average;
if (selectedCatId && sortMode === "cat") {
const aCat = a.categories.find((c) => c.id === selectedCatId);
const bCat = b.categories.find((c) => c.id === selectedCatId);
return (bCat?.average ?? 0) - (aCat?.average ?? 0);
}
return a.name.localeCompare(b.name);
const sorted = [...voteFiltered].sort((a, b) => {
if (sortMode === "alpha") return a.name.localeCompare(b.name);
return 0;
});
const currentType = ratingTypes.find((t) => t.name === category);
const selectedCat = currentType?.categories.find((c) => c.id === selectedCatId) ?? null;
const borderClr = dark ? "#444" : "#ccc";
const textClr = dark ? "#e0e0e0" : "#333";
@@ -240,7 +253,7 @@ function RatingsPage({ user }: Props) {
{ratingTypes.map((t) => (
<button
key={t.id}
onClick={() => { navigate(`/ratings/${t.name}`); setSelectedCatId(t.categories[0]?.id ?? null); setSelected(null); setExpandedIds(new Set()); setSortMode("default"); }}
onClick={() => { navigate(`/ratings/${t.name}`); setSelectedCatId(t.categories[0]?.id ?? null); setSelected(null); setExpandedIds(new Set()); setSortMode("default"); }}
style={{
padding: "6px 12px",
fontSize: 14,
@@ -258,6 +271,37 @@ function RatingsPage({ user }: Props) {
</div>
</div>
)}
{winWidth <= 768 && (
<div style={{ marginBottom: 8, border: `1px solid ${borderClr}`, padding: 12, background: cardBg }}>
<div style={{ fontSize: 13, fontWeight: 700, color: textClr, marginBottom: 4 }}>Sort By</div>
<div style={{ position: "relative" }}>
<div onClick={() => setSortOpen((v) => !v)} style={{ padding: "4px 8px", fontSize: 13, cursor: "pointer", userSelect: "none", color: textClr, border: `1px solid ${borderClr}`, background: cardBg, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span>{sortMode === "default" ? "Default" : "Alphabetical"}</span>
<span style={{ fontSize: 10 }}></span>
</div>
{sortOpen && (
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, background: cardBg, border: `1px solid ${borderClr}`, zIndex: 100, boxShadow: "0 4px 12px rgba(0,0,0,0.3)" }}>
<div onClick={() => { setSortMode("default"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "default" ? "#424e88" : "none", color: sortMode === "default" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Default</div>
<div onClick={() => { setSortMode("alpha"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "alpha" ? "#424e88" : "none", color: sortMode === "alpha" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Alphabetical</div>
</div>
)}
</div>
<div style={{ borderTop: `1px solid ${borderClr}`, marginTop: 8, paddingTop: 8 }}>
<div style={{ display: "flex", gap: 4, marginTop: 0 }}>
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13, color: textClr, cursor: "pointer" }}><input type="checkbox" checked={showVoted} onChange={() => setShowVoted((v) => !v)} style={{ cursor: "pointer" }} /> show voted</label>
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13, color: textClr, cursor: "pointer" }}><input type="checkbox" checked={showUnvoted} onChange={() => setShowUnvoted((v) => !v)} style={{ cursor: "pointer" }} /> show unvoted</label>
</div>
</div>
<div style={{ borderTop: `1px solid ${borderClr}`, marginTop: 8, paddingTop: 8 }}>
<div style={{ display: "flex", gap: 4, marginTop: 0 }}>
<button onClick={() => { setPeriod("7d"); }} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "7d" ? "#424e88" : "none", color: period === "7d" ? "#fff" : textClr, border: `1px solid ${period === "7d" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>7d</button>
<button onClick={() => { setPeriod("30d"); }} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "30d" ? "#424e88" : "none", color: period === "30d" ? "#fff" : textClr, border: `1px solid ${period === "30d" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>30d</button>
<button onClick={() => { setPeriod("all"); }} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "all" ? "#424e88" : "none", color: period === "all" ? "#fff" : textClr, border: `1px solid ${period === "all" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>all time</button>
</div>
</div>
</div>
)}
<input
placeholder="Search..."
value={search}
@@ -273,46 +317,9 @@ function RatingsPage({ user }: Props) {
boxSizing: "border-box",
}}
/>
{winWidth <= 768 && (
<div style={{ marginBottom: 8, border: `1px solid ${borderClr}`, padding: 12, background: cardBg }}>
<div style={{ fontSize: 13, fontWeight: 700, color: textClr, marginBottom: 4 }}>Sort By</div>
<div style={{ position: "relative" }}>
<div onClick={() => setSortOpen((v) => !v)} style={{ padding: "4px 8px", fontSize: 13, cursor: "pointer", userSelect: "none", color: textClr, border: `1px solid ${borderClr}`, background: cardBg, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span>{(() => { if (sortMode === "default") return "Default"; if (sortMode === "alpha") return "Alphabetical"; if (sortMode === "stars") return "Stars"; if (sortMode === "avg") return "Overall"; if (sortMode === "cat") { const c = ratingTypes.find((t) => t.name === category)?.categories.find((x) => x.id === selectedCatId); return c ? c.name : "Default"; } return "Default"; })()}</span>
<span style={{ fontSize: 10 }}></span>
</div>
{sortOpen && (
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, background: cardBg, border: `1px solid ${borderClr}`, zIndex: 100, boxShadow: "0 4px 12px rgba(0,0,0,0.3)" }}>
<div onClick={() => { setSortMode("default"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "default" ? "#424e88" : "none", color: sortMode === "default" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Default</div>
<div onClick={() => { setSortMode("alpha"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "alpha" ? "#424e88" : "none", color: sortMode === "alpha" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Alphabetical</div>
<div onClick={() => { setSortMode("stars"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "stars" ? "#424e88" : "none", color: sortMode === "stars" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Stars</div>
<div onClick={() => { setSortMode("avg"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "avg" ? "#424e88" : "none", color: sortMode === "avg" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Overall</div>
{ratingTypes.find((t) => t.name === category)?.categories.map((c) => (
<div key={c.id} onClick={() => { setSortMode("cat"); setSelectedCatId(c.id); setSelected(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "cat" && selectedCatId === c.id ? "#424e88" : "none", color: sortMode === "cat" && selectedCatId === c.id ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>{c.name}</div>
))}
</div>
)}
</div>
<div style={{ borderTop: `1px solid ${borderClr}`, marginTop: 8, paddingTop: 8 }}>
<div style={{ display: "flex", gap: 4, marginTop: 0 }}>
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13, color: textClr, cursor: "pointer" }}><input type="checkbox" checked={showVoted} onChange={() => setShowVoted((v) => !v)} style={{ cursor: "pointer" }} /> show voted</label>
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13, color: textClr, cursor: "pointer" }}><input type="checkbox" checked={showUnvoted} onChange={() => setShowUnvoted((v) => !v)} style={{ cursor: "pointer" }} /> show unvoted</label>
</div>
</div>
<div style={{ borderTop: `1px solid ${borderClr}`, marginTop: 8, paddingTop: 8 }}>
<div style={{ display: "flex", gap: 4, marginTop: 0 }}>
<button onClick={() => setPeriod("7d")} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "7d" ? "#424e88" : "none", color: period === "7d" ? "#fff" : textClr, border: `1px solid ${period === "7d" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>7d</button>
<button onClick={() => setPeriod("30d")} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "30d" ? "#424e88" : "none", color: period === "30d" ? "#fff" : textClr, border: `1px solid ${period === "30d" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>30d</button>
<button onClick={() => setPeriod("all")} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "all" ? "#424e88" : "none", color: period === "all" ? "#fff" : textClr, border: `1px solid ${period === "all" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>all time</button>
</div>
</div>
</div>
)}
<div style={{ display: "flex", flexDirection: winWidth <= 768 ? "column" : "row", gap: 24 }}>
{winWidth > 768 && (
<div style={{ display: "flex", flexDirection: "column", gap: 4, width: 200, minWidth: 200, flexShrink: 0, alignSelf: "flex-start", ...(winWidth <= 768 ? { position: "fixed", top: 0, left: 0, bottom: 0, width: 260, background: modalBg, zIndex: 1000, padding: 16, overflowY: "auto", boxShadow: "2px 0 12px rgba(0,0,0,0.3)" } : {}) }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4, width: 200, minWidth: 200, flexShrink: 0, alignSelf: "flex-start" }}>
<div style={{ fontSize: 16, fontWeight: 700, color: textClr, marginBottom: 4, textAlign: "left" }}>Rating Categories</div>
{ratingTypes.map((t) => (
<button
@@ -335,18 +342,13 @@ function RatingsPage({ user }: Props) {
<div style={{ fontSize: 13, fontWeight: 700, color: textClr, marginBottom: 4 }}>Sort By</div>
<div style={{ position: "relative" }}>
<div onClick={() => setSortOpen((v) => !v)} style={{ padding: "4px 8px", fontSize: 13, cursor: "pointer", userSelect: "none", color: textClr, border: `1px solid ${borderClr}`, background: cardBg, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span>{(() => { if (sortMode === "default") return "Default"; if (sortMode === "alpha") return "Alphabetical"; if (sortMode === "stars") return "Stars"; if (sortMode === "avg") return "Overall"; if (sortMode === "cat") { const c = ratingTypes.find((t) => t.name === category)?.categories.find((x) => x.id === selectedCatId); return c ? c.name : "Default"; } return "Default"; })()}</span>
<span>{sortMode === "default" ? "Default" : "Alphabetical"}</span>
<span style={{ fontSize: 10 }}></span>
</div>
{sortOpen && (
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, background: cardBg, border: `1px solid ${borderClr}`, zIndex: 100, boxShadow: "0 4px 12px rgba(0,0,0,0.3)" }}>
<div onClick={() => { setSortMode("default"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "default" ? "#424e88" : "none", color: sortMode === "default" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Default</div>
<div onClick={() => { setSortMode("alpha"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "alpha" ? "#424e88" : "none", color: sortMode === "alpha" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Alphabetical</div>
<div onClick={() => { setSortMode("stars"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "stars" ? "#424e88" : "none", color: sortMode === "stars" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Stars</div>
<div onClick={() => { setSortMode("avg"); setSelectedCatId(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "avg" ? "#424e88" : "none", color: sortMode === "avg" ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>Overall</div>
{ratingTypes.find((t) => t.name === category)?.categories.map((c) => (
<div key={c.id} onClick={() => { setSortMode("cat"); setSelectedCatId(c.id); setSelected(null); setSortOpen(false); }} style={{ padding: "8px 14px", fontSize: 13, cursor: "pointer", background: sortMode === "cat" && selectedCatId === c.id ? "#424e88" : "none", color: sortMode === "cat" && selectedCatId === c.id ? "#fff" : textClr, borderBottom: `1px solid ${borderClr}` }}>{c.name}</div>
))}
</div>
)}
</div>
@@ -358,9 +360,9 @@ function RatingsPage({ user }: Props) {
</div>
<div style={{ borderTop: `1px solid ${borderClr}`, marginTop: 8, paddingTop: 8 }}>
<div style={{ display: "flex", gap: 4, marginTop: 0 }}>
<button onClick={() => setPeriod("7d")} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "7d" ? "#424e88" : "none", color: period === "7d" ? "#fff" : textClr, border: `1px solid ${period === "7d" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>7d</button>
<button onClick={() => setPeriod("30d")} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "30d" ? "#424e88" : "none", color: period === "30d" ? "#fff" : textClr, border: `1px solid ${period === "30d" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>30d</button>
<button onClick={() => setPeriod("all")} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "all" ? "#424e88" : "none", color: period === "all" ? "#fff" : textClr, border: `1px solid ${period === "all" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>all</button>
<button onClick={() => { setPeriod("7d"); }} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "7d" ? "#424e88" : "none", color: period === "7d" ? "#fff" : textClr, border: `1px solid ${period === "7d" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>7d</button>
<button onClick={() => { setPeriod("30d"); }} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "30d" ? "#424e88" : "none", color: period === "30d" ? "#fff" : textClr, border: `1px solid ${period === "30d" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>30d</button>
<button onClick={() => { setPeriod("all"); }} style={{ padding: "6px 12px", fontSize: 13, flex: 1, background: period === "all" ? "#424e88" : "none", color: period === "all" ? "#fff" : textClr, border: `1px solid ${period === "all" ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>all</button>
</div>
</div>
{(() => {
@@ -390,9 +392,8 @@ function RatingsPage({ user }: Props) {
})()}
</div>
</div>
)}
<div style={{ flex: 1 }}>
)}
<div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
{currentType?.hasAttachments ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
@@ -401,55 +402,33 @@ function RatingsPage({ user }: Props) {
if (typeRatings.length === 0) return <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}><div style={{ color: mutedClr, fontSize: 14 }}>nothing there</div><img src="/nothingthere.webp" style={{ width: 120, opacity: 0.5 }} /></div>;
const tagFilteredMusic = selectedTags.length === 0 ? typeRatings : typeRatings.filter((r) => selectedTags.every((tagId) => r.tags?.some((t) => t.id === tagId)));
const filteredMusic = tagFilteredMusic.filter((r) => {
const catId = selectedCatId || r.categories[0]?.id;
const cat = r.categories.find((c) => c.id === catId) || r.categories[0];
const voted = user ? cat?.userVote != null : anonVotes[`${r.id}_${cat?.id}`] != null;
const voted = Object.keys(r.userVotes).length > 0;
if (showVoted && voted) return true;
if (showUnvoted && !voted) return true;
return false;
});
const sortKey = `${sortMode}_${selectedCatId || ""}_${search}_${selectedTags.join()}_${showVoted}_${showUnvoted}_${category}`;
if (!musicSortCache.current || musicSortCache.current.key !== sortKey) {
const sorted = sortMode === "default" ? filteredMusic : [...filteredMusic].sort((a, b) => {
if (sortMode === "stars") {
const aStars = selectedCatId ? a.categories.find((c) => c.id === selectedCatId)?.starCount ?? 0 : a.categories.reduce((s, c) => s + c.starCount, 0);
const bStars = selectedCatId ? b.categories.find((c) => c.id === selectedCatId)?.starCount ?? 0 : b.categories.reduce((s, c) => s + c.starCount, 0);
return bStars - aStars;
}
if (sortMode === "avg") return b.average - a.average;
if (selectedCatId && sortMode === "cat") {
const aCat = a.categories.find((c) => c.id === selectedCatId);
const bCat = b.categories.find((c) => c.id === selectedCatId);
return (bCat?.average ?? 0) - (aCat?.average ?? 0);
}
return a.name.localeCompare(b.name);
});
musicSortCache.current = { key: sortKey, ids: sorted.map((r) => r.id) };
}
const cacheSet = new Set(musicSortCache.current.ids);
const sortedMusic = [
...musicSortCache.current.ids.map((id) => filteredMusic.find((r) => r.id === id)).filter(Boolean),
...filteredMusic.filter((r) => !cacheSet.has(r.id)),
] as Rating[];
const sortedMusic = [...filteredMusic].sort((a, b) => {
if (sortMode === "alpha") return a.name.localeCompare(b.name);
return 0;
});
return sortedMusic.map((r) => {
const catId = selectedCatId || r.categories[0]?.id;
const cat = r.categories.find((c) => c.id === catId) || r.categories[0];
const sameType = sortedMusic;
const ranked = sameType;
const place = ranked.findIndex((x) => x.id === r.id) + 1;
const d = detailMap[r.id];
const cat = d ? (selectedCatId ? d.categories.find((c) => c.id === selectedCatId) || d.categories[0] : d.categories[0]) : undefined;
const isExpanded = expandedIds.has(r.id);
const dist = cat ? distributions[`${r.id}_${cat.id}`] : undefined;
const distKey = cat ? `${r.id}_${cat.id}` : "";
const dist = distKey ? distributions[distKey] : undefined;
return (
<div key={r.id} style={{ border: `1px solid ${borderClr}`, background: cardBg }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 12px" }}>
<div onClick={(e) => { e.stopPropagation(); setExpandedIds((prev) => { const n = new Set(prev); if (n.has(r.id)) n.delete(r.id); else n.add(r.id); return n; }); if (!isExpanded && cat) loadDistribution(r.id, cat.id); }} style={{ cursor: "pointer", fontSize: 12, color: mutedClr, width: 16, textAlign: "center", userSelect: "none", flexShrink: 0 }}>{isExpanded ? "▼" : "▶"}</div>
<div onClick={(e) => { e.stopPropagation(); setExpandedIds((prev) => { const n = new Set(prev); if (n.has(r.id)) n.delete(r.id); else n.add(r.id); return n; }); if (!isExpanded) fetchDetail(r.id); if (!isExpanded && cat) loadDistribution(r.id, cat.id); }} style={{ cursor: "pointer", fontSize: 12, color: mutedClr, width: 16, textAlign: "center", userSelect: "none", flexShrink: 0 }}>{isExpanded ? "▼" : "▶"}</div>
<div style={{ fontWeight: 700, fontSize: 14, color: textClr, flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap" }}>
<div ref={(el) => { if (el && el.parentElement && el.scrollWidth > el.parentElement.clientWidth && !el.dataset.scrolled) { el.dataset.scrolled = "1"; const dist = el.parentElement.clientWidth - el.scrollWidth; el.style.setProperty("--sd", dist + "px"); el.style.animation = "musicScroll 8s linear infinite"; } }} style={{ display: "inline-block", whiteSpace: "nowrap" }}>{r.name}</div>
</div>
{r.attachments && <div style={{ flexShrink: 1, minWidth: winWidth <= 768 ? 120 : 180, maxWidth: winWidth <= 768 ? 160 : 240, borderRadius: 4, overflow: "hidden" }}><audio controls controlsList="nodownload noplaybackrate" src={r.attachments} preload="none" style={{ height: 32, width: "100%", display: "block" }} onClick={(e) => e.stopPropagation()} onPlay={(e) => handlePlay(e.currentTarget)} /></div>}
{r.attachments ? <div style={{ flexShrink: 1, minWidth: winWidth <= 768 ? 120 : 180, maxWidth: winWidth <= 768 ? 160 : 240, borderRadius: 4, overflow: "hidden" }}><audio controls controlsList="nodownload noplaybackrate" src={r.attachments} preload="none" style={{ height: 32, width: "100%", display: "block" }} onClick={(e) => e.stopPropagation()} onPlay={(e) => handlePlay(e.currentTarget)} /></div> : null}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", minWidth: 40 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: mutedClr, textAlign: "right", lineHeight: 1.4 }}>
{cat?.totalVotes > 0 ? cat.average.toFixed(2) : "-"} / 10
{cat && cat.totalVotes > 0 ? cat.average.toFixed(2) : "-"} / 10
</div>
<div style={{ fontSize: 11, color: "#e6c200", textAlign: "right", lineHeight: 1.4 }}>
{cat?.starCount ?? 0}
@@ -465,6 +444,7 @@ function RatingsPage({ user }: Props) {
})}
</div>
)}
</div>
</div>
{isExpanded && cat && (
<div style={{ padding: "8px 12px 12px 12px", borderTop: `1px solid ${borderClr}` }}>
@@ -491,7 +471,7 @@ function RatingsPage({ user }: Props) {
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: textClr }}>{cat.name}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: textClr }}>{cat.average.toFixed(2)}</div>
<div style={{ fontSize: 13, color: place === 1 ? "#ffd700" : place === 2 ? "#c0c0c0" : place === 3 ? "#cd7f32" : mutedClr }}>#{place} of {sameType.length}</div>
<div style={{ fontSize: 13, color: mutedClr }}>#{sortedMusic.findIndex((x) => x.id === r.id) + 1} of {sortedMusic.length}</div>
<div style={{ fontSize: 12, color: "#e6c200", marginTop: 4 }}>
{user ? (
<span onClick={(e) => { e.stopPropagation(); toggleStar(r.id, r.type); }} style={{ cursor: "pointer", fontSize: 16, color: cat.userStarred ? "#e6c200" : mutedClr, lineHeight: 1 }}>{cat.userStarred ? "★" : "☆"} {cat.starCount}</span>
@@ -543,28 +523,32 @@ function RatingsPage({ user }: Props) {
const cw = winWidth <= 768 ? (winWidth - 44) / 2 : 150;
const s = cw / 150;
const sp = (v: number) => Math.round(v * s);
return sorted.map((r) => (
<div
key={r.id}
className="rating-card"
onClick={() => setSelected(r)}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
aspectRatio: "150/196",
border: `1px solid ${borderClr}`,
borderRadius: 6,
background: cardBg,
cursor: "pointer",
padding: `${sp(6)}px ${sp(6)}px ${sp(4)}px`,
textAlign: "center",
position: "relative",
boxSizing: "border-box",
overflow: "hidden",
justifyContent: "space-between",
}}
>
return sorted.map((r) => {
const d = detailMap[r.id];
const starCount = d?.categories[0]?.starCount ?? 0;
const userStarred = user ? (d?.categories[0]?.userStarred ?? false) : false;
return (
<div
key={r.id}
className="rating-card"
onClick={() => handleCardClick(r)}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
aspectRatio: "150/196",
border: `1px solid ${borderClr}`,
borderRadius: 6,
background: cardBg,
cursor: "pointer",
padding: `${sp(6)}px ${sp(6)}px ${sp(4)}px`,
textAlign: "center",
position: "relative",
boxSizing: "border-box",
overflow: "hidden",
justifyContent: "space-between",
}}
>
<div style={{ position: "relative", width: sp(118), height: sp(118), flexShrink: 0, marginTop: sp(4) }}>
{r.icon ? (
<>
@@ -582,20 +566,18 @@ function RatingsPage({ user }: Props) {
)}
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", flex: 1, gap: sp(2) }}>
<div style={{ height: sp(20), flexShrink: 0, fontSize: sp(18), fontWeight: 700, color: textClr, lineHeight: `${sp(20)}px` }}>
{(() => { if (sortMode === "cat") { const rc = selectedCat ? r.categories.find((c) => c.id === selectedCat.id) : r.categories[0]; return rc ? (rc.totalVotes > 0 ? rc.average.toFixed(2) : "-") : "-"; } return r.totalVotes > 0 ? r.average.toFixed(2) : "-"; })()}
</div>
<div style={{ height: sp(14), flexShrink: 0, fontSize: sp(10), lineHeight: `${sp(14)}px` }}>
{(() => { const idx = sorted.findIndex((x) => x.id === r.id); if (idx < 0) return ""; const place = idx + 1; const clr = place === 1 ? "#ffd700" : place === 2 ? "#c0c0c0" : place === 3 ? "#cd7f32" : mutedClr; return <span style={{ color: clr }}>#{place} of {sorted.length}</span>; })()}
<div style={{ height: sp(14), flexShrink: 0, fontSize: sp(10), lineHeight: `${sp(14)}px`, color: mutedClr }}>
{Object.keys(r.userVotes).length > 0 ? "voted" : ""}
</div>
<div style={{ height: sp(14), flexShrink: 0, fontSize: sp(11), color: "#e6c200", lineHeight: `${sp(14)}px` }}>
{(() => { const rc = selectedCat && r.categories.find((c) => c.id === selectedCat.id); const totalStars = r.categories[0]?.starCount ?? 0; const starred = user ? (r.categories[0]?.userStarred ?? false) : false; return user ? (
<span onClick={(e) => { e.stopPropagation(); if (rc) toggleStar(r.id, r.type); }} style={{ cursor: rc ? "pointer" : "default", fontSize: sp(14), color: starred ? "#e6c200" : mutedClr }}>{starred ? "★" : "☆"} {totalStars}</span>
) : <span style={{ fontSize: sp(11) }}> {totalStars}</span>; })()}
{user ? (
<span onClick={(e) => { e.stopPropagation(); if (d) toggleStar(r.id, r.type); }} style={{ cursor: d ? "pointer" : "default", fontSize: sp(14), color: userStarred ? "#e6c200" : mutedClr }}>{userStarred ? "★" : "☆"} {starCount}</span>
) : <span style={{ fontSize: sp(11) }}> {starCount}</span>}
</div>
</div>
</div>
));
);
});
})()}
</div>
)}
@@ -635,8 +617,10 @@ function RatingsPage({ user }: Props) {
<div style={{ display: "flex", flexDirection: "row", justifyContent: "center", gap: 24, marginBottom: 12 }}>
{selected.categories.map((cat) => {
const ranked = [...sameType].sort((a, b) => {
const aCat = a.categories.find((c) => c.id === cat.id);
const bCat = b.categories.find((c) => c.id === cat.id);
const aDetail = detailMap[a.id];
const bDetail = detailMap[b.id];
const aCat = aDetail?.categories.find((c) => c.id === cat.id);
const bCat = bDetail?.categories.find((c) => c.id === cat.id);
return (bCat?.average ?? 0) - (aCat?.average ?? 0);
});
const place = ranked.findIndex((r) => r.id === selected.id) + 1;
+406 -195
View File
@@ -16,6 +16,136 @@ const app = (0, express_1.default)();
exports.app = app;
const prisma = new client_1.PrismaClient();
exports.prisma = prisma;
const cache = {
ratings: new Map(),
ratingTypes: new Map(),
userVotes: new Map(),
anonVotes: new Map(),
userStars: new Map(),
pendingVotes: new Map(),
pendingStars: new Map(),
};
function getAvgAndTotal(ratingId, categoryId) {
let sum = 0, count = 0;
for (const perUser of cache.userVotes.values()) {
const score = perUser.get(ratingId)?.get(categoryId);
if (score) {
sum += score;
count++;
}
}
for (const perIp of cache.anonVotes.values()) {
const score = perIp.get(ratingId)?.get(categoryId);
if (score) {
sum += score;
count++;
}
}
return { average: count > 0 ? sum / count : 0, totalVotes: count };
}
function getStarCount(ratingId, categoryId) {
let count = 0;
for (const perUser of cache.userStars.values()) {
if (perUser.get(ratingId)?.has(categoryId))
count++;
}
return count;
}
function isUserStarred(userId, ratingId, categoryId) {
return cache.userStars.get(userId)?.get(ratingId)?.has(categoryId) ?? false;
}
function getUserVotesForRating(userId, ratingId) {
const result = {};
const perRating = cache.userVotes.get(userId)?.get(ratingId);
if (perRating) {
for (const [catId, score] of perRating)
result[catId] = score;
}
return result;
}
async function rebuildCache() {
const [ratings, ratingTypes, allVotes, anonVotes, allStars] = await Promise.all([
prisma.rating.findMany({ include: { tags: { include: { tag: true } } } }),
prisma.ratingType.findMany({ include: { categories: true } }),
prisma.userRating.findMany(),
prisma.anonymousRatingVote.findMany(),
prisma.ratingStar.findMany(),
]);
cache.ratings.clear();
for (const r of ratings)
cache.ratings.set(r.id, { ...r, tags: r.tags.map(rt => ({ id: rt.tag.id, name: rt.tag.name, color: rt.tag.color })) });
cache.ratingTypes.clear();
for (const t of ratingTypes)
cache.ratingTypes.set(t.name, t);
cache.userVotes.clear();
for (const v of allVotes) {
let u = cache.userVotes.get(v.userId);
if (!u) {
u = new Map();
cache.userVotes.set(v.userId, u);
}
let r = u.get(v.ratingId);
if (!r) {
r = new Map();
u.set(v.ratingId, r);
}
r.set(v.ratingCategoryId, v.score);
}
cache.anonVotes.clear();
for (const v of anonVotes) {
let a = cache.anonVotes.get(v.ip);
if (!a) {
a = new Map();
cache.anonVotes.set(v.ip, a);
}
let r = a.get(v.ratingId);
if (!r) {
r = new Map();
a.set(v.ratingId, r);
}
r.set(v.ratingCategoryId, v.score);
}
cache.userStars.clear();
for (const s of allStars) {
let u = cache.userStars.get(s.userId);
if (!u) {
u = new Map();
cache.userStars.set(s.userId, u);
}
let r = u.get(s.ratingId);
if (!r) {
r = new Set();
u.set(s.ratingId, r);
}
r.add(s.ratingCategoryId);
}
}
async function flushPendingOps() {
const votes = Array.from(cache.pendingVotes.entries());
const stars = Array.from(cache.pendingStars.entries());
cache.pendingVotes.clear();
cache.pendingStars.clear();
for (const [, op] of votes) {
if (op.userId) {
if (op.score === null)
await prisma.userRating.deleteMany({ where: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }).catch(() => { });
else
await prisma.userRating.upsert({ where: { userId_ratingId_ratingCategoryId: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }, create: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId, score: op.score }, update: { score: op.score } }).catch(() => { });
}
else if (op.ip) {
if (op.score === null)
await prisma.anonymousRatingVote.deleteMany({ where: { ip: op.ip, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }).catch(() => { });
else
await prisma.anonymousRatingVote.upsert({ where: { ip_ratingId_ratingCategoryId: { ip: op.ip, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }, create: { ip: op.ip, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId, score: op.score }, update: { score: op.score } }).catch(() => { });
}
}
for (const [, op] of stars) {
if (op.starred)
await prisma.ratingStar.upsert({ where: { userId_ratingId_ratingCategoryId: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }, create: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId }, update: {} }).catch(() => { });
else
await prisma.ratingStar.deleteMany({ where: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }).catch(() => { });
}
}
const uploadsDir = path_1.default.join(__dirname, "../uploads");
if (!fs_1.default.existsSync(uploadsDir))
fs_1.default.mkdirSync(uploadsDir, { recursive: true });
@@ -24,6 +154,32 @@ app.use((0, cors_1.default)());
app.use("/uploads", express_1.default.static(uploadsDir));
app.use(express_1.default.json({ limit: "100mb" }));
const upload = (0, multer_1.default)({ dest: uploadsDir, limits: { fileSize: 200 * 1024 * 1024 } });
const rateLimitStore = new Map();
setInterval(() => {
const cutoff = Date.now() - 60000;
for (const [key, times] of rateLimitStore) {
const filtered = times.filter(t => t > cutoff);
if (filtered.length === 0)
rateLimitStore.delete(key);
else
rateLimitStore.set(key, filtered);
}
}, 60000);
app.use((req, res, next) => {
const userId = req.body?.userId || req.query?.userId || undefined;
const key = userId || getIp(req);
const now = Date.now();
const cutoff = now - 60000;
let times = rateLimitStore.get(key) || [];
times = times.filter(t => t > cutoff);
if (times.length >= 60) {
res.status(429).json({ error: "rate limit exceeded" });
return;
}
times.push(now);
rateLimitStore.set(key, times);
next();
});
async function saveImage(base64) {
if (!base64 || base64.startsWith("http") || base64.startsWith("/uploads"))
return base64;
@@ -46,6 +202,19 @@ async function saveImage(base64) {
function getIp(req) {
return req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.ip || "unknown";
}
async function requireAdmin(req, res) {
const userId = req.body?.userId || req.query?.userId || undefined;
if (!userId) {
res.status(401).json({ error: "unauthorized" });
return false;
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || user.email !== "invoictoan@gmail.com") {
res.status(403).json({ error: "forbidden" });
return false;
}
return true;
}
app.get("/api/health", (_req, res) => {
res.json({ status: "ok" });
});
@@ -113,201 +282,151 @@ app.put("/api/user", async (req, res) => {
}
res.json({ id: user.id, email: user.email });
});
app.get("/api/ratings", async (req, res) => {
app.get("/api/ratings", (req, res) => {
const userId = req.query.userId;
const period = req.query.period;
let cutoff;
if (period === "7d")
cutoff = new Date(Date.now() - 7 * 86400000);
else if (period === "30d")
cutoff = new Date(Date.now() - 30 * 86400000);
const ratings = await prisma.rating.findMany({ include: { tags: { include: { tag: true } } } });
const ratingTypes = await prisma.ratingType.findMany({ include: { categories: true } });
const typeMap = {};
for (const t of ratingTypes)
typeMap[t.name] = t;
const userRatings = userId
? await prisma.userRating.findMany({ where: { userId } })
: [];
const userVoteMap = {};
for (const ur of userRatings) {
if (!userVoteMap[ur.ratingId])
userVoteMap[ur.ratingId] = {};
userVoteMap[ur.ratingId][ur.ratingCategoryId] = ur.score;
}
const allRatings = cutoff
? await prisma.userRating.findMany({ where: { createdAt: { gte: cutoff } } })
: await prisma.userRating.findMany();
const anonRatings = cutoff
? await prisma.anonymousRatingVote.findMany({ where: { createdAt: { gte: cutoff } } })
: await prisma.anonymousRatingVote.findMany();
const catTallyMap = {};
for (const ur of allRatings) {
if (!catTallyMap[ur.ratingId])
catTallyMap[ur.ratingId] = {};
if (!catTallyMap[ur.ratingId][ur.ratingCategoryId])
catTallyMap[ur.ratingId][ur.ratingCategoryId] = Array(10).fill(0);
catTallyMap[ur.ratingId][ur.ratingCategoryId][ur.score - 1]++;
}
for (const av of anonRatings) {
if (!catTallyMap[av.ratingId])
catTallyMap[av.ratingId] = {};
if (!catTallyMap[av.ratingId][av.ratingCategoryId])
catTallyMap[av.ratingId][av.ratingCategoryId] = Array(10).fill(0);
catTallyMap[av.ratingId][av.ratingCategoryId][av.score - 1]++;
}
const allStars = await prisma.ratingStar.groupBy({ by: ["ratingId", "ratingCategoryId"], _count: true });
const starCountMap = {};
for (const s of allStars) {
if (!starCountMap[s.ratingId])
starCountMap[s.ratingId] = {};
starCountMap[s.ratingId][s.ratingCategoryId] = s._count;
}
const userStars = userId
? await prisma.ratingStar.findMany({ where: { userId } })
: [];
const userStarMap = {};
for (const us of userStars) {
if (!userStarMap[us.ratingId])
userStarMap[us.ratingId] = new Set();
userStarMap[us.ratingId].add(us.ratingCategoryId);
}
const userStarCountPerCategory = {};
for (const us of userStars) {
userStarCountPerCategory[us.ratingCategoryId] = (userStarCountPerCategory[us.ratingCategoryId] || 0) + 1;
}
const result = ratings.map((r) => {
const type = typeMap[r.type];
const categories = type?.categories ?? [];
let catData = categories.map((c) => {
const counts = catTallyMap[r.id]?.[c.id] || Array(10).fill(0);
const total = counts.reduce((s, v) => s + v, 0);
const sum = counts.reduce((s, v, i) => s + v * (i + 1), 0);
return { id: c.id, name: c.name, average: total > 0 ? sum / total : 0, totalVotes: total, userVote: userVoteMap[r.id]?.[c.id] ?? null, starCount: starCountMap[r.id]?.[r.type] ?? 0, userStarred: !!userStarMap[r.id]?.has(r.type) };
});
if (catData.length === 0 && type?.hasAttachments) {
const merged = Array(10).fill(0);
for (const v of allRatings.filter(u => u.ratingId === r.id))
merged[v.score - 1]++;
for (const v of anonRatings.filter(u => u.ratingId === r.id))
merged[v.score - 1]++;
const total = merged.reduce((s, v) => s + v, 0);
const sum = merged.reduce((s, v, i) => s + v * (i + 1), 0);
const userEntry = userRatings.find(u => u.ratingId === r.id);
catData = [{ id: "score", name: "Score", average: total > 0 ? sum / total : 0, totalVotes: total, userVote: userEntry?.score ?? null, starCount: starCountMap[r.id]?.[r.type] ?? 0, userStarred: !!userStarMap[r.id]?.has(r.type) }];
}
const totalVotes = catData.reduce((s, c) => s + c.totalVotes, 0);
const totalSum = catData.reduce((s, c) => s + c.average * c.totalVotes, 0);
const tags = r.tags.map((rt) => ({ id: rt.tag.id, name: rt.tag.name, color: rt.tag.color }));
return { ...r, categories: catData, average: totalVotes > 0 ? totalSum / totalVotes : 0, totalVotes, tags };
});
res.json(result);
const type = req.query.type;
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(10000, Math.max(1, parseInt(req.query.limit) || 50));
let items = Array.from(cache.ratings.values());
if (type)
items = items.filter(r => r.type === type);
const result = items.map(r => ({ ...r, userVotes: userId ? getUserVotesForRating(userId, r.id) : {} }));
const total = result.length;
const start = (page - 1) * limit;
res.json({ items: result.slice(start, start + limit), total, page, totalPages: Math.ceil(total / limit) });
});
app.get("/api/rating-distribution/:ratingId", async (req, res) => {
app.get("/api/rating-detail/:ratingId", (req, res) => {
const { ratingId } = req.params;
const period = req.query.period;
let cutoff;
if (period === "7d")
cutoff = new Date(Date.now() - 7 * 86400000);
else if (period === "30d")
cutoff = new Date(Date.now() - 30 * 86400000);
const rating = await prisma.rating.findUnique({ where: { id: ratingId } });
const userId = req.query.userId;
const rating = cache.ratings.get(ratingId);
if (!rating) {
res.status(404).json({ error: "Rating not found" });
res.status(404).json({ error: "not found" });
return;
}
const ratingType = await prisma.ratingType.findUnique({ where: { name: rating.type } });
const categories = ratingType ? await prisma.ratingCategory.findMany({ where: { ratingTypeId: ratingType.id } }) : [];
const allRatings = cutoff
? await prisma.userRating.findMany({ where: { ratingId, createdAt: { gte: cutoff } } })
: await prisma.userRating.findMany({ where: { ratingId } });
const anonRatings = cutoff
? await prisma.anonymousRatingVote.findMany({ where: { ratingId, createdAt: { gte: cutoff } } })
: await prisma.anonymousRatingVote.findMany({ where: { ratingId } });
const accountTally = {};
const anonTally = {};
for (const ur of allRatings) {
if (!accountTally[ur.ratingCategoryId])
accountTally[ur.ratingCategoryId] = Array(10).fill(0);
accountTally[ur.ratingCategoryId][ur.score - 1]++;
const type = cache.ratingTypes.get(rating.type);
const categories = type?.categories ?? [];
const catData = categories.map(c => {
const { average, totalVotes } = getAvgAndTotal(ratingId, c.id);
return { id: c.id, name: c.name, average, totalVotes, userVote: cache.userVotes.get(userId || "")?.get(ratingId)?.get(c.id) ?? null, starCount: getStarCount(ratingId, c.id), userStarred: userId ? isUserStarred(userId, ratingId, c.id) : false };
});
if (catData.length === 0 && type?.hasAttachments) {
const { average, totalVotes } = getAvgAndTotal(ratingId, "score");
catData.push({ id: "score", name: "Score", average, totalVotes, userVote: cache.userVotes.get(userId || "")?.get(ratingId)?.get("score") ?? null, starCount: getStarCount(ratingId, "score"), userStarred: userId ? isUserStarred(userId, ratingId, "score") : false });
}
for (const av of anonRatings) {
if (!anonTally[av.ratingCategoryId])
anonTally[av.ratingCategoryId] = Array(10).fill(0);
anonTally[av.ratingCategoryId][av.score - 1]++;
}
if (categories.length === 0) {
const mergedAc = Array(10).fill(0);
const mergedAn = Array(10).fill(0);
for (const v of allRatings)
mergedAc[v.score - 1]++;
for (const v of anonRatings)
mergedAn[v.score - 1]++;
const total = mergedAc.reduce((s, v) => s + v, 0) + mergedAn.reduce((s, v) => s + v, 0);
const sum = mergedAc.reduce((s, v, i) => s + v * (i + 1), 0) + mergedAn.reduce((s, v, i) => s + v * (i + 1), 0);
res.json({ categories: [{ id: "score", name: "Score", accountRatings: mergedAc, anonRatings: mergedAn, average: total > 0 ? sum / total : 0, totalVotes: total }] });
const totalVotes = catData.reduce((s, c) => s + c.totalVotes, 0);
const totalSum = catData.reduce((s, c) => s + c.average * c.totalVotes, 0);
res.json({ ...rating, categories: catData, average: totalVotes > 0 ? totalSum / totalVotes : 0, totalVotes, userVotes: userId ? getUserVotesForRating(userId, ratingId) : {} });
});
app.get("/api/rating-distribution/:ratingId", (req, res) => {
const { ratingId } = req.params;
const rating = cache.ratings.get(ratingId);
if (!rating) {
res.status(404).json({ error: "not found" });
return;
}
const catData = categories.map((c) => {
const ac = accountTally[c.id] || Array(10).fill(0);
const an = anonTally[c.id] || Array(10).fill(0);
const totalAc = ac.reduce((s, v) => s + v, 0);
const totalAn = an.reduce((s, v) => s + v, 0);
const total = totalAc + totalAn;
const sum = ac.reduce((s, v, i) => s + v * (i + 1), 0) + an.reduce((s, v, i) => s + v * (i + 1), 0);
return { id: c.id, name: c.name, accountRatings: ac, anonRatings: an, average: total > 0 ? sum / total : 0, totalVotes: total };
const type = cache.ratingTypes.get(rating.type);
const categories = type ? type.categories : [];
if (categories.length === 0 && type?.hasAttachments) {
const accountRatings = Array(10).fill(0);
const anonRatings = Array(10).fill(0);
for (const perUser of cache.userVotes.values()) {
const score = perUser.get(ratingId)?.get("score");
if (score)
accountRatings[score - 1]++;
}
for (const perIp of cache.anonVotes.values()) {
const score = perIp.get(ratingId)?.get("score");
if (score)
anonRatings[score - 1]++;
}
const total = accountRatings.reduce((s, v) => s + v, 0) + anonRatings.reduce((s, v) => s + v, 0);
const sum = accountRatings.reduce((s, v, i) => s + v * (i + 1), 0) + anonRatings.reduce((s, v, i) => s + v * (i + 1), 0);
res.json({ categories: [{ id: "score", name: "Score", accountRatings, anonRatings, average: total > 0 ? sum / total : 0, totalVotes: total }] });
return;
}
const catData = categories.map(c => {
const accountRatings = Array(10).fill(0);
const anonRatings = Array(10).fill(0);
for (const perUser of cache.userVotes.values()) {
const score = perUser.get(ratingId)?.get(c.id);
if (score)
accountRatings[score - 1]++;
}
for (const perIp of cache.anonVotes.values()) {
const score = perIp.get(ratingId)?.get(c.id);
if (score)
anonRatings[score - 1]++;
}
const total = accountRatings.reduce((s, v) => s + v, 0) + anonRatings.reduce((s, v) => s + v, 0);
const sum = accountRatings.reduce((s, v, i) => s + v * (i + 1), 0) + anonRatings.reduce((s, v, i) => s + v * (i + 1), 0);
return { id: c.id, name: c.name, accountRatings, anonRatings, average: total > 0 ? sum / total : 0, totalVotes: total };
});
res.json({ categories: catData });
});
app.post("/api/star", async (req, res) => {
app.post("/api/star", (req, res) => {
const { userId, ratingId, ratingCategoryId } = req.body;
if (!userId || !ratingId) {
res.status(400).json({ error: "userId and ratingId required" });
return;
}
const existing = await prisma.ratingStar.findUnique({ where: { userId_ratingId_ratingCategoryId: { userId, ratingId, ratingCategoryId: ratingCategoryId || "score" } } });
if (existing) {
await prisma.ratingStar.delete({ where: { id: existing.id } });
const catId = ratingCategoryId || "score";
const key = `${userId}:${ratingId}:${catId}`;
let perUser = cache.userStars.get(userId);
if (!perUser) {
perUser = new Map();
cache.userStars.set(userId, perUser);
}
let cats = perUser.get(ratingId);
if (!cats) {
cats = new Set();
perUser.set(ratingId, cats);
}
if (cats.has(catId)) {
cats.delete(catId);
cache.pendingStars.set(key, { userId, ratingId, ratingCategoryId: catId, starred: false });
res.json({ starred: false });
}
else {
const catId = ratingCategoryId || "score";
const starCount = await prisma.ratingStar.count({ where: { userId, ratingCategoryId: catId } });
if (starCount >= 3) {
let catCount = 0;
for (const sci of perUser.values()) {
if (sci.has(catId))
catCount++;
}
if (catCount >= 3) {
res.status(400).json({ error: "Max 3 stars per category" });
return;
}
await prisma.ratingStar.create({ data: { userId, ratingId, ratingCategoryId: catId } });
cats.add(catId);
cache.pendingStars.set(key, { userId, ratingId, ratingCategoryId: catId, starred: true });
res.json({ starred: true });
}
});
app.get("/api/my-stars", async (req, res) => {
app.get("/api/my-stars", (req, res) => {
const userId = req.query.userId;
if (!userId) {
res.json([]);
return;
}
const stars = await prisma.ratingStar.findMany({ where: { userId } });
const perUser = cache.userStars.get(userId);
const map = {};
for (const s of stars) {
if (!map[s.ratingId])
map[s.ratingId] = [];
map[s.ratingId].push(s.ratingCategoryId);
if (perUser) {
for (const [ratingId, cats] of perUser)
map[ratingId] = Array.from(cats);
}
res.json(map);
});
app.post("/api/vote", async (req, res) => {
app.post("/api/vote", (req, res) => {
let { userId, ratingId, ratingCategoryId, score } = req.body;
if (!ratingId || !score || score < 1 || score > 10) {
res.status(400).json({ error: "ratingId and score (1-10) are required" });
return;
}
if (!ratingCategoryId) {
const rating = await prisma.rating.findUnique({ where: { id: ratingId } });
const rating = cache.ratings.get(ratingId);
if (rating) {
const rtype = await prisma.ratingType.findUnique({ where: { name: rating.type } });
const rtype = cache.ratingTypes.get(rating.type);
if (rtype?.hasAttachments) {
const cat = await prisma.ratingCategory.findFirst({ where: { ratingTypeId: rtype.id } });
const cat = rtype.categories[0];
if (cat)
ratingCategoryId = cat.id;
}
@@ -318,61 +437,69 @@ app.post("/api/vote", async (req, res) => {
return;
}
if (userId) {
await prisma.userRating.upsert({
where: { userId_ratingId_ratingCategoryId: { userId, ratingId, ratingCategoryId } },
update: { score },
create: { userId, ratingId, ratingCategoryId, score },
});
let u = cache.userVotes.get(userId);
if (!u) {
u = new Map();
cache.userVotes.set(userId, u);
}
let r = u.get(ratingId);
if (!r) {
r = new Map();
u.set(ratingId, r);
}
r.set(ratingCategoryId, score);
cache.pendingVotes.set(`${userId}:${ratingId}:${ratingCategoryId}`, { userId, ratingId, ratingCategoryId, score });
}
else {
const ip = getIp(req);
await prisma.anonymousRatingVote.upsert({
where: { ip_ratingId_ratingCategoryId: { ip, ratingId, ratingCategoryId } },
update: { score },
create: { ip, ratingId, ratingCategoryId, score },
});
let a = cache.anonVotes.get(ip);
if (!a) {
a = new Map();
cache.anonVotes.set(ip, a);
}
let r = a.get(ratingId);
if (!r) {
r = new Map();
a.set(ratingId, r);
}
r.set(ratingCategoryId, score);
cache.pendingVotes.set(`${ip}:${ratingId}:${ratingCategoryId}`, { ip, ratingId, ratingCategoryId, score });
}
const allVotes = await prisma.userRating.findMany({ where: { ratingId, ratingCategoryId } });
const anonVotes = await prisma.anonymousRatingVote.findMany({ where: { ratingId, ratingCategoryId } });
const totalVotes = allVotes.length + anonVotes.length;
const sum = allVotes.reduce((s, v) => s + v.score, 0) + anonVotes.reduce((s, v) => s + v.score, 0);
res.json({ average: totalVotes > 0 ? sum / totalVotes : 0, totalVotes });
res.json(getAvgAndTotal(ratingId, ratingCategoryId));
});
app.post("/api/unvote", async (req, res) => {
app.post("/api/unvote", (req, res) => {
const { userId, ratingId, ratingCategoryId } = req.body;
if (!ratingId || !ratingCategoryId) {
res.status(400).json({ error: "ratingId and ratingCategoryId are required" });
return;
}
if (userId) {
await prisma.userRating.deleteMany({
where: { userId, ratingId, ratingCategoryId },
});
cache.userVotes.get(userId)?.get(ratingId)?.delete(ratingCategoryId);
cache.pendingVotes.set(`${userId}:${ratingId}:${ratingCategoryId}`, { userId, ratingId, ratingCategoryId, score: null });
}
else {
const ip = getIp(req);
await prisma.anonymousRatingVote.deleteMany({
where: { ip, ratingId, ratingCategoryId },
});
cache.anonVotes.get(ip)?.get(ratingId)?.delete(ratingCategoryId);
cache.pendingVotes.set(`${ip}:${ratingId}:${ratingCategoryId}`, { ip, ratingId, ratingCategoryId, score: null });
}
const allVotes = await prisma.userRating.findMany({ where: { ratingId, ratingCategoryId } });
const anonVotes = await prisma.anonymousRatingVote.findMany({ where: { ratingId, ratingCategoryId } });
const totalVotes = allVotes.length + anonVotes.length;
const sum = allVotes.reduce((s, v) => s + v.score, 0) + anonVotes.reduce((s, v) => s + v.score, 0);
res.json({ average: totalVotes > 0 ? sum / totalVotes : 0, totalVotes });
res.json(getAvgAndTotal(ratingId, ratingCategoryId));
});
app.get("/api/anonymous-votes/:ratingId", async (req, res) => {
app.get("/api/anonymous-votes/:ratingId", (req, res) => {
const { ratingId } = req.params;
const ip = getIp(req);
const votes = await prisma.anonymousRatingVote.findMany({ where: { ip, ratingId } });
const perIp = cache.anonVotes.get(ip);
const map = {};
for (const v of votes) {
map[v.ratingCategoryId] = v.score;
if (perIp) {
const perRating = perIp.get(ratingId);
if (perRating) {
for (const [c, s] of perRating)
map[c] = s;
}
}
if (Object.keys(map).length > 0) {
const rating = await prisma.rating.findUnique({ where: { id: ratingId } });
const rating = cache.ratings.get(ratingId);
if (rating) {
const rtype = await prisma.ratingType.findUnique({ where: { name: rating.type } });
const rtype = cache.ratingTypes.get(rating.type);
if (rtype?.hasAttachments && !map.score) {
map.score = Object.values(map)[0];
}
@@ -380,12 +507,15 @@ app.get("/api/anonymous-votes/:ratingId", async (req, res) => {
}
res.json(map);
});
app.get("/api/my-anonymous-votes", async (req, res) => {
app.get("/api/my-anonymous-votes", (req, res) => {
const ip = getIp(req);
const votes = await prisma.anonymousRatingVote.findMany({ where: { ip } });
const perIp = cache.anonVotes.get(ip);
const map = {};
for (const v of votes) {
map[`${v.ratingId}_${v.ratingCategoryId}`] = v.score;
if (perIp) {
for (const [ratingId, cats] of perIp) {
for (const [catId, score] of cats)
map[`${ratingId}_${catId}`] = score;
}
}
res.json(map);
});
@@ -407,7 +537,9 @@ app.post("/api/reports", async (req, res) => {
const report = await prisma.report.create({ data: { userId, subject, text } });
res.json(report);
});
app.get("/api/admin/reports", async (_req, res) => {
app.get("/api/admin/reports", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const reports = await prisma.report.findMany({
include: { user: { select: { email: true } } },
orderBy: { createdAt: "desc" },
@@ -415,11 +547,15 @@ app.get("/api/admin/reports", async (_req, res) => {
res.json(reports);
});
app.delete("/api/admin/reports/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
await prisma.report.delete({ where: { id } });
res.json({ success: true });
});
app.post("/api/admin/ratings", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { type, name, description, picture, icon, attachments } = req.body;
if (!type || !name) {
res.status(400).json({ error: "type and name are required" });
@@ -441,6 +577,8 @@ async function saveFile(base64) {
return `/uploads/${filename}`;
}
app.put("/api/admin/ratings/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { name, description, picture, icon, attachments } = req.body;
const data = {};
@@ -458,6 +596,8 @@ app.put("/api/admin/ratings/:id", async (req, res) => {
res.json(rating);
});
app.delete("/api/admin/ratings/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.rating.delete({ where: { id } });
@@ -468,6 +608,8 @@ app.get("/api/rating-types", async (_req, res) => {
res.json(types);
});
app.post("/api/admin/rating-types", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { name } = req.body;
if (!name) {
res.status(400).json({ error: "name is required" });
@@ -478,6 +620,8 @@ app.post("/api/admin/rating-types", async (req, res) => {
res.json(t);
});
app.put("/api/admin/rating-types/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { hasAttachments } = req.body;
const data = {};
@@ -487,6 +631,8 @@ app.put("/api/admin/rating-types/:id", async (req, res) => {
res.json(t);
});
app.delete("/api/admin/rating-types/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingCategory: { ratingTypeId: id } } });
await prisma.ratingCategory.deleteMany({ where: { ratingTypeId: id } });
@@ -494,6 +640,8 @@ app.delete("/api/admin/rating-types/:id", async (req, res) => {
res.json({ success: true });
});
app.put("/api/admin/rating-types/reorder", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).json({ error: "ids array is required" });
@@ -505,6 +653,8 @@ app.put("/api/admin/rating-types/reorder", async (req, res) => {
res.json({ success: true });
});
app.post("/api/admin/rating-types/:id/categories", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { name } = req.body;
if (!name) {
@@ -515,12 +665,16 @@ app.post("/api/admin/rating-types/:id/categories", async (req, res) => {
res.json(cat);
});
app.delete("/api/admin/rating-types/:typeId/categories/:categoryId", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { categoryId } = req.params;
await prisma.userRating.deleteMany({ where: { ratingCategoryId: categoryId } });
await prisma.ratingCategory.delete({ where: { id: categoryId } });
res.json({ success: true });
});
app.put("/api/admin/rating-types/:typeId/categories/:categoryId", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { categoryId } = req.params;
const { name } = req.body;
if (!name) {
@@ -535,6 +689,8 @@ app.get("/api/tags", async (_req, res) => {
res.json(tags);
});
app.post("/api/admin/tags", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { name, color, ratingTypeId } = req.body;
if (!name || !color || !ratingTypeId) {
res.status(400).json({ error: "name, color, and ratingTypeId are required" });
@@ -544,6 +700,8 @@ app.post("/api/admin/tags", async (req, res) => {
res.json(tag);
});
app.put("/api/admin/tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { name, color, ratingTypeId } = req.body;
if (!name || !color || !ratingTypeId) {
@@ -554,6 +712,8 @@ app.put("/api/admin/tags/:id", async (req, res) => {
res.json(tag);
});
app.delete("/api/admin/tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
await prisma.ratingTag.deleteMany({ where: { tagId: id } });
await prisma.tag.delete({ where: { id } });
@@ -565,6 +725,8 @@ app.get("/api/tags-by-type/:ratingTypeId", async (req, res) => {
res.json(tags);
});
app.post("/api/admin/ratings/:id/tags", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { tagId } = req.body;
if (!tagId) {
@@ -579,11 +741,15 @@ app.post("/api/admin/ratings/:id/tags", async (req, res) => {
res.json({ success: true });
});
app.delete("/api/admin/ratings/:id/tags/:tagId", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id, tagId } = req.params;
await prisma.ratingTag.delete({ where: { ratingId_tagId: { ratingId: id, tagId } } });
res.json({ success: true });
});
app.post("/api/admin/upload-attachments", upload.array("files"), async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const files = req.files;
const typeName = req.body.type || "music";
if (!files || !files.length) {
@@ -614,6 +780,8 @@ app.post("/api/admin/upload-attachments", upload.array("files"), async (req, res
res.json(created);
});
app.delete("/api/admin/music/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingId: id } });
@@ -759,6 +927,8 @@ app.get("/api/my-anonymous-bets", async (req, res) => {
res.json(map);
});
app.post("/api/admin/bets", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { name, description, image, categoryId } = req.body;
if (!name) {
res.status(400).json({ error: "name is required" });
@@ -768,6 +938,8 @@ app.post("/api/admin/bets", async (req, res) => {
res.json(bet);
});
app.put("/api/admin/bets/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { name, description, image, categoryId } = req.body;
const data = {};
@@ -783,6 +955,8 @@ app.put("/api/admin/bets/:id", async (req, res) => {
res.json(bet);
});
app.delete("/api/admin/bets/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
await prisma.betToTag.deleteMany({ where: { betId: id } });
const entryIds = (await prisma.betEntry.findMany({ where: { betId: id }, select: { id: true } })).map(e => e.id);
@@ -792,11 +966,15 @@ app.delete("/api/admin/bets/:id", async (req, res) => {
await prisma.bet.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/admin/bet-categories", async (_req, res) => {
app.get("/api/admin/bet-categories", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const cats = await prisma.betCategory.findMany({ orderBy: { id: "asc" } });
res.json(cats);
});
app.post("/api/admin/bet-categories", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { name } = req.body;
if (!name) {
res.status(400).json({ error: "name is required" });
@@ -806,6 +984,8 @@ app.post("/api/admin/bet-categories", async (req, res) => {
res.json(cat);
});
app.put("/api/admin/bet-categories/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { name } = req.body;
if (!name) {
@@ -816,6 +996,8 @@ app.put("/api/admin/bet-categories/:id", async (req, res) => {
res.json(cat);
});
app.delete("/api/admin/bet-categories/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const bets = await prisma.bet.findMany({ where: { categoryId: id } });
const betIds = bets.map(b => b.id);
@@ -829,6 +1011,8 @@ app.delete("/api/admin/bet-categories/:id", async (req, res) => {
res.json({ success: true });
});
app.put("/api/admin/bets/:id/complete", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { resultEntryId } = req.body;
if (!resultEntryId) {
@@ -842,6 +1026,8 @@ app.put("/api/admin/bets/:id/complete", async (req, res) => {
res.json(bet);
});
app.post("/api/admin/bets/:id/entries", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { option } = req.body;
if (!option) {
@@ -852,6 +1038,8 @@ app.post("/api/admin/bets/:id/entries", async (req, res) => {
res.json(entry);
});
app.delete("/api/admin/bets/:betId/entries/:entryId", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { entryId } = req.params;
await prisma.anonymousBetVote.deleteMany({ where: { betEntryId: entryId } });
await prisma.userBet.deleteMany({ where: { betEntryId: entryId } });
@@ -863,6 +1051,8 @@ app.get("/api/bet-tags", async (_req, res) => {
res.json(tags);
});
app.post("/api/admin/bet-tags", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { name, color, image } = req.body;
if (!name) {
res.status(400).json({ error: "name is required" });
@@ -872,12 +1062,16 @@ app.post("/api/admin/bet-tags", async (req, res) => {
res.json(tag);
});
app.delete("/api/admin/bet-tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
await prisma.betToTag.deleteMany({ where: { tagId: id } });
await prisma.betTag.delete({ where: { id } });
res.json({ success: true });
});
app.post("/api/admin/bets/:id/tags", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { tagId } = req.body;
if (!tagId) {
@@ -892,15 +1086,21 @@ app.post("/api/admin/bets/:id/tags", async (req, res) => {
res.json({ success: true });
});
app.delete("/api/admin/bets/:id/tags/:tagId", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id, tagId } = req.params;
await prisma.betToTag.delete({ where: { betId_tagId: { betId: id, tagId } } });
res.json({ success: true });
});
app.get("/api/admin/assets", (_req, res) => {
app.get("/api/admin/assets", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const files = fs_1.default.readdirSync(uploadsDir).filter((f) => f !== "logo.png").map((f) => ({ name: f, url: `/uploads/${f}` }));
res.json(files);
});
app.post("/api/admin/assets", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { image } = req.body;
if (!image) {
res.status(400).json({ error: "image is required" });
@@ -909,7 +1109,9 @@ app.post("/api/admin/assets", async (req, res) => {
const url = await saveImage(image);
res.json({ name: url.replace("/uploads/", ""), url });
});
app.delete("/api/admin/assets/:filename", (req, res) => {
app.delete("/api/admin/assets/:filename", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { filename } = req.params;
if (filename === "logo.png") {
res.status(403).json({ error: "Cannot delete logo" });
@@ -925,6 +1127,8 @@ app.get("/api/site-updates", async (_req, res) => {
res.json(updates);
});
app.post("/api/admin/site-updates", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { title, text } = req.body;
if (!title) {
res.status(400).json({ error: "Title required" });
@@ -934,6 +1138,8 @@ app.post("/api/admin/site-updates", async (req, res) => {
res.json(update);
});
app.put("/api/admin/site-updates/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
const { title, text } = req.body;
if (!title) {
@@ -944,11 +1150,16 @@ app.put("/api/admin/site-updates/:id", async (req, res) => {
res.json(update);
});
app.delete("/api/admin/site-updates/:id", async (req, res) => {
if (!(await requireAdmin(req, res)))
return;
const { id } = req.params;
await prisma.siteUpdate.delete({ where: { id } });
res.json({ success: true });
});
const PORT = process.env.PORT ?? 4000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
rebuildCache().then(() => {
setInterval(flushPendingOps, 300000);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
});
+259 -186
View File
@@ -11,6 +11,106 @@ import fs from "fs";
const app = express();
const prisma = new PrismaClient();
type RatingCacheEntry = { id: string; type: string; name: string; picture: string; description: string; icon: string; attachments: string; tags: { id: string; name: string; color: string }[] };
type PendingVoteOp = { userId?: string; ip?: string; ratingId: string; ratingCategoryId: string; score: number | null };
type PendingStarOp = { userId: string; ratingId: string; ratingCategoryId: string; starred: boolean };
const cache = {
ratings: new Map<string, RatingCacheEntry>(),
ratingTypes: new Map<string, { id: string; name: string; hasAttachments: boolean; categories: { id: string; name: string }[] }>(),
userVotes: new Map<string, Map<string, Map<string, number>>>(),
anonVotes: new Map<string, Map<string, Map<string, number>>>(),
userStars: new Map<string, Map<string, Set<string>>>(),
pendingVotes: new Map<string, PendingVoteOp>(),
pendingStars: new Map<string, PendingStarOp>(),
};
function getAvgAndTotal(ratingId: string, categoryId: string) {
let sum = 0, count = 0;
for (const perUser of cache.userVotes.values()) {
const score = perUser.get(ratingId)?.get(categoryId);
if (score) { sum += score; count++; }
}
for (const perIp of cache.anonVotes.values()) {
const score = perIp.get(ratingId)?.get(categoryId);
if (score) { sum += score; count++; }
}
return { average: count > 0 ? sum / count : 0, totalVotes: count };
}
function getStarCount(ratingId: string, categoryId: string) {
let count = 0;
for (const perUser of cache.userStars.values()) {
if (perUser.get(ratingId)?.has(categoryId)) count++;
}
return count;
}
function isUserStarred(userId: string, ratingId: string, categoryId: string) {
return cache.userStars.get(userId)?.get(ratingId)?.has(categoryId) ?? false;
}
function getUserVotesForRating(userId: string, ratingId: string): Record<string, number> {
const result: Record<string, number> = {};
const perRating = cache.userVotes.get(userId)?.get(ratingId);
if (perRating) {
for (const [catId, score] of perRating) result[catId] = score;
}
return result;
}
async function rebuildCache() {
const [ratings, ratingTypes, allVotes, anonVotes, allStars] = await Promise.all([
prisma.rating.findMany({ include: { tags: { include: { tag: true } } } }),
prisma.ratingType.findMany({ include: { categories: true } }),
prisma.userRating.findMany(),
prisma.anonymousRatingVote.findMany(),
prisma.ratingStar.findMany(),
]);
cache.ratings.clear();
for (const r of ratings) cache.ratings.set(r.id, { ...r, tags: r.tags.map(rt => ({ id: rt.tag.id, name: rt.tag.name, color: rt.tag.color })) });
cache.ratingTypes.clear();
for (const t of ratingTypes) cache.ratingTypes.set(t.name, t);
cache.userVotes.clear();
for (const v of allVotes) {
let u = cache.userVotes.get(v.userId); if (!u) { u = new Map(); cache.userVotes.set(v.userId, u); }
let r = u.get(v.ratingId); if (!r) { r = new Map(); u.set(v.ratingId, r); }
r.set(v.ratingCategoryId, v.score);
}
cache.anonVotes.clear();
for (const v of anonVotes) {
let a = cache.anonVotes.get(v.ip); if (!a) { a = new Map(); cache.anonVotes.set(v.ip, a); }
let r = a.get(v.ratingId); if (!r) { r = new Map(); a.set(v.ratingId, r); }
r.set(v.ratingCategoryId, v.score);
}
cache.userStars.clear();
for (const s of allStars) {
let u = cache.userStars.get(s.userId); if (!u) { u = new Map(); cache.userStars.set(s.userId, u); }
let r = u.get(s.ratingId); if (!r) { r = new Set(); u.set(s.ratingId, r); }
r.add(s.ratingCategoryId);
}
}
async function flushPendingOps() {
const votes = Array.from(cache.pendingVotes.entries());
const stars = Array.from(cache.pendingStars.entries());
cache.pendingVotes.clear();
cache.pendingStars.clear();
for (const [, op] of votes) {
if (op.userId) {
if (op.score === null) await prisma.userRating.deleteMany({ where: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }).catch(() => {});
else await prisma.userRating.upsert({ where: { userId_ratingId_ratingCategoryId: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }, create: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId, score: op.score }, update: { score: op.score } }).catch(() => {});
} else if (op.ip) {
if (op.score === null) await prisma.anonymousRatingVote.deleteMany({ where: { ip: op.ip, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }).catch(() => {});
else await prisma.anonymousRatingVote.upsert({ where: { ip_ratingId_ratingCategoryId: { ip: op.ip, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }, create: { ip: op.ip, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId, score: op.score }, update: { score: op.score } }).catch(() => {});
}
}
for (const [, op] of stars) {
if (op.starred) await prisma.ratingStar.upsert({ where: { userId_ratingId_ratingCategoryId: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }, create: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId }, update: {} }).catch(() => {});
else await prisma.ratingStar.deleteMany({ where: { userId: op.userId, ratingId: op.ratingId, ratingCategoryId: op.ratingCategoryId } }).catch(() => {});
}
}
const uploadsDir = path.join(__dirname, "../uploads");
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
@@ -67,6 +167,16 @@ function getIp(req: express.Request): string {
return (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || "unknown";
}
async function requireAdmin(req: express.Request, res: express.Response): Promise<boolean> {
const userId = req.body?.userId || (req.query?.userId as string) || undefined;
if (!userId) { res.status(401).json({ error: "unauthorized" }); return false; }
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || user.email !== "invoictoan@gmail.com") {
res.status(403).json({ error: "forbidden" }); return false;
}
return true;
}
app.get("/api/health", (_req, res) => {
res.json({ status: "ok" });
});
@@ -135,183 +245,115 @@ app.put("/api/user", async (req, res) => {
res.json({ id: user.id, email: user.email });
});
app.get("/api/ratings", async (req, res) => {
app.get("/api/ratings", (req, res) => {
const userId = req.query.userId as string | undefined;
const period = req.query.period as string | undefined;
let cutoff: Date | undefined;
if (period === "7d") cutoff = new Date(Date.now() - 7 * 86400000);
else if (period === "30d") cutoff = new Date(Date.now() - 30 * 86400000);
const type = req.query.type as string | undefined;
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(10000, Math.max(1, parseInt(req.query.limit as string) || 50));
const ratings = await prisma.rating.findMany({ include: { tags: { include: { tag: true } } } });
const ratingTypes = await prisma.ratingType.findMany({ include: { categories: true } });
const typeMap: Record<string, typeof ratingTypes[0]> = {};
for (const t of ratingTypes) typeMap[t.name] = t;
let items = Array.from(cache.ratings.values());
if (type) items = items.filter(r => r.type === type);
const userRatings = userId
? await prisma.userRating.findMany({ where: { userId } })
: [];
const userVoteMap: Record<string, Record<string, number>> = {};
for (const ur of userRatings) {
if (!userVoteMap[ur.ratingId]) userVoteMap[ur.ratingId] = {};
userVoteMap[ur.ratingId][ur.ratingCategoryId] = ur.score;
}
const allRatings = cutoff
? await prisma.userRating.findMany({ where: { createdAt: { gte: cutoff } } })
: await prisma.userRating.findMany();
const anonRatings = cutoff
? await prisma.anonymousRatingVote.findMany({ where: { createdAt: { gte: cutoff } } })
: await prisma.anonymousRatingVote.findMany();
const catTallyMap: Record<string, Record<string, number[]>> = {};
for (const ur of allRatings) {
if (!catTallyMap[ur.ratingId]) catTallyMap[ur.ratingId] = {};
if (!catTallyMap[ur.ratingId][ur.ratingCategoryId]) catTallyMap[ur.ratingId][ur.ratingCategoryId] = Array(10).fill(0);
catTallyMap[ur.ratingId][ur.ratingCategoryId][ur.score - 1]++;
}
for (const av of anonRatings) {
if (!catTallyMap[av.ratingId]) catTallyMap[av.ratingId] = {};
if (!catTallyMap[av.ratingId][av.ratingCategoryId]) catTallyMap[av.ratingId][av.ratingCategoryId] = Array(10).fill(0);
catTallyMap[av.ratingId][av.ratingCategoryId][av.score - 1]++;
}
const allStars = await prisma.ratingStar.groupBy({ by: ["ratingId", "ratingCategoryId"], _count: true });
const starCountMap: Record<string, Record<string, number>> = {};
for (const s of allStars) {
if (!starCountMap[s.ratingId]) starCountMap[s.ratingId] = {};
starCountMap[s.ratingId][s.ratingCategoryId] = s._count;
}
const userStars = userId
? await prisma.ratingStar.findMany({ where: { userId } })
: [];
const userStarMap: Record<string, Set<string>> = {};
for (const us of userStars) {
if (!userStarMap[us.ratingId]) userStarMap[us.ratingId] = new Set();
userStarMap[us.ratingId].add(us.ratingCategoryId);
}
const userStarCountPerCategory: Record<string, number> = {};
for (const us of userStars) {
userStarCountPerCategory[us.ratingCategoryId] = (userStarCountPerCategory[us.ratingCategoryId] || 0) + 1;
}
const result = ratings.map((r) => {
const type = typeMap[r.type];
const categories = type?.categories ?? [];
let catData: { id: string; name: string; average: number; totalVotes: number; userVote: number | null; starCount: number; userStarred: boolean }[] = categories.map((c) => {
const counts = catTallyMap[r.id]?.[c.id] || Array(10).fill(0);
const total = counts.reduce((s, v) => s + v, 0);
const sum = counts.reduce((s, v, i) => s + v * (i + 1), 0);
return { id: c.id, name: c.name, average: total > 0 ? sum / total : 0, totalVotes: total, userVote: userVoteMap[r.id]?.[c.id] ?? null, starCount: starCountMap[r.id]?.[r.type] ?? 0, userStarred: !!userStarMap[r.id]?.has(r.type) };
});
if (catData.length === 0 && type?.hasAttachments) {
const merged = Array(10).fill(0);
for (const v of allRatings.filter(u => u.ratingId === r.id)) merged[v.score - 1]++;
for (const v of anonRatings.filter(u => u.ratingId === r.id)) merged[v.score - 1]++;
const total = merged.reduce((s, v) => s + v, 0);
const sum = merged.reduce((s, v, i) => s + v * (i + 1), 0);
const userEntry = userRatings.find(u => u.ratingId === r.id);
catData = [{ id: "score", name: "Score", average: total > 0 ? sum / total : 0, totalVotes: total, userVote: userEntry?.score ?? null, starCount: starCountMap[r.id]?.[r.type] ?? 0, userStarred: !!userStarMap[r.id]?.has(r.type) }];
}
const totalVotes = catData.reduce((s, c) => s + c.totalVotes, 0);
const totalSum = catData.reduce((s, c) => s + c.average * c.totalVotes, 0);
const tags = r.tags.map((rt) => ({ id: rt.tag.id, name: rt.tag.name, color: rt.tag.color }));
return { ...r, categories: catData, average: totalVotes > 0 ? totalSum / totalVotes : 0, totalVotes, tags };
});
res.json(result);
const result = items.map(r => ({ ...r, userVotes: userId ? getUserVotesForRating(userId, r.id) : {} }));
const total = result.length;
const start = (page - 1) * limit;
res.json({ items: result.slice(start, start + limit), total, page, totalPages: Math.ceil(total / limit) });
});
app.get("/api/rating-distribution/:ratingId", async (req, res) => {
app.get("/api/rating-detail/:ratingId", (req, res) => {
const { ratingId } = req.params;
const period = req.query.period as string | undefined;
let cutoff: Date | undefined;
if (period === "7d") cutoff = new Date(Date.now() - 7 * 86400000);
else if (period === "30d") cutoff = new Date(Date.now() - 30 * 86400000);
const rating = await prisma.rating.findUnique({ where: { id: ratingId } });
if (!rating) { res.status(404).json({ error: "Rating not found" }); return; }
const ratingType = await prisma.ratingType.findUnique({ where: { name: rating.type } });
const categories = ratingType ? await prisma.ratingCategory.findMany({ where: { ratingTypeId: ratingType.id } }) : [];
const allRatings = cutoff
? await prisma.userRating.findMany({ where: { ratingId, createdAt: { gte: cutoff } } })
: await prisma.userRating.findMany({ where: { ratingId } });
const anonRatings = cutoff
? await prisma.anonymousRatingVote.findMany({ where: { ratingId, createdAt: { gte: cutoff } } })
: await prisma.anonymousRatingVote.findMany({ where: { ratingId } });
const accountTally: Record<string, number[]> = {};
const anonTally: Record<string, number[]> = {};
for (const ur of allRatings) {
if (!accountTally[ur.ratingCategoryId]) accountTally[ur.ratingCategoryId] = Array(10).fill(0);
accountTally[ur.ratingCategoryId][ur.score - 1]++;
const userId = req.query.userId as string | undefined;
const rating = cache.ratings.get(ratingId);
if (!rating) { res.status(404).json({ error: "not found" }); return; }
const type = cache.ratingTypes.get(rating.type);
const categories = type?.categories ?? [];
const catData = categories.map(c => {
const { average, totalVotes } = getAvgAndTotal(ratingId, c.id);
return { id: c.id, name: c.name, average, totalVotes, userVote: cache.userVotes.get(userId || "")?.get(ratingId)?.get(c.id) ?? null, starCount: getStarCount(ratingId, c.id), userStarred: userId ? isUserStarred(userId, ratingId, c.id) : false };
});
if (catData.length === 0 && type?.hasAttachments) {
const { average, totalVotes } = getAvgAndTotal(ratingId, "score");
catData.push({ id: "score", name: "Score", average, totalVotes, userVote: cache.userVotes.get(userId || "")?.get(ratingId)?.get("score") ?? null, starCount: getStarCount(ratingId, "score"), userStarred: userId ? isUserStarred(userId, ratingId, "score") : false });
}
for (const av of anonRatings) {
if (!anonTally[av.ratingCategoryId]) anonTally[av.ratingCategoryId] = Array(10).fill(0);
anonTally[av.ratingCategoryId][av.score - 1]++;
}
if (categories.length === 0) {
const mergedAc = Array(10).fill(0);
const mergedAn = Array(10).fill(0);
for (const v of allRatings) mergedAc[v.score - 1]++;
for (const v of anonRatings) mergedAn[v.score - 1]++;
const total = mergedAc.reduce((s, v) => s + v, 0) + mergedAn.reduce((s, v) => s + v, 0);
const sum = mergedAc.reduce((s, v, i) => s + v * (i + 1), 0) + mergedAn.reduce((s, v, i) => s + v * (i + 1), 0);
res.json({ categories: [{ id: "score", name: "Score", accountRatings: mergedAc, anonRatings: mergedAn, average: total > 0 ? sum / total : 0, totalVotes: total }] });
const totalVotes = catData.reduce((s, c) => s + c.totalVotes, 0);
const totalSum = catData.reduce((s, c) => s + c.average * c.totalVotes, 0);
res.json({ ...rating, categories: catData, average: totalVotes > 0 ? totalSum / totalVotes : 0, totalVotes, userVotes: userId ? getUserVotesForRating(userId, ratingId) : {} });
});
app.get("/api/rating-distribution/:ratingId", (req, res) => {
const { ratingId } = req.params;
const rating = cache.ratings.get(ratingId);
if (!rating) { res.status(404).json({ error: "not found" }); return; }
const type = cache.ratingTypes.get(rating.type);
const categories = type ? type.categories : [];
if (categories.length === 0 && type?.hasAttachments) {
const accountRatings = Array(10).fill(0);
const anonRatings = Array(10).fill(0);
for (const perUser of cache.userVotes.values()) { const score = perUser.get(ratingId)?.get("score"); if (score) accountRatings[score - 1]++; }
for (const perIp of cache.anonVotes.values()) { const score = perIp.get(ratingId)?.get("score"); if (score) anonRatings[score - 1]++; }
const total = accountRatings.reduce((s, v) => s + v, 0) + anonRatings.reduce((s, v) => s + v, 0);
const sum = accountRatings.reduce((s, v, i) => s + v * (i + 1), 0) + anonRatings.reduce((s, v, i) => s + v * (i + 1), 0);
res.json({ categories: [{ id: "score", name: "Score", accountRatings, anonRatings, average: total > 0 ? sum / total : 0, totalVotes: total }] });
return;
}
const catData = categories.map((c) => {
const ac = accountTally[c.id] || Array(10).fill(0);
const an = anonTally[c.id] || Array(10).fill(0);
const totalAc = ac.reduce((s, v) => s + v, 0);
const totalAn = an.reduce((s, v) => s + v, 0);
const total = totalAc + totalAn;
const sum = ac.reduce((s, v, i) => s + v * (i + 1), 0) + an.reduce((s, v, i) => s + v * (i + 1), 0);
return { id: c.id, name: c.name, accountRatings: ac, anonRatings: an, average: total > 0 ? sum / total : 0, totalVotes: total };
const catData = categories.map(c => {
const accountRatings = Array(10).fill(0);
const anonRatings = Array(10).fill(0);
for (const perUser of cache.userVotes.values()) { const score = perUser.get(ratingId)?.get(c.id); if (score) accountRatings[score - 1]++; }
for (const perIp of cache.anonVotes.values()) { const score = perIp.get(ratingId)?.get(c.id); if (score) anonRatings[score - 1]++; }
const total = accountRatings.reduce((s, v) => s + v, 0) + anonRatings.reduce((s, v) => s + v, 0);
const sum = accountRatings.reduce((s, v, i) => s + v * (i + 1), 0) + anonRatings.reduce((s, v, i) => s + v * (i + 1), 0);
return { id: c.id, name: c.name, accountRatings, anonRatings, average: total > 0 ? sum / total : 0, totalVotes: total };
});
res.json({ categories: catData });
});
app.post("/api/star", async (req, res) => {
app.post("/api/star", (req, res) => {
const { userId, ratingId, ratingCategoryId } = req.body;
if (!userId || !ratingId) { res.status(400).json({ error: "userId and ratingId required" }); return; }
const existing = await prisma.ratingStar.findUnique({ where: { userId_ratingId_ratingCategoryId: { userId, ratingId, ratingCategoryId: ratingCategoryId || "score" } } });
if (existing) {
await prisma.ratingStar.delete({ where: { id: existing.id } });
const catId = ratingCategoryId || "score";
const key = `${userId}:${ratingId}:${catId}`;
let perUser = cache.userStars.get(userId);
if (!perUser) { perUser = new Map(); cache.userStars.set(userId, perUser); }
let cats = perUser.get(ratingId);
if (!cats) { cats = new Set(); perUser.set(ratingId, cats); }
if (cats.has(catId)) {
cats.delete(catId);
cache.pendingStars.set(key, { userId, ratingId, ratingCategoryId: catId, starred: false });
res.json({ starred: false });
} else {
const catId = ratingCategoryId || "score";
const starCount = await prisma.ratingStar.count({ where: { userId, ratingCategoryId: catId } });
if (starCount >= 3) { res.status(400).json({ error: "Max 3 stars per category" }); return; }
await prisma.ratingStar.create({ data: { userId, ratingId, ratingCategoryId: catId } });
let catCount = 0;
for (const sci of perUser.values()) { if (sci.has(catId)) catCount++; }
if (catCount >= 3) { res.status(400).json({ error: "Max 3 stars per category" }); return; }
cats.add(catId);
cache.pendingStars.set(key, { userId, ratingId, ratingCategoryId: catId, starred: true });
res.json({ starred: true });
}
});
app.get("/api/my-stars", async (req, res) => {
app.get("/api/my-stars", (req, res) => {
const userId = req.query.userId as string | undefined;
if (!userId) { res.json([]); return; }
const stars = await prisma.ratingStar.findMany({ where: { userId } });
const perUser = cache.userStars.get(userId);
const map: Record<string, string[]> = {};
for (const s of stars) {
if (!map[s.ratingId]) map[s.ratingId] = [];
map[s.ratingId].push(s.ratingCategoryId);
if (perUser) {
for (const [ratingId, cats] of perUser) map[ratingId] = Array.from(cats);
}
res.json(map);
});
app.post("/api/vote", async (req, res) => {
app.post("/api/vote", (req, res) => {
let { userId, ratingId, ratingCategoryId, score } = req.body;
if (!ratingId || !score || score < 1 || score > 10) {
res.status(400).json({ error: "ratingId and score (1-10) are required" });
return;
}
if (!ratingCategoryId) {
const rating = await prisma.rating.findUnique({ where: { id: ratingId } });
const rating = cache.ratings.get(ratingId);
if (rating) {
const rtype = await prisma.ratingType.findUnique({ where: { name: rating.type } });
const rtype = cache.ratingTypes.get(rating.type);
if (rtype?.hasAttachments) {
const cat = await prisma.ratingCategory.findFirst({ where: { ratingTypeId: rtype.id } });
const cat = rtype.categories[0];
if (cat) ratingCategoryId = cat.id;
}
}
@@ -320,64 +362,51 @@ app.post("/api/vote", async (req, res) => {
res.status(400).json({ error: "ratingCategoryId is required" });
return;
}
if (userId) {
await prisma.userRating.upsert({
where: { userId_ratingId_ratingCategoryId: { userId, ratingId, ratingCategoryId } },
update: { score },
create: { userId, ratingId, ratingCategoryId, score },
});
let u = cache.userVotes.get(userId); if (!u) { u = new Map(); cache.userVotes.set(userId, u); }
let r = u.get(ratingId); if (!r) { r = new Map(); u.set(ratingId, r); }
r.set(ratingCategoryId, score);
cache.pendingVotes.set(`${userId}:${ratingId}:${ratingCategoryId}`, { userId, ratingId, ratingCategoryId, score });
} else {
const ip = getIp(req);
await prisma.anonymousRatingVote.upsert({
where: { ip_ratingId_ratingCategoryId: { ip, ratingId, ratingCategoryId } },
update: { score },
create: { ip, ratingId, ratingCategoryId, score },
});
let a = cache.anonVotes.get(ip); if (!a) { a = new Map(); cache.anonVotes.set(ip, a); }
let r = a.get(ratingId); if (!r) { r = new Map(); a.set(ratingId, r); }
r.set(ratingCategoryId, score);
cache.pendingVotes.set(`${ip}:${ratingId}:${ratingCategoryId}`, { ip, ratingId, ratingCategoryId, score });
}
const allVotes = await prisma.userRating.findMany({ where: { ratingId, ratingCategoryId } });
const anonVotes = await prisma.anonymousRatingVote.findMany({ where: { ratingId, ratingCategoryId } });
const totalVotes = allVotes.length + anonVotes.length;
const sum = allVotes.reduce((s, v) => s + v.score, 0) + anonVotes.reduce((s, v) => s + v.score, 0);
res.json({ average: totalVotes > 0 ? sum / totalVotes : 0, totalVotes });
res.json(getAvgAndTotal(ratingId, ratingCategoryId));
});
app.post("/api/unvote", async (req, res) => {
app.post("/api/unvote", (req, res) => {
const { userId, ratingId, ratingCategoryId } = req.body;
if (!ratingId || !ratingCategoryId) {
res.status(400).json({ error: "ratingId and ratingCategoryId are required" });
return;
}
if (userId) {
await prisma.userRating.deleteMany({
where: { userId, ratingId, ratingCategoryId },
});
cache.userVotes.get(userId)?.get(ratingId)?.delete(ratingCategoryId);
cache.pendingVotes.set(`${userId}:${ratingId}:${ratingCategoryId}`, { userId, ratingId, ratingCategoryId, score: null });
} else {
const ip = getIp(req);
await prisma.anonymousRatingVote.deleteMany({
where: { ip, ratingId, ratingCategoryId },
});
cache.anonVotes.get(ip)?.get(ratingId)?.delete(ratingCategoryId);
cache.pendingVotes.set(`${ip}:${ratingId}:${ratingCategoryId}`, { ip, ratingId, ratingCategoryId, score: null });
}
const allVotes = await prisma.userRating.findMany({ where: { ratingId, ratingCategoryId } });
const anonVotes = await prisma.anonymousRatingVote.findMany({ where: { ratingId, ratingCategoryId } });
const totalVotes = allVotes.length + anonVotes.length;
const sum = allVotes.reduce((s, v) => s + v.score, 0) + anonVotes.reduce((s, v) => s + v.score, 0);
res.json({ average: totalVotes > 0 ? sum / totalVotes : 0, totalVotes });
res.json(getAvgAndTotal(ratingId, ratingCategoryId));
});
app.get("/api/anonymous-votes/:ratingId", async (req, res) => {
app.get("/api/anonymous-votes/:ratingId", (req, res) => {
const { ratingId } = req.params;
const ip = getIp(req);
const votes = await prisma.anonymousRatingVote.findMany({ where: { ip, ratingId } });
const perIp = cache.anonVotes.get(ip);
const map: Record<string, number> = {};
for (const v of votes) {
map[v.ratingCategoryId] = v.score;
if (perIp) {
const perRating = perIp.get(ratingId);
if (perRating) { for (const [c, s] of perRating) map[c] = s; }
}
if (Object.keys(map).length > 0) {
const rating = await prisma.rating.findUnique({ where: { id: ratingId } });
const rating = cache.ratings.get(ratingId);
if (rating) {
const rtype = await prisma.ratingType.findUnique({ where: { name: rating.type } });
const rtype = cache.ratingTypes.get(rating.type);
if (rtype?.hasAttachments && !map.score) {
map.score = Object.values(map)[0];
}
@@ -386,12 +415,14 @@ app.get("/api/anonymous-votes/:ratingId", async (req, res) => {
res.json(map);
});
app.get("/api/my-anonymous-votes", async (req, res) => {
app.get("/api/my-anonymous-votes", (req, res) => {
const ip = getIp(req);
const votes = await prisma.anonymousRatingVote.findMany({ where: { ip } });
const perIp = cache.anonVotes.get(ip);
const map: Record<string, number> = {};
for (const v of votes) {
map[`${v.ratingId}_${v.ratingCategoryId}`] = v.score;
if (perIp) {
for (const [ratingId, cats] of perIp) {
for (const [catId, score] of cats) map[`${ratingId}_${catId}`] = score;
}
}
res.json(map);
});
@@ -415,7 +446,8 @@ app.post("/api/reports", async (req, res) => {
res.json(report);
});
app.get("/api/admin/reports", async (_req, res) => {
app.get("/api/admin/reports", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const reports = await prisma.report.findMany({
include: { user: { select: { email: true } } },
orderBy: { createdAt: "desc" },
@@ -424,12 +456,14 @@ app.get("/api/admin/reports", async (_req, res) => {
});
app.delete("/api/admin/reports/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
await prisma.report.delete({ where: { id } });
res.json({ success: true });
});
app.post("/api/admin/ratings", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { type, name, description, picture, icon, attachments } = req.body;
if (!type || !name) {
res.status(400).json({ error: "type and name are required" });
@@ -451,6 +485,7 @@ async function saveFile(base64: string): Promise<string> {
}
app.put("/api/admin/ratings/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { name, description, picture, icon, attachments } = req.body;
const data: Record<string, unknown> = {};
@@ -464,6 +499,7 @@ app.put("/api/admin/ratings/:id", async (req, res) => {
});
app.delete("/api/admin/ratings/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.rating.delete({ where: { id } });
@@ -476,6 +512,7 @@ app.get("/api/rating-types", async (_req, res) => {
});
app.post("/api/admin/rating-types", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const max = await prisma.ratingType.aggregate({ _max: { sortOrder: true } });
@@ -484,6 +521,7 @@ app.post("/api/admin/rating-types", async (req, res) => {
});
app.put("/api/admin/rating-types/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { hasAttachments } = req.body;
const data: Record<string, unknown> = {};
@@ -493,6 +531,7 @@ app.put("/api/admin/rating-types/:id", async (req, res) => {
});
app.delete("/api/admin/rating-types/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingCategory: { ratingTypeId: id } } });
await prisma.ratingCategory.deleteMany({ where: { ratingTypeId: id } });
@@ -501,6 +540,7 @@ app.delete("/api/admin/rating-types/:id", async (req, res) => {
});
app.put("/api/admin/rating-types/reorder", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { ids }: { ids: string[] } = req.body;
if (!ids || !Array.isArray(ids)) { res.status(400).json({ error: "ids array is required" }); return; }
for (let i = 0; i < ids.length; i++) {
@@ -510,6 +550,7 @@ app.put("/api/admin/rating-types/reorder", async (req, res) => {
});
app.post("/api/admin/rating-types/:id/categories", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
@@ -518,6 +559,7 @@ app.post("/api/admin/rating-types/:id/categories", async (req, res) => {
});
app.delete("/api/admin/rating-types/:typeId/categories/:categoryId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { categoryId } = req.params;
await prisma.userRating.deleteMany({ where: { ratingCategoryId: categoryId } });
await prisma.ratingCategory.delete({ where: { id: categoryId } });
@@ -525,6 +567,7 @@ app.delete("/api/admin/rating-types/:typeId/categories/:categoryId", async (req,
});
app.put("/api/admin/rating-types/:typeId/categories/:categoryId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { categoryId } = req.params;
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
@@ -538,6 +581,7 @@ app.get("/api/tags", async (_req, res) => {
});
app.post("/api/admin/tags", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name, color, ratingTypeId } = req.body;
if (!name || !color || !ratingTypeId) { res.status(400).json({ error: "name, color, and ratingTypeId are required" }); return; }
const tag = await prisma.tag.create({ data: { name, color, ratingTypeId } });
@@ -545,6 +589,7 @@ app.post("/api/admin/tags", async (req, res) => {
});
app.put("/api/admin/tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { name, color, ratingTypeId } = req.body;
if (!name || !color || !ratingTypeId) { res.status(400).json({ error: "name, color, and ratingTypeId are required" }); return; }
@@ -553,6 +598,7 @@ app.put("/api/admin/tags/:id", async (req, res) => {
});
app.delete("/api/admin/tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
await prisma.ratingTag.deleteMany({ where: { tagId: id } });
await prisma.tag.delete({ where: { id } });
@@ -566,6 +612,7 @@ app.get("/api/tags-by-type/:ratingTypeId", async (req, res) => {
});
app.post("/api/admin/ratings/:id/tags", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { tagId } = req.body;
if (!tagId) { res.status(400).json({ error: "tagId is required" }); return; }
@@ -578,12 +625,14 @@ app.post("/api/admin/ratings/:id/tags", async (req, res) => {
});
app.delete("/api/admin/ratings/:id/tags/:tagId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id, tagId } = req.params;
await prisma.ratingTag.delete({ where: { ratingId_tagId: { ratingId: id, tagId } } });
res.json({ success: true });
});
app.post("/api/admin/upload-attachments", upload.array("files"), async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const files = req.files as Express.Multer.File[];
const typeName = req.body.type || "music";
if (!files || !files.length) { res.status(400).json({ error: "files are required" }); return; }
@@ -611,6 +660,7 @@ app.post("/api/admin/upload-attachments", upload.array("files"), async (req, res
});
app.delete("/api/admin/music/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingId: id } });
@@ -773,6 +823,7 @@ app.get("/api/my-anonymous-bets", async (req, res) => {
});
app.post("/api/admin/bets", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name, description, image, categoryId } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const bet = await prisma.bet.create({ data: { name, description: description ?? "", image: await saveImage(image ?? ""), categoryId: categoryId || null } });
@@ -780,6 +831,7 @@ app.post("/api/admin/bets", async (req, res) => {
});
app.put("/api/admin/bets/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { name, description, image, categoryId } = req.body;
const data: Record<string, unknown> = {};
@@ -792,6 +844,7 @@ app.put("/api/admin/bets/:id", async (req, res) => {
});
app.delete("/api/admin/bets/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
await prisma.betToTag.deleteMany({ where: { betId: id } });
const entryIds = (await prisma.betEntry.findMany({ where: { betId: id }, select: { id: true } })).map(e => e.id);
@@ -802,12 +855,14 @@ app.delete("/api/admin/bets/:id", async (req, res) => {
res.json({ success: true });
});
app.get("/api/admin/bet-categories", async (_req, res) => {
app.get("/api/admin/bet-categories", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const cats = await prisma.betCategory.findMany({ orderBy: { id: "asc" } });
res.json(cats);
});
app.post("/api/admin/bet-categories", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const cat = await prisma.betCategory.upsert({ where: { name }, create: { name }, update: {} });
@@ -815,6 +870,7 @@ app.post("/api/admin/bet-categories", async (req, res) => {
});
app.put("/api/admin/bet-categories/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
@@ -823,6 +879,7 @@ app.put("/api/admin/bet-categories/:id", async (req, res) => {
});
app.delete("/api/admin/bet-categories/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const bets = await prisma.bet.findMany({ where: { categoryId: id } });
const betIds = bets.map(b => b.id);
@@ -837,6 +894,7 @@ app.delete("/api/admin/bet-categories/:id", async (req, res) => {
});
app.put("/api/admin/bets/:id/complete", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { resultEntryId } = req.body;
if (!resultEntryId) { res.status(400).json({ error: "resultEntryId is required" }); return; }
@@ -848,6 +906,7 @@ app.put("/api/admin/bets/:id/complete", async (req, res) => {
});
app.post("/api/admin/bets/:id/entries", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { option } = req.body;
if (!option) { res.status(400).json({ error: "option is required" }); return; }
@@ -856,6 +915,7 @@ app.post("/api/admin/bets/:id/entries", async (req, res) => {
});
app.delete("/api/admin/bets/:betId/entries/:entryId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { entryId } = req.params;
await prisma.anonymousBetVote.deleteMany({ where: { betEntryId: entryId } });
await prisma.userBet.deleteMany({ where: { betEntryId: entryId } });
@@ -869,6 +929,7 @@ app.get("/api/bet-tags", async (_req, res) => {
});
app.post("/api/admin/bet-tags", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name, color, image } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const tag = await prisma.betTag.create({ data: { name, color: color ?? "#424e88", image: await saveImage(image ?? "") } });
@@ -876,6 +937,7 @@ app.post("/api/admin/bet-tags", async (req, res) => {
});
app.delete("/api/admin/bet-tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
await prisma.betToTag.deleteMany({ where: { tagId: id } });
await prisma.betTag.delete({ where: { id } });
@@ -883,6 +945,7 @@ app.delete("/api/admin/bet-tags/:id", async (req, res) => {
});
app.post("/api/admin/bets/:id/tags", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { tagId } = req.body;
if (!tagId) { res.status(400).json({ error: "tagId is required" }); return; }
@@ -895,24 +958,28 @@ app.post("/api/admin/bets/:id/tags", async (req, res) => {
});
app.delete("/api/admin/bets/:id/tags/:tagId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id, tagId } = req.params;
await prisma.betToTag.delete({ where: { betId_tagId: { betId: id, tagId } } });
res.json({ success: true });
});
app.get("/api/admin/assets", (_req, res) => {
app.get("/api/admin/assets", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const files = fs.readdirSync(uploadsDir).filter((f) => f !== "logo.png").map((f) => ({ name: f, url: `/uploads/${f}` }));
res.json(files);
});
app.post("/api/admin/assets", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { image } = req.body;
if (!image) { res.status(400).json({ error: "image is required" }); return; }
const url = await saveImage(image);
res.json({ name: url.replace("/uploads/", ""), url });
});
app.delete("/api/admin/assets/:filename", (req, res) => {
app.delete("/api/admin/assets/:filename", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { filename } = req.params;
if (filename === "logo.png") { res.status(403).json({ error: "Cannot delete logo" }); return; }
const filepath = path.join(uploadsDir, filename);
@@ -926,6 +993,7 @@ app.get("/api/site-updates", async (_req, res) => {
});
app.post("/api/admin/site-updates", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { title, text } = req.body;
if (!title) { res.status(400).json({ error: "Title required" }); return; }
const update = await prisma.siteUpdate.create({ data: { title, text: text || "" } });
@@ -933,6 +1001,7 @@ app.post("/api/admin/site-updates", async (req, res) => {
});
app.put("/api/admin/site-updates/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
const { title, text } = req.body;
if (!title) { res.status(400).json({ error: "Title required" }); return; }
@@ -941,6 +1010,7 @@ app.put("/api/admin/site-updates/:id", async (req, res) => {
});
app.delete("/api/admin/site-updates/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { id } = req.params;
await prisma.siteUpdate.delete({ where: { id } });
res.json({ success: true });
@@ -948,8 +1018,11 @@ app.delete("/api/admin/site-updates/:id", async (req, res) => {
const PORT = process.env.PORT ?? 4000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
rebuildCache().then(() => {
setInterval(flushPendingOps, 300000);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
});
export { app, prisma };