mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
feat: add back likes and admin banners
This commit is contained in:
parent
0bd2d6d565
commit
aa20e931ee
7 changed files with 141 additions and 97 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue