diff --git a/web/package-lock.json b/web/package-lock.json
index 78aea70..72ffb37 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,9 +9,11 @@
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^5.2.1",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@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-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
@@ -19,10 +21,12 @@
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"lucide-react": "^0.540.0",
"luxon": "^3.7.1",
"next": "15.5.0",
"react": "19.1.0",
+ "react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"recharts": "^3.1.2",
@@ -57,6 +61,12 @@
"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": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
@@ -1045,6 +1055,34 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"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": {
"version": "1.1.7",
"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": {
"version": "1.1.1",
"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": {
"version": "1.2.8",
"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"
}
},
+ "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": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -6414,6 +6540,27 @@
"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": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
diff --git a/web/package.json b/web/package.json
index 17a49f5..c7227c3 100644
--- a/web/package.json
+++ b/web/package.json
@@ -10,9 +10,11 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@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-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
@@ -20,10 +22,12 @@
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"lucide-react": "^0.540.0",
"luxon": "^3.7.1",
"next": "15.5.0",
"react": "19.1.0",
+ "react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"recharts": "^3.1.2",
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
index 343ef47..1f371f4 100644
--- a/web/src/app/layout.tsx
+++ b/web/src/app/layout.tsx
@@ -15,10 +15,12 @@ export default function RootLayout({
}>) {
return (
-
+
-
- {children}
+
diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx
index 651c105..8ed17fd 100644
--- a/web/src/app/login/page.tsx
+++ b/web/src/app/login/page.tsx
@@ -70,64 +70,62 @@ export default function LoginPage() {
};
return (
-
-
-
-
- 로그인
-
-
- {/* Name */}
-
+
+
+
);
}
diff --git a/web/src/app/me/page.tsx b/web/src/app/me/page.tsx
index 1f9a8a2..b016562 100644
--- a/web/src/app/me/page.tsx
+++ b/web/src/app/me/page.tsx
@@ -13,28 +13,26 @@ export default function MePage() {
{error &&
조회 실패
}
{!isLoading && !error && data && (
-
-
-
-
- {data.name?.[0]}
-
-
-
{data.name ?? '이름 없음'}
-
ID: {data.id}
-
-
+
+
+
+ {data.name?.[0]}
+
+
+
{data.name ?? '이름 없음'}
+
ID: {data.id}
+
+
-
- {data.email && (
-
- 이메일
- {data.email}
-
- )}
-
-
-
+
+ {data.email && (
+
+ 이메일
+ {data.email}
+
+ )}
+
+
)}
);
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
index 215f581..e36355f 100644
--- a/web/src/app/page.tsx
+++ b/web/src/app/page.tsx
@@ -31,7 +31,7 @@ export default function Home() {
const localNow = timezone ? now.setZone(timezone) : now;
return (
-
+
DateTime UI Sample
@@ -54,7 +54,7 @@ export default function Home() {
Current time: {localNow.toFormat('yyyy-MM-dd HH:mm:ss (ZZZZ)')}
Now: {now.toFormat('yyyy-MM-dd HH:mm:ss (ZZZZ)')}
-
Default date now: {defNow.toISOString()}
+
Default date now: {defNow.toISOString().split('.')[0] + 'Z'}
(Luxon: realtime rendering, local timezone support)
@@ -85,6 +85,6 @@ export default function Home() {
(Luxon: realtime rendering, local timezone support)
-
+
);
}
diff --git a/web/src/app/sensor-groups/[groupId]/page.tsx b/web/src/app/sensor-groups/[groupId]/page.tsx
index d6d9451..6490fa7 100644
--- a/web/src/app/sensor-groups/[groupId]/page.tsx
+++ b/web/src/app/sensor-groups/[groupId]/page.tsx
@@ -22,56 +22,54 @@ export default function GroupSensorsPage() {
return (
-
-
-
-
-
센서 그룹
-
-
+
+
+
+
센서 그룹
+
+
- {isLoading &&
불러오는 중...
}
- {isError &&
불러오지 못했습니다.
}
- {!isLoading && !isError && (
-
-
-
-
- ID
- 이름
- 단위
- 데이터
-
-
-
- {data && data.length > 0 ? (
- data.map((s) => (
-
- {s.id}
- {s.name}
- {s.unit ?? '-'}
-
-
-
-
- ))
- ) : (
-
-
- 데이터가 없습니다.
+ {isLoading && 불러오는 중...
}
+ {isError && 불러오지 못했습니다.
}
+ {!isLoading && !isError && (
+
+
+
+
+ ID
+ 이름
+ 단위
+ 데이터
+
+
+
+ {data && data.length > 0 ? (
+ data.map((s) => (
+
+ {s.id}
+ {s.name}
+ {s.unit ?? '-'}
+
+
- )}
-
-
-
- )}
-
+ ))
+ ) : (
+
+
+ 데이터가 없습니다.
+
+
+ )}
+
+
+
+ )}
);
}
diff --git a/web/src/app/sensor-groups/page.tsx b/web/src/app/sensor-groups/page.tsx
index 8c66d8b..7dbb77a 100644
--- a/web/src/app/sensor-groups/page.tsx
+++ b/web/src/app/sensor-groups/page.tsx
@@ -18,53 +18,49 @@ export default function SensorGroupPage() {
return (
-
-
-
센서 그룹
-
-
+
+
센서 그룹
+
+
- {isLoading && 불러오는 중...
}
- {isError && 불러오지 못했습니다.
}
- {!isLoading && !isError && (
-
-
-
-
- ID
- 이름
- 설명
- 상세
-
-
-
- {data && data.length > 0 ? (
- data.map((g) => (
-
- {g.id}
- {g.name}
-
- {g.description ?? '-'}
-
-
-
-
-
- ))
- ) : (
-
-
- 데이터가 없습니다.
+ {isLoading && 불러오는 중...
}
+ {isError && 불러오지 못했습니다.
}
+ {!isLoading && !isError && (
+
+
+
+
+ ID
+ 이름
+ 설명
+ 상세
+
+
+
+ {data && data.length > 0 ? (
+ data.map((g) => (
+
+ {g.id}
+ {g.name}
+ {g.description ?? '-'}
+
+
- )}
-
-
-
- )}
-
+ ))
+ ) : (
+
+
+ 데이터가 없습니다.
+
+
+ )}
+
+
+
+ )}
);
}
diff --git a/web/src/app/shadcn/alert-dialog/page.tsx b/web/src/app/shadcn/alert-dialog/page.tsx
new file mode 100644
index 0000000..e839ac6
--- /dev/null
+++ b/web/src/app/shadcn/alert-dialog/page.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+ 작업을 확인하세요
+
+ 이 작업은 되돌릴 수 없습니다. 진행하시겠습니까?
+
+
+
+ 취소
+ 계속
+
+
+
+ {dialogResult &&
{dialogResult}
}
+ >
+ );
+}
diff --git a/web/src/app/shadcn/badge/page.tsx b/web/src/app/shadcn/badge/page.tsx
new file mode 100644
index 0000000..2e93678
--- /dev/null
+++ b/web/src/app/shadcn/badge/page.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { Badge } from '@/components/ui/badge';
+import { BadgeCheckIcon } from 'lucide-react';
+
+export default function BadgePage() {
+ return (
+ <>
+
+
+ Badge
+ Secondary
+ Outline
+ Destructive
+
+
+
+
+ Varified
+
+
+ 99
+
+
+ 100+
+
+
+
+ >
+ );
+}
diff --git a/web/src/app/shadcn/calendar/page.tsx b/web/src/app/shadcn/calendar/page.tsx
new file mode 100644
index 0000000..bc0c58c
--- /dev/null
+++ b/web/src/app/shadcn/calendar/page.tsx
@@ -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
(new Date());
+ const [luxonDate, setLuxonDate] = useState(DateTime.now());
+
+ return (
+ <>
+
+
+
+
{date?.toLocaleDateString()}
+
+
+
+
setLuxonDate(d ? DateTime.fromJSDate(d) : undefined)}
+ className="rounded-md border shadow-sm"
+ captionLayout="dropdown"
+ />
+
+ {luxonDate?.toJSDate().toLocaleDateString()}
+
+
+
+ >
+ );
+}
diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx
index 2b1b613..92350d9 100644
--- a/web/src/app/signup/page.tsx
+++ b/web/src/app/signup/page.tsx
@@ -73,104 +73,102 @@ export default function SignupPage() {
};
return (
-
-
);
}
diff --git a/web/src/components/app-header.tsx b/web/src/components/app-header.tsx
index 2a69368..43a56b0 100644
--- a/web/src/components/app-header.tsx
+++ b/web/src/components/app-header.tsx
@@ -8,6 +8,14 @@ import { useQueryClient } from '@tanstack/react-query';
import { hasToken, setToken } from '@/lib/token';
import { useMe } from '@/hooks/useMe';
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 = [
{ href: '/', label: '홈' },
@@ -57,6 +65,40 @@ export default function AppHeader() {
+
+
+
+
+ shadcn
+
+
+
+
+ -
+
+
+ Alert Dialog
+
+
+
+
+ Badge
+
+
+
+
+ Calendar
+
+
+
+
+
+
+
+
+
+
+
{authed ? (
{/* 아바타 + 이름: 로딩 중이면 스켈레톤 */}
diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..0863e40
--- /dev/null
+++ b/web/src/components/ui/alert-dialog.tsx
@@ -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
) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx
new file mode 100644
index 0000000..1421354
--- /dev/null
+++ b/web/src/components/ui/alert.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
new file mode 100644
index 0000000..0205413
--- /dev/null
+++ b/web/src/components/ui/badge.tsx
@@ -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 & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx
index b579275..a2df8dc 100644
--- a/web/src/components/ui/button.tsx
+++ b/web/src/components/ui/button.tsx
@@ -1,36 +1,39 @@
-import * as React from 'react';
-import { Slot } from '@radix-ui/react-slot';
-import { cva, type VariantProps } from 'class-variance-authority';
+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';
+import { cn } from "@/lib/utils"
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",
{
variants: {
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:
- '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:
- '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',
- ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
- link: 'text-primary underline-offset-4 hover:underline',
+ "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",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
},
size: {
- 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',
- lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
- icon: 'size-9',
+ 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",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
},
},
defaultVariants: {
- variant: 'default',
- size: 'default',
+ variant: "default",
+ size: "default",
},
- },
-);
+ }
+)
function Button({
className,
@@ -38,11 +41,11 @@ function Button({
size,
asChild = false,
...props
-}: React.ComponentProps<'button'> &
+}: React.ComponentProps<"button"> &
VariantProps & {
- asChild?: boolean;
+ asChild?: boolean
}) {
- const Comp = asChild ? Slot : 'button';
+ const Comp = asChild ? Slot : "button"
return (
- );
+ )
}
-export { Button, buttonVariants };
+export { Button, buttonVariants }
diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..4d7c46a
--- /dev/null
+++ b/web/src/components/ui/calendar.tsx
@@ -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 & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ 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 (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+