feat(admin): implement product management — list, create, edit, archive (Plan 03)

Covers checklist items 3.1–3.4, 3.10–3.11 (product list, create, edit,
archive/restore, SEO fields, admin search).

Backend (convex/products.ts):
- Extended create/update with shortDescription, brand, attributes,
  seoTitle, seoDescription, canonicalSlug
- Both mutations now set createdAt/updatedAt timestamps
- Added getByIdForAdmin (admin-only, returns full product with relations)

UI — new pages:
- products/page.tsx: table with debounced search, column visibility
  dropdown, client-side sort, 10-row skeleton, load-more pagination,
  row preview dialog, per-row actions menu
- products/new/page.tsx: create product page
- products/[id]/edit/page.tsx: pre-populated edit page with archive button

UI — new components:
- ProductForm: shared form (create + edit); zod + react-hook-form,
  auto-slug, collapsible Attributes + SEO sections, submit spinner
- ProductPreviewDialog: read-only full-product dialog
- ProductActionsMenu: kebab menu (Edit link + Archive AlertDialog)

ShadCN components installed: table, badge, alert-dialog, dialog,
scroll-area, form, select, label, checkbox, textarea

Also:
- Updated CLAUDE.md: form submit buttons must use inline SVG spinner
  with data-icon="inline-start"; link-styled buttons use buttonVariants
  on <Link> (Button render prop not in TS types)
- Updated docs: checklist and plan marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 17:38:13 +03:00
parent 2dc8878db7
commit 5168553bae
39 changed files with 5209 additions and 498 deletions

114
apps/admin/CLAUDE.md Normal file
View File

