diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2bdca5c..fb25fe7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -83,4 +83,5 @@ jobs:
- name: Build
env:
DATABASE_URL: "postgresql://dummy:dummy@localhost:5432/dummy"
+ BETTER_AUTH_SECRET: "dummy_token"
run: yarn admin:build
diff --git a/apps/admin/.env.template b/apps/admin/.env.template
index 04871c4..bfa3c7e 100644
--- a/apps/admin/.env.template
+++ b/apps/admin/.env.template
@@ -1,6 +1,7 @@
NODE_ENV="development"
NEXT_TELEMETRY_DISABLED=1
+INTERNAL_API_URL=http://localhost:3500
NEXT_PUBLIC_HOST=http://localhost:3500
# NEXT_PUBLIC_IMAGE_CDN_URL="https://img.xxxxxx.jp"
NEXT_PUBLIC_IMAGE_CDN_URL="http://localhost:3500/static/images"
diff --git a/apps/admin/.prettierignore b/apps/admin/.prettierignore
new file mode 100644
index 0000000..7480370
--- /dev/null
+++ b/apps/admin/.prettierignore
@@ -0,0 +1,3 @@
+*.bk
+src/components/ui/
+src/hooks/use-mobile.ts
diff --git a/apps/admin/package.json b/apps/admin/package.json
index e8547b7..b1c8d6b 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -27,6 +27,7 @@
"@hookform/resolvers": "5.2.2",
"@prisma/client": "7.4.2",
"@radix-ui/react-checkbox": "1.3.3",
+ "@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-label": "2.1.8",
@@ -51,7 +52,7 @@
"formidable": "3.5.4",
"fs": "0.0.2",
"jsonwebtoken": "9.0.3",
- "lucide-react": "0.575.0",
+ "lucide-react": "0.576.0",
"next": "16.1.6",
"next-themes": "0.4.6",
"nodemailer": "8.0.1",
@@ -77,7 +78,6 @@
"resend": "6.9.3",
"sass": "1.97.3",
"sharp": "0.34.5",
- "simplebar-react": "3.3.2",
"smooth-scroll": "16.1.3",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
@@ -102,7 +102,7 @@
"@types/eslint-plugin-tailwindcss": "3.17.0",
"@types/formidable": "3.4.7",
"@types/jsonwebtoken": "9.0.10",
- "@types/node": "25.0.3",
+ "@types/node": "25.3.3",
"@types/nodemailer": "7.0.11",
"@types/quill": "2.0.14",
"@types/react": "19.2.14",
diff --git a/apps/admin/src/app/(app)/_layout/app-layout.module.scss b/apps/admin/src/app/(app)/_layout/app-layout.module.scss
deleted file mode 100644
index 3c9b8b1..0000000
--- a/apps/admin/src/app/(app)/_layout/app-layout.module.scss
+++ /dev/null
@@ -1,15 +0,0 @@
-@use "styles/utils" as utils;
-
-.container {
- display: flex;
- flex-direction: row;
-}
-
-.contents {
- flex: 1 0 auto;
- background-color: utils.$lightColor;
-}
-
-.main {
- margin: 1.5rem;
-}
diff --git a/apps/admin/src/app/(app)/_layout/app-layout.tsx b/apps/admin/src/app/(app)/_layout/app-layout.tsx
deleted file mode 100644
index c91da84..0000000
--- a/apps/admin/src/app/(app)/_layout/app-layout.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-"use client";
-
-import { usePathname } from "next/navigation";
-import { useSession } from "@/lib/auth-client";
-import ss from "./app-layout.module.scss";
-import { Footer } from "./footer";
-import { Header } from "./header";
-import { ProtectedView } from "./protected-view";
-import { Sidebar } from "./sidebar";
-
-type Props = {
- children: React.ReactNode;
-};
-
-export const AppLayout = ({ children }: Props) => {
- const pathname = usePathname() ?? "";
- const { data: session } = useSession();
- const userPrivilege = session?.user?.privilege;
-
- return (
-
-
-
- );
-};
diff --git a/apps/admin/src/app/(app)/_layout/footer/footer.module.scss b/apps/admin/src/app/(app)/_layout/footer/footer.module.scss
deleted file mode 100644
index 69b3c77..0000000
--- a/apps/admin/src/app/(app)/_layout/footer/footer.module.scss
+++ /dev/null
@@ -1,15 +0,0 @@
-@use "styles/utils" as utils;
-
-.container {
- width: 100%;
- text-align: end;
- position: fixed;
- bottom: 0;
- right: 1rem;
- padding: 1rem;
-}
-
-.copyright {
- margin-left: 1rem;
- color: #6e6e6e;
-}
diff --git a/apps/admin/src/app/(app)/_layout/footer/footer.tsx b/apps/admin/src/app/(app)/_layout/footer/footer.tsx
deleted file mode 100644
index 0c8b575..0000000
--- a/apps/admin/src/app/(app)/_layout/footer/footer.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from "react";
-import ss from "./footer.module.scss";
-
-export const COPY_RIGHT = "©seri"; // TODO: change it to yours
-export const LINK_TEXT = "next-admin"; // TODO: change it to yours
-export const LINK_HREF = "https://github.com/seriwb/next-admin"; // TODO: change it to yours
-
-const Footer = () => {
- return (
-
- );
-};
-
-export default React.memo(Footer);
diff --git a/apps/admin/src/app/(app)/_layout/footer/index.ts b/apps/admin/src/app/(app)/_layout/footer/index.ts
deleted file mode 100644
index a357e42..0000000
--- a/apps/admin/src/app/(app)/_layout/footer/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as Footer } from "./footer";
diff --git a/apps/admin/src/app/(app)/_layout/header/header.module.scss b/apps/admin/src/app/(app)/_layout/header/header.module.scss
deleted file mode 100644
index 95ca22e..0000000
--- a/apps/admin/src/app/(app)/_layout/header/header.module.scss
+++ /dev/null
@@ -1,85 +0,0 @@
-@use "styles/utils" as utils;
-
-$spacing: 0.5rem;
-$font-color: #7b7b7b;
-$border-color: #d8dbe0;
-
-.container {
- padding: $spacing;
- background-color: #fefefe;
- position: sticky;
- top: 0;
- z-index: 1029;
- border-bottom: 1px solid $border-color;
-}
-
-.nav {
- height: 3.5rem;
- display: flex;
- justify-content: space-between;
-
- .navLeft {
- }
-
- .navRight {
- margin-right: 4rem;
- }
-}
-
-.dropdownIcon {
- --var-color: #7b7b7b;
-
- width: 48px;
- height: 48px;
- border-radius: 50%;
- color: var(--var-color);
- border: 2px solid var(--var-color);
- overflow: hidden;
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-.dropdownMenu {
- width: 240px;
- padding: 0.5rem 1rem;
- color: $font-color;
-
- .title {
- margin-top: 0;
- color: inherit;
- }
-
- .menu {
- color: inherit;
-
- .item {
- width: calc(100% + 1rem);
- height: 100%;
- margin-left: -$spacing;
- padding: calc($spacing / 2) $spacing;
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- cursor: pointer;
-
- &:hover {
- background-color: utils.$selectColor;
- }
- }
-
- .divider {
- margin: 0.5rem -1rem;
- border-top: 1px solid $border-color;
- }
- }
-}
-
-.divider {
- margin: $spacing calc($spacing * -1);
- border-top: 1px solid $border-color;
-}
-
-.breadcrumb {
- margin-left: 1rem;
-}
diff --git a/apps/admin/src/app/(app)/_layout/header/header.tsx b/apps/admin/src/app/(app)/_layout/header/header.tsx
deleted file mode 100644
index 1ca8310..0000000
--- a/apps/admin/src/app/(app)/_layout/header/header.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-"use client";
-
-import { LogOut, User } from "lucide-react";
-import { Breadcrumb } from "@/components/breadcrumb";
-import { Dropdown, DropdownMenu, DropdownToggle } from "@/components/dropdown";
-import { signOut } from "@/lib/auth-client";
-import ss from "./header.module.scss";
-
-export const Header = () => {
- const handleSignout = async () => {
- const ok = confirm("Are you sure you want to sign out?");
- if (ok) {
- await signOut();
- window.location.href = "/";
- }
- };
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
Settings
-
- menu
-
-
-
- Sign out
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/admin/src/app/(app)/_layout/header/index.ts b/apps/admin/src/app/(app)/_layout/header/index.ts
deleted file mode 100644
index 49ac70f..0000000
--- a/apps/admin/src/app/(app)/_layout/header/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./header";
diff --git a/apps/admin/src/app/(app)/_layout/index.ts b/apps/admin/src/app/(app)/_layout/index.ts
deleted file mode 100644
index 07c56e2..0000000
--- a/apps/admin/src/app/(app)/_layout/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./app-layout";
diff --git a/apps/admin/src/app/(app)/_layout/index.tsx b/apps/admin/src/app/(app)/_layout/index.tsx
new file mode 100644
index 0000000..b3569fb
--- /dev/null
+++ b/apps/admin/src/app/(app)/_layout/index.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { Breadcrumb } from "@/components/breadcrumb";
+import { Separator } from "@/components/ui/separator";
+import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
+import type { Navigation } from "@/constants/navigation";
+import { ProtectedView } from "./protected-view";
+import { AppSidebar } from "./sidebar";
+
+type Props = {
+ children: React.ReactNode;
+ navigation: Navigation[];
+ userName: string;
+ userEmail: string;
+};
+
+export const AppLayout = ({ children, navigation, userName, userEmail }: Props) => {
+ const pathname = usePathname() ?? "";
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+};
diff --git a/apps/admin/src/app/(app)/_layout/protected-view.tsx b/apps/admin/src/app/(app)/_layout/protected-view.tsx
index 2a2b9e2..5fc33b1 100644
--- a/apps/admin/src/app/(app)/_layout/protected-view.tsx
+++ b/apps/admin/src/app/(app)/_layout/protected-view.tsx
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
+import { ACCOUNT_STATUS } from "@/constants/application";
import { useSession } from "@/lib/auth-client";
type Props = {
@@ -11,7 +12,7 @@ type Props = {
export const ProtectedView = (props: Props) => {
const router = useRouter();
const { data: session, isPending } = useSession();
- const activated = session?.user?.status === "active";
+ const activated = session?.user?.status === ACCOUNT_STATUS.active;
useEffect(() => {
if ((!isPending && !session) || (session && !activated)) {
diff --git a/apps/admin/src/app/(app)/_layout/sidebar.tsx b/apps/admin/src/app/(app)/_layout/sidebar.tsx
new file mode 100644
index 0000000..cc337ad
--- /dev/null
+++ b/apps/admin/src/app/(app)/_layout/sidebar.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import Link from "next/link";
+import { ChevronsUpDown, Gauge, LogOut, type LucideIcon, Shield, User, Users } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarRail,
+} from "@/components/ui/sidebar";
+import { APP_NAME } from "@/constants/application";
+import type { Navigation } from "@/constants/navigation";
+import { signOut } from "@/lib/auth-client";
+
+// アイコン名→コンポーネントのマッピング
+const iconMap: Record = {
+ Gauge,
+ Users,
+};
+
+const NavIcon = ({ name }: { name: string }) => {
+ const Icon = iconMap[name];
+ return Icon ? : null;
+};
+
+// ナビゲーション配列をグループに変換するヘルパー
+// "title"アイテムでグループ境界を作り、"item"アイテムをグループ内に配置
+type NavGroup = {
+ label?: string;
+ items: Navigation[];
+};
+
+const buildNavGroups = (navigation: Navigation[]): NavGroup[] => {
+ const groups: NavGroup[] = [];
+ let currentGroup: NavGroup = { items: [] };
+
+ for (const item of navigation) {
+ if (item.component === "title") {
+ // 前のグループにアイテムがあれば保存
+ if (currentGroup.items.length > 0 || currentGroup.label) {
+ groups.push(currentGroup);
+ }
+ currentGroup = { label: item.name, items: [] };
+ } else if (item.component === "item") {
+ currentGroup.items.push(item);
+ }
+ }
+
+ // 最後のグループを追加
+ if (currentGroup.items.length > 0 || currentGroup.label) {
+ groups.push(currentGroup);
+ }
+
+ return groups;
+};
+
+type Props = {
+ pathname: string;
+ navigation: Navigation[];
+ userName: string;
+ userEmail: string;
+};
+
+export const AppSidebar = ({ pathname, navigation, userName, userEmail }: Props) => {
+ const navGroups = buildNavGroups(navigation);
+
+ const handleSignout = async () => {
+ const ok = confirm("ログアウトしてもよろしいですか?");
+ if (ok) {
+ await signOut({
+ fetchOptions: {
+ onSuccess: () => {
+ window.location.href = "/signin?code=SignOut";
+ },
+ },
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {APP_NAME}
+
+
+
+
+
+
+
+
+ {navGroups.map((group, index) => (
+
+ {group.label && {group.label}}
+
+ {group.items.map((item) => (
+
+
+
+ {item.icon && iconMap[item.icon] && }
+ {item.name}
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ {userName}
+ {userEmail}
+
+
+
+
+
+
+
+
+
+
+
+ {userName}
+ {userEmail}
+
+
+
+
+
+
+ ログアウト
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/admin/src/app/(app)/_layout/sidebar/_nav.tsx b/apps/admin/src/app/(app)/_layout/sidebar/_nav.tsx
deleted file mode 100644
index 847a2ab..0000000
--- a/apps/admin/src/app/(app)/_layout/sidebar/_nav.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Gauge, Users } from "lucide-react";
-import type { Privilege } from "@/types/app";
-
-export type Navigation = {
- component: "title" | "item" | "group";
- name: string;
- to?: string;
- icon?: React.ReactElement;
- items?: Navigation[];
- privilege?: Privilege;
-};
-
-// ここに追加した場合は、パンくずコンポーネントも編集してください
-const _nav: Navigation[] = [
- {
- component: "item",
- name: "Dashboard",
- to: "/dashboard",
- icon: ,
- },
- {
- component: "title",
- name: "System management",
- privilege: "SuperAdmin",
- },
- {
- component: "item",
- name: "Accounts",
- to: "/system/accounts",
- icon: ,
- privilege: "SuperAdmin",
- },
-];
-export default _nav;
diff --git a/apps/admin/src/app/(app)/_layout/sidebar/index.ts b/apps/admin/src/app/(app)/_layout/sidebar/index.ts
deleted file mode 100644
index 01acaef..0000000
--- a/apps/admin/src/app/(app)/_layout/sidebar/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./sidebar";
diff --git a/apps/admin/src/app/(app)/_layout/sidebar/sidebar.module.scss b/apps/admin/src/app/(app)/_layout/sidebar/sidebar.module.scss
deleted file mode 100644
index 31de3dd..0000000
--- a/apps/admin/src/app/(app)/_layout/sidebar/sidebar.module.scss
+++ /dev/null
@@ -1,49 +0,0 @@
-@use "styles/utils" as utils;
-
-.container {
- width: 260px;
- height: 100vh;
- padding: 1rem 0;
- background-color: #1b47ac;
-
- div {
- color: #fff;
- }
-}
-
-.brand {
- padding: 0.5rem 1rem 1rem;
- margin-bottom: 0.5rem;
- font-size: 1.5rem;
-}
-
-.nav {
- width: 100%;
- border-top: 1px solid #fff;
-}
-
-.navList {
- margin-top: 1rem;
-}
-
-.navLink {
- width: 100%;
- padding: 0.5rem 1rem;
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.active {
- background-color: rgba($color: utils.$mainColor, $alpha: 40%);
-}
-
-.title {
- margin-top: 1rem;
-}
-
-.selected {
- &:hover {
- background-color: rgba($color: utils.$mainColor, $alpha: 40%);
- }
-}
diff --git a/apps/admin/src/app/(app)/_layout/sidebar/sidebar.tsx b/apps/admin/src/app/(app)/_layout/sidebar/sidebar.tsx
deleted file mode 100644
index b9802e0..0000000
--- a/apps/admin/src/app/(app)/_layout/sidebar/sidebar.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import React from "react";
-import Link from "next/link";
-import clsx from "clsx";
-import SimpleBar from "simplebar-react";
-import { APP_NAME } from "@/constants/application";
-import navigation, { type Navigation } from "./_nav";
-import ss from "./sidebar.module.scss";
-
-type Props = {
- pathname: string;
- userPrivilege: string;
-};
-
-export const Sidebar = (props: Props) => {
- return (
-
-
-
{APP_NAME}
-
-
-
-
- {navigation?.map((item, index) => navItem(item, index, props.pathname, props.userPrivilege))}
-
-
-
-
- );
-};
-
-const navItem = (item: Navigation, index: number, pathname: string, userPrivilege: string) => {
- const { component, name, to, icon, items, privilege } = item;
- if (privilege && privilege !== userPrivilege) {
- return;
- }
-
- const isActive = component === "item" && !items;
- return (
-
- {to ? (
-
-
- {icon && icon}
- {name && name}
-
-
- ) : (
-
- {icon && icon}
- {name && name}
-
- )}
-
- );
-};
diff --git a/apps/admin/src/app/(app)/layout.tsx b/apps/admin/src/app/(app)/layout.tsx
index ede0da9..7cd7696 100644
--- a/apps/admin/src/app/(app)/layout.tsx
+++ b/apps/admin/src/app/(app)/layout.tsx
@@ -1,6 +1,21 @@
import React from "react";
+import { ACCOUNT_STATUS } from "@/constants/application";
+import { NAVIGATION, filterNavigationByPrivilege } from "@/constants/navigation";
+import { getAppSession } from "@/lib/auth";
import { AppLayout as Layout } from "./_layout";
-export default function AppLayout({ children }: { children: React.ReactNode }) {
- return {children};
+export default async function AppLayout({ children }: { children: React.ReactNode }) {
+ const session = await getAppSession();
+
+ if (session?.user.status !== ACCOUNT_STATUS.active) {
+ return null;
+ }
+
+ const filteredNav = filterNavigationByPrivilege(NAVIGATION, session.user.privilege);
+
+ return (
+
+ {children}
+
+ );
}
diff --git a/apps/admin/src/app/(app)/system/accounts/_components/account-list/condition/condition.tsx b/apps/admin/src/app/(app)/system/accounts/_components/account-list/condition/condition.tsx
index b7c4612..c71c21e 100644
--- a/apps/admin/src/app/(app)/system/accounts/_components/account-list/condition/condition.tsx
+++ b/apps/admin/src/app/(app)/system/accounts/_components/account-list/condition/condition.tsx
@@ -23,7 +23,7 @@ export const Condition = (props: Props) => {
-
);
diff --git a/apps/admin/src/app/(auth)/_layout/auth-layout.module.scss b/apps/admin/src/app/(auth)/_layout/auth-layout.module.scss
deleted file mode 100644
index a3df1c2..0000000
--- a/apps/admin/src/app/(auth)/_layout/auth-layout.module.scss
+++ /dev/null
@@ -1,55 +0,0 @@
-@use "styles/utils" as utils;
-
-.content {
- height: 100vh;
- display: flex;
- font-style: normal;
- font-weight: 500;
- font-size: 13px;
- line-height: 19px;
- background-color: utils.$lightColor;
-
- @include utils.pc {
- flex-direction: row;
- justify-content: space-evenly;
- align-items: center;
- }
-
- @include utils.sp {
- flex-direction: column;
- align-items: center;
- gap: 52px;
- }
-}
-
-.logo {
- font-family: Montserrat;
- font-weight: 800;
- font-size: 2rem;
- line-height: 2.5rem;
- letter-spacing: 0.5px;
-
- @include utils.pc {
- margin: 0 100px;
- }
-
- @include utils.sp {
- margin-top: 50px;
- }
-
- .picture {
- width: 203px;
- height: 46px;
- object-fit: contain;
- }
-}
-
-.main {
- @include utils.pc {
- margin-right: 100px;
- }
-
- @include utils.sp {
- width: 100%;
- }
-}
diff --git a/apps/admin/src/app/(auth)/_layout/auth-layout.tsx b/apps/admin/src/app/(auth)/_layout/auth-layout.tsx
index b362857..4484238 100644
--- a/apps/admin/src/app/(auth)/_layout/auth-layout.tsx
+++ b/apps/admin/src/app/(auth)/_layout/auth-layout.tsx
@@ -2,7 +2,6 @@
import Link from "next/link";
import { APP_NAME } from "@/constants/application";
-import ss from "./auth-layout.module.scss";
import { ProtectedView } from "./protected-view";
type Props = {
@@ -12,11 +11,14 @@ type Props = {
export const AuthLayout = ({ children }: Props) => {
return (
-
-
+
+
{APP_NAME}
- {children}
+ {children}
);
diff --git a/apps/admin/src/app/(auth)/_layout/index.ts b/apps/admin/src/app/(auth)/_layout/index.ts
deleted file mode 100644
index b6da60e..0000000
--- a/apps/admin/src/app/(auth)/_layout/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./auth-layout";
diff --git a/apps/admin/src/app/(auth)/layout.tsx b/apps/admin/src/app/(auth)/layout.tsx
index 0a1e82b..e4ab0ab 100644
--- a/apps/admin/src/app/(auth)/layout.tsx
+++ b/apps/admin/src/app/(auth)/layout.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { AuthLayout as Layout } from "./_layout";
+import { AuthLayout as Layout } from "./_layout/auth-layout";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return
{children};
diff --git a/apps/admin/src/app/(auth)/signin/_components/signin.tsx b/apps/admin/src/app/(auth)/signin/_components/signin.tsx
new file mode 100644
index 0000000..23a010b
--- /dev/null
+++ b/apps/admin/src/app/(auth)/signin/_components/signin.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useForm } from "react-hook-form";
+import toast from "react-hot-toast";
+import { Button } from "@/components/buttons";
+import { TextInput } from "@/components/forms";
+import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Form } from "@/components/ui/form";
+import { signIn } from "@/lib/auth-client";
+import { ErrorMessages } from "../_lib/constants";
+
+type SignInForm = {
+ username: string;
+ password: string;
+};
+
+export const SignIn = () => {
+ const [toastShown, setToastShown] = useState(false);
+ const [errorType, setErrorType] = useState("");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const errorCode = searchParams?.get("code") ?? "";
+
+ const form = useForm
({
+ mode: "onBlur",
+ defaultValues: {
+ username: "",
+ password: "",
+ },
+ });
+ const { errors, isSubmitting } = form.formState;
+
+ useEffect(() => {
+ if (errorCode === "E401" && !toastShown) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setToastShown(true);
+ toast.error("認証が無効です。再度ログインしてください。", {
+ duration: 5000,
+ id: "session-invalid-toast",
+ });
+ }
+ if (errorCode === "SignOut" && !toastShown) {
+ setToastShown(true);
+ toast.success("ログアウトしました", {
+ duration: 5000,
+ id: "signout-toast",
+ });
+ }
+ }, [errorCode, toastShown]);
+
+ const signInSubmit = async (data: SignInForm) => {
+ const result = await signIn.email({
+ email: data.username,
+ password: data.password,
+ });
+
+ if (result.error) {
+ setErrorType(result.error.code || "CredentialsSignin");
+ } else if (result.data) {
+ router.refresh();
+ }
+ };
+
+ const error = errorType && (ErrorMessages[errorType] ?? ErrorMessages.default);
+
+ return (
+
+
+ Login to your account
+
+
+
+
+
+ Forgot password?
+
+
+
+ );
+};
diff --git a/apps/admin/src/app/(auth)/signin/_components/signin/index.ts b/apps/admin/src/app/(auth)/signin/_components/signin/index.ts
deleted file mode 100644
index 717070c..0000000
--- a/apps/admin/src/app/(auth)/signin/_components/signin/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./signin";
diff --git a/apps/admin/src/app/(auth)/signin/_components/signin/signin.module.scss b/apps/admin/src/app/(auth)/signin/_components/signin/signin.module.scss
deleted file mode 100644
index 65f523b..0000000
--- a/apps/admin/src/app/(auth)/signin/_components/signin/signin.module.scss
+++ /dev/null
@@ -1,71 +0,0 @@
-@use "styles/utils" as utils;
-
-.container {
- width: 400px;
- background-color: #fff;
- border: 1px solid #d8dbe0;
- padding: 1rem 2rem 2rem;
-
- @include utils.sp {
- width: 90%;
- margin: 1rem auto 0;
- }
-}
-
-.title {
- font-size: 2rem;
- margin-bottom: 1rem;
- padding-bottom: 1rem;
-}
-
-.suspend {
- margin: 1rem 0;
-
- .message {
- font-weight: 500;
- font-size: 13px;
- line-height: 1.25rem;
- }
-}
-
-.form {
- margin-top: 2rem;
- display: flex;
- flex-direction: column;
- gap: 1.5rem;
-
- @include utils.sp {
- width: 100%;
- margin-bottom: 2rem;
- padding: 0;
- }
-}
-
-.login {
- width: 130px;
-}
-
-.box {
- padding-top: 1.5rem;
-
- @include utils.sp {
- width: 100%;
- display: flex;
- justify-content: center;
- }
-}
-
-.forget {
- font-weight: 500;
- font-size: 13px;
- line-height: 19px;
- color: utils.$accentColor;
-
- &:hover {
- color: utils.$subColor;
- }
-}
-
-.error {
- color: utils.$invalidColor;
-}
diff --git a/apps/admin/src/app/(auth)/signin/_components/signin/signin.tsx b/apps/admin/src/app/(auth)/signin/_components/signin/signin.tsx
deleted file mode 100644
index 527ae42..0000000
--- a/apps/admin/src/app/(auth)/signin/_components/signin/signin.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useForm } from "react-hook-form";
-import { Button } from "@/components/buttons";
-import { TextInput } from "@/components/forms";
-import { signIn } from "@/lib/auth-client";
-import { ErrorMessages } from "../constants";
-import type { LoginUser } from "../types";
-import ss from "./signin.module.scss";
-
-type SignInForm = {
- username: string;
- password: string;
-};
-
-type Props = {
- user?: LoginUser;
-};
-
-export const SignIn = ({ user }: Props) => {
- const [errorType, setErrorType] = useState("");
- const router = useRouter();
- const {
- register,
- handleSubmit,
- formState: { errors, isSubmitting },
- } = useForm({
- mode: "onBlur",
- });
-
- const signInSubmit = handleSubmit(async (data: SignInForm) => {
- const result = await signIn.email({
- email: data.username,
- password: data.password,
- });
-
- if (result.error) {
- setErrorType(result.error.code || "CredentialsSignin");
- } else if (result.data) {
- router.refresh();
- }
- });
-
- const error = errorType && (ErrorMessages[errorType] ?? ErrorMessages.default);
-
- return (
-
-
Sign in
- {user?.status === "suspend" && (
-
-
Your account has been suspended for the following reasons:
-
f
-
- )}
-
-
-
- Forgot password?
-
-
-
- );
-};
diff --git a/apps/admin/src/app/(auth)/signin/_components/types.ts b/apps/admin/src/app/(auth)/signin/_components/types.ts
deleted file mode 100644
index f35b6fa..0000000
--- a/apps/admin/src/app/(auth)/signin/_components/types.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-// Better Auth用のLoginUser型
-export type LoginUser = {
- id: string;
- email: string;
- name?: string | null;
- image?: string | null;
- status: string;
- privilege: string;
- caution?: string | null;
-};
diff --git a/apps/admin/src/app/(auth)/signin/_components/constants.ts b/apps/admin/src/app/(auth)/signin/_lib/constants.ts
similarity index 100%
rename from apps/admin/src/app/(auth)/signin/_components/constants.ts
rename to apps/admin/src/app/(auth)/signin/_lib/constants.ts
diff --git a/apps/admin/src/app/(auth)/signin/_components/schemas.ts b/apps/admin/src/app/(auth)/signin/_lib/schemas.ts
similarity index 100%
rename from apps/admin/src/app/(auth)/signin/_components/schemas.ts
rename to apps/admin/src/app/(auth)/signin/_lib/schemas.ts
diff --git a/apps/admin/src/app/(auth)/signin/page.tsx b/apps/admin/src/app/(auth)/signin/page.tsx
index 9fc539d..a1582ed 100644
--- a/apps/admin/src/app/(auth)/signin/page.tsx
+++ b/apps/admin/src/app/(auth)/signin/page.tsx
@@ -1,9 +1,15 @@
-"use client";
-
+import { Suspense } from "react";
+import type { Metadata } from "next";
import { SignIn } from "./_components/signin";
-const SigninPage = () => {
- return ;
+export const metadata: Metadata = {
+ title: "Sign In",
};
-export default SigninPage;
+export default function SigninPage() {
+ return (
+ Loading... }>
+
+
+ );
+}
diff --git a/apps/admin/src/app/api/accounts/create-first-account/route.ts b/apps/admin/src/app/api/accounts/create-first-account/route.ts
index 3f8a2b4..be6d35c 100644
--- a/apps/admin/src/app/api/accounts/create-first-account/route.ts
+++ b/apps/admin/src/app/api/accounts/create-first-account/route.ts
@@ -1,5 +1,6 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
+import { ACCOUNT_PRIVILEGE } from "@/constants/application";
import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic";
@@ -14,6 +15,7 @@ export async function POST(request: NextRequest) {
email: body.email,
password: body.password,
image: "https://example.com/image.png",
+ privilege: ACCOUNT_PRIVILEGE.superAdmin,
},
});
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
index da796d5..a47cf7c 100644
--- a/apps/admin/src/app/layout.tsx
+++ b/apps/admin/src/app/layout.tsx
@@ -2,7 +2,6 @@ import React from "react";
import type { Metadata } from "next";
import { Inter, Montserrat, Noto_Sans_JP, Noto_Sans_Mono } from "next/font/google";
import { Toaster } from "react-hot-toast";
-import "simplebar-react/dist/simplebar.min.css";
import { TITLE } from "@/constants/application";
import "@/styles/globals.css";
diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx
index 9fbc220..7ae2bc3 100644
--- a/apps/admin/src/app/page.tsx
+++ b/apps/admin/src/app/page.tsx
@@ -2,18 +2,17 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
+import { ACCOUNT_STATUS, DEFAULT_VIEW } from "@/constants/application";
import { useSession } from "@/lib/auth-client";
-import { AuthLayout } from "./(auth)/_layout";
-import SigninPage from "./(auth)/signin/page";
-
-const DEFAULT_VIEW = "/dashboard";
+import { AuthLayout } from "./(auth)/_layout/auth-layout";
+import { SignIn } from "./(auth)/signin/_components/signin";
const Page = () => {
const router = useRouter();
const { data: session, isPending } = useSession();
const isUser = !!session?.user;
- const activated = session?.user?.status === "active";
+ const activated = session?.user?.status === ACCOUNT_STATUS.active;
useEffect(() => {
if (isPending) {
@@ -31,7 +30,7 @@ const Page = () => {
if (!isUser || !activated) {
return (
-
+
);
}
diff --git a/apps/admin/src/app/signout/page.tsx b/apps/admin/src/app/signout/page.tsx
index 304c37a..775e2ed 100644
--- a/apps/admin/src/app/signout/page.tsx
+++ b/apps/admin/src/app/signout/page.tsx
@@ -1,23 +1,19 @@
"use client";
import { useEffect } from "react";
-import { useRouter } from "next/navigation";
import { signOut } from "@/lib/auth-client";
export default function SignOutPage() {
- const router = useRouter();
-
useEffect(() => {
(async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
- router.push("/signin?code=SignOut");
+ window.location.href = "/signin?code=SignOut";
},
},
});
})();
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
diff --git a/apps/admin/src/components/badge/badge.module.scss b/apps/admin/src/components/badge/badge.module.scss
deleted file mode 100644
index fbbf8a1..0000000
--- a/apps/admin/src/components/badge/badge.module.scss
+++ /dev/null
@@ -1,19 +0,0 @@
-@use "styles/utils" as utils;
-
-.container {
- padding: 0.5rem 1rem;
- width: fit-content;
-}
-
-.info {
- background-color: utils.$subColor;
- color: #fff;
-}
-
-.round {
- border-radius: 80px;
-}
-
-.square {
- border-radius: 5px;
-}
diff --git a/apps/admin/src/components/badge/badge.tsx b/apps/admin/src/components/badge/badge.tsx
deleted file mode 100644
index f27db84..0000000
--- a/apps/admin/src/components/badge/badge.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { clsx } from "clsx";
-import ss from "./badge.module.scss";
-
-export type Color = "info";
-
-type Props = {
- color?: Color;
- text: string;
- shape?: "round" | "square";
-};
-
-export const Badge = ({ color = "info", text, shape = "round" }: Props) => {
- return {text}
;
-};
diff --git a/apps/admin/src/components/badge/index.ts b/apps/admin/src/components/badge/index.ts
deleted file mode 100644
index 80844a4..0000000
--- a/apps/admin/src/components/badge/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./badge";
diff --git a/apps/admin/src/components/breadcrumb/_routes.ts b/apps/admin/src/components/breadcrumb/_routes.ts
deleted file mode 100644
index c62e34c..0000000
--- a/apps/admin/src/components/breadcrumb/_routes.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-export type RouteType = {
- path: string; // routing path
- name: string; // display name
- disable?: boolean; // disabled to click
-};
-
-const ROUTES: RouteType[] = [
- { path: "/", name: "Home" },
- { path: "/dashboard", name: "Dashboard" },
- { path: "/articles", name: "Articles" },
- { path: "/analyses/new", name: "New" },
- { path: "/users", name: "Users" },
- { path: "/system", name: `System`, disable: true },
- { path: "/system/accounts", name: "Accounts" },
- { path: "/system/accounts/new", name: "New" },
- { path: "/system/accounts/[id]/edit", name: "Edit" },
-];
-
-export default ROUTES;
diff --git a/apps/admin/src/components/breadcrumb/breadcrumb.module.scss b/apps/admin/src/components/breadcrumb/breadcrumb.module.scss
deleted file mode 100644
index 069acb2..0000000
--- a/apps/admin/src/components/breadcrumb/breadcrumb.module.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-@use "styles/utils" as utils;
-
-.container {
- display: inline-flex;
- gap: 0.5rem;
-}
-
-.separator {
- color: utils.$subColor;
-}
-
-.item {
- color: utils.$menuColor;
-}
diff --git a/apps/admin/src/components/breadcrumb/breadcrumb.tsx b/apps/admin/src/components/breadcrumb/breadcrumb.tsx
index 0555f26..dd830e9 100644
--- a/apps/admin/src/components/breadcrumb/breadcrumb.tsx
+++ b/apps/admin/src/components/breadcrumb/breadcrumb.tsx
@@ -1,9 +1,17 @@
+"use client";
+
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
-import clsx from "clsx";
-import ROUTES from "./_routes";
-import ss from "./breadcrumb.module.scss";
+import {
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ Breadcrumb as BreadcrumbRoot,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import { BREADCRUMB_ROUTES } from "@/constants/navigation";
type BreadcrumbType = {
name: string;
@@ -21,7 +29,7 @@ export const Breadcrumb = () => {
location.split("/").reduce((prev, curr, index, array) => {
const currentPath = `${prev}/${curr}`;
- const route = ROUTES.find((route) => route.path === currentPath);
+ const route = BREADCRUMB_ROUTES.find((route) => route.path === currentPath);
const regexp = /\[(.*?)\]/g; // ex:[id]
let realpath = route?.path || "";
@@ -62,24 +70,28 @@ export const Breadcrumb = () => {
}
return (
-
-
- Home
-
- {breadcrumbs.map((breadcrumb, index) => {
- return (
+
+
+
+
+ Home
+
+
+ {breadcrumbs.map((breadcrumb, index) => (
- {">"}
- {breadcrumb.active ? (
- {breadcrumb.name}
- ) : (
-
- {breadcrumb.name}
-
- )}
+
+
+ {breadcrumb.active ? (
+ {breadcrumb.name}
+ ) : (
+
+ {breadcrumb.name}
+
+ )}
+
- );
- })}
-
+ ))}
+
+
);
};
diff --git a/apps/admin/src/components/buttons/button.tsx b/apps/admin/src/components/buttons/button.tsx
new file mode 100644
index 0000000..046572d
--- /dev/null
+++ b/apps/admin/src/components/buttons/button.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import { ClipLoader } from "react-spinners";
+import { Button as BaseButton } from "@/components/ui/button";
+import { cn } from "@/lib/utils/component";
+
+type Props = React.ComponentPropsWithoutRef<"button"> & {
+ label: string;
+ loading?: boolean;
+ loadingLabel?: string;
+};
+
+export const Button = (props: Props) => {
+ const { label, loading, loadingLabel, ...rest } = props;
+ return (
+
+ {loading !== undefined && loading && (
+
+ )}
+ {loading !== undefined && loading ? loadingLabel || label : label}
+
+ );
+};
diff --git a/apps/admin/src/components/buttons/button/button.module.scss b/apps/admin/src/components/buttons/button/button.module.scss
deleted file mode 100644
index 7b25742..0000000
--- a/apps/admin/src/components/buttons/button/button.module.scss
+++ /dev/null
@@ -1,40 +0,0 @@
-@use "styles/utils" as utils;
-
-.round {
- border-radius: 24px;
-}
-
-.square {
- border-radius: 3px;
-}
-
-.button {
- width: 100%;
- height: 38px;
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- white-space: nowrap;
- font-size: 1rem;
- font-weight: 700;
- letter-spacing: 0.1em;
- border: 2px solid utils.$accentColor;
- color: #fff;
- padding: 0 1rem;
- background: utils.$accentColor;
- // box-shadow: 0 2px 2px rgb(0 0 0 / 70%);
-
- &:hover,
- &:focus {
- border: 2px solid utils.$accentColor;
- color: utils.$accentColor;
- background: #fff;
- opacity: 1;
- }
-
- &:disabled {
- opacity: 0.6;
- cursor: initial;
- }
-}
diff --git a/apps/admin/src/components/buttons/button/button.tsx b/apps/admin/src/components/buttons/button/button.tsx
deleted file mode 100644
index 2ada4ee..0000000
--- a/apps/admin/src/components/buttons/button/button.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import { ClipLoader } from "react-spinners";
-import ss from "./button.module.scss";
-
-type Props = React.ComponentPropsWithoutRef<"button"> & {
- label: string;
- shape?: "round" | "square";
- loading?: boolean;
- loadingLabel?: string;
-};
-
-export const Button = (props: Props) => {
- const { shape = "round", label, loading, loadingLabel, ...rest } = props;
- return (
-
-
- {loading !== undefined && loading && (
-
- )}
- {loading !== undefined && loading ? loadingLabel || label : label}
-
-
- );
-};
diff --git a/apps/admin/src/components/buttons/button/index.ts b/apps/admin/src/components/buttons/button/index.ts
deleted file mode 100644
index 98d55ac..0000000
--- a/apps/admin/src/components/buttons/button/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./button";
diff --git a/apps/admin/src/components/divider/divider.module.scss b/apps/admin/src/components/divider/divider.module.scss
deleted file mode 100644
index 567a10d..0000000
--- a/apps/admin/src/components/divider/divider.module.scss
+++ /dev/null
@@ -1,7 +0,0 @@
-.container {
- width: 100%;
-}
-
-.divider {
- border: 1px solid rgb(255 255 255 / 11%);
-}
diff --git a/apps/admin/src/components/divider/divider.tsx b/apps/admin/src/components/divider/divider.tsx
deleted file mode 100644
index 0a5eeb7..0000000
--- a/apps/admin/src/components/divider/divider.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import ss from "./styles.module.scss";
-
-type Props = {
- margin?: string;
-};
-
-export const Divider = (props: Props) => (
-
-);
diff --git a/apps/admin/src/components/divider/index.ts b/apps/admin/src/components/divider/index.ts
deleted file mode 100644
index 43f46a1..0000000
--- a/apps/admin/src/components/divider/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./divider";
diff --git a/apps/admin/src/components/ui/button.tsx b/apps/admin/src/components/ui/button.tsx
index 4b31984..2fd025d 100644
--- a/apps/admin/src/components/ui/button.tsx
+++ b/apps/admin/src/components/ui/button.tsx
@@ -5,28 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils/component"
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-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
- "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
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 border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80",
- ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
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",
- "icon-sm": "size-8",
- "icon-lg": "size-10",
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
},
},
defaultVariants: {
@@ -36,25 +34,24 @@ const buttonVariants = cva(
}
)
-function Button({
- className,
- variant,
- size,
- asChild = false,
- ...props
-}: React.ComponentProps<"button"> &
- VariantProps & {
- asChild?: boolean
- }) {
- const Comp = asChild ? Slot : "button"
-
- return (
-
- )
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
}
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
export { Button, buttonVariants }
diff --git a/apps/admin/src/components/ui/collapsible.tsx b/apps/admin/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000..9fa4894
--- /dev/null
+++ b/apps/admin/src/components/ui/collapsible.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+const Collapsible = CollapsiblePrimitive.Root
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
+
+const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/apps/admin/src/components/ui/field.tsx b/apps/admin/src/components/ui/field.tsx
new file mode 100644
index 0000000..06ecd20
--- /dev/null
+++ b/apps/admin/src/components/ui/field.tsx
@@ -0,0 +1,244 @@
+"use client"
+
+import { useMemo } from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils/component"
+import { Label } from "@/components/ui/label"
+import { Separator } from "@/components/ui/separator"
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+