optimizations

This commit is contained in:
2026-07-02 13:07:59 -04:00
parent 2f6856c701
commit e25208e930
35 changed files with 1791 additions and 1156 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fandom Stock Trader</title>
<script type="module" crossorigin src="/assets/index-CcCRSq94.js"></script>
<script type="module" crossorigin src="/assets/index-BVhKS8Xa.js"></script>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -12,6 +12,7 @@
"@react-oauth/google": "^0.13.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.18.1",
"recharts": "^3.9.0"
},
"devDependencies": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+52 -104
View File
@@ -1,42 +1,40 @@
import { useEffect, useState } from "react";
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from "react-router-dom";
import { GoogleOAuthProvider, GoogleLogin } from "@react-oauth/google";
import SettingsPage from "./pages/SettingsPage";
import AccountPage from "./pages/AccountPage";
import BetsPage from "./pages/BetsPage";
import LeaderboardPage from "./pages/LeaderboardPage";
import RatingsPage from "./pages/RatingsPage";
import ChatPage from "./pages/ChatPage";
import AdminPage from "./pages/AdminPage";
import TellDevPage from "./pages/TellDevPage";
import Sidebar from "./components/Sidebar";
import { ThemeProvider } from "./context/ThemeContext";
type User = { id: string; email: string; username: string; nickname: string | null; profilePicture: string | null; admin: boolean; hasPassword: boolean };
type Page = "home" | "settings" | "account" | "bets" | "leaderboard" | "ratings" | "chat" | "admin" | "telldev";
type User = { id: string; email: string; admin: boolean };
function getCookie(name: string) {
const match = document.cookie.match(`(?:^|;\\s*)${name}=([^;]*)`);
return match ? decodeURIComponent(match[1]) : null;
}
function App() {
function AppInner() {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [page, setPage] = useState<Page>("home");
const [sidebarPinned, setSidebarPinned] = useState(() => getCookie("sidebarPinned") !== "false");
const [googleReady, setGoogleReady] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginField, setLoginField] = useState("");
const [loginPassword, setLoginPassword] = useState("");
const [loginError, setLoginError] = useState("");
const [setupOpen, setSetupOpen] = useState(false);
const [setupUsername, setSetupUsername] = useState("");
const [setupPassword, setSetupPassword] = useState("");
const [setupError, setSetupError] = useState("");
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const sidebarPinned = getCookie("sidebarPinned") !== "false";
const [sidebarOpen, setSidebarOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const page = location.pathname.slice(1) || "home";
useEffect(() => {
if (window.location.pathname === "/admin") setPage("admin");
const mq = window.matchMedia("(max-width: 768px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
useEffect(() => {
const userId = getCookie("userId");
if (!userId) { setLoading(false); return; }
@@ -61,7 +59,6 @@ function App() {
document.cookie = `userId=${encodeURIComponent(u.id)}; path=/; max-age=31536000`;
setUser(u);
setShowLoginModal(false);
if (!u.hasPassword) { setSetupUsername(u.username); setSetupOpen(true); }
}
});
}
@@ -69,103 +66,54 @@ function App() {
function handleLogout() {
document.cookie = "userId=; path=/; max-age=0";
setUser(null);
setPage("home");
navigate("/");
}
if (loading) return null;
return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID} onScriptLoadSuccess={() => setGoogleReady(true)}>
<ThemeProvider>
<style>{`
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #555; border-radius: 4px; }
* { scrollbar-width: thin; scrollbar-color: #555 transparent; }
body { overflow-x: hidden; margin: 0; }
`}</style>
<ThemeProvider>
<Sidebar user={user} page={page} pinned={sidebarPinned} onNavigate={(p) => setPage(p as Page)} onLoginClick={() => setShowLoginModal(true)} />
{page === "ratings" ? (
<div style={{ margin: "40px auto", padding: "0 16px", width: "100%", maxWidth: 1200 }}>
<RatingsPage user={user} />
<Sidebar user={user} page={page} pinned={sidebarPinned} onNavigate={(p) => navigate(p === "home" ? "/" : `/${p}`)} isMobile={isMobile} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} onLoginClick={() => setShowLoginModal(true)} />
{isMobile && (
<div style={{ position: "fixed", top: 0, left: 0, right: 0, zIndex: 100, display: "flex", alignItems: "center", padding: "8px 12px", background: "#1a1a22", boxShadow: "0 1px 4px rgba(0,0,0,0.3)" }}>
<button onClick={() => setSidebarOpen((v) => !v)} style={{ padding: "4px 8px", fontSize: 18, background: "none", color: "#e0e0e0", border: "none", cursor: "pointer", lineHeight: 1 }}></button>
</div>
)}
<div style={{ marginLeft: isMobile ? 0 : (sidebarPinned ? 220 : undefined), marginTop: isMobile ? 48 : undefined, transition: "margin-left 0.2s ease" }}>
<Routes>
<Route path="/" element={<div style={{ fontSize: 28, fontWeight: 700, color: "#e0e0e0", textAlign: "center", marginTop: 40 }}>Home</div>} />
<Route path="/ratings" element={<div style={{ margin: "40px auto", padding: "0 16px", width: "100%", maxWidth: 1200, boxSizing: "border-box" }}><RatingsPage user={user} /></div>} />
<Route path="/admin" element={<div style={{ margin: "40px auto", padding: "0 16px", width: "100%", maxWidth: 800, boxSizing: "border-box" }}><AdminPage user={user} /></div>} />
<Route path="/bets" element={<div style={{ margin: "40px auto", padding: "0 16px", width: "100%", maxWidth: 1200, boxSizing: "border-box" }}><BetsPage user={user} /></div>} />
<Route path="/report" element={<div style={{ maxWidth: 360, margin: "40px auto" }}><TellDevPage user={user} /></div>} />
<Route path="/account" element={user ? <div style={{ maxWidth: 360, margin: "40px auto" }}><AccountPage user={user} onLogout={handleLogout} /></div> : <Navigate to="/" />} />
</Routes>
</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()}>
<div style={{ fontSize: 20, fontWeight: 700, color: "#e0e0e0", marginBottom: 4 }}>Log In</div>
<div style={{ display: "flex", justifyContent: "center" }}><GoogleLogin onSuccess={(res) => res.credential && handleGoogleLogin(res.credential)} onError={() => {}} size="large" /></div>
</div>
) : page === "chat" ? (
<div style={{ margin: "40px auto", padding: "0 16px", width: "100%", maxWidth: 600 }}>
<ChatPage user={user} />
</div>
) : page === "admin" ? (
<div style={{ margin: "40px auto", padding: "0 16px", width: "100%", maxWidth: 800 }}>
<AdminPage user={user} />
</div>
) : page === "leaderboard" ? (
<div style={{ maxWidth: 360, margin: "40px auto" }}>
<LeaderboardPage />
</div>
) : page === "bets" ? (
<div style={{ maxWidth: 360, margin: "40px auto" }}>
<BetsPage user={user} />
</div>
) : page === "telldev" ? (
<div style={{ maxWidth: 360, margin: "40px auto" }}>
<TellDevPage user={user} />
</div>
) : page === "settings" ? (
<div style={{ maxWidth: 360, margin: "40px auto" }}>
<SettingsPage sidebarPinned={sidebarPinned} onToggleSidebar={() => {
const next = !sidebarPinned;
setSidebarPinned(next);
document.cookie = `sidebarPinned=${next}; path=/; max-age=31536000`;
}} />
</div>
) : page === "account" && user ? (
<div style={{ maxWidth: 360, margin: "40px auto" }}>
<AccountPage user={user} onUpdate={(u) => setUser(u)} onLogout={handleLogout} />
</div>
) : (
<div style={{ fontSize: 28, fontWeight: 700, color: "#e0e0e0", textAlign: "center", marginTop: 40 }}>Home</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()}>
<div style={{ fontSize: 20, fontWeight: 700, color: "#e0e0e0" }}>Log In</div>
<form onSubmit={async (e) => {
e.preventDefault(); setLoginError("");
const res = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ login: loginField, password: loginPassword }) });
if (!res.ok) { const d = await res.json(); setLoginError(d.error || "Login failed"); return; }
const u = await res.json();
document.cookie = `userId=${encodeURIComponent(u.id)}; path=/; max-age=31536000`;
setUser(u); setShowLoginModal(false); setLoginField(""); setLoginPassword("");
}} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<input value={loginField} onChange={(e) => setLoginField(e.target.value)} placeholder="Email or username" style={{ padding: "10px 14px", fontSize: 16, border: "1px solid #444", background: "#1e1e1e", color: "#e0e0e0" }} />
<input value={loginPassword} onChange={(e) => setLoginPassword(e.target.value)} placeholder="Password" type="password" style={{ padding: "10px 14px", fontSize: 16, border: "1px solid #444", background: "#1e1e1e", color: "#e0e0e0" }} />
{loginError && <p style={{ color: "#e74c3c", margin: 0, fontSize: 14 }}>{loginError}</p>}
<button type="submit" style={{ padding: "10px 0", fontSize: 16, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Log In</button>
</form>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13, color: "#888" }}><div style={{ flex: 1, height: 1, background: "#444" }} />or<div style={{ flex: 1, height: 1, background: "#444" }} /></div>
{googleReady && <div style={{ display: "flex", justifyContent: "center" }}><GoogleLogin onSuccess={(res) => res.credential && handleGoogleLogin(res.credential)} onError={() => {}} size="large" /></div>}
</div>
</div>
)}
{setupOpen && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 9999 }} onClick={() => {}}>
<div style={{ background: "#2a2a2a", padding: 24, borderRadius: 8, width: 320, display: "flex", flexDirection: "column", gap: 12 }} onClick={(e) => e.stopPropagation()}>
<div style={{ fontSize: 18, fontWeight: 700, color: "#e0e0e0" }}>Set up your account</div>
<div style={{ fontSize: 13, color: "#888" }}>Choose a username and password to enable login without Google.</div>
<form onSubmit={async (e) => {
e.preventDefault(); setSetupError("");
const res = await fetch("/api/set-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: setupUsername, password: setupPassword }) });
if (!res.ok) { const d = await res.json(); setSetupError(d.error || "Failed"); return; }
const u = await res.json();
setUser(u); setSetupOpen(false); setSetupPassword("");
}} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<input value={setupUsername} onChange={(e) => setSetupUsername(e.target.value)} placeholder="Username" style={{ padding: "10px 14px", fontSize: 16, border: "1px solid #444", background: "#1e1e1e", color: "#e0e0e0" }} />
<input value={setupPassword} onChange={(e) => setSetupPassword(e.target.value)} placeholder="Password" type="password" autoFocus style={{ padding: "10px 14px", fontSize: 16, border: "1px solid #444", background: "#1e1e1e", color: "#e0e0e0" }} />
{setupError && <p style={{ color: "#e74c3c", margin: 0, fontSize: 14 }}>{setupError}</p>}
<button type="submit" style={{ padding: "10px 0", fontSize: 16, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Save</button>
</form>
<button onClick={() => { setSetupOpen(false); setSetupPassword(""); }} style={{ background: "none", border: "none", color: "#888", cursor: "pointer", fontSize: 13 }}>Skip for now</button>
</div>
</div>
)}
</ThemeProvider>
</div>
)}
</ThemeProvider>
);
}
function App() {
return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
<BrowserRouter>
<AppInner />
</BrowserRouter>
</GoogleOAuthProvider>
);
}
+16 -94
View File
@@ -2,14 +2,17 @@ import { useEffect, useState } from "react";
import { useTheme } from "../context/ThemeContext";
type Props = {
user: { id: string; username: string; nickname: string | null; profilePicture: string | null } | null;
user: { id: string } | null;
page: string;
pinned: boolean;
onNavigate: (page: string) => void;
onLoginClick?: () => void;
isMobile?: boolean;
sidebarOpen?: boolean;
onToggleSidebar?: () => void;
};
function Sidebar({ user, page, pinned, onNavigate, onLoginClick }: Props) {
function Sidebar({ user, page, pinned, onNavigate, onLoginClick, isMobile, sidebarOpen, onToggleSidebar }: Props) {
const { dark } = useTheme();
const [visible, setVisible] = useState(false);
@@ -22,6 +25,7 @@ function Sidebar({ user, page, pinned, onNavigate, onLoginClick }: Props) {
return () => document.removeEventListener("mousemove", handleMouseMove);
}, [pinned]);
const show = isMobile ? (sidebarOpen ?? false) : visible;
const bg = dark ? "#1a1a22" : "#fff";
const fg = dark ? "#e0e0e0" : "#333";
const border = dark ? "#333333" : "#ccc";
@@ -33,7 +37,10 @@ function Sidebar({ user, page, pinned, onNavigate, onLoginClick }: Props) {
.sidebar-btn { font-family: 'Segoe UI Condensed', 'Arial Narrow', 'Franklin Gothic Medium Condensed', sans-serif; font-stretch: condensed; transition: background 0.2s ease; }
.sidebar-btn:hover { background: ${dark ? "#333333" : "#f0f0f0"} !important; }
`}</style>
{!pinned && !visible && (
{isMobile && show && (
<div onClick={onToggleSidebar} style={{ position: "fixed", top: 48, inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 999 }} />
)}
{!isMobile && !pinned && !show && (
<div
style={{
position: "fixed",
@@ -50,20 +57,20 @@ function Sidebar({ user, page, pinned, onNavigate, onLoginClick }: Props) {
style={{
position: "fixed",
left: 0,
top: 0,
top: isMobile ? 48 : 0,
width: 220,
height: "100vh",
height: isMobile ? "calc(100vh - 48px)" : "100vh",
borderRight: `1px solid ${border}`,
background: bg,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
padding: "16px 0",
transform: visible ? "translateX(0)" : "translateX(-210px)",
transform: show ? "translateX(0)" : isMobile ? "translateX(-100%)" : "translateX(-210px)",
transition: "transform 0.2s ease",
zIndex: 1000,
}}
onMouseEnter={() => !pinned && setVisible(true)}
onMouseEnter={() => !isMobile && !pinned && setVisible(true)}
>
<button onClick={() => onNavigate("home")} style={{ width: "100%", background: "none", border: "none", cursor: "pointer", padding: 0 }}>
<div style={{ padding: "40px 12px 12px", display: "flex", alignItems: "center", justifyContent: "center", gap: 8 }}>
@@ -85,36 +92,12 @@ function Sidebar({ user, page, pinned, onNavigate, onLoginClick }: Props) {
display: "flex",
alignItems: "center",
gap: 10,
fontSize: 14,
fontSize: 16,
fontWeight: 700,
textAlign: "left",
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: "50%",
background: user.profilePicture ? "none" : "#424e88",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontWeight: 700,
fontSize: 18,
flexShrink: 0,
overflow: "hidden",
}}
>
{user.profilePicture ? (
<img src={user.profilePicture} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
(user.nickname || user.username)[0].toUpperCase()
)}
</div>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{user.nickname || user.username}
</span>
Account
</button>
) : (
<button
@@ -158,27 +141,6 @@ function Sidebar({ user, page, pinned, onNavigate, onLoginClick }: Props) {
</button>
</div>
<div>
<button
className="sidebar-btn"
type="button"
onClick={() => onNavigate("chat")}
style={{
width: "100%",
padding: "16px 24px",
background: page === "chat" ? activeBg : "none",
color: fg,
border: "none",
cursor: "pointer",
fontSize: 16,
fontWeight: 700,
textAlign: "left",
}}
>
Chat
</button>
</div>
<div>
<button
className="sidebar-btn"
@@ -200,27 +162,6 @@ function Sidebar({ user, page, pinned, onNavigate, onLoginClick }: Props) {
</button>
</div>
<div>
<button
className="sidebar-btn"
type="button"
onClick={() => onNavigate("leaderboard")}
style={{
width: "100%",
padding: "16px 24px",
background: page === "leaderboard" ? activeBg : "none",
color: fg,
border: "none",
cursor: "pointer",
fontSize: 16,
fontWeight: 700,
textAlign: "left",
}}
>
Leaderboard
</button>
</div>
<div style={{ marginTop: "auto" }}>
<button
className="sidebar-btn"
@@ -242,25 +183,6 @@ function Sidebar({ user, page, pinned, onNavigate, onLoginClick }: Props) {
</button>
</div>
<div>
<button
className="sidebar-btn"
onClick={() => onNavigate("settings")}
style={{
width: "100%",
padding: "16px 24px",
background: page === "settings" ? activeBg : "none",
color: fg,
border: "none",
cursor: "pointer",
fontSize: 16,
fontWeight: 700,
textAlign: "left",
}}
>
Settings
</button>
</div>
</aside>
</>
);
+3 -122
View File
@@ -1,136 +1,17 @@
import { useRef, useState } from "react";
import { useTheme } from "../context/ThemeContext";
type Props = {
user: { id: string; email: string; username: string; nickname: string | null; profilePicture: string | null; admin: boolean };
onUpdate: (u: Props["user"]) => void;
user: { id: string; email: string; admin: boolean };
onLogout: () => void;
};
function AccountPage({ user, onUpdate, onLogout }: Props) {
function AccountPage({ user, onLogout }: Props) {
const { dark } = useTheme();
const mutedClr = dark ? "#aaa" : "#666";
const [nickname, setNickname] = useState(user.nickname ?? "");
const [profilePicture, setProfilePicture] = useState(user.profilePicture ?? "");
const [saveError, setSaveError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleSave() {
setSaveError("");
const res = await fetch("/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: user.id, nickname, profilePicture }),
});
if (res.ok) {
const updated = await res.json();
onUpdate(updated);
} else {
setSaveError("Failed to save");
}
}
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setProfilePicture(reader.result as string);
reader.readAsDataURL(file);
}
const inputStyle = {
padding: "12px 16px",
fontSize: 16,
border: `1px solid ${dark ? "#444" : "#ccc"}`,
background: dark ? "#2a2a2a" : "#fff",
color: dark ? "#e0e0e0" : "#000",
width: "100%",
boxSizing: "border-box" as const,
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ fontSize: 28, fontWeight: 700, color: dark ? "#e0e0e0" : "#333", marginBottom: 8 }}>Account</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
style={{
width: 64,
height: 64,
borderRadius: "50%",
background: profilePicture ? "none" : "#424e88",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontWeight: 700,
fontSize: 28,
overflow: "hidden",
flexShrink: 0,
}}
>
{profilePicture ? (
<img src={profilePicture} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
(nickname || user.username)[0].toUpperCase()
)}
</div>
<div>
<span style={{ fontSize: 18, fontWeight: 700, color: dark ? "#e0e0e0" : "#333" }}>
{nickname || user.username}
</span>
<div style={{ fontSize: 13, color: mutedClr, marginTop: 2 }}>@{user.username}</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
style={{ display: "none" }}
/>
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: "10px 0",
fontSize: 16,
background: "none",
color: dark ? "#e0e0e0" : "#333",
border: `1px solid ${dark ? "#444" : "#ccc"}`,
cursor: "pointer",
}}
>
Upload Profile Picture
</button>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontWeight: 600, color: dark ? "#aaa" : "#555" }}>Nickname</span>
<input
placeholder="Nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
style={inputStyle}
/>
</label>
{saveError && <p style={{ color: "red", margin: 0 }}>{saveError}</p>}
<button
onClick={handleSave}
style={{
padding: "16px 0",
fontSize: 18,
fontWeight: 700,
background: "#424e88",
color: "#fff",
border: "none",
cursor: "pointer",
}}
>
Confirm
</button>
<div style={{ fontSize: 16, color: dark ? "#e0e0e0" : "#333" }}>{user.email}</div>
<button
onClick={onLogout}
style={{
+296 -50
View File
@@ -32,22 +32,28 @@ type Bet = {
description: string;
finishTime: string;
image: string;
completed: boolean;
resultEntryId: string | null;
categoryId: string | null;
entries: Entry[];
};
type Props = {
user: { admin: boolean } | null;
type BetCategory = {
id: string;
name: string;
};
function AdminPage(_props: Props) {
type Props = {
user: { email: string } | null;
};
function AdminPage({ user }: Props) {
const { dark } = useTheme();
const [adminLoggedIn, setAdminLoggedIn] = useState(false);
const [adminPassword, setAdminPassword] = useState("");
const [adminError, setAdminError] = useState("");
const [tab, setTab] = useState<"ratings" | "reports" | "bets" | "assets">("ratings");
const adminLoggedIn = user?.email === "invoictoan@gmail.com";
const [tab, setTab] = useState<"ratings" | "reports" | "bets" | "assets" | "music">("ratings");
const [ratings, setRatings] = useState<Rating[]>([]);
const [ratingTypes, setRatingTypes] = useState<{ id: string; name: string; categories: { id: string; name: string }[] }[]>([]);
const [reports, setReports] = useState<{ id: string; subject: string; text: string; createdAt: string; user: { username: string } }[]>([]);
const [reports, setReports] = useState<{ id: string; subject: string; text: string; createdAt: string; user: { email: string } }[]>([]);
const [bets, setBets] = useState<Bet[]>([]);
const [type, setType] = useState("");
const [name, setName] = useState("");
@@ -68,6 +74,14 @@ function AdminPage(_props: Props) {
const betImageRef = useRef<HTMLInputElement>(null);
const [newEntryOption, setNewEntryOption] = useState("");
const [addingEntryFor, setAddingEntryFor] = useState<string | null>(null);
const [completingBetId, setCompletingBetId] = useState<string | null>(null);
const [betCategories, setBetCategories] = useState<BetCategory[]>([]);
const [newBetCategoryName, setNewBetCategoryName] = useState("");
const [editingBetCategory, setEditingBetCategory] = useState<string | null>(null);
const [editBetCategoryName, setEditBetCategoryName] = useState("");
const [selectedBetCategoryId, setSelectedBetCategoryId] = useState("");
const [assigningCategoryBetId, setAssigningCategoryBetId] = useState<string | null>(null);
const [assignCategoryId, setAssignCategoryId] = useState("");
const [tags, setTags] = useState<Tag[]>([]);
const [newTagName, setNewTagName] = useState("");
@@ -83,7 +97,13 @@ function AdminPage(_props: Props) {
const [assets, setAssets] = useState<{ name: string; url: string }[]>([]);
const [newAssetImage, setNewAssetImage] = useState("");
const assetImgRef = useRef<HTMLInputElement>(null);
const [showAssetPicker, setShowAssetPicker] = useState<"picture" | "icon" | null>(null);
const [musicUploads, setMusicUploads] = useState<{ name: string; data: string }[]>([]);
const musicInputRef = useRef<HTMLInputElement>(null);
const [uploadingMusic, setUploadingMusic] = useState(false);
const pickerImgRef = useRef<HTMLInputElement>(null);
const [showPicker, setShowPicker] = useState(false);
const pickerCbRef = useRef<((url: string) => void) | null>(null);
const [dragIdx, setDragIdx] = useState<number | null>(null);
function loadRatingTypes() {
fetch("/api/rating-types").then((r) => r.json()).then((types) => {
@@ -99,6 +119,7 @@ function AdminPage(_props: Props) {
loadRatingTypes();
loadTags();
loadAssets();
loadBetCategories();
}, []);
async function handleRatingSubmit(e: FormEvent) {
@@ -124,7 +145,7 @@ function AdminPage(_props: Props) {
await fetch("/api/admin/rating-types", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newTypeName.toLowerCase() }),
body: JSON.stringify({ name: newTypeName }),
});
setNewTypeName("");
loadRatingTypes();
@@ -140,7 +161,7 @@ function AdminPage(_props: Props) {
await fetch(`/api/admin/rating-types/${typeId}/categories`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newCategoryName.toLowerCase() }),
body: JSON.stringify({ name: newCategoryName }),
});
setNewCategoryName("");
loadRatingTypes();
@@ -156,7 +177,7 @@ function AdminPage(_props: Props) {
await fetch(`/api/admin/rating-types/${typeId}/categories/${categoryId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editCategoryName.toLowerCase() }),
body: JSON.stringify({ name: editCategoryName }),
});
setEditingCategory(null);
setEditCategoryName("");
@@ -176,6 +197,10 @@ function AdminPage(_props: Props) {
fetch("/api/admin/assets").then((r) => r.json()).then(setAssets).catch(() => {});
}
function loadBetCategories() {
fetch("/api/admin/bet-categories").then((r) => r.json()).then(setBetCategories).catch(() => {});
}
async function createTag() {
if (!newTagName || !newTagTypeId) return;
await fetch("/api/admin/tags", {
@@ -220,9 +245,9 @@ function AdminPage(_props: Props) {
await fetch("/api/admin/bets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: betName, description: betDesc, finishTime: betFinish, image: betImage }),
body: JSON.stringify({ name: betName, description: betDesc, finishTime: betFinish, image: betImage, categoryId: selectedBetCategoryId || undefined }),
});
setBetName(""); setBetDesc(""); setBetFinish(""); setBetImage("");
setBetName(""); setBetDesc(""); setBetFinish(""); setBetImage(""); setSelectedBetCategoryId("");
const res = await fetch("/api/bets").then((r) => r.json());
setBets(res);
}
@@ -250,6 +275,59 @@ function AdminPage(_props: Props) {
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`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resultEntryId }),
});
setCompletingBetId(null);
const res = await fetch("/api/bets").then((r) => r.json());
setBets(res);
}
async function createBetCategory() {
if (!newBetCategoryName) return;
await fetch("/api/admin/bet-categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newBetCategoryName }),
});
setNewBetCategoryName("");
loadBetCategories();
}
async function renameBetCategory(id: string, name: string) {
if (!name) return;
await fetch(`/api/admin/bet-categories/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
setEditingBetCategory(null);
setEditBetCategoryName("");
loadBetCategories();
}
async function deleteBetCategory(id: string) {
await fetch(`/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}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ categoryId: categoryId || null }),
});
setAssigningCategoryBetId(null);
setAssignCategoryId("");
const res = await fetch("/api/bets").then((r) => r.json());
setBets(res);
}
const borderClr = dark ? "#444" : "#ccc";
const textClr = dark ? "#e0e0e0" : "#333";
const mutedClr = dark ? "#aaa" : "#666";
@@ -257,12 +335,8 @@ function AdminPage(_props: Props) {
if (!adminLoggedIn) {
return (
<div style={{ maxWidth: 320, margin: "40px auto" }}>
<form onSubmit={(e) => { e.preventDefault(); if (adminPassword === "mitochondriapowerof12") { setAdminLoggedIn(true); setAdminError(""); } else { setAdminError("Wrong password"); } }} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<input type="password" placeholder="Admin password" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} style={{ padding: "10px 14px", fontSize: 16, border: `1px solid ${borderClr}`, background: dark ? "#2a2a2a" : "#fff", color: textClr }} />
{adminError && <p style={{ color: "#e74c3c", margin: 0, fontSize: 14 }}>{adminError}</p>}
<button type="submit" style={{ padding: "10px 0", fontSize: 16, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Enter Admin</button>
</form>
<div style={{ maxWidth: 320, margin: "40px auto", color: textClr, fontSize: 16, textAlign: "center" }}>
Not authorized
</div>
);
}
@@ -275,6 +349,7 @@ function AdminPage(_props: Props) {
<button onClick={() => setTab("bets")} style={{ flex: 1, padding: "10px 0", fontSize: 16, fontWeight: 700, background: tab === "bets" ? "#424e88" : "none", color: tab === "bets" ? "#fff" : textClr, border: `1px solid ${borderClr}`, cursor: "pointer", transition: "background 0.2s ease" }}>Bets ({bets.length})</button>
<button onClick={() => setTab("reports")} style={{ flex: 1, padding: "10px 0", fontSize: 16, fontWeight: 700, background: tab === "reports" ? "#424e88" : "none", color: tab === "reports" ? "#fff" : textClr, border: `1px solid ${borderClr}`, cursor: "pointer", transition: "background 0.2s ease" }}>Reports ({reports.length})</button>
<button onClick={() => setTab("assets")} style={{ flex: 1, padding: "10px 0", fontSize: 16, fontWeight: 700, background: tab === "assets" ? "#424e88" : "none", color: tab === "assets" ? "#fff" : textClr, border: `1px solid ${borderClr}`, cursor: "pointer", transition: "background 0.2s ease" }}>Assets ({assets.length})</button>
<button onClick={() => setTab("music")} style={{ flex: 1, padding: "10px 0", fontSize: 16, fontWeight: 700, background: tab === "music" ? "#424e88" : "none", color: tab === "music" ? "#fff" : textClr, border: `1px solid ${borderClr}`, cursor: "pointer", transition: "background 0.2s ease" }}>Music</button>
</div>
{tab === "ratings" ? (
@@ -289,8 +364,8 @@ function AdminPage(_props: Props) {
<input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }} />
<textarea placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }} />
<div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={() => setShowAssetPicker("picture")} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left" }}>{picture ? `Picture: ${picture.replace("/uploads/", "")}` : "Choose Picture"}</button>
<button type="button" onClick={() => setShowAssetPicker("icon")} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left" }}>{icon ? `Icon: ${icon.replace("/uploads/", "")}` : "Choose Icon"}</button>
<button type="button" onClick={() => { pickerCbRef.current = (url) => setPicture(url); setShowPicker(true); }} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left" }}>{picture ? `Picture: ${picture.replace("/uploads/", "")}` : "Choose Picture"}</button>
<button type="button" onClick={() => { pickerCbRef.current = (url) => setIcon(url); setShowPicker(true); }} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left" }}>{icon ? `Icon: ${icon.replace("/uploads/", "")}` : "Choose Icon"}</button>
</div>
<button type="submit" style={{ padding: "10px 0", fontSize: 16, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Create</button>
</form>
@@ -300,8 +375,15 @@ function AdminPage(_props: Props) {
<input placeholder="New type name" value={newTypeName} onChange={(e) => setNewTypeName(e.target.value)} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }} />
<button onClick={createRatingType} style={{ padding: "8px 16px", fontSize: 14, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Add</button>
</div>
{ratingTypes.map((t) => (
<div key={t.id} style={{ borderTop: `1px solid ${borderClr}`, padding: "8px 0" }}>
{ratingTypes.map((t, i) => (
<div
key={t.id}
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(() => {}); }}
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" }}>
<span style={{ color: textClr, fontSize: 14, fontWeight: 700 }}>{t.name} ({t.categories.length} categories)</span>
<div style={{ display: "flex", gap: 4 }}>
@@ -354,7 +436,10 @@ function AdminPage(_props: Props) {
<button onClick={createTag} style={{ padding: "8px 16px", fontSize: 14, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Add</button>
</div>
<input ref={tagImgRef} type="file" accept="image/*" onChange={(e) => { const f = e.target.files?.[0]; if (f) { const r = new FileReader(); r.onload = () => setNewTagImage(r.result as string); r.readAsDataURL(f); } }} style={{ display: "none" }} />
<button type="button" onClick={() => tagImgRef.current?.click()} style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left" }}>{newTagImage ? "Image set" : "Upload Tag Image"}</button>
<div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={() => tagImgRef.current?.click()} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left" }}>{newTagImage ? "Image set" : "Upload Tag Image"}</button>
<button type="button" onClick={() => { pickerCbRef.current = (url) => setNewTagImage(url); setShowPicker(true); }} style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer" }}>From Assets</button>
</div>
</div>
<div style={{ fontSize: 13, color: mutedClr, marginBottom: 6 }}>Tags for selected type:</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
@@ -413,8 +498,8 @@ function AdminPage(_props: Props) {
return tags.filter((t) => t.ratingTypeId === typeId && !r.tags?.some((rt) => rt.id === t.id));
})().map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
))}
</select>
<button onClick={() => addTagToRating(r.id, selectedTagForRating)} style={{ padding: "2px 6px", fontSize: 11, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>+</button>
<button onClick={() => { setAddingTagToRating(null); setSelectedTagForRating(""); }} style={{ padding: "2px 6px", fontSize: 11, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>x</button>
</div>
@@ -428,38 +513,122 @@ function AdminPage(_props: Props) {
</>
) : tab === "bets" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ border: `1px solid ${borderClr}`, padding: 16 }}>
<h3 style={{ margin: "0 0 8px" }}>Manage Bet Categories</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{betCategories.map((c) => (
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 6 }}>
{editingBetCategory === c.id ? (
<>
<input value={editBetCategoryName} onChange={(e) => setEditBetCategoryName(e.target.value)} style={{ flex: 1, padding: "4px 8px", fontSize: 13, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }} />
<button onClick={() => renameBetCategory(c.id, editBetCategoryName)} style={{ padding: "4px 10px", fontSize: 12, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Save</button>
<button onClick={() => { setEditingBetCategory(null); setEditBetCategoryName(""); }} style={{ padding: "4px 10px", fontSize: 12, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>Cancel</button>
</>
) : (
<>
<span style={{ color: textClr, fontSize: 13 }}>{c.name}</span>
<button onClick={() => { setEditingBetCategory(c.id); setEditBetCategoryName(c.name); }} style={{ padding: "2px 6px", fontSize: 11, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>Edit</button>
<button onClick={() => deleteBetCategory(c.id)} style={{ padding: "2px 6px", fontSize: 11, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>x</button>
</>
)}
</div>
))}
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
<input placeholder="New category name" value={newBetCategoryName} onChange={(e) => setNewBetCategoryName(e.target.value)} style={{ flex: 1, padding: "6px 10px", fontSize: 13, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }} />
<button onClick={createBetCategory} style={{ padding: "6px 12px", fontSize: 13, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Add</button>
</div>
</div>
</div>
<div style={{ border: `1px solid ${borderClr}`, padding: 16 }}>
<h2 style={{ margin: "0 0 8px" }}>New Bet</h2>
<input placeholder="Name" value={betName} onChange={(e) => setBetName(e.target.value)} style={{ display: "block", width: "100%", padding: "8px 12px", fontSize: 14, marginBottom: 8, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box" }} />
<textarea placeholder="Description" value={betDesc} onChange={(e) => setBetDesc(e.target.value)} style={{ display: "block", width: "100%", padding: "8px 12px", fontSize: 14, marginBottom: 8, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box" }} />
<input placeholder="Finish time" value={betFinish} onChange={(e) => setBetFinish(e.target.value)} style={{ display: "block", width: "100%", padding: "8px 12px", fontSize: 14, marginBottom: 8, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box" }} />
<input ref={betImageRef} type="file" accept="image/*" onChange={(e) => { const f = e.target.files?.[0]; if (f) { const r = new FileReader(); r.onload = () => setBetImage(r.result as string); r.readAsDataURL(f); } }} style={{ display: "none" }} />
<button type="button" onClick={() => betImageRef.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" }}>{betImage ? "Image set" : "Upload Image"}</button>
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<button type="button" onClick={() => betImageRef.current?.click()} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left", boxSizing: "border-box" }}>{betImage ? "Image set" : "Upload Image"}</button>
<button type="button" onClick={() => { pickerCbRef.current = (url) => setBetImage(url); setShowPicker(true); }} style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left", boxSizing: "border-box" }}>From Assets</button>
</div>
<select value={selectedBetCategoryId} onChange={(e) => setSelectedBetCategoryId(e.target.value)} style={{ display: "block", width: "100%", padding: "8px 12px", fontSize: 14, marginBottom: 8, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box" }}>
<option value="">-- No Category --</option>
{betCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button onClick={createBet} style={{ padding: "10px 0", fontSize: 16, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer", width: "100%" }}>Create</button>
</div>
{bets.map((bet) => (
<div key={bet.id} style={{ border: `1px solid ${borderClr}`, padding: 12, background: dark ? "#1a1a1a" : "#fafafa" }}>
{bets.map((bet) => {
const resultEntry = bet.completed ? bet.entries.find((e) => e.id === bet.resultEntryId) : null;
return (
<div key={bet.id} style={{ border: `1px solid ${resultEntry ? "#27ae60" : borderClr}`, padding: 12, background: dark ? "#1a1a1a" : "#fafafa" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, color: textClr }}>{bet.name}</div>
<div style={{ fontWeight: 700, color: textClr }}>
{bet.name}
{bet.completed && <span style={{ marginLeft: 6, fontSize: 11, color: "#27ae60" }}>(Completed)</span>}
</div>
<div style={{ fontSize: 12, color: mutedClr }}>{bet.description} · {bet.finishTime}</div>
<div style={{ fontSize: 12, color: mutedClr, marginTop: 4, display: "flex", alignItems: "center", gap: 6 }}>
<span>Category: {bet.categoryId ? (betCategories.find((c) => c.id === bet.categoryId)?.name ?? "Unknown") : "None"}</span>
{assigningCategoryBetId === bet.id ? (
<>
<select value={assignCategoryId} onChange={(e) => setAssignCategoryId(e.target.value)} style={{ padding: "2px 6px", fontSize: 11, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }}>
<option value="">-- None --</option>
{betCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button onClick={() => assignCategory(bet.id, assignCategoryId)} style={{ padding: "2px 6px", fontSize: 11, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Set</button>
<button onClick={() => { setAssigningCategoryBetId(null); setAssignCategoryId(""); }} style={{ padding: "2px 6px", fontSize: 11, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>x</button>
</>
) : (
<button onClick={() => { setAssigningCategoryBetId(bet.id); setAssignCategoryId(bet.categoryId ?? ""); }} style={{ padding: "2px 6px", fontSize: 11, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>Change</button>
)}
</div>
</div>
<div style={{ display: "flex", gap: 4 }}>
{!bet.completed && bet.entries.length > 0 && (
<button onClick={() => setCompletingBetId(completingBetId === bet.id ? null : bet.id)} style={{ padding: "4px 12px", fontSize: 13, background: "#27ae60", color: "#fff", border: "none", cursor: "pointer", whiteSpace: "nowrap" }}>
Complete
</button>
)}
<button onClick={() => deleteBet(bet.id)} style={{ padding: "4px 12px", fontSize: 13, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>Delete</button>
</div>
<button onClick={() => deleteBet(bet.id)} style={{ padding: "4px 12px", fontSize: 13, marginLeft: 8, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>Delete</button>
</div>
{bet.image && <img src={bet.image} alt="" style={{ width: "100%", maxHeight: 160, objectFit: "cover", marginTop: 8 }} />}
{completingBetId === bet.id && (
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 6, padding: 8, border: `1px solid ${borderClr}`, background: dark ? "#2a2a2a" : "#f0f0f0" }}>
<div style={{ fontSize: 13, fontWeight: 700, color: textClr }}>Select winner:</div>
{bet.entries.map((entry) => (
<button key={entry.id} onClick={() => completeBet(bet.id, entry.id)} style={{ padding: "6px 12px", fontSize: 13, background: "#424e88", color: "#fff", border: "none", cursor: "pointer", textAlign: "left" }}>
{entry.option} ({entry.supporters} votes)
</button>
))}
<button onClick={() => setCompletingBetId(null)} style={{ padding: "6px 12px", fontSize: 13, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>Cancel</button>
</div>
)}
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 4 }}>
{bet.entries.map((entry) => (
<div key={entry.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 13, padding: "4px 8px", border: `1px solid ${borderClr}` }}>
<span style={{ color: textClr }}>{entry.option}</span>
<div key={entry.id} style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
fontSize: 13, padding: "4px 8px",
border: `2px solid ${bet.resultEntryId === entry.id ? "#27ae60" : borderClr}`,
}}>
<span style={{ color: textClr }}>
{entry.option}
{bet.resultEntryId === entry.id && <span style={{ color: "#27ae60", marginLeft: 4, fontWeight: 700 }}>(Winner)</span>}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color: mutedClr }}>${(entry.amount / 100).toFixed(2)} · {entry.supporters}</span>
<button onClick={() => deleteEntry(bet.id, entry.id)} style={{ padding: "2px 6px", fontSize: 11, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>x</button>
<span style={{ color: mutedClr }}>{entry.supporters} voter{entry.supporters !== 1 ? "s" : ""}</span>
{!bet.completed && (
<button onClick={() => deleteEntry(bet.id, entry.id)} style={{ padding: "2px 6px", fontSize: 11, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>x</button>
)}
</div>
</div>
))}
</div>
))}
</div>
<div style={{ fontSize: 12, color: mutedClr, marginTop: 6, fontWeight: 600 }}>Total invested: ${(bet.entries.reduce((s, e) => s + e.amount, 0) / 100).toFixed(2)}</div>
{addingEntryFor === bet.id ? (
{resultEntry ? (
<div style={{ fontSize: 12, color: "#27ae60", marginTop: 6, fontWeight: 700 }}>Winner: {resultEntry.option}</div>
) : (
<div style={{ fontSize: 12, color: mutedClr, marginTop: 6, fontWeight: 600 }}>Total votes: {bet.entries.reduce((s, e) => s + e.supporters, 0)}</div>
)}
{!bet.completed && (addingEntryFor === bet.id ? (
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
<input placeholder="Option" value={newEntryOption} onChange={(e) => setNewEntryOption(e.target.value)} style={{ flex: 1, padding: "6px 10px", fontSize: 13, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }} />
<button onClick={() => addEntry(bet.id)} style={{ padding: "6px 12px", fontSize: 13, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Add</button>
@@ -467,9 +636,9 @@ function AdminPage(_props: Props) {
</div>
) : (
<button onClick={() => setAddingEntryFor(bet.id)} style={{ marginTop: 8, padding: "6px 0", fontSize: 13, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer", width: "100%" }}>+ Add Entry</button>
)}
))}
</div>
))}
)})}
</div>
) : tab === "assets" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
@@ -489,6 +658,66 @@ function AdminPage(_props: Props) {
))}
</div>
</div>
) : tab === "music" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: textClr, marginBottom: 4 }}>Existing Music</div>
{ratings.filter((r) => r.type === "music").map((r) => (
<div key={r.id} style={{ border: `1px solid ${borderClr}`, padding: "8px 12px", background: dark ? "#1a1a1a" : "#fafafa" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: 700, color: textClr }}>{r.name}</div>
<div style={{ display: "flex", gap: 4 }}>
<button onClick={() => { setEditRatingData({ name: r.name, description: r.description, picture: r.picture, icon: r.icon }); setEditingRating(r); }} style={{ padding: "4px 12px", fontSize: 13, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Edit</button>
<button onClick={() => removeRating(r.id)} style={{ padding: "4px 12px", fontSize: 13, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>Delete</button>
</div>
</div>
</div>
))}
</div>
<div style={{ border: `1px solid ${borderClr}`, padding: 16 }}>
<h3 style={{ margin: "0 0 8px" }}>Upload Music</h3>
<input ref={musicInputRef} type="file" accept="audio/mpeg" multiple onChange={(e) => {
const files = e.target.files;
if (!files) return;
const pending: { name: string; data: string }[] = [];
let loaded = 0;
for (let i = 0; i < files.length; i++) {
const f = files[i];
const r = new FileReader();
r.onload = () => {
pending.push({ name: f.name, data: r.result as string });
loaded++;
if (loaded === files.length) setMusicUploads(pending);
};
r.readAsDataURL(f);
}
}} style={{ display: "none" }} />
<button type="button" onClick={() => musicInputRef.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" }}>Choose MP3 files</button>
{musicUploads.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: textClr, marginBottom: 4 }}>Files to upload ({musicUploads.length}):</div>
{musicUploads.map((m, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: textClr, padding: "2px 0" }}>
<span>{m.name.replace(/\.[^.]+$/, "")}</span>
<button onClick={() => setMusicUploads((prev) => prev.filter((_, j) => j !== i))} style={{ padding: "2px 6px", fontSize: 11, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>x</button>
</div>
))}
</div>
)}
<button onClick={async () => {
if (musicUploads.length === 0) return;
setUploadingMusic(true);
try {
const r = await fetch("/api/admin/music", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: musicUploads }) });
if (!r.ok) { const err = await r.json(); console.error("Music upload failed:", err); return; }
setMusicUploads([]);
} catch (e) { console.error("Music upload error:", e); }
setUploadingMusic(false);
}} disabled={musicUploads.length === 0 || uploadingMusic} style={{ padding: "10px 0", fontSize: 16, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: musicUploads.length === 0 || uploadingMusic ? "not-allowed" : "pointer", width: "100%", opacity: musicUploads.length === 0 || uploadingMusic ? 0.6 : 1 }}>
{uploadingMusic ? "Uploading..." : `Upload ${musicUploads.length > 0 ? `(${musicUploads.length} files)` : ""}`}
</button>
</div>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{reports.map((r) => (
@@ -496,7 +725,7 @@ function AdminPage(_props: Props) {
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: 700, color: textClr }}>{r.subject}</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: 12, color: mutedClr }}>@{r.user.username} · {new Date(r.createdAt).toLocaleDateString()}</div>
<div style={{ fontSize: 12, color: mutedClr }}>{r.user.email} · {new Date(r.createdAt).toLocaleDateString()}</div>
<button onClick={() => removeReport(r.id)} style={{ padding: "2px 8px", fontSize: 12, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>Delete</button>
</div>
</div>
@@ -513,8 +742,10 @@ function AdminPage(_props: Props) {
<div style={{ fontWeight: 700, fontSize: 18, color: textClr }}>Edit {editingRating.name}</div>
<input value={editRatingData.name} onChange={(e) => setEditRatingData((p) => ({ ...p, name: e.target.value }))} placeholder="name" style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box" }} />
<textarea value={editRatingData.description} onChange={(e) => setEditRatingData((p) => ({ ...p, description: e.target.value }))} placeholder="description" rows={4} style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box", resize: "vertical" }} />
<input value={editRatingData.picture} onChange={(e) => setEditRatingData((p) => ({ ...p, picture: e.target.value }))} placeholder="picture" style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box" }} />
<input value={editRatingData.icon} onChange={(e) => setEditRatingData((p) => ({ ...p, icon: e.target.value }))} placeholder="icon" style={{ padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box" }} />
<div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={() => { pickerCbRef.current = (url) => setEditRatingData((p) => ({ ...p, picture: url })); setShowPicker(true); }} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left" }}>{editRatingData.picture ? `Picture: ${editRatingData.picture.replace("/uploads/", "")}` : "Choose Picture"}</button>
<button type="button" onClick={() => { pickerCbRef.current = (url) => setEditRatingData((p) => ({ ...p, icon: url })); setShowPicker(true); }} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left" }}>{editRatingData.icon ? `Icon: ${editRatingData.icon.replace("/uploads/", "")}` : "Choose Icon"}</button>
</div>
<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) });
@@ -528,19 +759,34 @@ function AdminPage(_props: Props) {
</div>
)}
{showAssetPicker && (
<div onClick={() => setShowAssetPicker(null)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.7)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 3000 }}>
{showPicker && (
<div onClick={() => { setShowPicker(false); pickerCbRef.current = null; }} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.7)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 3000 }}>
<div onClick={(e) => e.stopPropagation()} style={{ background: dark ? "#1e1e1e" : "#fff", border: `1px solid ${borderClr}`, padding: 24, maxWidth: 500, width: "90%", display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ fontWeight: 700, fontSize: 18, color: textClr }}>Choose {showAssetPicker}</div>
<div style={{ fontWeight: 700, fontSize: 18, color: textClr }}>Choose Asset</div>
<input ref={pickerImgRef} type="file" accept="image/*" style={{ display: "none" }} onChange={async (e) => {
const f = e.target.files?.[0];
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 data = await res.json();
pickerCbRef.current?.(data.url);
setShowPicker(false);
pickerCbRef.current = null;
loadAssets();
};
r.readAsDataURL(f);
}} />
<button type="button" onClick={() => pickerImgRef.current?.click()} style={{ padding: "8px 16px", fontSize: 14, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Upload New</button>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))", gap: 8, maxHeight: 300, overflowY: "auto" }}>
{assets.map((a) => (
<div key={a.name} onClick={() => { if (showAssetPicker === "picture") setPicture(a.url); else setIcon(a.url); setShowAssetPicker(null); }} style={{ border: `1px solid ${borderClr}`, overflow: "hidden", cursor: "pointer" }}>
<div key={a.name} onClick={() => { pickerCbRef.current?.(a.url); setShowPicker(false); pickerCbRef.current = null; }} style={{ border: `1px solid ${borderClr}`, overflow: "hidden", cursor: "pointer" }}>
<img src={a.url} alt="" style={{ width: "100%", height: 60, objectFit: "cover", display: "block" }} />
<div style={{ fontSize: 10, color: mutedClr, padding: "2px 4px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{a.name}</div>
</div>
))}
</div>
<button onClick={() => setShowAssetPicker(null)} style={{ padding: "8px 16px", fontSize: 14, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>Cancel</button>
<button onClick={() => { setShowPicker(false); pickerCbRef.current = null; }} style={{ padding: "8px 16px", fontSize: 14, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>Cancel</button>
</div>
</div>
)}
+128 -60
View File
@@ -18,41 +18,71 @@ type Bet = {
id: string;
name: string;
description: string;
finishTime: string;
image: string;
completed: boolean;
resultEntryId: string | null;
entries: Entry[];
};
type BetCategory = {
id: string;
name: string;
bets: Bet[];
};
type Props = {
user: { id: string; username: string; nickname: string | null; profilePicture: string | null; admin: boolean } | null;
user: { id: string; admin: boolean } | null;
};
function BetsPage({ user }: Props) {
const { dark } = useTheme();
const [bets, setBets] = useState<Bet[]>([]);
const [categories, setCategories] = useState<BetCategory[]>([]);
const [selected, setSelected] = useState<Bet | null>(null);
const [anonBetVotes, setAnonBetVotes] = useState<Record<string, string>>({});
function loadBets() {
const url = user ? `/api/bets?userId=${user.id}` : "/api/bets";
fetch(url).then((r) => r.json()).then(setBets).catch(() => {});
function loadData() {
const url = user ? `/api/bet-categories?userId=${user.id}` : "/api/bet-categories";
fetch(url).then((r) => r.json()).then(setCategories).catch(() => {});
if (user) {
setAnonBetVotes({});
} else {
fetch("/api/my-anonymous-bets").then((r) => r.json()).then(setAnonBetVotes).catch(() => {});
}
}
useEffect(() => { loadBets(); }, [user?.id]);
useEffect(() => { loadData(); }, [user?.id]);
useEffect(() => {
if (!selected) return;
const updated = bets.find((b) => b.id === selected.id);
if (updated) setSelected(updated);
}, [bets]);
for (const cat of categories) {
const found = cat.bets.find((b) => b.id === selected.id);
if (found) { setSelected(found); return; }
}
}, [categories]);
async function placeBet(entryId: string) {
if (!user) return;
function getUserVote(bet: Bet): string | null {
if (user) {
for (const e of bet.entries) {
if (e.userBets && e.userBets.length > 0) return e.id;
}
} else {
for (const e of bet.entries) {
if (anonBetVotes[e.id]) return e.id;
}
}
return null;
}
async function vote(entryId: string) {
if (!selected) return;
const body: Record<string, unknown> = { betEntryId: entryId, amount: 1 };
if (user) body.userId = user.id;
await fetch("/api/bet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: user.id, betEntryId: entryId, amount: 1 }),
body: JSON.stringify(body),
});
loadBets();
loadData();
}
const borderClr = dark ? "#444" : "#ccc";
@@ -61,42 +91,69 @@ function BetsPage({ user }: Props) {
const cardBg = dark ? "#1a1a1a" : "#fafafa";
const overlayBg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.4)";
const modalBg = dark ? "#1e1e1e" : "#fff";
function renderBetGrid(bets: Bet[]) {
return (
<div style={{ display: "flex", gap: 16, overflowX: "auto", paddingBottom: 8 }}>
{bets.map((bet) => {
const resultEntry = bet.completed ? bet.entries.find((e) => e.id === bet.resultEntryId) : null;
return (
<div
key={bet.id}
onClick={() => setSelected(bet)}
style={{
display: "flex",
flexDirection: "column",
border: `1px solid ${resultEntry ? "#27ae60" : borderClr}`,
borderRadius: 8,
background: cardBg,
cursor: "pointer",
overflow: "hidden",
position: "relative",
width: 260,
flexShrink: 0,
}}
>
{bet.completed && (
<div style={{ position: "absolute", top: 8, right: 8, background: "#27ae60", color: "#fff", fontSize: 11, fontWeight: 700, padding: "3px 8px", borderRadius: 4 }}>
Completed
</div>
)}
{bet.image ? (
<img src={bet.image} alt="" style={{ width: "100%", height: 180, objectFit: "contain", background: "#000" }} />
) : (
<div style={{ width: "100%", height: 180, background: "#424e88", display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 700, fontSize: 48 }}>
{bet.name[0]}
</div>
)}
<div style={{ padding: 16, display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 20, color: textClr }}>{bet.name}</div>
<div style={{ fontSize: 14, color: mutedClr, lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }}>
{bet.description}
</div>
{resultEntry ? (
<div style={{ fontSize: 14, color: "#27ae60", fontWeight: 700 }}>Winner: {resultEntry.option}</div>
) : (
<div style={{ fontSize: 14, color: mutedClr, fontWeight: 600 }}>Total: {bet.entries.reduce((s, e) => s + e.supporters, 0)} votes</div>
)}
</div>
</div>
);
})}
{bets.length === 0 && <p style={{ color: mutedClr, gridColumn: "1 / -1" }}>No bets yet.</p>}
</div>
);
}
return (
<div>
<div style={{ fontSize: 28, fontWeight: 700, color: textClr, marginBottom: 16 }}>Bets</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 16 }}>
{bets.map((bet) => (
<div
key={bet.id}
onClick={() => setSelected(bet)}
style={{
display: "flex",
flexDirection: "column",
border: `1px solid ${borderClr}`,
background: cardBg,
cursor: "pointer",
overflow: "hidden",
}}
>
{bet.image ? (
<img src={bet.image} alt="" style={{ width: "100%", height: 180, objectFit: "contain", background: "#000" }} />
) : (
<div style={{ width: "100%", height: 180, background: "#424e88", display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 700, fontSize: 48 }}>
{bet.name[0]}
</div>
)}
<div style={{ padding: 16, display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 20, color: textClr }}>{bet.name}</div>
<div style={{ fontSize: 14, color: mutedClr, lineHeight: 1.3, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }}>
{bet.description}
</div>
{bet.finishTime && <div style={{ fontSize: 13, color: mutedClr }}>{bet.finishTime}</div>}
<div style={{ fontSize: 14, color: mutedClr, fontWeight: 600 }}>Total: {bet.entries.reduce((s, e) => s + e.supporters, 0)} votes</div>
</div>
</div>
))}
{bets.length === 0 && <p style={{ color: mutedClr, gridColumn: "1 / -1" }}>No bets yet.</p>}
</div>
{categories.map((cat) => (
<div key={cat.id} style={{ marginBottom: 24 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: textClr, marginBottom: 12 }}>{cat.name}</div>
{renderBetGrid(cat.bets)}
</div>
))}
{selected && (
<div
@@ -110,7 +167,7 @@ function BetsPage({ user }: Props) {
<div
onClick={(e) => e.stopPropagation()}
style={{
background: modalBg, border: `1px solid ${borderClr}`, padding: 24,
background: modalBg, border: `1px solid ${borderClr}`, borderRadius: 8, padding: 24,
maxWidth: 420, width: "90%", maxHeight: "90vh", overflowY: "auto",
display: "flex", flexDirection: "column", gap: 16,
}}
@@ -118,28 +175,39 @@ function BetsPage({ user }: Props) {
{selected.image && <img src={selected.image} alt="" style={{ width: "100%", maxHeight: 400, objectFit: "contain", background: "#000" }} />}
<div style={{ fontWeight: 700, fontSize: 22, color: textClr }}>{selected.name}</div>
<div style={{ fontSize: 14, color: mutedClr }}>{selected.description}</div>
{selected.finishTime && <div style={{ fontSize: 13, color: mutedClr }}>Finishes: {selected.finishTime}</div>}
{selected.completed && (
<div style={{ fontSize: 14, color: "#27ae60", fontWeight: 700 }}>This bet has ended</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{selected.entries.map((entry) => {
const userBet = entry.userBets?.[0];
const userVoteId = getUserVote(selected);
const isMine = userVoteId === entry.id;
const isWinner = selected.resultEntryId === entry.id;
return (
<div key={entry.id} style={{ border: `1px solid ${borderClr}`, padding: 12, background: cardBg }}>
<div key={entry.id} style={{
border: `2px solid ${isWinner ? "#27ae60" : isMine ? "#424e88" : borderClr}`,
borderRadius: 6, padding: 12, background: cardBg,
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 700, fontSize: 16, color: textClr }}>{entry.option}</span>
<span style={{ fontSize: 13, color: mutedClr }}>
<span style={{ fontWeight: 700, fontSize: 16, color: textClr }}>
{entry.option}
{isWinner && <span style={{ color: "#27ae60", fontSize: 13, marginLeft: 8 }}>(Winner)</span>}
</span>
<span style={{ fontSize: 13, color: mutedClr, minWidth: 70, textAlign: "right" }}>
{entry.supporters} voter{entry.supporters !== 1 ? "s" : ""}
</span>
</div>
{userBet && (
<div style={{ fontSize: 13, color: mutedClr, marginTop: 4 }}>Voted</div>
)}
{user && (
{!selected.completed && (
<button
onClick={() => placeBet(entry.id)}
style={{ marginTop: 8, padding: "8px 0", fontSize: 14, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer", width: "100%" }}
onClick={() => vote(entry.id)}
style={{
marginTop: 8, padding: "8px 0", fontSize: 14, fontWeight: 700,
background: isMine ? "#e74c3c" : "#424e88",
color: "#fff", border: "none", cursor: "pointer", width: "100%",
}}
>
Bet
{isMine ? "Unvote" : "Vote"}
</button>
)}
</div>
-148
View File
@@ -1,148 +0,0 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useTheme } from "../context/ThemeContext";
type ChatMsg = {
id: string;
userId: string;
username: string;
nickname: string | null;
profilePicture: string | null;
text: string;
createdAt: string;
};
type Props = {
user: { id: string; username: string; nickname: string | null; profilePicture: string | null } | null;
};
function ChatPage({ user }: Props) {
const { dark } = useTheme();
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState("");
const [anonName, setAnonName] = useState("");
const [anonReady, setAnonReady] = useState(!!user);
const [pendingIds, setPendingIds] = useState<string[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
let idCounter = useRef(0);
function load() {
fetch("/api/chat").then((r) => r.json()).then(setMessages).catch(() => {});
}
useEffect(() => { load(); const t = setInterval(load, 3000); return () => clearInterval(t); }, []);
useEffect(() => { bottomRef.current?.scrollIntoView(); }, [messages]);
useEffect(() => { if (user) setAnonReady(true); }, [user]);
async function send(e: FormEvent) {
e.preventDefault();
if (!input.trim()) return;
const text = input.trim();
const tmpId = `pending-${++idCounter.current}`;
const tmp: ChatMsg & { _pending?: boolean } = {
id: tmpId, userId: user?.id || "anonymous", username: user?.username || anonName.trim() || "Anonymous",
nickname: user?.nickname || null, profilePicture: user?.profilePicture || null, text, createdAt: new Date().toISOString(), _pending: true,
};
setInput("");
setPendingIds((prev) => [...prev, tmpId]);
setMessages((prev) => [...prev, tmp as ChatMsg]);
const body: Record<string, string> = { text };
if (user) {
body.userId = user.id;
} else {
body.userId = "anonymous";
body.username = anonName.trim() || "Anonymous";
}
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setPendingIds((prev) => prev.filter((id) => id !== tmpId));
if (!res.ok) {
setMessages((prev) => prev.filter((m) => m.id !== tmpId));
return;
}
const msg = await res.json();
setMessages((prev) => prev.map((m) => m.id === tmpId ? msg : m));
} catch {
// message stays grey — never confirmed by server
}
}
const textClr = dark ? "#e0e0e0" : "#333";
const mutedClr = dark ? "#aaa" : "#666";
const borderClr = dark ? "#444" : "#ccc";
const inputBg = dark ? "#2a2a2a" : "#fff";
if (!anonReady) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12, height: "calc(100vh - 120px)", position: "relative" }}>
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 10, padding: "0 4px", filter: "blur(4px)", pointerEvents: "none", userSelect: "none" }}>
{messages.map((m) => (
<div key={m.id} style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: "50%", background: "#424e88", overflow: "hidden", flexShrink: 0 }}>
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 700, fontSize: 16 }}>?</div>
</div>
<div>
<span style={{ fontWeight: 700, fontSize: 15, color: textClr }}>Someone</span>
<span style={{ fontSize: 12, color: mutedClr, marginLeft: 6 }}>...</span>
<div style={{ fontSize: 16, color: textClr, marginTop: 2 }}>messages are hidden</div>
</div>
</div>
))}
</div>
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", zIndex: 10 }}>
<div style={{ background: dark ? "#1e1e1e" : "#fff", border: `1px solid ${borderClr}`, padding: 24, display: "flex", flexDirection: "column", gap: 12, maxWidth: 280, width: "100%" }}>
<h2 style={{ margin: 0, color: textClr, fontSize: 18 }}>Enter the chat</h2>
<input value={anonName} onChange={(e) => setAnonName(e.target.value)} placeholder="Your name (optional)" maxLength={20} style={{ padding: "10px 14px", fontSize: 16, border: `1px solid ${borderClr}`, background: dark ? "#2a2a2a" : "#fff", color: textClr }} />
<button onClick={() => setAnonReady(true)} style={{ padding: "10px 0", fontSize: 16, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Join Chat</button>
</div>
</div>
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12, height: "calc(100vh - 120px)" }}>
<div style={{ fontSize: 28, fontWeight: 700, color: textClr }}>Chat</div>
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2, padding: "0 4px" }}>
{messages.map((m, i) => {
const prev = messages[i - 1];
const sameUser = prev && prev.userId === m.userId && prev.username === m.username;
const pending = pendingIds.includes(m.id);
const msgTextClr = pending ? mutedClr : textClr;
return (
<div key={m.id} style={{ display: "flex", alignItems: "flex-start", gap: 10, marginTop: sameUser ? 0 : undefined, paddingLeft: sameUser ? 42 : undefined }}>
{sameUser ? null : (
<div style={{ width: 32, height: 32, borderRadius: "50%", background: m.profilePicture ? "none" : "#424e88", overflow: "hidden", flexShrink: 0, marginTop: sameUser ? 0 : 0 }}>
{m.profilePicture ? <img src={m.profilePicture} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : <div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontWeight: 700, fontSize: 16 }}>{(m.nickname || m.username)[0].toUpperCase()}</div>}
</div>
)}
<div>
{sameUser ? null : (
<div>
<span style={{ fontWeight: 700, fontSize: 15, color: msgTextClr }}>{m.nickname || m.username}</span>
<span style={{ fontSize: 12, color: m.userId === "anonymous" ? (pending ? mutedClr : "#e74c3c") : mutedClr, marginLeft: 4 }}>{m.userId === "anonymous" ? "anonymous" : `@${m.username}`}</span>
<span style={{ fontSize: 12, color: mutedClr, marginLeft: 6 }}>{new Date(m.createdAt).toLocaleTimeString()}</span>
</div>
)}
<div style={{ fontSize: 16, color: msgTextClr, marginTop: sameUser ? 0 : 2 }}>{m.text}</div>
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
<form onSubmit={send} style={{ display: "flex", gap: 8 }}>
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Type a message..." style={{ flex: 1, padding: "10px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }} />
<button type="submit" style={{ padding: "10px 16px", fontSize: 14, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Send</button>
</form>
</div>
);
}
export default ChatPage;
-55
View File
@@ -1,55 +0,0 @@
import { useEffect, useState } from "react";
import { useTheme } from "../context/ThemeContext";
type Entry = {
id: string;
username: string;
netWorth: number;
nickname: string | null;
};
function LeaderboardPage() {
const { dark } = useTheme();
const [entries, setEntries] = useState<Entry[]>([]);
useEffect(() => {
fetch("/api/leaderboard")
.then((r) => r.json())
.then(setEntries)
.catch(() => {});
}, []);
const itemBg = dark ? "#1a1a1a" : "#fafafa";
const borderClr = dark ? "#444" : "#ccc";
const textClr = dark ? "#e0e0e0" : "#333";
const mutedClr = dark ? "#aaa" : "#666";
return (
<div>
<div style={{ fontSize: 28, fontWeight: 700, color: textClr, marginBottom: 16 }}>Leaderboard</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{entries.map((e, i) => (
<div
key={e.id}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
border: `1px solid ${borderClr}`,
background: itemBg,
}}
>
<span style={{ fontWeight: 700, fontSize: 18, color: textClr }}>
#{i + 1} {e.nickname || e.username}
</span>
<span style={{ fontSize: 14, color: mutedClr }}>${(e.netWorth / 100).toFixed(2)}</span>
</div>
))}
</div>
</div>
);
}
export default LeaderboardPage;
+311 -84
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTheme } from "../context/ThemeContext";
type RatingCategory = {
@@ -24,6 +24,7 @@ type Rating = {
picture: string;
description: string;
icon: string;
musicFile: string;
average: number;
totalVotes: number;
categories: RatingCategory[];
@@ -46,6 +47,16 @@ function RatingsPage({ user }: Props) {
const [loadingDist, setLoadingDist] = useState<Record<string, boolean>>({});
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [anonVotes, setAnonVotes] = useState<Record<string, number>>({});
const [expandedId, setExpandedId] = useState<string | null>(null);
const [showVoted, setShowVoted] = useState(false);
const [showUnvoted, setShowUnvoted] = useState(false);
const playingRef = useRef<HTMLAudioElement | null>(null);
function handlePlay(el: HTMLAudioElement | null) {
if (playingRef.current && playingRef.current !== el) {
playingRef.current.pause();
}
playingRef.current = el;
}
async function loadDistribution(ratingId: string, catId: string) {
setLoadingDist((prev) => ({ ...prev, [catId]: true }));
@@ -54,7 +65,7 @@ function RatingsPage({ user }: Props) {
const data = await res.json();
const cat = data.categories.find((c: { id: string }) => c.id === catId);
if (cat) {
setDistributions({ [catId]: { accountRatings: cat.accountRatings, anonRatings: cat.anonRatings, average: cat.average, totalVotes: cat.totalVotes } });
setDistributions((prev) => ({ ...prev, [catId]: { accountRatings: cat.accountRatings, anonRatings: cat.anonRatings, average: cat.average, totalVotes: cat.totalVotes } }));
}
} catch {}
setLoadingDist((prev) => ({ ...prev, [catId]: false }));
@@ -62,7 +73,7 @@ function RatingsPage({ user }: Props) {
function fetchRatings() {
const q = user ? `?userId=${user.id}` : "";
fetch(`/api/ratings${q}`)
return fetch(`/api/ratings${q}`)
.then((r) => r.json())
.then(setRatings)
.catch(() => {});
@@ -70,6 +81,11 @@ function RatingsPage({ user }: Props) {
useEffect(() => { fetchRatings(); }, [user]);
useEffect(() => {
if (user) return;
fetch("/api/my-anonymous-votes").then((r) => r.json()).then(setAnonVotes).catch(() => {});
}, [user]);
useEffect(() => {
fetch("/api/rating-types").then((r) => r.json()).then((types) => {
setRatingTypes(types);
@@ -81,6 +97,14 @@ function RatingsPage({ user }: Props) {
}, []);
async function vote(ratingId: string, ratingCategoryId: string, score: number) {
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 }) };
}));
} else {
setAnonVotes((prev) => ({ ...prev, [`${ratingId}_${ratingCategoryId}`]: score }));
}
const body: Record<string, unknown> = { ratingId, ratingCategoryId, score };
if (user) body.userId = user.id;
await fetch("/api/vote", {
@@ -88,16 +112,13 @@ function RatingsPage({ user }: Props) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (user) {
fetchRatings();
} else {
setAnonVotes((prev) => ({ ...prev, [`${ratingId}_${ratingCategoryId}`]: score }));
}
setDistributions({});
const dist = await fetch(`/api/rating-distribution/${ratingId}`).then((r) => r.json());
const cat = dist.categories.find((c: { id: string }) => c.id === ratingCategoryId);
if (cat) {
setDistributions({ [ratingCategoryId]: { accountRatings: cat.accountRatings, anonRatings: cat.anonRatings, average: cat.average, totalVotes: cat.totalVotes } });
await fetchRatings();
if (distributions[ratingCategoryId]) {
const dist = await fetch(`/api/rating-distribution/${ratingId}`).then((r) => r.json());
const cat = dist.categories.find((c: { id: string }) => c.id === ratingCategoryId);
if (cat) {
setDistributions((prev) => ({ ...prev, [ratingCategoryId]: { accountRatings: cat.accountRatings, anonRatings: cat.anonRatings, average: cat.average, totalVotes: cat.totalVotes } }));
}
}
}
@@ -111,7 +132,11 @@ function RatingsPage({ user }: Props) {
const tagFiltered = selectedTags.length === 0
? filtered
: filtered.filter((r) => selectedTags.every((tagId) => r.tags?.some((t) => t.id === tagId)));
const sorted = [...tagFiltered].sort((a, b) => {
const voteFiltered = !showVoted && !showUnvoted ? tagFiltered : tagFiltered.filter((r) => {
const voted = r.categories.some((c) => user ? c.userVote != null : anonVotes[`${r.id}_${c.id}`] != null);
return showVoted ? voted : !voted;
});
const sorted = [...voteFiltered].sort((a, b) => {
if (selectedCatId) {
const aCat = a.categories.find((c) => c.id === selectedCatId);
const bCat = b.categories.find((c) => c.id === selectedCatId);
@@ -129,10 +154,47 @@ function RatingsPage({ user }: Props) {
const cardBg = dark ? "#1a1a1a" : "#fafafa";
const overlayBg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.4)";
const modalBg = dark ? "#1e1e1e" : "#fff";
const [winWidth, setWinWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setWinWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
const musicRatings = ratings.filter((r) => r.type === "music" && r.name.toLowerCase().includes(search.toLowerCase()));
const scrollCss = "div.music-scroll{display:inline-block;}@keyframes musicScroll{0%{transform:translateX(0)}50%{transform:translateX(var(--sd,0))}100%{transform:translateX(0)}}";
return (
<div>
<div style={{ fontSize: 28, fontWeight: 700, color: textClr, marginBottom: 16 }}>Ratings</div>
<style dangerouslySetInnerHTML={{__html: scrollCss}} />
<style>{`audio::-webkit-media-controls-current-time-display,audio::-webkit-media-controls-time-remaining-display{display:none!important}@media(max-width:768px){audio::-webkit-media-controls-volume-slider,audio::-webkit-media-controls-mute-button,audio::-webkit-media-controls-overflow-button{display:none!important}audio::-webkit-media-controls-timeline{flex:1 0 60px!important}}`}</style>
<div style={{ fontSize: 28, fontWeight: 700, color: textClr, marginBottom: 4 }}>ratings</div>
<div style={{ fontSize: 13, color: mutedClr, marginBottom: 16 }}>rate some random stuff</div>
{winWidth <= 768 && (
<div style={{ display: "flex", alignItems: "center", gap: 4, marginBottom: 8, overflowX: "auto" }}>
{ratingTypes.map((t) => (
<button
key={t.id}
onClick={() => { setCategory(t.name); setSelectedCatId(t.categories[0]?.id ?? null); setSelected(null); setExpandedId(null); }}
style={{
padding: "6px 12px",
fontSize: 14,
fontWeight: 700,
whiteSpace: "nowrap",
borderRadius: 4,
background: category === t.name ? "#424e88" : "none",
color: category === t.name ? "#fff" : textClr,
border: `1px solid ${category === t.name ? "#424e88" : borderClr}`,
cursor: "pointer",
flexShrink: 0,
}}
>
{t.name}
</button>
))}
</div>
)}
<input
placeholder="Search..."
value={search}
@@ -148,12 +210,59 @@ function RatingsPage({ user }: Props) {
boxSizing: "border-box",
}}
/>
<div style={{ display: "flex", gap: 24 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 110 }}>
{winWidth <= 768 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: mutedClr, marginBottom: 4 }}>Sort by</div>
<div style={{ display: "flex", gap: 4, overflowX: "auto" }}>
<button
onClick={() => setSelectedCatId(null)}
style={{
padding: "6px 12px",
fontSize: 13,
whiteSpace: "nowrap",
background: !selectedCatId ? "#424e88" : "none",
color: !selectedCatId ? "#fff" : textClr,
border: `1px solid ${!selectedCatId ? "#424e88" : borderClr}`,
cursor: "pointer",
borderRadius: 4,
flexShrink: 0,
}}
>
Alphabetical
</button>
{ratingTypes.find((t) => t.name === category)?.categories.map((c) => (
<button
key={c.id}
onClick={() => { setSelectedCatId(c.id); setSelected(null); }}
style={{
padding: "6px 12px",
fontSize: 13,
whiteSpace: "nowrap",
background: selectedCatId === c.id ? "#424e88" : "none",
color: selectedCatId === c.id ? "#fff" : textClr,
border: `1px solid ${selectedCatId === c.id ? "#424e88" : borderClr}`,
cursor: "pointer",
borderRadius: 4,
flexShrink: 0,
}}
>
{c.name}
</button>
))}
</div>
<div style={{ display: "flex", gap: 4, marginTop: 6 }}>
<button onClick={() => { setShowVoted((v) => !v); setShowUnvoted(false); }} style={{ padding: "4px 10px", fontSize: 12, background: showVoted ? "#424e88" : "none", color: showVoted ? "#fff" : textClr, border: `1px solid ${showVoted ? "#424e88" : borderClr}`, cursor: "pointer", borderRadius: 4, flexShrink: 0 }}>voted</button>
<button onClick={() => { setShowUnvoted((v) => !v); setShowVoted(false); }} style={{ padding: "4px 10px", fontSize: 12, background: showUnvoted ? "#424e88" : "none", color: showUnvoted ? "#fff" : textClr, border: `1px solid ${showUnvoted ? "#424e88" : borderClr}`, cursor: "pointer", borderRadius: 4, flexShrink: 0 }}>unvoted</button>
</div>
</div>
)}
<div style={{ display: "flex", flexDirection: winWidth <= 768 ? "column" : "row", gap: 24 }}>
{winWidth > 768 && (
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 110, ...(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)" } : {}) }}>
{ratingTypes.map((t) => (
<button
key={t.id}
onClick={() => { setCategory(t.name); setSelectedCatId(t.categories[0]?.id ?? null); setSelected(null); }}
onClick={() => { setCategory(t.name); setSelectedCatId(t.categories[0]?.id ?? null); setSelected(null); setExpandedId(null); }}
style={{
padding: "10px 16px",
fontSize: 16,
@@ -169,70 +278,188 @@ function RatingsPage({ user }: Props) {
{t.name}
</button>
))}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 120 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: mutedClr, marginBottom: 4 }}>Sort by</div>
{ratingTypes.find((t) => t.name === category)?.categories.map((c) => {
return (
<button
key={c.id}
onClick={() => { setSelectedCatId(c.id); setSelected(null); }}
style={{
padding: "8px 14px",
fontSize: 13,
width: "100%",
background: selectedCatId === c.id ? activeBg : "none",
color: textClr,
border: `1px solid ${selectedCatId === c.id ? "#424e88" : borderClr}`,
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s ease",
}}
>
{c.name}
</button>
);
})}
{(() => {
const allTags: Tag[] = [];
const seen = new Set<string>();
for (const r of ratings) {
if (r.type !== category) continue;
for (const t of r.tags || []) {
if (!seen.has(t.id)) { seen.add(t.id); allTags.push(t); }
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: mutedClr, marginBottom: 4, marginTop: 8 }}>Sort by</div>
<button
onClick={() => setSelectedCatId(null)}
style={{
padding: "8px 14px",
fontSize: 13,
width: "100%",
background: !selectedCatId ? activeBg : "none",
color: textClr,
border: `1px solid ${!selectedCatId ? "#424e88" : borderClr}`,
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s ease",
}}
>
Alphabetical
</button>
{ratingTypes.find((t) => t.name === category)?.categories.map((c) => {
return (
<button
key={c.id}
onClick={() => { setSelectedCatId(c.id); setSelected(null); }}
style={{
padding: "8px 14px",
fontSize: 13,
width: "100%",
background: selectedCatId === c.id ? activeBg : "none",
color: textClr,
border: `1px solid ${selectedCatId === c.id ? "#424e88" : borderClr}`,
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s ease",
}}
>
{c.name}
</button>
);
})}
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
<button onClick={() => { setShowVoted((v) => !v); setShowUnvoted(false); }} style={{ padding: "6px 12px", fontSize: 12, flex: 1, background: showVoted ? "#424e88" : "none", color: showVoted ? "#fff" : textClr, border: `1px solid ${showVoted ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>voted</button>
<button onClick={() => { setShowUnvoted((v) => !v); setShowVoted(false); }} style={{ padding: "6px 12px", fontSize: 12, flex: 1, background: showUnvoted ? "#424e88" : "none", color: showUnvoted ? "#fff" : textClr, border: `1px solid ${showUnvoted ? "#424e88" : borderClr}`, cursor: "pointer", textAlign: "left" }}>unvoted</button>
</div>
{(() => {
const allTags: Tag[] = [];
const seen = new Set<string>();
for (const r of ratings) {
if (r.type !== category) continue;
for (const t of r.tags || []) {
if (!seen.has(t.id)) { seen.add(t.id); allTags.push(t); }
}
}
}
return allTags.length > 0 ? (
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: mutedClr, marginBottom: 4, marginTop: 8 }}>Tags{selectedTags.length > 0 ? ` (${selectedTags.length})` : ""}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{allTags.map((t) => {
const active = selectedTags.includes(t.id);
return (
<button
key={t.id}
onClick={() => setSelectedTags((prev) => active ? prev.filter((id) => id !== t.id) : [...prev, t.id])}
style={{
padding: "8px 14px", fontSize: 13, fontWeight: 700, cursor: "pointer", textAlign: "left", width: "100%", borderRadius: 6,
background: active ? (t.image ? `url(${t.image}) center/cover` : t.color) : "#fff",
color: active ? "#fff" : t.color,
border: active ? "2px solid #fff" : `1px solid ${borderClr}`,
backgroundSize: "cover",
transition: "background 0.2s ease, border 0.2s ease, color 0.2s ease",
}}
>
{t.name}
</button>
);
})}
</div>
</div>
) : null;
})()}
return allTags.length > 0 ? (
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: mutedClr, marginBottom: 4, marginTop: 8 }}>tags</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{allTags.map((t) => {
const active = selectedTags.includes(t.id);
return (
<button
key={t.id}
onClick={() => setSelectedTags((prev) => active ? prev.filter((id) => id !== t.id) : [...prev, t.id])}
style={{
padding: "8px 14px", fontSize: 13, fontWeight: 700, cursor: "pointer", textAlign: "left", width: "100%",
background: active ? "#424e88" : "none",
color: active ? "#fff" : textClr,
border: `1px solid ${active ? "#424e88" : borderClr}`,
}}
>
{t.name}
</button>
);
})}
</div>
</div>
) : null;
})()}
</div>
</div>
)}
<div style={{ flex: 1 }}>
{category === "music" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{musicRatings.length === 0 && <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 filteredMusic = !showVoted && !showUnvoted ? musicRatings : musicRatings.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;
return showVoted ? voted : !voted;
});
const sortedMusic = selectedCatId ? [...filteredMusic].sort((a, b) => {
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);
}) : [...musicRatings].sort((a, b) => a.name.localeCompare(b.name));
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 isExpanded = expandedId === r.id;
const dist = cat ? distributions[cat.id] : undefined;
return (
<div key={r.id} style={{ border: `1px solid ${borderClr}`, background: cardBg }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 12px" }}>
<div onClick={(e) => { e.stopPropagation(); setExpandedId(isExpanded ? null : 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.musicFile && <div style={{ flex: 1, minWidth: 90, display: "flex" }}><audio controls controlsList="nodownload noplaybackrate" src={r.musicFile} style={{ height: 36, width: "100%", overflow: "hidden", borderRadius: 4 }} onClick={(e) => e.stopPropagation()} onPlay={(e) => handlePlay(e.currentTarget)} /></div>}
<div style={{ fontSize: 12, fontWeight: 700, color: mutedClr, minWidth: 40, textAlign: "right" }}>
{cat?.totalVotes > 0 ? cat.average.toFixed(2) : "-"} / 10
</div>
{winWidth > 768 && cat && (
<div style={{ display: "flex", gap: 2 }} onClick={(e) => e.stopPropagation()}>
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => {
const myVote = user ? cat.userVote : anonVotes[`${r.id}_${cat.id}`];
return (
<button key={score} onClick={() => vote(r.id, cat.id, score)} style={{ width: 26, height: 26, fontSize: 11, fontWeight: 700, background: myVote === score ? "#424e88" : dark ? "#2a2a2a" : "#eee", color: myVote === score ? "#fff" : textClr, border: `1px solid ${myVote === score ? "#424e88" : borderClr}`, cursor: "pointer", padding: 0 }}>{score}</button>
);
})}
</div>
)}
</div>
{isExpanded && cat && (
<div style={{ padding: "8px 12px 12px 12px", borderTop: `1px solid ${borderClr}` }}>
<div style={{ fontSize: 14, fontWeight: 700, color: textClr, marginBottom: 8 }}>{r.name}</div>
{winWidth <= 768 && (
<div style={{ display: "flex", justifyContent: "center", gap: 2, marginBottom: 10, flexWrap: "wrap" }} onClick={(e) => e.stopPropagation()}>
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => {
const myVote = user ? cat.userVote : anonVotes[`${r.id}_${cat.id}`];
return (
<button key={score} onClick={() => vote(r.id, cat.id, score)} style={{ width: 32, height: 32, fontSize: 12, fontWeight: 700, background: myVote === score ? "#424e88" : dark ? "#2a2a2a" : "#eee", color: myVote === score ? "#fff" : textClr, border: `1px solid ${myVote === score ? "#424e88" : borderClr}`, cursor: "pointer", padding: 0 }}>{score}</button>
);
})}
</div>
)}
<div style={{ display: "flex", justifyContent: "center", gap: 24, marginBottom: 10 }}>
<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>
</div>
{dist ? (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{dist.totalVotes > 0 ? dist.accountRatings.map((ac, i) => {
const an = dist.anonRatings[i];
const total = ac + an;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11, color: mutedClr, width: 18, textAlign: "right" }}>{i + 1}</span>
{total > 0 ? (
<div style={{ display: "flex", height: 12, width: `${(total / dist.totalVotes) * 100}%`, minWidth: 4 }}>
{ac > 0 && <div style={{ height: "100%", flex: ac, background: "#e6c200" }} />}
{an > 0 && <div style={{ height: "100%", flex: an, background: "#888" }} />}
</div>
) : <div />}
<span style={{ fontSize: 10, color: mutedClr }}>{total}</span>
</div>
);
}) : null}
<div style={{ fontSize: 11, color: mutedClr, marginTop: 2 }}>
Avg: {dist.average.toFixed(2)} / 10 &middot; {dist.totalVotes} vote{dist.totalVotes !== 1 ? "s" : ""}
</div>
</div>
) : (
<div style={{ fontSize: 12, color: mutedClr }}>Loading...</div>
)}
</div>
)}
</div>
);
});
})()}
</div>
) : sorted.length === 0 ? (
<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>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{sorted.map((r) => (
<div
@@ -284,6 +511,8 @@ function RatingsPage({ user }: Props) {
</div>
))}
</div>
)}
</div>
</div>
{selected && (
@@ -355,17 +584,16 @@ function RatingsPage({ user }: Props) {
<div style={{ display: "flex", flexDirection: "column", gap: 2, marginBottom: 6 }}>
{dist.totalVotes > 0 ? dist.accountRatings.map((ac, i) => {
const an = dist.anonRatings[i];
const maxCount = Math.max(...dist.accountRatings.map((v, j) => v + dist.anonRatings[j]), 1);
const total = ac + an;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 11, color: mutedClr, width: 14, textAlign: "right" }}>{i + 1}</span>
{total > 0 ? (
<div style={{ flex: 1, display: "flex", height: 12, width: `${(total / maxCount) * 100}%`, minWidth: 4 }}>
<div style={{ height: "100%", flex: 1, background: "#e6c200", minWidth: ac > 0 ? 4 : 0 }} />
{an > 0 && <div style={{ height: "100%", flex: 1, background: "#888", minWidth: 4 }} />}
<div style={{ display: "flex", height: 12, width: `${(total / dist.totalVotes) * 100}%`, minWidth: 4 }}>
{ac > 0 && <div style={{ height: "100%", flex: ac, background: "#e6c200" }} />}
{an > 0 && <div style={{ height: "100%", flex: an, background: "#888" }} />}
</div>
) : <div style={{ flex: 1 }} />}
) : <div />}
<span style={{ fontSize: 10, color: mutedClr }}>{total}</span>
</div>
);
@@ -429,7 +657,6 @@ function RatingsPage({ user }: Props) {
</div>
)}
</div>
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
import { useState } from "react";
import { useTheme } from "../context/ThemeContext";
type Props = {
sidebarPinned: boolean;
onToggleSidebar: () => void;
};
function getCookie(name: string) {
const match = document.cookie.match(`(?:^|;\\s*)${name}=([^;]*)`);
return match ? match[1] : null;
}
function SettingsPage({ sidebarPinned, onToggleSidebar }: Props) {
const [debug, setDebug] = useState(() => getCookie("debug") === "true");
function toggleDebug() {
const next = !debug;
setDebug(next);
document.cookie = `debug=${next}; path=/; max-age=31536000`;
}
return (
<div>
<div style={{ fontSize: 28, fontWeight: 700, color: "#e0e0e0", marginBottom: 16 }}>Settings</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<input type="checkbox" checked={debug} onChange={toggleDebug} />
Debug mode
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<input type="checkbox" checked={sidebarPinned} onChange={onToggleSidebar} />
Keep sidebar open
</label>
</div>
</div>
);
}
export default SettingsPage;
+3 -3
View File
@@ -2,7 +2,7 @@ import { FormEvent, useState } from "react";
import { useTheme } from "../context/ThemeContext";
type Props = {
user: { id: string; username: string } | null;
user: { id: string } | null;
};
const MAX_SUBJECT = 100;
@@ -61,8 +61,8 @@ function TellDevPage({ user }: Props) {
/>
<div style={{ fontSize: 12, color: mutedClr, textAlign: "right", marginTop: 2 }}>{text.length}/{MAX_TEXT}</div>
</div>
<button type="submit" style={{ padding: "14px 0", fontSize: 18, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>
Send
<button type="submit" disabled={!user} style={{ padding: "14px 0", fontSize: 18, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: user ? "pointer" : "not-allowed", opacity: user ? 1 : 0.6 }}>
{user ? "Send" : "Log in to report"}
</button>
</form>
)}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/sidebar.tsx","./src/context/themecontext.tsx","./src/pages/accountpage.tsx","./src/pages/adminpage.tsx","./src/pages/balancepage.tsx","./src/pages/betspage.tsx","./src/pages/chatpage.tsx","./src/pages/leaderboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/portfoliopage.tsx","./src/pages/ratingspage.tsx","./src/pages/settingspage.tsx","./src/pages/signuppage.tsx","./src/pages/stockspage.tsx","./src/pages/telldevpage.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/sidebar.tsx","./src/context/themecontext.tsx","./src/pages/accountpage.tsx","./src/pages/adminpage.tsx","./src/pages/betspage.tsx","./src/pages/leaderboardpage.tsx","./src/pages/ratingspage.tsx","./src/pages/telldevpage.tsx"],"version":"5.9.3"}
+58 -18
View File
@@ -16,6 +16,7 @@
"@react-oauth/google": "^0.13.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.18.1",
"recharts": "^3.9.0"
},
"devDependencies": {
@@ -1855,13 +1856,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2184,15 +2178,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@@ -3963,6 +3948,57 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -4187,6 +4223,12 @@
"resolved": "server",
"link": true
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -5139,7 +5181,6 @@
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^6.0.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"express": "^4.21.0",
"google-auth-library": "^10.9.0",
@@ -5147,7 +5188,6 @@
"sharp": "^0.35.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
+513 -195
View File
@@ -6,15 +6,54 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.prisma = exports.app = void 0;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const bcryptjs_1 = __importDefault(require("bcryptjs"));
const google_auth_library_1 = require("google-auth-library");
const googleClient = new google_auth_library_1.OAuth2Client(process.env.GOOGLE_CLIENT_ID);
const client_1 = require("@prisma/client");
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const app = (0, express_1.default)();
exports.app = app;
const prisma = new client_1.PrismaClient();
exports.prisma = prisma;
const uploadsDir = path_1.default.join(__dirname, "../uploads");
if (!fs_1.default.existsSync(uploadsDir))
fs_1.default.mkdirSync(uploadsDir, { recursive: true });
app.set("trust proxy", true);
app.use((0, cors_1.default)());
app.use(express_1.default.json({ limit: "10mb" }));
app.use("/uploads", express_1.default.static(uploadsDir));
app.use(express_1.default.json({ limit: "100mb" }));
async function saveImage(base64) {
if (!base64 || base64.startsWith("http") || base64.startsWith("/uploads"))
return base64;
const match = base64.match(/^data:image\/(\w+);base64,(.+)$/);
if (!match)
return base64;
const ext = match[1] === "jpeg" ? "jpg" : match[1];
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const filepath = path_1.default.join(uploadsDir, filename);
const buf = Buffer.from(match[2], "base64");
try {
const sharp = (await import("sharp")).default;
await sharp(buf).resize(256, 256, { fit: "cover", position: "center" }).toFile(filepath);
}
catch {
fs_1.default.writeFileSync(filepath, match[2], "base64");
}
return `/uploads/${filename}`;
}
async function saveMusic(base64, filename) {
if (!base64)
return "";
if (base64.startsWith("http") || base64.startsWith("/uploads"))
return base64;
const match = base64.match(/^data:audio\/\w+;base64,(.+)$/);
if (!match)
return base64;
const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
const filepath = path_1.default.join(uploadsDir, safe);
fs_1.default.writeFileSync(filepath, match[1], "base64");
return `/uploads/${safe}`;
}
function getIp(req) {
return req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.ip || "unknown";
}
@@ -26,176 +65,232 @@ app.get("/api/ip-count", async (req, res) => {
const account = await prisma.ipAccount.findUnique({ where: { ip } });
res.json({ count: account?.count ?? 0 });
});
app.post("/api/signup", async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: "Username and password are required" });
app.post("/api/google-login", async (req, res) => {
const { credential } = req.body;
if (!credential) {
res.status(400).json({ error: "credential required" });
return;
}
if (!/^[a-zA-Z0-9]+$/.test(username) || username.length > 20) {
res.status(400).json({ error: "Letters and numbers only, max 20 characters" });
return;
try {
const ticket = await googleClient.verifyIdToken({
idToken: credential,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload || !payload.email) {
res.status(400).json({ error: "Invalid token" });
return;
}
const email = payload.email;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
res.json({ id: existing.id, email: existing.email, admin: existing.admin });
return;
}
const user = await prisma.user.create({
data: { email },
});
const ip = getIp(req);
await prisma.ipAccount.upsert({
where: { ip },
create: { ip, count: 1 },
update: { count: { increment: 1 } },
});
res.json({ id: user.id, email: user.email, admin: user.admin });
}
const existing = await prisma.user.findUnique({ where: { username } });
if (existing) {
res.status(409).json({ error: "Username already taken" });
return;
catch (e) {
res.status(401).json({ error: "Invalid Google token" });
}
const ip = getIp(req);
const ipAccount = await prisma.ipAccount.findUnique({ where: { ip } });
if (ipAccount && ipAccount.count >= 3) {
res.status(429).json({ error: "Maximum 3 accounts per IP address" });
return;
}
const hashed = await bcryptjs_1.default.hash(password, 10);
const user = await prisma.user.create({
data: { username, password: hashed },
});
await prisma.ipAccount.upsert({
where: { ip },
create: { ip, count: 1 },
update: { count: { increment: 1 } },
});
await prisma.balanceHistory.create({
data: { userId: user.id, amount: user.balance },
});
res.status(201).json({ id: user.id, username: user.username, balance: user.balance, nickname: user.nickname, profilePicture: user.profilePicture, admin: user.admin });
});
app.post("/api/login", async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: "Username and password are required" });
return;
}
const user = await prisma.user.findUnique({ where: { username } });
app.get("/api/user-by-id/:id", async (req, res) => {
const { id } = req.params;
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
res.status(401).json({ error: "Invalid credentials" });
res.status(404).json({ error: "not found" });
return;
}
const valid = await bcryptjs_1.default.compare(password, user.password);
if (!valid) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
res.json({ id: user.id, username: user.username, balance: user.balance, nickname: user.nickname, profilePicture: user.profilePicture, admin: user.admin });
res.json({ id: user.id, email: user.email, admin: user.admin });
});
app.put("/api/user", async (req, res) => {
const { id, nickname, profilePicture } = req.body;
const { id } = req.body;
if (!id) {
res.status(400).json({ error: "User ID is required" });
return;
}
const user = await prisma.user.update({
where: { id },
data: { nickname, profilePicture },
});
res.json({ id: user.id, username: user.username, balance: user.balance, nickname: user.nickname, profilePicture: user.profilePicture, admin: user.admin });
});
app.get("/api/stocks", async (_req, res) => {
const stocks = await prisma.stock.findMany();
res.json(stocks);
});
app.post("/api/buy", async (req, res) => {
const { userId, stockId, quantity } = req.body;
if (!userId || !stockId || !quantity || quantity <= 0) {
res.status(400).json({ error: "userId, stockId, and positive quantity are required" });
return;
}
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
res.status(404).json({ error: "User not found" });
res.status(404).json({ error: "not found" });
return;
}
const stock = await prisma.stock.findUnique({ where: { id: stockId } });
if (!stock) {
res.status(404).json({ error: "Stock not found" });
return;
}
const cost = stock.price * quantity;
if (user.balance < cost) {
res.status(400).json({ error: "Insufficient balance" });
return;
}
const [updatedUser] = await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: { balance: { decrement: cost } },
}),
prisma.balanceHistory.create({
data: { userId, amount: user.balance - cost },
}),
prisma.portfolio.upsert({
where: { userId_stockId: { userId, stockId } },
update: { shares: { increment: quantity } },
create: { userId, stockId, shares: quantity },
}),
]);
res.json({ id: updatedUser.id, username: updatedUser.username, balance: updatedUser.balance, nickname: updatedUser.nickname, profilePicture: updatedUser.profilePicture });
});
app.get("/api/portfolio/:userId", async (req, res) => {
const { userId } = req.params;
const portfolio = await prisma.portfolio.findMany({
where: { userId },
include: { stock: true },
});
res.json(portfolio);
});
app.get("/api/balance-history/:userId", async (req, res) => {
const { userId } = req.params;
const history = await prisma.balanceHistory.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: { amount: true, createdAt: true },
});
res.json(history);
res.json({ id: user.id, email: user.email, admin: user.admin });
});
app.get("/api/ratings", async (req, res) => {
const userId = req.query.userId;
const ratings = await prisma.rating.findMany();
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) {
userVoteMap[ur.ratingId] = ur.score;
if (!userVoteMap[ur.ratingId])
userVoteMap[ur.ratingId] = {};
userVoteMap[ur.ratingId][ur.ratingCategoryId] = ur.score;
}
const allRatings = await prisma.userRating.findMany();
const tallyMap = {};
const anonRatings = await prisma.anonymousRatingVote.findMany();
const catTallyMap = {};
for (const ur of allRatings) {
if (!tallyMap[ur.ratingId])
tallyMap[ur.ratingId] = Array(10).fill(0);
tallyMap[ur.ratingId][ur.score - 1]++;
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 result = ratings.map((r) => {
const counts = tallyMap[r.id] || Array(10).fill(0);
const total = counts.reduce((s, c) => s + c, 0);
const sum = counts.reduce((s, c, i) => s + c * (i + 1), 0);
return { ...r, average: total > 0 ? sum / total : 0, ratings: counts, totalVotes: total, userVote: userVoteMap[r.id] ?? null };
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 };
});
if (catData.length === 0 && r.musicFile) {
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 }];
}
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);
});
app.post("/api/vote", async (req, res) => {
const { userId, ratingId, score } = req.body;
if (!userId || !ratingId || !score || score < 1 || score > 10) {
res.status(400).json({ error: "userId, ratingId, and score (1-10) are required" });
app.get("/api/rating-distribution/:ratingId", async (req, res) => {
const { ratingId } = req.params;
const rating = await prisma.rating.findUnique({ where: { id: ratingId } });
if (!rating) {
res.status(404).json({ error: "Rating not found" });
return;
}
const existing = await prisma.userRating.findUnique({
where: { userId_ratingId: { userId, ratingId } },
const ratingType = await prisma.ratingType.findUnique({ where: { name: rating.type } });
const categories = ratingType ? await prisma.ratingCategory.findMany({ where: { ratingTypeId: ratingType.id } }) : [];
const allRatings = await prisma.userRating.findMany({ where: { ratingId } });
const anonRatings = 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]++;
}
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 }] });
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 };
});
if (existing) {
await prisma.userRating.update({
where: { userId_ratingId: { userId, ratingId } },
data: { score },
res.json({ categories: catData });
});
app.post("/api/vote", async (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 } });
if (rating?.musicFile) {
const rtype = await prisma.ratingType.findUnique({ where: { name: rating.type } });
const cat = await prisma.ratingCategory.findFirst({ where: { ratingTypeId: rtype.id } });
if (cat)
ratingCategoryId = cat.id;
}
}
if (!ratingCategoryId) {
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 },
});
}
else {
await prisma.userRating.create({
data: { userId, ratingId, score },
const ip = getIp(req);
await prisma.anonymousRatingVote.upsert({
where: { ip_ratingId_ratingCategoryId: { ip, ratingId, ratingCategoryId } },
update: { score },
create: { ip, ratingId, ratingCategoryId, score },
});
}
res.json({ success: true });
});
app.get("/api/anonymous-votes/:ratingId", async (req, res) => {
const { ratingId } = req.params;
const ip = getIp(req);
const votes = await prisma.anonymousRatingVote.findMany({ where: { ip, ratingId } });
const map = {};
for (const v of votes) {
map[v.ratingCategoryId] = v.score;
}
if (Object.keys(map).length > 0) {
const rating = await prisma.rating.findUnique({ where: { id: ratingId } });
if (rating?.musicFile && !map.score) {
map.score = Object.values(map)[0];
}
}
res.json(map);
});
app.get("/api/my-anonymous-votes", async (req, res) => {
const ip = getIp(req);
const votes = await prisma.anonymousRatingVote.findMany({ where: { ip } });
const map = {};
for (const v of votes) {
map[`${v.ratingId}_${v.ratingCategoryId}`] = v.score;
}
res.json(map);
});
app.post("/api/reports", async (req, res) => {
const { userId, subject, text } = req.body;
if (!userId || !subject || !text) {
@@ -207,7 +302,7 @@ app.post("/api/reports", async (req, res) => {
});
app.get("/api/admin/reports", async (_req, res) => {
const reports = await prisma.report.findMany({
include: { user: { select: { username: true } } },
include: { user: { select: { email: true } } },
orderBy: { createdAt: "desc" },
});
res.json(reports);
@@ -223,104 +318,324 @@ app.post("/api/admin/ratings", async (req, res) => {
res.status(400).json({ error: "type and name are required" });
return;
}
const rating = await prisma.rating.create({ data: { type, name, description: description ?? "", picture: picture ?? "", icon: icon ?? "" } });
const rating = await prisma.rating.create({ data: { type, name, description: description ?? "", picture: await saveImage(picture ?? ""), icon: await saveImage(icon ?? "") } });
res.json(rating);
});
app.put("/api/admin/ratings/:id", async (req, res) => {
const { id } = req.params;
const { name, description, picture, icon } = req.body;
const data = {};
if (name !== undefined)
data.name = name;
if (description !== undefined)
data.description = description;
if (picture !== undefined)
data.picture = await saveImage(picture);
if (icon !== undefined)
data.icon = await saveImage(icon);
const rating = await prisma.rating.update({ where: { id }, data });
res.json(rating);
});
app.delete("/api/admin/ratings/:id", async (req, res) => {
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.rating.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/rating-types", async (_req, res) => {
const types = await prisma.ratingType.findMany({ include: { categories: true }, orderBy: { sortOrder: "asc" } });
res.json(types);
});
app.post("/api/admin/rating-types", async (req, res) => {
const { name } = req.body;
if (!name) {
res.status(400).json({ error: "name is required" });
return;
}
const max = await prisma.ratingType.aggregate({ _max: { sortOrder: true } });
const t = await prisma.ratingType.upsert({ where: { name }, create: { name, sortOrder: (max._max.sortOrder ?? 0) + 1 }, update: {} });
res.json(t);
});
app.delete("/api/admin/rating-types/:id", async (req, res) => {
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingCategory: { ratingTypeId: id } } });
await prisma.ratingCategory.deleteMany({ where: { ratingTypeId: id } });
await prisma.ratingType.delete({ where: { id } });
res.json({ success: true });
});
app.put("/api/admin/rating-types/reorder", async (req, res) => {
const { ids } = 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++) {
await prisma.ratingType.update({ where: { id: ids[i] }, data: { sortOrder: i } });
}
res.json({ success: true });
});
app.post("/api/admin/rating-types/:id/categories", async (req, res) => {
const { id } = req.params;
const { name } = req.body;
if (!name) {
res.status(400).json({ error: "name is required" });
return;
}
const cat = await prisma.ratingCategory.create({ data: { ratingTypeId: id, name } });
res.json(cat);
});
app.delete("/api/admin/rating-types/:typeId/categories/:categoryId", async (req, res) => {
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) => {
const { categoryId } = req.params;
const { name } = req.body;
if (!name) {
res.status(400).json({ error: "name is required" });
return;
}
const cat = await prisma.ratingCategory.update({ where: { id: categoryId }, data: { name } });
res.json(cat);
});
app.get("/api/tags", async (_req, res) => {
const tags = await prisma.tag.findMany({ orderBy: { name: "asc" } });
res.json(tags);
});
app.post("/api/admin/tags", async (req, res) => {
const { name, color, image, 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, image: await saveImage(image ?? "") } });
res.json(tag);
});
app.delete("/api/admin/tags/:id", async (req, res) => {
const { id } = req.params;
await prisma.ratingTag.deleteMany({ where: { tagId: id } });
await prisma.tag.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/tags-by-type/:ratingTypeId", async (req, res) => {
const { ratingTypeId } = req.params;
const tags = await prisma.tag.findMany({ where: { ratingTypeId }, orderBy: { name: "asc" } });
res.json(tags);
});
app.post("/api/admin/ratings/:id/tags", async (req, res) => {
const { id } = req.params;
const { tagId } = req.body;
if (!tagId) {
res.status(400).json({ error: "tagId is required" });
return;
}
await prisma.ratingTag.upsert({
where: { ratingId_tagId: { ratingId: id, tagId } },
create: { ratingId: id, tagId },
update: {},
});
res.json({ success: true });
});
app.delete("/api/admin/ratings/:id/tags/:tagId", async (req, res) => {
const { id, tagId } = req.params;
await prisma.ratingTag.delete({ where: { ratingId_tagId: { ratingId: id, tagId } } });
res.json({ success: true });
});
app.post("/api/admin/music", async (req, res) => {
const { files } = req.body;
if (!files || !files.length) {
res.status(400).json({ error: "files array is required" });
return;
}
let musicType = await prisma.ratingType.findFirst({ where: { name: "music" }, include: { categories: true } });
if (!musicType) {
musicType = await prisma.ratingType.create({ data: { name: "music" }, include: { categories: true } });
}
if (!musicType.categories.length) {
await prisma.ratingCategory.create({ data: { ratingTypeId: musicType.id, name: "Score" } });
}
const created = [];
for (const f of files) {
const name = f.name.replace(/\.[^.]+$/, "");
const musicFile = await saveMusic(f.data, f.name);
const rating = await prisma.rating.create({ data: { type: "music", name, description: "", picture: "", icon: "", musicFile } });
created.push({ id: rating.id, name });
}
res.json(created);
});
app.delete("/api/admin/music/:id", async (req, res) => {
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingId: id } });
await prisma.ratingTag.deleteMany({ where: { ratingId: id } });
await prisma.rating.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/leaderboard", async (_req, res) => {
const users = await prisma.user.findMany({
select: { id: true, username: true, balance: true, nickname: true },
select: { id: true, email: true },
});
const portfolios = await prisma.portfolio.findMany({
include: { stock: true },
});
const portfolioMap = {};
for (const p of portfolios) {
if (!portfolioMap[p.userId])
portfolioMap[p.userId] = [];
portfolioMap[p.userId].push(p);
}
const result = users.map((u) => {
const userPortfolio = portfolioMap[u.id] || [];
const stockValue = userPortfolio.reduce((sum, p) => sum + p.shares * p.stock.price, 0);
return { id: u.id, username: u.username, nickname: u.nickname, netWorth: u.balance + stockValue };
});
result.sort((a, b) => b.netWorth - a.netWorth);
const result = users.map((u) => ({
id: u.id, email: u.email, netWorth: 0,
}));
res.json(result);
});
app.get("/api/net-worth/:userId", async (req, res) => {
const { userId } = req.params;
const user = await prisma.user.findUnique({ where: { id: userId }, select: { balance: true } });
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
const portfolio = await prisma.portfolio.findMany({
where: { userId },
include: { stock: true },
});
const stockValue = portfolio.reduce((sum, p) => sum + p.shares * p.stock.price, 0);
res.json({ netWorth: user.balance + stockValue });
});
app.get("/api/bets", async (req, res) => {
const userId = req.query.userId;
const bets = await prisma.bet.findMany({
include: {
entries: userId
? { include: { userBets: { where: { userId } } } }
: true,
? { orderBy: { id: "asc" }, include: { userBets: { where: { userId } } } }
: { orderBy: { id: "asc" } },
},
orderBy: { id: "asc" },
});
res.json(bets);
});
app.get("/api/bet-categories", async (req, res) => {
const userId = req.query.userId;
const cats = await prisma.betCategory.findMany({
include: {
bets: {
include: {
entries: userId
? { orderBy: { id: "asc" }, include: { userBets: { where: { userId } } } }
: { orderBy: { id: "asc" } },
},
orderBy: { id: "asc" },
},
},
orderBy: { id: "asc" },
});
res.json(cats);
});
app.post("/api/bet", async (req, res) => {
const { userId, betEntryId, amount } = req.body;
if (!userId || !betEntryId || !amount || amount <= 0) {
res.status(400).json({ error: "userId, betEntryId, and positive amount are required" });
return;
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || user.balance < amount) {
res.status(400).json({ error: "Insufficient balance" });
return;
}
const entry = await prisma.betEntry.findUnique({ where: { id: betEntryId } });
const entry = await prisma.betEntry.findUnique({
where: { id: betEntryId },
include: { bet: true },
});
if (!entry) {
res.status(404).json({ error: "Bet entry not found" });
return;
}
if (entry.bet.completed) {
res.status(400).json({ error: "This bet is already completed" });
return;
}
const existingOnThis = await prisma.userBet.findUnique({
where: { userId_betEntryId: { userId, betEntryId } },
});
if (existingOnThis) {
await prisma.$transaction([
prisma.userBet.delete({ where: { id: existingOnThis.id } }),
prisma.betEntry.update({
where: { id: betEntryId },
data: { amount: { decrement: existingOnThis.amount }, supporters: { decrement: 1 } },
}),
]);
res.json({ success: true, action: "unvoted" });
return;
}
const existingInBet = await prisma.userBet.findFirst({
where: { userId, betEntry: { betId: entry.betId } },
include: { betEntry: true },
});
if (existingInBet) {
await prisma.$transaction([
prisma.userBet.delete({ where: { id: existingInBet.id } }),
prisma.betEntry.update({
where: { id: existingInBet.betEntryId },
data: { amount: { decrement: existingInBet.amount }, supporters: { decrement: 1 } },
}),
prisma.userBet.create({ data: { userId, betEntryId, amount } }),
prisma.betEntry.update({ where: { id: betEntryId }, data: { amount: { increment: amount }, supporters: { increment: 1 } } }),
]);
res.json({ success: true, action: "switched" });
return;
}
await prisma.$transaction([
prisma.user.update({ where: { id: userId }, data: { balance: { decrement: amount } } }),
prisma.balanceHistory.create({ data: { userId, amount: user.balance - amount } }),
prisma.userBet.upsert({
where: { userId_betEntryId: { userId, betEntryId } },
update: { amount: { increment: amount } },
create: { userId, betEntryId, amount },
}),
prisma.userBet.create({ data: { userId, betEntryId, amount } }),
prisma.betEntry.update({ where: { id: betEntryId }, data: { amount: { increment: amount }, supporters: { increment: 1 } } }),
]);
res.json({ success: true });
res.json({ success: true, action: "voted" });
});
app.post("/api/admin/bets", async (req, res) => {
const { name, description, finishTime, image } = req.body;
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 ?? "", finishTime: finishTime ?? "", image: image ?? "" } });
const bet = await prisma.bet.create({ data: { name, description: description ?? "", image: await saveImage(image ?? ""), categoryId: categoryId || null } });
res.json(bet);
});
app.put("/api/admin/bets/:id", async (req, res) => {
const { id } = req.params;
const { categoryId } = req.body;
const bet = await prisma.bet.update({ where: { id }, data: { categoryId: categoryId || null } });
res.json(bet);
});
app.delete("/api/admin/bets/:id", async (req, res) => {
const { id } = req.params;
const entryIds = (await prisma.betEntry.findMany({ where: { betId: id }, select: { id: true } })).map(e => e.id);
await prisma.userBet.deleteMany({ where: { betEntryId: { in: entryIds } } });
await prisma.betEntry.deleteMany({ where: { betId: id } });
await prisma.bet.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/admin/bet-categories", async (_req, res) => {
const cats = await prisma.betCategory.findMany({ orderBy: { id: "asc" } });
res.json(cats);
});
app.post("/api/admin/bet-categories", async (req, res) => {
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: {} });
res.json(cat);
});
app.put("/api/admin/bet-categories/:id", async (req, res) => {
const { id } = req.params;
const { name } = req.body;
if (!name) {
res.status(400).json({ error: "name is required" });
return;
}
const cat = await prisma.betCategory.update({ where: { id }, data: { name } });
res.json(cat);
});
app.delete("/api/admin/bet-categories/:id", async (req, res) => {
const { id } = req.params;
const bets = await prisma.bet.findMany({ where: { categoryId: id } });
const betIds = bets.map(b => b.id);
const entryIds = (await prisma.betEntry.findMany({ where: { betId: { in: betIds } }, select: { id: true } })).map(e => e.id);
await prisma.userBet.deleteMany({ where: { betEntryId: { in: entryIds } } });
await prisma.betEntry.deleteMany({ where: { betId: { in: betIds } } });
await prisma.bet.deleteMany({ where: { categoryId: id } });
await prisma.betCategory.delete({ where: { id } });
res.json({ success: true });
});
app.put("/api/admin/bets/:id/complete", async (req, res) => {
const { id } = req.params;
const { resultEntryId } = req.body;
if (!resultEntryId) {
res.status(400).json({ error: "resultEntryId is required" });
return;
}
const bet = await prisma.bet.update({
where: { id },
data: { completed: true, resultEntryId },
});
res.json(bet);
});
app.post("/api/admin/bets/:id/entries", async (req, res) => {
const { id } = req.params;
const { option } = req.body;
@@ -333,30 +648,33 @@ app.post("/api/admin/bets/:id/entries", async (req, res) => {
});
app.delete("/api/admin/bets/:betId/entries/:entryId", async (req, res) => {
const { entryId } = req.params;
await prisma.userBet.deleteMany({ where: { betEntryId: entryId } });
await prisma.betEntry.delete({ where: { id: entryId } });
res.json({ success: true });
});
const chatMessages = [];
let chatIdCounter = 0;
app.get("/api/chat", (_req, res) => {
res.json(chatMessages);
app.get("/api/admin/assets", (_req, res) => {
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/chat", async (req, res) => {
const { userId, text } = req.body;
if (!userId || !text) {
res.status(400).json({ error: "userId and text are required" });
app.post("/api/admin/assets", async (req, res) => {
const { image } = req.body;
if (!image) {
res.status(400).json({ error: "image is required" });
return;
}
const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true, nickname: true, profilePicture: true } });
if (!user) {
res.status(404).json({ error: "User not found" });
const url = await saveImage(image);
res.json({ name: url.replace("/uploads/", ""), url });
});
app.delete("/api/admin/assets/:filename", (req, res) => {
const { filename } = req.params;
if (filename === "logo.png") {
res.status(403).json({ error: "Cannot delete logo" });
return;
}
const msg = { id: String(++chatIdCounter), userId, username: user.username, nickname: user.nickname, profilePicture: user.profilePicture, text, createdAt: new Date().toISOString() };
chatMessages.push(msg);
if (chatMessages.length > 100)
chatMessages.splice(0, chatMessages.length - 100);
res.json(msg);
const filepath = path_1.default.join(uploadsDir, filename);
if (fs_1.default.existsSync(filepath))
fs_1.default.unlinkSync(filepath);
res.json({ success: true });
});
const PORT = process.env.PORT ?? 4000;
app.listen(PORT, () => {
-2
View File
@@ -14,7 +14,6 @@
},
"dependencies": {
"@prisma/client": "^6.0.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"express": "^4.21.0",
"google-auth-library": "^10.9.0",
@@ -22,7 +21,6 @@
"sharp": "^0.35.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
+30 -12
View File
@@ -10,10 +10,6 @@ datasource db {
model User {
id String @id @default(cuid())
email String @unique
username String @unique
password String?
nickname String?
profilePicture String?
admin Boolean @default(false)
createdAt DateTime @default(now())
userRatings UserRating[]
@@ -59,6 +55,7 @@ model Rating {
picture String
description String
icon String
musicFile String @default("")
userRatings UserRating[]
anonVotes AnonymousRatingVote[]
tags RatingTag[]
@@ -67,6 +64,7 @@ model Rating {
model RatingType {
id String @id @default(cuid())
name String @unique
sortOrder Int @default(0)
categories RatingCategory[]
tags Tag[]
}
@@ -107,23 +105,43 @@ model IpAccount {
count Int @default(0)
}
model AnonymousBetVote {
id String @id @default(cuid())
ip String
betEntryId String
betEntry BetEntry @relation(fields: [betEntryId], references: [id], onDelete: Cascade)
amount Int
@@unique([ip, betEntryId])
}
model BetCategory {
id String @id @default(cuid())
name String @unique
bets Bet[] @relation("BetToCategory")
}
model Bet {
id String @id @default(cuid())
name String
description String
finishTime String
image String @default("")
entries BetEntry[]
id String @id @default(cuid())
name String
description String
image String @default("")
completed Boolean @default(false)
resultEntryId String?
categoryId String?
category BetCategory? @relation("BetToCategory", fields: [categoryId], references: [id])
entries BetEntry[]
}
model BetEntry {
id String @id @default(cuid())
betId String
bet Bet @relation(fields: [betId], references: [id])
bet Bet @relation(fields: [betId], references: [id], onDelete: Cascade)
option String
amount Int @default(0)
supporters Int @default(0)
userBets UserBet[]
anonBets AnonymousBetVote[]
}
model UserBet {
@@ -131,7 +149,7 @@ model UserBet {
userId String
user User @relation(fields: [userId], references: [id])
betEntryId String
betEntry BetEntry @relation(fields: [betEntryId], references: [id])
betEntry BetEntry @relation(fields: [betEntryId], references: [id], onDelete: Cascade)
amount Int
@@unique([userId, betEntryId])
+309 -115
View File
@@ -1,7 +1,6 @@
import express from "express";
import cors from "cors";
import { OAuth2Client } from "google-auth-library";
import bcrypt from "bcryptjs";
const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
import { PrismaClient } from "@prisma/client";
@@ -17,7 +16,7 @@ if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
app.set("trust proxy", true);
app.use(cors());
app.use("/uploads", express.static(uploadsDir));
app.use(express.json({ limit: "10mb" }));
app.use(express.json({ limit: "100mb" }));
async function saveImage(base64: string): Promise<string> {
if (!base64 || base64.startsWith("http") || base64.startsWith("/uploads")) return base64;
@@ -36,6 +35,17 @@ async function saveImage(base64: string): Promise<string> {
return `/uploads/${filename}`;
}
async function saveMusic(base64: string, filename: string): Promise<string> {
if (!base64) return "";
if (base64.startsWith("http") || base64.startsWith("/uploads")) return base64;
const match = base64.match(/^data:audio\/\w+;base64,(.+)$/);
if (!match) return base64;
const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
const filepath = path.join(uploadsDir, safe);
fs.writeFileSync(filepath, match[1], "base64");
return `/uploads/${safe}`;
}
function getIp(req: express.Request): string {
return (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || "unknown";
}
@@ -63,27 +73,15 @@ app.post("/api/google-login", async (req, res) => {
if (!payload || !payload.email) { res.status(400).json({ error: "Invalid token" }); return; }
const email = payload.email;
const name = payload.name || email.split("@")[0];
const picture = payload.picture || "";
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
if (picture && !existing.profilePicture) {
await prisma.user.update({ where: { email }, data: { profilePicture: picture } });
}
res.json({ id: existing.id, email: existing.email, username: existing.username, nickname: existing.nickname, profilePicture: existing.profilePicture || picture, admin: existing.admin, hasPassword: !!existing.password });
res.json({ id: existing.id, email: existing.email, admin: existing.admin });
return;
}
let username = name.toLowerCase().replace(/[^a-z0-9]/g, "");
if (!username) username = email.split("@")[0].replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
let suffix = "";
while (await prisma.user.findUnique({ where: { username: username + suffix } })) {
suffix = suffix ? (parseInt(suffix) + 1).toString() : "1";
}
const user = await prisma.user.create({
data: { email, username: username + suffix, nickname: email.split("@")[0], profilePicture: picture },
data: { email },
});
const ip = getIp(req);
@@ -93,64 +91,31 @@ app.post("/api/google-login", async (req, res) => {
update: { count: { increment: 1 } },
});
res.json({ id: user.id, email: user.email, username: user.username, nickname: user.nickname, profilePicture: user.profilePicture, admin: user.admin, hasPassword: !!user.password });
res.json({ id: user.id, email: user.email, admin: user.admin });
} catch (e) {
res.status(401).json({ error: "Invalid Google token" });
}
});
app.post("/api/set-password", async (req, res) => {
const { username, password } = req.body;
if (!username || !password) { res.status(400).json({ error: "username and password required" }); return; }
const userId = req.headers.cookie?.split(";").find((c) => c.trim().startsWith("userId="))?.split("=")[1];
if (!userId) { res.status(401).json({ error: "Not authenticated" }); return; }
const id = decodeURIComponent(userId);
if (await prisma.user.findFirst({ where: { username, NOT: { id } } })) { res.status(409).json({ error: "Username already taken" }); return; }
const hash = await bcrypt.hash(password, 10);
const user = await prisma.user.update({ where: { id }, data: { username, password: hash } });
res.json({ id: user.id, email: user.email, username: user.username, nickname: user.nickname, profilePicture: user.profilePicture, admin: user.admin, hasPassword: !!user.password });
});
app.post("/api/register", async (req, res) => {
const { email, username, password } = req.body;
if (!email || !username || !password) { res.status(400).json({ error: "email, username, and password required" }); return; }
if (await prisma.user.findUnique({ where: { email } })) { res.status(409).json({ error: "Email already in use" }); return; }
if (await prisma.user.findUnique({ where: { username } })) { res.status(409).json({ error: "Username already taken" }); return; }
const hash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({ data: { email, username, password: hash } });
res.json({ id: user.id, email: user.email, username: user.username, nickname: user.nickname, profilePicture: user.profilePicture, admin: user.admin, hasPassword: !!user.password });
});
app.post("/api/login", async (req, res) => {
const { login, password } = req.body;
if (!login || !password) { res.status(400).json({ error: "login and password required" }); return; }
const user = await prisma.user.findFirst({ where: { OR: [{ email: login }, { username: login }] } });
if (!user || !user.password) { res.status(401).json({ error: "Invalid credentials" }); return; }
if (!(await bcrypt.compare(password, user.password))) { res.status(401).json({ error: "Invalid credentials" }); return; }
res.json({ id: user.id, email: user.email, username: user.username, nickname: user.nickname, profilePicture: user.profilePicture, admin: user.admin, hasPassword: !!user.password });
});
app.get("/api/user-by-id/:id", async (req, res) => {
const { id } = req.params;
const user = await prisma.user.findUnique({ where: { id } });
if (!user) { res.status(404).json({ error: "not found" }); return; }
res.json({ id: user.id, email: user.email, username: user.username, nickname: user.nickname, profilePicture: user.profilePicture, admin: user.admin, hasPassword: !!user.password });
res.json({ id: user.id, email: user.email, admin: user.admin });
});
app.put("/api/user", async (req, res) => {
const { id, nickname, profilePicture } = req.body;
const { id } = req.body;
if (!id) {
res.status(400).json({ error: "User ID is required" });
return;
}
const user = await prisma.user.update({
where: { id },
data: { nickname, profilePicture: await saveImage(profilePicture ?? "") },
});
const user = await prisma.user.findUnique({ where: { id } });
if (!user) { res.status(404).json({ error: "not found" }); return; }
res.json({ id: user.id, email: user.email, username: user.username, nickname: user.nickname, profilePicture: user.profilePicture, admin: user.admin, hasPassword: !!user.password });
res.json({ id: user.id, email: user.email, admin: user.admin });
});
app.get("/api/ratings", async (req, res) => {
@@ -188,12 +153,21 @@ app.get("/api/ratings", async (req, res) => {
const result = ratings.map((r) => {
const type = typeMap[r.type];
const categories = type?.categories ?? [];
const catData = categories.map((c) => {
let catData: { id: string; name: string; average: number; totalVotes: number; userVote: number | null }[] = 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 };
});
if (catData.length === 0 && r.musicFile) {
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 }];
}
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 }));
@@ -220,6 +194,16 @@ app.get("/api/rating-distribution/:ratingId", async (req, res) => {
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 }] });
return;
}
const catData = categories.map((c) => {
const ac = accountTally[c.id] || Array(10).fill(0);
const an = anonTally[c.id] || Array(10).fill(0);
@@ -233,10 +217,23 @@ app.get("/api/rating-distribution/:ratingId", async (req, res) => {
});
app.post("/api/vote", async (req, res) => {
const { userId, ratingId, ratingCategoryId, score } = req.body;
let { userId, ratingId, ratingCategoryId, score } = req.body;
if (!ratingId || !ratingCategoryId || !score || score < 1 || score > 10) {
res.status(400).json({ error: "ratingId, ratingCategoryId, and score (1-10) are required" });
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 } });
if (rating?.musicFile) {
const rtype = await prisma.ratingType.findUnique({ where: { name: rating.type } });
const cat = await prisma.ratingCategory.findFirst({ where: { ratingTypeId: rtype!.id } });
if (cat) ratingCategoryId = cat.id;
}
}
if (!ratingCategoryId) {
res.status(400).json({ error: "ratingCategoryId is required" });
return;
}
@@ -247,7 +244,7 @@ app.post("/api/vote", async (req, res) => {
create: { userId, ratingId, ratingCategoryId, score },
});
} else {
const ip = req.headers["x-forwarded-for"]?.toString().split(",")[0].trim() || req.ip || "unknown";
const ip = getIp(req);
await prisma.anonymousRatingVote.upsert({
where: { ip_ratingId_ratingCategoryId: { ip, ratingId, ratingCategoryId } },
update: { score },
@@ -258,6 +255,33 @@ app.post("/api/vote", async (req, res) => {
res.json({ success: true });
});
app.get("/api/anonymous-votes/:ratingId", async (req, res) => {
const { ratingId } = req.params;
const ip = getIp(req);
const votes = await prisma.anonymousRatingVote.findMany({ where: { ip, ratingId } });
const map: Record<string, number> = {};
for (const v of votes) {
map[v.ratingCategoryId] = v.score;
}
if (Object.keys(map).length > 0) {
const rating = await prisma.rating.findUnique({ where: { id: ratingId } });
if (rating?.musicFile && !map.score) {
map.score = Object.values(map)[0];
}
}
res.json(map);
});
app.get("/api/my-anonymous-votes", async (req, res) => {
const ip = getIp(req);
const votes = await prisma.anonymousRatingVote.findMany({ where: { ip } });
const map: Record<string, number> = {};
for (const v of votes) {
map[`${v.ratingId}_${v.ratingCategoryId}`] = v.score;
}
res.json(map);
});
app.post("/api/reports", async (req, res) => {
const { userId, subject, text } = req.body;
if (!userId || !subject || !text) {
@@ -270,7 +294,7 @@ app.post("/api/reports", async (req, res) => {
app.get("/api/admin/reports", async (_req, res) => {
const reports = await prisma.report.findMany({
include: { user: { select: { username: true } } },
include: { user: { select: { email: true } } },
orderBy: { createdAt: "desc" },
});
res.json(reports);
@@ -312,14 +336,15 @@ app.delete("/api/admin/ratings/:id", async (req, res) => {
});
app.get("/api/rating-types", async (_req, res) => {
const types = await prisma.ratingType.findMany({ include: { categories: true }, orderBy: { name: "asc" } });
const types = await prisma.ratingType.findMany({ include: { categories: true }, orderBy: { sortOrder: "asc" } });
res.json(types);
});
app.post("/api/admin/rating-types", async (req, res) => {
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const t = await prisma.ratingType.upsert({ where: { name }, create: { name }, update: {} });
const max = await prisma.ratingType.aggregate({ _max: { sortOrder: true } });
const t = await prisma.ratingType.upsert({ where: { name }, create: { name, sortOrder: (max._max.sortOrder ?? 0) + 1 }, update: {} });
res.json(t);
});
@@ -331,6 +356,15 @@ app.delete("/api/admin/rating-types/:id", async (req, res) => {
res.json({ success: true });
});
app.put("/api/admin/rating-types/reorder", async (req, res) => {
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++) {
await prisma.ratingType.update({ where: { id: ids[i] }, data: { sortOrder: i } });
}
res.json({ success: true });
});
app.post("/api/admin/rating-types/:id/categories", async (req, res) => {
const { id } = req.params;
const { name } = req.body;
@@ -397,16 +431,33 @@ app.delete("/api/admin/ratings/:id/tags/:tagId", async (req, res) => {
res.json({ success: true });
});
app.get("/api/leaderboard", async (_req, res) => {
const users = await prisma.user.findMany({
select: { id: true, username: true, nickname: true },
});
app.post("/api/admin/music", async (req, res) => {
const { files }: { files: { name: string; data: string }[] } = req.body;
if (!files || !files.length) { res.status(400).json({ error: "files array is required" }); return; }
let musicType = await prisma.ratingType.findFirst({ where: { name: "music" }, include: { categories: true } });
if (!musicType) {
musicType = await prisma.ratingType.create({ data: { name: "music" }, include: { categories: true } });
}
if (!musicType.categories.length) {
await prisma.ratingCategory.create({ data: { ratingTypeId: musicType.id, name: "Score" } });
}
const created: { id: string; name: string }[] = [];
for (const f of files) {
const name = f.name.replace(/\.[^.]+$/, "");
const musicFile = await saveMusic(f.data, f.name);
const rating = await prisma.rating.create({ data: { type: "music", name, description: "", picture: "", icon: "", musicFile } });
created.push({ id: rating.id, name });
}
res.json(created);
});
const result = users.map((u) => ({
id: u.id, username: u.username, nickname: u.nickname, netWorth: 0,
}));
res.json(result);
app.delete("/api/admin/music/:id", async (req, res) => {
const { id } = req.params;
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingId: id } });
await prisma.ratingTag.deleteMany({ where: { ratingId: id } });
await prisma.rating.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/bets", async (req, res) => {
@@ -414,53 +465,220 @@ app.get("/api/bets", async (req, res) => {
const bets = await prisma.bet.findMany({
include: {
entries: userId
? { include: { userBets: { where: { userId } } } }
: true,
? { orderBy: { id: "asc" }, include: { userBets: { where: { userId } } } }
: { orderBy: { id: "asc" } },
},
orderBy: { id: "asc" },
});
res.json(bets);
});
app.get("/api/bet-categories", async (req, res) => {
const userId = req.query.userId as string | undefined;
const cats = await prisma.betCategory.findMany({
include: {
bets: {
include: {
entries: userId
? { orderBy: { id: "asc" }, include: { userBets: { where: { userId } } } }
: { orderBy: { id: "asc" } },
},
orderBy: { id: "asc" },
},
},
orderBy: { id: "asc" },
});
res.json(cats);
});
app.post("/api/bet", async (req, res) => {
const { userId, betEntryId, amount } = req.body;
if (!userId || !betEntryId || !amount || amount <= 0) {
res.status(400).json({ error: "userId, betEntryId, and positive amount are required" });
const { betEntryId, amount } = req.body;
if (!betEntryId || !amount || amount <= 0) {
res.status(400).json({ error: "betEntryId and positive amount are required" });
return;
}
const entry = await prisma.betEntry.findUnique({ where: { id: betEntryId } });
const entry = await prisma.betEntry.findUnique({
where: { id: betEntryId },
include: { bet: true },
});
if (!entry) {
res.status(404).json({ error: "Bet entry not found" });
return;
}
await prisma.$transaction([
prisma.userBet.upsert({
where: { userId_betEntryId: { userId, betEntryId } },
update: { amount: { increment: amount } },
create: { userId, betEntryId, amount },
}),
prisma.betEntry.update({ where: { id: betEntryId }, data: { amount: { increment: amount }, supporters: { increment: 1 } } }),
]);
if (entry.bet.completed) {
res.status(400).json({ error: "This bet is already completed" });
return;
}
res.json({ success: true });
const userId = req.body.userId;
if (userId) {
const existingOnThis = await prisma.userBet.findUnique({
where: { userId_betEntryId: { userId, betEntryId } },
});
if (existingOnThis) {
await prisma.$transaction([
prisma.userBet.delete({ where: { id: existingOnThis.id } }),
prisma.betEntry.update({
where: { id: betEntryId },
data: { amount: { decrement: existingOnThis.amount }, supporters: { decrement: 1 } },
}),
]);
res.json({ success: true, action: "unvoted" });
return;
}
const existingInBet = await prisma.userBet.findFirst({
where: { userId, betEntry: { betId: entry.betId } },
include: { betEntry: true },
});
if (existingInBet) {
await prisma.$transaction([
prisma.userBet.delete({ where: { id: existingInBet.id } }),
prisma.betEntry.update({
where: { id: existingInBet.betEntryId },
data: { amount: { decrement: existingInBet.amount }, supporters: { decrement: 1 } },
}),
prisma.userBet.create({ data: { userId, betEntryId, amount } }),
prisma.betEntry.update({ where: { id: betEntryId }, data: { amount: { increment: amount }, supporters: { increment: 1 } } }),
]);
res.json({ success: true, action: "switched" });
return;
}
await prisma.$transaction([
prisma.userBet.create({ data: { userId, betEntryId, amount } }),
prisma.betEntry.update({ where: { id: betEntryId }, data: { amount: { increment: amount }, supporters: { increment: 1 } } }),
]);
res.json({ success: true, action: "voted" });
} else {
const ip = getIp(req);
const existingOnThis = await prisma.anonymousBetVote.findUnique({
where: { ip_betEntryId: { ip, betEntryId } },
});
if (existingOnThis) {
await prisma.$transaction([
prisma.anonymousBetVote.delete({ where: { id: existingOnThis.id } }),
prisma.betEntry.update({
where: { id: betEntryId },
data: { amount: { decrement: existingOnThis.amount }, supporters: { decrement: 1 } },
}),
]);
res.json({ success: true, action: "unvoted" });
return;
}
const existingInBet = await prisma.anonymousBetVote.findFirst({
where: { ip, betEntry: { betId: entry.betId } },
include: { betEntry: true },
});
if (existingInBet) {
await prisma.$transaction([
prisma.anonymousBetVote.delete({ where: { id: existingInBet.id } }),
prisma.betEntry.update({
where: { id: existingInBet.betEntryId },
data: { amount: { decrement: existingInBet.amount }, supporters: { decrement: 1 } },
}),
prisma.anonymousBetVote.create({ data: { ip, betEntryId, amount } }),
prisma.betEntry.update({ where: { id: betEntryId }, data: { amount: { increment: amount }, supporters: { increment: 1 } } }),
]);
res.json({ success: true, action: "switched" });
return;
}
await prisma.$transaction([
prisma.anonymousBetVote.create({ data: { ip, betEntryId, amount } }),
prisma.betEntry.update({ where: { id: betEntryId }, data: { amount: { increment: amount }, supporters: { increment: 1 } } }),
]);
res.json({ success: true, action: "voted" });
}
});
app.get("/api/my-anonymous-bets", async (req, res) => {
const ip = getIp(req);
const votes = await prisma.anonymousBetVote.findMany({ where: { ip } });
const map: Record<string, string> = {};
for (const v of votes) {
map[v.betEntryId] = v.id;
}
res.json(map);
});
app.post("/api/admin/bets", async (req, res) => {
const { name, description, finishTime, image } = req.body;
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 ?? "", finishTime: finishTime ?? "", image: await saveImage(image ?? "") } });
const bet = await prisma.bet.create({ data: { name, description: description ?? "", image: await saveImage(image ?? ""), categoryId: categoryId || null } });
res.json(bet);
});
app.put("/api/admin/bets/:id", async (req, res) => {
const { id } = req.params;
const { categoryId } = req.body;
const bet = await prisma.bet.update({ where: { id }, data: { categoryId: categoryId || null } });
res.json(bet);
});
app.delete("/api/admin/bets/:id", async (req, res) => {
const { id } = req.params;
const entryIds = (await prisma.betEntry.findMany({ where: { betId: id }, select: { id: true } })).map(e => e.id);
await prisma.anonymousBetVote.deleteMany({ where: { betEntryId: { in: entryIds } } });
await prisma.userBet.deleteMany({ where: { betEntryId: { in: entryIds } } });
await prisma.betEntry.deleteMany({ where: { betId: id } });
await prisma.bet.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/admin/bet-categories", async (_req, res) => {
const cats = await prisma.betCategory.findMany({ orderBy: { id: "asc" } });
res.json(cats);
});
app.post("/api/admin/bet-categories", async (req, res) => {
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: {} });
res.json(cat);
});
app.put("/api/admin/bet-categories/:id", async (req, res) => {
const { id } = req.params;
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const cat = await prisma.betCategory.update({ where: { id }, data: { name } });
res.json(cat);
});
app.delete("/api/admin/bet-categories/:id", async (req, res) => {
const { id } = req.params;
const bets = await prisma.bet.findMany({ where: { categoryId: id } });
const betIds = bets.map(b => b.id);
const entryIds = (await prisma.betEntry.findMany({ where: { betId: { in: betIds } }, select: { id: true } })).map(e => e.id);
await prisma.anonymousBetVote.deleteMany({ where: { betEntryId: { in: entryIds } } });
await prisma.userBet.deleteMany({ where: { betEntryId: { in: entryIds } } });
await prisma.betEntry.deleteMany({ where: { betId: { in: betIds } } });
await prisma.bet.deleteMany({ where: { categoryId: id } });
await prisma.betCategory.delete({ where: { id } });
res.json({ success: true });
});
app.put("/api/admin/bets/:id/complete", async (req, res) => {
const { id } = req.params;
const { resultEntryId } = req.body;
if (!resultEntryId) { res.status(400).json({ error: "resultEntryId is required" }); return; }
const bet = await prisma.bet.update({
where: { id },
data: { completed: true, resultEntryId },
});
res.json(bet);
});
app.post("/api/admin/bets/:id/entries", async (req, res) => {
const { id } = req.params;
const { option } = req.body;
@@ -471,36 +689,12 @@ app.post("/api/admin/bets/:id/entries", async (req, res) => {
app.delete("/api/admin/bets/:betId/entries/:entryId", async (req, res) => {
const { entryId } = req.params;
await prisma.anonymousBetVote.deleteMany({ where: { betEntryId: entryId } });
await prisma.userBet.deleteMany({ where: { betEntryId: entryId } });
await prisma.betEntry.delete({ where: { id: entryId } });
res.json({ success: true });
});
type ChatMsg = { id: string; userId: string; username: string; nickname: string | null; profilePicture: string | null; text: string; createdAt: string };
const chatMessages: ChatMsg[] = [];
let chatIdCounter = 0;
app.get("/api/chat", (_req, res) => {
res.json(chatMessages);
});
app.post("/api/chat", async (req, res) => {
const { userId, text, username } = req.body;
if (!text) { res.status(400).json({ error: "text is required" }); return; }
let msg: ChatMsg;
if (userId && userId !== "anonymous") {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true, nickname: true, profilePicture: true } });
if (!user) { res.status(404).json({ error: "User not found" }); return; }
msg = { id: String(++chatIdCounter), userId, username: user.username, nickname: user.nickname, profilePicture: user.profilePicture, text, createdAt: new Date().toISOString() };
} else {
const name = (username || "Anonymous").trim().slice(0, 20);
msg = { id: String(++chatIdCounter), userId: "anonymous", username: name, nickname: null, profilePicture: null, text, createdAt: new Date().toISOString() };
}
chatMessages.push(msg);
if (chatMessages.length > 100) chatMessages.splice(0, chatMessages.length - 100);
res.json(msg);
});
app.get("/api/admin/assets", (_req, res) => {
const files = fs.readdirSync(uploadsDir).filter((f) => f !== "logo.png").map((f) => ({ name: f, url: `/uploads/${f}` }));
res.json(files);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.