diff --git a/package-lock.json b/package-lock.json index 3928c10..f0a42f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "class-variance-authority": "^0.7.1", "client-only": "^0.0.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "lucide-react": "^1.17.0", "next": "16.2.7", "next-themes": "^0.4.6", @@ -33,6 +34,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.77.0", + "react-intersection-observer": "^10.0.3", "server-only": "^0.0.1", "shadcn": "^4.10.0", "sonner": "^2.0.7", @@ -6869,6 +6871,34 @@ "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -11517,6 +11547,21 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-intersection-observer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.3.tgz", + "integrity": "sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 8d4d976..02c71f1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.1", "client-only": "^0.0.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "lucide-react": "^1.17.0", "next": "16.2.7", "next-themes": "^0.4.6", @@ -37,6 +38,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.77.0", + "react-intersection-observer": "^10.0.3", "server-only": "^0.0.1", "shadcn": "^4.10.0", "sonner": "^2.0.7", diff --git a/src/__tests__/blocks.test.ts b/src/__tests__/blocks.test.ts new file mode 100644 index 0000000..dc6a592 --- /dev/null +++ b/src/__tests__/blocks.test.ts @@ -0,0 +1,32 @@ +/** + * Blocks Unit Tests — Phase 2 Plan 02-01 (scaffold) + * + * Covers: SAFE-01 + * + * Test: block filters feed — blocks.create inserts Block; blocked pet excluded from feed + * + * All tests are it.todo stubs until Wave 1 implementation. + */ + +import { describe, it } from "vitest"; + +// Mock server-only guard +vi.mock("server-only", () => ({})); + +// Mock Clerk server auth +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), +})); + +// Mock prisma singleton +vi.mock("@/lib/prisma", () => ({ + prisma: {}, +})); + +describe("blocks.create", () => { + it.todo("SAFE-01 — block creates a Block row with correct blockerPetId and blockedPetId"); +}); + +describe("blocks — feed filtering", () => { + it.todo("SAFE-01 — blocked pet's posts are excluded from feed.getFeed hydration"); +}); diff --git a/src/__tests__/feed.test.ts b/src/__tests__/feed.test.ts new file mode 100644 index 0000000..de9b66a --- /dev/null +++ b/src/__tests__/feed.test.ts @@ -0,0 +1,47 @@ +/** + * Feed Unit Tests — Phase 2 Plan 02-01 (scaffold) + * + * Covers: FEED-01 + * + * Test: empty feed — feed.getFeed returns empty for pet with no follows + * Test: cursor pagination — feed.getFeed returns nextCursor when hasNextPage + * + * Redis is mocked via vi.mock('@/lib/redis') — no real Upstash calls in tests. + * All tests are it.todo stubs until Wave 1 implementation. + */ + +import { describe, it } from "vitest"; + +// Mock server-only guard +vi.mock("server-only", () => ({})); + +// Mock Redis — no real Upstash calls in unit tests +vi.mock("@/lib/redis", () => ({ + redis: { + zrange: vi.fn(), + zadd: vi.fn(), + pipeline: vi.fn(() => ({ + zadd: vi.fn(), + exec: vi.fn().mockResolvedValue([]), + })), + zremrangebyscore: vi.fn(), + }, +})); + +// Mock Clerk server auth +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), +})); + +// Mock prisma singleton +vi.mock("@/lib/prisma", () => ({ + prisma: {}, +})); + +describe("feed.getFeed", () => { + it.todo("FEED-01 — empty feed returns empty posts array and null nextCursor for pet with no follows"); + + it.todo("FEED-01 — cursor pagination returns nextCursor when more results available"); + + it.todo("FEED-01 — blocked pets are excluded from feed hydration"); +}); diff --git a/src/__tests__/follows.test.ts b/src/__tests__/follows.test.ts new file mode 100644 index 0000000..29d420f --- /dev/null +++ b/src/__tests__/follows.test.ts @@ -0,0 +1,42 @@ +/** + * Follows Unit Tests — Phase 2 Plan 02-01 (scaffold) + * + * Covers: SOCL-01, SOCL-02, SOCL-03 + * + * Test: follow creates row — follows.create inserts Follow row (SOCL-01) + * Test: unfollow removes row — follows.delete removes Follow row (SOCL-02) + * Test: listFollowers scoped — follows.listFollowers scoped by petId (SOCL-03) + * + * All tests are it.todo stubs until Wave 1 implementation. + */ + +import { describe, it } from "vitest"; + +// Mock server-only guard +vi.mock("server-only", () => ({})); + +// Mock Clerk server auth +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), +})); + +// Mock prisma singleton +vi.mock("@/lib/prisma", () => ({ + prisma: {}, +})); + +describe("follows.create", () => { + it.todo("SOCL-01 — follow creates a Follow row in the DB"); +}); + +describe("follows.delete", () => { + it.todo("SOCL-02 — unfollow removes the Follow row from the DB"); +}); + +describe("follows.listFollowers", () => { + it.todo("SOCL-03 — listFollowers returns only followers for the given petId"); +}); + +describe("follows.listFollowing", () => { + it.todo("SOCL-03 — listFollowing returns only pets followed by the given petId"); +}); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 001b4b3..66189ac 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -8,6 +8,8 @@ import { vi } from "vitest"; * import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; * const mockPrisma = createMockPrisma(); * mockPrisma.pet.create.mockResolvedValue({ ... }); + * + * Phase 2 additions: post, postImage, milestone, story, storyView, follow, block, report */ type MockFn = ReturnType; @@ -18,6 +20,7 @@ interface MockPetClient { findUnique: MockFn; update: MockFn; delete: MockFn; + count: MockFn; } interface MockOwnerClient { @@ -38,11 +41,92 @@ interface MockBreedClient { upsert: MockFn; } +// ─── Phase 2 mock interfaces ────────────────────────────────────────────────── + +interface MockPostClient { + create: MockFn; + findMany: MockFn; + findUnique: MockFn; + update: MockFn; + delete: MockFn; + count: MockFn; +} + +interface MockPostImageClient { + create: MockFn; + findMany: MockFn; + findUnique: MockFn; + update: MockFn; + delete: MockFn; + deleteMany: MockFn; +} + +interface MockMilestoneClient { + create: MockFn; + findUnique: MockFn; + findMany: MockFn; + update: MockFn; + delete: MockFn; +} + +interface MockStoryClient { + create: MockFn; + findMany: MockFn; + findUnique: MockFn; + update: MockFn; + delete: MockFn; + count: MockFn; + deleteMany: MockFn; +} + +interface MockStoryViewClient { + upsert: MockFn; + findMany: MockFn; + findUnique: MockFn; + create: MockFn; + delete: MockFn; + deleteMany: MockFn; + count: MockFn; +} + +interface MockFollowClient { + create: MockFn; + findMany: MockFn; + findUnique: MockFn; + delete: MockFn; + count: MockFn; + upsert: MockFn; +} + +interface MockBlockClient { + create: MockFn; + findMany: MockFn; + findUnique: MockFn; + delete: MockFn; + count: MockFn; +} + +interface MockReportClient { + create: MockFn; + findMany: MockFn; + findUnique: MockFn; + count: MockFn; +} + interface MockPrismaClient { pet: MockPetClient; owner: MockOwnerClient; species: MockSpeciesClient; breed: MockBreedClient; + // Phase 2 + post: MockPostClient; + postImage: MockPostImageClient; + milestone: MockMilestoneClient; + story: MockStoryClient; + storyView: MockStoryViewClient; + follow: MockFollowClient; + block: MockBlockClient; + report: MockReportClient; $transaction: MockFn; $disconnect: MockFn; } @@ -55,6 +139,7 @@ export function createMockPrisma(): MockPrismaClient { findUnique: vi.fn(), update: vi.fn(), delete: vi.fn(), + count: vi.fn(), }, owner: { upsert: vi.fn(), @@ -71,6 +156,69 @@ export function createMockPrisma(): MockPrismaClient { findUnique: vi.fn(), upsert: vi.fn(), }, + // Phase 2 mocks + post: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + postImage: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + }, + milestone: { + create: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + story: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + deleteMany: vi.fn(), + }, + storyView: { + upsert: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + count: vi.fn(), + }, + follow: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + upsert: vi.fn(), + }, + block: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + report: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + count: vi.fn(), + }, $transaction: vi.fn(), $disconnect: vi.fn(), }; diff --git a/src/__tests__/milestones.test.ts b/src/__tests__/milestones.test.ts new file mode 100644 index 0000000..8f6b1be --- /dev/null +++ b/src/__tests__/milestones.test.ts @@ -0,0 +1,30 @@ +/** + * Milestones Unit Tests — Phase 2 Plan 02-01 (scaffold) + * + * Covers: MILE-01 + * + * Test: invalid type — milestones.create rejects invalid milestone type (MILE-01) + * + * All tests are it.todo stubs until Wave 1 implementation. + */ + +import { describe, it } from "vitest"; + +// Mock server-only guard +vi.mock("server-only", () => ({})); + +// Mock Clerk server auth +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), +})); + +// Mock prisma singleton +vi.mock("@/lib/prisma", () => ({ + prisma: {}, +})); + +describe("milestones.create", () => { + it.todo("MILE-01 — invalid milestone type (not in MilestoneType enum) returns BAD_REQUEST"); + + it.todo("MILE-01 — valid milestone type creates Post + Milestone row"); +}); diff --git a/src/__tests__/posts.test.ts b/src/__tests__/posts.test.ts new file mode 100644 index 0000000..3c65da1 --- /dev/null +++ b/src/__tests__/posts.test.ts @@ -0,0 +1,42 @@ +/** + * Posts Unit Tests — Phase 2 Plan 02-01 (scaffold) + * + * Covers: PHOTO-01, PHOTO-02, PHOTO-03, PHOTO-04 + * + * Test: create single photo (PHOTO-01) + * Test: carousel limit — rejects >10 images (PHOTO-02) + * Test: updateCaption forbidden — non-owner cannot edit (PHOTO-03) + * Test: delete cascades PostImages (PHOTO-04) + * + * All tests are it.todo stubs until Wave 1 implementation. + * No real DB or Redis calls — all mocked. + */ + +import { describe, it } from "vitest"; + +// Mock server-only guard +vi.mock("server-only", () => ({})); + +// Mock Clerk server auth +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), +})); + +// Mock prisma singleton +vi.mock("@/lib/prisma", () => ({ + prisma: {}, +})); + +describe("posts.create", () => { + it.todo("PHOTO-01 — create single photo inserts Post + PostImage with correct petId"); + + it.todo("PHOTO-02 — carousel limit rejects >10 images with BAD_REQUEST"); +}); + +describe("posts.updateCaption", () => { + it.todo("PHOTO-03 — updateCaption forbidden for non-owner returns FORBIDDEN"); +}); + +describe("posts.delete", () => { + it.todo("PHOTO-04 — delete cascades PostImages and removes post from feed"); +}); diff --git a/src/__tests__/reports.test.ts b/src/__tests__/reports.test.ts new file mode 100644 index 0000000..97a5e80 --- /dev/null +++ b/src/__tests__/reports.test.ts @@ -0,0 +1,32 @@ +/** + * Reports Unit Tests — Phase 2 Plan 02-01 (scaffold) + * + * Covers: SAFE-02 + * + * Test: report creates row — reports.create inserts Report with reason (SAFE-02) + * + * All tests are it.todo stubs until Wave 1 implementation. + */ + +import { describe, it } from "vitest"; + +// Mock server-only guard +vi.mock("server-only", () => ({})); + +// Mock Clerk server auth +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), +})); + +// Mock prisma singleton +vi.mock("@/lib/prisma", () => ({ + prisma: {}, +})); + +describe("reports.create", () => { + it.todo("SAFE-02 — report creates a Report row with reporterPetId, reason, and optional notes"); + + it.todo("SAFE-02 — report with invalid reason (not in ReportReason enum) returns BAD_REQUEST"); + + it.todo("SAFE-02 — report without targetPostId or targetPetId is accepted (either is optional)"); +}); diff --git a/src/__tests__/stories.test.ts b/src/__tests__/stories.test.ts new file mode 100644 index 0000000..5e992da --- /dev/null +++ b/src/__tests__/stories.test.ts @@ -0,0 +1,38 @@ +/** + * Stories Unit Tests — Phase 2 Plan 02-01 (scaffold) + * + * Covers: STORY-01, STORY-02, STORY-03 + * + * Test: expiresAt 24h — story.create sets expiresAt = now + 24h (STORY-01) + * Test: hasActiveStory — filters by expiresAt > now (STORY-02) + * Test: recordView dedup — upsert deduplicates by (storyId, viewerPetId) (STORY-03) + * + * All tests are it.todo stubs until Wave 1 implementation. + */ + +import { describe, it } from "vitest"; + +// Mock server-only guard +vi.mock("server-only", () => ({})); + +// Mock Clerk server auth +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), +})); + +// Mock prisma singleton +vi.mock("@/lib/prisma", () => ({ + prisma: {}, +})); + +describe("stories.create", () => { + it.todo("STORY-01 — expiresAt set to approximately now + 24h on story creation"); +}); + +describe("stories.hasActiveStory", () => { + it.todo("STORY-02 — hasActiveStory filters stories where expiresAt > now"); +}); + +describe("stories.recordView", () => { + it.todo("STORY-03 — recordView deduplicates: second view for same (storyId, viewerPetId) is an upsert"); +}); diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0ee2c5f --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -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 +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..0b73c6b --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" + +import { cn } from "@/lib/utils" + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + className, + align = "center", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ( + + ) +} + +function PopoverDescription({ + className, + ...props +}: PopoverPrimitive.Description.Props) { + return ( + + ) +} + +export { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..3172cf3 --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,38 @@ +"use client" + +import { Radio as RadioPrimitive } from "@base-ui/react/radio" +import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group" + +import { cn } from "@/lib/utils" + +function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) { + return ( + + ) +} + +function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..84c1e9f --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -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 ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: ScrollAreaPrimitive.Scrollbar.Props) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..78c0a76 --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,138 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..8ee8054 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,82 @@ +"use client" + +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: TabsPrimitive.Root.Props) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }