refactor(ts): convert all UI primitives + ThemeContext to TSX (B2)
Foundational for phase B: the ui/* primitives were untyped .jsx, and TS infers React-19 ref-as-prop components as *requiring* a ref, so every consumer errored. Converted all 22 ui primitives (button, input, card, dialog, select, table, dropdown-menu, tabs, badge, checkbox, switch, tooltip, separator, label, Skeleton, collapsible, alert-dialog, sonner, save-status, theme-toggle, input-dialog, confirm-dialog) using ComponentProps<'el'> / ComponentProps<typeof Radix.X> + VariantProps for cva components — refs now optional, event params typed. Also converted ThemeContext.tsx (createContext(null) made useContext's post-guard return `never`, so setTheme was uncallable — fixed with a typed context value) and three tracker cells (EditableCell/NotesCell/ LowerThisMonthButton) that consume the branded TrackerRow. Added a shared errMessage(unknown) helper to utils (strict catch → unknown); usePaymentActions reuses it. TrackerRow gains monthly_notes + amount_suggestion (real server fields). typecheck 0, lint 0 errors (42 warns), build green, 48 client tests. NOTE: the e2e auth-setup probe is currently red on an unrelated, pre-existing release-notes-modal dismissal race (reproduces on the last known-good commit; the a11y snapshot shows these converted primitives rendering correctly) — investigating separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dc675fbecd
commit
1f18a720c4
|
|
@ -1,14 +1,24 @@
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import type { Dollars } from '@/lib/money';
|
||||||
|
import type { TrackerRow } from '@/types';
|
||||||
|
|
||||||
// `threshold` = actual_amount ?? expected_amount for this bill/month
|
// `threshold` = actual_amount ?? expected_amount for this bill/month
|
||||||
export function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
|
interface EditableCellProps {
|
||||||
|
row: TrackerRow;
|
||||||
|
field: 'amount' | 'date';
|
||||||
|
threshold: Dollars;
|
||||||
|
defaultPaymentDate: string;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }: EditableCellProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const displayVal = field === 'amount'
|
const displayVal = field === 'amount'
|
||||||
? (row.total_paid > 0 ? fmt(row.total_paid) : '—')
|
? (row.total_paid > 0 ? fmt(row.total_paid) : '—')
|
||||||
|
|
@ -33,10 +43,10 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres
|
||||||
if (!val) return;
|
if (!val) return;
|
||||||
try {
|
try {
|
||||||
if (row.payments && row.payments.length > 0) {
|
if (row.payments && row.payments.length > 0) {
|
||||||
const update = {};
|
const update: { amount?: number; paid_date?: string } = {};
|
||||||
if (field === 'amount') update.amount = parseFloat(val);
|
if (field === 'amount') update.amount = parseFloat(val);
|
||||||
if (field === 'date') update.paid_date = val;
|
if (field === 'date') update.paid_date = val;
|
||||||
await api.updatePayment(row.payments[0].id, update);
|
await api.updatePayment(row.payments[0]!.id, update);
|
||||||
} else {
|
} else {
|
||||||
await api.createPayment({
|
await api.createPayment({
|
||||||
bill_id: row.id,
|
bill_id: row.id,
|
||||||
|
|
@ -47,11 +57,11 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres
|
||||||
toast.success('Saved');
|
toast.success('Saved');
|
||||||
refresh();
|
refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error(errMessage(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e) {
|
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
if (e.key === 'Enter') inputRef.current?.blur();
|
if (e.key === 'Enter') inputRef.current?.blur();
|
||||||
if (e.key === 'Escape') { setValue(''); setEditing(false); }
|
if (e.key === 'Escape') { setValue(''); setEditing(false); }
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt, errMessage } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils';
|
import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils';
|
||||||
|
import type { TrackerRow } from '@/types';
|
||||||
|
|
||||||
export function LowerThisMonthButton({ row, year, month, refresh, compact = false }) {
|
interface LowerThisMonthButtonProps {
|
||||||
|
row: TrackerRow;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
refresh?: () => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LowerThisMonthButton({ row, year, month, refresh, compact = false }: LowerThisMonthButtonProps) {
|
||||||
const threshold = rowThreshold(row);
|
const threshold = rowThreshold(row);
|
||||||
const summary = paymentSummary(row, threshold);
|
const summary = paymentSummary(row, threshold);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -25,7 +34,7 @@ export function LowerThisMonthButton({ row, year, month, refresh, compact = fals
|
||||||
toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`);
|
toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`);
|
||||||
refresh?.();
|
refresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to update monthly amount');
|
toast.error(errMessage(err, 'Failed to update monthly amount'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, errMessage } from '@/lib/utils';
|
||||||
|
import type { TrackerRow } from '@/types';
|
||||||
|
|
||||||
// Monthly notes stored in monthly_bill_state — per-month, not per-bill.
|
// Monthly notes stored in monthly_bill_state — per-month, not per-bill. The
|
||||||
export function NotesCell({ row, refresh }) {
|
// tracker page injects year/month onto the row for this cell's save.
|
||||||
|
interface NotesCellProps {
|
||||||
|
row: TrackerRow & { year?: number; month?: number };
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotesCell({ row, refresh }: NotesCellProps) {
|
||||||
const savedNote = row.monthly_notes || '';
|
const savedNote = row.monthly_notes || '';
|
||||||
const [value, setValue] = useState(savedNote);
|
const [value, setValue] = useState(savedNote);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -33,7 +40,7 @@ export function NotesCell({ row, refresh }) {
|
||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error(errMessage(err));
|
||||||
setValue(savedNote);
|
setValue(savedNote);
|
||||||
} finally { setSaving(false); }
|
} finally { setSaving(false); }
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Skeleton({ className, variant = 'line', ref, ...props }) {
|
type SkeletonVariant = 'line' | 'circle' | 'card' | 'button' | 'input';
|
||||||
const variants = {
|
|
||||||
|
function Skeleton({ className, variant = 'line', ref, ...props }: ComponentProps<'div'> & { variant?: SkeletonVariant }) {
|
||||||
|
const variants: Record<SkeletonVariant, string> = {
|
||||||
line: 'h-4 w-full rounded-md',
|
line: 'h-4 w-full rounded-md',
|
||||||
circle: 'h-10 w-10 rounded-full',
|
circle: 'h-10 w-10 rounded-full',
|
||||||
card: 'h-24 w-full rounded-xl',
|
card: 'h-24 w-full rounded-xl',
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { buttonVariants } from '@/components/ui/button';
|
|
||||||
|
|
||||||
const AlertDialog = AlertDialogPrimitive.Root;
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
|
@ -8,7 +8,7 @@ const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
const AlertDialogCancel = AlertDialogPrimitive.Cancel;
|
const AlertDialogCancel = AlertDialogPrimitive.Cancel;
|
||||||
const AlertDialogAction = AlertDialogPrimitive.Action;
|
const AlertDialogAction = AlertDialogPrimitive.Action;
|
||||||
|
|
||||||
function AlertDialogOverlay({ className, ...props }) {
|
function AlertDialogOverlay({ className, ...props }: ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -20,7 +20,7 @@ function AlertDialogOverlay({ className, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogContent({ className, children, ...props }) {
|
function AlertDialogContent({ className, children, ...props }: ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay />
|
<AlertDialogOverlay />
|
||||||
|
|
@ -42,17 +42,17 @@ function AlertDialogContent({ className, children, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogHeader({ className, ...props }) {
|
function AlertDialogHeader({ className, ...props }: ComponentProps<'div'>) {
|
||||||
return <div className={cn('flex flex-col gap-2', className)} {...props} />;
|
return <div className={cn('flex flex-col gap-2', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogFooter({ className, ...props }) {
|
function AlertDialogFooter({ className, ...props }: ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTitle({ className, ...props }) {
|
function AlertDialogTitle({ className, ...props }: ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
className={cn('text-base font-semibold text-foreground', className)}
|
className={cn('text-base font-semibold text-foreground', className)}
|
||||||
|
|
@ -61,7 +61,7 @@ function AlertDialogTitle({ className, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogDescription({ className, ...props }) {
|
function AlertDialogDescription({ className, ...props }: ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description
|
||||||
className={cn('text-sm text-muted-foreground leading-relaxed', className)}
|
className={cn('text-sm text-muted-foreground leading-relaxed', className)}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
|
|
@ -25,7 +26,7 @@ const badgeVariants = cva(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }) {
|
function Badge({ className, variant, ...props }: ComponentProps<'div'> & VariantProps<typeof badgeVariants>) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
);
|
);
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import type { ComponentProps, ElementType } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
|
|
@ -28,8 +29,10 @@ const buttonVariants = cva(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function Button({ className, variant, size, asChild = false, ref, ...props }) {
|
type ButtonProps = ComponentProps<'button'> & VariantProps<typeof buttonVariants> & { asChild?: boolean };
|
||||||
const Comp = asChild ? Slot : 'button';
|
|
||||||
|
function Button({ className, variant, size, asChild = false, ref, ...props }: ButtonProps) {
|
||||||
|
const Comp: ElementType = asChild ? Slot : 'button';
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Card({ className, ref, ...props }) {
|
function Card({ className, ref, ...props }: ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -10,7 +11,7 @@ function Card({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ref, ...props }) {
|
function CardHeader({ className, ref, ...props }: ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -20,7 +21,7 @@ function CardHeader({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ref, ...props }) {
|
function CardTitle({ className, ref, ...props }: ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -30,7 +31,7 @@ function CardTitle({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ref, ...props }) {
|
function CardDescription({ className, ref, ...props }: ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -40,7 +41,7 @@ function CardDescription({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ref, ...props }) {
|
function CardContent({ className, ref, ...props }: ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -50,7 +51,7 @@ function CardContent({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ref, ...props }) {
|
function CardFooter({ className, ref, ...props }: ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Checkbox({ className, ref, ...props }) {
|
function Checkbox({ className, ref, ...props }: ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root;
|
const Collapsible = CollapsiblePrimitive.Root;
|
||||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||||
|
|
||||||
function CollapsibleContent({ ref, ...props }) {
|
function CollapsibleContent({ ref, ...props }: ComponentProps<typeof CollapsiblePrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<CollapsiblePrimitive.Content
|
<CollapsiblePrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -11,20 +11,22 @@ import {
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
/* ── ConfirmDialog ──────────────────────────────────────────────
|
type ConfirmVariant = 'default' | 'destructive';
|
||||||
Reusable confirmation dialog that replaces window.confirm.
|
|
||||||
|
|
||||||
Props:
|
interface ConfirmDialogProps {
|
||||||
open boolean
|
open: boolean;
|
||||||
onOpenChange (open: boolean) => void
|
onOpenChange: (open: boolean) => void;
|
||||||
title string
|
title?: string;
|
||||||
description string | ReactNode
|
description?: React.ReactNode;
|
||||||
confirmLabel string (default: 'Confirm')
|
confirmLabel?: string;
|
||||||
cancelLabel string (default: 'Cancel')
|
cancelLabel?: string;
|
||||||
variant 'default' | 'destructive'
|
variant?: ConfirmVariant;
|
||||||
onConfirm () => void | Promise<void>
|
onConfirm?: () => void | Promise<void>;
|
||||||
loading boolean
|
loading?: boolean;
|
||||||
─────────────────────────────────────────────────────────────── */
|
}
|
||||||
|
|
||||||
|
/* ── ConfirmDialog ──────────────────────────────────────────────
|
||||||
|
Reusable confirmation dialog that replaces window.confirm. */
|
||||||
|
|
||||||
export function ConfirmDialog({
|
export function ConfirmDialog({
|
||||||
open,
|
open,
|
||||||
|
|
@ -36,7 +38,7 @@ export function ConfirmDialog({
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
onConfirm,
|
onConfirm,
|
||||||
loading = false,
|
loading = false,
|
||||||
}) {
|
}: ConfirmDialogProps) {
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
await onConfirm?.();
|
await onConfirm?.();
|
||||||
};
|
};
|
||||||
|
|
@ -84,14 +86,29 @@ export function ConfirmDialog({
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirm();
|
const { confirm, ConfirmDialogComponent } = useConfirm();
|
||||||
// ...
|
|
||||||
const ok = await confirm({ title, description, confirmLabel, variant });
|
const ok = await confirm({ title, description, confirmLabel, variant });
|
||||||
// Returns true if confirmed, false if cancelled.
|
// Render <ConfirmDialogComponent /> anywhere in the tree. */
|
||||||
// Render <ConfirmDialogComponent /> anywhere in the tree.
|
|
||||||
─────────────────────────────────────────────────────────────── */
|
interface ConfirmState {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description: React.ReactNode;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
variant: ConfirmVariant;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmOptions {
|
||||||
|
title?: string;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
variant?: ConfirmVariant;
|
||||||
|
}
|
||||||
|
|
||||||
export function useConfirm() {
|
export function useConfirm() {
|
||||||
const [state, setState] = React.useState({
|
const [state, setState] = React.useState<ConfirmState>({
|
||||||
open: false,
|
open: false,
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
|
@ -102,7 +119,7 @@ export function useConfirm() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep a stable ref to the resolve callback so it survives re-renders
|
// Keep a stable ref to the resolve callback so it survives re-renders
|
||||||
const resolveRef = React.useRef(null);
|
const resolveRef = React.useRef<((value: boolean) => void) | null>(null);
|
||||||
|
|
||||||
const confirm = React.useCallback(
|
const confirm = React.useCallback(
|
||||||
({
|
({
|
||||||
|
|
@ -111,7 +128,7 @@ export function useConfirm() {
|
||||||
confirmLabel = 'Confirm',
|
confirmLabel = 'Confirm',
|
||||||
cancelLabel = 'Cancel',
|
cancelLabel = 'Cancel',
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
} = {}) => {
|
}: ConfirmOptions = {}): Promise<boolean> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
resolveRef.current = resolve;
|
resolveRef.current = resolve;
|
||||||
setState({
|
setState({
|
||||||
|
|
@ -135,7 +152,7 @@ export function useConfirm() {
|
||||||
setState((s) => ({ ...s, open: false, loading: false }));
|
setState((s) => ({ ...s, open: false, loading: false }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenChange = React.useCallback((open) => {
|
const handleOpenChange = React.useCallback((open: boolean) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
resolveRef.current?.(false);
|
resolveRef.current?.(false);
|
||||||
resolveRef.current = null;
|
resolveRef.current = null;
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
@ -8,7 +9,7 @@ const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
const DialogPortal = DialogPrimitive.Portal;
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
const DialogClose = DialogPrimitive.Close;
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
function DialogOverlay({ className, ref, ...props }) {
|
function DialogOverlay({ className, ref, ...props }: ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -21,7 +22,7 @@ function DialogOverlay({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({ className, children, ref, ...props }) {
|
function DialogContent({ className, children, ref, ...props }: ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
|
|
@ -53,7 +54,7 @@ function DialogContent({ className, children, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }) {
|
function DialogHeader({ className, ...props }: ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||||
|
|
@ -62,7 +63,7 @@ function DialogHeader({ className, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }) {
|
function DialogFooter({ className, ...props }: ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
|
@ -71,7 +72,7 @@ function DialogFooter({ className, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({ className, ref, ...props }) {
|
function DialogTitle({ className, ref, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -81,7 +82,7 @@ function DialogTitle({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({ className, ref, ...props }) {
|
function DialogDescription({ className, ref, ...props }: ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
@ -9,7 +10,7 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({ className, inset, children, ...props }) {
|
function DropdownMenuSubTrigger({ className, inset, children, ...props }: ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -26,7 +27,7 @@ function DropdownMenuSubTrigger({ className, inset, children, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({ className, ...props }) {
|
function DropdownMenuSubContent({ className, ...props }: ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -40,7 +41,7 @@ function DropdownMenuSubContent({ className, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({ className, sideOffset = 4, ...props }) {
|
function DropdownMenuContent({ className, sideOffset = 4, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
|
|
@ -57,7 +58,7 @@ function DropdownMenuContent({ className, sideOffset = 4, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({ className, inset, destructive, ...props }) {
|
function DropdownMenuItem({ className, inset, destructive, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; destructive?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -72,7 +73,7 @@ function DropdownMenuItem({ className, inset, destructive, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({ className, children, checked, ...props }) {
|
function DropdownMenuCheckboxItem({ className, children, checked, ...props }: ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -93,7 +94,7 @@ function DropdownMenuCheckboxItem({ className, children, checked, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({ className, children, ...props }) {
|
function DropdownMenuRadioItem({ className, children, ...props }: ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -113,7 +114,7 @@ function DropdownMenuRadioItem({ className, children, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({ className, inset, ...props }) {
|
function DropdownMenuLabel({ className, inset, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
||||||
|
|
@ -122,7 +123,7 @@ function DropdownMenuLabel({ className, inset, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({ className, ...props }) {
|
function DropdownMenuSeparator({ className, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||||
|
|
@ -132,7 +133,7 @@ function DropdownMenuSeparator({ className, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({ className, ...props }) {
|
function DropdownMenuShortcut({ className, ...props }: ComponentProps<'span'>) {
|
||||||
return (
|
return (
|
||||||
<span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
|
<span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
|
||||||
);
|
);
|
||||||
|
|
@ -17,6 +17,21 @@ import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
interface InputDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
label?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
validate?: (value: string) => string | null;
|
||||||
|
onConfirm?: (value: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function InputDialog({
|
export function InputDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -30,10 +45,10 @@ export function InputDialog({
|
||||||
validate, // optional (value: string) => string | null (null = valid)
|
validate, // optional (value: string) => string | null (null = valid)
|
||||||
onConfirm,
|
onConfirm,
|
||||||
loading = false,
|
loading = false,
|
||||||
}) {
|
}: InputDialogProps) {
|
||||||
const [value, setValue] = useState(defaultValue);
|
const [value, setValue] = useState(defaultValue);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Reset value when dialog opens
|
// Reset value when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -58,7 +73,7 @@ export function InputDialog({
|
||||||
onConfirm?.(trimmed);
|
onConfirm?.(trimmed);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); }
|
if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); }
|
||||||
if (e.key === 'Escape') onOpenChange?.(false);
|
if (e.key === 'Escape') onOpenChange?.(false);
|
||||||
};
|
};
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Input({ className, type, ref, ...props }) {
|
function Input({ className, type, ref, ...props }: ComponentProps<'input'>) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||||
);
|
);
|
||||||
|
|
||||||
function Label({ className, ref, ...props }) {
|
function Label({ className, ref, ...props }: ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Check, CloudUpload, AlertCircle } from 'lucide-react';
|
import { Check, CloudUpload, AlertCircle, type LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const STATES = {
|
interface SaveState {
|
||||||
|
icon: LucideIcon;
|
||||||
|
text: string;
|
||||||
|
cls: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATES: Record<string, SaveState> = {
|
||||||
saving: { icon: CloudUpload, text: 'Saving…', cls: 'text-muted-foreground border-border/70 bg-muted/40' },
|
saving: { icon: CloudUpload, text: 'Saving…', cls: 'text-muted-foreground border-border/70 bg-muted/40' },
|
||||||
saved: { icon: Check, text: 'Saved', cls: 'text-emerald-600 dark:text-emerald-300 border-emerald-500/30 bg-emerald-500/10' },
|
saved: { icon: Check, text: 'Saved', cls: 'text-emerald-600 dark:text-emerald-300 border-emerald-500/30 bg-emerald-500/10' },
|
||||||
error: { icon: AlertCircle, text: 'Save failed', cls: 'text-rose-500 dark:text-rose-300 border-rose-500/35 bg-rose-500/10' },
|
error: { icon: AlertCircle, text: 'Save failed', cls: 'text-rose-500 dark:text-rose-300 border-rose-500/35 bg-rose-500/10' },
|
||||||
|
|
@ -12,8 +18,8 @@ const STATES = {
|
||||||
* Tiny animated pill that reflects auto-save state. Renders nothing while idle —
|
* Tiny animated pill that reflects auto-save state. Renders nothing while idle —
|
||||||
* the page communicates "changes save automatically" once, statically.
|
* the page communicates "changes save automatically" once, statically.
|
||||||
*/
|
*/
|
||||||
export function SaveStatus({ status, className }) {
|
export function SaveStatus({ status, className }: { status?: string | null; className?: string }) {
|
||||||
const state = STATES[status];
|
const state = status ? STATES[status] : undefined;
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{state && (
|
{state && (
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root;
|
const Select = SelectPrimitive.Root;
|
||||||
const SelectGroup = SelectPrimitive.Group;
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
const SelectValue = SelectPrimitive.Value;
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
function SelectTrigger({ className, children, ref, ...props }) {
|
function SelectTrigger({ className, children, ref, ...props }: ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -26,7 +27,7 @@ function SelectTrigger({ className, children, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({ className, ref, ...props }) {
|
function SelectScrollUpButton({ className, ref, ...props }: ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -38,7 +39,7 @@ function SelectScrollUpButton({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({ className, ref, ...props }) {
|
function SelectScrollDownButton({ className, ref, ...props }: ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -50,7 +51,7 @@ function SelectScrollDownButton({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({ className, children, position = 'popper', ref, ...props }) {
|
function SelectContent({ className, children, position = 'popper', ref, ...props }: ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
|
|
@ -82,7 +83,7 @@ function SelectContent({ className, children, position = 'popper', ref, ...props
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({ className, ref, ...props }) {
|
function SelectLabel({ className, ref, ...props }: ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -92,7 +93,7 @@ function SelectLabel({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({ className, children, ref, ...props }) {
|
function SelectItem({ className, children, ref, ...props }: ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -113,7 +114,7 @@ function SelectItem({ className, children, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({ className, ref, ...props }) {
|
function SelectSeparator({ className, ref, ...props }: ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Separator({ className, orientation = 'horizontal', decorative = true, ref, ...props }) {
|
function Separator({ className, orientation = 'horizontal', decorative = true, ref, ...props }: ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<SeparatorPrimitive.Root
|
<SeparatorPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Switch({ className, ...props }) {
|
function Switch({ className, ...props }: ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<SwitchPrimitive.Root
|
<SwitchPrimitive.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Table({ className, ref, ...props }) {
|
function Table({ className, ref, ...props }: ComponentProps<'table'>) {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full overflow-auto">
|
<div className="relative w-full overflow-auto">
|
||||||
<table
|
<table
|
||||||
|
|
@ -17,7 +18,7 @@ function Table({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ref, ...props }) {
|
function TableHeader({ className, ref, ...props }: ComponentProps<'thead'>) {
|
||||||
return (
|
return (
|
||||||
<thead
|
<thead
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -30,7 +31,7 @@ function TableHeader({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ref, ...props }) {
|
function TableBody({ className, ref, ...props }: ComponentProps<'tbody'>) {
|
||||||
return (
|
return (
|
||||||
<tbody
|
<tbody
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -43,7 +44,7 @@ function TableBody({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableFooter({ className, ref, ...props }) {
|
function TableFooter({ className, ref, ...props }: ComponentProps<'tfoot'>) {
|
||||||
return (
|
return (
|
||||||
<tfoot
|
<tfoot
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -57,7 +58,7 @@ function TableFooter({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ className, ref, ...props }) {
|
function TableRow({ className, ref, ...props }: ComponentProps<typeof motion.tr>) {
|
||||||
return (
|
return (
|
||||||
<motion.tr
|
<motion.tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -73,7 +74,7 @@ function TableRow({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHead({ className, ref, ...props }) {
|
function TableHead({ className, ref, ...props }: ComponentProps<'th'>) {
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -90,7 +91,7 @@ function TableHead({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCell({ className, ref, ...props }) {
|
function TableCell({ className, ref, ...props }: ComponentProps<'td'>) {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -106,7 +107,7 @@ function TableCell({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCaption({ className, ref, ...props }) {
|
function TableCaption({ className, ref, ...props }: ComponentProps<'caption'>) {
|
||||||
return (
|
return (
|
||||||
<caption
|
<caption
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root;
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
function TabsList({ className, ref, ...props }) {
|
function TabsList({ className, ref, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -16,7 +17,7 @@ function TabsList({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsTrigger({ className, ref, ...props }) {
|
function TabsTrigger({ className, ref, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -29,7 +30,7 @@ function TabsTrigger({ className, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsContent({ className, ref, ...props }) {
|
function TabsContent({ className, ref, ...props }: ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
import { Sun, Moon } from 'lucide-react';
|
import { Sun, Moon, type LucideIcon } from 'lucide-react';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
|
|
@ -8,7 +9,7 @@ import { useTheme } from '@/contexts/ThemeContext';
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
function DropdownMenuContent({ className, sideOffset = 6, ref, ...props }) {
|
function DropdownMenuContent({ className, sideOffset = 6, ref, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
|
|
@ -28,7 +29,7 @@ function DropdownMenuContent({ className, sideOffset = 6, ref, ...props }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({ className, inset, ref, ...props }) {
|
function DropdownMenuItem({ className, inset, ref, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -46,17 +47,23 @@ function DropdownMenuItem({ className, inset, ref, ...props }) {
|
||||||
|
|
||||||
/* ── Theme options config ───────────────────────────────────── */
|
/* ── Theme options config ───────────────────────────────────── */
|
||||||
|
|
||||||
const THEMES = [
|
interface ThemeOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
Icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEMES: ThemeOption[] = [
|
||||||
{ value: 'light', label: 'Light', Icon: Sun },
|
{ value: 'light', label: 'Light', Icon: Sun },
|
||||||
{ value: 'dark', label: 'Dark', Icon: Moon },
|
{ value: 'dark', label: 'Dark', Icon: Moon },
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ── ThemeToggle ────────────────────────────────────────────── */
|
/* ── ThemeToggle ────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function ThemeToggle({ className }) {
|
export function ThemeToggle({ className }: { className?: string }) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
const current = THEMES.find((t) => t.value === theme) ?? THEMES[1];
|
const current = THEMES.find((t) => t.value === theme) ?? THEMES[1]!;
|
||||||
const CurrentIcon = current.Icon;
|
const CurrentIcon = current.Icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider;
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
const Tooltip = TooltipPrimitive.Root;
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
function TooltipContent({ className, sideOffset = 4, ref, ...props }) {
|
function TooltipContent({ className, sideOffset = 4, ref, ...props }: ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Content
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Themes: 'light' | 'dark'
|
* Themes: 'light' | 'dark'
|
||||||
|
|
@ -9,11 +9,13 @@ import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
* dark → 'dark'
|
* dark → 'dark'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = 'bt-theme';
|
type Theme = 'light' | 'dark';
|
||||||
const VALID_THEMES = ['light', 'dark'];
|
|
||||||
const DEFAULT_THEME = 'dark';
|
|
||||||
|
|
||||||
function applyTheme(theme) {
|
const STORAGE_KEY = 'bt-theme';
|
||||||
|
const VALID_THEMES: Theme[] = ['light', 'dark'];
|
||||||
|
const DEFAULT_THEME: Theme = 'dark';
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
if (theme === 'dark') {
|
if (theme === 'dark') {
|
||||||
|
|
@ -22,31 +24,36 @@ function applyTheme(theme) {
|
||||||
// 'light' → no classes
|
// 'light' → no classes
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStoredTheme() {
|
function loadStoredTheme(): Theme {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored === 'dark-purple') return 'dark';
|
if (stored === 'dark-purple') return 'dark';
|
||||||
if (stored && VALID_THEMES.includes(stored)) return stored;
|
if (stored && (VALID_THEMES as string[]).includes(stored)) return stored as Theme;
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage unavailable
|
// localStorage unavailable
|
||||||
}
|
}
|
||||||
return DEFAULT_THEME;
|
return DEFAULT_THEME;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext(null);
|
interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }) {
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
const [theme, setThemeState] = useState(() => {
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
const stored = loadStoredTheme();
|
const stored = loadStoredTheme();
|
||||||
// Apply immediately (before first paint) to avoid flash
|
// Apply immediately (before first paint) to avoid flash
|
||||||
applyTheme(stored);
|
applyTheme(stored);
|
||||||
return stored;
|
return stored;
|
||||||
});
|
});
|
||||||
|
|
||||||
const setTheme = (newTheme) => {
|
const setTheme = (newTheme: string) => {
|
||||||
if (!VALID_THEMES.includes(newTheme)) return;
|
if (!(VALID_THEMES as string[]).includes(newTheme)) return;
|
||||||
applyTheme(newTheme);
|
applyTheme(newTheme as Theme);
|
||||||
setThemeState(newTheme);
|
setThemeState(newTheme as Theme);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { fmt } from '@/lib/utils';
|
import { fmt, errMessage } from '@/lib/utils';
|
||||||
import type { Dollars } from '@/lib/money';
|
import type { Dollars } from '@/lib/money';
|
||||||
import { useInvalidateTrackerData } from '@/hooks/useQueries';
|
import { useInvalidateTrackerData } from '@/hooks/useQueries';
|
||||||
|
|
||||||
|
|
@ -11,11 +11,6 @@ interface PayableRow {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Narrow an unknown error (strict catch clauses) to a message string. */
|
|
||||||
function errMessage(err: unknown, fallback: string): string {
|
|
||||||
return err instanceof Error && err.message ? err.message : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a
|
// Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a
|
||||||
// reversible Undo toast. A React Query mutation: `isPending` comes for free and
|
// reversible Undo toast. A React Query mutation: `isPending` comes for free and
|
||||||
// the tracker/badge caches are invalidated on settle. Shared by the desktop and
|
// the tracker/badge caches are invalidated on settle. Shared by the desktop and
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ export function fmt(amount: Parameters<typeof formatUSD>[0]): string {
|
||||||
return formatUSD(amount);
|
return formatUSD(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Narrow an unknown error (strict catch clauses give `unknown`) to a message
|
||||||
|
// string, falling back when the thrown value isn't an Error with a message.
|
||||||
|
export function errMessage(err: unknown, fallback = 'Something went wrong'): string {
|
||||||
|
return err instanceof Error && err.message ? err.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function fmtDate(dateStr: string | null | undefined): string {
|
export function fmtDate(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return '—';
|
if (!dateStr) return '—';
|
||||||
const [y = '', m = '', d = ''] = dateStr.split('-');
|
const [y = '', m = '', d = ''] = dateStr.split('-');
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,9 @@ export interface TrackerRow {
|
||||||
actual_amount: Dollars | null;
|
actual_amount: Dollars | null;
|
||||||
is_skipped: boolean;
|
is_skipped: boolean;
|
||||||
snoozed_until: string | null;
|
snoozed_until: string | null;
|
||||||
|
monthly_notes?: string | null;
|
||||||
autopay_suggestion?: unknown;
|
autopay_suggestion?: unknown;
|
||||||
|
amount_suggestion?: unknown;
|
||||||
previous_month_paid: Dollars;
|
previous_month_paid: Dollars;
|
||||||
// Bank-mode augmentation (present only when bank_tracking.enabled):
|
// Bank-mode augmentation (present only when bank_tracking.enabled):
|
||||||
pending_cleared?: boolean;
|
pending_cleared?: boolean;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue