mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-24 01:24:48 -05:00
Merge branch 'Hackdex-App:main' into feat/version-management
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { createClient, createServiceClient } from "@/utils/supabase/server";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FiAlertTriangle, FiExternalLink } from "react-icons/fi";
|
||||
@@ -14,7 +14,12 @@ export default async function DashboardPage() {
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const { data: isAdmin } = await supa.rpc("is_admin");
|
||||
let pendingHacks: (HackRow & { created_by: string; creator_username: string | null; creator_full_name: string | null })[] = [];
|
||||
let pendingHacks: (HackRow & {
|
||||
created_by: string;
|
||||
creator_username: string | null;
|
||||
creator_full_name: string | null;
|
||||
creator_email: string | null
|
||||
})[] = [];
|
||||
if (isAdmin) {
|
||||
const { data: pendingHacksData } = await supa
|
||||
.from("hacks")
|
||||
@@ -37,10 +42,26 @@ export default async function DashboardPage() {
|
||||
fullNameById.set(p.id, p.full_name);
|
||||
});
|
||||
|
||||
// Fetch creator emails using service client (admin API)
|
||||
const serviceClient = await createServiceClient();
|
||||
const emailById = new Map<string, string | null>();
|
||||
for (const userId of creatorIds) {
|
||||
try {
|
||||
const { data: userData, error } = await serviceClient.auth.admin.getUserById(userId);
|
||||
if (!error && userData?.user?.email) {
|
||||
emailById.set(userId, userData.user.email);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if we can't get the email
|
||||
console.error(`Failed to get email for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
pendingHacks = pendingHacksData.map((h) => ({
|
||||
...h,
|
||||
creator_username: usernameById.get(h.created_by as string) || null,
|
||||
creator_full_name: fullNameById.get(h.created_by as string) || null,
|
||||
creator_email: emailById.get(h.created_by as string) || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -89,8 +110,8 @@ export default async function DashboardPage() {
|
||||
<div className="overflow-hidden rounded-lg border border-amber-600/30 bg-amber-500/5">
|
||||
{/* Header row (desktop only) */}
|
||||
<div className="hidden lg:grid grid-cols-12 bg-amber-500/5 px-4 py-2 text-xs text-amber-900/80 dark:text-amber-200/80">
|
||||
<div className="col-span-5">Title</div>
|
||||
<div className="col-span-3">Creator</div>
|
||||
<div className="col-span-4">Title</div>
|
||||
<div className="col-span-4">Creator</div>
|
||||
<div className="col-span-4">Created</div>
|
||||
</div>
|
||||
<div className="divide-y divide-amber-600/20">
|
||||
@@ -119,7 +140,7 @@ export default async function DashboardPage() {
|
||||
>
|
||||
{/* Desktop row */}
|
||||
<div className="hidden lg:grid grid-cols-12 items-center">
|
||||
<div className="col-span-5 min-w-0">
|
||||
<div className="col-span-4 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-start min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -135,9 +156,12 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col">
|
||||
<div className="col-span-4 flex flex-col min-w-0">
|
||||
{h.creator_full_name && <div className="text-xs text-amber-900/70 dark:text-amber-200/70">{h.creator_full_name}</div>}
|
||||
<div className="text-amber-900/90 dark:text-amber-200/90">{creator}</div>
|
||||
{h.creator_email && (
|
||||
<div className="text-xs text-amber-900/60 dark:text-amber-200/60 truncate mt-0.5">{h.creator_email}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-4 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -160,10 +184,19 @@ export default async function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{h.slug}</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-amber-900/90 dark:text-amber-200/90 mt-2">
|
||||
<span>{creator}</span>
|
||||
<span>•</span>
|
||||
<span>{createdDate}</span>
|
||||
<div className="flex flex-col gap-1 text-xs text-amber-900/90 dark:text-amber-200/90 mt-2">
|
||||
{(h.creator_full_name || h.creator_email) && (
|
||||
<div className="text-amber-900/70 dark:text-amber-200/70 break-all flex flex-wrap items-center gap-2">
|
||||
{h.creator_full_name && <span>{h.creator_full_name}</span>}
|
||||
{h.creator_full_name && h.creator_email && <span>•</span>}
|
||||
{h.creator_email && <span>{h.creator_email}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span>{creator}</span>
|
||||
<span>•</span>
|
||||
<span>{createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FiExternalLink className="h-4 w-4 text-foreground/80 flex-shrink-0" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useActionState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { validateEmail } from "@/utils/auth";
|
||||
import { sendContact, type ContactActionState } from "@/app/contact/actions";
|
||||
import Select from "@/components/Primitives/Select";
|
||||
|
||||
type Topic =
|
||||
| "general"
|
||||
@@ -70,17 +71,16 @@ export default function ContactForm() {
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="topic" className="text-sm text-foreground/80">Topic</label>
|
||||
<select
|
||||
<Select
|
||||
id="topic"
|
||||
name="topic"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value as Topic)}
|
||||
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
||||
>
|
||||
{(Object.keys(topicLabels) as Topic[]).map((key) => (
|
||||
<option key={key} value={key}>{topicLabels[key]}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => setTopic(value as Topic)}
|
||||
options={(Object.keys(topicLabels) as Topic[]).map((key) => ({
|
||||
value: key,
|
||||
label: topicLabels[key],
|
||||
}))}
|
||||
/>
|
||||
<span className="text-xs text-foreground/60">
|
||||
Choose the most relevant topic so we can help you better.
|
||||
</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { FiExternalLink, FiEdit2, FiTrash2, FiChevronLeft, FiChevronRight, FiArrowDown, FiSearch, FiLoader, FiDownload, FiInfo, FiBarChart2 } from "react-icons/fi";
|
||||
import { getArchives, deleteArchive } from "@/app/dashboard/archives/actions";
|
||||
import { baseRoms } from "@/data/baseRoms";
|
||||
import Select from "@/components/Primitives/Select";
|
||||
|
||||
type Archive = {
|
||||
slug: string;
|
||||
@@ -118,24 +119,26 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full md:w-auto">
|
||||
<select
|
||||
<Select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as "all" | "downloadable" | "informational")}
|
||||
className="w-full md:w-auto rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
||||
>
|
||||
<option value="all">All Archives</option>
|
||||
<option value="downloadable">Downloadable</option>
|
||||
<option value="informational">Informational</option>
|
||||
</select>
|
||||
<select
|
||||
onChange={(value) => setFilter(value as "all" | "downloadable" | "informational")}
|
||||
className="w-full md:w-auto"
|
||||
options={[
|
||||
{ value: "all", label: "All Archives" },
|
||||
{ value: "downloadable", label: "Downloadable" },
|
||||
{ value: "informational", label: "Informational" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="w-full md:w-auto rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
||||
>
|
||||
<option value="created_at">Sort by date</option>
|
||||
<option value="title">Sort by title</option>
|
||||
<option value="original_author">Sort by author</option>
|
||||
</select>
|
||||
onChange={(value) => setSortBy(value as any)}
|
||||
className="w-full md:w-auto"
|
||||
options={[
|
||||
{ value: "created_at", label: "Sort by date" },
|
||||
{ value: "title", label: "Sort by title" },
|
||||
{ value: "original_author", label: "Sort by author" },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { HackCardAttributes } from "@/components/HackCard";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { getDiscoverData } from "@/app/discover/actions";
|
||||
import type { DiscoverSortOption } from "@/types/discover";
|
||||
import Select, { SelectOption } from "@/components/Primitives/Select";
|
||||
|
||||
const SORT_ICON_MAP: Record<DiscoverSortOption, IconType> = {
|
||||
trending: MdWhatshot,
|
||||
@@ -33,6 +34,14 @@ const SORT_ICON_MAP: Record<DiscoverSortOption, IconType> = {
|
||||
alphabetical: MdSortByAlpha,
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: SelectOption[] = [
|
||||
{ value: "trending", label: "Trending", icon: MdWhatshot },
|
||||
{ value: "popular", label: "Most popular", icon: MdTrendingUp },
|
||||
{ value: "new", label: "Newest", icon: MdNewReleases },
|
||||
{ value: "updated", label: "Recently updated", icon: MdUpdate },
|
||||
{ value: "alphabetical", label: "Alphabetical", icon: MdSortByAlpha },
|
||||
];
|
||||
|
||||
const HACKS_PER_PAGE = 9;
|
||||
|
||||
interface DiscoverBrowserProps {
|
||||
@@ -241,30 +250,26 @@ export default function DiscoverBrowser({ initialSort = "trending" }: DiscoverBr
|
||||
className="h-11 w-full rounded-md bg-[var(--surface-2)] px-3 text-sm text-foreground placeholder:text-foreground/60 ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="inline-flex h-11 items-center gap-1.5 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus-within:ring-2 focus-within:ring-[var(--ring)]"
|
||||
>
|
||||
<div className="flex h-11 w-full items-center gap-1.5 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] sm:inline-flex sm:w-auto">
|
||||
{sortIcon}
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => {
|
||||
const nextSort = e.target.value as DiscoverSortOption;
|
||||
setSort(nextSort);
|
||||
// Keep URL query param in sync so refresh/back preserves sort
|
||||
const current = searchParams ? new URLSearchParams(searchParams.toString()) : new URLSearchParams();
|
||||
current.set("sort", nextSort);
|
||||
const queryString = current.toString();
|
||||
const url = queryString ? `${pathname}?${queryString}` : pathname;
|
||||
router.replace(url);
|
||||
}}
|
||||
className="w-full h-full bg-transparent pl-1 pr-0 text-sm text-foreground focus:outline-none focus:ring-0"
|
||||
>
|
||||
<option value="trending">Trending</option>
|
||||
<option value="popular">Most popular</option>
|
||||
<option value="new">Newest</option>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="alphabetical">Alphabetical</option>
|
||||
</select>
|
||||
<div className="relative flex-1 sm:flex-none">
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={(value) => {
|
||||
const nextSort = value as DiscoverSortOption;
|
||||
setSort(nextSort);
|
||||
const current = searchParams ? new URLSearchParams(searchParams.toString()) : new URLSearchParams();
|
||||
current.set("sort", nextSort);
|
||||
const queryString = current.toString();
|
||||
const url = queryString ? `${pathname}?${queryString}` : pathname;
|
||||
router.replace(url);
|
||||
}}
|
||||
options={SORT_OPTIONS}
|
||||
// this css gets a little janky, but it gets the job done
|
||||
className="flex h-11 w-full items-center rounded-none bg-transparent px-0 pl-1 pr-8 text-left sm:w-fit !ring-0 focus:ring-0"
|
||||
dropdownClassName="-left-[2.313rem] top-[44px] !min-w-0 !max-w-none !w-[calc(100%_+_3.125rem)] sm:left-auto sm:right-[-12px] sm:!w-max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import Image from "next/image";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
import { updateHack, saveHackCovers, presignCoverUpload } from "@/app/hack/actions";
|
||||
import SortableCovers from "@/components/Hack/SortableCovers";
|
||||
import Select from "@/components/Primitives/Select";
|
||||
|
||||
interface HackEditFormProps {
|
||||
slug: string;
|
||||
@@ -393,11 +394,15 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<select value={language} onChange={(e) => setLanguage(e.target.value)} className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${languageChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}>
|
||||
{['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => (
|
||||
<option key={l} value={l}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
<Select
|
||||
value={language}
|
||||
onChange={setLanguage}
|
||||
className={languageChanged ? 'ring-[var(--ring)]' : ''}
|
||||
options={['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => ({
|
||||
value: l,
|
||||
label: l,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Current version</label>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAuthContext } from "@/contexts/AuthContext";
|
||||
import { useBaseRoms } from "@/contexts/BaseRomContext";
|
||||
import TagSelector from "@/components/Submit/TagSelector";
|
||||
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
|
||||
import Select from "@/components/Primitives/Select";
|
||||
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
|
||||
import { sha1Hex } from "@/utils/hash";
|
||||
import { platformAccept, setDraftCovers, getDraftCovers, deleteDraftCovers } from "@/utils/idb";
|
||||
@@ -733,17 +734,16 @@ export default function HackSubmitForm({
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Platform <span className="text-red-500">*</span></label>
|
||||
{!isDummy ? (
|
||||
<select
|
||||
<Select
|
||||
value={platform}
|
||||
onChange={(e) => { if ((newCoverFiles.length) > 0) return; setPlatform(e.target.value as any); setBaseRom(""); }}
|
||||
onChange={(value) => { if ((newCoverFiles.length) > 0) return; setPlatform(value as any); setBaseRom(""); }}
|
||||
disabled={newCoverFiles.length > 0}
|
||||
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>Select platform</option>
|
||||
{(["GB","GBC","GBA","NDS"] as const).map(p => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
placeholder="Select platform"
|
||||
options={(["GB","GBC","GBA","NDS"] as const).map(p => ({
|
||||
value: p,
|
||||
label: p,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{platform || ""}</div>
|
||||
)}
|
||||
@@ -755,19 +755,16 @@ export default function HackSubmitForm({
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Base ROM <span className="text-red-500">*</span></label>
|
||||
{!isDummy ? (
|
||||
<select
|
||||
<Select
|
||||
value={baseRom}
|
||||
onChange={(e) => setBaseRom(e.target.value)}
|
||||
onChange={setBaseRom}
|
||||
disabled={!platform}
|
||||
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>{platform ? "Select base rom" : "Select platform first"}</option>
|
||||
{baseRoms.filter(r => !platform || r.platform === platform).map(({ id, name, region }) => (
|
||||
<option key={id} value={id}>
|
||||
{name.replace('Pokémon ', '')} ({region})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
placeholder={platform ? "Select base rom" : "Select platform first"}
|
||||
options={baseRoms.filter(r => !platform || r.platform === platform).map(({ id, name, region }) => ({
|
||||
value: id,
|
||||
label: `${name.replace('Pokémon ', '')} (${region})`,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{baseRoms.find(r=>r.id===baseRom)?.name || baseRom}</div>
|
||||
)}
|
||||
@@ -776,16 +773,15 @@ export default function HackSubmitForm({
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Language <span className="text-red-500">*</span></label>
|
||||
{!isDummy ? (
|
||||
<select
|
||||
<Select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
||||
>
|
||||
<option value="" disabled>Select language</option>
|
||||
{['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => (
|
||||
<option key={l} value={l}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setLanguage}
|
||||
placeholder="Select language"
|
||||
options={['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => ({
|
||||
value: l,
|
||||
label: l,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div role="textbox" aria-disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{language}</div>
|
||||
)}
|
||||
|
||||
99
src/components/Primitives/Select.tsx
Normal file
99
src/components/Primitives/Select.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, Transition } from "@headlessui/react";
|
||||
import { FiChevronDown, FiCheck } from "react-icons/fi";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
dropdownClassName?: string;
|
||||
dropdownAlign?: "left" | "right";
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select an option",
|
||||
disabled = false,
|
||||
className = "",
|
||||
dropdownClassName = "",
|
||||
dropdownAlign = "left",
|
||||
id,
|
||||
name,
|
||||
}: SelectProps) {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
return (
|
||||
<Listbox value={value} onChange={onChange} disabled={disabled}>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<ListboxButton
|
||||
id={id}
|
||||
className={`relative h-11 w-full cursor-pointer rounded-md bg-[var(--surface-2)] px-3 pr-10 text-left text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
>
|
||||
<span className={`block truncate ${selectedOption ? "" : "text-foreground/60"}`}>
|
||||
{selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<FiChevronDown className={`h-4 w-4 text-foreground/60 transition-transform ${open ? "rotate-180" : ""}`} aria-hidden="true" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
{name && <input type="hidden" name={name} value={value} />}
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions className={`absolute z-50 mt-1 max-h-60 min-w-full max-w-[400px] w-max overflow-auto rounded-md bg-background/95 backdrop-blur-sm py-1 text-sm shadow-lg ring-1 ring-[var(--border)] focus:outline-none ${dropdownAlign === "right" ? "right-0" : ""} ${dropdownClassName}`}>
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<ListboxOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={({ focus }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
||||
focus ? "bg-black/5 dark:bg-white/10" : ""
|
||||
} ${option.disabled ? "opacity-50 cursor-not-allowed" : ""}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className={`flex items-center gap-2 truncate ${selected ? "font-medium" : "font-normal"}`}>
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
||||
{option.label}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-foreground">
|
||||
<FiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
);
|
||||
})}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user