fat fixes and audio changes
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)!] } : {}),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user