0.0.1
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.env
|
||||||
|
.cursor/*
|
||||||
|
data/*
|
||||||
1286
package-lock.json
generated
1286
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -11,17 +11,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"firebase": "^12.12.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"shadcn": "^4.3.0",
|
"react-hook-form": "^7.72.1",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -33,6 +38,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
|
"shadcn": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.58.0",
|
||||||
"vite": "^8.0.4"
|
"vite": "^8.0.4"
|
||||||
|
|||||||
210
src/App.tsx
210
src/App.tsx
@@ -1,121 +1,103 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import reactLogo from './assets/react.svg'
|
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
import viteLogo from './assets/vite.svg'
|
import { collection, doc, writeBatch } from "firebase/firestore";
|
||||||
import heroImg from './assets/hero.png'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
import { AppLayout } from "@/components/AppLayout";
|
||||||
const [count, setCount] = useState(0)
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { GroupsProvider } from "@/context/GroupsContext";
|
||||||
|
import { CONNECTORS_SEED } from "@/data/connectorsSeed";
|
||||||
|
import { db } from "@/lib/firebase";
|
||||||
|
import { CompartmentPage } from "@/pages/CompartmentPage";
|
||||||
|
import { GroupPage } from "@/pages/GroupPage";
|
||||||
|
import { HomePage } from "@/pages/Home";
|
||||||
|
import { PlaceholderPage } from "@/pages/PlaceholderPage";
|
||||||
|
import { NewPage } from "@/pages/NewPage";
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <AppLayout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <HomePage /> },
|
||||||
|
{ path: "groups/:groupId", element: <GroupPage /> },
|
||||||
|
{
|
||||||
|
path: "groups/:groupId/compartments/:compartmentId",
|
||||||
|
element: <CompartmentPage />,
|
||||||
|
},
|
||||||
|
{ path: "search", element: <PlaceholderPage title="Search" /> },
|
||||||
|
{ path: "new", element: <NewPage /> },
|
||||||
|
{ path: "account", element: <PlaceholderPage title="Account" /> },
|
||||||
|
{ path: "settings", element: <PlaceholderPage title="Settings" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CONNECTORS_COLLECTION = "connectors";
|
||||||
|
|
||||||
|
export function ConnectorsSeedControl() {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
async function insertConnectors() {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Insert ${CONNECTORS_SEED.length} new documents into Firestore collection "${CONNECTORS_COLLECTION}"? Each run creates new document IDs (duplicates if you run again).`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const col = collection(db, CONNECTORS_COLLECTION);
|
||||||
|
const batch = writeBatch(db);
|
||||||
|
for (const row of CONNECTORS_SEED) {
|
||||||
|
batch.set(doc(col), { ...row });
|
||||||
|
}
|
||||||
|
await batch.commit();
|
||||||
|
setNotice(`Inserted ${CONNECTORS_SEED.length} rows.`);
|
||||||
|
} catch (e) {
|
||||||
|
setNotice(
|
||||||
|
e instanceof Error ? e.message : "Failed to write to Firestore.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="fixed right-4 bottom-24 z-50 flex max-w-xs flex-col gap-2 rounded-lg border border-border bg-background/95 p-3 shadow-md backdrop-blur-sm">
|
||||||
<section id="center">
|
<p className="text-xs text-muted-foreground">
|
||||||
<div className="hero">
|
Local only: push rows from{" "}
|
||||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
<code className="text-foreground">connectorsSeed.ts</code> into{" "}
|
||||||
<img src={reactLogo} className="framework" alt="React logo" />
|
<code className="text-foreground">{CONNECTORS_COLLECTION}</code>.
|
||||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1>Get started</h1>
|
|
||||||
<p>
|
|
||||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
|
||||||
</p>
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void insertConnectors()}
|
||||||
|
>
|
||||||
|
{busy ? "Inserting…" : "Insert connectors"}
|
||||||
|
</Button>
|
||||||
|
{notice ? (
|
||||||
|
<p className="text-xs text-muted-foreground wrap-break-word">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
);
|
||||||
className="counter"
|
|
||||||
onClick={() => setCount((count) => count + 1)}
|
|
||||||
>
|
|
||||||
Count is {count}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="ticks"></div>
|
|
||||||
|
|
||||||
<section id="next-steps">
|
|
||||||
<div id="docs">
|
|
||||||
<svg className="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#documentation-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Documentation</h2>
|
|
||||||
<p>Your questions, answered</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://vite.dev/" target="_blank">
|
|
||||||
<img className="logo" src={viteLogo} alt="" />
|
|
||||||
Explore Vite
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://react.dev/" target="_blank">
|
|
||||||
<img className="button-icon" src={reactLogo} alt="" />
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div id="social">
|
|
||||||
<svg className="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#social-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Connect with us</h2>
|
|
||||||
<p>Join the Vite community</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#github-icon"></use>
|
|
||||||
</svg>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://chat.vite.dev/" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#discord-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Discord
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://x.com/vite_js" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#x-icon"></use>
|
|
||||||
</svg>
|
|
||||||
X.com
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#bluesky-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Bluesky
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="ticks"></div>
|
|
||||||
<section id="spacer"></section>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
function App() {
|
||||||
|
return (
|
||||||
|
<GroupsProvider>
|
||||||
|
{/* {import.meta.env.DEV ? <ConnectorsSeedControl /> : null} */}
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</GroupsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
19
src/components/AppLayout.tsx
Normal file
19
src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Breadcrumbs } from "@/components/Breadcrumbs";
|
||||||
|
import { BottomNav } from "@/components/BottomNav";
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-muted h-screen overflow-y-auto">
|
||||||
|
<Breadcrumbs />
|
||||||
|
<div className="pb-16">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BottomNav />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/BottomNav.tsx
Normal file
76
src/components/BottomNav.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
HomeIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { matchPath, NavLink, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ButtonGroup } from "@/components/ui/button-group";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
id: string;
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
Icon: React.ComponentType<{ className?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS: NavItem[] = [
|
||||||
|
{ id: "home", to: "/", label: "Home", Icon: HomeIcon },
|
||||||
|
{ id: "search", to: "/search", label: "Search", Icon: SearchIcon },
|
||||||
|
{ id: "new", to: "/new", label: "New", Icon: PlusIcon },
|
||||||
|
{ id: "account", to: "/account", label: "Account", Icon: UserIcon },
|
||||||
|
{ id: "settings", to: "/settings", label: "Settings", Icon: SettingsIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BottomNav({ className }: { className?: string }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const activeLabel =
|
||||||
|
ITEMS.find((i) =>
|
||||||
|
Boolean(matchPath({ path: i.to, end: i.to === "/" }, location.pathname)),
|
||||||
|
)?.label ?? "Groups";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Bottom navigation"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 bottom-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-md pb-[env(safe-area-inset-bottom)] ">
|
||||||
|
<div className="sr-only" aria-live="polite">
|
||||||
|
{activeLabel}
|
||||||
|
</div>
|
||||||
|
<ButtonGroup className="grid w-full grid-cols-5 rounded-l-none">
|
||||||
|
{ITEMS.map(({ id, to, label, Icon }) => {
|
||||||
|
const isActive = Boolean(
|
||||||
|
matchPath({ path: to, end: to === "/" }, location.pathname),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
asChild
|
||||||
|
variant={isActive ? "default" : "ghost"}
|
||||||
|
className="h-14 w-full flex-col gap-1 px-0 py-2 rounded-l-none"
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
end={to === "/"}
|
||||||
|
className="flex h-full w-full flex-col items-center justify-center gap-1 rounded-0 rounded-l-none"
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
<span className="text-[11px] leading-none">{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
src/components/Breadcrumbs.tsx
Normal file
176
src/components/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Fragment, useMemo } from "react";
|
||||||
|
import { Link, matchPath, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { useGroups } from "@/context/GroupsContext";
|
||||||
|
import { useFirestoreDoc } from "@/hooks/useFirestoreDoc";
|
||||||
|
import { asCompartmentFirestoreDoc } from "@/types/compartments";
|
||||||
|
|
||||||
|
type Crumb = {
|
||||||
|
label: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatSegmentLabel(segment: string) {
|
||||||
|
const decoded = decodeURIComponent(segment);
|
||||||
|
return decoded
|
||||||
|
.replaceAll("-", " ")
|
||||||
|
.replaceAll("&", " & ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { groups } = useGroups();
|
||||||
|
const compartmentMatch = matchPath(
|
||||||
|
{ path: "/groups/:groupId/compartments/:compartmentId", end: true },
|
||||||
|
location.pathname,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupOnlyMatch = matchPath(
|
||||||
|
{ path: "/groups/:groupId", end: true },
|
||||||
|
location.pathname,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupId = compartmentMatch?.params.groupId
|
||||||
|
? decodeURIComponent(compartmentMatch.params.groupId)
|
||||||
|
: groupOnlyMatch?.params.groupId
|
||||||
|
? decodeURIComponent(groupOnlyMatch.params.groupId)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const compartmentId = compartmentMatch?.params.compartmentId
|
||||||
|
? decodeURIComponent(compartmentMatch.params.compartmentId)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const compartmentDocPath = useMemo(() => {
|
||||||
|
if (!compartmentId) return null;
|
||||||
|
return ["compartments", compartmentId] as const;
|
||||||
|
}, [compartmentId]);
|
||||||
|
|
||||||
|
const { data: compartmentDoc } = useFirestoreDoc(
|
||||||
|
compartmentDocPath,
|
||||||
|
Boolean(groupId && compartmentId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const compartmentDisplayName = useMemo(() => {
|
||||||
|
if (!groupId || !compartmentId) return "";
|
||||||
|
|
||||||
|
const data = asCompartmentFirestoreDoc(compartmentDoc);
|
||||||
|
if (!data) return "";
|
||||||
|
|
||||||
|
if (typeof data.group === "string" && data.group.length > 0 && data.group !== groupId) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof data.display_name === "string" && data.display_name.length > 0
|
||||||
|
? data.display_name
|
||||||
|
: "";
|
||||||
|
}, [compartmentDoc, compartmentId, groupId]);
|
||||||
|
|
||||||
|
const groupDisplayName = useMemo(() => {
|
||||||
|
if (!groupId) return "";
|
||||||
|
return groups.find((g) => g.groupId === groupId)?.displayName ?? "";
|
||||||
|
}, [groupId, groups]);
|
||||||
|
|
||||||
|
const groupLabel = useMemo(() => {
|
||||||
|
if (!groupId) return "";
|
||||||
|
return groupDisplayName || formatSegmentLabel(groupId);
|
||||||
|
}, [groupDisplayName, groupId]);
|
||||||
|
|
||||||
|
const compartmentLabel = useMemo(() => {
|
||||||
|
if (!compartmentId) return "";
|
||||||
|
return compartmentDisplayName || formatSegmentLabel(compartmentId);
|
||||||
|
}, [compartmentDisplayName, compartmentId]);
|
||||||
|
|
||||||
|
const crumbs = useMemo(() => {
|
||||||
|
const compartmentMatchForCrumbs = matchPath(
|
||||||
|
{ path: "/groups/:groupId/compartments/:compartmentId", end: true },
|
||||||
|
location.pathname,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupMatchForCrumbs = matchPath(
|
||||||
|
{ path: "/groups/:groupId", end: true },
|
||||||
|
location.pathname,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (location.pathname === "/") {
|
||||||
|
return [{ label: "Groups" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compartmentMatchForCrumbs?.params.groupId && compartmentMatchForCrumbs.params.compartmentId) {
|
||||||
|
const gid = decodeURIComponent(compartmentMatchForCrumbs.params.groupId);
|
||||||
|
const cid = decodeURIComponent(compartmentMatchForCrumbs.params.compartmentId);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: "Groups", to: "/" },
|
||||||
|
{
|
||||||
|
label: groupLabel || formatSegmentLabel(gid),
|
||||||
|
to: `/groups/${encodeURIComponent(gid)}`,
|
||||||
|
},
|
||||||
|
{ label: compartmentLabel || formatSegmentLabel(cid) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupMatchForCrumbs?.params.groupId) {
|
||||||
|
const id = decodeURIComponent(groupMatchForCrumbs.params.groupId);
|
||||||
|
return [
|
||||||
|
{ label: "Groups", to: "/" },
|
||||||
|
{ label: groupLabel || formatSegmentLabel(id) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = location.pathname.split("/").filter(Boolean);
|
||||||
|
const built: Crumb[] = [{ label: "Groups", to: "/" }];
|
||||||
|
|
||||||
|
let acc = "";
|
||||||
|
segments.forEach((segment, idx) => {
|
||||||
|
acc += `/${segment}`;
|
||||||
|
const isLast = idx === segments.length - 1;
|
||||||
|
|
||||||
|
const label = formatSegmentLabel(segment);
|
||||||
|
|
||||||
|
built.push(isLast ? { label } : { label, to: acc });
|
||||||
|
});
|
||||||
|
|
||||||
|
return built;
|
||||||
|
}, [compartmentLabel, groupLabel, location.pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||||
|
<div className="mx-auto max-w-md px-4 py-3">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
{crumbs.map((c, idx) => {
|
||||||
|
const isLast = idx === crumbs.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={`${c.label}-${idx}`}>
|
||||||
|
{idx > 0 ? <BreadcrumbSeparator /> : null}
|
||||||
|
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{c.to && !isLast ? (
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link to={c.to}>{c.label}</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbPage>{c.label}</BreadcrumbPage>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/CompartmentItemCountDrawer.tsx
Normal file
146
src/components/CompartmentItemCountDrawer.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { doc, updateDoc } from "firebase/firestore";
|
||||||
|
import { Minus, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
import type { CompartmentItemRow } from "@/hooks/useCompartmentItems";
|
||||||
|
import { itemPrimaryTitle } from "@/lib/compartmentItemDisplay";
|
||||||
|
import { db } from "@/lib/firebase";
|
||||||
|
import type {
|
||||||
|
CompartmentFirestoreDoc,
|
||||||
|
CompartmentId,
|
||||||
|
} from "@/types/compartments";
|
||||||
|
import { compartmentFieldLabel } from "@/types/compartments";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
compartmentCollectionId: CompartmentId;
|
||||||
|
item: CompartmentItemRow | null;
|
||||||
|
schemaOrder: string[];
|
||||||
|
compartmentDoc: CompartmentFirestoreDoc | undefined;
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readCount(data: CompartmentItemRow["data"]): number {
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
const raw = record.count;
|
||||||
|
if (typeof raw !== "number" || !Number.isFinite(raw)) return 0;
|
||||||
|
return Math.max(0, Math.floor(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompartmentItemCountDrawer({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
compartmentCollectionId,
|
||||||
|
item,
|
||||||
|
schemaOrder,
|
||||||
|
compartmentDoc,
|
||||||
|
onSaved,
|
||||||
|
}: Props) {
|
||||||
|
const [draft, setDraft] = useState(0);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!item) return;
|
||||||
|
setDraft(readCount(item.data));
|
||||||
|
setSaveError("");
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const countLabel = compartmentFieldLabel(compartmentDoc, "count");
|
||||||
|
const unitLabel = countLabel.toUpperCase();
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!item) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError("");
|
||||||
|
try {
|
||||||
|
await updateDoc(doc(db, compartmentCollectionId, item.id), {
|
||||||
|
count: draft,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
setSaveError(e instanceof Error ? e.message : "Could not update count.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>Item count</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{item
|
||||||
|
? `Update quantity for ${itemPrimaryTitle(item, schemaOrder)}.`
|
||||||
|
: "Set how many you have on hand."}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2 px-4 pb-2">
|
||||||
|
<div className="flex w-full max-w-xs items-center justify-center gap-6 py-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="size-12 rounded-full"
|
||||||
|
aria-label="Decrease count"
|
||||||
|
disabled={!item || saving || draft <= 0}
|
||||||
|
onClick={() => setDraft((n) => Math.max(0, n - 1))}
|
||||||
|
>
|
||||||
|
<Minus className="size-5" />
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-[4ch] text-center text-4xl font-semibold tabular-nums tracking-tight">
|
||||||
|
{item ? draft : "—"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="size-12 rounded-full"
|
||||||
|
aria-label="Increase count"
|
||||||
|
disabled={!item || saving}
|
||||||
|
onClick={() => setDraft((n) => n + 1)}
|
||||||
|
>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
|
{unitLabel}
|
||||||
|
</p>
|
||||||
|
{saveError ? (
|
||||||
|
<p className="text-center text-sm text-destructive">{saveError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!item || saving}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/components/ui/breadcrumb.tsx
Normal file
122
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("transition-colors hover:text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
21
src/components/ui/button-group.tsx
Normal file
21
src/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ButtonGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="button-group"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex w-fit items-center",
|
||||||
|
"[&>[data-slot=button]:not(:first-child)]:-ml-px",
|
||||||
|
"[&>[data-slot=button]]:rounded-none",
|
||||||
|
"[&>[data-slot=button]]:focus-visible:z-10",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ButtonGroup };
|
||||||
58
src/components/ui/card.tsx
Normal file
58
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn("flex flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("text-base font-semibold leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardContent, CardDescription, CardHeader, CardTitle };
|
||||||
132
src/components/ui/drawer.tsx
Normal file
132
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
236
src/components/ui/field.tsx
Normal file
236
src/components/ui/field.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
19
src/components/ui/input.tsx
Normal file
19
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={position === "item-aligned"}
|
||||||
|
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
data-position={position}
|
||||||
|
className={cn(
|
||||||
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
|
position === "popper" && ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
45
src/context/GroupsContext.tsx
Normal file
45
src/context/GroupsContext.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { useSettingsGroups, type GroupRow } from "@/hooks/useSettingsGroups";
|
||||||
|
|
||||||
|
export type { GroupRow };
|
||||||
|
|
||||||
|
type GroupsContextValue = {
|
||||||
|
groups: GroupRow[];
|
||||||
|
status: "idle" | "loading" | "error";
|
||||||
|
error: string;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupsContext = createContext<GroupsContextValue | null>(null);
|
||||||
|
|
||||||
|
export function GroupsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { groups, status, error, refresh } = useSettingsGroups(true);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
groups,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
[error, groups, refresh, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GroupsContext.Provider value={value}>{children}</GroupsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGroups() {
|
||||||
|
const ctx = useContext(GroupsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useGroups must be used within a GroupsProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
153
src/data/connectorsSeed.ts
Normal file
153
src/data/connectorsSeed.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type { Connector } from "@/types/compartments";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed rows for the Firestore `connectors` collection.
|
||||||
|
* Shape matches `Connector` in `@/types/compartments`.
|
||||||
|
*/
|
||||||
|
export const CONNECTORS_SEED: readonly Connector[] = [
|
||||||
|
{
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
brand: "Belden",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "Black",
|
||||||
|
count: 51
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Belden",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "Mixed",
|
||||||
|
count: 83,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Goobay",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat6 Shielded",
|
||||||
|
color: "Silver",
|
||||||
|
count: 50,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Panduit",
|
||||||
|
type: "Mini-Com",
|
||||||
|
category: "Cat6A",
|
||||||
|
color: "Green",
|
||||||
|
count: 6,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Panduit",
|
||||||
|
type: "Mini-Com",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "White",
|
||||||
|
count: 18,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Panduit",
|
||||||
|
type: "Netkey",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "Blue",
|
||||||
|
count: 24,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Panduit",
|
||||||
|
type: "Netkey",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "White",
|
||||||
|
count: 67,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Panduit",
|
||||||
|
type: "Mini-Com",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "Black",
|
||||||
|
count: 200,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Panduit",
|
||||||
|
type: "Mini-Com",
|
||||||
|
category: "Cat5e",
|
||||||
|
color: "Black",
|
||||||
|
count: 150,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Wavenet",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat5e",
|
||||||
|
color: "Blue",
|
||||||
|
count: 7,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Wavenet",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat5e",
|
||||||
|
color: "Black",
|
||||||
|
count: 18,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Wavenet",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat5e",
|
||||||
|
color: "White",
|
||||||
|
count: 25,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Wavenet",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "Black",
|
||||||
|
count: 25,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Wavenet",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "Yellow",
|
||||||
|
count: 34,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Wavenet",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "White",
|
||||||
|
count: 50,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brand: "Wavenet",
|
||||||
|
type: "Keystone",
|
||||||
|
category: "Cat6",
|
||||||
|
color: "Red",
|
||||||
|
count: 102,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
85
src/hooks/useCompartmentItems.ts
Normal file
85
src/hooks/useCompartmentItems.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { collection, getDocs } from "firebase/firestore";
|
||||||
|
|
||||||
|
import { db } from "@/lib/firebase";
|
||||||
|
import type { CompartmentId, CompartmentItem } from "@/types/compartments";
|
||||||
|
|
||||||
|
export type CompartmentItemRow = {
|
||||||
|
id: string;
|
||||||
|
data: CompartmentItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FirestoreStatus = "idle" | "loading" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all documents from the top-level Firestore collection whose id matches
|
||||||
|
* the compartment id (e.g. compartment `connectors` → collection `connectors`).
|
||||||
|
*/
|
||||||
|
export function useCompartmentItems(
|
||||||
|
compartmentCollectionId: CompartmentId,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const [items, setItems] = useState<CompartmentItemRow[]>([]);
|
||||||
|
const [status, setStatus] = useState<FirestoreStatus>("idle");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
const [reloadToken, setReloadToken] = useState(0);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setReloadToken((n) => n + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!enabled || !compartmentCollectionId) {
|
||||||
|
setItems([]);
|
||||||
|
setStatus("idle");
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snap = await getDocs(
|
||||||
|
collection(db, compartmentCollectionId),
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const rows: CompartmentItemRow[] = snap.docs.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
data: d.data() as CompartmentItem,
|
||||||
|
}));
|
||||||
|
|
||||||
|
rows.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
|
||||||
|
|
||||||
|
setItems(rows);
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setStatus("error");
|
||||||
|
setError(
|
||||||
|
e instanceof Error ? e.message : "Failed to load compartment items",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [compartmentCollectionId, enabled, reloadToken]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
items,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
reload,
|
||||||
|
}),
|
||||||
|
[items, error, status, reload],
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/hooks/useCompartmentsByGroup.ts
Normal file
108
src/hooks/useCompartmentsByGroup.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
collection,
|
||||||
|
getDocs,
|
||||||
|
query,
|
||||||
|
where,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
|
||||||
|
import { db } from "@/lib/firebase";
|
||||||
|
|
||||||
|
export type CompartmentRow = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
group: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FirestoreStatus = "idle" | "loading" | "error";
|
||||||
|
|
||||||
|
export function useCompartmentsByGroup(groupId: string) {
|
||||||
|
const [compartments, setCompartments] = useState<CompartmentRow[]>([]);
|
||||||
|
const [status, setStatus] = useState<FirestoreStatus>("idle");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!groupId) {
|
||||||
|
setCompartments([]);
|
||||||
|
setStatus("idle");
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const q = query(
|
||||||
|
collection(db, "compartments"),
|
||||||
|
where("group", "==", groupId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snap = await getDocs(q);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const rows: CompartmentRow[] = snap.docs.map((d) => {
|
||||||
|
const data = d.data() as {
|
||||||
|
compartment?: unknown;
|
||||||
|
display_name?: unknown;
|
||||||
|
group?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const compartmentId =
|
||||||
|
typeof data.compartment === "string" && data.compartment.length > 0
|
||||||
|
? data.compartment
|
||||||
|
: d.id;
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
typeof data.display_name === "string" && data.display_name.length > 0
|
||||||
|
? data.display_name
|
||||||
|
: compartmentId;
|
||||||
|
|
||||||
|
const group =
|
||||||
|
typeof data.group === "string" && data.group.length > 0
|
||||||
|
? data.group
|
||||||
|
: groupId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: compartmentId,
|
||||||
|
displayName,
|
||||||
|
group,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.sort((a, b) =>
|
||||||
|
a.displayName.localeCompare(b.displayName, undefined, {
|
||||||
|
sensitivity: "base",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setCompartments(rows);
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setStatus("error");
|
||||||
|
setError(
|
||||||
|
e instanceof Error ? e.message : "Failed to load compartments",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
compartments,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
[compartments, error, status],
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/hooks/useFirestoreDoc.ts
Normal file
56
src/hooks/useFirestoreDoc.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { doc, getDoc, type DocumentData } from "firebase/firestore";
|
||||||
|
|
||||||
|
import { db } from "@/lib/firebase";
|
||||||
|
|
||||||
|
type FirestoreStatus = "idle" | "loading" | "error";
|
||||||
|
|
||||||
|
export function useFirestoreDoc(
|
||||||
|
path: readonly [string, string] | null,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const [data, setData] = useState<DocumentData | undefined>(undefined);
|
||||||
|
const [status, setStatus] = useState<FirestoreStatus>("idle");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
const [exists, setExists] = useState<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!enabled || !path) {
|
||||||
|
setData(undefined);
|
||||||
|
setExists(undefined);
|
||||||
|
setStatus("idle");
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
setError("");
|
||||||
|
setExists(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snap = await getDoc(doc(db, path[0], path[1]));
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setExists(snap.exists());
|
||||||
|
setData(snap.exists() ? snap.data() : undefined);
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setExists(undefined);
|
||||||
|
setStatus("error");
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load document");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [enabled, path?.[0], path?.[1]]);
|
||||||
|
|
||||||
|
return { data, status, error, exists };
|
||||||
|
}
|
||||||
65
src/hooks/useFormFieldOrder.ts
Normal file
65
src/hooks/useFormFieldOrder.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { doc, getDoc } from "firebase/firestore";
|
||||||
|
|
||||||
|
import { db } from "@/lib/firebase";
|
||||||
|
|
||||||
|
type FirestoreStatus = "idle" | "loading" | "error";
|
||||||
|
|
||||||
|
function parseOrder(data: unknown): string[] {
|
||||||
|
if (data == null || typeof data !== "object") return [];
|
||||||
|
const order = (data as { order?: unknown }).order;
|
||||||
|
if (!Array.isArray(order)) return [];
|
||||||
|
return order.filter(
|
||||||
|
(x): x is string => typeof x === "string" && x.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global field order from `settings/form` — `order` lists every inventory field key
|
||||||
|
* in display order; each compartment form uses the subset that exists in its schema.
|
||||||
|
*/
|
||||||
|
export function useFormFieldOrder(enabled = true) {
|
||||||
|
const [order, setOrder] = useState<string[]>([]);
|
||||||
|
const [status, setStatus] = useState<FirestoreStatus>("idle");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!enabled) {
|
||||||
|
setOrder([]);
|
||||||
|
setStatus("idle");
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snap = await getDoc(doc(db, "settings", "form"));
|
||||||
|
if (!snap.exists()) {
|
||||||
|
setOrder([]);
|
||||||
|
setStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOrder(parseOrder(snap.data()));
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load form field order");
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
order,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
[order, status, error, refresh],
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/hooks/useSettingsGroups.ts
Normal file
75
src/hooks/useSettingsGroups.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { doc, getDoc } from "firebase/firestore";
|
||||||
|
|
||||||
|
import { db } from "@/lib/firebase";
|
||||||
|
|
||||||
|
export type GroupRow = {
|
||||||
|
groupId: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FirestoreStatus = "idle" | "loading" | "error";
|
||||||
|
|
||||||
|
function parseGroupsDoc(data: { groups?: unknown } | undefined): GroupRow[] {
|
||||||
|
const raw = data?.groups;
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.map((g) => g as Partial<GroupRow>)
|
||||||
|
.filter((g) => typeof g.groupId === "string" && g.groupId.length > 0)
|
||||||
|
.map((g) => ({
|
||||||
|
groupId: g.groupId as string,
|
||||||
|
displayName:
|
||||||
|
typeof g.displayName === "string" && g.displayName.length > 0
|
||||||
|
? g.displayName
|
||||||
|
: (g.groupId as string),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsGroups(enabled = true) {
|
||||||
|
const [groups, setGroups] = useState<GroupRow[]>([]);
|
||||||
|
const [status, setStatus] = useState<FirestoreStatus>("idle");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!enabled) {
|
||||||
|
setGroups([]);
|
||||||
|
setStatus("idle");
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snap = await getDoc(doc(db, "settings", "groups"));
|
||||||
|
|
||||||
|
if (!snap.exists()) {
|
||||||
|
setGroups([]);
|
||||||
|
setStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGroups(parseGroupsDoc(snap.data() as { groups?: unknown }));
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load groups");
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
groups,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
[error, groups, refresh, status],
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/lib/compartmentAddForm.ts
Normal file
58
src/lib/compartmentAddForm.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { CompartmentFirestoreDoc } from "@/types/compartments";
|
||||||
|
import { compartmentFieldKeys } from "@/types/compartments";
|
||||||
|
|
||||||
|
function isNumericField(
|
||||||
|
key: string,
|
||||||
|
doc: CompartmentFirestoreDoc | undefined,
|
||||||
|
): boolean {
|
||||||
|
const t = doc?.schema?.[key]?.type?.toLowerCase() ?? "";
|
||||||
|
if (t === "number") return true;
|
||||||
|
return key === "count";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldZod(key: string, doc: CompartmentFirestoreDoc | undefined) {
|
||||||
|
if (!isNumericField(key, doc)) {
|
||||||
|
return z.string().trim().min(1, "Required");
|
||||||
|
}
|
||||||
|
if (key === "count") {
|
||||||
|
return z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Required")
|
||||||
|
.regex(/^\d+$/, { message: "Use a whole number" })
|
||||||
|
.transform((s) => parseInt(s, 10));
|
||||||
|
}
|
||||||
|
return z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Required")
|
||||||
|
.refine((s) => /^-?\d+(\.\d+)?$/.test(s), { message: "Invalid number" })
|
||||||
|
.transform((s) => parseFloat(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zod schema for a new row; shape matches the compartment Firestore `schema` keys. */
|
||||||
|
export function buildAddItemSchema(
|
||||||
|
doc: CompartmentFirestoreDoc | undefined,
|
||||||
|
formKeyOrder?: readonly string[] | undefined,
|
||||||
|
): z.ZodObject<Record<string, z.ZodTypeAny>> | null {
|
||||||
|
const keys = compartmentFieldKeys(doc, formKeyOrder);
|
||||||
|
if (keys.length === 0) return null;
|
||||||
|
const shape: Record<string, z.ZodTypeAny> = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
shape[key] = fieldZod(key, doc);
|
||||||
|
}
|
||||||
|
return z.object(shape);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultValuesForDoc(
|
||||||
|
doc: CompartmentFirestoreDoc | undefined,
|
||||||
|
formKeyOrder?: readonly string[] | undefined,
|
||||||
|
): Record<string, string> {
|
||||||
|
const keys = compartmentFieldKeys(doc, formKeyOrder);
|
||||||
|
return Object.fromEntries(keys.map((k) => [k, ""])) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
}
|
||||||
82
src/lib/compartmentItemDisplay.ts
Normal file
82
src/lib/compartmentItemDisplay.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Timestamp } from "firebase/firestore";
|
||||||
|
|
||||||
|
import type { CompartmentItemRow } from "@/hooks/useCompartmentItems";
|
||||||
|
|
||||||
|
const dateTimeDisplay = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
|
function coerceToDate(value: unknown): Date | null {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? null : value;
|
||||||
|
}
|
||||||
|
if (value instanceof Timestamp) {
|
||||||
|
const d = value.toDate();
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as { toDate?: unknown }).toDate === "function"
|
||||||
|
) {
|
||||||
|
const d = (value as { toDate: () => unknown }).toDate();
|
||||||
|
return d instanceof Date && !Number.isNaN(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFieldValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
const asDate = coerceToDate(value);
|
||||||
|
if (asDate) return dateTimeDisplay.format(asDate);
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||||
|
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||||
|
if (typeof value === "string") return value.length > 0 ? value : "—";
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderedDisplayKeys(schemaOrder: string[], data: object): string[] {
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const k of schemaOrder) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(record, k)) {
|
||||||
|
out.push(k);
|
||||||
|
seen.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of Object.keys(record).sort()) {
|
||||||
|
if (!seen.has(k)) out.push(k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLE_FIELD_ORDER = ["type", "brand", "color"] as const;
|
||||||
|
|
||||||
|
export function itemPrimaryTitle(
|
||||||
|
item: CompartmentItemRow,
|
||||||
|
schemaOrder: string[],
|
||||||
|
): string {
|
||||||
|
const record = item.data as Record<string, unknown>;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const k of TITLE_FIELD_ORDER) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(record, k)) continue;
|
||||||
|
const s = formatFieldValue(record[k]);
|
||||||
|
if (s !== "—") parts.push(s);
|
||||||
|
}
|
||||||
|
if (parts.length > 0) return parts.join(" · ");
|
||||||
|
|
||||||
|
const keys =
|
||||||
|
schemaOrder.length > 0
|
||||||
|
? schemaOrder.filter((k) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(record, k),
|
||||||
|
)
|
||||||
|
: Object.keys(record).sort();
|
||||||
|
const fallback: string[] = [];
|
||||||
|
for (const k of keys) {
|
||||||
|
const s = formatFieldValue(record[k]);
|
||||||
|
if (s !== "—" && fallback.length < 3) fallback.push(s);
|
||||||
|
}
|
||||||
|
return fallback.length > 0 ? fallback.join(" · ") : item.id;
|
||||||
|
}
|
||||||
16
src/lib/firebase.ts
Normal file
16
src/lib/firebase.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { initializeApp } from "firebase/app"
|
||||||
|
import { getFirestore } from "firebase/firestore"
|
||||||
|
|
||||||
|
// Your web app's Firebase configuration
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "AIzaSyAwvC3GZzh1dYTiA7y6LpNb15A4D5PIMHc",
|
||||||
|
authDomain: "garcia-inv.firebaseapp.com",
|
||||||
|
projectId: "garcia-inv",
|
||||||
|
storageBucket: "garcia-inv.firebasestorage.app",
|
||||||
|
messagingSenderId: "981984935521",
|
||||||
|
appId: "1:981984935521:web:771359bc2c0345503f7420",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Firebase
|
||||||
|
export const app = initializeApp(firebaseConfig)
|
||||||
|
export const db = getFirestore(app)
|
||||||
211
src/pages/CompartmentPage.tsx
Normal file
211
src/pages/CompartmentPage.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { CompartmentItemCountDrawer } from "@/components/CompartmentItemCountDrawer";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useGroups } from "@/context/GroupsContext";
|
||||||
|
import {
|
||||||
|
type CompartmentItemRow,
|
||||||
|
useCompartmentItems,
|
||||||
|
} from "@/hooks/useCompartmentItems";
|
||||||
|
import { useFormFieldOrder } from "@/hooks/useFormFieldOrder";
|
||||||
|
import { useFirestoreDoc } from "@/hooks/useFirestoreDoc";
|
||||||
|
import {
|
||||||
|
formatFieldValue,
|
||||||
|
itemPrimaryTitle,
|
||||||
|
orderedDisplayKeys,
|
||||||
|
} from "@/lib/compartmentItemDisplay";
|
||||||
|
import type { CompartmentId } from "@/types/compartments";
|
||||||
|
import {
|
||||||
|
asCompartmentFirestoreDoc,
|
||||||
|
compartmentFieldKeys,
|
||||||
|
compartmentFieldLabel,
|
||||||
|
} from "@/types/compartments";
|
||||||
|
|
||||||
|
export function CompartmentPage() {
|
||||||
|
const [drawerItem, setDrawerItem] = useState<CompartmentItemRow | null>(null);
|
||||||
|
const { groups } = useGroups();
|
||||||
|
const { order: formKeyOrder } = useFormFieldOrder();
|
||||||
|
const { groupId: groupIdParam, compartmentId: compartmentIdParam } =
|
||||||
|
useParams();
|
||||||
|
|
||||||
|
const groupId = useMemo(() => {
|
||||||
|
if (!groupIdParam) return "";
|
||||||
|
return decodeURIComponent(groupIdParam);
|
||||||
|
}, [groupIdParam]);
|
||||||
|
|
||||||
|
const compartmentId = useMemo(() => {
|
||||||
|
if (!compartmentIdParam) return "";
|
||||||
|
return decodeURIComponent(compartmentIdParam);
|
||||||
|
}, [compartmentIdParam]);
|
||||||
|
|
||||||
|
const compartmentDocPath = useMemo(() => {
|
||||||
|
if (!compartmentId) return null;
|
||||||
|
return ["compartments", compartmentId] as const;
|
||||||
|
}, [compartmentId]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: compartmentDoc,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
exists,
|
||||||
|
} = useFirestoreDoc(compartmentDocPath, Boolean(compartmentId));
|
||||||
|
|
||||||
|
const doc = useMemo(
|
||||||
|
() => asCompartmentFirestoreDoc(compartmentDoc),
|
||||||
|
[compartmentDoc],
|
||||||
|
);
|
||||||
|
|
||||||
|
const schemaOrder = useMemo(
|
||||||
|
() => compartmentFieldKeys(doc, formKeyOrder),
|
||||||
|
[doc, formKeyOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupName = useMemo(() => {
|
||||||
|
if (!groupId) return "";
|
||||||
|
return groups.find((g) => g.groupId === groupId)?.displayName ?? "";
|
||||||
|
}, [groupId, groups]);
|
||||||
|
|
||||||
|
const docGroup = doc?.group ?? "";
|
||||||
|
|
||||||
|
const groupMismatch = useMemo(() => {
|
||||||
|
if (!groupId || !docGroup) return false;
|
||||||
|
return docGroup !== groupId;
|
||||||
|
}, [docGroup, groupId]);
|
||||||
|
|
||||||
|
const itemsEnabled = Boolean(
|
||||||
|
compartmentId && exists === true && !groupMismatch,
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
status: itemsStatus,
|
||||||
|
error: itemsError,
|
||||||
|
reload: reloadItems,
|
||||||
|
} = useCompartmentItems(compartmentId as CompartmentId, itemsEnabled);
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
if (!compartmentId) return "Compartment";
|
||||||
|
if (exists === true && groupMismatch) return compartmentId;
|
||||||
|
const name = doc?.display_name;
|
||||||
|
if (typeof name === "string" && name.length > 0) return name;
|
||||||
|
return compartmentId;
|
||||||
|
}, [compartmentId, doc?.display_name, exists, groupMismatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-4 pt-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{groupId ? (
|
||||||
|
<>
|
||||||
|
Group:{" "}
|
||||||
|
<span className="font-medium text-foreground">{groupName}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{status === "loading" ? (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
Loading compartment…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === "error" ? (
|
||||||
|
<p className="mt-4 text-sm text-destructive">{error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === "idle" && exists === false ? (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
No compartment document found for this id.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{exists === true && groupMismatch ? (
|
||||||
|
<p className="mt-4 text-sm text-destructive">
|
||||||
|
This compartment is not in the group you opened it from.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{exists === true && !groupMismatch && compartmentDoc ? (
|
||||||
|
<p className="mt-4 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">Id:</span>{" "}
|
||||||
|
{compartmentId}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{itemsEnabled ? (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">Items</h2>
|
||||||
|
|
||||||
|
{itemsStatus === "loading" ? (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Loading items…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{itemsStatus === "error" ? (
|
||||||
|
<p className="mt-2 text-sm text-destructive">{itemsError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const row = item.data as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl text-left transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
onClick={() => setDrawerItem(item)}
|
||||||
|
>
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="gap-2 p-0">
|
||||||
|
<CardTitle className="text-base leading-snug">
|
||||||
|
{itemPrimaryTitle(item, schemaOrder)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="space-y-1 text-xs">
|
||||||
|
{orderedDisplayKeys(schemaOrder, item.data).map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="grid grid-cols-[minmax(0,0.42fr)_minmax(0,0.58fr)] gap-x-2 gap-y-0.5"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{compartmentFieldLabel(doc, key)}
|
||||||
|
</span>
|
||||||
|
<span className="wrap-break-word text-foreground">
|
||||||
|
{formatFieldValue(row[key])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CompartmentItemCountDrawer
|
||||||
|
open={drawerItem !== null}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setDrawerItem(null);
|
||||||
|
}}
|
||||||
|
compartmentCollectionId={compartmentId as CompartmentId}
|
||||||
|
item={drawerItem}
|
||||||
|
schemaOrder={schemaOrder}
|
||||||
|
compartmentDoc={doc}
|
||||||
|
onSaved={reloadItems}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{itemsStatus === "idle" && items.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
No items in this compartment.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/pages/GroupPage.tsx
Normal file
65
src/pages/GroupPage.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useGroups } from "@/context/GroupsContext";
|
||||||
|
import { useCompartmentsByGroup } from "@/hooks/useCompartmentsByGroup";
|
||||||
|
|
||||||
|
export function GroupPage() {
|
||||||
|
const { groupId: groupIdParam } = useParams();
|
||||||
|
const { groups } = useGroups();
|
||||||
|
|
||||||
|
const groupId = useMemo(() => {
|
||||||
|
if (!groupIdParam) return "";
|
||||||
|
return decodeURIComponent(groupIdParam);
|
||||||
|
}, [groupIdParam]);
|
||||||
|
|
||||||
|
const groupTitle = useMemo(() => {
|
||||||
|
if (!groupId) return "Group";
|
||||||
|
const match = groups.find((g) => g.groupId === groupId);
|
||||||
|
return match?.displayName ?? groupId;
|
||||||
|
}, [groupId, groups]);
|
||||||
|
|
||||||
|
const { compartments, status, error } = useCompartmentsByGroup(groupId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-4 pt-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{groupTitle}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Compartments</p>
|
||||||
|
|
||||||
|
{status === "loading" ? (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">Loading compartments…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === "error" ? (
|
||||||
|
<p className="mt-4 text-sm text-destructive">{error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||||
|
{compartments.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.id}
|
||||||
|
to={`/groups/${encodeURIComponent(groupId)}/compartments/${encodeURIComponent(c.id)}`}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
|
<Card className="h-full transition-colors hover:bg-muted/40">
|
||||||
|
<CardHeader className="p-0">
|
||||||
|
<CardTitle className="line-clamp-2">{c.displayName}</CardTitle>
|
||||||
|
<CardDescription className="truncate">{c.id}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status !== "loading" && groupId && compartments.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">No compartments found.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/pages/Home.tsx
Normal file
51
src/pages/Home.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useGroups } from "@/context/GroupsContext";
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const { groups, status, error } = useGroups();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-4 pt-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Groups</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Choose a group.</p>
|
||||||
|
|
||||||
|
{status === "loading" ? (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">Loading groups…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status === "error" ? (
|
||||||
|
<p className="mt-4 text-sm text-destructive">{error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Link
|
||||||
|
key={g.groupId}
|
||||||
|
to={`/groups/${encodeURIComponent(g.groupId)}`}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
|
<Card className="h-full transition-colors hover:bg-muted/40">
|
||||||
|
<CardHeader className="p-0">
|
||||||
|
<CardTitle className="line-clamp-2">{g.displayName}</CardTitle>
|
||||||
|
<CardDescription className="truncate">
|
||||||
|
{g.groupId}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status !== "loading" && groups.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">No groups found.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
306
src/pages/NewPage.tsx
Normal file
306
src/pages/NewPage.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { addDoc, collection } from "firebase/firestore";
|
||||||
|
import { db } from "@/lib/firebase";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSet,
|
||||||
|
} from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useGroups } from "@/context/GroupsContext";
|
||||||
|
import { useCompartmentsByGroup } from "@/hooks/useCompartmentsByGroup";
|
||||||
|
import { useFormFieldOrder } from "@/hooks/useFormFieldOrder";
|
||||||
|
import { useFirestoreDoc } from "@/hooks/useFirestoreDoc";
|
||||||
|
import {
|
||||||
|
buildAddItemSchema,
|
||||||
|
defaultValuesForDoc,
|
||||||
|
} from "@/lib/compartmentAddForm";
|
||||||
|
import type { CompartmentId } from "@/types/compartments";
|
||||||
|
import {
|
||||||
|
asCompartmentFirestoreDoc,
|
||||||
|
compartmentFieldKeys,
|
||||||
|
compartmentFieldLabel,
|
||||||
|
} from "@/types/compartments";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
function AddItemForm(props: {
|
||||||
|
doc: ReturnType<typeof asCompartmentFirestoreDoc>;
|
||||||
|
compartmentId: CompartmentId;
|
||||||
|
groupId: string;
|
||||||
|
formKeyOrder: readonly string[];
|
||||||
|
}) {
|
||||||
|
const { doc, compartmentId, groupId, formKeyOrder } = props;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const schema = useMemo(
|
||||||
|
() => buildAddItemSchema(doc, formKeyOrder),
|
||||||
|
[doc, formKeyOrder],
|
||||||
|
);
|
||||||
|
const emptyDefaults = useMemo(
|
||||||
|
() => defaultValuesForDoc(doc, formKeyOrder),
|
||||||
|
[doc, formKeyOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
type FormValues = z.infer<NonNullable<typeof schema>>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: schema ? zodResolver(schema) : undefined,
|
||||||
|
defaultValues: emptyDefaults as FormValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
await addDoc(collection(db, compartmentId), values);
|
||||||
|
navigate(
|
||||||
|
`/groups/${encodeURIComponent(groupId)}/compartments/${encodeURIComponent(compartmentId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema) return null;
|
||||||
|
|
||||||
|
const fieldKeys = compartmentFieldKeys(doc, formKeyOrder);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-8 flex flex-col gap-6"
|
||||||
|
onSubmit={(e) => void handleSubmit(onSubmit)(e)}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<FieldSet>
|
||||||
|
<FieldGroup>
|
||||||
|
{fieldKeys.map((key) => {
|
||||||
|
const name = String(key);
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
key={name}
|
||||||
|
name={name as keyof FormValues}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor={name}>
|
||||||
|
{compartmentFieldLabel(doc, name)}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={name}
|
||||||
|
{...field}
|
||||||
|
value={field.value as string}
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
inputMode={
|
||||||
|
doc?.schema?.[name]?.type === "number" ||
|
||||||
|
name === "count"
|
||||||
|
? name === "count"
|
||||||
|
? "numeric"
|
||||||
|
: "decimal"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldError
|
||||||
|
errors={
|
||||||
|
fieldState.error
|
||||||
|
? [{ message: fieldState.error.message }]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Saving…" : "Add item"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Saves a new document to the{" "}
|
||||||
|
<span className="font-mono text-foreground">{compartmentId}</span>{" "}
|
||||||
|
collection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewPage() {
|
||||||
|
const { groups, status: groupsStatus, error: groupsError } = useGroups();
|
||||||
|
const { order: formKeyOrder } = useFormFieldOrder();
|
||||||
|
const [groupId, setGroupId] = useState("");
|
||||||
|
const [compartmentId, setCompartmentId] = useState("");
|
||||||
|
|
||||||
|
const {
|
||||||
|
compartments,
|
||||||
|
status: compStatus,
|
||||||
|
error: compError,
|
||||||
|
} = useCompartmentsByGroup(groupId);
|
||||||
|
|
||||||
|
const compartmentDocPath = useMemo(() => {
|
||||||
|
if (!compartmentId) return null;
|
||||||
|
return ["compartments", compartmentId] as const;
|
||||||
|
}, [compartmentId]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: compartmentRaw,
|
||||||
|
status: docStatus,
|
||||||
|
error: docError,
|
||||||
|
exists,
|
||||||
|
} = useFirestoreDoc(compartmentDocPath, Boolean(compartmentId));
|
||||||
|
|
||||||
|
const doc = useMemo(
|
||||||
|
() => asCompartmentFirestoreDoc(compartmentRaw),
|
||||||
|
[compartmentRaw],
|
||||||
|
);
|
||||||
|
|
||||||
|
const docGroup = doc?.group ?? "";
|
||||||
|
const groupMismatch = Boolean(groupId && docGroup && docGroup !== groupId);
|
||||||
|
|
||||||
|
const schemaKeys = useMemo(() => {
|
||||||
|
const s = doc?.schema;
|
||||||
|
if (!s || typeof s !== "object") return [];
|
||||||
|
return Object.keys(s);
|
||||||
|
}, [doc?.schema]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-4 pt-6 pb-24">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">New item</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Choose where to add inventory, then fill in the fields.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup className="gap-6">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Group</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={groupId || undefined}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setGroupId(v);
|
||||||
|
setCompartmentId("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a group" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<SelectItem key={g.groupId} value={g.groupId}>
|
||||||
|
{g.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{groupsStatus === "loading" ? (
|
||||||
|
<FieldDescription>Loading groups…</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
{groupsError ? (
|
||||||
|
<p className="text-sm text-destructive">{groupsError}</p>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Compartment</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={compartmentId || undefined}
|
||||||
|
onValueChange={setCompartmentId}
|
||||||
|
disabled={!groupId || compStatus === "loading"}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
groupId ? "Select a compartment" : "Pick a group first"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{compartments.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{compStatus === "loading" && groupId ? (
|
||||||
|
<FieldDescription>Loading compartments…</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
{compError ? (
|
||||||
|
<p className="text-sm text-destructive">{compError}</p>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{compartmentId && docStatus === "loading" ? (
|
||||||
|
<p className="mt-6 text-sm text-muted-foreground">
|
||||||
|
Loading compartment schema…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{compartmentId && docStatus === "error" ? (
|
||||||
|
<p className="mt-6 text-sm text-destructive">{docError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{compartmentId && docStatus !== "loading" && exists === false ? (
|
||||||
|
<p className="mt-6 text-sm text-muted-foreground">
|
||||||
|
No compartment document found for{" "}
|
||||||
|
<span className="font-mono">{compartmentId}</span>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{compartmentId && exists === true && groupMismatch ? (
|
||||||
|
<p className="mt-6 text-sm text-destructive">
|
||||||
|
This compartment is not in the selected group.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{compartmentId &&
|
||||||
|
exists === true &&
|
||||||
|
!groupMismatch &&
|
||||||
|
schemaKeys.length === 0 ? (
|
||||||
|
<p className="mt-6 text-sm text-muted-foreground">
|
||||||
|
This compartment has no schema fields defined in Firestore. Add a{" "}
|
||||||
|
<span className="font-mono">schema</span> on the compartment
|
||||||
|
document to use this form.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{compartmentId &&
|
||||||
|
exists === true &&
|
||||||
|
!groupMismatch &&
|
||||||
|
schemaKeys.length > 0 ? (
|
||||||
|
<AddItemForm
|
||||||
|
key={compartmentId}
|
||||||
|
doc={doc}
|
||||||
|
compartmentId={compartmentId as CompartmentId}
|
||||||
|
groupId={groupId}
|
||||||
|
formKeyOrder={formKeyOrder}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/pages/PlaceholderPage.tsx
Normal file
8
src/pages/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function PlaceholderPage({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-4 pt-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Coming soon.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
243
src/types/compartments.ts
Normal file
243
src/types/compartments.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
export type Connector = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
type: string;
|
||||||
|
category: string;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlatesAndBox = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
type: string;
|
||||||
|
item: string;
|
||||||
|
ports: string;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Rack = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
type: string;
|
||||||
|
size: string;
|
||||||
|
condition: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Row shape for the `managers` compartment (cable managers). */
|
||||||
|
export type CableManager = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
type: string;
|
||||||
|
u: string;
|
||||||
|
condition: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Shelf = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
type: string;
|
||||||
|
size: string;
|
||||||
|
u: string;
|
||||||
|
condition: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PatchPanel = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
type: string;
|
||||||
|
u: string;
|
||||||
|
condition: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PowerBar = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
type: string;
|
||||||
|
u: string;
|
||||||
|
condition: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataCable = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
length: number;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FiberCable = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
standards: string;
|
||||||
|
type: string;
|
||||||
|
length: number;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CoaxCable = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
length: number;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PatchCord = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
length: string;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HdmiCable = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
length: string;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PvcConduit = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
length: number;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OtherCable = {
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
brand: string;
|
||||||
|
pairs: string;
|
||||||
|
type: string;
|
||||||
|
length: number;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Top-level collection id == compartment id in routes / Firestore. */
|
||||||
|
export type CompartmentId =
|
||||||
|
| "connectors"
|
||||||
|
| "plates-and-boxes"
|
||||||
|
| "racks"
|
||||||
|
| "managers"
|
||||||
|
| "shelves"
|
||||||
|
| "patch-panels"
|
||||||
|
| "power-bars"
|
||||||
|
| "data-cable"
|
||||||
|
| "fiber-cable"
|
||||||
|
| "coax-cable"
|
||||||
|
| "patch-cords"
|
||||||
|
| "hdmi-cables"
|
||||||
|
| "pvc-conduit"
|
||||||
|
| "other-cables";
|
||||||
|
|
||||||
|
/** Inventory row for each compartment collection. */
|
||||||
|
export type CompartmentItemMap = {
|
||||||
|
connectors: Connector;
|
||||||
|
"plates-and-boxes": PlatesAndBox;
|
||||||
|
racks: Rack;
|
||||||
|
managers: CableManager;
|
||||||
|
shelves: Shelf;
|
||||||
|
"patch-panels": PatchPanel;
|
||||||
|
"power-bars": PowerBar;
|
||||||
|
"data-cable": DataCable;
|
||||||
|
"fiber-cable": FiberCable;
|
||||||
|
"coax-cable": CoaxCable;
|
||||||
|
"patch-cords": PatchCord;
|
||||||
|
"hdmi-cables": HdmiCable;
|
||||||
|
"pvc-conduit": PvcConduit;
|
||||||
|
"other-cables": OtherCable;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompartmentItem = CompartmentItemMap[CompartmentId];
|
||||||
|
|
||||||
|
/** Firestore `compartments/{compartmentId}` — trusted shape (no runtime parse). */
|
||||||
|
export type CompartmentSchemaFieldDef = {
|
||||||
|
type?: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompartmentFirestoreDoc = {
|
||||||
|
display_name?: string;
|
||||||
|
group?: string;
|
||||||
|
schema?: Record<string, CompartmentSchemaFieldDef>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keys that belong on the compartment doc, not on inventory rows — never shown in add/edit item forms. */
|
||||||
|
const SCHEMA_KEYS_OMIT = new Set(["group", "compartment"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys present in `doc.schema`, ordered by `formKeyOrder` (`settings/form.order`).
|
||||||
|
* Without it, order follows `Object.keys(schema)` (not reliable on Firestore).
|
||||||
|
*/
|
||||||
|
export function compartmentFieldKeys(
|
||||||
|
doc: CompartmentFirestoreDoc | undefined,
|
||||||
|
formKeyOrder?: readonly string[] | undefined,
|
||||||
|
): string[] {
|
||||||
|
const schema = doc?.schema;
|
||||||
|
if (!schema) return [];
|
||||||
|
const raw = Object.keys(schema).filter((k) => !SCHEMA_KEYS_OMIT.has(k));
|
||||||
|
if (!formKeyOrder?.length) return raw;
|
||||||
|
|
||||||
|
const rawSet = new Set(raw);
|
||||||
|
const ordered = formKeyOrder.filter((k) => rawSet.has(k));
|
||||||
|
const seen = new Set(ordered);
|
||||||
|
const rest = raw.filter((k) => !seen.has(k)).sort();
|
||||||
|
return [...ordered, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FIELD_LABELS: Record<string, string> = {
|
||||||
|
createdAt: "Created",
|
||||||
|
createAt: "Created",
|
||||||
|
updatedAt: "Updated",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function compartmentFieldLabel(
|
||||||
|
doc: CompartmentFirestoreDoc | undefined,
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
const label = doc?.schema?.[key]?.label;
|
||||||
|
if (typeof label === "string" && label.length > 0) return label;
|
||||||
|
return DEFAULT_FIELD_LABELS[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cast Firestore payload to compartment doc (runtime errors if shape is wrong). */
|
||||||
|
export function asCompartmentFirestoreDoc(
|
||||||
|
raw: unknown,
|
||||||
|
): CompartmentFirestoreDoc | undefined {
|
||||||
|
if (raw == null || typeof raw !== "object") return undefined;
|
||||||
|
return raw as CompartmentFirestoreDoc;
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [react(),
|
plugins: [react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user