Peace 3 weeks ago
parent 88b6d2a597
commit 99a2893d51
  1. 147
      web/package-lock.json
  2. 4
      web/package.json
  3. 8
      web/src/app/layout.tsx
  4. 106
      web/src/app/login/page.tsx
  5. 40
      web/src/app/me/page.tsx
  6. 6
      web/src/app/page.tsx
  7. 90
      web/src/app/sensor-groups/[groupId]/page.tsx
  8. 84
      web/src/app/sensor-groups/page.tsx
  9. 50
      web/src/app/shadcn/alert-dialog/page.tsx
  10. 31
      web/src/app/shadcn/badge/page.tsx
  11. 40
      web/src/app/shadcn/calendar/page.tsx
  12. 182
      web/src/app/signup/page.tsx
  13. 42
      web/src/components/app-header.tsx
  14. 157
      web/src/components/ui/alert-dialog.tsx
  15. 66
      web/src/components/ui/alert.tsx
  16. 46
      web/src/components/ui/badge.tsx
  17. 49
      web/src/components/ui/button.tsx
  18. 213
      web/src/components/ui/calendar.tsx
  19. 168
      web/src/components/ui/navigation-menu.tsx

147
web/package-lock.json generated

@ -9,9 +9,11 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@ -19,10 +21,12 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"luxon": "^3.7.1", "luxon": "^3.7.1",
"next": "15.5.0", "next": "15.5.0",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",
@ -57,6 +61,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
@ -1045,6 +1055,34 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@ -1151,6 +1189,42 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@ -1343,6 +1417,42 @@
} }
} }
}, },
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
"integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@ -3549,6 +3659,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@ -6414,6 +6540,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-day-picker": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.9.0.tgz",
"integrity": "sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",

@ -10,9 +10,11 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@ -20,10 +22,12 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"luxon": "^3.7.1", "luxon": "^3.7.1",
"next": "15.5.0", "next": "15.5.0",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",

@ -15,10 +15,12 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="ko"> <html lang="ko">
<body className="m-0 bg-gray-50"> <body className="min-h-screen bg-gray-50">
<Providers> <Providers>
<AppHeader /> <div className="flex min-h-screen flex-col">
<main className="mx-auto w-full">{children}</main> <AppHeader />
<main className="flex flex-1 flex-col items-center justify-center">{children}</main>
</div>
</Providers> </Providers>
</body> </body>
</html> </html>

@ -70,64 +70,62 @@ export default function LoginPage() {
}; };
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="mx-auto my-auto w-full max-w-sm">
<div className="mx-auto my-auto w-full max-w-sm"> <Card className="shadow-lg">
<Card className="shadow-lg"> <CardHeader>
<CardHeader> <CardTitle></CardTitle>
<CardTitle></CardTitle> </CardHeader>
</CardHeader> <CardContent className="flex flex-col gap-y-2 pb-4">
<CardContent className="flex flex-col gap-y-2 pb-4"> {/* Name */}
{/* Name */} <Form {...form}>
<Form {...form}> <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}> <FormField
<FormField control={form.control}
control={form.control} name="name"
name="name" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormControl>
<FormControl> <Input placeholder="아이디" autoComplete="username" {...field} />
<Input placeholder="아이디" autoComplete="username" {...field} /> </FormControl>
</FormControl> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
)} />
/>
{/* Password */} {/* Password */}
<FormField <FormField
control={form.control} control={form.control}
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
type="password" type="password"
placeholder="비밀번호" placeholder="비밀번호"
autoComplete="current-password" autoComplete="current-password"
{...field} {...field}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading ? '로그인 중...' : '로그인'} {loading ? '로그인 중...' : '로그인'}
</Button> </Button>
{error && <p className="text-center text-sm text-red-600">{error}</p>} {error && <p className="text-center text-sm text-red-600">{error}</p>}
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-gray-500">
?{' '} ?{' '}
<Link href="/signup" className="underline"> <Link href="/signup" className="underline">
</Link> </Link>
</p> </p>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div> </div>
); );
} }

