Merge branch 'main' into feat/living-the-dream-qr-code

This commit is contained in:
trafficlunar 2026-02-20 15:23:11 +00:00
commit 118739041f
30 changed files with 1596 additions and 1524 deletions

View 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>
);
}

View file

@ -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,
)}
</>
);

View file

@ -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);

View file

@ -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>

View file

@ -28,7 +28,7 @@ export default function GenderSelect() {
}
startTransition(() => {
router.push(`?${params.toString()}`);
router.push(`?${params.toString()}`, { scroll: false });
});
};

View file

@ -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>

View 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>
);
}

View file

@ -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>
))}

View file

@ -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>
);
}

View file

@ -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) => {

View file

@ -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;
}

View file

@ -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">

View file

@ -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>
)}