Compare commits
2 Commits
45ae1d346d
...
393c8c41d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 393c8c41d4 | |||
| b0f2094a30 |
Vendored
+73
File diff suppressed because one or more lines are too long
Vendored
-73
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Vendored
+406
-195
@@ -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
@@ -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 };
|
||||
Reference in New Issue
Block a user