@ -13,28 +13,26 @@ export default function MePage() {
{error && <p className="text-red-600"> </p>} {error && <p className="text-red-600"> </p>}
{!isLoading && !error && data && ( {!isLoading && !error && data && (
<div className="flex min-h-screen items-center justify-center"> <Card className="w-full max-w-sm shadow-lg">
<Card className="w-full max-w-sm shadow-lg"> <CardHeader className="flex flex-row items-center gap-4">
<CardHeader className="flex flex-row items-center gap-4"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-300 text-xl font-bold text-gray-500">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-300 text-xl font-bold text-gray-500"> {data.name?.[0]}
{data.name?.[0]} </div>
</div> <div>
<div> <CardTitle>{data.name ?? '이름 없음'}</CardTitle>
<CardTitle>{data.name ?? '이름 없음'}</CardTitle> <div className="text-xs text-gray-400">ID: {data.id}</div>
<div className="text-xs text-gray-400">ID: {data.id}</div> </div>
</div> </CardHeader>
</CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{data.email && ( {data.email && (
<div className="flex items-center gap-2 text-gray-700"> <div className="flex items-center gap-2 text-gray-700">
<span className="font-medium"></span> <span className="font-medium"></span>
<span className="truncate">{data.email}</span> <span className="truncate">{data.email}</span>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div>
)} )}
</Guard> </Guard>
); );

@ -31,7 +31,7 @@ export default function Home() {
const localNow = timezone ? now.setZone(timezone) : now; const localNow = timezone ? now.setZone(timezone) : now;
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center space-y-10 p-6"> <div className="space-y-10">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="flex justify-center"> <CardHeader className="flex justify-center">
<CardTitle className="text-3xl font-bold">DateTime UI Sample</CardTitle> <CardTitle className="text-3xl font-bold">DateTime UI Sample</CardTitle>
@ -54,7 +54,7 @@ export default function Home() {
<div className="flex flex-col items-center font-mono text-teal-600"> <div className="flex flex-col items-center font-mono text-teal-600">
<p>Current time: {localNow.toFormat('yyyy-MM-dd HH:mm:ss (ZZZZ)')}</p> <p>Current time: {localNow.toFormat('yyyy-MM-dd HH:mm:ss (ZZZZ)')}</p>
<p>Now: {now.toFormat('yyyy-MM-dd HH:mm:ss (ZZZZ)')}</p> <p>Now: {now.toFormat('yyyy-MM-dd HH:mm:ss (ZZZZ)')}</p>
<p>Default date now: {defNow.toISOString()}</p> <p>Default date now: {defNow.toISOString().split('.')[0] + 'Z'}</p>
</div> </div>
<div className="text-xs opacity-60"> <div className="text-xs opacity-60">
(Luxon: realtime rendering, local timezone support) (Luxon: realtime rendering, local timezone support)
@ -85,6 +85,6 @@ export default function Home() {
(Luxon: realtime rendering, local timezone support) (Luxon: realtime rendering, local timezone support)
</div> </div>
</div> </div>
</main> </div>
); );
} }

@ -22,56 +22,54 @@ export default function GroupSensorsPage() {
return ( return (
<Guard> <Guard>
<main className="mx-auto max-w-full px-4 py-6"> <div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex items-center justify-between"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <Button variant="ghost" onClick={() => router.back()}>
<Button variant="ghost" onClick={() => router.back()}>
</Button>
</Button> <h1 className="text-xl font-semibold"> </h1>
<h1 className="text-xl font-semibold"> </h1>
</div>
<Button onClick={() => refetch()}></Button>
</div> </div>
<Button onClick={() => refetch()}></Button>
</div>
{isLoading && <p className="text-gray-500"> ...</p>} {isLoading && <p className="text-gray-500"> ...</p>}
{isError && <p className="text-red-600"> .</p>} {isError && <p className="text-red-600"> .</p>}
{!isLoading && !isError && ( {!isLoading && !isError && (
<div className="overflow-x-auto rounded border"> <div className="overflow-x-auto rounded border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="px-4 py-2 text-center">ID</TableHead> <TableHead className="px-4 py-2 text-center">ID</TableHead>
<TableHead className="px-4 py-2 text-center"></TableHead> <TableHead className="px-4 py-2 text-center"></TableHead>
<TableHead className="px-4 py-2 text-center"></TableHead> <TableHead className="px-4 py-2 text-center"></TableHead>
<TableHead className="px-4 py-2 text-center"></TableHead> <TableHead className="px-4 py-2 text-center"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data && data.length > 0 ? ( {data && data.length > 0 ? (
data.map((s) => ( data.map((s) => (
<TableRow key={s.id}> <TableRow key={s.id}>
<TableCell className="px-4 py-2 text-center">{s.id}</TableCell> <TableCell className="px-4 py-2 text-center">{s.id}</TableCell>
<TableCell className="px-4 py-2 text-center">{s.name}</TableCell> <TableCell className="px-4 py-2 text-center">{s.name}</TableCell>
<TableCell className="px-4 py-2 text-center">{s.unit ?? '-'}</TableCell> <TableCell className="px-4 py-2 text-center">{s.unit ?? '-'}</TableCell>
<TableCell className="flex items-center justify-center px-4 py-2 text-center"> <TableCell className="flex items-center justify-center px-4 py-2 text-center">
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/sensors/${s.id}`}></Link> <Link href={`/sensors/${s.id}`}></Link>
</Button> </Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="px-4 py-6 text-center text-gray-500">
.
</TableCell> </TableCell>
</TableRow> </TableRow>
)} ))
</TableBody> ) : (
</Table> <TableRow>
</div> <TableCell colSpan={4} className="px-4 py-6 text-center text-gray-500">
)} .
</main> </TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</Guard> </Guard>
); );
} }

@ -18,53 +18,49 @@ export default function SensorGroupPage() {
return ( return (
<Guard> <Guard>
<main className="mx-auto px-4 py-6"> <div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex items-center justify-between"> <h1 className="text-xl font-semibold"> </h1>
<h1 className="text-xl font-semibold"> </h1> <Button onClick={() => refetch()}></Button>
<Button onClick={() => refetch()}></Button> </div>
</div>
{isLoading && <p className="text-gray-500"> ...</p>} {isLoading && <p className="text-gray-500"> ...</p>}
{isError && <p className="text-red-600"> .</p>} {isError && <p className="text-red-600"> .</p>}
{!isLoading && !isError && ( {!isLoading && !isError && (
<div className="overflow-x-auto rounded border"> <div className="overflow-x-auto rounded border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="px-4 py-2 text-center">ID</TableHead> <TableHead className="px-4 py-2 text-center">ID</TableHead>
<TableHead className="px-4 py-2 text-center"></TableHead> <TableHead className="px-4 py-2 text-center"></TableHead>
<TableHead className="px-4 py-2 text-center"></TableHead> <TableHead className="px-4 py-2 text-center"></TableHead>
<TableHead className="px-4 py-2 text-center"></TableHead> <TableHead className="px-4 py-2 text-center"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data && data.length > 0 ? ( {data && data.length > 0 ? (
data.map((g) => ( data.map((g) => (
<TableRow key={g.id}> <TableRow key={g.id}>
<TableCell className="px-4 py-2 text-center">{g.id}</TableCell> <TableCell className="px-4 py-2 text-center">{g.id}</TableCell>
<TableCell className="px-4 py-2 text-center">{g.name}</TableCell> <TableCell className="px-4 py-2 text-center">{g.name}</TableCell>
<TableCell className="px-4 py-2 text-center"> <TableCell className="px-4 py-2 text-center">{g.description ?? '-'}</TableCell>
{g.description ?? '-'} <TableCell className="flex items-center justify-center px-4 py-2">
</TableCell> <Button asChild variant="outline">
<TableCell className="flex items-center justify-center px-4 py-2"> <Link href={`/sensor-groups/${g.id}`}></Link>
<Button asChild variant="outline"> </Button>
<Link href={`/sensor-groups/${g.id}`}></Link>
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="px-4 py-6 text-center text-gray-500">
.
</TableCell> </TableCell>
</TableRow> </TableRow>
)} ))
</TableBody> ) : (
</Table> <TableRow>
</div> <TableCell colSpan={4} className="px-4 py-6 text-center text-gray-500">
)} .
</main> </TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</Guard> </Guard>
); );
} }

@ -0,0 +1,50 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogContent,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogFooter,
AlertDialogCancel,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
export default function AlertDialogPage() {
const [dialogResult, setDialogResult] = useState('');
function handleContinue() {
setDialogResult('계속을 눌렀습니다.');
}
function handleCancel() {
setDialogResult('취소를 눌렀습니다.');
}
return (
<>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline"> </Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
. ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}></AlertDialogCancel>
<AlertDialogAction onClick={handleContinue}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{dialogResult && <p className="mt-4 text-blue-500">{dialogResult}</p>}
</>
);
}

@ -0,0 +1,31 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { BadgeCheckIcon } from 'lucide-react';
export default function BadgePage() {
return (
<>
<div className="flex items-center justify-center space-x-3">
<div className="flex flex-col items-center gap-2 rounded p-3 shadow-lg">
<Badge>Badge</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Destructive</Badge>
</div>
<div className="flex flex-col items-center gap-2 rounded p-3 shadow-lg">
<Badge>
<BadgeCheckIcon />
Varified
</Badge>
<Badge variant="destructive" className="h-5 min-w-5 rounded-full font-mono tabular-nums">
99
</Badge>
<Badge variant="outline" className="h-5 min-w-5 rounded-full font-mono tabular-nums">
100+
</Badge>
</div>
</div>
</>
);
}

@ -0,0 +1,40 @@
'use client';
import { Calendar } from '@/components/ui/calendar';
import { DateTime } from 'luxon';
import { useState } from 'react';
export default function CalendarPage() {
const [date, setDate] = useState<Date | undefined>(new Date());
const [luxonDate, setLuxonDate] = useState<DateTime | undefined>(DateTime.now());
return (
<>
<div className="flex items-center justify-center space-x-4">
<div>
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border shadow-sm"
captionLayout="dropdown"
/>
<p className="text-center text-sm text-gray-600">{date?.toLocaleDateString()}</p>
</div>
<div>
<Calendar
mode="single"
selected={luxonDate ? luxonDate.toJSDate() : undefined}
onSelect={(d: Date | undefined) => setLuxonDate(d ? DateTime.fromJSDate(d) : undefined)}
className="rounded-md border shadow-sm"
captionLayout="dropdown"
/>
<p className="text-center text-sm text-gray-600">
{luxonDate?.toJSDate().toLocaleDateString()}
</p>
</div>
</div>
</>
);
}

@ -73,104 +73,102 @@ export default function SignupPage() {
}; };
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="mx-auto w-full max-w-sm">
<div className="mx-auto w-full max-w-sm"> <Card className="shadow-lg">
<Card className="shadow-lg"> <CardHeader>
<CardHeader> <CardTitle></CardTitle>
<CardTitle></CardTitle> </CardHeader>
</CardHeader> <CardContent className="flex flex-col gap-y-2 pb-4">
<CardContent className="flex flex-col gap-y-2 pb-4"> <Form {...form}>
<Form {...form}> <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}> {/* name */}
{/* name */} <FormField
<FormField control={form.control}
control={form.control} name="name"
name="name" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormControl>
<FormControl> <Input placeholder="아이디" autoComplete="username" {...field} />
<Input placeholder="아이디" autoComplete="username" {...field} /> </FormControl>
</FormControl> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
)} />
/>
{/* email */} {/* email */}
<FormField <FormField
control={form.control} control={form.control}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="이메일(선택)" placeholder="이메일(선택)"
type="email" type="email"
autoComplete="email" autoComplete="email"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* password */} {/* password */}
<FormField <FormField
control={form.control} control={form.control}
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="비밀번호" placeholder="비밀번호"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* confirmPassword */} {/* confirmPassword */}
<FormField <FormField
control={form.control} control={form.control}
name="confirmPassword" name="confirmPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
placeholder="비밀번호 확인" placeholder="비밀번호 확인"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading ? '처리 중...' : '가입하기'} {loading ? '처리 중...' : '가입하기'}
</Button> </Button>
{error && <p className="text-center text-sm text-red-600">{error}</p>} {error && <p className="text-center text-sm text-red-600">{error}</p>}
{ok && <p className="text-center text-sm text-green-600">{ok}</p>} {ok && <p className="text-center text-sm text-green-600">{ok}</p>}
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-gray-500">
?{' '} ?{' '}
<Link href="/login" className="underline"> <Link href="/login" className="underline">
</Link> </Link>
</p> </p>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div> </div>
); );
} }

@ -8,6 +8,14 @@ import { useQueryClient } from '@tanstack/react-query';
import { hasToken, setToken } from '@/lib/token'; import { hasToken, setToken } from '@/lib/token';
import { useMe } from '@/hooks/useMe'; import { useMe } from '@/hooks/useMe';
import { Button } from './ui/button'; import { Button } from './ui/button';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from './ui/navigation-menu';
import { NavigationMenuContent } from '@radix-ui/react-navigation-menu';
const nav = [ const nav = [
{ href: '/', label: '홈' }, { href: '/', label: '홈' },
@ -57,6 +65,40 @@ export default function AppHeader() {
<Separator orientation="vertical" className="h-5 bg-white/30" /> <Separator orientation="vertical" className="h-5 bg-white/30" />
<NavigationMenu viewport={false} className="text-black">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger className="bg-gray-900 text-white">
shadcn
</NavigationMenuTrigger>
<NavigationMenuContent className="absolute top-full left-0 z-50 m-1 w-max rounded-lg shadow-lg">
<ul className="grid gap-4">
<li>
<NavigationMenuLink asChild className="bg-white">
<Link href="/shadcn/alert-dialog" className="block px-4 py-2">
Alert Dialog
</Link>
</NavigationMenuLink>
<NavigationMenuLink asChild className="bg-white">
<Link href="/shadcn/badge" className="block px-4 py-2">
Badge
</Link>
</NavigationMenuLink>
<NavigationMenuLink asChild className="bg-white">
<Link href="/shadcn/calendar" className="block px-4 py-2">
Calendar
</Link>
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<Separator orientation="vertical" className="h-5 bg-white/30" />
{authed ? ( {authed ? (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* 아바타 + 이름: 로딩 중이면 스켈레톤 */} {/* 아바타 + 이름: 로딩 중이면 스켈레톤 */}

@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

@ -1,36 +1,39 @@
import * as React from 'react'; import * as React from "react"
import { Slot } from '@radix-ui/react-slot'; import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from "class-variance-authority"
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', secondary:
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
link: 'text-primary underline-offset-4 hover:underline', ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: 'size-9', icon: "size-9",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
}, }
); )
function Button({ function Button({
className, className,
@ -38,11 +41,11 @@ function Button({
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<'button'> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean
}) { }) {
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp
@ -50,7 +53,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
); )
} }
export { Button, buttonVariants }; export { Button, buttonVariants }

@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
Loading…
Cancel
Save