feat: 添加日期处理库和日历选择器,更新管理员抽奖模块
This commit is contained in:
92
src/components/admin/admin-date-field.tsx
Normal file
92
src/components/admin/admin-date-field.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { format, parse } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AdminDateField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "选择日期",
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
/** `yyyy-MM-dd` 或空 */
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const parsed = React.useMemo(() => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const d = parse(value, "yyyy-MM-dd", new Date());
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}, [value]);
|
||||
|
||||
const summary = parsed ? format(parsed, "yyyy年M月d日", { locale: zhCN }) : placeholder;
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Popover
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
id={id}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "default" }),
|
||||
"h-8 w-full justify-start gap-2 px-2.5 font-normal md:text-sm",
|
||||
!parsed && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{summary}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" sideOffset={6} className="w-auto min-w-fit p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
locale={zhCN}
|
||||
captionLayout="dropdown"
|
||||
selected={parsed}
|
||||
defaultMonth={parsed}
|
||||
onSelect={(d) => {
|
||||
onChange(d ? format(d, "yyyy-MM-dd") : "");
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end border-t px-2 py-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 px-2"
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/components/admin/admin-date-range-field.tsx
Normal file
154
src/components/admin/admin-date-range-field.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import { format, parse } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { CalendarRange } from "lucide-react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function parseYmd(value: string): Date | undefined {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const d = parse(value, "yyyy-MM-dd", new Date());
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
function formatYmd(d: Date): string {
|
||||
return format(d, "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
function summarize(from: string, to: string, placeholder: string): string {
|
||||
const df = parseYmd(from);
|
||||
const dt = parseYmd(to);
|
||||
if (!df && !dt) {
|
||||
return placeholder;
|
||||
}
|
||||
const a = df ? format(df, "yyyy年M月d日", { locale: zhCN }) : "…";
|
||||
const b = dt ? format(dt, "yyyy年M月d日", { locale: zhCN }) : "…";
|
||||
return `${a} 至 ${b}`;
|
||||
}
|
||||
|
||||
/** shadcn Popover + Calendar `mode="range"`;输出与原先两个 `yyyy-MM-dd` 筛选字段兼容 */
|
||||
export function AdminDateRangeField({
|
||||
id,
|
||||
label,
|
||||
from: fromProp,
|
||||
to: toProp,
|
||||
onRangeChange,
|
||||
placeholder = "选择日期范围",
|
||||
}: {
|
||||
id: string;
|
||||
label?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
onRangeChange: (next: { from: string; to: string }) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const selected = React.useMemo((): DateRange | undefined => {
|
||||
const df = parseYmd(fromProp);
|
||||
const dt = parseYmd(toProp);
|
||||
if (!df && !dt) {
|
||||
return undefined;
|
||||
}
|
||||
if (df && !dt) {
|
||||
return { from: df };
|
||||
}
|
||||
if (df && dt) {
|
||||
return { from: df, to: dt };
|
||||
}
|
||||
return dt ? { from: dt } : undefined;
|
||||
}, [fromProp, toProp]);
|
||||
|
||||
const hasSelection = Boolean(parseYmd(fromProp) || parseYmd(toProp));
|
||||
const defaultMonth = selected?.from ?? selected?.to ?? new Date();
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
{label ? (
|
||||
<Label htmlFor={id} className="leading-none">
|
||||
{label}
|
||||
</Label>
|
||||
) : null}
|
||||
<Popover
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
id={id}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "default" }),
|
||||
"h-8 min-h-8 w-full justify-start gap-2 px-2.5 font-normal md:text-sm",
|
||||
!hasSelection && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarRange className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{summarize(fromProp, toProp, placeholder)}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0">
|
||||
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
|
||||
先选开始日,再选结束日(单日可对同一天点两次);点「完成」关闭面板。
|
||||
</p>
|
||||
<Calendar
|
||||
mode="range"
|
||||
locale={zhCN}
|
||||
captionLayout="dropdown"
|
||||
selected={selected}
|
||||
defaultMonth={defaultMonth}
|
||||
numberOfMonths={isMobile ? 1 : 2}
|
||||
onSelect={(range) => {
|
||||
if (!range?.from && !range?.to) {
|
||||
onRangeChange({ from: "", to: "" });
|
||||
return;
|
||||
}
|
||||
const nf = range.from ? formatYmd(range.from) : "";
|
||||
const nt = range.to ? formatYmd(range.to) : "";
|
||||
onRangeChange({ from: nf, to: nt });
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 border-t px-2 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 px-2"
|
||||
onClick={() => {
|
||||
onRangeChange({ from: "", to: "" });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="h-7 px-2"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
src/components/admin/admin-list-pagination-footer.tsx
Normal file
198
src/components/admin/admin-list-pagination-footer.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const ADMIN_LIST_PER_PAGE_OPTIONS = [10, 20, 25, 50, 100] as const;
|
||||
|
||||
/** 服务端分页页码序列(省略号);与 shadcn Pagination 拼装用 */
|
||||
export function adminListPageSlices(
|
||||
current: number,
|
||||
last: number,
|
||||
delta = 1,
|
||||
): (number | "ellipsis")[] {
|
||||
if (last <= 1) {
|
||||
return last === 1 ? [1] : [];
|
||||
}
|
||||
const cap = delta * 2 + 3;
|
||||
if (last <= cap) {
|
||||
return Array.from({ length: last }, (_, i) => i + 1);
|
||||
}
|
||||
const set = new Set<number>([1, last]);
|
||||
for (let p = current - delta; p <= current + delta; p++) {
|
||||
if (p >= 1 && p <= last) {
|
||||
set.add(p);
|
||||
}
|
||||
}
|
||||
const sorted = [...set].sort((a, b) => a - b);
|
||||
const out: (number | "ellipsis")[] = [];
|
||||
let prev = 0;
|
||||
for (const p of sorted) {
|
||||
if (prev && p - prev > 1) {
|
||||
out.push("ellipsis");
|
||||
}
|
||||
out.push(p);
|
||||
prev = p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function AdminPerPagePicker({
|
||||
selectId,
|
||||
perPage,
|
||||
onChange,
|
||||
}: {
|
||||
/** 表单控件 id · 区分同页多块列表 */
|
||||
selectId: string;
|
||||
perPage: number;
|
||||
onChange: (next: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={selectId} className="text-sm leading-none whitespace-nowrap">
|
||||
每页条数
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={String(perPage)}
|
||||
onValueChange={(v) => {
|
||||
if (v == null || v === "") {
|
||||
return;
|
||||
}
|
||||
onChange(Number(v));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={selectId} size="sm" className="w-[6.75rem]">
|
||||
<SelectValue placeholder="请选择" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格底栏:左统计 + 右「每页条数」与 shadcn Pagination(与 Data Table pagination 一节一致) */
|
||||
export function AdminListPaginationFooter({
|
||||
selectId,
|
||||
total,
|
||||
page,
|
||||
lastPage,
|
||||
perPage,
|
||||
loading,
|
||||
onPerPageChange,
|
||||
onPageChange,
|
||||
}: {
|
||||
selectId: string;
|
||||
total: number;
|
||||
page: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
loading: boolean;
|
||||
onPerPageChange: (nextPerPage: number) => void;
|
||||
onPageChange: (nextPage: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground text-sm tabular-nums">
|
||||
共 {total} 条;第 {page} / {lastPage} 页
|
||||
</p>
|
||||
<div className="flex w-full flex-col items-stretch gap-4 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end lg:gap-6">
|
||||
<AdminPerPagePicker
|
||||
selectId={selectId}
|
||||
perPage={perPage}
|
||||
onChange={(next) => {
|
||||
onPerPageChange(next);
|
||||
}}
|
||||
/>
|
||||
{lastPage > 1 ? (
|
||||
<Pagination className="mx-0 flex w-full max-w-none justify-center sm:w-auto sm:justify-end md:justify-end">
|
||||
<PaginationContent className="flex-wrap justify-center md:justify-end">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
text="上一页"
|
||||
aria-disabled={page <= 1 || loading}
|
||||
className={cn(
|
||||
(page <= 1 || loading) && "pointer-events-none opacity-50",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (page <= 1 || loading) {
|
||||
return;
|
||||
}
|
||||
onPageChange(Math.max(1, page - 1));
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{adminListPageSlices(page, lastPage).map((item, idx) =>
|
||||
item === "ellipsis" ? (
|
||||
<PaginationItem key={`${selectId}-e-${idx}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={`${selectId}-${item}`}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
size="sm"
|
||||
isActive={item === page}
|
||||
className="min-w-8 tabular-nums"
|
||||
aria-disabled={loading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
onPageChange(item);
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
text="下一页"
|
||||
aria-disabled={page >= lastPage || loading}
|
||||
className={cn(
|
||||
(page >= lastPage || loading) && "pointer-events-none opacity-50",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (page >= lastPage || loading) {
|
||||
return;
|
||||
}
|
||||
onPageChange(Math.min(lastPage, page + 1));
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
src/components/ui/calendar.tsx
Normal file
221
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
type Locale,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
locale,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={locale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString(locale?.code, { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative rounded-(--cell-radius)",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute inset-0 bg-popover opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-(--cell-size) select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] text-muted-foreground select-none",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn(
|
||||
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_end
|
||||
),
|
||||
today: cn(
|
||||
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: ({ ...props }) => (
|
||||
<CalendarDayButton locale={locale} {...props} />
|
||||
),
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
130
src/components/ui/pagination.tsx
Normal file
130
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex items-center gap-0.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<Button
|
||||
variant={isActive ? "outline" : "ghost"}
|
||||
size={size}
|
||||
className={cn(className)}
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
text = "Previous",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("pl-1.5!", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon data-icon="inline-start" />
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
text = "Next",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("pr-1.5!", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">{text}</span>
|
||||
<ChevronRightIcon data-icon="inline-end" />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon
|
||||
/>
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
90
src/components/ui/popover.tsx
Normal file
90
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
@@ -38,6 +38,7 @@ function SelectTrigger({
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
type="button"
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user