slideshow + multiple images per rating

This commit is contained in:
2026-08-01 07:16:40 -04:00
parent b251d1f193
commit eca8c98f92
8 changed files with 134 additions and 40 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico?v=1" sizes="any" />
<link rel="shortcut icon" href="/favicon.ico?v=1" type="image/x-icon" />
<title>JRS - Community Voting Site</title>
<title>JohnRuina Site - Community Rating Site</title>
<meta name="description" content="View Limbus Company community driven rankings regarding topics such as music and story quality." />
<meta property="og:type" content="website" />
<meta property="og:title" content="JRS - Community Voting Site" />
+6 -4
View File
@@ -6,7 +6,7 @@ import PollsPage from "./pages/PollsPage";
import RatingsPage from "./pages/RatingsPage";
import AdminPage from "./pages/AdminPage";
import Sidebar from "./components/Sidebar";
import { ThemeProvider } from "./context/ThemeContext";
import { ThemeProvider, useTheme } from "./context/ThemeContext";
import { VolumeProvider } from "./context/VolumeContext";
type User = { id: number; email: string; username: string };
@@ -17,6 +17,7 @@ function getCookie(name: string) {
}
function AppInner() {
const { descBg } = useTheme();
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [rateLimited, setRateLimited] = useState(false);
@@ -24,8 +25,9 @@ function AppInner() {
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const sidebarPinned = localStorage.getItem("sidebarPinned") !== "false";
const [sidebarOpen, setSidebarOpen] = useState(false);
const [whatsNewText, setWhatsNewText] = useState("");
const [randomMessage, setRandomMessage] = useState("");
const [updates, setUpdates] = useState<{ id: number; title: string; text: string; createdAt: string }[]>([]);
const [expandedUpdate, setExpandedUpdate] = useState<number | null>(null);
const navigate = useNavigate();
const location = useLocation();
const page = location.pathname.slice(1) || "home";
@@ -50,7 +52,7 @@ function AppInner() {
}, []);
useEffect(() => {
fetch("/api/whatsnew").then((r) => r.json()).then((d) => setWhatsNewText(d.text)).catch(() => {});
fetch("/api/site-updates").then((r) => r.json()).then((d) => { if (Array.isArray(d)) setUpdates(d); }).catch(() => {});
fetch("/api/random-message").then((r) => r.json()).then((d) => setRandomMessage(d.text)).catch(() => {});
}, []);
@@ -105,7 +107,7 @@ function AppInner() {
)}
<div style={{ marginLeft: isMobile ? 0 : (sidebarPinned ? 220 : undefined), marginTop: isMobile ? 48 : undefined, transition: "margin-left 0.2s ease", display: "flex", flexDirection: "column", minHeight: "100vh" }}>
<Routes>
<Route path="/" element={<><div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: 20, paddingBottom: 40 }}><div style={{ position: "relative", width: isMobile ? "90%" : "60%" }}><img src="/S418.png" alt="" style={{ width: "100%", maxHeight: "calc(100vh - 140px)", objectFit: "contain", display: "block" }} /><div style={{ position: "absolute", bottom: 0, left: 0, right: 0, fontSize: "clamp(24px, 6vw, 60px)", fontWeight: 700, color: "#e0e0e0", textAlign: "center", padding: "80px 16px 16px", background: "linear-gradient(transparent, rgba(0,0,0,0.8))" }}>JOHNRUINA SITE</div></div><div style={{ fontSize: 18, color: "#e0e0e0", textAlign: "center", whiteSpace: "pre-line", marginTop: 16, width: isMobile ? "90%" : "60%", border: "1px solid #444", borderRadius: 8, padding: 16 }}>Simple little Limbus website.{'\n\n'}Currently a Limbus community ranking website, if it gets traction I'll add more rating categories and features.{'\n\n'}Glory to K Corp!</div>{randomMessage && <div style={{ marginTop: 20, width: isMobile ? "90%" : "60%", display: "flex", alignItems: "center", justifyContent: "center", gap: 10, fontSize: 16, color: "#e0e0e0", textAlign: "center", fontStyle: "italic", border: "1px solid #444", borderRadius: 8, padding: 12 }}>{randomMessage}<span onClick={() => fetch("/api/random-message").then((r) => r.json()).then((d) => setRandomMessage(d.text)).catch(() => {})} style={{ cursor: "pointer", fontSize: 14, opacity: 0.6, flexShrink: 0 }} title="Reroll">&#x21bb;</span></div>}{whatsNewText && <div style={{ marginTop: 20, width: isMobile ? "90%" : "60%", border: "1px solid #444", borderRadius: 8, padding: 16 }}><div style={{ fontSize: 20, fontWeight: 700, color: "#e0e0e0", marginBottom: 8 }}>What's New</div><div style={{ fontSize: 18, color: "#e0e0e0", whiteSpace: "pre-line" }}>{whatsNewText}</div></div>}</div></>} />
<Route path="/" element={<><div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: 20, paddingBottom: 40 }}><div style={{ position: "relative", width: isMobile ? "90%" : "60%" }}><img src="/S418.png" alt="" style={{ width: "100%", maxHeight: "calc(100vh - 140px)", objectFit: "contain", display: "block" }} /><div style={{ position: "absolute", bottom: 0, left: 0, right: 0, fontSize: "clamp(24px, 6vw, 60px)", fontWeight: 700, color: "#e0e0e0", textAlign: "center", padding: "80px 16px 16px", background: "linear-gradient(transparent, rgba(0,0,0,0.8))" }}>JOHNRUINA SITE</div></div><div style={{ fontSize: 18, color: "#e0e0e0", textAlign: "center", whiteSpace: "pre-line", marginTop: 16, width: isMobile ? "90%" : "90%", border: "1px solid #444", borderRadius: 8, padding: 16, background: descBg }}>Simple little Limbus website.{'\n\n'}Currently a Limbus community ranking website, if it gets traction I'll add more rating categories and features.{'\n\n'}Glory to K Corp!</div>{randomMessage && <div style={{ marginTop: 20, width: isMobile ? "90%" : "90%", display: "flex", alignItems: "center", justifyContent: "center", gap: 10, fontSize: 16, color: "#e0e0e0", textAlign: "center", fontStyle: "italic", border: "1px solid #444", borderRadius: 8, padding: 12, background: descBg }}>{randomMessage}<span onClick={() => fetch("/api/random-message").then((r) => r.json()).then((d) => setRandomMessage(d.text)).catch(() => {})} style={{ cursor: "pointer", fontSize: 14, opacity: 0.6, flexShrink: 0 }} title="Reroll">&#x21bb;</span></div>}{updates.length > 0 && <div style={{ display: "flex", flexDirection: isMobile ? "column" : "row", gap: 16, marginTop: 20, width: isMobile ? "90%" : "90%", alignItems: "flex-start" }}><div style={{ flex: 1, minWidth: 0, border: "1px solid #444", borderRadius: 8, padding: 16, background: descBg }}><div style={{ fontSize: 20, fontWeight: 700, color: "#e0e0e0", marginBottom: 8 }}>What's New — <span style={{ fontSize: 13, fontWeight: 400, color: "#888" }}>{new Date(updates[0].createdAt).toLocaleString("en-US", { timeZone: "America/New_York", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span></div><div style={{ fontSize: 18, fontWeight: 600, color: "#e0e0e0", marginBottom: 4 }}>{updates[0].title}</div><div style={{ fontSize: 18, color: "#e0e0e0", whiteSpace: "pre-line" }}>{updates[0].text}</div></div><div style={{ flex: 1, minWidth: 0, border: "1px solid #444", borderRadius: 8, padding: 16, background: descBg }}><div style={{ fontSize: 20, fontWeight: 700, color: "#e0e0e0", marginBottom: 8 }}>Site Updates</div><div style={{ display: "flex", flexDirection: "column", gap: 4 }}>{updates.map((u) => (<div key={u.id}><div onClick={() => setExpandedUpdate(expandedUpdate === u.id ? null : u.id)} style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", cursor: "pointer", padding: "6px 0" }}><span style={{ fontSize: 15, fontWeight: 600, color: "#e0e0e0" }}>{u.title}</span><span style={{ fontSize: 11, color: "#888", flexShrink: 0, marginLeft: 8 }}>{new Date(u.createdAt).toLocaleString("en-US", { timeZone: "America/New_York", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span></div>{expandedUpdate === u.id && <div style={{ fontSize: 14, color: "#e0e0e0", whiteSpace: "pre-line", padding: "0 0 6px 0" }}>{u.text}</div>}</div>))}</div></div></div>}</div></>} />
<Route path="/ratings/:category?" 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="/polls" element={<div style={{ margin: "40px auto", padding: "0 16px", width: "100%", maxWidth: 1200, boxSizing: "border-box" }}><PollsPage user={user} /></div>} />
+3 -2
View File
@@ -2,9 +2,10 @@ import { createContext, useContext, useEffect, type ReactNode } from "react";
type ThemeContextType = {
dark: boolean;
descBg: string;
};
const ThemeContext = createContext<ThemeContextType>({ dark: true });
const ThemeContext = createContext<ThemeContextType>({ dark: true, descBg: "#181818" });
export function ThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
@@ -14,7 +15,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, []);
return (
<ThemeContext.Provider value={{ dark: true }}>
<ThemeContext.Provider value={{ dark: true, descBg: "#181818" }}>
{children}
</ThemeContext.Provider>
);
+78 -21
View File
@@ -17,7 +17,7 @@ type Rating = {
id: number;
ratingTypeId: number;
name: string;
picture: string;
picture: string[];
description: string;
icon: string;
attachments: string;
@@ -68,14 +68,14 @@ function AdminPage({ user }: Props) {
const adminLoggedIn = user?.email === "invoictoan@gmail.com";
function ua(url: string) { const sep = url.includes("?") ? "&" : "?"; return `${url}${sep}sessionToken=${getCookie("sessionToken")}`; }
const [tab, setTab] = useState<"ratings" | "polls" | "assets" | "whatsnew" | "messages">("ratings");
const [tab, setTab] = useState<"ratings" | "polls" | "assets" | "updates" | "messages">("ratings");
const [ratings, setRatings] = useState<Rating[]>([]);
const [ratingTypes, setRatingTypes] = useState<{ id: number; name: string; hasAttachments: boolean; categories: { id: number; name: string }[] }[]>([]);
const [polls, setPolls] = useState<Poll[]>([]);
const [type, setType] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [picture, setPicture] = useState("");
const [picture, setPicture] = useState<string[]>([]);
const [icon, setIcon] = useState("");
const [attachments, setAttachments] = useState("");
@@ -135,7 +135,7 @@ function AdminPage({ user }: Props) {
const [selectedRatingIds, setSelectedRatingIds] = useState<Set<number>>(new Set());
const [massTagId, setMassTagId] = useState("");
const [editingRating, setEditingRating] = useState<Rating | null>(null);
const [editRatingData, setEditRatingData] = useState({ name: "", description: "", picture: "", icon: "", attachments: "" });
const [editRatingData, setEditRatingData] = useState({ name: "", description: "", picture: [] as string[], icon: "", attachments: "" });
const [assets, setAssets] = useState<{ name: string; url: string }[]>([]);
const [newAssetImage, setNewAssetImage] = useState("");
const [compressImages, setCompressImages] = useState(false);
@@ -149,9 +149,11 @@ function AdminPage({ user }: Props) {
const [showPicker, setShowPicker] = useState(false);
const pickerCbRef = useRef<((url: string) => void) | null>(null);
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [whatsNewText, setWhatsNewText] = useState("");
const [messages, setMessages] = useState<{ id: number; text: string }[]>([]);
const [newMessageText, setNewMessageText] = useState("");
const [updates, setUpdates] = useState<{ id: number; title: string; text: string; createdAt: string }[]>([]);
const [newUpdateTitle, setNewUpdateTitle] = useState("");
const [newUpdateText, setNewUpdateText] = useState("");
function loadRatingTypes() {
fetch("/api/rating-types").then((r) => r.json()).then((types) => {
@@ -171,18 +173,18 @@ function AdminPage({ user }: Props) {
loadAssets();
loadPollCategories();
loadPollTags();
loadWhatsNew();
loadMessages();
loadUpdates();
}, []);
function loadWhatsNew() {
fetch("/api/whatsnew").then((r) => r.json()).then((d) => setWhatsNewText(d.text)).catch(() => {});
}
function loadMessages() {
fetch(ua("/api/admin/messages")).then((r) => r.json()).then((d) => { if (Array.isArray(d)) setMessages(d); }).catch(() => {});
}
function loadUpdates() {
fetch("/api/site-updates").then((r) => r.json()).then((d) => { if (Array.isArray(d)) setUpdates(d); }).catch(() => {});
}
async function handleRatingSubmit(e: FormEvent) {
e.preventDefault();
if (!type) return;
@@ -191,7 +193,7 @@ function AdminPage({ user }: Props) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ratingTypeId: Number(type), name, description, picture, icon, attachments, compress: compressImages }),
});
setName(""); setDescription(""); setPicture(""); setIcon(""); setAttachments("");
setName(""); setDescription(""); setPicture([]); setIcon(""); setAttachments("");
const res = await fetch("/api/ratings?limit=10000").then((r) => r.json());
setRatings(res.items);
}
@@ -465,8 +467,8 @@ function AdminPage({ user }: Props) {
<button onClick={() => setTab("ratings")} style={{ flex: 1, padding: "10px 0", fontSize: 16, fontWeight: 700, background: tab === "ratings" ? "#424e88" : "none", color: tab === "ratings" ? "#fff" : textClr, border: `1px solid ${borderClr}`, cursor: "pointer", transition: "background 0.2s ease" }}>Ratings</button>
<button onClick={() => setTab("polls")} style={{ flex: 1, padding: "10px 0", fontSize: 16, fontWeight: 700, background: tab === "polls" ? "#424e88" : "none", color: tab === "polls" ? "#fff" : textClr, border: `1px solid ${borderClr}`, cursor: "pointer", transition: "background 0.2s ease" }}>Polls ({polls.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("whatsnew")} style={{ flex: 1, padding: "10px 0", fontSize: 16, fontWeight: 700, background: tab === "whatsnew" ? "#424e88" : "none", color: tab === "whatsnew" ? "#fff" : textClr, border: `1px solid ${borderClr}`, cursor: "pointer", transition: "background 0.2s ease" }}>What's New</button>
<button onClick={() => setTab("messages")} style={{ flex: 1, padding: "10px 0", fontSize: 16, fontWeight: 700, background: tab === "messages" ? "#424e88" : "none", color: tab === "messages" ? "#fff" : textClr, border: `1px solid ${borderClr}`, cursor: "pointer", transition: "background 0.2s ease" }}>Messages ({messages.length})</button>
<button onClick={() => setTab("updates")} style={{ flex: 1, padding: "10px 0", fontSize: 16, fontWeight: 700, background: tab === "updates" ? "#424e88" : "none", color: tab === "updates" ? "#fff" : textClr, border: `1px solid ${borderClr}`, cursor: "pointer", transition: "background 0.2s ease" }}>Updates ({updates.length})</button>
</div>
<div style={{ marginBottom: 20, display: "flex", alignItems: "center", gap: 16 }}>
<button onClick={async () => { await fetch(ua("/api/admin/rebuild-cache"), { method: "POST" }); }} style={{ padding: "8px 16px", fontSize: 13, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Rebuild Cache</button>
@@ -488,9 +490,19 @@ function AdminPage({ user }: 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={() => { 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) => setPicture((p) => [...p, url]); setShowPicker(true); }} style={{ flex: 1, padding: "8px 12px", fontSize: 14, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, cursor: "pointer", textAlign: "left" }}>{picture.length > 0 ? `Pictures (${picture.length})` : "Add 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>
{picture.length > 0 && (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{picture.map((p, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 11, color: mutedClr, border: `1px solid ${borderClr}`, padding: "2px 6px" }}>
{p.replace("/uploads/", "")}
<span onClick={() => setPicture((arr) => arr.filter((_, j) => j !== i))} style={{ color: "#e74c3c", cursor: "pointer", fontWeight: 700 }}>&times;</span>
</span>
))}
</div>
)}
{ratingTypes.find((t) => String(t.id) === type)?.hasAttachments && (
<div>
<input ref={createAttachInputRef} type="file" accept="audio/*" style={{ display: "none" }} onChange={(e) => {
@@ -642,7 +654,7 @@ function AdminPage({ user }: Props) {
<span style={{ fontSize: 13, color: textClr, fontWeight: 600 }}>Add tag to {selectedRatingIds.size} selected:</span>
<select value={massTagId} onChange={(e) => setMassTagId(e.target.value)} style={{ flex: 1, padding: "6px 10px", fontSize: 13, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }}>
<option value="">-- Tag --</option>
{tags.filter((t) => !ratingTypeFilter || t.ratingTypeId === Number(ratingTypeFilter)).map((t) => (
{tags.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
@@ -698,7 +710,7 @@ function AdminPage({ user }: Props) {
<>
<select value={selectedTagForRating} onChange={(e) => setSelectedTagForRating(e.target.value)} style={{ padding: "2px 4px", fontSize: 11, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, width: 120 }}>
<option value="">-- Tag --</option>
{tags.filter((t) => t.ratingTypeId === r.ratingTypeId).map((t) => (
{tags.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
@@ -936,11 +948,27 @@ function AdminPage({ user }: Props) {
))}
</div>
</div>
) : tab === "whatsnew" ? (
<div style={{ border: `1px solid ${borderClr}`, padding: 16 }}>
<h3 style={{ margin: "0 0 8px" }}>What's New</h3>
<textarea value={whatsNewText} onChange={(e) => setWhatsNewText(e.target.value)} rows={6} style={{ display: "block", width: "100%", padding: "8px 12px", fontSize: 14, marginBottom: 8, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box", resize: "vertical" }} />
<button onClick={async () => { await fetch(ua("/api/admin/whatsnew"), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: whatsNewText }) }); }} style={{ padding: "10px 0", fontSize: 16, fontWeight: 700, background: "#424e88", color: "#fff", border: "none", cursor: "pointer", width: "100%" }}>Save</button>
) : tab === "updates" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ border: `1px solid ${borderClr}`, padding: 16 }}>
<h3 style={{ margin: "0 0 8px" }}>New Update</h3>
<input placeholder="Title" value={newUpdateTitle} onChange={(e) => setNewUpdateTitle(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={newUpdateText} onChange={(e) => setNewUpdateText(e.target.value)} rows={4} style={{ display: "block", width: "100%", padding: "8px 12px", fontSize: 14, marginBottom: 8, border: `1px solid ${borderClr}`, background: inputBg, color: textClr, boxSizing: "border-box", resize: "vertical" }} />
<button onClick={async () => { if (!newUpdateTitle) return; await fetch(ua("/api/admin/site-updates"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newUpdateTitle, text: newUpdateText }) }); setNewUpdateTitle(""); setNewUpdateText(""); loadUpdates(); }} style={{ padding: "8px 16px", fontSize: 14, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Add</button>
</div>
{updates.map((u) => (
<div key={u.id} style={{ border: `1px solid ${borderClr}`, padding: 12, background: dark ? "#1a1a1a" : "#fafafa" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}>
<span style={{ color: textClr, fontSize: 15, fontWeight: 700 }}>{u.title}</span>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ color: mutedClr, fontSize: 11 }}>{new Date(u.createdAt).toLocaleString("en-US", { timeZone: "America/New_York", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<button onClick={async () => { if (!window.confirm("Delete this update?")) return; await fetch(ua(`/api/admin/site-updates/${u.id}`), { method: "DELETE" }); loadUpdates(); }} style={{ padding: "2px 8px", fontSize: 12, background: "#e74c3c", color: "#fff", border: "none", cursor: "pointer" }}>Delete</button>
</div>
</div>
<div style={{ color: textClr, fontSize: 14, whiteSpace: "pre-line" }}>{u.text}</div>
</div>
))}
{updates.length === 0 && <p style={{ color: mutedClr }}>No updates yet.</p>}
</div>
) : tab === "messages" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
@@ -968,9 +996,19 @@ function AdminPage({ user }: Props) {
<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" }} />
<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, picture: [...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.length > 0 ? `Pictures (${editRatingData.picture.length})` : "Add 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>
{editRatingData.picture.length > 0 && (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{editRatingData.picture.map((p, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 11, color: mutedClr, border: `1px solid ${borderClr}`, padding: "2px 6px" }}>
{p.replace("/uploads/", "")}
<span onClick={() => setEditRatingData((d) => ({ ...d, picture: d.picture.filter((_, j) => j !== i) }))} style={{ color: "#e74c3c", cursor: "pointer", fontWeight: 700 }}>&times;</span>
</span>
))}
</div>
)}
{ratingTypes.find((t) => t.id === editingRating?.ratingTypeId)?.hasAttachments && (
<div>
<input ref={attachmentInputRef} type="file" accept="audio/*" style={{ display: "none" }} onChange={(e) => {
@@ -984,6 +1022,25 @@ function AdminPage({ user }: Props) {
{editRatingData.attachments && <div style={{ fontSize: 12, color: mutedClr, marginTop: 4 }}>{editRatingData.attachments.replace("/uploads/", "")} <span onClick={() => setEditRatingData((p) => ({ ...p, attachments: "" }))} style={{ color: "#e74c3c", cursor: "pointer", textDecoration: "underline" }}>remove</span></div>}
</div>
)}
<div style={{ borderTop: `1px solid ${borderClr}`, paddingTop: 8, marginTop: 8 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: textClr, marginBottom: 6 }}>Categories</div>
{ratingTypes.find((t) => t.id === editingRating?.ratingTypeId)?.categories.map((c) => (
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
{editingCategory === c.id ? (
<>
<input value={editCategoryName} onChange={(e) => setEditCategoryName(e.target.value)} style={{ flex: 1, padding: "4px 8px", fontSize: 13, border: `1px solid ${borderClr}`, background: inputBg, color: textClr }} />
<button onClick={async () => { if (!editCategoryName) return; await fetch(ua(`/api/admin/rating-types/${editingRating?.ratingTypeId}/categories/${c.id}`), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editCategoryName }) }); setEditingCategory(null); loadRatingTypes(); }} style={{ padding: "4px 8px", fontSize: 12, background: "#424e88", color: "#fff", border: "none", cursor: "pointer" }}>Save</button>
<button onClick={() => setEditingCategory(null)} style={{ padding: "4px 8px", fontSize: 12, background: "none", color: textClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>x</button>
</>
) : (
<>
<span style={{ flex: 1, color: textClr, fontSize: 13 }}>{c.name}</span>
<button onClick={() => { setEditingCategory(c.id); setEditCategoryName(c.name); }} style={{ padding: "2px 6px", fontSize: 11, background: "none", color: mutedClr, border: `1px solid ${borderClr}`, cursor: "pointer" }}>edit</button>
</>
)}
</div>
))}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={async () => {
await fetch(ua(`/api/admin/ratings/${editingRating.id}`), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...editRatingData, compress: compressImages }) });
+2 -2
View File
@@ -44,7 +44,7 @@ type Props = {
};
function PollsPage({ user }: Props) {
const { dark } = useTheme();
const { dark, descBg } = useTheme();
const [categories, setCategories] = useState<PollCategory[]>([]);
const [selected, setSelected] = useState<Poll | null>(null);
const [showCompleted, setShowCompleted] = useState(true);
@@ -211,7 +211,7 @@ function PollsPage({ user }: Props) {
<div>
<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}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 }}>Polls</div>
<div style={{ fontSize: 15, color: textClr, marginBottom: 20, border: `1px solid ${borderClr}`, borderRadius: 8, padding: 16 }}>Polls regarding both the site and random Limbus stuff.</div>
<div style={{ fontSize: 15, color: textClr, marginBottom: 20, border: `1px solid ${borderClr}`, borderRadius: 8, padding: 16, background: descBg }}>Polls regarding both the site and random Limbus stuff.</div>
{winWidth <= 768 && (
<div style={{ marginBottom: 12, border: `1px solid ${borderClr}`, padding: 12, background: cardBg }}>
+29 -5
View File
@@ -42,7 +42,7 @@ type Rating = {
id: number;
ratingTypeId: number;
name: string;
picture: string;
picture: string[];
description: string;
icon: string;
attachments: string;
@@ -176,13 +176,14 @@ function MiniAudioPlayer({ src }: { src: string }) {
}
function RatingsPage({ user }: Props) {
const { dark } = useTheme();
const { dark, descBg } = useTheme();
const [ratings, setRatings] = useState<Rating[]>([]);
const [ratingTypes, setRatingTypes] = useState<{ id: number; name: string; hasAttachments: boolean; categories: { id: number; name: string }[] }[]>([]);
const { category: urlCategory } = useParams();
const navigate = useNavigate();
const category = urlCategory || (ratingTypes.length > 0 ? ratingTypes[0].name : "");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [slideIdx, setSlideIdx] = useState(0);
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>>({});
@@ -498,7 +499,7 @@ function RatingsPage({ user }: Props) {
<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:"";display:block;width:6px;height:6px;background:#fff;border-radius:1px}`}</style>
<div style={{ fontSize: 28, fontWeight: 700, color: textClr, marginBottom: 12 }}>Ratings</div>
<div style={{ fontSize: 15, color: textClr, marginBottom: 20, whiteSpace: "pre-line", border: `1px solid ${borderClr}`, borderRadius: 8, padding: 16 }}>Rate stuff from different categories.{'\n\n'}Anonymous users can still vote, but can't leave reviews or leave stars.{'\n\n'}Accounts can place 3 stars per rating category, put them on your all time favs.</div>
<div style={{ fontSize: 15, color: textClr, marginBottom: 20, whiteSpace: "pre-line", border: `1px solid ${borderClr}`, borderRadius: 8, padding: 16, background: descBg }}>Rate stuff from different categories.{'\n\n'}Anonymous users can still vote, but can't leave reviews or leave stars.{'\n\n'}Accounts can place 3 stars per rating category, put them on your all time favs.</div>
<div style={{ display: "flex", width: "100%", marginBottom: 8, border: `1px solid ${borderClr}`, borderRadius: 2, overflow: "hidden" }}>
{ratingTypes.map((t, i) => (
<button
@@ -778,7 +779,7 @@ function RatingsPage({ user }: Props) {
<div
key={r.id}
className="rating-card"
onClick={() => setSelectedId(r.id)}
onClick={() => { setSelectedId(r.id); setSlideIdx(0); }}
style={{
display: "flex",
flexDirection: "column",
@@ -900,7 +901,30 @@ function RatingsPage({ user }: Props) {
</div>
)}
</div>
{selected.picture && <img src={selected.picture} alt="" style={{ width: "100%", maxHeight: 400, objectFit: "contain", marginBottom: 12 }} />}
{(() => {
const slides = [...(selected.picture || []), selected.icon].filter(Boolean) as string[];
if (slides.length === 0) return null;
const count = slides.length;
const idx = slideIdx % count;
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", marginBottom: 12 }}>
<div style={{ position: "relative", width: "100%", height: 300, display: "flex", alignItems: "center", justifyContent: "center", background: "#000", overflow: "hidden" }}>
<img src={slides[idx]} alt="" style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
{count > 1 && (
<>
<div onClick={() => setSlideIdx((slideIdx - 1 + count) % count)} style={{ position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)", cursor: "pointer", background: "rgba(0,0,0,0.6)", color: "#fff", width: 32, height: 32, borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18, userSelect: "none" }}>‹</div>
<div onClick={() => setSlideIdx((slideIdx + 1) % count)} style={{ position: "absolute", right: 8, top: "50%", transform: "translateY(-50%)", cursor: "pointer", background: "rgba(0,0,0,0.6)", color: "#fff", width: 32, height: 32, borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18, userSelect: "none" }}>›</div>
<div style={{ position: "absolute", bottom: 6, left: 0, right: 0, display: "flex", justifyContent: "center", gap: 4 }}>
{slides.map((_, i) => (
<div key={i} onClick={() => setSlideIdx(i)} style={{ width: 8, height: 8, borderRadius: "50%", background: i === idx ? "#fff" : "rgba(255,255,255,0.4)", cursor: "pointer" }} />
))}
</div>
</>
)}
</div>
</div>
);
})()}
{(() => {
return (
<div style={{ display: "flex", flexDirection: "row", justifyContent: "center", gap: 24, marginBottom: 12 }}>
+1 -1
View File
@@ -68,7 +68,7 @@ model Rating {
ratingTypeId Int
sortOrder Int @default(0)
name String
picture String
picture String[]
description String
icon String
attachments String @default("")
+14 -4
View File
@@ -13,7 +13,7 @@ import crypto from "crypto";
const app = express();
const prisma = new PrismaClient();
type RatingCacheEntry = { id: number; ratingTypeId: number; sortOrder: number; name: string; picture: string; description: string; icon: string; attachments: string; tags: { id: number; name: string; color: string }[] };
type RatingCacheEntry = { id: number; ratingTypeId: number; sortOrder: number; name: string; picture: string[]; description: string; icon: string; attachments: string; tags: { id: number; name: string; color: string }[] };
const cache = {
ratings: new Map<number, RatingCacheEntry>(),
@@ -345,6 +345,16 @@ async function saveImage(base64: string, compress = false): Promise<string> {
return `/uploads/${filename}`;
}
async function saveImages(pics: unknown, compress = false): Promise<string[]> {
if (!pics) return [];
const arr = Array.isArray(pics) ? pics : [pics];
const out: string[] = [];
for (const p of arr) {
if (typeof p === "string" && p) out.push(await saveImage(p, compress));
}
return out;
}
function getIp(req: express.Request): string {
return (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || "unknown";
}
@@ -911,7 +921,7 @@ app.post("/api/admin/ratings", async (req, res) => {
return;
}
const max = await prisma.rating.aggregate({ _max: { sortOrder: true } });
const rating = await prisma.rating.create({ data: { ratingTypeId, name, description: description ?? "", picture: await saveImage(picture ?? "", compress), icon: await saveImage(icon ?? "", compress), attachments: await saveFile(attachments ?? ""), sortOrder: (max._max.sortOrder ?? 0) + 1 } });
const rating = await prisma.rating.create({ data: { ratingTypeId, name, description: description ?? "", picture: await saveImages(picture, compress), icon: await saveImage(icon ?? "", compress), attachments: await saveFile(attachments ?? ""), sortOrder: (max._max.sortOrder ?? 0) + 1 } });
await rebuildCache();
res.json(rating);
});
@@ -934,7 +944,7 @@ app.put("/api/admin/ratings/:id", async (req, res) => {
const data: Record<string, unknown> = {};
if (name !== undefined) data.name = name;
if (description !== undefined) data.description = description;
if (picture !== undefined) data.picture = await saveImage(picture, compress);
if (picture !== undefined) data.picture = await saveImages(picture, compress);
if (icon !== undefined) data.icon = await saveImage(icon, compress);
if (attachments !== undefined) data.attachments = await saveFile(attachments);
const rating = await prisma.rating.update({ where: { id }, data });
@@ -1127,7 +1137,7 @@ app.post("/api/admin/upload-attachments", async (req, res, next) => { if (!(awai
const destPath = path.join(uploadsDir, safeName);
fs.renameSync(f.path, destPath);
const attachments = `/uploads/${safeName}`;
const rating = await prisma.rating.create({ data: { ratingTypeId: ratingType.id, name, description: "", picture: "", icon: "", attachments } });
const rating = await prisma.rating.create({ data: { ratingTypeId: ratingType.id, name, description: "", picture: [], icon: "", attachments } });
created.push({ id: rating.id, name });
}
await rebuildCache();