fat fixes and audio changes

This commit is contained in:
2026-07-14 22:32:35 -04:00
parent 1b895abdbb
commit 0930b8b89b
6 changed files with 166 additions and 56 deletions
+3
View File
@@ -8,6 +8,7 @@ import AdminPage from "./pages/AdminPage";
import TellDevPage from "./pages/TellDevPage";
import Sidebar from "./components/Sidebar";
import { ThemeProvider } from "./context/ThemeContext";
import { VolumeProvider } from "./context/VolumeContext";
type User = { id: number; email: string };
@@ -88,6 +89,7 @@ function AppInner() {
return (
<ThemeProvider>
<VolumeProvider>
<style>{`
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
@@ -127,6 +129,7 @@ function AppInner() {
</div>
</div>
)}
</VolumeProvider>
</ThemeProvider>
);
}
+8
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useTheme } from "../context/ThemeContext";
import { useVolume } from "../context/VolumeContext";
type Props = {
user: { id: number } | null;
@@ -14,6 +15,7 @@ type Props = {
function Sidebar({ user, page, pinned, onNavigate, onLoginClick, isMobile, sidebarOpen, onToggleSidebar }: Props) {
const { dark } = useTheme();
const { volume, setVolume } = useVolume();
const [visible, setVisible] = useState(false);
const logoRef = useRef<HTMLImageElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
@@ -179,6 +181,12 @@ function Sidebar({ user, page, pinned, onNavigate, onLoginClick, isMobile, sideb
</div>
<div style={{ marginTop: "auto" }}>
<div style={{ padding: "8px 24px 12px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 14, color: fg, flexShrink: 0 }}>{volume === 0 ? "🔇" : volume < 0.5 ? "🔉" : "🔊"}</span>
<input type="range" min="0" max="1" step="0.01" value={volume} onChange={(e) => setVolume(Number(e.target.value))} style={{ flex: 1, height: 4, accentColor: "#424e88", cursor: "pointer" }} />
</div>
</div>
<button
className="sidebar-btn"
type="button"
+28
View File
@@ -0,0 +1,28 @@
import { createContext, useContext, useState, type ReactNode } from "react";
type VolumeContextType = {
volume: number;
setVolume: (v: number) => void;
};
const VolumeContext = createContext<VolumeContextType>({ volume: 0.75, setVolume: () => {} });
export function VolumeProvider({ children }: { children: ReactNode }) {
const [volume, setVolume] = useState(() => {
const saved = localStorage.getItem("globalVolume");
return saved !== null ? Number(saved) : 0.75;
});
function update(v: number) {
setVolume(v);
localStorage.setItem("globalVolume", String(v));
}
return (
<VolumeContext.Provider value={{ volume, setVolume: update }}>
{children}
</VolumeContext.Provider>
);
}
export const useVolume = () => useContext(VolumeContext);
+4 -14
View File
@@ -12,6 +12,7 @@ type Entry = {
amount: number;
supporters: number;
userPolls?: UserPoll[];
anonPollVotes?: UserPoll[];
};
type PollTag = {
@@ -46,7 +47,6 @@ function PollsPage({ user }: Props) {
const { dark } = useTheme();
const [categories, setCategories] = useState<PollCategory[]>([]);
const [selected, setSelected] = useState<Poll | null>(null);
const [anonPollVotes, setAnonPollVotes] = useState<Record<number, number>>({});
const [showCompleted, setShowCompleted] = useState(true);
const [showUncompleted, setShowUncompleted] = useState(true);
const [showVoted, setShowVoted] = useState(true);
@@ -58,11 +58,6 @@ function PollsPage({ user }: Props) {
const url = user ? `/api/poll-categories?userId=${user.id}` : "/api/poll-categories";
fetch(url).then((r) => r.json()).then(setCategories).catch(() => {});
fetch("/api/poll-tags").then((r) => r.json()).then(setAllTags).catch(() => {});
if (user) {
setAnonPollVotes({});
} else {
fetch("/api/my-anonymous-polls").then((r) => r.json()).then(setAnonPollVotes).catch(() => {});
}
}
useEffect(() => { loadData(); }, [user?.id]);
@@ -76,14 +71,9 @@ function PollsPage({ user }: Props) {
}, [categories]);
function getUserVote(poll: Poll): number | null {
if (user) {
for (const e of poll.entries) {
if (e.userPolls && e.userPolls.length > 0) return e.id;
}
} else {
for (const e of poll.entries) {
if (anonPollVotes[e.id]) return e.id;
}
for (const e of poll.entries) {
if (e.userPolls && e.userPolls.length > 0) return e.id;
if (e.anonPollVotes && e.anonPollVotes.length > 0) return e.id;
}
return null;
}
+91 -23
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useTheme } from "../context/ThemeContext";
import { useVolume } from "../context/VolumeContext";
type RatingCategory = {
id: number;
@@ -41,6 +42,76 @@ type Props = {
user: { id: number } | null;
};
function MiniAudioPlayer({ src, onPlay }: { src: string; onPlay: (el: HTMLAudioElement) => void }) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const progressRef = useRef<HTMLDivElement>(null);
const playingRef = useRef(false);
const rafRef = useRef(0);
const [playing, setPlaying] = useState(false);
const { dark } = useTheme();
const { volume } = useVolume();
useEffect(() => {
if (audioRef.current) audioRef.current.volume = volume;
}, [volume]);
function tick() {
const audio = audioRef.current;
if (audio && progressRef.current) {
progressRef.current.style.width = audio.duration ? `${(audio.currentTime / audio.duration) * 100}%` : "0%";
}
if (playingRef.current) rafRef.current = requestAnimationFrame(tick);
}
function togglePlay() {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
onPlay(audio);
audio.play();
playingRef.current = true;
setPlaying(true);
rafRef.current = requestAnimationFrame(tick);
} else {
audio.pause();
playingRef.current = false;
setPlaying(false);
cancelAnimationFrame(rafRef.current);
}
}
function seek(e: React.MouseEvent<HTMLDivElement>) {
const audio = audioRef.current;
if (!audio?.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
}
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onEnded = () => { playingRef.current = false; setPlaying(false); cancelAnimationFrame(rafRef.current); };
audio.addEventListener("ended", onEnded);
return () => { audio.removeEventListener("ended", onEnded); cancelAnimationFrame(rafRef.current); };
}, []);
const btnBg = dark ? "#2a2a2a" : "#eee";
const borderClr = dark ? "#444" : "#ccc";
const textClr = dark ? "#e0e0e0" : "#333";
return (
<div style={{ display: "flex", alignItems: "center", gap: 4, height: 32, width: "100%" }} onClick={(e) => e.stopPropagation()}>
<audio ref={audioRef} src={src} preload="none" style={{ display: "none" }} />
<button onClick={togglePlay} style={{ width: 24, height: 24, fontSize: 11, background: btnBg, border: `1px solid ${borderClr}`, color: textClr, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, padding: 0, lineHeight: 1 }}>
{playing ? "❚❚" : "▶"}
</button>
<div onClick={seek} style={{ flex: 1, height: 6, background: dark ? "#333" : "#ddd", borderRadius: 3, cursor: "pointer", overflow: "hidden" }}>
<div ref={progressRef} style={{ height: "100%", background: "#424e88", width: "0%", pointerEvents: "none" }} />
</div>
</div>
);
}
function RatingsPage({ user }: Props) {
const { dark } = useTheme();
const [ratings, setRatings] = useState<Rating[]>([]);
@@ -52,7 +123,6 @@ function RatingsPage({ user }: Props) {
const [search, setSearch] = useState("");
const [distributions, setDistributions] = useState<Record<string, { accountRatings: number[]; anonRatings: number[]; average: number; totalVotes: number }>>({});
const [loadingDist, setLoadingDist] = useState<Record<string, boolean>>({});
const [anonVotes, setAnonVotes] = useState<Record<string, number>>({});
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [fetching, setFetching] = useState(true);
const [page, setPage] = useState(1);
@@ -154,11 +224,6 @@ function RatingsPage({ user }: Props) {
fetchRatings();
}, [user, category, page, debouncedSearch, selectedTags, sortMode, selectedCatId, showVoted, showUnvoted]);
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);
@@ -183,7 +248,7 @@ function RatingsPage({ user }: Props) {
async function vote(ratingId: number, ratingCategoryId: number, score: number) {
const current = user
? ratings.find((r) => r.id === ratingId)?.userVotes[ratingCategoryId]
: anonVotes[`${ratingId}_${ratingCategoryId}`];
: ratings.find((r) => r.id === ratingId)?.categories.find((c) => c.id === ratingCategoryId)?.userVote;
if (current === score) {
const res = await fetch("/api/unvote", {
method: "POST",
@@ -198,10 +263,9 @@ function RatingsPage({ user }: Props) {
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, userVote: null, average: data.average, totalVotes: data.totalVotes }), userVotes: n };
}));
} else {
setAnonVotes((prev) => { const n = { ...prev }; delete n[`${ratingId}_${ratingCategoryId}`]; return n; });
setRatings((prev) => prev.map((r) => {
if (r.id !== ratingId) return r;
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, average: data.average, totalVotes: data.totalVotes }) };
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, userVote: null, average: data.average, totalVotes: data.totalVotes }) };
}));
}
} else {
@@ -219,10 +283,9 @@ function RatingsPage({ user }: Props) {
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, userVote: score, average: data.average, totalVotes: data.totalVotes }), userVotes: { ...r.userVotes, [ratingCategoryId]: score } };
}));
} else {
setAnonVotes((prev) => ({ ...prev, [`${ratingId}_${ratingCategoryId}`]: score }));
setRatings((prev) => prev.map((r) => {
if (r.id !== ratingId) return r;
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, average: data.average, totalVotes: data.totalVotes }) };
return { ...r, categories: r.categories.map((c) => c.id !== ratingCategoryId ? c : { ...c, userVote: score, average: data.average, totalVotes: data.totalVotes }) };
}));
}
}
@@ -278,7 +341,7 @@ function RatingsPage({ user }: Props) {
return (
<div>
<style dangerouslySetInnerHTML={{__html: scrollCss}} />
<style>{`input[type="checkbox"]{-webkit-appearance:none;appearance:none;width:14px;height:14px;border:1px solid #424e88;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin:0;padding:0}input[type="checkbox"]:checked{background:#424e88!important}input[type="checkbox"]:checked::after{content:"✓";color:#fff;font-size:11px;font-weight:700;line-height:1}audio::-webkit-media-controls-current-time-display,audio::-webkit-media-controls-time-remaining-display{display:none!important}audio::-webkit-media-controls-overflow-button,audio::-internal-media-controls-overflow-button,audio::-webkit-media-controls-volume-slider,audio::-webkit-media-controls-mute-button,audio::-webkit-media-controls-volume-control-container{display:none!important;width:0!important;height:0!important;padding:0!important;margin:0!important;opacity:0!important;pointer-events:none!important;overflow:hidden!important}audio::-webkit-media-controls-panel,audio::-webkit-media-controls-enclosure,audio::-webkit-media-controls-timeline-container{padding:0!important;margin:0!important;gap:0!important;justify-content:start!important}audio::-webkit-media-controls-play-button{margin:0!important;padding:0!important;min-width:24px!important;width:24px!important}audio::-webkit-media-controls-timeline{margin:0!important;padding:0!important;flex:1!important}`}</style>
<style>{`input[type="checkbox"]{-webkit-appearance:none;appearance:none;width:14px;height:14px;border:1px solid #424e88;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin:0;padding:0}input[type="checkbox"]:checked{background:#424e88!important}input[type="checkbox"]:checked::after{content:"✓";color:#fff;font-size:11px;font-weight:700;line-height:1}`}</style>
<div style={{ fontSize: 28, fontWeight: 700, color: textClr, marginBottom: 12 }}>ratings</div>
<div style={{ fontSize: 15, color: textClr, marginBottom: 20, whiteSpace: "pre-line" }}>rate some random stuff{'\n\n'}Accounts get to place 3 stars per rating category, put them on your all time favs.</div>
{winWidth <= 768 && (
@@ -458,7 +521,7 @@ onClick={() => { navigate(`/ratings/${t.name}`); setSelectedCatId(t.categories[0
<div style={{ fontWeight: 700, fontSize: 14, color: textClr, flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap" }}>
<div ref={(el) => { if (el && el.parentElement && el.scrollWidth > el.parentElement.clientWidth && !el.dataset.scrolled) { el.dataset.scrolled = "1"; const dist = el.parentElement.clientWidth - el.scrollWidth; el.style.setProperty("--sd", dist + "px"); el.style.animation = "musicScroll 8s linear infinite"; } }} style={{ display: "inline-block", whiteSpace: "nowrap" }}>{r.name}</div>
</div>
{r.attachments ? <div style={{ flexShrink: 1, minWidth: winWidth <= 768 ? 120 : 180, maxWidth: winWidth <= 768 ? 160 : 240, borderRadius: 4, overflow: "hidden" }}><audio controls controlsList="nodownload noplaybackrate" src={r.attachments} preload="none" style={{ height: 32, width: "100%", display: "block" }} onClick={(e) => e.stopPropagation()} onPlay={(e) => handlePlay(e.currentTarget)} /></div> : null}
{r.attachments ? <div style={{ flexShrink: 1, minWidth: winWidth <= 768 ? 120 : 180, maxWidth: winWidth <= 768 ? 160 : 240, borderRadius: 4, overflow: "hidden" }}><MiniAudioPlayer src={r.attachments} onPlay={handlePlay} /></div> : null}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", minWidth: 40 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: mutedClr, textAlign: "right", lineHeight: 1.4 }}>
@@ -471,7 +534,7 @@ onClick={() => { navigate(`/ratings/${t.name}`); setSelectedCatId(t.categories[0
{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}`];
const myVote = cat.userVote;
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>
);
@@ -493,7 +556,7 @@ onClick={() => { navigate(`/ratings/${t.name}`); setSelectedCatId(t.categories[0
)}
<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}`];
const myVote = cat.userVote;
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>
);
@@ -661,7 +724,18 @@ onClick={() => { navigate(`/ratings/${t.name}`); setSelectedCatId(t.categories[0
overflowY: "auto",
}}
>
<div style={{ fontWeight: 700, fontSize: 20, color: textClr, marginBottom: 4 }}>{selected.name}</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
<div style={{ fontWeight: 700, fontSize: 20, color: textClr }}>{selected.name}</div>
<div style={{ display: "flex", gap: 8, flexShrink: 0, marginLeft: 8 }}>
{selected.categories.map((cat) => (
<div key={cat.id} style={{ fontSize: 14, color: "#e6c200" }}>
{user ? (
<span onClick={() => toggleStar(selected.id, cat.id)} style={{ cursor: "pointer", fontSize: 16, color: cat.userStarred ? "#e6c200" : mutedClr, lineHeight: 1 }}>{cat.userStarred ? "★" : "☆"} {cat.starCount}</span>
) : <span style={{ fontSize: 14 }}> {cat.starCount}</span>}
</div>
))}
</div>
</div>
{selected.picture && <img src={selected.picture} alt="" style={{ width: "100%", maxHeight: 400, objectFit: "contain", marginBottom: 12 }} />}
{(() => {
return (
@@ -678,11 +752,6 @@ onClick={() => { navigate(`/ratings/${t.name}`); setSelectedCatId(t.categories[0
<div style={{ fontSize: 13, color: place === 1 ? "#ffd700" : place === 2 ? "#c0c0c0" : place === 3 ? "#cd7f32" : mutedClr }}>
#{place} of {totalInType}
</div>
<div style={{ fontSize: 12, color: "#e6c200", marginTop: 2 }}>
{user ? (
<span onClick={() => toggleStar(selected.id, cat.id)} style={{ cursor: "pointer", fontSize: 16, color: cat.userStarred ? "#e6c200" : mutedClr, lineHeight: 1 }}>{cat.userStarred ? "★" : "☆"} {cat.starCount}</span>
) : <span style={{ fontSize: 12 }}> {cat.starCount}</span>}
</div>
</div>
);
})}
@@ -729,8 +798,7 @@ onClick={() => { navigate(`/ratings/${t.name}`); setSelectedCatId(t.categories[0
)}
<div style={{ display: "flex", gap: 3, flexWrap: "wrap" }}>
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => {
const anonKey = `${selected.id}_${cat.id}`;
const myVote = user ? cat.userVote : anonVotes[anonKey];
const myVote = cat.userVote;
return (
<button
key={score}
+32 -19
View File
@@ -370,18 +370,21 @@ app.get("/api/ratings", (req, res) => {
const rtype = cache.ratingTypes.get(r.ratingTypeId);
const categories = rtype?.categories ?? [];
const userVotes = userId ? getUserVotesForRating(userId, r.id) : {};
const ip = !userId ? getIp(req) : undefined;
const anonMap = ip ? cache.anonVotes.get(ip) : undefined;
const anonPerRating = anonMap?.get(r.id);
const totalInType = cache.totalInType.get(r.ratingTypeId) ?? 0;
let catData = categories.map(c => {
const { average, totalVotes } = getAvgAndTotal(r.id, c.id);
const rank = cache.catRanks.get(c.id)?.get(r.id) ?? 0;
return { id: c.id, name: c.name, average, totalVotes, userVote: userVotes[c.id] ?? null, starCount: getStarCount(r.id, c.id), userStarred: userId ? isUserStarred(userId, r.id, c.id) : false, rank, totalInType };
return { id: c.id, name: c.name, average, totalVotes, userVote: userVotes[c.id] ?? anonPerRating?.get(c.id) ?? null, starCount: getStarCount(r.id, c.id), userStarred: userId ? isUserStarred(userId, r.id, c.id) : false, rank, totalInType };
});
if (catData.length === 0 && rtype?.hasAttachments) {
const firstCat = rtype.categories[0];
if (firstCat) {
const { average, totalVotes } = getAvgAndTotal(r.id, firstCat.id);
const rank = cache.catRanks.get(firstCat.id)?.get(r.id) ?? 0;
catData.push({ id: firstCat.id, name: firstCat.name, average, totalVotes, userVote: userVotes[firstCat.id] ?? null, starCount: getStarCount(r.id, firstCat.id), userStarred: userId ? isUserStarred(userId, r.id, firstCat.id) : false, rank, totalInType });
catData.push({ id: firstCat.id, name: firstCat.name, average, totalVotes, userVote: userVotes[firstCat.id] ?? anonPerRating?.get(firstCat.id) ?? null, starCount: getStarCount(r.id, firstCat.id), userStarred: userId ? isUserStarred(userId, r.id, firstCat.id) : false, rank, totalInType });
}
}
const totalVotes = catData.reduce((s, c) => s + c.totalVotes, 0);
@@ -643,7 +646,8 @@ app.delete("/api/admin/reports/:id", async (req, res) => {
app.post("/api/admin/ratings", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { ratingTypeId, name, description, picture, icon, attachments } = req.body;
const ratingTypeId = Number(req.body.ratingTypeId);
const { name, description, picture, icon, attachments } = req.body;
if (!ratingTypeId || !name) {
res.status(400).json({ error: "ratingTypeId and name are required" });
return;
@@ -683,6 +687,9 @@ app.delete("/api/admin/ratings/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingId: id } });
await prisma.ratingStar.deleteMany({ where: { ratingId: id } });
await prisma.ratingTag.deleteMany({ where: { ratingId: id } });
await prisma.rating.delete({ where: { id } });
res.json({ success: true });
@@ -719,8 +726,18 @@ app.put("/api/admin/rating-types/:id", async (req, res) => {
app.delete("/api/admin/rating-types/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
await prisma.userRating.deleteMany({ where: { ratingCategory: { ratingTypeId: id } } });
const catIds = (await prisma.ratingCategory.findMany({ where: { ratingTypeId: id }, select: { id: true } })).map(c => c.id);
const ratingIds = (await prisma.rating.findMany({ where: { ratingTypeId: id }, select: { id: true } })).map(r => r.id);
if (catIds.length) {
await prisma.userRating.deleteMany({ where: { ratingCategoryId: { in: catIds } } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingCategoryId: { in: catIds } } });
await prisma.ratingStar.deleteMany({ where: { ratingCategoryId: { in: catIds } } });
}
if (ratingIds.length) {
await prisma.ratingTag.deleteMany({ where: { ratingId: { in: ratingIds } } });
}
await prisma.ratingCategory.deleteMany({ where: { ratingTypeId: id } });
await prisma.rating.deleteMany({ where: { ratingTypeId: id } });
await prisma.ratingType.delete({ where: { id } });
res.json({ success: true });
@@ -748,21 +765,10 @@ app.post("/api/admin/rating-types/:id/categories", async (req, res) => {
app.delete("/api/admin/rating-types/:typeId/categories/:categoryId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const typeId = Number(req.params.typeId);
const categoryId = Number(req.params.categoryId);
await prisma.userRating.deleteMany({ where: { ratingCategoryId: categoryId } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingCategoryId: categoryId } });
await prisma.ratingStar.deleteMany({ where: { ratingCategoryId: categoryId } });
const type = await prisma.ratingType.findUnique({ where: { id: typeId } });
if (type) {
const ratings = await prisma.rating.findMany({ where: { ratingTypeId: type.id } });
for (const r of ratings) {
await prisma.userRating.deleteMany({ where: { ratingId: r.id } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingId: r.id } });
await prisma.ratingStar.deleteMany({ where: { ratingId: r.id } });
await prisma.rating.delete({ where: { id: r.id } });
}
}
await prisma.ratingCategory.delete({ where: { id: categoryId } });
res.json({ success: true });
@@ -784,7 +790,8 @@ app.get("/api/tags", (_req, res) => {
app.post("/api/admin/tags", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name, color, ratingTypeId } = req.body;
const { name, color } = req.body;
const ratingTypeId = Number(req.body.ratingTypeId);
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 } });
@@ -794,7 +801,8 @@ app.post("/api/admin/tags", async (req, res) => {
app.put("/api/admin/tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const { name, color, ratingTypeId } = req.body;
const { name, color } = req.body;
const ratingTypeId = Number(req.body.ratingTypeId);
if (!name || !color || !ratingTypeId) { res.status(400).json({ error: "name, color, and ratingTypeId are required" }); return; }
const tag = await prisma.tag.update({ where: { id }, data: { name, color, ratingTypeId } });
@@ -838,8 +846,7 @@ app.delete("/api/admin/ratings/:id/tags/:tagId", async (req, res) => {
res.json({ success: true });
});
app.post("/api/admin/upload-attachments", upload.array("files"), async (req, res) => {
if (!(await requireAdmin(req, res))) return;
app.post("/api/admin/upload-attachments", async (req, res, next) => { if (!(await requireAdmin(req, res))) return; next(); }, upload.array("files"), async (req, res) => {
const files = req.files as Express.Multer.File[];
const typeName = req.body.type || "music";
if (!files || !files.length) { res.status(400).json({ error: "files are required" }); return; }
@@ -880,11 +887,14 @@ app.delete("/api/admin/music/:id", async (req, res) => {
app.get("/api/polls", (req, res) => {
const userId = req.query.userId ? Number(req.query.userId) : undefined;
const ip = getIp(req);
const anonVotes = cache.anonPollVotes.get(ip);
const polls = cache.polls.map(p => ({
...p,
entries: p.entries.map(e => ({
...e,
...(userId ? { userPolls: (() => { const v = cache.userPollVotes.get(userId)?.get(e.id); return v ? [v] : []; })() } : {}),
...(anonVotes?.has(e.id) ? { anonPollVotes: [anonVotes.get(e.id)!] } : {}),
})),
}));
res.json(polls);
@@ -892,6 +902,8 @@ app.get("/api/polls", (req, res) => {
app.get("/api/poll-categories", (req, res) => {
const userId = req.query.userId ? Number(req.query.userId) : undefined;
const ip = getIp(req);
const anonVotes = cache.anonPollVotes.get(ip);
const cats = cache.pollCategories.map(pc => ({
...pc,
polls: cache.polls.filter(p => p.categoryId === pc.id).map(p => ({
@@ -899,6 +911,7 @@ app.get("/api/poll-categories", (req, res) => {
entries: p.entries.map(e => ({
...e,
...(userId ? { userPolls: (() => { const v = cache.userPollVotes.get(userId)?.get(e.id); return v ? [v] : []; })() } : {}),
...(anonVotes?.has(e.id) ? { anonPollVotes: [anonVotes.get(e.id)!] } : {}),
})),
})),
}));