feat: add back likes and admin banners

This commit is contained in:
trafficlunar 2026-04-19 00:45:24 +01:00
parent 0bd2d6d565
commit aa20e931ee
7 changed files with 141 additions and 97 deletions

View file

@ -2,13 +2,14 @@ import { useState } from "react";
export default function BannerForm() { export default function BannerForm() {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const API_URL = import.meta.env.VITE_API_URL;
const onClickClear = async () => { const onClickClear = async () => {
await fetch("/api/admin/banner", { method: "DELETE" }); await fetch(`${API_URL}/api/admin/banner`, { method: "DELETE", credentials: "include" }); // TODO
}; };
const onClickSet = async () => { const onClickSet = async () => {
await fetch("/api/admin/banner", { method: "POST", body: message }); await fetch(`${API_URL}/api/admin/banner`, { method: "POST", body: message, credentials: "include" });
}; };
return ( return (

View file

@ -1,65 +1,68 @@
// import { useSearchParams } from "next/navigation"; import { useEffect, useState } from "react";
// import { Suspense, useEffect, useState } from "react"; import { Icon } from "@iconify/react";
import { useSearchParams } from "react-router";
// import useSWR from "swr";
// import { Icon } from "@iconify/react"; interface ApiResponse {
message: string;
// interface ApiResponse { }
// message: string;
// } function RedirectBanner() {
const [searchParams] = useSearchParams();
// const fetcher = (url: string) => fetch(url).then((res) => res.json()); const from = searchParams.get("from");
if (from !== "old-domain") return null;
// function RedirectBanner() {
// const searchParams = useSearchParams(); return (
// const from = searchParams.get("from"); <div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
// if (from !== "old-domain") return null; <Icon icon="humbleicons:link" className="text-2xl min-w-6" />
<span>We have moved URLs, welcome to tomodachishare.com!</span>
// return ( </div>
// <div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start"> );
// <Icon icon="humbleicons:link" className="text-2xl min-w-6" /> }
// <span>We have moved URLs, welcome to tomodachishare.com!</span>
// </div> export default function AdminBanner() {
// ); const [message, setMessage] = useState<string | null>(null);
// } const [shouldShow, setShouldShow] = useState(false);
// export default function AdminBanner() { useEffect(() => {
// const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher); fetch(`${import.meta.env.VITE_API_URL}/api/admin/banner`)
// const [shouldShow, setShouldShow] = useState(true); .then((res) => {
if (!res.ok) throw new Error("Failed to get admin banner");
// useEffect(() => { return res.json() as Promise<ApiResponse>;
// if (!data?.message) return; })
.then((data) => {
// // Check if the current banner text was closed by the user if (!data.message) return;
// const closedBanner = window.localStorage.getItem("closedBanner"); const closedBanner = localStorage.getItem("closedBanner");
// setShouldShow(data.message !== closedBanner); setMessage(data.message);
// }, [data]); setShouldShow(data.message !== closedBanner);
})
// const handleClose = () => { .catch((err) => {
// if (!data) return; console.error(err);
});
// // Close banner and remember it }, []);
// window.localStorage.setItem("closedBanner", data.message);
// setShouldShow(false); const handleClose = () => {
// }; if (!message) return;
// return ( // Close banner and remember it
// <> localStorage.setItem("closedBanner", message);
// {data && data.message && shouldShow && ( setShouldShow(false);
// <div className="relative w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between"> };
// <div className="flex gap-2 h-full items-center w-fit">
// <Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" /> return (
// <span>{data.message}</span> <>
// </div> {shouldShow && message && (
<div className="relative w-full min-h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between">
// <button onClick={handleClose} className="min-sm:absolute right-2 cursor-pointer p-1.5"> <div className="flex gap-2 h-full items-center w-fit">
// <Icon icon="humbleicons:times" className="text-2xl min-w-6" /> <Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
// </button> <span>{message}</span>
// </div> </div>
// )}
// <Suspense> <button onClick={handleClose} className="sm:absolute right-2 cursor-pointer p-1.5">
// <RedirectBanner /> <Icon icon="humbleicons:times" className="text-2xl min-w-6" />
// </Suspense> </button>
// </> </div>
// ); )}
// } <RedirectBanner />
</>
);
}