@@ -0,0 +1,114 @@
# Admin Dashboard UI — Implementation Rules
Applies to all UI work inside `apps/admin/`. These rules take precedence over
general project conventions for anything under this directory.
---
## 1. Component Library — ShadCN UI Only
All UI must be composed strictly from ShadCN UI components. No other component
libraries, no raw HTML element styling where a ShadCN component exists.
**Before building any UI, check if ShadCN has a component for it:**
| Category | Components |
|--------------|-----------|
| Layout | `Sheet`, `Separator`, `ScrollArea`, `ResizablePanel` |
| Navigation | `NavigationMenu`, `Breadcrumb`, `Tabs` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Forms | `Form`, `Input`, `Select`, `Checkbox`, `Switch`, `Textarea`, `RadioGroup`, `DatePicker` |
| Feedback | `Toast` (via Sonner), `Alert`, `Dialog`, `AlertDialog` |
| Loading | `Skeleton` |
| Actions | `Button`, `DropdownMenu`, `ContextMenu`, `Command` |
**Install components via the ShadCN CLI — never copy-paste or hand-write ShadCN component source:**
```bash
npx shadcn@latest add button
npx shadcn@latest add table
```
**ShadCN best practices:**
- Use `cn()` from `lib/utils.ts` for all conditional className merging — never string concatenation
- Extend ShadCN components via `className` props, never modify files in `components/ui/` directly
- Use `variant` and `size` props before reaching for custom styles
- Compose complex components by combining primitives — a stat card is `Card` + `CardHeader` + `CardContent`, not a custom div
- Use `asChild` when you need to change the rendered element (e.g. wrapping a Next.js `Link` in a `Button`)
---
## 2. Skills & MCP Usage
**Always invoke the `shadcn-ui` skill** before starting any new page or significant component:
```
/shadcn-ui
```
The skill guides intentional layout, spacing, and visual hierarchy decisions within the ShadCN constraint.
**If the ShadCN MCP server is available**, use it to look up component APIs before implementing. Do not guess prop names or variant values from memory.
**When MCP is unavailable**, refer to https://ui.shadcn.com/docs/components before writing component usage.
---
## 3. No SEO
- No `<Head>` metadata beyond the bare minimum `layout.tsx` title
- No `generateMetadata` functions on admin pages
- No Open Graph, Twitter card, or structured data tags
- No sitemap or robots.txt entries for admin routes
---
## 4. Accessibility — Required Minimums
ShadCN handles most accessibility via Radix UI primitives. Additionally ensure:
- All interactive elements are keyboard navigable (use ShadCN correctly and this is automatic)
- Form fields always have an associated `<label>` — use ShadCN `Label`; use `sr-only` if the design hides it visually
- Data tables include `scope` on `<th>` elements
- Icon-only buttons always have `aria-label` — e.g. `<Button aria-label="Delete product" size="icon"><Trash2 /></Button>`
- Modal dialogs use ShadCN `Dialog` or `AlertDialog` — never custom divs with `display:none` toggling
- Color is never the only indicator of state — always pair color with text or icon
- Focus rings must remain visible — never add `outline-none` without a replacement focus style
---
## 5. Code Quality
- Admin page components are **Server Components by default**. Add `"use client"` only when the component uses hooks, event handlers, or browser APIs
- Data fetching happens in Server Components via Convex server-side queries — not in `useEffect`
- Forms use `react-hook-form` + `zod` validation wired through ShadCN `Form` components
- Loading states use ShadCN `Skeleton` — never spinners on full page loads
- Form submit buttons **must** show a spinner while submitting. Use an inline SVG with `data-icon="inline-start"` and `animate-spin` placed before the label text, and set `disabled={isSubmitting}`. The button label should also change to reflect the in-progress state (e.g. "Creating…", "Saving…"):
```tsx
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && (
<svg data-icon="inline-start" className="animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{isSubmitting ? "Saving…" : "Save changes"}
</Button>
```
- Destructive actions (delete, archive) always use `AlertDialog` for confirmation — never `window.confirm`
- Empty states are always handled explicitly — never render an empty table or blank page silently
---
## 6. Imports
The admin app tsconfig has **no path alias**. Use relative imports only:
```typescript
// Correct
import { cn } from "../../lib/utils";
// Wrong — no @/ alias in this app
import { cn } from "@/lib/utils";
```

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova", "style": "base-nova",
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {

View File

@@ -0,0 +1,273 @@
# Admin Dashboard — Feature Checklist
**Date:** 2026-03-04
**Audience:** Senior software engineers, project stakeholders
---
## How to Read This Document
Features are grouped into **MVP** (required for launch) and **Post-MVP** (phased rollout). Within each group, features are ordered by implementation priority.
**Legend:**
| Symbol | Meaning |
|--------|---------|
| `[ ]` | Not started |
| `[~]` | Backend exists, admin UI needed |
| `[x]` | Complete |
| **BE** | Backend work required (new Convex functions) |
| **UI** | Admin frontend work only |
| **3P** | Third-party integration required |
---
## MVP
### 1. Authentication & Authorization
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 1.1 | Clerk sign-in page (branded, in-app) | `[x]` UI | Replace hosted sign-in redirect |
| 1.2 | Admin user sync (Convex record on sign-in) | `[x]` UI | Wire existing `useStoreUserEffect` hook |
| 1.3 | Role-based auth gate (block non-admin users) | `[x]` UI | `AdminAuthGate` component, query `users.current` |
| 1.4 | Access denied page for customers | `[x]` UI | Sign-out button + storefront link |
| 1.5 | Admin layout shell (header with `UserButton`) | `[x]` UI | Persistent header with session management |
| 1.6 | Route group structure (`(auth)` vs `(dashboard)`) | `[x]` UI | Separate sign-in from protected routes |
> Full implementation plan: [05-admin-auth-implementation-plan.md](./05-admin-auth-implementation-plan.md)
---
### 2. Navigation & Layout
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 2.1 | Sidebar navigation | `[x]` UI | Collapsible; links to all admin sections |
| 2.2 | Breadcrumbs | `[x]` UI | Context-aware breadcrumb trail |
| 2.3 | Mobile-responsive admin shell | `[x]` UI | Hamburger menu on mobile, full sidebar on `lg:` |
| 2.4 | Active route highlighting | `[x]` UI | Visual indicator for current section |
---
### 3. Product Management (Inventory)
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 3.1 | Product list page | `[x]` UI | Backend: `products.list` (paginated, filterable by status/category). Build table with search, filters, pagination. |
| 3.2 | Create product form | `[x]` UI | Backend: `products.create`. Form: name, slug, description, status, category, tags. |
| 3.3 | Edit product form | `[x]` UI | Backend: `products.update`. Pre-populated form with all fields. |
| 3.4 | Archive/restore product | `[x]` UI | Backend: `products.archive`. Confirmation dialog. Restore via edit status field. |
| 3.5 | Product image upload | `[ ]` BE+UI | Need Convex file storage upload flow → `products.addImage`. Currently `addImage` takes a URL; need `generateUploadUrl` + upload action. |
| 3.6 | Image gallery management | `[~]` UI | Backend: `addImage`, `deleteImage`, `reorderImages`. Drag-and-drop reorder UI. |
| 3.7 | Variant management | `[~]` UI | Backend: `addVariant`, `updateVariant`, `deleteVariant`. Inline table or modal for CRUD. |
| 3.8 | Stock quantity editing | `[~]` UI | Backend: `updateVariant` accepts `stockQuantity`. Inline edit in variant table. |
| 3.9 | Price and compare-at-price editing | `[~]` UI | Backend: `updateVariant` accepts `price`, `compareAtPrice`. |
| 3.10 | Product SEO fields (title, description) | `[x]` UI | `seoTitle`, `seoDescription`, `canonicalSlug` in collapsible Advanced/SEO section. |
| 3.11 | Product search within admin | `[x]` UI | Debounced search bar on list page; switches between `products.list` and `products.search`. |
| 3.12 | Bulk status change (draft → active, etc.) | `[ ]` BE+UI | New mutation: `products.bulkUpdateStatus`. Multi-select in table. |
---
### 4. Category Management
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 4.1 | Category list / tree view | `[~]` UI | Backend: `categories.list` (supports `parentId`). Show hierarchical tree. |
| 4.2 | Create category | `[~]` UI | Backend: `categories.create`. Form: name, slug, parent, top-category slug, SEO. |
| 4.3 | Edit category | `[~]` UI | Backend: `categories.update`. |
| 4.4 | Category image upload | `[ ]` BE+UI | Schema has `imageUrl`. Need file upload flow. |
---
### 5. Order Processing
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 5.1 | Order list page | `[~]` UI | Backend: `orders.listAll` (paginated, filterable by status/paymentStatus). |
| 5.2 | Order detail page | `[~]` UI | Backend: `orders.getById` (returns items, addresses, payment info). |
| 5.3 | Update order status | `[~]` UI | Backend: `orders.updateStatus`. Dropdown or status stepper. |
| 5.4 | Cancel order (admin-initiated) | `[ ]` BE+UI | New mutation: `orders.adminCancel` — cancel regardless of customer rules, update Stripe if paid, restore stock. |
| 5.5 | Create shipping label (Shippo) | `[ ]` BE+UI+3P | New action: `shippo.createLabel` — calls Shippo Transactions API. Store `trackingNumber`, `trackingUrl`, `labelUrl` on order. |
| 5.6 | Print shipping label | `[ ]` UI+3P | Fetch label PDF URL from Shippo, open in new tab / trigger print dialog. |
| 5.7 | Track shipment status | `[ ]` BE+3P | New: Shippo tracking webhook → update order `status` and `trackingUrl`. Or poll Shippo Tracking Status API. |
| 5.8 | Refund order (full) | `[ ]` BE+UI+3P | New action: `stripe.refundPayment` — calls Stripe Refunds API. Update `paymentStatus` to `"refunded"`, `status` to `"refunded"`. |
| 5.9 | Partial refund | `[ ]` BE+UI+3P | Same Stripe Refunds API with `amount` parameter. |
| 5.10 | Return processing | `[ ]` BE+UI | New: `returns` table or status sub-flow. Accept return request → inspect → refund or reject. |
| 5.11 | Send order update email | `[ ]` BE+3P | New: email service integration (Resend or SendGrid). Triggered on status changes: confirmed, shipped (with tracking), delivered, cancelled, refunded. |
| 5.12 | Send order update SMS | `[ ]` BE+3P | New: SMS integration (Twilio or similar). Triggered on key status changes: shipped, delivered. |
| 5.13 | Order notes (internal) | `[~]` UI | Schema has `notes` field. Admin can add/edit internal notes. |
| 5.14 | Order search / filters | `[ ]` BE+UI | Search by order number, customer email, date range. May need new indexes. |
| 5.15 | Batch label creation | `[ ]` BE+UI+3P | Select multiple orders → create labels via Shippo Batches API. |
---
### 6. Customer Management (MVP-lite)
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 6.1 | Customer list page | `[~]` UI | Backend: `users.listCustomers` (paginated). |
| 6.2 | Customer detail page | `[ ]` BE+UI | New query: `users.getCustomerDetail` — user + orders + addresses. |
| 6.3 | View customer orders | `[ ]` UI | Link from customer detail to filtered order list. |
---
## Post-MVP
### 7. Dashboard & Analytics
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 7.1 | Dashboard home — key metrics | `[ ]` BE+UI | New queries: total revenue, order count, new customers (time-windowed). `DashboardStats` type already exists in `@repo/types`. |
| 7.2 | Revenue chart (daily/weekly/monthly) | `[ ]` BE+UI | New query: aggregated revenue by period. Chart library (Recharts or similar). |
| 7.3 | Orders chart | `[ ]` BE+UI | Order volume over time. |
| 7.4 | Top-selling products | `[ ]` BE+UI | New query: aggregate `orderItems` by product, sort by quantity. |
| 7.5 | Low stock alerts | `[ ]` BE+UI | New query: variants where `stockQuantity` < threshold. Dashboard widget + notification badge. |
| 7.6 | Recent orders feed | `[~]` UI | Backend: `orders.listAll` with `limit`. Real-time feed on dashboard. |
| 7.7 | Conversion funnel | `[ ]` BE+UI | Track: visits → cart adds → checkouts → completed orders. Requires analytics events. |
---
### 8. Review Management
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 8.1 | Review list page (all reviews) | `[~]` UI | Backend: `reviews.listForAdmin` (filterable by approval status, product). |
| 8.2 | Approve review | `[~]` UI | Backend: `reviews.approve`. |
| 8.3 | Delete review | `[~]` UI | Backend: `reviews.deleteReview`. |
| 8.4 | Review detail / preview | `[ ]` UI | Show full review content, images, linked product. |
| 8.5 | Bulk approve/delete | `[ ]` BE+UI | New mutations for batch operations. |
---
### 9. Customer Communication
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 9.1 | Contact form messages inbox | `[ ]` BE+UI | New `messages` table. Storefront contact form → Convex. Admin reads/replies. |
| 9.2 | Reply to customer message | `[ ]` BE+UI+3P | Send reply via email (Resend/SendGrid). Store thread in Convex. |
| 9.3 | Message status (unread/read/resolved) | `[ ]` BE+UI | Status field on messages table. |
| 9.4 | Email templates | `[ ]` BE+3P | Transactional email templates for order updates, review responses, etc. |
---
### 10. Newsletter & Marketing
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 10.1 | Newsletter subscriber list | `[ ]` BE+UI | New `subscribers` table. Storefront signup → Convex. Admin views list. |
| 10.2 | Export subscribers (CSV) | `[ ]` UI | Client-side CSV generation from subscriber list. |
| 10.3 | Compose & send newsletter | `[ ]` BE+3P | Integration with email provider (Resend/Mailchimp). Template editor. |
| 10.4 | Unsubscribe handling | `[ ]` BE | Unsubscribe link in emails → Convex mutation. |
---
### 11. Promotions & Discounts
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 11.1 | Coupon/discount code management | `[ ]` BE+UI+3P | New `coupons` table or Stripe Coupons API (already available via MCP). CRUD UI for codes, percentage/fixed amount, expiry, usage limits. |
| 11.2 | Sale tag management | `[~]` UI | Products already have `tags[]`. Admin can add/remove "sale" tag. Backend: `products.update`. |
| 11.3 | Compare-at-price (was/now pricing) | `[~]` UI | Schema has `compareAtPrice` on variants. Editable in variant management. |
---
### 12. Admin User Management
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 12.1 | Admin/staff list | `[ ]` BE+UI | New query: `users.listAdmins`. |
| 12.2 | Promote user to admin | `[ ]` BE+UI | New mutation: `users.setRole` (super_admin only). |
| 12.3 | Demote admin to customer | `[ ]` BE+UI | Same `users.setRole` mutation. |
| 12.4 | Activity / audit log | `[ ]` BE+UI | New `auditLogs` table. Log admin actions with userId, action, target, timestamp. |
---
### 13. Settings & Configuration
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 13.1 | Store settings (name, logo, contact info) | `[ ]` BE+UI | New `storeSettings` table (singleton). |
| 13.2 | Shipping configuration | `[ ]` BE+UI | Default parcel dimensions, weight limits, carrier preferences. Currently hardcoded in `model/shippo.ts`. |
| 13.3 | Tax configuration | `[ ]` BE+UI | Tax rates by region. Currently `tax` is passed manually on order creation. |
| 13.4 | Email notification preferences | `[ ]` BE+UI | Which status changes trigger emails/SMS. |
| 13.5 | Payment settings (Stripe config) | `[ ]` UI | Display Stripe connection status, webhook health. |
---
### 14. Data & Export
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 14.1 | Export orders (CSV) | `[ ]` UI | Client-side CSV from `orders.listAll`. |
| 14.2 | Export products (CSV) | `[ ]` UI | Client-side CSV from `products.listAll`. |
| 14.3 | Export customers (CSV) | `[ ]` UI | Client-side CSV from `users.listCustomers`. |
| 14.4 | Import products (CSV) | `[ ]` BE+UI | Parse CSV → batch `products.create` calls. |
---
## MVP Scope Summary
| Section | Features | New Backend Work | Third-Party |
|---------|----------|------------------|-------------|
| 1. Auth & Authorization | 6 | None | — |
| 2. Navigation & Layout | 4 | None | — |
| 3. Product Management | 12 | File upload, bulk status | — |
| 4. Category Management | 4 | File upload | — |
| 5. Order Processing | 15 | Cancel, refund, label, tracking, return, email, SMS, search, batch | Shippo, Stripe, Resend/SendGrid, Twilio |
| 6. Customer Management | 3 | Customer detail query | — |
| **Total MVP** | **44** | | |
---
## Third-Party Integration Summary
| Service | Purpose | MVP? | Existing? |
|---------|---------|------|-----------|
| **Clerk** | Authentication | Yes | Yes — sign-in, JWT, webhooks |
| **Convex** | Backend, real-time DB | Yes | Yes — full schema + functions |
| **Stripe** | Payments, refunds | Yes | Partial — checkout exists, refunds needed |
| **Shippo** | Shipping labels, tracking | Yes | Partial — rates/validation exist, labels/tracking needed |
| **Resend** or **SendGrid** | Transactional email | Yes | No — not integrated |
| **Twilio** or **SNS** | SMS notifications | Yes | No — not integrated |
| **Recharts** or **Chart.js** | Dashboard charts | Post-MVP | No |
---
## Recommended Implementation Order (MVP)
```
Phase 1 ─ Auth & Layout (1-2 days)
├─ 1.11.6 Authentication & authorization
└─ 2.12.4 Navigation & layout shell
Phase 2 ─ Product Management (3-4 days)
├─ 3.13.4 Product list, create, edit, archive
├─ 3.53.6 Image upload & gallery
├─ 3.73.9 Variant CRUD, stock, pricing
├─ 3.103.11 SEO fields, search
└─ 4.14.4 Category management
Phase 3 ─ Order Processing — Core (2-3 days)
├─ 5.15.3 Order list, detail, status update
├─ 5.4 Admin cancel
├─ 5.135.14 Order notes, search
Phase 4 ─ Shipping & Labels (2-3 days)
├─ 5.55.6 Create & print labels (Shippo)
├─ 5.7 Track shipments
└─ 5.15 Batch label creation
Phase 5 ─ Refunds & Returns (1-2 days)
├─ 5.85.9 Full & partial refund (Stripe)
└─ 5.10 Return processing
Phase 6 ─ Notifications (1-2 days)
├─ 5.11 Order update emails
└─ 5.12 Order update SMS
Phase 7 ─ Customer Management (1 day)
└─ 6.16.3 Customer list, detail, orders
```
Total estimated MVP effort: **1117 days** for a senior engineer.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,304 @@
# Navigation & Layout — Implementation Plan
**Date:** 2026-03-04
**Audience:** Senior software engineers
**Scope:** `apps/admin` — checklist section 2 (Navigation & Layout)
---
## 1. Current State
### What exists
| Component | File | Status |
|-----------|------|--------|
| `AppSidebar` | `components/layout/sidebar/app-sidebar.tsx` | Done — renders header, nav groups, footer |
| `NavMain` | `components/layout/sidebar/nav-main.tsx` | Done — collapsible nav + flat nav via `isOverview` flag |
| `NavUser` | `components/layout/sidebar/nav-user.tsx` | Done — `UserButton` + name/email, collapses with sidebar |
| `NAV_LINKS` | `lib/constants/app.constants.ts` | Done — complete route tree (overview, navMain, users) |
| Dashboard layout | `app/(dashboard)/layout.tsx` | Partial — wires `SidebarProvider` + `SidebarInset`; header slot has only `SidebarTrigger` + `Separator` |
| ShadCN UI | `components/ui/sidebar`, `collapsible`, `breadcrumb`, `separator`, `tooltip`, `skeleton` | Installed |
### What is missing
| # | Checklist item | Gap |
|---|----------------|-----|
| 2.1 | Sidebar navigation | Functional but `isActive` is hardcoded in `NAV_LINKS`; active state is not driven by current URL |
| 2.2 | Breadcrumbs | Not implemented; header slot in `(dashboard)/layout.tsx` is empty after `SidebarTrigger` |
| 2.3 | Mobile-responsive shell | ShadCN `Sidebar` handles mobile via `Sheet` — the `SidebarTrigger` in the header is the hamburger. Behaviour needs verification and a polish pass |
| 2.4 | Active route highlighting | Blocked by 2.1 — `isActive` must come from `usePathname()`, not static data |
### Architecture overview (current)
```
(dashboard)/layout.tsx
└── SidebarProvider
├── AppSidebar ← collapsible="icon"
│ ├── SidebarHeader (logo)
│ ├── SidebarContent
│ │ ├── NavMain (Platform) ← overview flat links
│ │ ├── NavMain (Application) ← collapsible groups
│ │ └── NavMain (Users) ← flat link
│ ├── SidebarFooter
│ │ └── NavUser (UserButton)
│ └── SidebarRail
└── SidebarInset
├── <header>
│ ├── SidebarTrigger ← hamburger on mobile, collapse toggle on desktop
│ └── Separator
│ └── [BREADCRUMB SLOT — empty]
└── <main>{children}</main>
```
---
## 2. Target Architecture
```
(dashboard)/layout.tsx
└── SidebarProvider
├── AppSidebar ← unchanged structure
│ ├── SidebarHeader (logo)
│ ├── SidebarContent
│ │ ├── NavMain (Platform) ← active state from usePathname()
│ │ ├── NavMain (Application) ← active state + auto-opens active group
│ │ └── NavMain (Users) ← active state from usePathname()
│ ├── SidebarFooter
│ │ └── NavUser
│ └── SidebarRail
└── SidebarInset
├── <header>
│ ├── SidebarTrigger
│ ├── Separator
│ └── DynamicBreadcrumb ← NEW: reads pathname → renders ShadCN Breadcrumb
└── <main>{children}</main>
```
---
## 3. Design Decisions
### D1: Active state via `usePathname()` — not static data
`isActive` booleans in `NAV_LINKS` are unreliable (they are snapshots, not reactive). `NavMain` should call `usePathname()` internally and derive active state at render time.
- Flat links: `isActive = pathname === item.url`
- Collapsible groups: `isActive = pathname.startsWith(item.url)` — also controls `defaultOpen`
- Sub-items: `isActive = pathname === subItem.url`
`isActive` fields in `NAV_LINKS` should be removed to avoid confusion.
### D2: Breadcrumb is a single `DynamicBreadcrumb` component
The breadcrumb reads `usePathname()`, splits into segments, maps each to a human-readable label via a static `ROUTE_LABELS` map, and renders ShadCN's `Breadcrumb` primitive.
**Segment matching rules:**
- Known static segments → label from map (e.g. `"products"``"Products"`)
- Dynamic segments (Convex IDs, slugs) → displayed as-is for now; individual detail pages can override with a `<PageBreadcrumbItem>` context pattern in a future iteration
**Home segment:** A `<House />` icon link to `/` instead of the word "Dashboard".
### D3: Mobile shell — no new code needed
ShadCN's `Sidebar` primitive (`collapsible="icon"`) renders as an `offcanvas` sheet on mobile automatically (breakpoint `md`). The `SidebarTrigger` in the layout header is already the hamburger. No structural changes required — the plan documents this explicitly so it is not re-investigated.
### D4: `NAV_LINKS` remains the single source of truth for route structure
Active state logic is moved into the component, but route definitions (titles, URLs, icons, children) stay in `NAV_LINKS`. This keeps route changes in one place.
---
## 4. Implementation Phases
### Phase 1 — Active route highlighting (2.1 / 2.4)
**Goal:** Drive `isActive` from `usePathname()` in `NavMain`. Remove hardcoded `isActive` from `NAV_LINKS`.
#### 1.1 Update `NavMain` to read `usePathname()`
`NavMain` is already a client component (`"use client"`). Add `usePathname()` from `next/navigation`.
**Flat link items (overview / users groups):**
```tsx
const pathname = usePathname();
// ...
<SidebarMenuButton isActive={pathname === item.url}>
```
**Collapsible group items:**
```tsx
const isGroupActive = pathname.startsWith(item.url);
// defaultOpen driven by isGroupActive
<Collapsible defaultOpen={isGroupActive}>
<SidebarMenuButton isActive={isGroupActive}>
// ...
{item.items?.map(subItem => (
<SidebarMenuSubButton isActive={pathname === subItem.url}>
))}
```
**Edge case — Dashboard `/` link:** Use `pathname === "/"` (exact match only) so the Dashboard link is not active on every page.
#### 1.2 Remove `isActive` from `NAV_LINKS`
Remove the `isActive: true` fields from `navMain` items in `app.constants.ts`. The type definition in `NavMain` props should also drop `isActive` from the item shape (or keep it as optional and ignore it — removing is cleaner).
#### Files changed
| File | Action |
|------|--------|
| `src/components/layout/sidebar/nav-main.tsx` | Edit — add `usePathname()`, derive `isActive` for flat and collapsible items |
| `src/lib/constants/app.constants.ts` | Edit — remove `isActive: true` from `navMain` entries |
---
### Phase 2 — Dynamic breadcrumbs (2.2)
**Goal:** Render a context-aware breadcrumb trail in the dashboard header.
#### 2.1 Create route label map
Add to `lib/constants/app.constants.ts`:
```typescript
export const ROUTE_LABELS: Record<string, string> = {
orders: "Orders",
products: "Products",
categories: "Categories",
images: "Images",
variants: "Variants",
customers: "Customers",
reviews: "Reviews",
messages: "Messages",
newsletter: "Newsletter",
users: "Users",
settings: "Settings",
returns: "Returns",
};
```
Dynamic segments (Convex IDs) that don't match a key will be displayed as-is (e.g. `"jx7abc123"` → shown literally). This is acceptable for MVP.
#### 2.2 Create `DynamicBreadcrumb` component
Create `src/components/layout/DynamicBreadcrumb.tsx`:
```
"use client"
reads usePathname()
splits into segments: "/products/categories" → ["products", "categories"]
maps each segment to ROUTE_LABELS[segment] ?? segment
renders:
- first segment: House icon link to "/"
- intermediate segments: BreadcrumbLink
- last segment: BreadcrumbPage (not a link)
- BreadcrumbSeparator between each item
```
Uses ShadCN components: `Breadcrumb`, `BreadcrumbList`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator` from `@/components/ui/breadcrumb`.
**Special case — root `/`:** No segments → render only "Dashboard" as `BreadcrumbPage` (no links needed).
#### 2.3 Wire `DynamicBreadcrumb` into dashboard layout header
Edit `app/(dashboard)/layout.tsx` — the header already has `SidebarTrigger` + `Separator`. Add `<DynamicBreadcrumb />` immediately after:
```tsx
<header className="flex h-12 ...">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<DynamicBreadcrumb />
</div>
</header>
```
#### Files changed
| File | Action |
|------|--------|
| `src/lib/constants/app.constants.ts` | Edit — add `ROUTE_LABELS` export |
| `src/components/layout/DynamicBreadcrumb.tsx` | Create |
| `src/app/(dashboard)/layout.tsx` | Edit — add `<DynamicBreadcrumb />` in header |
---
### Phase 3 — Mobile shell verification (2.3)
**Goal:** Confirm mobile sidebar behaviour works and document it. No new code expected.
#### 3.1 Verify mobile behavior
ShadCN's `Sidebar` primitive uses CSS variables and a `data-mobile` attribute (set by a `useIsMobile()` hook inside the primitive) to switch between:
- **Desktop (`lg:` and above):** Collapsible to icon rail (`collapsible="icon"`)
- **Mobile (below `lg:`):** Renders as a `Sheet` overlay; `SidebarTrigger` toggles it
The existing `(dashboard)/layout.tsx` already has `SidebarTrigger` in the header — this functions as the hamburger on mobile. No structural changes are required.
#### 3.2 What to check during implementation
- `SidebarTrigger` is visible and tappable on mobile viewports
- Sidebar opens as sheet overlay (not push) on mobile
- Nav links close the sheet on tap (ShadCN handles this via `useSidebar().setOpenMobile(false)` called inside `SidebarMenuButton` when `isMobile` is true)
- `NavUser` footer is visible and scrollable on small screens
#### Files changed
None — observation only. Document findings in `MEMORY.md` if a fix is needed.
---
## 5. File Inventory
### New files
| File | Phase | Purpose |
|------|-------|---------|
| `src/components/layout/DynamicBreadcrumb.tsx` | 2 | Context-aware breadcrumb trail |
### Modified files
| File | Phase | Change |
|------|-------|--------|
| `src/components/layout/sidebar/nav-main.tsx` | 1 | Add `usePathname()` — derive `isActive` for flat + collapsible items |
| `src/lib/constants/app.constants.ts` | 1 + 2 | Remove `isActive` from `navMain` entries; add `ROUTE_LABELS` map |
| `src/app/(dashboard)/layout.tsx` | 2 | Add `<DynamicBreadcrumb />` in header |
---
## 6. Breadcrumb Route Reference
| Path | Breadcrumb rendered |
|------|---------------------|
| `/` | Dashboard |
| `/orders` | 🏠 / Orders |
| `/orders/jx7abc` | 🏠 / Orders / jx7abc |
| `/products` | 🏠 / Products |
| `/products/categories` | 🏠 / Products / Categories |
| `/products/images` | 🏠 / Products / Images |
| `/products/variants` | 🏠 / Products / Variants |
| `/products/jx7abc` | 🏠 / Products / jx7abc |
| `/customers` | 🏠 / Customers |
| `/customers/reviews` | 🏠 / Customers / Reviews |
| `/customers/messages` | 🏠 / Customers / Messages |
| `/customers/newsletter` | 🏠 / Customers / Newsletter |
| `/users` | 🏠 / Users |
| `/settings` | 🏠 / Settings |
| `/returns` | 🏠 / Returns |
Dynamic segments (IDs/slugs not in `ROUTE_LABELS`) render their raw value. Future detail pages can introduce a breadcrumb context or slot pattern to override with a fetched entity name.
---
## 7. Implementation Order & Effort
| Phase | Description | Effort |
|-------|-------------|--------|
| 1 | Active route highlighting | ~30 min |
| 2 | Dynamic breadcrumbs | ~45 min |
| 3 | Mobile shell verification | ~15 min |
Total estimated effort: **~1.5 hours**.
Phases 1 and 2 are independent and can be worked in parallel.

View File

@@ -0,0 +1,240 @@
# Products Feature — Implementation Plan (Admin Dashboard)
**Audience:** Senior software engineers
**Scope:** Products route only — list page, create page, edit page. No images or variants.
**References:** Convex MCP, ShadCN UI MCP, `.agent/skills/shadcn-ui`, `convex/schema.ts`, `apps/admin` CLAUDE.md and admin-dashboard-ui rule.
---
## 1. Overview
Implement the Product Management feature for the admin dashboard as defined in the checklist (items 3.13.4, 3.11), limited to:
- **Product list page** — table with search, column visibility, sort, pagination, loading skeleton, row preview dialog, actions menu.
- **Create product page** — form for required and optional product fields; categories prefetched.
- **Edit product page** — same form pre-populated; archive with confirmation.
All UI must use **ShadCN UI only** (admin rule). Data is served by existing Convex `products.*` and `categories.*` APIs with small backend extensions where noted.
---
## 2. Backend (Convex) — Required Changes
### 2.1 Extend `products.create` and `products.update`
**Current state:**
`create` accepts: `name`, `slug`, `description?`, `status`, `categoryId`, `tags`.
`update` accepts: `id`, `name?`, `slug?`, `description?`, `status?`, `categoryId?`, `tags?`.
**Required:** Add optional fields so the admin form can persist a full product record (no SEO in MVP form if you prefer; otherwise add in 2.2).
| Field | Validator | Notes |
|-------|-----------|--------|
| `shortDescription` | `v.optional(v.string())` | 12 line summary |
| `brand` | `v.optional(v.string())` | e.g. "Royal Canin" |
| `attributes` | `v.optional(v.object({ petSize?, ageRange?, specialDiet?, material?, flavor? }))` | Match schema shape in `convex/schema.ts` |
**Optional (low priority):** Add `seoTitle`, `seoDescription`, `canonicalSlug` to both create and update for the “Advanced” section.
**Timestamps:** In the mutation handlers, set `createdAt: Date.now()` on insert and `updatedAt: Date.now()` on every patch so the admin can show them in the preview. Schema already has these as optional.
**Slug:** Keep slug unique by convention; no need for a separate “check slug” query if the form derives slug from name and the storefront uses it as canonical. If you want uniqueness validation, add an internal helper that checks `by_slug` before insert/update and surface a clear error.
### 2.2 Single-product fetch for admin (optional)
For the **product preview dialog**, the table already receives enriched rows from `products.list` or `products.search`. Passing the selected row into the dialog is sufficient; no extra fetch required.
If you later want to “refetch after edit” or open preview by deep link, add a public admin-only query, e.g. `products.getByIdForAdmin(id)`, that calls `requireAdmin(ctx)` and returns `getProductWithRelations(ctx, id)` (reuse existing model helper). Not required for the initial scope.
### 2.3 Sorting (optional but recommended)
`products.list` currently returns pages in index order (e.g. by status/category) with no `sortBy`/`sortOrder`. For “sort by name, brand, childCategorySlug”:
- **Option A — Client-side:** Sort the current page only (quick, no backend change).
- **Option B — Backend:** Add optional `sortBy: v.optional(v.union(v.literal("name"), v.literal("brand"), v.literal("childCategorySlug")))` and `sortOrder: v.optional(v.union(v.literal("asc"), v.literal("desc")))` to `products.list`. Use the appropriate index where possible (e.g. `by_brand`) or a single generic index and `.order()` so pagination stays consistent. Prefer Option B for correct cross-page sort.
### 2.4 Search vs list
- **Empty search:** Use `products.list({ paginationOpts, status?, categoryId? })`. Paginated; use `page`, `isDone`, `continueCursor` for the table.
- **Non-empty search:** Use `products.search({ query, status?, categoryId?, brand?, limit })`. Default `limit` is 24; for admin, pass a larger limit (e.g. 100) or document that search results are “top N” and pagination is not applied when search is active.
---
## 3. Product List Page (`apps/admin/src/app/(dashboard)/products/page.tsx`)
### 3.1 Layout (top to bottom)
1. **Title** — e.g. “Products” (heading level 1).
2. **Toolbar row** — same row, three elements:
- **Search input** — debounced (e.g. 300 ms); when empty, table uses `products.list`; when non-empty, `products.search` with admin limit. Clear button when query is non-empty.
- **Columns visibility dropdown** — control which columns are visible (see table columns below). Use ShadCN DropdownMenu + Checkbox items; persist preference in React state (or localStorage if desired).
- **Create Product button** — primary CTA; links to `/products/new` (or your create route).
3. **Data table** — ShadCN Table with sortable headers for name, brand, childCategorySlug; row click or name-cell click opens preview dialog.
4. **Pagination** — below the table; only when using `products.list` (not when showing search results). Use `continueCursor` / `isDone` from pagination result; page size selector optional (e.g. 10, 25, 50).
### 3.2 Table columns — what to show (first sight)
Prioritise what an admin needs at a glance; everything else is in the preview dialog.
| Column | Sortable | Visible by default | Notes |
|--------|----------|--------------------|--------|
| **Name** | Yes | Yes | Trigger for preview dialog (clickable). |
| **Brand** | Yes | Yes | Optional field; show “—” if empty. |
| **Child category** | Yes | Yes | `childCategorySlug` (or category name if you resolve id → name). |
| **Status** | No | Yes | Badge: active / draft / archived (colour + text). |
| **Slug** | No | Yes | For quick URL reference. |
| **Tags** | No | Optional | Comma-separated or count; hide by default in columns dropdown if too noisy. |
| **Updated** | No | Optional | `updatedAt` formatted; hide by default. |
Avoid cluttering the table with description, SEO, or attributes; those belong in the **preview dialog**.
### 3.3 Loading state — skeleton
When `products.list` or `products.search` is loading, render a **skeleton that matches the table layout**: same number of columns and a fixed number of rows (e.g. 10). Use ShadCN `Skeleton` for each cell so the table doesnt jump when data loads. No full-page spinner (per admin rules).
### 3.4 Product preview dialog
- **Trigger:** Product name cell (or whole row if you prefer; name is required for a11y).
- **Content:** Full product snapshot — all required and optional fields (name, slug, status, category, parent/child slugs, brand, tags, shortDescription, description, attributes, SEO if present, timestamps). Read-only; no form. Use ShadCN Dialog; optionally use ScrollArea for long content.
- **Data source:** Selected row from the table (no extra fetch needed for MVP).
### 3.5 Actions menu
Per row: a ShadCN DropdownMenu (e.g. “Actions” or kebab icon with `aria-label`).
- **Edit** — link to `/products/[id]/edit` (or equivalent).
- **Archive** — opens ShadCN AlertDialog: “Archive this product? It will no longer appear on the storefront.” Confirm calls `products.archive({ id })`; on success, invalidate list and close dialog.
If “Restore” (draft/active from archived) is required later, add a mutation that patches status back to `draft` and an action in the menu for archived rows.
---
## 4. Create Product Page
**Route:** e.g. `apps/admin/src/app/(dashboard)/products/new/page.tsx`.
### 4.1 Form fields (aligned with schema)
**Required (non-optional in schema):**
- `name` — text
- `slug` — text (unique; consider auto-derive from name with “Edit” override)
- `status` — select: `active` | `draft` | `archived`
- `categoryId` — select; options from prefetched categories
- `tags` — array of strings (tag input or comma-separated; allow empty `[]`)
- `parentCategorySlug` / `childCategorySlug`**do not** put in the form; backend derives them from `categoryId` in `products.create` / `products.update`
**Optional (storefront-useful):**
- `description` — textarea (rich text out of scope)
- `shortDescription` — textarea or short text
- `brand` — text
- `attributes` — structured fields (pet size, age range, diet, material, flavor) as per schema; can be a simple key-value or dedicated inputs
**System-managed (never from form):**
- `createdAt` / `updatedAt` — set in Convex only.
- `averageRating` / `reviewCount` — default 0; only changed by reviews system.
**Advanced (collapsed section):**
- `seoTitle`, `seoDescription`, `canonicalSlug` — optional; collapse under “Advanced” or “SEO” so the main form stays minimal.
### 4.2 Categories prefetch
- In the layout or page, fetch categories once (e.g. `categories.list({})` with no `parentId`, or with `parentId` if you only need leaves). Build a flat or tree structure for a select: value = `categoryId`, label = category name (and optionally slug). Use this for the **Category** dropdown on both create and edit.
### 4.3 Validation and submission
- Use **react-hook-form** with **zod**: require `name`, `slug`, `status`, `categoryId`, `tags` (array); optional fields as nullable or optional in the schema. On submit, call `products.create` with the payload; do not send `createdAt`/`updatedAt`. Redirect to list or edit page on success; toast on error.
### 4.4 Slug uniqueness
- Either derive slug from name (e.g. slugify) and rely on backend error if duplicate, or add a small Convex query that checks existence by slug and call it on blur (optional).
---
## 5. Edit Product Page
**Route:** e.g. `apps/admin/src/app/(dashboard)/products/[id]/edit/page.tsx`.
- Load product by id. If using only public APIs, use `products.list` with a filter or `products.search` and find by id, or add `products.getByIdForAdmin` (see 2.2) for a direct load. Pre-populate the same form as create; all required and optional fields that exist on the document.
- Submit calls `products.update({ id, ...updates })`. Only send changed or user-edited fields if you want partial updates; otherwise send full form state (backend ignores undefined).
- **Archive:** Same AlertDialog pattern as on the list page; after archive, redirect to list or show status updated.
---
## 6. ShadCN Components to Install
Install via CLI (no copy-paste). Example:
```bash
npx shadcn@latest add table button input dropdown-menu dialog alert-dialog badge skeleton select form label textarea checkbox
```
Ensure **Table**, **Dialog**, **AlertDialog**, **DropdownMenu**, **Form**, **Select**, **Badge**, **Skeleton**, **Button**, **Input** are available. Add **ScrollArea** for the preview dialog if content is long. Use relative imports (no `@/` in admin app).
---
## 7. File and Route Structure
- `app/(dashboard)/products/page.tsx` — list page (client component for table state, search, pagination).
- `app/(dashboard)/products/new/page.tsx` — create product (client form or server + client form).
- `app/(dashboard)/products/[id]/edit/page.tsx` — edit product.
- Shared: product form component (used by create and edit), product preview dialog component, and optionally a reusable products table (with columns configurable by columns dropdown). Place under `components/` as appropriate (e.g. `components/products/ProductForm.tsx`, `ProductPreviewDialog.tsx`, `ProductsTable.tsx`).
---
## 8. Implementation Order
1. **Backend** — Extend `products.create` and `products.update` with optional fields; set `createdAt`/`updatedAt` in mutations. Optionally add sort args to `products.list` and/or `products.getByIdForAdmin`.
2. **ShadCN** — Install required components.
3. **Categories** — Prefetch categories (query in layout or page) and pass into form; ensure category select works.
4. **List page** — Toolbar (search, columns dropdown, Create button), table with columns above, skeleton, pagination, then wire search (list vs search).
5. **Sort** — Wire sort to column headers (client-side or via backend).
6. **Preview dialog** — Name-cell trigger, read-only full product view from row data.
7. **Actions menu** — Edit link, Archive with AlertDialog and `products.archive`.
8. **Create page** — Form with required + optional fields, zod + react-hook-form, categories select, submit → `products.create`.
9. **Edit page** — Same form, load product, submit → `products.update`; archive from list or edit page.
10. **Polish** — Empty states (no products, no search results), a11y (labels, aria-labels on icon buttons), and any columns persistence.
---
## 9. Out of Scope (This Plan)
- Product images (upload, gallery, reorder).
- Variants (CRUD, stock, price).
- Bulk status change (multi-select + `products.bulkUpdateStatus`).
- SEO as first-class in the form (optional “Advanced” only).
This plan is the single reference for implementing the products feature in the admin dashboard for senior engineers; use Convex and ShadCN MCP/skills for API and component details as needed.
---
## 10. Completed Work
**Implemented:** 2026-03-05
### Backend
- [x] Extended `products.create` with optional fields: `shortDescription`, `brand`, `attributes` (petSize, ageRange, specialDiet, material, flavor), `seoTitle`, `seoDescription`, `canonicalSlug`
- [x] Extended `products.update` with the same optional fields
- [x] Both mutations set `createdAt` / `updatedAt` timestamps
- [x] Added `products.getByIdForAdmin` — admin-only query using `requireAdmin` + `getProductWithRelations`
### ShadCN Components Installed
- [x] `table`, `badge`, `skeleton`, `dropdown-menu`, `alert-dialog`, `dialog`, `scroll-area`, `form`, `select`, `input`, `textarea`, `label`, `checkbox`, `separator`, `collapsible`
### Files Created / Modified
- [x] `convex/products.ts` — extended `create`, `update`; added `getByIdForAdmin`
- [x] `src/app/(dashboard)/products/page.tsx` — full list page (search, column visibility, client-side sort, skeleton, load-more pagination, preview dialog, actions menu)
- [x] `src/components/products/ProductPreviewDialog.tsx` — read-only full-product dialog triggered from name cell
- [x] `src/components/products/ProductActionsMenu.tsx` — per-row kebab menu (Edit link + Archive with AlertDialog)
- [x] `src/components/products/ProductForm.tsx` — shared form (create + edit); zod schema, react-hook-form, auto-slug, collapsible Attributes + SEO sections, spinner on submit
- [x] `src/app/(dashboard)/products/new/page.tsx` — create product page
- [x] `src/app/(dashboard)/products/[id]/edit/page.tsx` — edit product page (pre-populated, archive button, loading/not-found states)
### Key Decisions
- Category select shows only leaf categories (those with a `parentId`) with "Parent / Child" labels
- Sort is client-side (current page only) — sufficient for the initial scope
- Submit button shows an inline SVG spinner (`data-icon="inline-start"`) during submission per updated CLAUDE.md rule
- Link-styled buttons use `<Link className={buttonVariants({...})}>` — the `Button` component's TypeScript type does not expose the `render` / `nativeButton` props from `@base-ui/react`

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@clerk/nextjs": "^6.38.2", "@clerk/nextjs": "^6.38.2",
"@hookform/resolvers": "^5.2.2",
"@hugeicons/core-free-icons": "^3.3.0", "@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/react": "^1.1.5", "@hugeicons/react": "^1.1.5",
"@repo/convex": "*", "@repo/convex": "*",
@@ -21,7 +22,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"tailwind-merge": "^2.6.1" "react-hook-form": "^7.71.2",
"tailwind-merge": "^2.6.1",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
@@ -30,4 +33,4 @@
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
} }
} }

View File

@@ -0,0 +1,204 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation } from "convex/react"
import { useRouter, useParams } from "next/navigation"
import Link from "next/link"
import { api } from "../../../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../../../convex/_generated/dataModel"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
ProductForm,
buildProductPayload,
type ProductFormValues,
} from "@/components/products/ProductForm"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowLeft01Icon, Archive01Icon } from "@hugeicons/core-free-icons"
export default function EditProductPage() {
const params = useParams()
const productId = params.id as Id<"products">
const router = useRouter()
const product = useQuery(api.products.getByIdForAdmin, { id: productId })
const categories = useQuery(api.categories.list, {}) ?? []
const updateProduct = useMutation(api.products.update)
const archiveProduct = useMutation(api.products.archive)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [archiveOpen, setArchiveOpen] = useState(false)
const [isArchiving, setIsArchiving] = useState(false)
async function handleSubmit(values: ProductFormValues) {
setIsSubmitting(true)
setError(null)
try {
const payload = buildProductPayload(values)
await updateProduct({
id: productId,
...payload,
categoryId: payload.categoryId as Id<"categories">,
})
router.push("/products")
} catch (e: any) {
setError(e?.message ?? "Failed to save product. Please try again.")
setIsSubmitting(false)
}
}
async function handleArchive() {
setIsArchiving(true)
try {
await archiveProduct({ id: productId })
router.push("/products")
} catch (e: any) {
setError(e?.message ?? "Failed to archive product.")
setIsArchiving(false)
}
}
// Loading state
if (product === undefined) {
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<div className="flex flex-col gap-1">
<Skeleton className="h-7 w-20" />
<Skeleton className="h-7 w-48" />
</div>
<div className="max-w-2xl space-y-8">
<div className="space-y-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<div className="grid grid-cols-2 gap-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
</div>
</div>
</main>
)
}
// Not found
if (product === null) {
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<div className="flex flex-col gap-1">
<Link
href="/products"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-fit -ml-2")}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
Products
</Link>
<h1 className="text-xl font-semibold">Product not found</h1>
</div>
<p className="text-sm text-muted-foreground">
This product does not exist or you do not have permission to view it.
</p>
</main>
)
}
const defaultValues: Partial<ProductFormValues> = {
name: product.name ?? "",
slug: product.slug ?? "",
status: product.status ?? "draft",
categoryId: product.categoryId ?? "",
tags: (product.tags ?? []).join(", "),
shortDescription: product.shortDescription ?? "",
description: product.description ?? "",
brand: product.brand ?? "",
petSize: (product.attributes?.petSize ?? []).join(", "),
ageRange: (product.attributes?.ageRange ?? []).join(", "),
specialDiet: (product.attributes?.specialDiet ?? []).join(", "),
material: product.attributes?.material ?? "",
flavor: product.attributes?.flavor ?? "",
seoTitle: product.seoTitle ?? "",
seoDescription: product.seoDescription ?? "",
canonicalSlug: product.canonicalSlug ?? "",
}
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-1">
<Link
href="/products"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-fit -ml-2")}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
Products
</Link>
<h1 className="text-xl font-semibold">{product.name}</h1>
</div>
{product.status !== "archived" && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setArchiveOpen(true)}
>
<HugeiconsIcon icon={Archive01Icon} strokeWidth={2} />
Archive
</Button>
)}
</div>
{error && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="max-w-2xl">
<ProductForm
mode="edit"
categories={categories}
defaultValues={defaultValues}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
</div>
<AlertDialog open={archiveOpen} onOpenChange={setArchiveOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Archive &ldquo;{product.name}&rdquo;?
</AlertDialogTitle>
<AlertDialogDescription>
This product will no longer appear on the storefront. You can
restore it by editing the product status later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleArchive}
disabled={isArchiving}
>
{isArchiving ? "Archiving…" : "Archive"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
)
}

View File

@@ -0,0 +1,71 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation } from "convex/react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { api } from "../../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../../convex/_generated/dataModel"
import { buttonVariants } from "@/components/ui/button"
import {
ProductForm,
buildProductPayload,
type ProductFormValues,
} from "@/components/products/ProductForm"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons"
import { cn } from "@/lib/utils"
export default function NewProductPage() {
const router = useRouter()
const categories = useQuery(api.categories.list, {}) ?? []
const createProduct = useMutation(api.products.create)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(values: ProductFormValues) {
setIsSubmitting(true)
setError(null)
try {
const payload = buildProductPayload(values)
await createProduct({
...payload,
categoryId: payload.categoryId as Id<"categories">,
})
router.push("/products")
} catch (e: any) {
setError(e?.message ?? "Failed to create product. Please try again.")
setIsSubmitting(false)
}
}
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<div className="flex flex-col gap-1">
<Link
href="/products"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-fit -ml-2")}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
Products
</Link>
<h1 className="text-xl font-semibold">New product</h1>
</div>
{error && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="max-w-2xl">
<ProductForm
mode="create"
categories={categories}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
</div>
</main>
)
}

View File

@@ -1,5 +1,471 @@
import StillBuildingPlaceholder from "../../../components/shared/still_building_placeholder"; "use client"
import { useState, useEffect, useMemo } from "react"
import { usePaginatedQuery, useQuery } from "convex/react"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import Link from "next/link"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { HugeiconsIcon } from "@hugeicons/react"
import {
Search01Icon,
Cancel01Icon,
ArrowUpDownIcon,
SortByUp01Icon,
SortByDown01Icon,
AddCircleIcon,
} from "@hugeicons/core-free-icons"
import {
ProductPreviewDialog,
type PreviewProduct,
} from "@/components/products/ProductPreviewDialog"
import { ProductActionsMenu } from "@/components/products/ProductActionsMenu"
import { cn } from "@/lib/utils"
// ─── Types ────────────────────────────────────────────────────────────────────
type SortField = "name" | "brand" | "childCategory"
type SortOrder = "asc" | "desc"
type ColumnKey = "brand" | "childCategory" | "status" | "slug" | "tags" | "updated"
const OPTIONAL_COLUMNS: { key: ColumnKey; label: string }[] = [
{ key: "brand", label: "Brand" },
{ key: "childCategory", label: "Category" },
{ key: "status", label: "Status" },
{ key: "slug", label: "Slug" },
{ key: "tags", label: "Tags" },
{ key: "updated", label: "Updated" },
]
const DEFAULT_VISIBLE: Record<ColumnKey, boolean> = {
brand: true,
childCategory: true,
status: true,
slug: true,
tags: false,
updated: false,
}
const STATUS_CONFIG = {
active: { label: "Active", variant: "default" as const },
draft: { label: "Draft", variant: "secondary" as const },
archived: { label: "Archived", variant: "outline" as const },
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function SortableHeader({
field,
sortField,
sortOrder,
onSort,
children,
}: {
field: SortField
sortField: SortField | null
sortOrder: SortOrder
onSort: (field: SortField) => void
children: React.ReactNode
}) {
const active = sortField === field
return (
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => onSort(field)}
>
{children}
<HugeiconsIcon
icon={
active
? sortOrder === "asc"
? SortByUp01Icon
: SortByDown01Icon
: ArrowUpDownIcon
}
strokeWidth={2}
className={cn(
"size-3.5",
active ? "text-foreground" : "text-muted-foreground",
)}
/>
</button>
)
}
function TableSkeleton({
visibleCols,
}: {
visibleCols: Record<ColumnKey, boolean>
}) {
return (
<>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-36" />
</TableCell>
{visibleCols.brand && (
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
)}
{visibleCols.childCategory && (
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
)}
{visibleCols.status && (
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
)}
{visibleCols.slug && (
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
)}
{visibleCols.tags && (
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
)}
{visibleCols.updated && (
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
)}
<TableCell>
<Skeleton className="size-7 rounded-lg" />
</TableCell>
</TableRow>
))}
</>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ProductsPage() { export default function ProductsPage() {
return <StillBuildingPlaceholder />; const [searchInput, setSearchInput] = useState("")
const [searchQuery, setSearchQuery] = useState("")
const [visibleCols, setVisibleCols] =
useState<Record<ColumnKey, boolean>>(DEFAULT_VISIBLE)
const [sortField, setSortField] = useState<SortField | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [previewProduct, setPreviewProduct] = useState<PreviewProduct | null>(null)
const [previewOpen, setPreviewOpen] = useState(false)
// Debounce search input by 300ms
useEffect(() => {
const t = setTimeout(() => setSearchQuery(searchInput), 300)
return () => clearTimeout(t)
}, [searchInput])
const isSearching = searchQuery.trim().length > 0
const {
results: listResults,
status: listStatus,
loadMore,
} = usePaginatedQuery(api.products.list, {}, { initialNumItems: 25 })
const searchResults = useQuery(
api.products.search,
isSearching ? { query: searchQuery, limit: 100 } : "skip",
)
const isLoading = isSearching
? searchResults === undefined
: listStatus === "LoadingFirstPage"
const rawProducts = isSearching ? (searchResults ?? []) : listResults
const products = useMemo(() => {
if (!sortField) return rawProducts
return [...rawProducts].sort((a: any, b: any) => {
const aVal: string =
sortField === "name"
? (a.name ?? "")
: sortField === "brand"
? (a.brand ?? "")
: (a.childCategorySlug ?? "")
const bVal: string =
sortField === "name"
? (b.name ?? "")
: sortField === "brand"
? (b.brand ?? "")
: (b.childCategorySlug ?? "")
return sortOrder === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal)
})
}, [rawProducts, sortField, sortOrder])
function handleSort(field: SortField) {
if (sortField === field) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortField(field)
setSortOrder("asc")
}
}
function toggleColumn(key: ColumnKey, checked: boolean) {
setVisibleCols((prev) => ({ ...prev, [key]: checked }))
}
function openPreview(product: any) {
setPreviewProduct(product as PreviewProduct)
setPreviewOpen(true)
}
const visibleOptionalCount = Object.values(visibleCols).filter(Boolean).length
// Name + visible optional cols + Actions
const totalColSpan = 1 + visibleOptionalCount + 1
return (
<main className="flex flex-1 flex-col gap-4 p-4 pt-0">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Products</h1>
<Link href="/products/new" className={buttonVariants()}>
<HugeiconsIcon icon={AddCircleIcon} strokeWidth={2} />
New product
</Link>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2">
<div className="relative max-w-sm flex-1">
<HugeiconsIcon
icon={Search01Icon}
strokeWidth={2}
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search products…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8 pr-8"
/>
{searchInput && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchInput("")}
aria-label="Clear search"
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
className="size-4"
/>
</button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
Columns
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{OPTIONAL_COLUMNS.map(({ key, label }) => (
<DropdownMenuCheckboxItem
key={key}
checked={visibleCols[key]}
onCheckedChange={(checked: boolean) =>
toggleColumn(key, checked)
}
>
{label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Table */}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">
<SortableHeader
field="name"
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
>
Name
</SortableHeader>
</TableHead>
{visibleCols.brand && (
<TableHead scope="col">
<SortableHeader
field="brand"
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
>
Brand
</SortableHeader>
</TableHead>
)}
{visibleCols.childCategory && (
<TableHead scope="col">
<SortableHeader
field="childCategory"
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
>
Category
</SortableHeader>
</TableHead>
)}
{visibleCols.status && (
<TableHead scope="col">Status</TableHead>
)}
{visibleCols.slug && <TableHead scope="col">Slug</TableHead>}
{visibleCols.tags && <TableHead scope="col">Tags</TableHead>}
{visibleCols.updated && (
<TableHead scope="col">Updated</TableHead>
)}
<TableHead scope="col" className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton visibleCols={visibleCols} />
) : products.length === 0 ? (
<TableRow>
<TableCell
colSpan={totalColSpan}
className="py-16 text-center text-sm text-muted-foreground"
>
{isSearching
? `No products match "${searchQuery}".`
: "No products yet. Create your first product to get started."}
</TableCell>
</TableRow>
) : (
products.map((product: any) => {
const statusCfg =
STATUS_CONFIG[product.status as keyof typeof STATUS_CONFIG]
return (
<TableRow key={product._id}>
<TableCell>
<button
type="button"
className="font-medium hover:underline text-left"
onClick={() => openPreview(product)}
>
{product.name}
</button>
</TableCell>
{visibleCols.brand && (
<TableCell className="text-muted-foreground">
{product.brand ?? "—"}
</TableCell>
)}
{visibleCols.childCategory && (
<TableCell className="text-muted-foreground">
{product.childCategorySlug ?? "—"}
</TableCell>
)}
{visibleCols.status && (
<TableCell>
{statusCfg ? (
<Badge variant={statusCfg.variant}>
{statusCfg.label}
</Badge>
) : (
<span className="text-muted-foreground">
{product.status}
</span>
)}
</TableCell>
)}
{visibleCols.slug && (
<TableCell className="font-mono text-xs text-muted-foreground">
{product.slug}
</TableCell>
)}
{visibleCols.tags && (
<TableCell className="text-muted-foreground">
{product.tags?.length > 0
? product.tags.join(", ")
: "—"}
</TableCell>
)}
{visibleCols.updated && (
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{product.updatedAt
? new Date(product.updatedAt).toLocaleDateString()
: "—"}
</TableCell>
)}
<TableCell>
<ProductActionsMenu
productId={product._id as Id<"products">}
productName={product.name}
isArchived={product.status === "archived"}
/>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
{/* Pagination footer — list mode only */}
{!isSearching && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{listStatus === "Exhausted"
? `${listResults.length} product${listResults.length !== 1 ? "s" : ""} total`
: `${listResults.length} loaded`}
</span>
{listStatus === "CanLoadMore" && (
<Button variant="outline" size="sm" onClick={() => loadMore(25)}>
Load more
</Button>
)}
</div>
)}
<ProductPreviewDialog
product={previewProduct}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
</main>
)
} }

View File

@@ -36,10 +36,8 @@ export function DynamicBreadcrumb() {
<BreadcrumbList> <BreadcrumbList>
{/* Home icon link */} {/* Home icon link */}
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink asChild> <BreadcrumbLink render={<Link href="/" />}>
<Link href="/"> <HugeiconsIcon icon={Home01Icon} size={16} />
<HugeiconsIcon icon={Home01Icon} size={16} />
</Link>
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
@@ -55,8 +53,8 @@ export function DynamicBreadcrumb() {
{isLast ? ( {isLast ? (
<BreadcrumbPage>{label}</BreadcrumbPage> <BreadcrumbPage>{label}</BreadcrumbPage>
) : ( ) : (
<BreadcrumbLink asChild> <BreadcrumbLink render={<Link href={href} />}>
<Link href={href}>{label}</Link> {label}
</BreadcrumbLink> </BreadcrumbLink>
)} )}
</BreadcrumbItem> </BreadcrumbItem>

View File

@@ -24,7 +24,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" tooltip="The Pet Loft Admin" asChild={false}> <SidebarMenuButton size="lg" tooltip="The Pet Loft Admin">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"> <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<PawPrint className="size-4" /> <PawPrint className="size-4" />
</div> </div>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
@@ -16,26 +17,64 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { CollapsibleTrigger, CollapsibleContent, Collapsible } from "@/components/ui/collapsible"; import { CollapsibleTrigger, CollapsibleContent, Collapsible } from "@/components/ui/collapsible";
type NavItem = {
title: string;
url: string;
icon: IconSvgElement;
items?: { title: string; url: string }[];
};
function NavMainItem({ item, pathname }: { item: NavItem; pathname: string }) {
const isGroupActive = pathname.startsWith(item.url);
const [open, setOpen] = useState(isGroupActive);
// Auto-open when navigating into this group's routes
useEffect(() => {
if (isGroupActive) setOpen(true);
}, [isGroupActive]);
return (
<SidebarMenuItem>
<Collapsible open={open} onOpenChange={setOpen} className="group/collapsible">
<CollapsibleTrigger
render={
<SidebarMenuButton tooltip={item.title} isActive={isGroupActive} />
}
>
<HugeiconsIcon icon={item.icon} size={16} />
<span>{item.title}</span>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="ml-auto transition-transform duration-200 group-data-[open]/collapsible:rotate-90"
/>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
render={<Link href={subItem.url} />}
isActive={pathname === subItem.url}
>
<span>{subItem.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenuItem>
);
}
export function NavMain({ export function NavMain({
overview, overview,
isOverview, isOverview,
navMain, navMain,
}: { }: {
overview: { overview: Omit<NavItem, "items">[];
title: string;
url: string;
icon: IconSvgElement;
}[];
isOverview?: boolean; isOverview?: boolean;
navMain: { navMain: NavItem[];
title: string;
url: string;
icon: IconSvgElement;
items?: {
title: string;
url: string;
}[];
}[];
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -46,11 +85,13 @@ export function NavMain({
<SidebarMenu> <SidebarMenu>
{overview.map((item) => ( {overview.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title} isActive={pathname === item.url}> <SidebarMenuButton
<Link href={item.url}> render={<Link href={item.url} />}
<HugeiconsIcon icon={item.icon} size={16} /> tooltip={item.title}
<span>{item.title}</span> isActive={pathname === item.url}
</Link> >
<HugeiconsIcon icon={item.icon} size={16} />
<span>{item.title}</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
@@ -63,40 +104,9 @@ export function NavMain({
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel> <SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{navMain.map((item) => { {navMain.map((item) => (
const isGroupActive = pathname.startsWith(item.url); <NavMainItem key={item.title} item={item} pathname={pathname} />
return ( ))}
<Collapsible
key={item.title}
asChild
defaultOpen={isGroupActive}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={isGroupActive}>
<HugeiconsIcon icon={item.icon} size={16} />
<span>{item.title}</span>
<HugeiconsIcon icon={ArrowRight01Icon} className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
); );

View File

@@ -0,0 +1,117 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useMutation } from "convex/react"
import { api } from "../../../../../convex/_generated/api"
import type { Id } from "../../../../../convex/_generated/dataModel"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import {
MoreVerticalIcon,
PencilEdit01Icon,
Archive01Icon,
} from "@hugeicons/core-free-icons"
interface ProductActionsMenuProps {
productId: Id<"products">
productName: string
isArchived?: boolean
}
export function ProductActionsMenu({
productId,
productName,
isArchived,
}: ProductActionsMenuProps) {
const [archiveOpen, setArchiveOpen] = useState(false)
const [isArchiving, setIsArchiving] = useState(false)
const archive = useMutation(api.products.archive)
async function handleArchive() {
setIsArchiving(true)
try {
await archive({ id: productId })
setArchiveOpen(false)
} catch (e) {
console.error("Failed to archive product:", e)
} finally {
setIsArchiving(false)
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label={`Actions for ${productName}`}
/>
}
>
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={2} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem render={<Link href={`/products/${productId}/edit`} />}>
<HugeiconsIcon icon={PencilEdit01Icon} strokeWidth={2} />
Edit
</DropdownMenuItem>
{!isArchived && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setArchiveOpen(true)}
>
<HugeiconsIcon icon={Archive01Icon} strokeWidth={2} />
Archive
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={archiveOpen} onOpenChange={setArchiveOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive &ldquo;{productName}&rdquo;?</AlertDialogTitle>
<AlertDialogDescription>
This product will no longer appear on the storefront. You can
restore it by editing the product later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleArchive}
disabled={isArchiving}
>
{isArchiving ? "Archiving…" : "Archive"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,576 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { slugify } from "@repo/utils"
import Link from "next/link"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button, buttonVariants } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowDown01Icon } from "@hugeicons/core-free-icons"
// ─── Schema ───────────────────────────────────────────────────────────────────
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
slug: z
.string()
.min(1, "Slug is required")
.regex(/^[a-z0-9-]+$/, "Lowercase letters, numbers, and hyphens only"),
status: z.enum(["active", "draft", "archived"]),
categoryId: z.string().min(1, "Category is required"),
tags: z.string().default(""),
shortDescription: z.string().optional(),
description: z.string().optional(),
brand: z.string().optional(),
// Attributes — comma-separated for array fields
petSize: z.string().optional(),
ageRange: z.string().optional(),
specialDiet: z.string().optional(),
material: z.string().optional(),
flavor: z.string().optional(),
// SEO
seoTitle: z.string().optional(),
seoDescription: z.string().optional(),
canonicalSlug: z.string().optional(),
})
export type ProductFormValues = z.infer<typeof formSchema>
// ─── Types ─────────────────────────────────────────────────────────────────────
export type CategoryOption = {
_id: string
name: string
slug: string
parentId?: string
}
interface ProductFormProps {
mode: "create" | "edit"
categories: CategoryOption[]
defaultValues?: Partial<ProductFormValues>
onSubmit: (values: ProductFormValues) => Promise<void>
isSubmitting: boolean
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseCommaList(str?: string): string[] {
if (!str?.trim()) return []
return str
.split(",")
.map((s) => s.trim())
.filter(Boolean)
}
export function buildProductPayload(values: ProductFormValues) {
const tags = parseCommaList(values.tags)
const petSize = parseCommaList(values.petSize)
const ageRange = parseCommaList(values.ageRange)
const specialDiet = parseCommaList(values.specialDiet)
const material = values.material?.trim() || undefined
const flavor = values.flavor?.trim() || undefined
const hasAttrs =
petSize.length > 0 ||
ageRange.length > 0 ||
specialDiet.length > 0 ||
material ||
flavor
return {
name: values.name,
slug: values.slug,
status: values.status,
categoryId: values.categoryId,
tags,
description: values.description?.trim() || undefined,
shortDescription: values.shortDescription?.trim() || undefined,
brand: values.brand?.trim() || undefined,
attributes: hasAttrs
? {
...(petSize.length > 0 && { petSize }),
...(ageRange.length > 0 && { ageRange }),
...(specialDiet.length > 0 && { specialDiet }),
...(material && { material }),
...(flavor && { flavor }),
}
: undefined,
seoTitle: values.seoTitle?.trim() || undefined,
seoDescription: values.seoDescription?.trim() || undefined,
canonicalSlug: values.canonicalSlug?.trim() || undefined,
}
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ProductForm({
mode,
categories,
defaultValues,
onSubmit,
isSubmitting,
}: ProductFormProps) {
const [slugManuallySet, setSlugManuallySet] = useState(mode === "edit")
const form = useForm<ProductFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
slug: "",
status: "draft",
categoryId: "",
tags: "",
shortDescription: "",
description: "",
brand: "",
petSize: "",
ageRange: "",
specialDiet: "",
material: "",
flavor: "",
seoTitle: "",
seoDescription: "",
canonicalSlug: "",
...defaultValues,
},
})
// Auto-derive slug from name in create mode
const nameValue = form.watch("name")
useEffect(() => {
if (!slugManuallySet && nameValue) {
form.setValue("slug", slugify(nameValue), { shouldValidate: false })
}
}, [nameValue, slugManuallySet, form])
// Build category options with "Parent / Child" display labels
const categoryOptions = useMemo(() => {
const idToName: Record<string, string> = {}
for (const cat of categories) idToName[cat._id] = cat.name
return categories
.filter((cat) => cat.parentId)
.map((cat) => ({
value: cat._id,
label: `${idToName[cat.parentId!] ?? "?"} / ${cat.name}`,
}))
.sort((a, b) => a.label.localeCompare(b.label))
}, [categories])
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* ── Core ───────────────────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Core
</h2>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Royal Canin Adult Cat" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="royal-canin-adult-cat"
{...field}
onChange={(e) => {
setSlugManuallySet(true)
field.onChange(e)
}}
/>
</FormControl>
<FormDescription>
URL-safe identifier. Auto-generated from name; edit to
override.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
value={field.value}
onValueChange={(v) => field.onChange(v)}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select
value={field.value}
onValueChange={(v) => field.onChange(v)}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.length === 0 ? (
<SelectItem value="_loading" disabled>
Loading
</SelectItem>
) : (
categoryOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input placeholder="cat-food, dry, indoor" {...field} />
</FormControl>
<FormDescription>Comma-separated list of tags.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</section>
<Separator />
{/* ── Details ────────────────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Details
</h2>
<FormField
control={form.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Brand</FormLabel>
<FormControl>
<Input placeholder="Royal Canin" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="shortDescription"
render={({ field }) => (
<FormItem>
<FormLabel>Short description</FormLabel>
<FormControl>
<Textarea
placeholder="One or two sentences shown in listings…"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Full description</FormLabel>
<FormControl>
<Textarea
placeholder="Detailed product description…"
className="min-h-32 resize-y"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</section>
<Separator />
{/* ── Attributes (collapsible) ────────────────────────────────── */}
<Collapsible className="group/collapsible">
<CollapsibleTrigger className="flex w-full items-center justify-between py-1 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
<span className="uppercase tracking-wide">Attributes</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2}
className="size-4 transition-transform group-data-[open]/collapsible:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="petSize"
render={({ field }) => (
<FormItem>
<FormLabel>Pet size</FormLabel>
<FormControl>
<Input placeholder="small, medium, large" {...field} />
</FormControl>
<FormDescription>Comma-separated.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ageRange"
render={({ field }) => (
<FormItem>
<FormLabel>Age range</FormLabel>
<FormControl>
<Input placeholder="puppy, adult, senior" {...field} />
</FormControl>
<FormDescription>Comma-separated.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="specialDiet"
render={({ field }) => (
<FormItem>
<FormLabel>Special diet</FormLabel>
<FormControl>
<Input
placeholder="grain-free, hypoallergenic"
{...field}
/>
</FormControl>
<FormDescription>Comma-separated.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="material"
render={({ field }) => (
<FormItem>
<FormLabel>Material</FormLabel>
<FormControl>
<Input placeholder="Nylon" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="flavor"
render={({ field }) => (
<FormItem>
<FormLabel>Flavor</FormLabel>
<FormControl>
<Input placeholder="Chicken" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<Separator />
{/* ── SEO / Advanced (collapsible) ───────────────────────────── */}
<Collapsible className="group/collapsible">
<CollapsibleTrigger className="flex w-full items-center justify-between py-1 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
<span className="uppercase tracking-wide">Advanced / SEO</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2}
className="size-4 transition-transform group-data-[open]/collapsible:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<FormField
control={form.control}
name="seoTitle"
render={({ field }) => (
<FormItem>
<FormLabel>SEO title</FormLabel>
<FormControl>
<Input
placeholder="Overrides product name in search results"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="seoDescription"
render={({ field }) => (
<FormItem>
<FormLabel>SEO description</FormLabel>
<FormControl>
<Textarea
placeholder="Meta description for search engines…"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="canonicalSlug"
render={({ field }) => (
<FormItem>
<FormLabel>Canonical slug</FormLabel>
<FormControl>
<Input
placeholder="Overrides slug for canonical URL"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
{/* ── Actions ────────────────────────────────────────────────── */}
<div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && (
<svg
data-icon="inline-start"
className="animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isSubmitting
? mode === "create"
? "Creating…"
: "Saving…"
: mode === "create"
? "Create product"
: "Save changes"}
</Button>
<Link
href="/products"
className={buttonVariants({ variant: "outline" })}
>
Cancel
</Link>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,231 @@
"use client"
import React from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
export type PreviewProduct = {
_id: string
name: string
slug: string
status: "active" | "draft" | "archived"
description?: string
shortDescription?: string
brand?: string
tags: string[]
parentCategorySlug?: string
childCategorySlug?: string
topCategorySlug?: string
attributes?: {
petSize?: string[]
ageRange?: string[]
specialDiet?: string[]
material?: string
flavor?: string
}
seoTitle?: string
seoDescription?: string
canonicalSlug?: string
createdAt?: number
updatedAt?: number
averageRating?: number
reviewCount?: number
}
const STATUS_CONFIG = {
active: { label: "Active", variant: "default" as const },
draft: { label: "Draft", variant: "secondary" as const },
archived: { label: "Archived", variant: "outline" as const },
}
function formatDate(ms?: number) {
if (!ms) return "—"
return new Date(ms).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
function InfoRow({
label,
children,
}: {
label: string
children?: React.ReactNode
}) {
if (children == null || children === "") return null
return (
<div className="grid grid-cols-[128px_1fr] gap-2 py-0.5">
<span className="pt-0.5 text-xs text-muted-foreground">{label}</span>
<span className="break-words text-sm">{children}</span>
</div>
)
}
interface ProductPreviewDialogProps {
product: PreviewProduct | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function ProductPreviewDialog({
product,
open,
onOpenChange,
}: ProductPreviewDialogProps) {
if (!product) return null
const statusCfg = STATUS_CONFIG[product.status]
const attrs = product.attributes
const hasAttrs =
attrs &&
((attrs.petSize?.length ?? 0) > 0 ||
(attrs.ageRange?.length ?? 0) > 0 ||
(attrs.specialDiet?.length ?? 0) > 0 ||
attrs.material ||
attrs.flavor)
const hasSeo =
product.seoTitle || product.seoDescription || product.canonicalSlug
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{product.name}</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="space-y-3 py-1">
{/* Core fields */}
<div className="space-y-0.5">
<InfoRow label="Status">
<Badge variant={statusCfg.variant}>{statusCfg.label}</Badge>
</InfoRow>
<InfoRow label="Slug">{product.slug}</InfoRow>
<InfoRow label="Brand">{product.brand}</InfoRow>
</div>
<Separator />
{/* Category */}
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Category
</p>
<InfoRow label="Parent">{product.parentCategorySlug}</InfoRow>
<InfoRow label="Child">{product.childCategorySlug}</InfoRow>
<InfoRow label="Top">{product.topCategorySlug}</InfoRow>
</div>
{(product.shortDescription || product.description) && (
<>
<Separator />
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Description
</p>
<InfoRow label="Short">{product.shortDescription}</InfoRow>
{product.description && (
<div className="py-0.5">
<p className="mb-1 text-xs text-muted-foreground">Full</p>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{product.description}
</p>
</div>
)}
</div>
</>
)}
{product.tags.length > 0 && (
<>
<Separator />
<InfoRow label="Tags">
<div className="flex flex-wrap gap-1">
{product.tags.map((t) => (
<Badge key={t} variant="secondary">
{t}
</Badge>
))}
</div>
</InfoRow>
</>
)}
{hasAttrs && (
<>
<Separator />
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Attributes
</p>
{(attrs?.petSize?.length ?? 0) > 0 && (
<InfoRow label="Pet size">{attrs!.petSize!.join(", ")}</InfoRow>
)}
{(attrs?.ageRange?.length ?? 0) > 0 && (
<InfoRow label="Age range">{attrs!.ageRange!.join(", ")}</InfoRow>
)}
{(attrs?.specialDiet?.length ?? 0) > 0 && (
<InfoRow label="Special diet">
{attrs!.specialDiet!.join(", ")}
</InfoRow>
)}
{attrs?.material && (
<InfoRow label="Material">{attrs.material}</InfoRow>
)}
{attrs?.flavor && (
<InfoRow label="Flavor">{attrs.flavor}</InfoRow>
)}
</div>
</>
)}
{hasSeo && (
<>
<Separator />
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
SEO
</p>
<InfoRow label="SEO title">{product.seoTitle}</InfoRow>
<InfoRow label="SEO description">{product.seoDescription}</InfoRow>
<InfoRow label="Canonical slug">{product.canonicalSlug}</InfoRow>
</div>
</>
)}
<Separator />
{/* Meta */}
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Meta
</p>
<InfoRow label="Created">{formatDate(product.createdAt)}</InfoRow>
<InfoRow label="Updated">{formatDate(product.updatedAt)}</InfoRow>
{product.averageRating != null && (
<InfoRow label="Rating">
{product.averageRating.toFixed(1)} ({product.reviewCount ?? 0}{" "}
reviews)
</InfoRow>
)}
</div>
</div>
</ScrollArea>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 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 AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm 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",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui" import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -9,7 +9,7 @@ function Avatar({
className, className,
size = "default", size = "default",
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & { }: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg" size?: "default" | "sm" | "lg"
}) { }) {
return ( return (
@@ -17,7 +17,7 @@ function Avatar({
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
className={cn( className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6", "group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className className
)} )}
{...props} {...props}
@@ -25,14 +25,14 @@ function Avatar({
) )
} }
function AvatarImage({ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return ( return (
<AvatarPrimitive.Image <AvatarPrimitive.Image
data-slot="avatar-image" data-slot="avatar-image"
className={cn("aspect-square size-full", className)} className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props} {...props}
/> />
) )
@@ -41,7 +41,7 @@ function AvatarImage({
function AvatarFallback({ function AvatarFallback({
className, className,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { }: AvatarPrimitive.Fallback.Props) {
return ( return (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
@@ -59,7 +59,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
<span <span
data-slot="avatar-badge" data-slot="avatar-badge"
className={cn( className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none", "absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
@@ -103,7 +103,7 @@ export {
Avatar, Avatar,
AvatarImage, AvatarImage,
AvatarFallback, AvatarFallback,
AvatarBadge,
AvatarGroup, AvatarGroup,
AvatarGroupCount, AvatarGroupCount,
AvatarBadge,
} }

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -1,11 +1,20 @@
import * as React from "react" import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react" import { mergeProps } from "@base-ui/react/merge-props"
import { Slot } from "radix-ui" import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowRight01Icon, MoreHorizontalCircle01Icon } from "@hugeicons/core-free-icons"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} /> return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
} }
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@@ -13,7 +22,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
<ol <ol
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5", "flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className className
)} )}
{...props} {...props}
@@ -25,28 +34,30 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return ( return (
<li <li
data-slot="breadcrumb-item" data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)} className={cn("inline-flex items-center gap-1", className)}
{...props} {...props}
/> />
) )
} }
function BreadcrumbLink({ function BreadcrumbLink({
asChild,
className, className,
render,
...props ...props
}: React.ComponentProps<"a"> & { }: useRender.ComponentProps<"a">) {
asChild?: boolean return useRender({
}) { defaultTagName: "a",
const Comp = asChild ? Slot.Root : "a" props: mergeProps<"a">(
{
return ( className: cn("transition-colors hover:text-foreground", className),
<Comp },
data-slot="breadcrumb-link" props
className={cn("transition-colors hover:text-foreground", className)} ),
{...props} render,
/> state: {
) slot: "breadcrumb-link",
},
})
} }
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
@@ -75,7 +86,9 @@ function BreadcrumbSeparator({
className={cn("[&>svg]:size-3.5", className)} className={cn("[&>svg]:size-3.5", className)}
{...props} {...props}
> >
{children ?? <ChevronRight />} {children ?? (
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} />
)}
</li> </li>
) )
} }
@@ -89,10 +102,13 @@ function BreadcrumbEllipsis({
data-slot="breadcrumb-ellipsis" data-slot="breadcrumb-ellipsis"
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)} className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props} {...props}
> >
<MoreHorizontal className="size-4" /> <HugeiconsIcon icon={MoreHorizontalCircle01Icon} strokeWidth={2} />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) )

View File

@@ -1,34 +1,38 @@
import * as React from "react" "use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 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",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default:
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
icon: "size-9", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", icon: "size-8",
"icon-sm": "size-8", "icon-xs":
"icon-lg": "size-10", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -42,19 +46,11 @@ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return ( return (
<Comp <ButtonPrimitive
data-slot="button" data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />

View File

@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { Tick02Icon } from "@hugeicons/core-free-icons"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 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 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -1,32 +1,20 @@
"use client" "use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui" import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
} }
function CollapsibleTrigger({ function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return ( return (
<CollapsiblePrimitive.CollapsibleTrigger <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
data-slot="collapsible-trigger"
{...props}
/>
) )
} }
function CollapsibleContent({ function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return ( return (
<CollapsiblePrimitive.CollapsibleContent <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
data-slot="collapsible-content"
{...props}
/>
) )
} }

View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { Cancel01Icon } from "@hugeicons/core-free-icons"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -1,61 +1,76 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
function DropdownMenu({ function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
...props return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
} }
function DropdownMenuPortal({ function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
...props return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
...props return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
} }
function DropdownMenuContent({ function DropdownMenuContent({
className, align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4, sideOffset = 4,
className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { }: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return ( return (
<DropdownMenuPrimitive.Portal> <MenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <MenuPrimitive.Positioner
data-slot="dropdown-menu-content" className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( >
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", <MenuPrimitive.Popup
className data-slot="dropdown-menu-content"
)} className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
) )
} }
function DropdownMenuGroup({ function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
) )
} }
@@ -64,17 +79,17 @@ function DropdownMenuItem({
inset, inset,
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: MenuPrimitive.Item.Props & {
inset?: boolean inset?: boolean
variant?: "default" | "destructive" variant?: "default" | "destructive"
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <MenuPrimitive.Item
data-slot="dropdown-menu-item" data-slot="dropdown-menu-item"
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className className
)} )}
{...props} {...props}
@@ -82,37 +97,94 @@ function DropdownMenuItem({
) )
} }
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
className, className,
children, children,
checked, checked,
inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { }: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return ( return (
<DropdownMenuPrimitive.CheckboxItem <MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent 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", "relative flex 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 focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span
<DropdownMenuPrimitive.ItemIndicator> className="pointer-events-none absolute right-2 flex items-center justify-center"
<CheckIcon className="size-4" /> data-slot="dropdown-menu-checkbox-item-indicator"
</DropdownMenuPrimitive.ItemIndicator> >
<MenuPrimitive.CheckboxItemIndicator>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
</MenuPrimitive.CheckboxItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </MenuPrimitive.CheckboxItem>
) )
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return ( return (
<DropdownMenuPrimitive.RadioGroup <MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
@@ -122,53 +194,40 @@ function DropdownMenuRadioGroup({
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
className, className,
children, children,
inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { }: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return ( return (
<DropdownMenuPrimitive.RadioItem <MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent 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", "relative flex 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 focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span
<DropdownMenuPrimitive.ItemIndicator> className="pointer-events-none absolute right-2 flex items-center justify-center"
<CircleIcon className="size-2 fill-current" /> data-slot="dropdown-menu-radio-item-indicator"
</DropdownMenuPrimitive.ItemIndicator> >
<MenuPrimitive.RadioItemIndicator>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
</MenuPrimitive.RadioItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </MenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
) )
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { }: MenuPrimitive.Separator.Props) {
return ( return (
<DropdownMenuPrimitive.Separator <MenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)} className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
@@ -184,53 +243,7 @@ function DropdownMenuShortcut({
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", "ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className className
)} )}
{...props} {...props}

View File

@@ -0,0 +1,136 @@
"use client"
import * as React from "react"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { name: TName }
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = { id: string }
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div data-slot="form-item" className={cn("grid gap-2", className)} {...props} />
</FormItemContext.Provider>
)
}
function FormLabel({ className, ...props }: React.ComponentProps<"label">) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ children }: { children: React.ReactElement<React.HTMLAttributes<HTMLElement>> }) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return React.cloneElement(children, {
id: formItemId,
"aria-describedby": !error
? formDescriptionId
: `${formDescriptionId} ${formMessageId}`,
"aria-invalid": !!error,
})
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) return null
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -1,16 +1,15 @@
import * as React from "react" import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
<input <InputPrimitive
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30", "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",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className className
)} )}
{...props} {...props}

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
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 }

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { UnfoldMoreIcon, Tick02Icon, ArrowUp01Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
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
render={
<HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--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=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
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}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
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.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -1,23 +1,20 @@
"use client" "use client"
import * as React from "react" import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = "horizontal",
decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: SeparatorPrimitive.Props) {
return ( return (
<SeparatorPrimitive.Root <SeparatorPrimitive
data-slot="separator" data-slot="separator"
decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className className
)} )}
{...props} {...props}

View File

@@ -1,42 +1,35 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { XIcon } from "lucide-react" import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { Cancel01Icon } from "@hugeicons/core-free-icons"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />
} }
function SheetTrigger({ function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
} }
function SheetClose({ function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
} }
function SheetPortal({ function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
} }
function SheetOverlay({ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return ( return (
<SheetPrimitive.Overlay <SheetPrimitive.Backdrop
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 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 className
)} )}
{...props} {...props}
@@ -50,37 +43,39 @@ function SheetContent({
side = "right", side = "right",
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean showCloseButton?: boolean
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Popup
data-slot="sheet-content" data-slot="sheet-content"
data-side={side}
className={cn( className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500", "fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className className
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetPrimitive.Close
<XIcon className="size-4" /> data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
)} )}
</SheetPrimitive.Content> </SheetPrimitive.Popup>
</SheetPortal> </SheetPortal>
) )
} }
@@ -89,7 +84,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-header" data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-0.5 p-4", className)}
{...props} {...props}
/> />
) )
@@ -105,14 +100,11 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
) )
} }
function SheetTitle({ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return ( return (
<SheetPrimitive.Title <SheetPrimitive.Title
data-slot="sheet-title" data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)} className={cn("text-base font-medium text-foreground", className)}
{...props} {...props}
/> />
) )
@@ -121,7 +113,7 @@ function SheetTitle({
function SheetDescription({ function SheetDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) { }: SheetPrimitive.Description.Props) {
return ( return (
<SheetPrimitive.Description <SheetPrimitive.Description
data-slot="sheet-description" data-slot="sheet-description"

View File

@@ -1,9 +1,9 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -21,9 +21,10 @@ import { Skeleton } from "@/components/ui/skeleton"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { HugeiconsIcon } from "@hugeicons/react"
import { SidebarLeftIcon } from "@hugeicons/core-free-icons"
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
@@ -128,25 +129,23 @@ function SidebarProvider({
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}> <div
<div data-slot="sidebar-wrapper"
data-slot="sidebar-wrapper" style={
style={ {
{ "--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width": SIDEBAR_WIDTH, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON, ...style,
...style, } as React.CSSProperties
} as React.CSSProperties }
} className={cn(
className={cn( "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className
className )}
)} {...props}
{...props} >
> {children}
{children} </div>
</div>
</TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) )
} }
@@ -157,6 +156,7 @@ function Sidebar({
collapsible = "offcanvas", collapsible = "offcanvas",
className, className,
children, children,
dir,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
side?: "left" | "right" side?: "left" | "right"
@@ -184,6 +184,7 @@ function Sidebar({
return ( return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent <SheetContent
dir={dir}
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar" data-slot="sidebar"
data-mobile="true" data-mobile="true"
@@ -228,11 +229,9 @@ function Sidebar({
/> />
<div <div
data-slot="sidebar-container" data-slot="sidebar-container"
data-side={side}
className={cn( className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants. // Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
@@ -244,7 +243,7 @@ function Sidebar({
<div <div
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar-inner" data-slot="sidebar-inner"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm" className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
> >
{children} {children}
</div> </div>
@@ -265,15 +264,15 @@ function SidebarTrigger({
data-sidebar="trigger" data-sidebar="trigger"
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon-sm"
className={cn("size-7", className)} className={cn(className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event)
toggleSidebar() toggleSidebar()
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <HugeiconsIcon icon={SidebarLeftIcon} strokeWidth={2} />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) )
@@ -291,7 +290,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="Toggle Sidebar"
className={cn( className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex", "absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar", "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
@@ -309,8 +308,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
"relative flex w-full flex-1 flex-col bg-background", "relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className className
)} )}
{...props} {...props}
@@ -374,7 +372,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className className
)} )}
{...props} {...props}
@@ -395,46 +393,50 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
function SidebarGroupLabel({ function SidebarGroupLabel({
className, className,
asChild = false, render,
...props ...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) { }: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
const Comp = asChild ? Slot.Root : "div" return useRender({
defaultTagName: "div",
return ( props: mergeProps<"div">(
<Comp {
data-slot="sidebar-group-label" className: cn(
data-sidebar="group-label" "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className={cn( className
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", ),
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", },
className props
)} ),
{...props} render,
/> state: {
) slot: "sidebar-group-label",
sidebar: "group-label",
},
})
} }
function SidebarGroupAction({ function SidebarGroupAction({
className, className,
asChild = false, render,
...props ...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) { }: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
const Comp = asChild ? Slot.Root : "button" return useRender({
defaultTagName: "button",
return ( props: mergeProps<"button">(
<Comp {
data-slot="sidebar-group-action" className: cn(
data-sidebar="group-action" "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className={cn( className
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", ),
// Increases the hit area of the button on mobile. },
"after:absolute after:-inset-2 md:after:hidden", props
"group-data-[collapsible=icon]:hidden", ),
className render,
)} state: {
{...props} slot: "sidebar-group-action",
/> sidebar: "group-action",
) },
})
} }
function SidebarGroupContent({ function SidebarGroupContent({
@@ -456,7 +458,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul <ul
data-slot="sidebar-menu" data-slot="sidebar-menu"
data-sidebar="menu" data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)} className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props} {...props}
/> />
) )
@@ -474,7 +476,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{ {
variants: { variants: {
variant: { variant: {
@@ -496,34 +498,38 @@ const sidebarMenuButtonVariants = cva(
) )
function SidebarMenuButton({ function SidebarMenuButton({
asChild = false, render,
isActive = false, isActive = false,
variant = "default", variant = "default",
size = "default", size = "default",
tooltip, tooltip,
className, className,
...props ...props
}: React.ComponentProps<"button"> & { }: useRender.ComponentProps<"button"> &
asChild?: boolean React.ComponentProps<"button"> & {
isActive?: boolean isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar()
const comp = useRender({
const button = ( defaultTagName: "button",
<Comp props: mergeProps<"button">(
data-slot="sidebar-menu-button" {
data-sidebar="menu-button" className: cn(sidebarMenuButtonVariants({ variant, size }), className),
data-size={size} },
data-active={isActive} props
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} ),
{...props} render: !tooltip ? render : <TooltipTrigger render={render} />,
/> state: {
) slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) { if (!tooltip) {
return button return comp
} }
if (typeof tooltip === "string") { if (typeof tooltip === "string") {
@@ -534,7 +540,7 @@ function SidebarMenuButton({
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger> {comp}
<TooltipContent <TooltipContent
side="right" side="right"
align="center" align="center"
@@ -547,34 +553,32 @@ function SidebarMenuButton({
function SidebarMenuAction({ function SidebarMenuAction({
className, className,
asChild = false, render,
showOnHover = false, showOnHover = false,
...props ...props
}: React.ComponentProps<"button"> & { }: useRender.ComponentProps<"button"> &
asChild?: boolean React.ComponentProps<"button"> & {
showOnHover?: boolean showOnHover?: boolean
}) { }) {
const Comp = asChild ? Slot.Root : "button" return useRender({
defaultTagName: "button",
return ( props: mergeProps<"button">(
<Comp {
data-slot="sidebar-menu-action" className: cn(
data-sidebar="menu-action" "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className={cn( showOnHover &&
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
// Increases the hit area of the button on mobile. className
"after:absolute after:-inset-2 md:after:hidden", ),
"peer-data-[size=sm]/menu-button:top-1", },
"peer-data-[size=default]/menu-button:top-1.5", props
"peer-data-[size=lg]/menu-button:top-2.5", ),
"group-data-[collapsible=icon]:hidden", render,
showOnHover && state: {
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0", slot: "sidebar-menu-action",
className sidebar: "menu-action",
)} },
{...props} })
/>
)
} }
function SidebarMenuBadge({ function SidebarMenuBadge({
@@ -586,12 +590,7 @@ function SidebarMenuBadge({
data-slot="sidebar-menu-badge" data-slot="sidebar-menu-badge"
data-sidebar="menu-badge" data-sidebar="menu-badge"
className={cn( className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none", "pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className className
)} )}
{...props} {...props}
@@ -607,9 +606,9 @@ function SidebarMenuSkeleton({
showIcon?: boolean showIcon?: boolean
}) { }) {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`
}, []) })
return ( return (
<div <div
@@ -643,8 +642,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub" data-slot="sidebar-menu-sub"
data-sidebar="menu-sub" data-sidebar="menu-sub"
className={cn( className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
"group-data-[collapsible=icon]:hidden",
className className
)} )}
{...props} {...props}
@@ -667,35 +665,35 @@ function SidebarMenuSubItem({
} }
function SidebarMenuSubButton({ function SidebarMenuSubButton({
asChild = false, render,
size = "md", size = "md",
isActive = false, isActive = false,
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: useRender.ComponentProps<"a"> &
asChild?: boolean React.ComponentProps<"a"> & {
size?: "sm" | "md" size?: "sm" | "md"
isActive?: boolean isActive?: boolean
}) { }) {
const Comp = asChild ? Slot.Root : "a" return useRender({
defaultTagName: "a",
return ( props: mergeProps<"a">(
<Comp {
data-slot="sidebar-menu-sub-button" className: cn(
data-sidebar="menu-sub-button" "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
data-size={size} className
data-active={isActive} ),
className={cn( },
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", props
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", ),
size === "sm" && "text-xs", render,
size === "md" && "text-sm", state: {
"group-data-[collapsible=icon]:hidden", slot: "sidebar-menu-sub-button",
className sidebar: "menu-sub-button",
)} size,
{...props} active: isActive,
/> },
) })
} }
export { export {

View File

@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)} className={cn("animate-pulse rounded-md bg-muted", className)}
{...props} {...props}
/> />
) )

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 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 { Textarea }

