diff --git a/backend/next.config.ts b/backend/next.config.ts index 9253312..f9ef87e 100644 --- a/backend/next.config.ts +++ b/backend/next.config.ts @@ -9,7 +9,8 @@ const nextConfig: NextConfig = { headers: [ { key: "Access-Control-Allow-Origin", value: process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:4321" }, { key: "Access-Control-Allow-Credentials", value: "true" }, - { key: "Access-Control-Allow-Methods", value: "GET,POST,PATCH,DELETE,OPTIONS" }, + { key: "Access-Control-Allow-Methods", value: "GET,POST,DELETE,OPTIONS" }, + { key: "Access-Control-Allow-Headers", value: "Content-Type" }, ], }, ]; diff --git a/backend/src/app/api/admin/accept-mii/route.ts b/backend/src/app/api/admin/accept-mii/route.ts index 941f9df..06b966b 100644 --- a/backend/src/app/api/admin/accept-mii/route.ts +++ b/backend/src/app/api/admin/accept-mii/route.ts @@ -4,7 +4,7 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { idSchema } from "@tomodachi-share/shared/schemas"; -export async function PATCH(request: NextRequest) { +export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/admin/can-submit/route.ts b/backend/src/app/api/admin/can-submit/route.ts index c5459b3..f164e7d 100644 --- a/backend/src/app/api/admin/can-submit/route.ts +++ b/backend/src/app/api/admin/can-submit/route.ts @@ -1,13 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/lib/auth"; -import { settings } from "@/lib/settings"; +import { settings } from "../../../../lib/settings"; export async function GET() { return NextResponse.json({ success: true, value: settings.canSubmit }); } -export async function PATCH(request: NextRequest) { +export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/admin/queue/route.ts b/backend/src/app/api/admin/queue/route.ts index 422fd7d..2f23490 100644 --- a/backend/src/app/api/admin/queue/route.ts +++ b/backend/src/app/api/admin/queue/route.ts @@ -1,13 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/lib/auth"; -import { settings } from "@/lib/settings"; +import { settings } from "../../../../lib/settings"; export async function GET() { return NextResponse.json({ success: true, value: settings.queueEnabled }); } -export async function PATCH(request: NextRequest) { +export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/admin/regenerate-metadata-images/route.ts b/backend/src/app/api/admin/regenerate-metadata-images/route.ts index 69cb2d3..10237d5 100644 --- a/backend/src/app/api/admin/regenerate-metadata-images/route.ts +++ b/backend/src/app/api/admin/regenerate-metadata-images/route.ts @@ -3,7 +3,7 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { generateMetadataImage } from "@/lib/images"; -export async function PATCH() { +export async function POST() { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/auth/about-me/route.ts b/backend/src/app/api/auth/about-me/route.ts index 5269d0c..587daf5 100644 --- a/backend/src/app/api/auth/about-me/route.ts +++ b/backend/src/app/api/auth/about-me/route.ts @@ -6,7 +6,7 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { RateLimit } from "@/lib/rate-limit"; -export async function PATCH(request: NextRequest) { +export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/auth/delete/route.ts b/backend/src/app/api/auth/delete/route.ts index c772e28..ccf92f4 100644 --- a/backend/src/app/api/auth/delete/route.ts +++ b/backend/src/app/api/auth/delete/route.ts @@ -4,7 +4,7 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { RateLimit } from "@/lib/rate-limit"; -export async function DELETE(request: NextRequest) { +export async function POST(request: NextRequest) { const session = await auth(); if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/auth/name/route.ts b/backend/src/app/api/auth/name/route.ts index 2e79541..8a9af28 100644 --- a/backend/src/app/api/auth/name/route.ts +++ b/backend/src/app/api/auth/name/route.ts @@ -6,7 +6,7 @@ import { prisma } from "@/lib/prisma"; import { userNameSchema } from "@tomodachi-share/shared/schemas"; import { RateLimit } from "@/lib/rate-limit"; -export async function PATCH(request: NextRequest) { +export async function POST(request: NextRequest) { const session = await auth(); if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/auth/picture/route.ts b/backend/src/app/api/auth/picture/route.ts index 7f5d5fa..c5f5775 100644 --- a/backend/src/app/api/auth/picture/route.ts +++ b/backend/src/app/api/auth/picture/route.ts @@ -17,7 +17,7 @@ const formDataSchema = z.object({ image: z.union([z.instanceof(File), z.any()]).optional(), }); -export async function PATCH(request: NextRequest) { +export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/mii/[id]/delete/route.ts b/backend/src/app/api/mii/[id]/delete/route.ts index 1a96c99..6c6127b 100644 --- a/backend/src/app/api/mii/[id]/delete/route.ts +++ b/backend/src/app/api/mii/[id]/delete/route.ts @@ -10,7 +10,7 @@ import { RateLimit } from "@/lib/rate-limit"; const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); -export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/mii/[id]/edit/route.ts b/backend/src/app/api/mii/[id]/edit/route.ts index 930bc8e..d840838 100644 --- a/backend/src/app/api/mii/[id]/edit/route.ts +++ b/backend/src/app/api/mii/[id]/edit/route.ts @@ -14,7 +14,7 @@ import { idSchema, nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@ import { generateMetadataImage, validateImage } from "@/lib/images"; import { RateLimit } from "@/lib/rate-limit"; import { minifyInstructions, SwitchMiiInstructions } from "@tomodachi-share/shared"; -import { settings } from "@/lib/settings"; +import { settings } from "../../../../../lib/settings"; const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); @@ -41,7 +41,7 @@ const editSchema = z.object({ image3: z.union([z.instanceof(File), z.any()]).optional(), }); -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/mii/[id]/like/route.ts b/backend/src/app/api/mii/[id]/like/route.ts index 9464bad..332f5bb 100644 --- a/backend/src/app/api/mii/[id]/like/route.ts +++ b/backend/src/app/api/mii/[id]/like/route.ts @@ -5,7 +5,7 @@ import { prisma } from "@/lib/prisma"; import { idSchema } from "@tomodachi-share/shared/schemas"; import { RateLimit } from "@/lib/rate-limit"; -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/mii/list/route.ts b/backend/src/app/api/mii/list/route.ts index ba90a2a..7a4caef 100644 --- a/backend/src/app/api/mii/list/route.ts +++ b/backend/src/app/api/mii/list/route.ts @@ -89,6 +89,10 @@ export async function GET(request: NextRequest) { _count: { select: { likedBy: true }, }, + // Admin + ...(parentPage === "admin" && { + description: true, + }), }; let totalCount: number; diff --git a/backend/src/app/api/return/route.ts b/backend/src/app/api/return/route.ts index 6da81b6..a83887c 100644 --- a/backend/src/app/api/return/route.ts +++ b/backend/src/app/api/return/route.ts @@ -4,7 +4,7 @@ import { auth } from "@/lib/auth"; import { RateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/prisma"; -export async function DELETE(request: NextRequest) { +export async function POST(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/backend/src/app/api/submit/route.ts b/backend/src/app/api/submit/route.ts index c062842..178d776 100644 --- a/backend/src/app/api/submit/route.ts +++ b/backend/src/app/api/submit/route.ts @@ -18,7 +18,7 @@ import Mii from "../../../../../shared/src/mii.js/mii"; import { convertQrCode, minifyInstructions, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared"; import { SwitchMiiInstructions } from "@tomodachi-share/shared"; -import { settings } from "@/lib/settings"; +import { settings } from "../../../lib/settings"; const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); diff --git a/backend/src/app/out/page.tsx b/backend/src/app/out/page.tsx deleted file mode 100644 index d72c91b..0000000 --- a/backend/src/app/out/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Metadata } from "next"; -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { Icon } from "@iconify/react"; - -export const metadata: Metadata = { - title: "Leaving TomodachiShare", - description: "Warning: You are leaving TomodachiShare, proceed with caution", -}; - -interface Props { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -} - -export default async function LinkOutPage({ searchParams }: Props) { - const url = (await searchParams).url; - if (!url || Array.isArray(url)) redirect("/"); - - let parsed: URL; - try { - parsed = new URL(url); - } catch { - redirect("/"); // redirect if URL is invalid - } - - // Next.js doesn't allow attacks like these but you can never be too safe - if (!["http:", "https:"].includes(parsed.protocol)) redirect("/"); - - const isSafe = Array.from(SAFE_LINKS).some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`)); - if (isSafe) redirect(url); - - return ( -
-
-

- - Warning -

-

You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.

- -
- {url} -
- -
- - - Travel Back - - - - Continue - -
-
-
- ); -} - -const SAFE_LINKS = new Set([ - "tomodachishare.com", - "trafficlunar.net", - "youtube.com", - "youtu.be", - "twitter.com", - "x.com", - "reddit.com", - "tiktok.com", - "tumblr.com", - "instagram.com", - "wikipedia.org", -]); diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 9a04006..71f2774 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -18,6 +18,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ sameSite: "none", path: "/", secure: true, + domain: process.env.NODE_ENV === "production" ? ".tomodachishare.com" : "localhost", }, }, }, diff --git a/backend/src/lib/images.tsx b/backend/src/lib/images.tsx index 373d1e3..61b8ac7 100644 --- a/backend/src/lib/images.tsx +++ b/backend/src/lib/images.tsx @@ -190,7 +190,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{ {/* Watermark */}
- + {/* I tried using text-orange-400 but it wasn't correct..? */} TomodachiShare diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..58309aa --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/admin/banner-form.tsx b/frontend/src/components/admin/banner-form.tsx index f6a93ea..d9c02b0 100644 --- a/frontend/src/components/admin/banner-form.tsx +++ b/frontend/src/components/admin/banner-form.tsx @@ -1,25 +1,26 @@ -import { useState } from "react"; - -export default function BannerForm() { - const [message, setMessage] = useState(""); - - const onClickClear = async () => { - await fetch("/api/admin/banner", { method: "DELETE" }); - }; - - const onClickSet = async () => { - await fetch("/api/admin/banner", { method: "POST", body: message }); - }; - - return ( -
- setMessage(e.target.value)} /> - - -
- ); -} +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_URL}/api/admin/banner`, { method: "DELETE", credentials: "include" }); // TODO + }; + + const onClickSet = async () => { + await fetch(`${API_URL}/api/admin/banner`, { method: "POST", body: message, credentials: "include" }); + }; + + return ( +
+ setMessage(e.target.value)} /> + + +
+ ); +} diff --git a/frontend/src/components/admin/banner.tsx b/frontend/src/components/admin/banner.tsx index 0b00c63..4ac02b5 100644 --- a/frontend/src/components/admin/banner.tsx +++ b/frontend/src/components/admin/banner.tsx @@ -1,65 +1,68 @@ -// import { useSearchParams } from "next/navigation"; -// import { Suspense, useEffect, useState } from "react"; - -// import useSWR from "swr"; -// import { Icon } from "@iconify/react"; - -// interface ApiResponse { -// message: string; -// } - -// const fetcher = (url: string) => fetch(url).then((res) => res.json()); - -// function RedirectBanner() { -// const searchParams = useSearchParams(); -// const from = searchParams.get("from"); -// if (from !== "old-domain") return null; - -// return ( -//
-// -// We have moved URLs, welcome to tomodachishare.com! -//
-// ); -// } - -// export default function AdminBanner() { -// const { data } = useSWR("/api/admin/banner", fetcher); -// const [shouldShow, setShouldShow] = useState(true); - -// useEffect(() => { -// if (!data?.message) return; - -// // Check if the current banner text was closed by the user -// const closedBanner = window.localStorage.getItem("closedBanner"); -// setShouldShow(data.message !== closedBanner); -// }, [data]); - -// const handleClose = () => { -// if (!data) return; - -// // Close banner and remember it -// window.localStorage.setItem("closedBanner", data.message); -// setShouldShow(false); -// }; - -// return ( -// <> -// {data && data.message && shouldShow && ( -//
-//
-// -// {data.message} -//
- -// -//
-// )} -// -// -// -// -// ); -// } +import { useEffect, useState } from "react"; +import { Icon } from "@iconify/react"; +import { useSearchParams } from "react-router"; + +interface ApiResponse { + message: string; +} + +function RedirectBanner() { + const [searchParams] = useSearchParams(); + const from = searchParams.get("from"); + if (from !== "old-domain") return null; + + return ( +
+ + We have moved URLs, welcome to tomodachishare.com! +
+ ); +} + +export default function AdminBanner() { + const [message, setMessage] = useState(null); + const [shouldShow, setShouldShow] = useState(false); + + 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; + }) + .then((data) => { + if (!data.message) return; + const closedBanner = localStorage.getItem("closedBanner"); + setMessage(data.message); + setShouldShow(data.message !== closedBanner); + }) + .catch((err) => { + console.error(err); + }); + }, []); + + const handleClose = () => { + if (!message) return; + + // Close banner and remember it + localStorage.setItem("closedBanner", message); + setShouldShow(false); + }; + + return ( + <> + {shouldShow && message && ( +
+
+ + {message} +
+ + +
+ )} + + + ); +} diff --git a/frontend/src/components/admin/control-center.tsx b/frontend/src/components/admin/control-center.tsx index 4616a07..320690d 100644 --- a/frontend/src/components/admin/control-center.tsx +++ b/frontend/src/components/admin/control-center.tsx @@ -1,45 +1,45 @@ -// import { settings } from "@/lib/settings"; -// import { useState } from "react"; - -// export default function ControlCenter() { -// const [canSubmit, setCanSubmit] = useState(settings.canSubmit); -// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled); - -// const onClickSet = async () => { -// await fetch("/api/admin/can-submit", { method: "PATCH", body: JSON.stringify(canSubmit) }); -// await fetch("/api/admin/queue", { method: "PATCH", body: JSON.stringify(isQueueEnabled) }); -// }; - -// return ( -//
-//
-// setCanSubmit(e.target.checked)} -// /> -// -//
-//
-// setIsQeueueEnabled(e.target.checked)} -// /> -// -//
- -//
-// -//
-//
-// ); -// } +// import { settings } from "@/lib/settings"; +// import { useState } from "react"; + +// export default function ControlCenter() { +// const [canSubmit, setCanSubmit] = useState(settings.canSubmit); +// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled); + +// const onClickSet = async () => { +// await fetch("/api/admin/can-submit", { method: "POST", body: JSON.stringify(canSubmit) }); +// await fetch("/api/admin/queue", { method: "POST", body: JSON.stringify(isQueueEnabled) }); +// }; + +// return ( +//
+//
+// setCanSubmit(e.target.checked)} +// /> +// +//
+//
+// setIsQeueueEnabled(e.target.checked)} +// /> +// +//
+ +//
+// +//
+//
+// ); +// } diff --git a/frontend/src/components/admin/regenerate-images.tsx b/frontend/src/components/admin/regenerate-images.tsx index fda6a53..8e15372 100644 --- a/frontend/src/components/admin/regenerate-images.tsx +++ b/frontend/src/components/admin/regenerate-images.tsx @@ -1,84 +1,84 @@ -import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; - -import { Icon } from "@iconify/react"; -import SubmitButton from "../submit-button"; - -export default function RegenerateImagesButton() { - const [isOpen, setIsOpen] = useState(false); - const [isVisible, setIsVisible] = useState(false); - - const [error, setError] = useState(undefined); - - const handleSubmit = async () => { - const response = await fetch("/api/admin/regenerate-metadata-images", { method: "PATCH" }); - - if (!response.ok) { - const data = await response.json(); - setError(data.error); - - return; - } - - close(); - }; - - const close = () => { - setIsVisible(false); - setTimeout(() => { - setIsOpen(false); - }, 300); - }; - - useEffect(() => { - if (isOpen) { - // slight delay to trigger animation - setTimeout(() => setIsVisible(true), 10); - } - }, [isOpen]); - - return ( - <> - - - {isOpen && - createPortal( -
-
- -
-
-

Regenerate Images

- -
- -

Are you sure? This will delete and regenerate every metadata image.

- - {error && Error: {error}} - -
- - -
-
-
, - document.body, - )} - - ); -} +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import { Icon } from "@iconify/react"; +import SubmitButton from "../submit-button"; + +export default function RegenerateImagesButton() { + const [isOpen, setIsOpen] = useState(false); + const [isVisible, setIsVisible] = useState(false); + + const [error, setError] = useState(undefined); + + const handleSubmit = async () => { + const response = await fetch("/api/admin/regenerate-metadata-images", { method: "POST" }); + + if (!response.ok) { + const data = await response.json(); + setError(data.error); + + return; + } + + close(); + }; + + const close = () => { + setIsVisible(false); + setTimeout(() => { + setIsOpen(false); + }, 300); + }; + + useEffect(() => { + if (isOpen) { + // slight delay to trigger animation + setTimeout(() => setIsVisible(true), 10); + } + }, [isOpen]); + + return ( + <> + + + {isOpen && + createPortal( +
+
+ +
+
+

Regenerate Images

+ +
+ +

Are you sure? This will delete and regenerate every metadata image.

+ + {error && Error: {error}} + +
+ + +
+
+
, + document.body, + )} + + ); +} diff --git a/frontend/src/components/admin/return-to-island.tsx b/frontend/src/components/admin/return-to-island.tsx index 528f8d2..868d2ee 100644 --- a/frontend/src/components/admin/return-to-island.tsx +++ b/frontend/src/components/admin/return-to-island.tsx @@ -11,7 +11,7 @@ // const [error, setError] = useState(undefined); // const handleClick = async () => { -// const response = await fetch("/api/return", { method: "DELETE" }); +// const response = await fetch("/api/return", { method: "POST" }); // if (!response.ok) { // const data = await response.json(); diff --git a/frontend/src/components/header-profile.tsx b/frontend/src/components/header-profile.tsx deleted file mode 100644 index 4853946..0000000 --- a/frontend/src/components/header-profile.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Icon } from "@iconify/react"; -import { useEffect } from "react"; -import { useStore } from "@nanostores/react"; -import { session } from "../session"; -import { Link } from "react-router"; - -export default function HeaderProfile() { - const API_BASE_URL = import.meta.env.VITE_API_URL; - const $session = useStore(session); - - useEffect(() => { - fetch(`${API_BASE_URL}/api/auth/session`, { credentials: "include" }) - .then((res) => { - if (!res.ok) throw new Error("Failed to get session"); - return res.json(); - }) - .then((data) => { - session.set(data); - }) - .catch((err) => { - console.error(err); - }); - }, []); - - return ( - <> - {!$session?.user ? ( -
  • - - Login - -
  • - ) : ( - <> -
  • - - profile picture - {$session?.user?.name ?? "unknown"} - -
  • -
  • - - - -
  • - - )} - - ); -} diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index 0c515bf..c3d8493 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -1,9 +1,27 @@ import { Icon } from "@iconify/react"; import SearchBar from "./search-bar"; -import HeaderProfile from "./header-profile"; import { Link } from "react-router"; +import { useStore } from "@nanostores/react"; +import { session } from "../session"; +import { useEffect } from "react"; export default function Header() { + const $session = useStore(session); + + useEffect(() => { + fetch(`${import.meta.env.VITE_API_URL}/api/auth/session`, { credentials: "include" }) + .then((res) => { + if (!res.ok) throw new Error("Failed to get session"); + return res.json(); + }) + .then((data) => { + session.set(data); + }) + .catch((err) => { + console.error(err); + }); + }, []); + return (
    - + {!$session?.user ? ( +
  • + + Login + +
  • + ) : ( + <> +
  • + + { + e.currentTarget.onerror = null; // Prevent infinite loops + e.currentTarget.src = "/guest.png"; + }} + alt="profile picture" + width={40} + height={40} + className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400" + /> + {$session?.user?.name ?? "unknown"} + +
  • +
  • + + + +
  • + + )}
    ); diff --git a/frontend/src/components/like-button.tsx b/frontend/src/components/like-button.tsx index 9082918..410d100 100644 --- a/frontend/src/components/like-button.tsx +++ b/frontend/src/components/like-button.tsx @@ -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: "PATCH" }); - // 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 diff --git a/frontend/src/components/mii/author-buttons.tsx b/frontend/src/components/mii/author-buttons.tsx index c7f4840..7af8efc 100644 --- a/frontend/src/components/mii/author-buttons.tsx +++ b/frontend/src/components/mii/author-buttons.tsx @@ -1,16 +1,18 @@ import { Icon } from "@iconify/react"; import DeleteMiiButton from "./delete-mii-button"; import { Link } from "react-router"; +import { useStore } from "@nanostores/react"; +import { session } from "../../session"; interface Props { mii: any; } export default function AuthorButtons({ mii }: Props) { - // const session = useSession(); - - // if (!session.data || (Number(session.data.user?.id) !== mii.userId && Number(session.data.user?.id) !== Number(import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID))) - // return null; + const $session = useStore(session); + if ($session === undefined) return null; + if ($session === null) return null; + if (Number($session?.user?.id) !== mii.userId && Number($session?.user?.id) !== Number(import.meta.env.VITE_ADMIN_USER_ID)) return null; return ( <> diff --git a/frontend/src/components/mii/delete-mii-button.tsx b/frontend/src/components/mii/delete-mii-button.tsx index 3820466..199ae3b 100644 --- a/frontend/src/components/mii/delete-mii-button.tsx +++ b/frontend/src/components/mii/delete-mii-button.tsx @@ -22,7 +22,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr const [inputMiiName, setInputMiiName] = useState(""); const handleSubmit = async () => { - const response = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/${miiId}/delete`, { method: "DELETE", credentials: "include" }); + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/${miiId}/delete`, { method: "POST", credentials: "include" }); if (!response.ok) { const { error } = await response.json(); setError(error); diff --git a/frontend/src/components/mii/list/index.tsx b/frontend/src/components/mii/list/index.tsx index 5cfaa97..4c9f84e 100644 --- a/frontend/src/components/mii/list/index.tsx +++ b/frontend/src/components/mii/list/index.tsx @@ -9,7 +9,7 @@ import { Icon } from "@iconify/react"; import LikeButton from "../../like-button"; import { useStore } from "@nanostores/react"; import { session } from "../../../session"; -import Carousel from "../../carousel"; +import Description from "../../description"; interface ApiResponse { totalCount: number; @@ -26,6 +26,8 @@ export default function MiiList({ parentPage, userId }: Props) { const [searchParams] = useSearchParams(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const [acceptingAll, setAcceptingAll] = useState(false); + const [likedIds, setLikedIds] = useState>(new Set()); const $session = useStore(session); @@ -39,15 +41,46 @@ 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; + setAcceptingAll(true); + try { + await Promise.all( + data.miis.map((mii) => + fetch(`${import.meta.env.VITE_API_URL}/api/admin/accept-mii?id=${mii.id}`, { + method: "POST", + credentials: "include", + }), + ), + ); + const params = new URLSearchParams(searchParams.toString()); + if (userId) params.append("userId", userId.toString()); + if (parentPage) params.append("parentPage", parentPage); + const res = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/list?${params.toString()}`, { credentials: "include" }); + if (res.ok) setData(await res.json()); + } finally { + setAcceptingAll(false); + } + } return ( <> @@ -62,6 +95,16 @@ export default function MiiList({ parentPage, userId }: Props) {
    + {parentPage === "admin" && data.miis.length > 0 && ( + + )}
    @@ -80,21 +123,29 @@ export default function MiiList({ parentPage, userId }: Props) {
    )} - {parentPage !== "admin" ? - mii image - : `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=image${index}`), - ]} - />} + {parentPage !== "admin" ? ( + + mii image + + ) : ( +
    + {[ + `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`, + mii.platform === "THREE_DS" + ? `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=qr-code` + : `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=features`, + ...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=image${i}`), + ].map((src, i) => ( + mii image + ))} +
    + )}
    @@ -109,6 +160,7 @@ export default function MiiList({ parentPage, userId }: Props) { )}
    +
    {mii.tags.map((tag: string) => ( @@ -117,8 +169,10 @@ export default function MiiList({ parentPage, userId }: Props) { ))}
    + {parentPage === "admin" && mii.description && } +
    - + {!userId && ( @@ -135,13 +189,12 @@ export default function MiiList({ parentPage, userId }: Props) {
    )} - {/* Admin Controls */} {parentPage === "admin" && (