View file

@ -1,6 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Icon, loadIcons } from "@iconify/react"; import { Icon, loadIcons } from "@iconify/react";
import { abbreviateNumber } from "../lib/abbreviation"; import { abbreviateNumber } from "../lib/abbreviation";
import { useStore } from "@nanostores/react";
import { session } from "../session";
import { useNavigate } from "react-router";
interface Props { interface Props {
likes: number; likes: number;
@ -11,33 +14,41 @@ interface Props {
big?: boolean; big?: boolean;
} }
export default function LikeButton({ likes, isLiked, disabled, abbreviate, big }: Props) { export default function LikeButton({ likes, miiId, isLiked, disabled, abbreviate, big }: Props) {
const $session = useStore(session);
const navigate = useNavigate();
const [isLikedState, setIsLikedState] = useState(isLiked); const [isLikedState, setIsLikedState] = useState(isLiked);
const [likesState] = useState(likes); const [likesState, setLikesState] = useState(likes);
const [isAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const onClick = async () => { const onClick = async () => {
// if (disabled) return; if (disabled || !miiId) return;
// if (!session.data?.user) { if ($session === undefined) return;
// router.push("/login"); if ($session === null) {
// return; navigate("/login");
// } return;
// setIsLikedState(!isLikedState); }
// setLikesState(isLikedState ? likesState - 1 : likesState + 1);
// // Trigger animation const prevLiked = isLikedState;
// if (!isLikedState) { const prevLikes = likesState;
// setIsAnimating(true); setIsLikedState(!prevLiked);
// setTimeout(() => setIsAnimating(false), 1000); // match animation duration setLikesState(prevLiked ? likesState - 1 : likesState + 1);
// }
// const response = await fetch(`/api/mii/${miiId}/like`, { method: "POST" }); // Trigger animation
// if (response.ok) { if (!prevLiked) {
// const { liked, count } = await response.json(); setIsAnimating(true);
// setIsLikedState(liked); setTimeout(() => setIsAnimating(false), 1000);
// setLikesState(count); }
// } else {
// setIsLikedState(isLikedState); const response = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/${miiId}/like`, { method: "POST", credentials: "include" });
// setLikesState(likesState); if (response.ok) {
// } const { liked, count } = await response.json();
setIsLikedState(liked);
setLikesState(count);
} else {
setIsLikedState(prevLiked);
setLikesState(prevLikes);
}
}; };
// Preload like button icons // Preload like button icons

View file

@ -27,6 +27,7 @@ export default function MiiList({ parentPage, userId }: Props) {
const [data, setData] = useState<ApiResponse | null>(null); const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [acceptingAll, setAcceptingAll] = useState(false); const [acceptingAll, setAcceptingAll] = useState(false);
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const $session = useStore(session); const $session = useStore(session);
@ -40,15 +41,24 @@ export default function MiiList({ parentPage, userId }: Props) {
if (!res.ok) throw new Error("Failed to fetch Miis"); if (!res.ok) throw new Error("Failed to fetch Miis");
return res.json(); return res.json();
}) })
.then((data) => { .then(async (data) => {
setData(data); setData(data);
setLoading(false); setLoading(false);
if ($session === undefined || $session === null || !data.miis.length) return;
const ids = data.miis.map((m: any) => m.id).join(",");
fetch(`${import.meta.env.VITE_API_URL}/api/mii/has-liked?ids=${ids}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((likedIds: number[]) => setLikedIds(new Set(likedIds)))
.catch((err) => {
console.error("Error fetching likes:", err);
});
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
setLoading(false); setLoading(false);
}); });
}, [searchParams, userId, parentPage]); }, [searchParams, userId, parentPage, $session]);
async function handleAcceptAll() { async function handleAcceptAll() {
if (!data) return; if (!data) return;
@ -162,7 +172,7 @@ export default function MiiList({ parentPage, userId }: Props) {
{parentPage === "admin" && mii.description && <Description text={mii.description} />} {parentPage === "admin" && mii.description && <Description text={mii.description} />}
<div className="mt-auto grid grid-cols-2 items-center"> <div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate /> <LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={likedIds.has(mii.id)} abbreviate />
{!userId && ( {!userId && (
<Link to={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap"> <Link to={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">

View file

@ -1,3 +1,4 @@
import AdminBanner from "./components/admin/banner";
import Footer from "./components/footer"; import Footer from "./components/footer";
import Header from "./components/header"; import Header from "./components/header";
import { useEffect } from "react"; import { useEffect } from "react";
@ -27,7 +28,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<> <>
<Header /> <Header />
{/* <AdminBanner /> */} <AdminBanner />
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main> <main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
<Footer /> <Footer />
</> </>

View file

@ -2,10 +2,16 @@ import { useStore } from "@nanostores/react";
import MiiList from "../components/mii/list"; import MiiList from "../components/mii/list";
import { session } from "../session"; import { session } from "../session";
import { Navigate } from "react-router"; import { Navigate } from "react-router";
import BannerForm from "../components/admin/banner-form";
export default function AdminPage() { export default function AdminPage() {
const $session = useStore(session); const $session = useStore(session);
if ($session === undefined) return <div className="p-6 text-center">Loading...</div>; if ($session === undefined) return <div className="p-6 text-center">Loading...</div>;
if ($session === null || Number($session?.user?.id) != import.meta.env.VITE_ADMIN_USER_ID) return <Navigate to="/404" replace />; if ($session === null || Number($session?.user?.id) != import.meta.env.VITE_ADMIN_USER_ID) return <Navigate to="/404" replace />;
return <MiiList parentPage="admin" />; return (
<>
<BannerForm />
<MiiList parentPage="admin" />
</>
);
} }

View file

@ -11,12 +11,16 @@ import { Icon } from "@iconify/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
import AuthorButtons from "../components/mii/author-buttons"; import AuthorButtons from "../components/mii/author-buttons";
import { useStore } from "@nanostores/react";
import { session } from "../session";
export default function MiiPage() { export default function MiiPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const $session = useStore(session);
const [mii, setMii] = useState<any>(null); const [mii, setMii] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isLiked, setIsLiked] = useState(false);
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
@ -29,6 +33,14 @@ export default function MiiPage() {
.then((data) => { .then((data) => {
setMii(data); setMii(data);
setLoading(false); setLoading(false);
if ($session === null || $session === undefined) return;
fetch(`${API_URL}/api/mii/has-liked?ids=${data.id}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((likedIds: number[]) => setIsLiked(likedIds.length > 0))
.catch((err) => {
console.error("Error liking:", err);
});
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
@ -252,7 +264,7 @@ export default function MiiPage() {
{/* Submission name */} {/* Submission name */}
<h1 className="text-4xl font-extrabold wrap-break-word whitespace-break-spaces text-amber-700 flex-1 min-w-0">{mii.name}</h1> <h1 className="text-4xl font-extrabold wrap-break-word whitespace-break-spaces text-amber-700 flex-1 min-w-0">{mii.name}</h1>
{/* Like button */} {/* Like button */}
<LikeButton likes={mii._count?.likedBy ?? 0} miiId={mii.id} isLiked={false} big /> <LikeButton likes={mii._count?.likedBy ?? 0} miiId={mii.id} isLiked={isLiked} big />
</div> </div>
{/* Tags */} {/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs"> <div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">