View File

@@ -1,55 +1,64 @@
"use client" "use client"
import * as React from "react" import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delay = 0,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { }: TooltipPrimitive.Provider.Props) {
return ( return (
<TooltipPrimitive.Provider <TooltipPrimitive.Provider
data-slot="tooltip-provider" data-slot="tooltip-provider"
delayDuration={delayDuration} delay={delay}
{...props} {...props}
/> />
) )
} }
function Tooltip({ function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
} }
function TooltipTrigger({ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
} }
function TooltipContent({ function TooltipContent({
className, className,
sideOffset = 0, side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children, children,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { }: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Positioner
data-slot="tooltip-content" align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className="isolate z-50"
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
> >
{children} <TooltipPrimitive.Popup
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" /> data-slot="tooltip-content"
</TooltipPrimitive.Content> className={cn(
"z-50 w-fit max-w-xs origin-(--transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 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",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )
} }

View File

@@ -557,6 +557,14 @@ export const getById = internalQuery({
}, },
}); });
export const getByIdForAdmin = query({
args: { id: v.id("products") },
handler: async (ctx, { id }) => {
await Users.requireAdmin(ctx);
return getProductWithRelations(ctx, id);
},
});
/** /**
* One-time migration: backfill parentCategorySlug, childCategorySlug, topCategorySlug * One-time migration: backfill parentCategorySlug, childCategorySlug, topCategorySlug
* from categories. Run once after deploying Phase 1 schema. * from categories. Run once after deploying Phase 1 schema.
@@ -704,13 +712,27 @@ export const create = mutation({
name: v.string(), name: v.string(),
slug: v.string(), slug: v.string(),
description: v.optional(v.string()), description: v.optional(v.string()),
shortDescription: v.optional(v.string()),
status: v.union( status: v.union(
v.literal("active"), v.literal("active"),
v.literal("draft"), v.literal("draft"),
v.literal("archived"), v.literal("archived"),
), ),
categoryId: v.id("categories"), categoryId: v.id("categories"),
brand: v.optional(v.string()),
tags: v.array(v.string()), tags: v.array(v.string()),
attributes: v.optional(
v.object({
petSize: v.optional(v.array(v.string())),
ageRange: v.optional(v.array(v.string())),
specialDiet: v.optional(v.array(v.string())),
material: v.optional(v.string()),
flavor: v.optional(v.string()),
}),
),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
canonicalSlug: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await Users.requireAdmin(ctx); await Users.requireAdmin(ctx);
@@ -726,6 +748,8 @@ export const create = mutation({
parentCategorySlug, parentCategorySlug,
childCategorySlug, childCategorySlug,
...(topCategorySlug !== undefined && { topCategorySlug }), ...(topCategorySlug !== undefined && { topCategorySlug }),
createdAt: Date.now(),
updatedAt: Date.now(),
}); });
}, },
}); });
@@ -736,6 +760,7 @@ export const update = mutation({
name: v.optional(v.string()), name: v.optional(v.string()),
slug: v.optional(v.string()), slug: v.optional(v.string()),
description: v.optional(v.string()), description: v.optional(v.string()),
shortDescription: v.optional(v.string()),
status: v.optional( status: v.optional(
v.union( v.union(
v.literal("active"), v.literal("active"),
@@ -744,7 +769,20 @@ export const update = mutation({
), ),
), ),
categoryId: v.optional(v.id("categories")), categoryId: v.optional(v.id("categories")),
brand: v.optional(v.string()),
tags: v.optional(v.array(v.string())), tags: v.optional(v.array(v.string())),
attributes: v.optional(
v.object({
petSize: v.optional(v.array(v.string())),
ageRange: v.optional(v.array(v.string())),
specialDiet: v.optional(v.array(v.string())),
material: v.optional(v.string()),
flavor: v.optional(v.string()),
}),
),
seoTitle: v.optional(v.string()),
seoDescription: v.optional(v.string()),
canonicalSlug: v.optional(v.string()),
}, },
handler: async (ctx, { id, ...updates }) => { handler: async (ctx, { id, ...updates }) => {
await Users.requireAdmin(ctx); await Users.requireAdmin(ctx);
@@ -769,6 +807,8 @@ export const update = mutation({
} }
} }
fields.updatedAt = Date.now();
await ctx.db.patch(id, fields); await ctx.db.patch(id, fields);
return id; return id;
}, },

41
package-lock.json generated
View File

@@ -47,6 +47,7 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@clerk/nextjs": "^6.38.2", "@clerk/nextjs": "^6.38.2",
"@hookform/resolvers": "^5.2.2",
"@hugeicons/core-free-icons": "^3.3.0", "@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/react": "^1.1.5", "@hugeicons/react": "^1.1.5",
"@repo/convex": "*", "@repo/convex": "*",
@@ -56,7 +57,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"tailwind-merge": "^2.6.1" "react-hook-form": "^7.71.2",
"tailwind-merge": "^2.6.1",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
@@ -1769,6 +1772,18 @@
"hono": "^4" "hono": "^4"
} }
}, },
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@hugeicons/core-free-icons": { "node_modules/@hugeicons/core-free-icons": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.3.0.tgz", "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.3.0.tgz",
@@ -8522,6 +8537,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": { "node_modules/@stripe/react-stripe-js": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz",
@@ -15605,6 +15626,23 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.71.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -18161,7 +18199,6 @@
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {