diff --git a/.adonisjs/client/registry/index.ts b/.adonisjs/client/registry/index.ts index fbc77fe..5ff544d 100644 --- a/.adonisjs/client/registry/index.ts +++ b/.adonisjs/client/registry/index.ts @@ -42,11 +42,11 @@ const routes = { tokens: [{"old":"/oauth/callback","type":0,"val":"oauth","end":""},{"old":"/oauth/callback","type":0,"val":"callback","end":""}], types: placeholder as Registry['oauth.callback']['types'], }, - 'dashboard': { + 'shelf': { methods: ["GET","HEAD"], - pattern: '/dashboard', - tokens: [{"old":"/dashboard","type":0,"val":"dashboard","end":""}], - types: placeholder as Registry['dashboard']['types'], + pattern: '/shelf', + tokens: [{"old":"/shelf","type":0,"val":"shelf","end":""}], + types: placeholder as Registry['shelf']['types'], }, 'kits.new': { methods: ["GET","HEAD"], diff --git a/.adonisjs/client/registry/schema.d.ts b/.adonisjs/client/registry/schema.d.ts index 989c424..7aee66c 100644 --- a/.adonisjs/client/registry/schema.d.ts +++ b/.adonisjs/client/registry/schema.d.ts @@ -79,9 +79,9 @@ export interface Registry { errorResponse: ExtractErrorResponse>> } } - 'dashboard': { + 'shelf': { methods: ["GET","HEAD"] - pattern: '/dashboard' + pattern: '/shelf' types: { body: {} paramsTuple: [] diff --git a/.adonisjs/client/registry/tree.d.ts b/.adonisjs/client/registry/tree.d.ts index 8b6b601..f858433 100644 --- a/.adonisjs/client/registry/tree.d.ts +++ b/.adonisjs/client/registry/tree.d.ts @@ -10,7 +10,7 @@ export interface ApiDefinition { signup: typeof routes['oauth.signup'] callback: typeof routes['oauth.callback'] } - dashboard: typeof routes['dashboard'] + shelf: typeof routes['shelf'] kits: { new: typeof routes['kits.new'] store: typeof routes['kits.store'] diff --git a/.adonisjs/server/pages.d.ts b/.adonisjs/server/pages.d.ts index 603d3b0..4c7e96b 100644 --- a/.adonisjs/server/pages.d.ts +++ b/.adonisjs/server/pages.d.ts @@ -13,12 +13,12 @@ type ExtractProps = declare module '@adonisjs/inertia/types' { export interface InertiaPages { 'auth/login': ExtractProps<(typeof import('../../inertia/pages/auth/login.tsx'))['default']> - 'dashboard': ExtractProps<(typeof import('../../inertia/pages/dashboard.tsx'))['default']> 'errors/not_found': ExtractProps<(typeof import('../../inertia/pages/errors/not_found.tsx'))['default']> 'errors/server_error': ExtractProps<(typeof import('../../inertia/pages/errors/server_error.tsx'))['default']> 'home': ExtractProps<(typeof import('../../inertia/pages/home.tsx'))['default']> 'kits/edit': ExtractProps<(typeof import('../../inertia/pages/kits/edit.tsx'))['default']> 'kits/new': ExtractProps<(typeof import('../../inertia/pages/kits/new.tsx'))['default']> 'profile': ExtractProps<(typeof import('../../inertia/pages/profile.tsx'))['default']> + 'shelf': ExtractProps<(typeof import('../../inertia/pages/shelf.tsx'))['default']> } } diff --git a/.adonisjs/server/routes.d.ts b/.adonisjs/server/routes.d.ts index a8a7dc4..ad8ccc6 100644 --- a/.adonisjs/server/routes.d.ts +++ b/.adonisjs/server/routes.d.ts @@ -10,7 +10,7 @@ export type ScannedRoutes = { 'oauth.login': { paramsTuple?: []; params?: {} } 'oauth.signup': { paramsTuple?: []; params?: {} } 'oauth.callback': { paramsTuple?: []; params?: {} } - 'dashboard': { paramsTuple?: []; params?: {} } + 'shelf': { paramsTuple?: []; params?: {} } 'kits.new': { paramsTuple?: []; params?: {} } 'kits.store': { paramsTuple?: []; params?: {} } 'kits.edit': { paramsTuple: [ParamValue]; params: {'rkey': ParamValue} } @@ -25,7 +25,7 @@ export type ScannedRoutes = { 'home': { paramsTuple?: []; params?: {} } 'login': { paramsTuple?: []; params?: {} } 'oauth.callback': { paramsTuple?: []; params?: {} } - 'dashboard': { paramsTuple?: []; params?: {} } + 'shelf': { paramsTuple?: []; params?: {} } 'kits.new': { paramsTuple?: []; params?: {} } 'kits.edit': { paramsTuple: [ParamValue]; params: {'rkey': ParamValue} } 'profile': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } @@ -35,7 +35,7 @@ export type ScannedRoutes = { 'home': { paramsTuple?: []; params?: {} } 'login': { paramsTuple?: []; params?: {} } 'oauth.callback': { paramsTuple?: []; params?: {} } - 'dashboard': { paramsTuple?: []; params?: {} } + 'shelf': { paramsTuple?: []; params?: {} } 'kits.new': { paramsTuple?: []; params?: {} } 'kits.edit': { paramsTuple: [ParamValue]; params: {'rkey': ParamValue} } 'profile': { paramsTuple: [ParamValue]; params: {'handle': ParamValue} } diff --git a/app/auth/controllers/callback_controller.ts b/app/auth/controllers/callback_controller.ts index 02c1650..65dfd59 100644 --- a/app/auth/controllers/callback_controller.ts +++ b/app/auth/controllers/callback_controller.ts @@ -9,7 +9,7 @@ export default class CallbackController { async handle({ response, logger }: HttpContext) { try { await this.authService.handleCallback() - return response.redirect().toRoute('dashboard') + return response.redirect().toRoute('shelf') } catch (err) { logger.error(err, 'Error completing AT Protocol OAuth flow') return response.redirect().toRoute('login') diff --git a/app/kits/controllers/destroy_kit_controller.ts b/app/kits/controllers/destroy_kit_controller.ts index f9a60d2..de07498 100644 --- a/app/kits/controllers/destroy_kit_controller.ts +++ b/app/kits/controllers/destroy_kit_controller.ts @@ -13,7 +13,7 @@ export default class DestroyKitController { await this.kitsService.delete(rkey) session.flash('success', 'Kit deleted successfully') - return response.redirect().toRoute('dashboard') + return response.redirect().toRoute('shelf') } catch { session.flash('error', 'Failed to delete kit from ATProto') return response.redirect().back() diff --git a/app/kits/controllers/list_kits_controller.ts b/app/kits/controllers/list_kits_controller.ts index 3a7c0a8..1398429 100644 --- a/app/kits/controllers/list_kits_controller.ts +++ b/app/kits/controllers/list_kits_controller.ts @@ -8,6 +8,6 @@ export default class ListKitsController { async render({ inertia }: HttpContext) { const kits = await this.kitsService.listForUser() - return inertia.render('dashboard', { kits }) + return inertia.render('shelf', { kits }) } } diff --git a/app/kits/controllers/store_kit_controller.ts b/app/kits/controllers/store_kit_controller.ts index 875038b..c473c40 100644 --- a/app/kits/controllers/store_kit_controller.ts +++ b/app/kits/controllers/store_kit_controller.ts @@ -17,6 +17,6 @@ export default class StoreKitController { await this.kitsService.create(data) session.flash('success', `Kit "${data.name}" added to your shelf`) - return response.redirect().toRoute('dashboard') + return response.redirect().toRoute('shelf') } } diff --git a/app/kits/controllers/update_kit_controller.ts b/app/kits/controllers/update_kit_controller.ts index fb6b6d4..9f76ccc 100644 --- a/app/kits/controllers/update_kit_controller.ts +++ b/app/kits/controllers/update_kit_controller.ts @@ -64,7 +64,7 @@ export default class UpdateKitController { await this.kitsService.update(rkey, data) session.flash('success', `Updated "${data.name}"`) - return response.redirect().toRoute('dashboard') + return response.redirect().toRoute('shelf') } catch { session.flash('error', 'Failed to update kit on ATProto') return response.redirect().back() diff --git a/app/kits/routes.ts b/app/kits/routes.ts index d21ad85..8367440 100644 --- a/app/kits/routes.ts +++ b/app/kits/routes.ts @@ -13,7 +13,7 @@ const { router .group(() => { - router.get('/dashboard', [ListKitsController, 'render']).as('dashboard') + router.get('/shelf', [ListKitsController, 'render']).as('shelf') router.get('/kits/new', [StoreKitController, 'render']).as('kits.new') router.post('/kits', [StoreKitController, 'handle']).as('kits.store') router.get('/kits/:rkey/edit', [UpdateKitController, 'render']).as('kits.edit') diff --git a/components.json b/components.json new file mode 100644 index 0000000..6a548be --- /dev/null +++ b/components.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-lyra", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "inertia/css/app.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide", + "rtl": false, + "menuColor": "default", + "menuAccent": "subtle", + "registries": { + "@neobrutalism": "https://neobrutalism.com/r/radix/{name}.json", + "@neobrutalism-base": "https://neobrutalism.com/r/base/{name}.json" + } +} diff --git a/inertia/app.tsx b/inertia/app.tsx index babf07c..a7e460c 100644 --- a/inertia/app.tsx +++ b/inertia/app.tsx @@ -2,6 +2,7 @@ import './css/app.css' import { client } from './client' import { createRoot } from 'react-dom/client' import { TuyauProvider } from '@adonisjs/inertia/react' +import { TooltipProvider } from '~/components/ui/tooltip' import { resolvePageComponent } from '@adonisjs/inertia/helpers' import { createInertiaApp, type ResolvedComponent } from '@inertiajs/react' @@ -19,7 +20,9 @@ createInertiaApp({ setup({ el, App, props }) { createRoot(el).render( - + + + ) }, diff --git a/inertia/components/flash_toasts.tsx b/inertia/components/flash_toasts.tsx index 4b9350f..7e3e56a 100644 --- a/inertia/components/flash_toasts.tsx +++ b/inertia/components/flash_toasts.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { toast, Toaster } from 'sonner' +import { Toaster, toast } from 'sonner' import { router, usePage } from '@inertiajs/react' import { CircleAlert, CircleCheck } from 'lucide-react' @@ -18,10 +18,18 @@ export default function FlashToasts() { return ( , - error: , + success: , + error: , }} /> ) diff --git a/inertia/components/kit_card.tsx b/inertia/components/kit_card.tsx index 53fc1b5..f49d04f 100644 --- a/inertia/components/kit_card.tsx +++ b/inertia/components/kit_card.tsx @@ -1,13 +1,7 @@ import { Link } from '@adonisjs/inertia/react' import { Pencil, Camera, ZoomIn, Images } from 'lucide-react' - -export const STATUS_LABELS: Record = { - 'backlog': 'Backlog', - 'in-progress': 'In progress', - 'on-hold': 'On hold', - 'dropped': 'Dropped', - 'done': 'Done', -} +import { Button } from '~/components/ui/button' +import { StatusBadge } from '~/components/kit_preview' export interface KitCardProps { name: string @@ -19,7 +13,6 @@ export interface KitCardProps { fullPhotoUrl?: string | null photoCount?: number rkey?: string - isPreview?: boolean onPhotoClick?: () => void } @@ -32,19 +25,16 @@ export default function KitCard({ photoUrl, photoCount, rkey, - isPreview = false, onPhotoClick, }: KitCardProps) { const subtitle = [manufacturer, grade, scale].filter(Boolean).join(' · ') - const displayName = name.trim() || (isPreview ? 'Kit Name' : 'Untitled Kit') - const displaySub = subtitle || (isPreview ? 'Manufacturer · Grade · Scale' : '') - + const displayName = name.trim() || 'Untitled Kit' const isClickable = Boolean(onPhotoClick && photoUrl) return ( -
+
{photoUrl ? ( <> - - {displayName} + + {displayName} {photoCount && photoCount > 1 && ( - - - {photoCount} + + + {photoCount} )} {isClickable && ( - - + + )} ) : ( -
- - - {isPreview ? 'Cover photo preview' : 'No photo'} - +
+ + No photo
)}
-
-
-
-

- {displayName} -

- {displaySub && ( -

- {displaySub} -

- )} -
- {rkey && !isPreview && ( - - - - )} -
+
+

+ {displayName} +

+ {subtitle &&

{subtitle}

}
-
- {STATUS_LABELS[status] ?? status} - {isPreview && ( - - Shelf preview - +
+ + {rkey && ( + )}
diff --git a/inertia/components/kit_preview.tsx b/inertia/components/kit_preview.tsx new file mode 100644 index 0000000..9b635c1 --- /dev/null +++ b/inertia/components/kit_preview.tsx @@ -0,0 +1,105 @@ +import { cn } from 'cn' +import { Camera, Images } from 'lucide-react' + +export const STATUS_CONFIG: Record = { + 'backlog': { + label: 'Backlog', + className: 'bg-muted text-muted-foreground', + }, + 'in-progress': { + label: 'In progress', + className: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', + }, + 'on-hold': { + label: 'On hold', + className: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + }, + 'dropped': { + label: 'Dropped', + className: 'bg-destructive/10 text-destructive', + }, + 'done': { + label: 'Done', + className: 'bg-green-500/10 text-green-600 dark:text-green-400', + }, +} + +export function StatusBadge({ status }: { status: string }) { + const cfg = STATUS_CONFIG[status] ?? { label: status, className: '' } + return ( + + {cfg.label} + + ) +} + +export interface KitPreviewProps { + name: string + grade?: string | null + scale?: string | null + manufacturer?: string | null + status: string + photoUrl?: string | null + photoCount?: number +} + +export default function KitPreview({ + name, + grade, + scale, + manufacturer, + status, + photoUrl, + photoCount, +}: KitPreviewProps) { + const subtitle = [manufacturer, grade, scale].filter(Boolean).join(' · ') + const displayName = name.trim() || 'Kit Name' + const displaySub = subtitle || 'Manufacturer · Grade · Scale' + + return ( +
+ {/* Media */} +
+ {photoUrl ? ( + <> + + {displayName} + {photoCount && photoCount > 1 && ( + + + {photoCount} + + )} + + ) : ( +
+ + No photo +
+ )} +
+ + {/* Body */} +
+

+ {displayName} +

+

+ {displaySub} +

+
+ + {/* Footer */} +
+ +
+
+ ) +} diff --git a/inertia/components/logo.tsx b/inertia/components/logo.tsx index 29015ec..e72457a 100644 --- a/inertia/components/logo.tsx +++ b/inertia/components/logo.tsx @@ -2,11 +2,7 @@ import { Link } from '@adonisjs/inertia/react' export default function Logo({ size = 26 }: { size?: number }) { return ( - + + {children} ) diff --git a/inertia/components/page.tsx b/inertia/components/page.tsx index 1ab868d..2e6904f 100644 --- a/inertia/components/page.tsx +++ b/inertia/components/page.tsx @@ -1,36 +1,47 @@ -import { type ReactNode } from 'react' +import React, { type ReactNode } from 'react' import { Head } from '@inertiajs/react' +import { ChevronLeft } from 'lucide-react' +import { Link } from '@adonisjs/inertia/react' -/** - * The frame for an app page: sets the document title and renders the page - * heading, an optional description and optional actions (buttons, links) - * aligned to the right. Pass `hideTitle` to only set the document title. - */ export default function Page({ title, hideTitle = false, description, actions, + back, children, }: { title: string hideTitle?: boolean description?: ReactNode actions?: ReactNode + back?: { + route: Extract, { route: unknown }>['route'] + label: string + } children?: ReactNode }) { const hasHeader = !hideTitle || description || actions return ( -
+
+ {back && ( + + + {back.label} + + )} {hasHeader && ( -
+
- {!hideTitle &&

{title}

} - {description &&

{description}

} + {!hideTitle &&

{title}

} + {description &&

{description}

}
- {actions &&
{actions}
} + {actions &&
{actions}
}
)} {children} diff --git a/inertia/components/theme_toggle.tsx b/inertia/components/theme_toggle.tsx index cac777b..43d62c9 100644 --- a/inertia/components/theme_toggle.tsx +++ b/inertia/components/theme_toggle.tsx @@ -1,11 +1,20 @@ import { useState } from 'react' import { Moon, Sun } from 'lucide-react' import { usePage } from '@inertiajs/react' +import { Button } from '~/components/ui/button' const COOKIE = 'app_theme' const ONE_YEAR = 60 * 60 * 24 * 365 -export default function ThemeToggle() { +export default function ThemeToggle({ + variant = 'ghost', + size = 'icon-sm', + className, +}: { + variant?: React.ComponentProps['variant'] + size?: React.ComponentProps['size'] + className?: string +} = {}) { const page = usePage() const [theme, setTheme] = useState<'light' | 'dark'>(page.props.preferences?.theme ?? 'light') @@ -18,15 +27,15 @@ export default function ThemeToggle() { const isDark = theme === 'dark' return ( - + {isDark ? : } + ) } diff --git a/inertia/components/ui/avatar.tsx b/inertia/components/ui/avatar.tsx new file mode 100644 index 0000000..073f725 --- /dev/null +++ b/inertia/components/ui/avatar.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import { Avatar as AvatarPrimitive } from 'radix-ui' + +import { cn } from '@/lib/utils' + +function Avatar({ + className, + size = 'default', + ...props +}: React.ComponentProps & { + size?: 'default' | 'sm' | 'lg' +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { + return ( + svg]:hidden', + 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', + 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', + className + )} + {...props} + /> + ) +} + +export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge } diff --git a/inertia/components/ui/badge.tsx b/inertia/components/ui/badge.tsx new file mode 100644 index 0000000..6facdb3 --- /dev/null +++ b/inertia/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { Slot } from 'radix-ui' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded border-2 px-2 py-0.5 text-xs font-head font-medium whitespace-nowrap shadow-sm transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', + destructive: 'bg-destructive text-destructive-foreground [a]:hover:bg-destructive/90', + outline: + 'bg-transparent text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', + ghost: + 'border-transparent bg-transparent shadow-none hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', + link: 'border-transparent bg-transparent shadow-none text-primary underline-offset-4 hover:underline', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +function Badge({ + className, + variant = 'default', + asChild = false, + ...props +}: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'span' + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/inertia/components/ui/button.tsx b/inertia/components/ui/button.tsx new file mode 100644 index 0000000..f440137 --- /dev/null +++ b/inertia/components/ui/button.tsx @@ -0,0 +1,87 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { Slot } from 'radix-ui' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + cn( + 'group/button font-head font-medium inline-flex cursor-pointer items-center justify-center gap-2 rounded whitespace-nowrap select-none transition-all duration-200', + 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60', + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary aria-invalid:border-destructive', + // Icons keep their own size; we only set a default when none is given so + // Neobrutalism's h-4/size-4 icons aren't overridden. + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + ), + { + variants: { + variant: { + default: + 'border-2 border-black bg-primary text-primary-foreground shadow-md transition duration-200 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:bg-primary-hover hover:shadow-lg active:translate-x-1 active:translate-y-1 active:shadow-none', + secondary: + 'border-2 border-black bg-secondary text-secondary-foreground shadow-md transition duration-200 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:bg-secondary-hover hover:shadow-lg active:translate-x-1 active:translate-y-1 active:shadow-none', + destructive: + 'border-2 border-black bg-destructive text-destructive-foreground shadow-md transition duration-200 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:bg-destructive/90 hover:shadow-lg active:translate-x-1 active:translate-y-1 active:shadow-none', + outline: + 'border-2 bg-transparent shadow-md transition duration-200 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-lg active:translate-x-1 active:translate-y-1 active:shadow-none', + ghost: 'bg-transparent hover:bg-accent', + link: 'bg-transparent hover:underline', + }, + size: { + 'default': 'px-4 py-1.5 text-base', + 'xs': 'px-2 py-0.5 text-xs', + 'sm': 'px-3 py-1 text-sm', + 'lg': 'px-6 py-2 text-base lg:px-8 lg:py-3 lg:text-lg', + 'icon': 'p-2', + 'icon-xs': 'p-1', + 'icon-sm': 'p-1.5', + 'icon-lg': 'p-3', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +function Button({ + className, + variant = 'default', + size = 'default', + asChild = false, + render, + children, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + render?: React.ReactElement<{ className?: string; children?: React.ReactNode }> + }) { + if (render) { + return React.cloneElement( + render, + { + ...props, + className: cn(buttonVariants({ variant, size, className }), render.props.className), + }, + children ?? render.props.children + ) + } + + const Comp = asChild ? Slot.Root : 'button' + + return ( + + {children} + + ) +} + +export { Button, buttonVariants } diff --git a/inertia/components/ui/calendar.tsx b/inertia/components/ui/calendar.tsx new file mode 100644 index 0000000..41c1c18 --- /dev/null +++ b/inertia/components/ui/calendar.tsx @@ -0,0 +1,196 @@ +import * as React from 'react' +import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' +import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from 'react-day-picker' + +import { cn } from '@/lib/utils' +import { Button, buttonVariants } from '@/components/ui/button' + +function Calendar({ + className, + classNames, + showOutsideDays = true, + fixedWeeks = true, + captionLayout = 'label', + buttonVariant = 'ghost', + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps['variant'] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date, dateLib) => + dateLib + ? dateLib.format(date, 'LLL') + : 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) rounded border-2 p-0 shadow-sm select-none aria-disabled:opacity-50', + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + 'size-(--cell-size) rounded border-2 p-0 shadow-sm 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-head 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: cn('w-full border-collapse', defaultClassNames.month_grid), + weekdays: cn('flex', defaultClassNames.weekdays), + weekday: cn( + 'flex w-(--cell-size) items-center justify-center 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 size-(--cell-size) 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-primary/30 after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-primary/30', + defaultClassNames.range_start + ), + range_middle: cn('rounded-none', defaultClassNames.range_middle), + range_end: cn( + 'relative isolate z-0 rounded-r-(--cell-radius) bg-primary/30 after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-primary/30', + defaultClassNames.range_end + ), + today: cn( + 'rounded-(--cell-radius) border-2 border-primary 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: rootClassName, rootRef, ...rootProps }) => { + return ( +
+ ) + }, + Chevron: ({ className: chevronClassName, orientation, ...chevronProps }) => { + if (orientation === 'left') { + return + } + + if (orientation === 'right') { + return + } + + return + }, + DayButton: ({ ...dayButtonProps }) => ( + + ), + WeekNumber: ({ children, ...weekNumberProps }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/inertia/components/ui/field.tsx b/inertia/components/ui/field.tsx new file mode 100644 index 0000000..6c4387e --- /dev/null +++ b/inertia/components/ui/field.tsx @@ -0,0 +1,222 @@ +import { useMemo } from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' + +function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = 'legend', + ...props +}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +const fieldVariants = cva('group/field flex w-full gap-2 data-[invalid=true]:text-destructive', { + variants: { + orientation: { + vertical: 'flex-col *:w-full [&>.sr-only]:w-auto', + horizontal: + 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + responsive: + 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + }, + }, + defaultVariants: { + orientation: 'vertical', + }, +}) + +function Field({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function FieldLabel({ className, ...props }: React.ComponentProps) { + return ( +