mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 14:44:15 +00:00
Merge branch 'main' into feat/living-the-dream-qr-code
This commit is contained in:
commit
118739041f
30 changed files with 1596 additions and 1524 deletions
63
src/components/countdown.tsx
Normal file
63
src/components/countdown.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function Countdown() {
|
||||
const [days, setDays] = useState(31);
|
||||
const [hours, setHours] = useState(59);
|
||||
const [minutes, setMinutes] = useState(59);
|
||||
const [seconds, setSeconds] = useState(59);
|
||||
|
||||
const targetDate = new Date("2026-04-16T00:00:00Z").getTime();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = new Date().getTime();
|
||||
const distance = targetDate - now;
|
||||
|
||||
if (distance < 0) {
|
||||
clearInterval(interval);
|
||||
setDays(0);
|
||||
setHours(0);
|
||||
setMinutes(0);
|
||||
setSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setDays(Math.floor(distance / (1000 * 60 * 60 * 24)));
|
||||
setHours(Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)));
|
||||
setMinutes(Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)));
|
||||
setSeconds(Math.floor((distance % (1000 * 60)) / 1000));
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-4 py-2.5 flex justify-center items-center gap-8 w-fit max-sm:max-w-72 max-sm:w-full max-sm:flex-col max-sm:gap-2">
|
||||
<div className="flex flex-col max-sm:items-center">
|
||||
<h1 className="text-xl font-bold">Living the Dream</h1>
|
||||
<h2 className="text-right text-sm max-sm:text-center">releases in:</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-2xl font-semibold">{days}</span>
|
||||
<span className="text-xs">days</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-2xl font-semibold">{hours}</span>
|
||||
<span className="text-xs">hours</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-2xl font-semibold">{minutes}</span>
|
||||
<span className="text-xs">minutes</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-2xl font-semibold">{seconds}</span>
|
||||
<span className="text-xs">seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel();
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
// Keep order of images whilst opening at src prop
|
||||
const index = images.indexOf(src);
|
||||
if (index !== -1) {
|
||||
emblaApi.scrollTo(index);
|
||||
emblaApi.scrollTo(index, true);
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
|
||||
|
|
@ -80,83 +80,74 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) 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"
|
||||
}`}
|
||||
className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm 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 aspect-square w-full max-w-xl relative transition-discrete duration-300 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={close}
|
||||
className={`pill button p-2! aspect-square text-2xl absolute top-4 right-4 ${isVisible ? "opacity-100" : "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 type="button" aria-label="Close" onClick={close} className="text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl h-full" ref={emblaRef}>
|
||||
<div className="flex h-full items-center">
|
||||
{imagesMap.map((image, index) => (
|
||||
<div key={index} className="shrink-0 w-full">
|
||||
<Image
|
||||
src={image}
|
||||
alt={alt}
|
||||
width={576}
|
||||
height={576}
|
||||
className="object-contain"
|
||||
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{imagesMap.map((image, index) => (
|
||||
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
|
||||
<Image
|
||||
src={image}
|
||||
alt={alt}
|
||||
width={896}
|
||||
height={896}
|
||||
priority={index === selectedIndex}
|
||||
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
|
||||
className="max-w-full max-h-full object-contain drop-shadow-lg"
|
||||
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length != 0 && (
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
{/* Carousel counter */}
|
||||
<div
|
||||
className={`flex justify-center gap-2 bg-orange-300/25 text-orange-300 w-15 font-semibold text-sm py-1 rounded-full border border-orange-300 absolute top-4 left-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{selectedIndex + 1} / {images.length}
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Left"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Left"
|
||||
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>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
{/* 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
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Right"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Right"
|
||||
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>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
|
||||
{/* 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 ${
|
||||
className={`flex justify-center gap-2 bg-orange-300/25 p-2.5 rounded-full border border-orange-300 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -165,14 +156,14 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
key={index}
|
||||
aria-label={`Go to ${index} in Carousel`}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-2.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
|
||||
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-orange-300 w-8" : "bg-orange-300/40"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Icon, loadIcons } from "@iconify/react";
|
||||
import { abbreviateNumber } from "@/lib/abbreviation";
|
||||
|
||||
|
|
@ -16,13 +16,18 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function LikeButton({ likes, isLiked, miiId, isLoggedIn, disabled, abbreviate, big }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isLikedState, setIsLikedState] = useState(isLiked);
|
||||
const [likesState, setLikesState] = useState(likes);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
const onClick = async () => {
|
||||
if (disabled) return;
|
||||
if (!isLoggedIn) redirect("/login");
|
||||
if (!isLoggedIn) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLikedState(!isLikedState);
|
||||
setLikesState(isLikedState ? likesState - 1 : likesState + 1);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { useSearchParams } from "next/navigation";
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import { MiiGender, MiiPlatform } from "@prisma/client";
|
||||
import { MiiGender } from "@prisma/client";
|
||||
|
||||
import TagFilter from "./tag-filter";
|
||||
import PlatformSelect from "./platform-select";
|
||||
import GenderSelect from "./gender-select";
|
||||
import OtherFilters from "./other-filters";
|
||||
|
||||
export default function FilterMenu() {
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -17,8 +17,9 @@ export default function FilterMenu() {
|
|||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const rawTags = searchParams.get("tags") || "";
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
const rawExclude = searchParams.get("exclude") || "";
|
||||
const gender = (searchParams.get("gender") as MiiGender) || undefined;
|
||||
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
|
||||
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
|
|
@ -28,7 +29,17 @@ export default function FilterMenu() {
|
|||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags]
|
||||
[rawTags],
|
||||
);
|
||||
const exclude = useMemo(
|
||||
() =>
|
||||
rawExclude
|
||||
? rawExclude
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawExclude],
|
||||
);
|
||||
|
||||
const [filterCount, setFilterCount] = useState(tags.length);
|
||||
|
|
@ -49,45 +60,56 @@ export default function FilterMenu() {
|
|||
|
||||
// Count all active filters
|
||||
useEffect(() => {
|
||||
let count = tags.length;
|
||||
if (platform) count++;
|
||||
let count = tags.length + exclude.length;
|
||||
if (gender) count++;
|
||||
if (allowCopying) count++;
|
||||
|
||||
setFilterCount(count);
|
||||
}, [tags, platform, gender]);
|
||||
}, [tags, exclude, gender, allowCopying]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button className="pill button gap-2" onClick={handleClick}>
|
||||
<Icon icon="mdi:filter" className="text-xl" />
|
||||
Filter {filterCount !== 0 ? `(${filterCount})` : ""}
|
||||
Filter
|
||||
<span className="w-5">({filterCount})</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute w-80 left-0 top-full mt-8 z-50 flex flex-col items-center bg-orange-50
|
||||
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${
|
||||
isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
|
||||
}`}
|
||||
className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50
|
||||
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Include</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<TagFilter />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>Platform</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Exclude</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<PlatformSelect />
|
||||
<TagFilter isExclude />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Gender</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<GenderSelect />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Other</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<OtherFilters />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default function GenderSelect() {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@ import { searchSchema } from "@/lib/schemas";
|
|||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import FilterMenu from "./filter-menu";
|
||||
import SortSelect from "./sort-select";
|
||||
import Carousel from "../carousel";
|
||||
import LikeButton from "../like-button";
|
||||
import DeleteMiiButton from "../delete-mii";
|
||||
import Pagination from "./pagination";
|
||||
import FilterMenu from "./filter-menu";
|
||||
|
||||
interface Props {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
|
|
@ -23,26 +23,13 @@ interface Props {
|
|||
inLikesPage?: boolean; // Self-explanatory
|
||||
}
|
||||
|
||||
export default async function MiiList({
|
||||
searchParams,
|
||||
userId,
|
||||
inLikesPage,
|
||||
}: Props) {
|
||||
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
|
||||
const session = await auth();
|
||||
|
||||
const parsed = searchSchema.safeParse(searchParams);
|
||||
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
||||
|
||||
const {
|
||||
q: query,
|
||||
sort,
|
||||
tags,
|
||||
platform,
|
||||
gender,
|
||||
page = 1,
|
||||
limit = 24,
|
||||
seed,
|
||||
} = parsed.data;
|
||||
const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
|
||||
|
||||
// My Likes page
|
||||
let miiIdsLiked: number[] | undefined = undefined;
|
||||
|
|
@ -60,18 +47,17 @@ export default async function MiiList({
|
|||
...(inLikesPage && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
||||
// Searching
|
||||
...(query && {
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ tags: { has: query } },
|
||||
{ description: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
|
||||
}),
|
||||
// Tag filtering
|
||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
|
||||
// Platform
|
||||
...(platform && { platform: { equals: platform } }),
|
||||
// Gender
|
||||
...(gender && { gender: { equals: gender } }),
|
||||
// Allow Copying
|
||||
...(allowCopying && { allowedCopying: true }),
|
||||
// Profiles
|
||||
...(userId && { userId }),
|
||||
};
|
||||
|
|
@ -93,6 +79,7 @@ export default async function MiiList({
|
|||
tags: true,
|
||||
createdAt: true,
|
||||
gender: true,
|
||||
allowedCopying: true,
|
||||
// Mii liked check
|
||||
...(session?.user?.id && {
|
||||
likedBy: {
|
||||
|
|
@ -113,9 +100,6 @@ export default async function MiiList({
|
|||
let list: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
|
||||
if (sort === "random") {
|
||||
// Use seed for consistent random results
|
||||
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
|
||||
// Get all IDs that match the where conditions
|
||||
const matchingIds = await prisma.mii.findMany({
|
||||
where,
|
||||
|
|
@ -123,10 +107,12 @@ export default async function MiiList({
|
|||
});
|
||||
|
||||
totalCount = matchingIds.length;
|
||||
filteredCount = Math.min(matchingIds.length, limit);
|
||||
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
|
||||
|
||||
if (matchingIds.length === 0) return;
|
||||
|
||||
// Use seed for consistent random results
|
||||
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
const rng = seedrandom(randomSeed.toString());
|
||||
|
||||
// Randomize all IDs using the Durstenfeld algorithm
|
||||
|
|
@ -136,7 +122,7 @@ export default async function MiiList({
|
|||
}
|
||||
|
||||
// Convert to number[] array
|
||||
const selectedIds = matchingIds.slice(0, limit).map((i) => i.id);
|
||||
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
|
||||
|
||||
list = await prisma.mii.findMany({
|
||||
where: {
|
||||
|
|
@ -183,22 +169,14 @@ export default async function MiiList({
|
|||
<div className="flex items-center gap-2">
|
||||
{totalCount == filteredCount ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-amber-900">
|
||||
{totalCount}
|
||||
</span>
|
||||
<span className="text-lg text-amber-700">
|
||||
{totalCount === 1 ? "Mii" : "Miis"}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-amber-900">{totalCount}</span>
|
||||
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-amber-900">
|
||||
{filteredCount}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-amber-900">{filteredCount}</span>
|
||||
<span className="text-sm text-amber-700">of</span>
|
||||
<span className="text-lg font-semibold text-amber-800">
|
||||
{totalCount}
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-amber-800">{totalCount}</span>
|
||||
<span className="text-lg text-amber-700">Miis</span>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -220,66 +198,37 @@ export default async function MiiList({
|
|||
images={[
|
||||
`/mii/${mii.id}/image?type=mii`,
|
||||
`/mii/${mii.id}/image?type=qr-code`,
|
||||
...Array.from(
|
||||
{ length: mii.imageCount },
|
||||
(_, index) => `/mii/${mii.id}/image?type=image${index}`
|
||||
),
|
||||
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
|
||||
]}
|
||||
/>
|
||||
|
||||
<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}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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={session?.user != null}
|
||||
abbreviate
|
||||
/>
|
||||
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={session?.user != null} abbreviate />
|
||||
|
||||
{!userId && (
|
||||
<Link
|
||||
href={`/profile/${mii.user?.id}`}
|
||||
className="text-sm text-right overflow-hidden text-ellipsis"
|
||||
>
|
||||
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
|
||||
@{mii.user?.username}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{userId && Number(session?.user.id) == userId && (
|
||||
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
|
||||
<Link
|
||||
href={`/edit/${mii.id}`}
|
||||
title="Edit Mii"
|
||||
aria-label="Edit Mii"
|
||||
data-tooltip="Edit"
|
||||
>
|
||||
<Link href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
|
||||
<Icon icon="mdi:pencil" />
|
||||
</Link>
|
||||
<DeleteMiiButton
|
||||
miiId={mii.id}
|
||||
miiName={mii.name}
|
||||
likes={mii.likes}
|
||||
/>
|
||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
38
src/components/mii-list/other-filters.tsx
Normal file
38
src/components/mii-list/other-filters.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { ChangeEvent, ChangeEventHandler, useState, useTransition } from "react";
|
||||
|
||||
export default function OtherFilters() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
||||
|
||||
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setAllowCopying(e.target.checked);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (!allowCopying) {
|
||||
params.set("allowCopying", "true");
|
||||
} else {
|
||||
params.delete("allowCopying");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<label htmlFor="allowCopying" className="text-sm">
|
||||
Allow Copying
|
||||
</label>
|
||||
<input type="checkbox" name="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ export default function SortSelect() {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -54,11 +54,7 @@ export default function SortSelect() {
|
|||
>
|
||||
{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" : ""}`}
|
||||
>
|
||||
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import TagSelector from "../tag-selector";
|
||||
|
||||
export default function TagFilter() {
|
||||
interface Props {
|
||||
isExclude?: boolean;
|
||||
}
|
||||
|
||||
export default function TagFilter({ isExclude }: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const rawTags = searchParams.get("tags") || "";
|
||||
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
||||
const preexistingTags = useMemo(
|
||||
() =>
|
||||
rawTags
|
||||
|
|
@ -18,7 +22,7 @@ export default function TagFilter() {
|
|||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags]
|
||||
[rawTags],
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<string[]>(preexistingTags);
|
||||
|
|
@ -39,20 +43,20 @@ export default function TagFilter() {
|
|||
params.set("page", "1");
|
||||
|
||||
if (tags.length > 0) {
|
||||
params.set("tags", stateTags);
|
||||
params.set(isExclude ? "exclude" : "tags", stateTags);
|
||||
} else {
|
||||
params.delete("tags");
|
||||
params.delete(isExclude ? "exclude" : "tags");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tags]);
|
||||
|
||||
return (
|
||||
<div className="w-72">
|
||||
<TagSelector tags={tags} setTags={setTags} />
|
||||
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { redirect, useSearchParams } from "next/navigation";
|
||||
import { redirect, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { querySchema } from "@/lib/schemas";
|
||||
|
||||
export default function SearchBar() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const handleSearch = () => {
|
||||
const result = querySchema.safeParse(query);
|
||||
if (!result.success) redirect("/");
|
||||
if (!result.success) {
|
||||
router.push("/", { scroll: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone current search params and add query param
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("q", query);
|
||||
params.set("page", "1");
|
||||
|
||||
redirect(`/?${params.toString()}`);
|
||||
router.push(`/?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default function SubmitForm() {
|
|||
if (files.length >= 3) return;
|
||||
setFiles((prev) => [...prev, ...acceptedFiles]);
|
||||
},
|
||||
[files.length]
|
||||
[files.length],
|
||||
);
|
||||
|
||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||
|
|
@ -101,7 +101,7 @@ export default function SubmitForm() {
|
|||
const { id, error } = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(error);
|
||||
setError(String(error)); // app can crash if error message is not a string
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Webcam from "react-webcam";
|
||||
import jsQR from "jsqr";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
|
|
@ -17,14 +16,12 @@ interface Props {
|
|||
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
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 videoRef = useRef<HTMLVideoElement>(null);
|
||||
const requestRef = useRef<number>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
|
|
@ -42,8 +39,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
selectedItem,
|
||||
} = useSelect({
|
||||
items: cameraItems,
|
||||
selectedItem:
|
||||
cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
||||
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
setSelectedDeviceId(selectedItem?.value ?? null);
|
||||
},
|
||||
|
|
@ -55,12 +51,9 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
// Continue scanning in a loop
|
||||
requestRef.current = requestAnimationFrame(scanQRCode);
|
||||
|
||||
const webcam = webcamRef.current;
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!webcam || !canvas) return;
|
||||
|
||||
const video = webcam.video;
|
||||
if (!video || video.videoWidth === 0 || video.videoHeight === 0) return;
|
||||
if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
|
@ -69,14 +62,9 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
|
||||
|
||||
const imageData = ctx.getImageData(
|
||||
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;
|
||||
if (!code || !code.binaryData) return;
|
||||
|
||||
// Cancel animation frame to stop scanning
|
||||
if (requestRef.current) {
|
||||
|
|
@ -84,15 +72,20 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
requestRef.current = null;
|
||||
}
|
||||
|
||||
setQrBytesRaw(code.binaryData!);
|
||||
setQrBytesRaw(code.binaryData);
|
||||
setIsOpen(false);
|
||||
}, [isOpen, setIsOpen, setQrBytesRaw]);
|
||||
|
||||
const requestPermission = async () => {
|
||||
const requestPermission = () => {
|
||||
if (!navigator.mediaDevices) return;
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: true })
|
||||
.getUserMedia({ video: true, audio: false })
|
||||
.then(() => setPermissionGranted(true))
|
||||
.catch(() => setPermissionGranted(false));
|
||||
.catch((err) => {
|
||||
setPermissionGranted(false);
|
||||
console.error("An error occurred trying to access the camera", err);
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
|
|
@ -106,34 +99,50 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
requestPermission();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
requestPermission();
|
||||
|
||||
if (!navigator.mediaDevices.enumerateDevices) return;
|
||||
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;
|
||||
|
||||
navigator.mediaDevices
|
||||
.enumerateDevices()
|
||||
.then((devices) => {
|
||||
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
||||
setDevices(videoDevices);
|
||||
|
||||
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
|
||||
if (!targetDeviceId) return;
|
||||
setSelectedDeviceId(targetDeviceId);
|
||||
|
||||
// start camera stream
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
video: { deviceId: targetDeviceId },
|
||||
audio: false,
|
||||
});
|
||||
})
|
||||
.then((stream) => {
|
||||
if (!stream || !videoRef.current) return;
|
||||
videoRef.current.srcObject = stream;
|
||||
videoRef.current.play();
|
||||
})
|
||||
.catch((err) => console.error("Camera error", err));
|
||||
|
||||
requestRef.current = requestAnimationFrame(scanQRCode);
|
||||
|
||||
// cleanup
|
||||
return () => {
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
}
|
||||
if (videoRef.current?.srcObject) {
|
||||
const stream = videoRef.current.srcObject as MediaStream;
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, permissionGranted, scanQRCode]);
|
||||
}, [isOpen, permissionGranted, selectedDeviceId, scanQRCode]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
|
@ -141,9 +150,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) 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"
|
||||
}`}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
@ -153,12 +160,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Scan QR Code</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={close}
|
||||
className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"
|
||||
>
|
||||
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -191,9 +193,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
<li
|
||||
key={item.value}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${
|
||||
highlightedIndex === index ? "bg-black/15" : ""
|
||||
}`}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
|
|
@ -204,51 +204,19 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
|||
)}
|
||||
|
||||
<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!"
|
||||
>
|
||||
{!permissionGranted && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center rounded-2xl bg-amber-50 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
|
||||
key={selectedDeviceId}
|
||||
ref={webcamRef}
|
||||
audio={false}
|
||||
videoConstraints={{
|
||||
deviceId: selectedDeviceId
|
||||
? { exact: selectedDeviceId }
|
||||
: undefined,
|
||||
...(selectedDeviceId
|
||||
? {}
|
||||
: { facingMode: { ideal: "environment" } }),
|
||||
}}
|
||||
onUserMedia={async () => {
|
||||
const newDevices =
|
||||
await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = newDevices.filter(
|
||||
(d) => d.kind === "videoinput"
|
||||
);
|
||||
setDevices(videoDevices);
|
||||
}}
|
||||
className="size-full object-cover rounded-2xl border-2 border-amber-500"
|
||||
/>
|
||||
<QrFinder />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<video ref={videoRef} 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">
|
||||
|
|
|
|||
|
|
@ -8,32 +8,18 @@ interface Props {
|
|||
tags: string[];
|
||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
showTagLimit?: boolean;
|
||||
isExclude?: boolean;
|
||||
}
|
||||
|
||||
const tagRegex = /^[a-z0-9-_]*$/;
|
||||
const predefinedTags = [
|
||||
"anime",
|
||||
"art",
|
||||
"cartoon",
|
||||
"celebrity",
|
||||
"games",
|
||||
"history",
|
||||
"meme",
|
||||
"movie",
|
||||
"oc",
|
||||
"tv",
|
||||
];
|
||||
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
|
||||
|
||||
export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
||||
export default function TagSelector({ tags, setTags, showTagLimit, isExclude }: Props) {
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const getFilteredItems = (): string[] =>
|
||||
predefinedTags
|
||||
.filter((item) =>
|
||||
item.toLowerCase().includes(inputValue?.toLowerCase() || "")
|
||||
)
|
||||
.filter((item) => !tags.includes(item));
|
||||
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
|
||||
|
||||
const filteredItems = getFilteredItems();
|
||||
const isMaxItemsSelected = tags.length >= 8;
|
||||
|
|
@ -49,37 +35,39 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
setTags(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
openMenu,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
} = useCombobox<string>({
|
||||
const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||
inputValue,
|
||||
items: filteredItems,
|
||||
selectedItem: null,
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
if (inputValue && !tagRegex.test(inputValue)) return;
|
||||
setInputValue(inputValue || "");
|
||||
const newValue = inputValue || "";
|
||||
if (newValue && !tagRegex.test(newValue)) return;
|
||||
setInputValue(newValue);
|
||||
},
|
||||
onStateChange: ({ type, selectedItem }) => {
|
||||
onSelectedItemChange: ({ type, selectedItem }) => {
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
||||
addTag(selectedItem);
|
||||
setInputValue("");
|
||||
}
|
||||
},
|
||||
stateReducer: (_, { type, changes }) => {
|
||||
// Prevent input from being filled when item is selected
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick) {
|
||||
return {
|
||||
...changes,
|
||||
inputValue: "",
|
||||
};
|
||||
}
|
||||
return changes;
|
||||
},
|
||||
});
|
||||
|
||||
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 === "") {
|
||||
} else if (event.key === "Backspace" && inputValue === "") {
|
||||
// Spill onto last tag
|
||||
const lastTag = tags[tags.length - 1];
|
||||
setInputValue(lastTag);
|
||||
removeTag(lastTag);
|
||||
|
|
@ -104,10 +92,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
{/* 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"
|
||||
>
|
||||
<span key={tag} className={`py-1 px-3 rounded-2xl flex items-center gap-1 text-sm ${isExclude ? "bg-red-300" : "bg-orange-300"}`}>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -137,17 +122,9 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{hasSelectedItems && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove All Tags"
|
||||
className="text-black cursor-pointer"
|
||||
onClick={() => setTags([])}
|
||||
>
|
||||
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -176,9 +153,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${
|
||||
highlightedIndex === index ? "bg-black/15" : ""
|
||||
}`}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
|
|
@ -202,9 +177,7 @@ export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
|
|||
{showTagLimit && (
|
||||
<div className="mt-1.5 text-xs min-h-4">
|
||||
{isMaxItemsSelected ? (
|
||||
<span className="text-red-400 font-medium">
|
||||
Maximum of 8 tags reached. Remove a tag to add more.
|
||||
</span>
|
||||
<span className="text-red-400 font-medium">Maximum of 8 tags reached. Remove a tag to add more.</span>
|
||||
) : (
|
||||
<span className="text-black/60">{tags.length}/8 tags</span>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue