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() {
const [message, setMessage] = useState("");
const API_URL = import.meta.env.VITE_API_URL;
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 () => {
await fetch("/api/admin/banner", { method: "POST", body: message });
await fetch(`${API_URL}/api/admin/banner`, { method: "POST", body: message, credentials: "include" });
};
return (

View file

@ -1,65 +1,68 @@
// import { useSearchParams } from "next/navigation";
// import { Suspense, useEffect, useState } from "react";
import { 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 from = searchParams.get("from");
if (from !== "old-domain") return null;
// const fetcher = (url: string) => fetch(url).then((res) => res.json());
return (
<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>
);
}
// function RedirectBanner() {
// const searchParams = useSearchParams();
// const from = searchParams.get("from");
// if (from !== "old-domain") return null;
export default function AdminBanner() {
const [message, setMessage] = useState<string | null>(null);
const [shouldShow, setShouldShow] = useState(false);
// return (
// <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>
// );
// }
useEffect(() => {
fetch(`${import.meta.env.VITE_API_URL}/api/admin/banner`)
.then((res) => {
if (!res.ok) throw new Error("Failed to get admin banner");
return res.json() as Promise<ApiResponse>;
})
.then((data) => {
if (!data.message) return;
const closedBanner = localStorage.getItem("closedBanner");
setMessage(data.message);
setShouldShow(data.message !== closedBanner);
})
.catch((err) => {
console.error(err);
});
}, []);
// export default function AdminBanner() {
// const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher);
// const [shouldShow, setShouldShow] = useState(true);
const handleClose = () => {
if (!message) return;
// useEffect(() => {
// if (!data?.message) return;
// Close banner and remember it
localStorage.setItem("closedBanner", message);
setShouldShow(false);
};
// // Check if the current banner text was closed by the user
// const closedBanner = window.localStorage.getItem("closedBanner");
// setShouldShow(data.message !== closedBanner);
// }, [data]);
return (
<>
{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">
<div className="flex gap-2 h-full items-center w-fit">
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
<span>{message}</span>
</div>
// const handleClose = () => {
// if (!data) return;
// // Close banner and remember it
// window.localStorage.setItem("closedBanner", data.message);
// setShouldShow(false);
// };
// return (
// <>
// {data && data.message && shouldShow && (
// <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" />
// <span>{data.message}</span>
// </div>
// <button onClick={handleClose} className="min-sm:absolute right-2 cursor-pointer p-1.5">
// <Icon icon="humbleicons:times" className="text-2xl min-w-6" />
// </button>
// </div>
// )}
// <Suspense>
// <RedirectBanner />
// </Suspense>
// </>
// );
// }
<button onClick={handleClose} className="sm:absolute right-2 cursor-pointer p-1.5">
<Icon icon="humbleicons:times" className="text-2xl min-w-6" />
</button>
</div>
)}
<RedirectBanner />
</>
);
}

View file

@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import { Icon, loadIcons } from "@iconify/react";
import { abbreviateNumber } from "../lib/abbreviation";
import { useStore } from "@nanostores/react";
import { session } from "../session";
import { useNavigate } from "react-router";
interface Props {
likes: number;
@ -11,33 +14,41 @@ interface Props {
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 [likesState] = useState(likes);
const [isAnimating] = useState(false);
const [likesState, setLikesState] = useState(likes);
const [isAnimating, setIsAnimating] = useState(false);
const onClick = async () => {
// if (disabled) return;
// if (!session.data?.user) {
// router.push("/login");
// return;
// }
// setIsLikedState(!isLikedState);
// setLikesState(isLikedState ? likesState - 1 : likesState + 1);
// // Trigger animation
// if (!isLikedState) {
// setIsAnimating(true);
// setTimeout(() => setIsAnimating(false), 1000); // match animation duration
// }
// const response = await fetch(`/api/mii/${miiId}/like`, { method: "POST" });
// if (response.ok) {
// const { liked, count } = await response.json();
// setIsLikedState(liked);
// setLikesState(count);
// } else {
// setIsLikedState(isLikedState);
// setLikesState(likesState);
// }
if (disabled || !miiId) return;
if ($session === undefined) return;
if ($session === null) {
navigate("/login");
return;
}
const prevLiked = isLikedState;
const prevLikes = likesState;
setIsLikedState(!prevLiked);
setLikesState(prevLiked ? likesState - 1 : likesState + 1);
// Trigger animation
if (!prevLiked) {
setIsAnimating(true);
setTimeout(() => setIsAnimating(false), 1000);
}
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/${miiId}/like`, { method: "POST", credentials: "include" });
if (response.ok) {
const { liked, count } = await response.json();
setIsLikedState(liked);
setLikesState(count);
} else {
setIsLikedState(prevLiked);
setLikesState(prevLikes);
}
};
// 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 [loading, setLoading] = useState(true);
const [acceptingAll, setAcceptingAll] = useState(false);
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
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");
return res.json();
})
.then((data) => {
.then(async (data) => {
setData(data);
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) => {
console.error(err);
setLoading(false);
});
}, [searchParams, userId, parentPage]);
}, [searchParams, userId, parentPage, $session]);
async function handleAcceptAll() {
if (!data) return;
@ -162,7 +172,7 @@ export default function MiiList({ parentPage, userId }: Props) {
{parentPage === "admin" && mii.description && <Description text={mii.description} />}
<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 && (
<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 Header from "./components/header";
import { useEffect } from "react";
@ -27,7 +28,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{/* <AdminBanner /> */}
<AdminBanner />
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
<Footer />
</>

View file

@ -2,10 +2,16 @@ import { useStore } from "@nanostores/react";
import MiiList from "../components/mii/list";
import { session } from "../session";
import { Navigate } from "react-router";
import BannerForm from "../components/admin/banner-form";
export default function AdminPage() {
const $session = useStore(session);
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 />;
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 { Link, useNavigate, useParams } from "react-router";
import AuthorButtons from "../components/mii/author-buttons";
import { useStore } from "@nanostores/react";
import { session } from "../session";
export default function MiiPage() {
const { id } = useParams();
const navigate = useNavigate();
const $session = useStore(session);
const [mii, setMii] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [isLiked, setIsLiked] = useState(false);
const API_URL = import.meta.env.VITE_API_URL;
@ -29,6 +33,14 @@ export default function MiiPage() {
.then((data) => {
setMii(data);
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) => {
console.error(err);
@ -252,7 +264,7 @@ export default function MiiPage() {
{/* 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>
{/* 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>
{/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">