mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 06:34:15 +00:00
refactor: move components directory into src
This commit is contained in:
parent
eef495e809
commit
ce7a57baef
34 changed files with 16 additions and 17 deletions
95
src/components/carousel.tsx
Normal file
95
src/components/carousel.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import ImageViewer from "./image-viewer";
|
||||
|
||||
interface Props {
|
||||
images: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Carousel({ images, className }: Props) {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
setScrollSnaps(emblaApi.scrollSnapList());
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [images, emblaApi]);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
if (!isFocused || !emblaApi) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
emblaApi.scrollPrev();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
emblaApi.scrollNext();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isFocused, emblaApi]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-fit" tabIndex={0} onMouseEnter={() => setIsFocused(true)} onMouseLeave={() => setIsFocused(false)}>
|
||||
<div className={`overflow-hidden rounded-xl bg-zinc-300 border-2 border-zinc-300 ${className ?? ""}`} ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{images.map((src, index) => (
|
||||
<div key={index} className="flex-[0_0_100%]">
|
||||
<ImageViewer src={src} alt="mii image" width={480} height={320} className="w-full h-auto aspect-[3/2] object-contain" images={images} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={!emblaApi?.canScrollPrev()}
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
|
||||
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={!emblaApi?.canScrollNext()}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
|
||||
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
|
||||
<div className="flex justify-center gap-2 absolute left-1/2 -translate-x-1/2 bottom-2">
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-1.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/components/delete-mii.tsx
Normal file
105
src/components/delete-mii.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import LikeButton from "./like-button";
|
||||
|
||||
interface Props {
|
||||
miiId: number;
|
||||
miiName: string;
|
||||
likes: number;
|
||||
}
|
||||
|
||||
export default function DeleteMiiButton({ miiId, miiName, likes }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const submit = async () => {
|
||||
const response = await fetch(`/api/mii/${miiId}/delete`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
window.location.reload(); // I would use router.refresh() here but the Mii list doesn't update
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} className="cursor-pointer">
|
||||
<Icon icon="mdi:trash" />
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Delete Mii</h2>
|
||||
<button onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">Are you sure? This will delete your Mii permanently. This action cannot be undone.</p>
|
||||
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex">
|
||||
<Image src={`/mii/${miiId}/mii.webp`} alt="mii image" width={128} height={128} />
|
||||
<div className="p-4">
|
||||
<p className="text-xl font-bold line-clamp-1" title={miiName}>
|
||||
{miiName}
|
||||
</p>
|
||||
<LikeButton likes={likes} isLiked={true} disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={submit} className="pill button !bg-red-400 !border-red-500 hover:!bg-red-500">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
src/components/footer.tsx
Normal file
23
src/components/footer.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import Link from "next/link";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-auto text-xs flex flex-col justify-center gap-y-0.5 gap-2 text-black/50 p-8 *:text-center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<span>tomodachishare is not affiliated with nintendo</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-2">
|
||||
<Link href="/terms-of-service">terms of service</Link>
|
||||
<span>•</span>
|
||||
<Link href="/privacy">privacy</Link>
|
||||
<span>•</span>
|
||||
<a href="https://github.com/trafficlunar/tomodachi-share">source code</a>
|
||||
<span>•</span>
|
||||
<a href="https://trafficlunar.net">
|
||||
made by <span className="text-orange-400">trafficlunar</span>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
49
src/components/header.tsx
Normal file
49
src/components/header.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import Link from "next/link";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
import SearchBar from "./search-bar";
|
||||
import ProfileOverview from "./profile-overview";
|
||||
import LogoutButton from "./logout-button";
|
||||
|
||||
export default async function Header() {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-50 w-full p-4 grid grid-cols-3 gap-2 gap-x-4 items-center bg-amber-50 border-b-4 border-amber-500 shadow-md max-lg:grid-cols-2 max-sm:grid-cols-1">
|
||||
<Link href={"/"} className="font-black text-3xl tracking-wide text-orange-400 max-sm:text-center max-sm:col-span-3">
|
||||
TomodachiShare
|
||||
</Link>
|
||||
|
||||
<div className="flex justify-center max-lg:justify-end max-sm:col-span-3 max-sm:justify-center">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
<ul className="flex justify-end gap-3 items-center h-11 *:h-full max-lg:col-span-2 max-sm:justify-center max-sm:col-span-3">
|
||||
<li title="Random Mii">
|
||||
<Link href={"/random"} className="pill button !p-0 h-full aspect-square">
|
||||
<Icon icon="mdi:dice-3" fontSize={28} />
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={"/submit"} className="pill button h-full">
|
||||
Submit
|
||||
</Link>
|
||||
</li>
|
||||
{!session?.user ? (
|
||||
<li>
|
||||
<Link href={"/login"} className="pill button h-full">
|
||||
Login
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<ProfileOverview />
|
||||
<LogoutButton />
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/components/image-viewer.tsx
Normal file
181
src/components/image-viewer.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
// Keep order of images whilst opening on src
|
||||
const index = images.indexOf(src);
|
||||
if (index !== -1) {
|
||||
emblaApi.scrollTo(index);
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
|
||||
// Scroll snaps
|
||||
setScrollSnaps(emblaApi.scrollSnapList());
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi, images, src]);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
if (!isOpen || !emblaApi) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
emblaApi.scrollPrev();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
emblaApi.scrollNext();
|
||||
break;
|
||||
case "Escape":
|
||||
close();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, emblaApi]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image src={src} alt={alt} width={width} height={height} className={`cursor-pointer ${className}`} onClick={() => setIsOpen(true)} />
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl mx-4 shadow-lg w-full max-w-xl relative transition-discrete duration-300 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="z-50 absolute right-0 bg-amber-500 rounded-tr-xl rounded-bl-md p-1 flex justify-between items-center">
|
||||
<button onClick={close} className="text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl" ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{images.length == 0 ? (
|
||||
<Image src={src} alt={alt} width={576} height={576} className="w-full" />
|
||||
) : (
|
||||
<>
|
||||
{images.map((image, index) => (
|
||||
<div key={index} className="flex-[0_0_100%]">
|
||||
<Image src={image} alt={alt} width={576} height={576} className="w-full" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length != 0 && (
|
||||
<>
|
||||
{/* Carousel buttons */}
|
||||
{/* Prev button */}
|
||||
<div
|
||||
className={`z-50 absolute left-2 top-1/2 -translate-y-1/2 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={!emblaApi?.canScrollPrev()}
|
||||
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
|
||||
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Next button */}
|
||||
<div
|
||||
className={`z-50 absolute right-2 top-1/2 -translate-y-1/2 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={!emblaApi?.canScrollNext()}
|
||||
className={`bg-white p-1 rounded-full shadow text-4xl transition-opacity ${
|
||||
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Carousel snaps */}
|
||||
<div
|
||||
className={`z-50 flex justify-center gap-3 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-2.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
src/components/like-button.tsx
Normal file
47
src/components/like-button.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { abbreviateNumber } from "@/lib/abbreviation";
|
||||
|
||||
interface Props {
|
||||
likes: number;
|
||||
miiId?: number | undefined;
|
||||
isLiked: boolean;
|
||||
isLoggedIn?: boolean;
|
||||
disabled?: boolean;
|
||||
abbreviate?: boolean;
|
||||
big?: boolean;
|
||||
}
|
||||
|
||||
export default function LikeButton({ likes, isLiked, miiId, isLoggedIn, disabled, abbreviate, big }: Props) {
|
||||
const [isLikedState, setIsLikedState] = useState(isLiked);
|
||||
const [likesState, setLikesState] = useState(likes);
|
||||
|
||||
const onClick = async () => {
|
||||
if (disabled) return;
|
||||
if (!isLoggedIn) redirect("/login");
|
||||
|
||||
setIsLikedState(!isLikedState);
|
||||
setLikesState(isLikedState ? likesState - 1 : likesState + 1);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className={`flex items-center gap-2 text-red-400 ${disabled ? "" : "cursor-pointer"} ${big ? "text-3xl" : "text-xl"}`}>
|
||||
<Icon icon={isLikedState ? "icon-park-solid:like" : "icon-park-outline:like"} />
|
||||
<span>{abbreviate ? abbreviateNumber(likesState) : likesState}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
25
src/components/login-buttons.tsx
Normal file
25
src/components/login-buttons.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react/dist/iconify.js";
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
export default function LoginButtons() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
onClick={() => signIn("discord", { redirectTo: "/create-username" })}
|
||||
className="pill button gap-2 !px-3 !bg-indigo-400 !border-indigo-500 hover:!bg-indigo-500"
|
||||
>
|
||||
<Icon icon="ic:baseline-discord" fontSize={32} />
|
||||
Login with Discord
|
||||
</button>
|
||||
<button
|
||||
onClick={() => signIn("github", { redirectTo: "/create-username" })}
|
||||
className="pill button gap-2 !px-3 !bg-zinc-700 !border-zinc-800 hover:!bg-zinc-800 text-white"
|
||||
>
|
||||
<Icon icon="mdi:github" fontSize={32} />
|
||||
Login with GitHub
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/components/logout-button.tsx
Normal file
14
src/components/logout-button.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
export default function LogoutButton() {
|
||||
return (
|
||||
<li title="Logout">
|
||||
<button onClick={() => signOut()} className="pill button !p-0 aspect-square h-full">
|
||||
<Icon icon="ic:round-logout" fontSize={24} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
53
src/components/mii-list/filter-select.tsx
Normal file
53
src/components/mii-list/filter-select.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { redirect, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import TagSelector from "../tag-selector";
|
||||
|
||||
export default function FilterSelect() {
|
||||
const searchParams = useSearchParams();
|
||||
const rawTags = searchParams.get("tags");
|
||||
const preexistingTags = rawTags
|
||||
? rawTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [];
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tags, setTags] = useState<string[]>(preexistingTags);
|
||||
|
||||
const handleSubmit = () => {
|
||||
redirect(`/?tags=${encodeURIComponent(tags.join(","))}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button onClick={() => setIsOpen((prev) => !prev)} className="pill button gap-1 text-nowrap">
|
||||
Filter{" "}
|
||||
{tags.length > 0 ? (
|
||||
<span>
|
||||
({tags.length} {tags.length == 1 ? "filter" : "filters"})
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`absolute z-40 left-1/2 -translate-x-1/2 w-96 bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg flex flex-col justify-between gap-2 p-2 max-[32rem]:-left-8 max-[32rem]:w-80 max-[32rem]:translate-x-0 ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<label className="text-sm ml-2">Tags</label>
|
||||
<TagSelector tags={tags} setTags={setTags} />
|
||||
</div>
|
||||
|
||||
<button onClick={handleSubmit} className="pill button text-sm !px-3 !py-0.5 w-min">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/components/mii-list/index.tsx
Normal file
140
src/components/mii-list/index.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import Skeleton from "./skeleton";
|
||||
import FilterSelect from "./filter-select";
|
||||
import SortSelect from "./sort-select";
|
||||
import Carousel from "../carousel";
|
||||
import LikeButton from "../like-button";
|
||||
import DeleteMiiButton from "../delete-mii";
|
||||
import Pagination from "./pagination";
|
||||
|
||||
interface Props {
|
||||
isLoggedIn: boolean;
|
||||
// Profiles
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
total: number;
|
||||
filtered: number;
|
||||
lastPage: number;
|
||||
miis: {
|
||||
id: number;
|
||||
user?: {
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
name: string;
|
||||
imageCount: number;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
likes: number;
|
||||
isLiked: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
export default function MiiList({ isLoggedIn, userId }: Props) {
|
||||
const searchParams = useSearchParams();
|
||||
const { data, error } = useSWR<ApiResponse>(`/api/mii/list?${searchParams.toString()}`, fetcher);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
|
||||
<p className="text-lg">
|
||||
{data ? (
|
||||
data.total == data.filtered ? (
|
||||
<>
|
||||
<span className="font-extrabold">{data.total}</span> Miis
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-extrabold">{data.filtered}</span> of <span className="font-extrabold">{data.total}</span> Miis
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="font-extrabold">0</span> Miis
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<FilterSelect />
|
||||
<SortSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data ? (
|
||||
data.miis.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2 max-[25rem]:grid-cols-1">
|
||||
{data.miis.map((mii) => (
|
||||
<div
|
||||
key={mii.id}
|
||||
className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600"
|
||||
>
|
||||
<Carousel
|
||||
images={[
|
||||
`/mii/${mii.id}/mii.webp`,
|
||||
`/mii/${mii.id}/qr-code.webp`,
|
||||
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image${index}.webp`),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
<Link href={`/mii/${mii.id}`} className="font-bold text-2xl line-clamp-1" title={mii.name}>
|
||||
{mii.name}
|
||||
</Link>
|
||||
<div id="tags" className="flex flex-wrap gap-1">
|
||||
{mii.tags.map((tag) => (
|
||||
<Link href={{ query: { tags: tag } }} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto grid grid-cols-2 items-center">
|
||||
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={isLoggedIn} abbreviate />
|
||||
|
||||
{!userId ? (
|
||||
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
|
||||
@{mii.user?.username}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
|
||||
<Link href={`/edit/${mii.id}`}>
|
||||
<Icon icon="mdi:pencil" />
|
||||
</Link>
|
||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xl font-semibold text-center mt-10">No results found.</p>
|
||||
)
|
||||
) : error ? (
|
||||
<p className="text-xl text-red-400 font-semibold text-center mt-10">Error: {error}</p>
|
||||
) : (
|
||||
// Show skeleton when data is loading
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2 max-[25rem]:grid-cols-1">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<Skeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && <Pagination lastPage={data.lastPage} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/components/mii-list/pagination.tsx
Normal file
92
src/components/mii-list/pagination.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
lastPage: number;
|
||||
}
|
||||
|
||||
export default function Pagination({ lastPage }: Props) {
|
||||
const searchParams = useSearchParams();
|
||||
const page = Number(searchParams.get("page") ?? 1);
|
||||
|
||||
const numbers = useMemo(() => {
|
||||
const result = [];
|
||||
|
||||
// Always show 5 pages, centering around the current page when possible
|
||||
const start = Math.max(1, Math.min(page - 2, lastPage - 4));
|
||||
const end = Math.min(lastPage, start + 4);
|
||||
|
||||
for (let i = start; i <= end; i++) result.push(i);
|
||||
|
||||
return result;
|
||||
}, [page, lastPage]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full mt-8">
|
||||
{/* Go to first page */}
|
||||
<Link
|
||||
href={page === 1 ? "#" : "/?page=1"}
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
|
||||
page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-left" />
|
||||
</Link>
|
||||
|
||||
{/* Previous page */}
|
||||
<Link
|
||||
href={page === 1 ? "#" : `/?page=${page - 1}`}
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill !bg-orange-100 !p-0.5 aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-left" />
|
||||
</Link>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div className="flex mx-2">
|
||||
{numbers.map((number) => (
|
||||
<Link
|
||||
key={number}
|
||||
href={`/?page=${number}`}
|
||||
aria-current={number === page ? "page" : undefined}
|
||||
className={`pill !p-0 w-8 h-8 text-center !rounded-md ${number == page ? "!bg-orange-400" : "!bg-orange-100 hover:!bg-orange-400"}`}
|
||||
>
|
||||
{number}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Next page */}
|
||||
<Link
|
||||
href={page === lastPage ? "#" : `/?page=${page + 1}`}
|
||||
aria-disabled={page === lastPage}
|
||||
tabIndex={page === lastPage ? -1 : undefined}
|
||||
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
|
||||
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-right" />
|
||||
</Link>
|
||||
|
||||
{/* Go to last page */}
|
||||
<Link
|
||||
href={page === lastPage ? "#" : `/?page=${lastPage}`}
|
||||
aria-disabled={page === lastPage}
|
||||
tabIndex={page === lastPage ? -1 : undefined}
|
||||
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
|
||||
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-right" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/mii-list/skeleton.tsx
Normal file
28
src/components/mii-list/skeleton.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export default function Skeleton() {
|
||||
return (
|
||||
<div className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 animate-pulse">
|
||||
{/* Carousel Skeleton */}
|
||||
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
|
||||
<div className="aspect-[3/2]"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
{/* Name */}
|
||||
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" />
|
||||
</div>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="mt-0.5 grid grid-cols-2 items-center">
|
||||
<div className="h-6 w-12 bg-red-200 rounded" />
|
||||
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/mii-list/sort-select.tsx
Normal file
54
src/components/mii-list/sort-select.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useSelect } from "downshift";
|
||||
import { redirect, useSearchParams } from "next/navigation";
|
||||
|
||||
type Sort = "likes" | "newest";
|
||||
|
||||
const items = ["likes", "newest"];
|
||||
|
||||
export default function SortSelect() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentSort = (searchParams.get("sort") as Sort) || "newest";
|
||||
|
||||
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
|
||||
items,
|
||||
selectedItem: currentSort,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
if (!selectedItem) return;
|
||||
redirect(`?sort=${selectedItem}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/* Toggle button to open the dropdown */}
|
||||
<button type="button" {...getToggleButtonProps()} className="pill input w-full gap-1 !justify-between text-nowrap">
|
||||
<span>Sort by </span>
|
||||
{selectedItem || "Select a way to sort"}
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isOpen &&
|
||||
items.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/components/profile-overview.tsx
Normal file
22
src/components/profile-overview.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import Image from "next/image";
|
||||
import { auth } from "@/lib/auth";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function ProfileOverview() {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<li title="Your profile">
|
||||
<Link href={`/profile/${session?.user.id}`} className="pill button !gap-2 !p-0 h-full max-w-64">
|
||||
<Image
|
||||
src={session?.user?.image ?? "/missing.webp"}
|
||||
alt="profile picture"
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full aspect-square object-cover h-full outline-2 outline-orange-400"
|
||||
/>
|
||||
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.username ?? "unknown"}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
91
src/components/profile-settings/delete-account.tsx
Normal file
91
src/components/profile-settings/delete-account.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function DeleteAccount() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const submit = async () => {
|
||||
const response = await fetch("/api/auth/delete", { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
redirect("/404");
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
name="deletion"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="pill button w-fit h-min ml-auto !bg-red-400 !border-red-500 hover:!bg-red-500"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Delete Account</h2>
|
||||
<button onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">
|
||||
Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={submit} className="pill button !bg-red-400 !border-red-500 hover:!bg-red-500">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
173
src/components/profile-settings/index.tsx
Normal file
173
src/components/profile-settings/index.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { displayNameSchema, usernameSchema } from "@/lib/schemas";
|
||||
|
||||
import SubmitDialogButton from "./submit-dialog-button";
|
||||
import DeleteAccount from "./delete-account";
|
||||
|
||||
export default function ProfileSettings() {
|
||||
const router = useRouter();
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
|
||||
const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined);
|
||||
const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined);
|
||||
|
||||
const usernameDate = dayjs().add(90, "days");
|
||||
|
||||
const handleSubmitDisplayNameChange = async (close: () => void) => {
|
||||
const parsed = displayNameSchema.safeParse(displayName);
|
||||
if (!parsed.success) {
|
||||
setDisplayNameChangeError(parsed.error.errors[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/auth/display-name", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setDisplayNameChangeError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleSubmitUsernameChange = async (close: () => void) => {
|
||||
const parsed = usernameSchema.safeParse(username);
|
||||
if (!parsed.success) {
|
||||
setUsernameChangeError(parsed.error.errors[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/auth/username", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setUsernameChangeError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Profile Settings</h2>
|
||||
<p className="text-sm text-zinc-500">Update your account info, and username.</p>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Account Info</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
{/* Change Name */}
|
||||
<div className="grid grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="deletion" className="font-semibold">
|
||||
Change Display Name
|
||||
</label>
|
||||
<p className="text-sm text-zinc-500">This is a display name shown on your profile — feel free to change it anytime</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-1">
|
||||
<input
|
||||
type="text"
|
||||
className="pill input w-full max-w-64"
|
||||
placeholder="Type here..."
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<SubmitDialogButton
|
||||
title="Confirm Display Name Change"
|
||||
description="Update your display name? This will only be visible on your profile. You can change it again later."
|
||||
error={displayNameChangeError}
|
||||
onSubmit={handleSubmitDisplayNameChange}
|
||||
>
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 px-2 py-1">
|
||||
<p className="font-semibold">New display name:</p>
|
||||
<p className="indent-4">'{displayName}'</p>
|
||||
</div>
|
||||
</SubmitDialogButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Username */}
|
||||
<div className="grid grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="deletion" className="font-semibold">
|
||||
Change Username
|
||||
</label>
|
||||
<p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className="pill input w-full max-w-64 indent-4"
|
||||
placeholder="Type here..."
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<span className="absolute top-1/2 -translate-y-1/2 left-4 select-none">@</span>
|
||||
</div>
|
||||
<SubmitDialogButton
|
||||
title="Confirm Username Change"
|
||||
description="Are you sure? Your username is your unique indentifier and can only be changed every 90 days."
|
||||
error={usernameChangeError}
|
||||
onSubmit={handleSubmitUsernameChange}
|
||||
>
|
||||
<p className="text-sm text-zinc-500 mt-2">
|
||||
After submitting, you can change it again on{" "}
|
||||
{usernameDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.
|
||||
</p>
|
||||
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 px-2 py-1">
|
||||
<p className="font-semibold">New username:</p>
|
||||
<p className="indent-4">'@{username}'</p>
|
||||
</div>
|
||||
</SubmitDialogButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Danger Zone</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
{/* Delete Account */}
|
||||
<div className="grid grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="deletion" className="font-semibold">
|
||||
Delete Account
|
||||
</label>
|
||||
<p className="text-sm text-zinc-500">This will permanently remove your account and all uploaded Miis. This action cannot be undone</p>
|
||||
</div>
|
||||
|
||||
<DeleteAccount />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/profile-settings/submit-dialog-button.tsx
Normal file
84
src/components/profile-settings/submit-dialog-button.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
onSubmit: (close: () => void) => void;
|
||||
error?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SubmitDialogButton({ title, description, onSubmit, error, children }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
onSubmit(close);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} className="pill button aspect-square !p-1 text-2xl">
|
||||
<Icon icon="material-symbols:check-rounded" />
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 flex-grow flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">{title}</h2>
|
||||
<button onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">{description}</p>
|
||||
|
||||
{children}
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={submit} className="pill button">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
src/components/search-bar.tsx
Normal file
45
src/components/search-bar.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { redirect, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { querySchema } from "@/lib/schemas";
|
||||
|
||||
export default function SearchBar() {
|
||||
const searchParams = useSearchParams();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const handleSearch = () => {
|
||||
const result = querySchema.safeParse(query);
|
||||
if (!result.success) redirect("/");
|
||||
|
||||
// Clone current search params and add query param
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("q", query);
|
||||
|
||||
redirect(`/?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full flex rounded-xl focus-within:ring-[3px] ring-orange-400/50 transition shadow-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="bg-orange-200 border-2 border-orange-400 py-2 px-3 rounded-l-xl outline-0 w-full placeholder:text-black/40"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="bg-orange-400 p-2 w-12 rounded-r-xl flex justify-center items-center cursor-pointer text-2xl transition-all hover:text-[1.75rem] active:text-2xl"
|
||||
>
|
||||
<Icon icon="ic:baseline-search" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/submit-form/image-list.tsx
Normal file
74
src/components/submit-form/image-list.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import Image from "next/image";
|
||||
import { FileWithPath } from "react-dropzone";
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
files: readonly FileWithPath[];
|
||||
setFiles: React.Dispatch<React.SetStateAction<FileWithPath[]>>;
|
||||
}
|
||||
|
||||
export default function ImageList({ files, setFiles }: Props) {
|
||||
const handleDelete = (index: number) => {
|
||||
const newFiles = [...files];
|
||||
newFiles.splice(index, 1);
|
||||
setFiles(newFiles);
|
||||
};
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const items = Array.from(files);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
setFiles(items);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="imageDroppable">
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps} className="flex flex-col px-12 max-lg:px-0 max-md:px-12 max-[32rem]:px-0">
|
||||
{files.map((file, index) => (
|
||||
<Draggable key={file.name} draggableId={file.name} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className="w-full p-4 rounded-xl bg-orange-100 border-2 border-amber-500 flex gap-2 shadow-md my-1"
|
||||
>
|
||||
<Image
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="aspect-[3/2] object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
|
||||
/>
|
||||
<div className="flex flex-col justify-center w-full min-w-0">
|
||||
<span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span>
|
||||
<button
|
||||
onClick={() => handleDelete(index)}
|
||||
className="pill button text-xs w-min !px-3 !py-1 !bg-red-300 !border-red-400 hover:!bg-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
className="h-full w-11 px-1 cursor-grab flex items-center justify-center rounded transition-colors hover:bg-black/10"
|
||||
>
|
||||
<Icon icon="tabler:grip-horizontal" className="size-6 text-black/50" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
248
src/components/submit-form/index.tsx
Normal file
248
src/components/submit-form/index.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"use client";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FileWithPath, useDropzone } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import qrcode from "qrcode-generator";
|
||||
|
||||
import { nameSchema, tagsSchema } from "@/lib/schemas";
|
||||
import { convertQrCode } from "@/lib/qr-codes";
|
||||
import Mii from "@/lib/mii.js/mii";
|
||||
import TomodachiLifeMii from "@/lib/tomodachi-life-mii";
|
||||
|
||||
import TagSelector from "../tag-selector";
|
||||
import ImageList from "./image-list";
|
||||
import QrUpload from "./qr-upload";
|
||||
import QrScanner from "./qr-scanner";
|
||||
import LikeButton from "../like-button";
|
||||
import Carousel from "../carousel";
|
||||
|
||||
export default function SubmitForm() {
|
||||
const [files, setFiles] = useState<FileWithPath[]>([]);
|
||||
|
||||
const handleDrop = useCallback((acceptedFiles: FileWithPath[]) => {
|
||||
setFiles((prev) => [...prev, ...acceptedFiles]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: handleDrop,
|
||||
maxFiles: 3,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".webp"],
|
||||
},
|
||||
});
|
||||
|
||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||
const [studioUrl, setStudioUrl] = useState<string | undefined>();
|
||||
const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState<string | undefined>();
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Validate before sending request
|
||||
const nameValidation = nameSchema.safeParse(name);
|
||||
if (!nameValidation.success) {
|
||||
setError(nameValidation.error.errors[0].message);
|
||||
return;
|
||||
}
|
||||
const tagsValidation = tagsSchema.safeParse(tags);
|
||||
if (!tagsValidation.success) {
|
||||
setError(tagsValidation.error.errors[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send request to server
|
||||
const formData = new FormData();
|
||||
formData.append("name", name);
|
||||
formData.append("tags", JSON.stringify(tags));
|
||||
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
|
||||
files.forEach((file, index) => {
|
||||
// image1, image2, etc.
|
||||
formData.append(`image${index + 1}`, file);
|
||||
});
|
||||
|
||||
const response = await fetch("/api/submit", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const { id, error } = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
redirect(`/mii/${id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (qrBytesRaw.length == 0) return;
|
||||
const qrBytes = new Uint8Array(qrBytesRaw);
|
||||
|
||||
const preview = async () => {
|
||||
setError("");
|
||||
|
||||
// Validate QR code size
|
||||
if (qrBytesRaw.length !== 372) {
|
||||
setError("QR code size is not a valid Tomodachi Life QR code");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert QR code to JS
|
||||
let conversion: { mii: Mii; tomodachiLifeMii: TomodachiLifeMii };
|
||||
try {
|
||||
conversion = convertQrCode(qrBytes);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStudioUrl(conversion.mii.studioUrl({ width: 512 }));
|
||||
|
||||
// Generate a new QR code for aesthetic reasons
|
||||
const byteString = String.fromCharCode(...qrBytes);
|
||||
const generatedCode = qrcode(0, "L");
|
||||
generatedCode.addData(byteString, "Byte");
|
||||
generatedCode.make();
|
||||
|
||||
setGeneratedQrCodeUrl(generatedCode.createDataURL());
|
||||
} catch {
|
||||
setError("Failed to get and/or generate Mii images");
|
||||
}
|
||||
};
|
||||
|
||||
preview();
|
||||
}, [qrBytesRaw]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
<Carousel
|
||||
images={[studioUrl ?? "/missing.webp", generatedQrCodeUrl ?? "/missing.webp", ...files.map((file) => URL.createObjectURL(file))]}
|
||||
/>
|
||||
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
|
||||
{name || "Mii name"}
|
||||
</h1>
|
||||
<div id="tags" className="flex flex-wrap gap-1">
|
||||
{tags.length == 0 && <span className="px-2 py-1 bg-orange-300 rounded-full text-xs">tag</span>}
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<LikeButton likes={0} isLiked={false} disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 max-w-2xl w-full">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Submit your Mii</h2>
|
||||
<p className="text-sm text-zinc-500">Share your creation for others to see.</p>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Info</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="w-full grid grid-cols-3 items-center">
|
||||
<label htmlFor="name" className="font-semibold">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
className="pill input w-full col-span-2"
|
||||
minLength={2}
|
||||
maxLength={64}
|
||||
placeholder="Type your mii's name here..."
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full grid grid-cols-3 items-center">
|
||||
<label htmlFor="tags" className="font-semibold">
|
||||
Tags
|
||||
</label>
|
||||
<TagSelector tags={tags} setTags={setTags} />
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>QR Code</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<QrUpload setQrBytesRaw={setQrBytesRaw} />
|
||||
|
||||
<span>or</span>
|
||||
|
||||
<button type="button" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:camera" fontSize={20} />
|
||||
Use your camera
|
||||
</button>
|
||||
|
||||
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Custom images</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full self-center">
|
||||
<div
|
||||
{...getRootProps({
|
||||
className:
|
||||
"bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border border-2 border-dashed border-amber-500 select-none h-full",
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Icon icon="material-symbols:upload" fontSize={48} />
|
||||
<p className="text-center text-sm">
|
||||
Drag and drop your images here
|
||||
<br />
|
||||
or click to open
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageList files={files} setFiles={setFiles} />
|
||||
|
||||
<hr className="border-zinc-300 my-2" />
|
||||
<div className="flex justify-between items-center">
|
||||
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
||||
|
||||
<button type="submit" className="pill button w-min ml-auto">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
20
src/components/submit-form/qr-finder.tsx
Normal file
20
src/components/submit-form/qr-finder.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export default function QrFinder() {
|
||||
return (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none size-72 z-10">
|
||||
{/* Top-left corner */}
|
||||
<div className="absolute top-0 left-0 size-6 border-t-3 border-l-3 border-amber-500 rounded-tl-lg" />
|
||||
|
||||
{/* Top-right corner */}
|
||||
<div className="absolute top-0 right-0 size-6 border-t-3 border-r-3 border-amber-500 rounded-tr-lg" />
|
||||
|
||||
{/* Bottom-left corner */}
|
||||
<div className="absolute bottom-0 left-0 size-6 border-b-3 border-l-3 border-amber-500 rounded-bl-lg" />
|
||||
|
||||
{/* Bottom-right corner */}
|
||||
<div className="absolute bottom-0 right-0 size-6 border-b-3 border-r-3 border-amber-500 rounded-br-lg" />
|
||||
|
||||
{/* Center point */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-5 bg-amber-500/70 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
src/components/submit-form/qr-scanner.tsx
Normal file
214
src/components/submit-form/qr-scanner.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Webcam from "react-webcam";
|
||||
import jsQR from "jsqr";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import QrFinder from "./qr-finder";
|
||||
import { useSelect } from "downshift";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
}
|
||||
|
||||
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
|
||||
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
|
||||
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const requestRef = useRef<number>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const cameraItems = devices.map((device) => ({
|
||||
value: device.deviceId,
|
||||
label: device.label || `Camera ${device.deviceId.slice(-5)}`,
|
||||
}));
|
||||
|
||||
const {
|
||||
isOpen: isDropdownOpen,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
} = useSelect({
|
||||
items: cameraItems,
|
||||
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
setSelectedDeviceId(selectedItem?.value ?? null);
|
||||
},
|
||||
});
|
||||
|
||||
const scanQRCode = useCallback(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Continue scanning in a loop
|
||||
requestRef.current = requestAnimationFrame(scanQRCode);
|
||||
|
||||
const webcam = webcamRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!webcam || !canvas) return;
|
||||
|
||||
const video = webcam.video;
|
||||
if (!video || video.videoWidth === 0 || video.videoHeight === 0) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
if (!code) return;
|
||||
|
||||
// Cancel animation frame to stop scanning
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
}
|
||||
|
||||
setQrBytesRaw(code.binaryData!);
|
||||
setIsOpen(false);
|
||||
}, [isOpen, setIsOpen, setQrBytesRaw]);
|
||||
|
||||
const requestPermission = async () => {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: true })
|
||||
.then(() => setPermissionGranted(true))
|
||||
.catch(() => setPermissionGranted(false));
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
requestPermission();
|
||||
|
||||
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
||||
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
||||
setDevices(videoDevices);
|
||||
if (!selectedDeviceId && videoDevices.length > 0) {
|
||||
setSelectedDeviceId(videoDevices[0].deviceId);
|
||||
}
|
||||
});
|
||||
}, [isOpen, selectedDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen && !permissionGranted) return;
|
||||
requestRef.current = requestAnimationFrame(scanQRCode);
|
||||
}, [isOpen, permissionGranted, scanQRCode]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Scan QR Code</h2>
|
||||
<button onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{devices.length > 1 && (
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold">Camera:</label>
|
||||
<div className="relative w-full">
|
||||
{/* Toggle button to open the dropdown */}
|
||||
<button
|
||||
type="button"
|
||||
{...getToggleButtonProps({}, { suppressRefError: true })}
|
||||
className="pill input w-full !px-2 !py-0.5 !justify-between text-sm"
|
||||
>
|
||||
{selectedItem?.label || "Select a camera"}
|
||||
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<ul
|
||||
{...getMenuProps({}, { suppressRefError: true })}
|
||||
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
||||
isDropdownOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isDropdownOpen &&
|
||||
cameraItems.map((item, index) => (
|
||||
<li
|
||||
key={item.value}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-full aspect-square">
|
||||
{!permissionGranted ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
|
||||
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
|
||||
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
|
||||
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 !py-0.5 !px-2">
|
||||
Request Permission
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Webcam
|
||||
ref={webcamRef}
|
||||
audio={false}
|
||||
videoConstraints={{
|
||||
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
|
||||
}}
|
||||
className="size-full object-cover rounded-2xl border-2 border-amber-500"
|
||||
/>
|
||||
<QrFinder />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button type="button" onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/submit-form/qr-upload.tsx
Normal file
74
src/components/submit-form/qr-upload.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { FileWithPath, useDropzone } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
import jsQR from "jsqr";
|
||||
|
||||
interface Props {
|
||||
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
}
|
||||
|
||||
export default function QrUpload({ setQrBytesRaw }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: FileWithPath[]) => {
|
||||
acceptedFiles.forEach((file) => {
|
||||
// Scan QR code
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
ctx.drawImage(image, 0, 0, image.width, image.height);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, image.width, image.height);
|
||||
const code = jsQR(imageData.data, image.width, image.height);
|
||||
if (!code) return;
|
||||
|
||||
setQrBytesRaw(code.binaryData!);
|
||||
};
|
||||
image.src = event.target!.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
[setQrBytesRaw]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".webp"],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full">
|
||||
<div
|
||||
{...getRootProps({
|
||||
className:
|
||||
"bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border border-2 border-dashed border-amber-500 select-none h-full",
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps({ multiple: false })} />
|
||||
<Icon icon="material-symbols:upload" fontSize={48} />
|
||||
<p className="text-center text-sm">
|
||||
Drag and drop your QR code image here
|
||||
<br />
|
||||
or click to open
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/components/tag-selector.tsx
Normal file
145
src/components/tag-selector.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
tags: string[];
|
||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
const tagRegex = /^[a-z0-9-_]*$/;
|
||||
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
|
||||
|
||||
export default function TagSelector({ tags, setTags }: Props) {
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
|
||||
const getFilteredItems = (): string[] =>
|
||||
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
|
||||
|
||||
const filteredItems = getFilteredItems();
|
||||
const isMaxItemsSelected = tags.length >= 8;
|
||||
const hasSelectedItems = tags.length > 0;
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
if (!tags.includes(tag) && tags.length < 8) {
|
||||
setTags([...tags, tag]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setTags(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||
inputValue,
|
||||
items: filteredItems,
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
if (inputValue && !tagRegex.test(inputValue)) return;
|
||||
setInputValue(inputValue || "");
|
||||
},
|
||||
onStateChange: ({ type, selectedItem }) => {
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
||||
addTag(selectedItem);
|
||||
setInputValue("");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
}
|
||||
|
||||
// Spill onto last tag
|
||||
if (event.key === "Backspace" && inputValue === "") {
|
||||
const lastTag = tags[tags.length - 1];
|
||||
setInputValue(lastTag);
|
||||
removeTag(lastTag);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`col-span-2 !justify-between pill input relative focus-within:ring-[3px] ring-orange-400/50 transition ${
|
||||
tags.length > 0 ? "!py-1.5" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 w-full">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="text-black cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:close" className="text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
{...getInputProps({
|
||||
onKeyDown: handleKeyDown,
|
||||
disabled: isMaxItemsSelected,
|
||||
placeholder: tags.length > 0 ? "" : "Type or select an item...",
|
||||
className: "w-full flex-1 outline-none",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{hasSelectedItems && (
|
||||
<button type="button" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button type="button" {...getToggleButtonProps()} className="text-black cursor-pointer text-xl">
|
||||
<Icon icon="mdi:chevron-down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{!isMaxItemsSelected && (
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className={`absolute left-0 top-full mt-2 z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg shadow-lg max-h-60 overflow-y-auto ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isOpen &&
|
||||
filteredItems.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
{isOpen && inputValue && !filteredItems.includes(inputValue) && (
|
||||
<li
|
||||
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
|
||||
onClick={() => {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
}}
|
||||
>
|
||||
Add "{inputValue}"
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/components/username-form.tsx
Normal file
49
src/components/username-form.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { usernameSchema } from "@/lib/schemas";
|
||||
|
||||
export default function UsernameForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const parsed = usernameSchema.safeParse(username);
|
||||
if (!parsed.success) setError(parsed.error.errors[0].message);
|
||||
|
||||
const response = await fetch("/api/auth/username", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
redirect("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type your username..."
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="pill input w-96 mb-2"
|
||||
/>
|
||||
|
||||
<button type="submit" className="pill button w-min">
|
||||
Submit
|
||||
</button>
|
||||
{error && <p className="text-red-400 font-semibold mt-4">Error: {error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue