Yearly was a single Month dropdown + a Day number input — one Month and
one Day per rule. That meant "every quarter on the 1st" needed four
separate schedule rows.
Now Yearly mirrors Monthly's grid pattern but with two grids:
Months [Jan][Feb][Mar][Apr][May][Jun]
[Jul][Aug][Sep][Oct][Nov][Dec]
Days [ 1][ 2][ 3]...[31] (7×5 grid)
Both grids are multi-select. Cron output uses the comma-list form on
both DOM and month positions:
months: [1,4,7,10] + days: [1] → "0 9 1 1,4,7,10 *"
months: [12] + days: [24,25,31] → "0 9 24,25,31 12 *"
The cron field is a Cartesian product — every selected day fires in
every selected month. So "every quarter on the 1st" is now one rule.
Round-trip: parser accepts comma-lists for both DOM and month, with
single-element shapes (the old "0 9 13 5 *") still loading fine.
Migration of saved data: old yearly rules with one DOM + one month
parse into monthDays=[X], months=[Y] — identical visual selection in
the new grid, identical cron output. No DB changes needed.
Renamed `Draft.month` to `Draft.months: number[]`. The "Single
day-of-month for yearly" field is gone — yearly now reads
`monthDays` (same as monthly).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
588 lines
20 KiB
TypeScript
588 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { DateTime } from "luxon";
|
|
import { PlusIcon, RepeatIcon, Trash2Icon } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
WEEKDAY_LABELS,
|
|
isoWeekdayToCron,
|
|
type RecurrenceSpec,
|
|
} from "@/lib/recurrence";
|
|
|
|
interface RecurrencePickerProps {
|
|
/**
|
|
* First fire — only used to seed defaults for newly-added rules
|
|
* (initial weekday, monthday, time). Each rule then carries its
|
|
* own hour/minute, so changing the date+time inputs above no
|
|
* longer reshapes existing rules.
|
|
*/
|
|
firstFire: DateTime;
|
|
value: RecurrenceSpec;
|
|
onChange: (next: RecurrenceSpec) => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-row draft (one recurring rule)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type RuleType = "daily" | "weekly" | "monthly" | "yearly";
|
|
|
|
interface Draft {
|
|
type: RuleType;
|
|
/** Cron weekday list (0=Sun..6=Sat). */
|
|
weekdays: number[];
|
|
/** Sorted unique day-of-month list (1-31). Used by monthly + yearly. */
|
|
monthDays: number[];
|
|
/** Sorted unique month list (1-12). Used by yearly only. */
|
|
months: number[];
|
|
/** Hour-of-day (0-23) for this rule's fire time. */
|
|
hour: number;
|
|
/** Minute-of-hour (0-59). */
|
|
minute: number;
|
|
}
|
|
|
|
const MAX_RULES = 8;
|
|
|
|
function defaultDraft(firstFire: DateTime): Draft {
|
|
return {
|
|
type: "daily",
|
|
weekdays: [isoWeekdayToCron(firstFire.weekday)],
|
|
monthDays: [firstFire.day],
|
|
months: [firstFire.month],
|
|
hour: firstFire.hour,
|
|
minute: firstFire.minute,
|
|
};
|
|
}
|
|
|
|
function uniqSortedDays(days: number[]): number[] {
|
|
return Array.from(new Set(days.filter((d) => d >= 1 && d <= 31))).sort((a, b) => a - b);
|
|
}
|
|
|
|
function uniqSortedMonths(months: number[]): number[] {
|
|
return Array.from(new Set(months.filter((m) => m >= 1 && m <= 12))).sort((a, b) => a - b);
|
|
}
|
|
|
|
function pad2(n: number): string {
|
|
return n.toString().padStart(2, "0");
|
|
}
|
|
|
|
function parseHHMM(s: string): { hour: number; minute: number } | null {
|
|
const m = s.match(/^(\d{1,2}):(\d{2})$/);
|
|
if (!m) return null;
|
|
const h = Number(m[1]);
|
|
const min = Number(m[2]);
|
|
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
|
return { hour: h, minute: min };
|
|
}
|
|
|
|
function clamp(n: number, lo: number, hi: number): number {
|
|
if (!Number.isFinite(n)) return lo;
|
|
return Math.min(Math.max(Math.floor(n), lo), hi);
|
|
}
|
|
|
|
const MONTH_NAMES = [
|
|
"January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December",
|
|
];
|
|
|
|
function draftToCron(d: Draft): string | null {
|
|
const m = clamp(d.minute, 0, 59);
|
|
const h = clamp(d.hour, 0, 23);
|
|
switch (d.type) {
|
|
case "daily":
|
|
return `${m} ${h} * * *`;
|
|
case "weekly":
|
|
if (!d.weekdays.length) return null;
|
|
return `${m} ${h} * * ${d.weekdays.slice().sort((a, b) => a - b).join(",")}`;
|
|
case "monthly": {
|
|
const days = uniqSortedDays(d.monthDays);
|
|
if (!days.length) return null;
|
|
return `${m} ${h} ${days.join(",")} * *`;
|
|
}
|
|
case "yearly": {
|
|
const days = uniqSortedDays(d.monthDays);
|
|
const months = uniqSortedMonths(d.months);
|
|
if (!days.length || !months.length) return null;
|
|
return `${m} ${h} ${days.join(",")} ${months.join(",")} *`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function describeDraft(d: Draft): string {
|
|
const t = `${pad2(clamp(d.hour, 0, 23))}:${pad2(clamp(d.minute, 0, 59))}`;
|
|
switch (d.type) {
|
|
case "daily":
|
|
return `Every day at ${t}`;
|
|
case "weekly": {
|
|
if (!d.weekdays.length) return "Pick at least one weekday";
|
|
const labels = d.weekdays
|
|
.slice()
|
|
.sort((a, b) => a - b)
|
|
.map((c) => {
|
|
const iso = c === 0 ? 7 : c;
|
|
return WEEKDAY_LABELS[iso - 1]?.short ?? "";
|
|
})
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
return `Every week on ${labels} at ${t}`;
|
|
}
|
|
case "monthly": {
|
|
const days = uniqSortedDays(d.monthDays);
|
|
if (!days.length) return "Pick at least one day";
|
|
const list =
|
|
days.length <= 6
|
|
? days.join(", ")
|
|
: `${days.slice(0, 6).join(", ")} +${days.length - 6} more`;
|
|
return `Every month on day${days.length > 1 ? "s" : ""} ${list} at ${t}`;
|
|
}
|
|
case "yearly": {
|
|
const days = uniqSortedDays(d.monthDays);
|
|
const months = uniqSortedMonths(d.months);
|
|
if (!days.length) return "Pick at least one day";
|
|
if (!months.length) return "Pick at least one month";
|
|
const monthLabel = months.map((mo) => MONTH_NAMES[mo - 1]?.slice(0, 3)).join(", ");
|
|
const dayLabel =
|
|
days.length <= 6
|
|
? days.join(", ")
|
|
: `${days.slice(0, 6).join(", ")} +${days.length - 6} more`;
|
|
return `Every year in ${monthLabel} on day${days.length > 1 ? "s" : ""} ${dayLabel} at ${t}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Reverse-engineer a single cron expression into a Draft. Falls back to
|
|
* daily-every-day if the expression doesn't match a known shape. */
|
|
function draftFromCronExpr(expr: string, firstFire: DateTime): Draft {
|
|
const base = defaultDraft(firstFire);
|
|
// Pull MM HH off the front so each rule restores its own time.
|
|
const head = expr.match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
if (!head) return base;
|
|
const minute = clamp(Number(head[1]), 0, 59);
|
|
const hour = clamp(Number(head[2]), 0, 23);
|
|
const rest = head[3]!.trim();
|
|
|
|
let m: RegExpMatchArray | null;
|
|
if (rest === "* * *") {
|
|
return { ...base, type: "daily", hour, minute };
|
|
}
|
|
// Any DOW list (including the legacy "1-5" weekday-only daily rule)
|
|
// round-trips as a Weekly draft.
|
|
if ((m = rest.match(/^\* \* ([0-9,\-]+)$/))) {
|
|
const days = m[1]!
|
|
.split(",")
|
|
.flatMap((p) => {
|
|
const r = p.match(/^(\d+)-(\d+)$/);
|
|
if (r) {
|
|
const out: number[] = [];
|
|
for (let i = Number(r[1]); i <= Number(r[2]); i++) out.push(i);
|
|
return out;
|
|
}
|
|
return [Number(p)];
|
|
})
|
|
.filter((n) => n >= 0 && n <= 6);
|
|
return { ...base, type: "weekly", weekdays: days, hour, minute };
|
|
}
|
|
if ((m = rest.match(/^([0-9,]+) \* \*$/))) {
|
|
const days = uniqSortedDays(m[1]!.split(",").map((s) => Number(s)));
|
|
return {
|
|
...base,
|
|
type: "monthly",
|
|
monthDays: days.length ? days : [1],
|
|
hour,
|
|
minute,
|
|
};
|
|
}
|
|
if ((m = rest.match(/^([0-9,]+) ([0-9,]+) \*$/))) {
|
|
const days = uniqSortedDays(m[1]!.split(",").map((s) => Number(s)));
|
|
const months = uniqSortedMonths(m[2]!.split(",").map((s) => Number(s)));
|
|
return {
|
|
...base,
|
|
type: "yearly",
|
|
monthDays: days.length ? days : [1],
|
|
months: months.length ? months : [1],
|
|
hour,
|
|
minute,
|
|
};
|
|
}
|
|
return { ...base, hour, minute };
|
|
}
|
|
|
|
/** Parse a (possibly multi-line) cron rule into an array of drafts. */
|
|
function draftsFromRule(rule: string | null | undefined, firstFire: DateTime): Draft[] {
|
|
if (!rule) return [];
|
|
const expr = rule.startsWith("CRON:") ? rule.slice(5) : rule;
|
|
const lines = expr.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
if (lines.length === 0) return [];
|
|
return lines.map((line) => draftFromCronExpr(line, firstFire));
|
|
}
|
|
|
|
/** Compile an array of drafts to a single multi-line CRON: rule, or null
|
|
* if there are no rules (= one-off). */
|
|
function draftsToRule(drafts: Draft[]): string | null {
|
|
if (drafts.length === 0) return null;
|
|
const exprs = drafts
|
|
.map((d) => draftToCron(d))
|
|
.filter((s): s is string => Boolean(s));
|
|
if (exprs.length === 0) return null;
|
|
return exprs.join("\n");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The picker
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Inline recurrence picker. Lives in the When form right under the
|
|
* date+time inputs.
|
|
*
|
|
* Repeats
|
|
* ┌─────────────────────────────────────────────────────┐
|
|
* │ Schedule 1 [✕ remove] │
|
|
* │ [Daily] [Weekly] [Monthly] [Yearly] │
|
|
* │ <per-tab config> │
|
|
* ├─────────────────────────────────────────────────────┤
|
|
* │ Schedule 2 [✕ remove] │
|
|
* │ [Daily] [Weekly] [Monthly] [Yearly] │
|
|
* │ <per-tab config> │
|
|
* └─────────────────────────────────────────────────────┘
|
|
* [+ Add another schedule]
|
|
*
|
|
* Fires:
|
|
* • Every weekday at 09:00
|
|
* • Every Friday at 17:00
|
|
*
|
|
* Zero rules = "Don't repeat" (fires once at date+time and ends).
|
|
* Adding a rule emits a `{ kind: "cron", cron: "<line1>\n<line2>" }`
|
|
* spec; the bot's `nextOccurrence` already supports newline-joined
|
|
* cron and returns the earliest next fire across all rules.
|
|
*/
|
|
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
|
|
const [drafts, setDrafts] = useState<Draft[]>(() =>
|
|
draftsFromRule(value.kind === "cron" ? value.cron ?? null : null, firstFire),
|
|
);
|
|
|
|
// Compile drafts back to the parent value whenever they change. Each
|
|
// rule carries its own hour/minute now, so the date+time inputs above
|
|
// no longer drive cron output — they only set the very first fire.
|
|
useEffect(() => {
|
|
const rule = draftsToRule(drafts);
|
|
if (!rule) {
|
|
if (value.kind !== "none") {
|
|
onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
|
|
}
|
|
return;
|
|
}
|
|
if (value.kind !== "cron" || value.cron !== rule) {
|
|
onChange({
|
|
kind: "cron",
|
|
interval: 1,
|
|
weeklyDays: [],
|
|
cron: rule,
|
|
end: { kind: "never" },
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [drafts]);
|
|
|
|
function updateDraft(idx: number, patch: Partial<Draft>) {
|
|
setDrafts((prev) => prev.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
|
|
}
|
|
function addRule() {
|
|
if (drafts.length >= MAX_RULES) return;
|
|
setDrafts((prev) => [...prev, defaultDraft(firstFire)]);
|
|
}
|
|
function removeRule(idx: number) {
|
|
setDrafts((prev) => prev.filter((_, i) => i !== idx));
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label className="flex items-center gap-1.5">
|
|
<RepeatIcon className="size-3.5" />
|
|
Repeats
|
|
</Label>
|
|
|
|
{drafts.length === 0 ? (
|
|
<div className="rounded-xl border border-border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
|
|
No Repeats
|
|
</div>
|
|
) : (
|
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
|
{drafts.map((draft, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={cn(
|
|
"p-3 space-y-3",
|
|
idx > 0 && "border-t border-border",
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
Schedule {idx + 1}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 px-2 text-muted-foreground hover:text-destructive"
|
|
onClick={() => removeRule(idx)}
|
|
aria-label={`Remove schedule ${idx + 1}`}
|
|
>
|
|
<Trash2Icon className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
|
|
<RuleEditor
|
|
draft={draft}
|
|
onChange={(patch) => updateDraft(idx, patch)}
|
|
/>
|
|
|
|
<p className="rounded-lg bg-primary/5 px-2.5 py-1.5 text-xs text-primary/80">
|
|
{describeDraft(draft)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={addRule}
|
|
disabled={drafts.length >= MAX_RULES}
|
|
className="gap-1.5"
|
|
>
|
|
<PlusIcon className="size-3.5" />
|
|
{drafts.length === 0 ? "Add a recurring schedule" : "Add another schedule"}
|
|
</Button>
|
|
{drafts.length >= MAX_RULES && (
|
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
Up to {MAX_RULES} schedules per reminder
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Single-rule editor (tabs + per-type config)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface RuleEditorProps {
|
|
draft: Draft;
|
|
onChange: (patch: Partial<Draft>) => void;
|
|
}
|
|
|
|
function RuleEditor({ draft, onChange }: RuleEditorProps) {
|
|
return (
|
|
<Tabs
|
|
value={draft.type}
|
|
onValueChange={(v) => onChange({ type: v as RuleType })}
|
|
>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="daily">Daily</TabsTrigger>
|
|
<TabsTrigger value="weekly">Weekly</TabsTrigger>
|
|
<TabsTrigger value="monthly">Monthly</TabsTrigger>
|
|
<TabsTrigger value="yearly">Yearly</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="daily" className="space-y-3 pt-3">
|
|
<p className="text-xs text-muted-foreground">
|
|
Fires once a day at the time below.
|
|
</p>
|
|
<TimeField draft={draft} onChange={onChange} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="weekly" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<Label className="text-sm">On these days</Label>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{WEEKDAY_LABELS.map(({ iso, short }) => {
|
|
const cronDow = isoWeekdayToCron(iso);
|
|
const active = draft.weekdays.includes(cronDow);
|
|
return (
|
|
<button
|
|
key={iso}
|
|
type="button"
|
|
onClick={() =>
|
|
onChange({
|
|
weekdays: active
|
|
? draft.weekdays.filter((d) => d !== cronDow)
|
|
: [...draft.weekdays, cronDow].sort((a, b) => a - b),
|
|
})
|
|
}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
|
|
active
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
|
)}
|
|
>
|
|
{short}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<TimeField draft={draft} onChange={onChange} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="monthly" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="text-sm">Days of the month</Label>
|
|
<span className="text-xs text-muted-foreground">
|
|
{draft.monthDays.length} selected
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => {
|
|
const active = draft.monthDays.includes(day);
|
|
return (
|
|
<button
|
|
key={day}
|
|
type="button"
|
|
onClick={() =>
|
|
onChange({
|
|
monthDays: active
|
|
? draft.monthDays.filter((d) => d !== day)
|
|
: uniqSortedDays([...draft.monthDays, day]),
|
|
})
|
|
}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
"inline-flex h-8 items-center justify-center rounded-md border text-xs font-medium transition-colors",
|
|
active
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
|
)}
|
|
>
|
|
{day}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Months without a selected day skip naturally (e.g. day 31 in February).
|
|
</p>
|
|
</div>
|
|
<TimeField draft={draft} onChange={onChange} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="yearly" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="text-sm">Months</Label>
|
|
<span className="text-xs text-muted-foreground">
|
|
{draft.months.length} selected
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-6 gap-1">
|
|
{MONTH_NAMES.map((name, i) => {
|
|
const month = i + 1;
|
|
const active = draft.months.includes(month);
|
|
return (
|
|
<button
|
|
key={month}
|
|
type="button"
|
|
onClick={() =>
|
|
onChange({
|
|
months: active
|
|
? draft.months.filter((mo) => mo !== month)
|
|
: uniqSortedMonths([...draft.months, month]),
|
|
})
|
|
}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
"inline-flex h-8 items-center justify-center rounded-md border text-xs font-medium transition-colors",
|
|
active
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
|
)}
|
|
>
|
|
{name.slice(0, 3)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label className="text-sm">Days</Label>
|
|
<span className="text-xs text-muted-foreground">
|
|
{draft.monthDays.length} selected
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => {
|
|
const active = draft.monthDays.includes(day);
|
|
return (
|
|
<button
|
|
key={day}
|
|
type="button"
|
|
onClick={() =>
|
|
onChange({
|
|
monthDays: active
|
|
? draft.monthDays.filter((d) => d !== day)
|
|
: uniqSortedDays([...draft.monthDays, day]),
|
|
})
|
|
}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
"inline-flex h-8 items-center justify-center rounded-md border text-xs font-medium transition-colors",
|
|
active
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
|
)}
|
|
>
|
|
{day}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Fires on every selected day of every selected month.
|
|
</p>
|
|
</div>
|
|
|
|
<TimeField draft={draft} onChange={onChange} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
);
|
|
}
|
|
|
|
interface TimeFieldProps {
|
|
draft: Draft;
|
|
onChange: (patch: Partial<Draft>) => void;
|
|
}
|
|
|
|
/** Per-rule time picker (HH:MM). Drives the cron expression's MM HH columns. */
|
|
function TimeField({ draft, onChange }: TimeFieldProps) {
|
|
const value = `${pad2(clamp(draft.hour, 0, 23))}:${pad2(clamp(draft.minute, 0, 59))}`;
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">Fires at</Label>
|
|
<Input
|
|
type="time"
|
|
value={value}
|
|
onChange={(e) => {
|
|
const parsed = parseHHMM(e.target.value);
|
|
if (parsed) onChange(parsed);
|
|
}}
|
|
className="h-8 w-32"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|