Compare commits
5 Commits
fad2331aae
...
main
Author | SHA1 | Date | |
---|---|---|---|
d6624b1ff2 | |||
0110bf782b | |||
12fe9d63b4 | |||
eebd6a400b | |||
5ab5cfda9c |
@@ -1,12 +0,0 @@
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-svh">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Hello World</h1>
|
||||
<Button size="sm">Button</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,6 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@workspace/ui"],
|
||||
transpilePackages: [
|
||||
"@workspace/ui",
|
||||
"@workspace/db"
|
||||
],
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
@@ -4,20 +4,26 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"dev": "pnpm with-env next dev --turbopack",
|
||||
"build": "pnpm with-env next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@workspace/db": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "^15.2.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"nuqs": "^2.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
@@ -25,6 +31,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@workspace/eslint-config": "workspace:^",
|
||||
"@workspace/typescript-config": "workspace:*",
|
||||
"typescript": "^5.7.3"
|
||||
"dotenv-cli": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
7
apps/web/src/app/(public)/(website)/(home)/page.tsx
Normal file
7
apps/web/src/app/(public)/(website)/(home)/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<></>
|
||||
)
|
||||
}
|
14
apps/web/src/app/(public)/(website)/layout.tsx
Normal file
14
apps/web/src/app/(public)/(website)/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Header } from "@/components/layout/website/header";
|
||||
|
||||
export default async function WebsiteRootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -20,10 +20,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${fontSans.variable} ${fontMono.variable} font-sans antialiased `}
|
||||
>
|
||||
<Providers>{children}</Providers>
|
||||
<body className={`${fontSans.variable} ${fontMono.variable} font-sans antialiased `}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
100
apps/web/src/app/not-found.tsx
Normal file
100
apps/web/src/app/not-found.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Card, CardContent } from "@workspace/ui/components/card";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { ArrowLeft, BookOpen, Home, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function NotFound() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const router = useRouter()
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 404 Illustration */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="relative mb-8">
|
||||
<div className="text-9xl font-bold text-primary/20 select-none">404</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-32 h-32 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<BookOpen className="h-16 w-16 text-primary/60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-bold mb-4">Page Not Found</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
|
||||
Oops! The page you{"'"}re looking for seems to have wandered off into another dimension.
|
||||
Don{"'"}t worry, we{"'"}ll help you find your way back to amazing stories!
|
||||
</p>
|
||||
</div>
|
||||
{/* Search Section */}
|
||||
<Card className="mb-12">
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-semibold mb-2">Search for Stories</h2>
|
||||
<p className="text-muted-foreground">Looking for a specific novel? Try searching for it below.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="max-w-md mx-auto">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search novels, authors, genres..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-4 h-12 text-base"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-10"
|
||||
disabled={!searchQuery.trim()}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button onClick={() => router.back()} variant="outline" size="lg" className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<Link href="/">
|
||||
<Button size="lg" className="gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
Return Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Help Text */}
|
||||
<div className="text-center mt-12 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Still can{"'"}t find what you{"'"}re looking for?{" "}
|
||||
<Link href="/contact" className="text-primary hover:underline">
|
||||
Contact our support team
|
||||
</Link>{" "}
|
||||
for assistance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
79
apps/web/src/components/layout/website/header.tsx
Normal file
79
apps/web/src/components/layout/website/header.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ModeToggle } from "@/components/mode-theme";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { BookOpen, ChartNoAxesColumnIncreasing, Compass, LogIn, Pencil } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-orange-500 to-red-500 rounded-lg flex items-center justify-center">
|
||||
<BookOpen className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-orange-500 to-red-500 bg-clip-text text-transparent">
|
||||
Webnovel Fever
|
||||
</span>
|
||||
</Link>
|
||||
{/* Navigation Links */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/browse">
|
||||
<Compass className="inline w-4 h-4" />
|
||||
<span>Browse</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/ranking">
|
||||
<ChartNoAxesColumnIncreasing className="inline w-4 h-4 -rotate-90" />
|
||||
<span>Ranking</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/author">
|
||||
<Pencil className="inline w-4 h-4" />
|
||||
<span>Create</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
{/* Search Bar */}
|
||||
<div className="hidden md:flex items-center flex-1 max-w-md mx-8"></div>
|
||||
{/* Right Side Actions */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Theme Toggle */}
|
||||
<ModeToggle />
|
||||
{/* Creator Library Link */}
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/library">
|
||||
<BookOpen className="w-4 h-4 mr-1" />
|
||||
Library
|
||||
</Link>
|
||||
</Button>
|
||||
{/* User Profile or Sign In */}
|
||||
<>
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/sign-in">
|
||||
<LogIn className="w-4 h-4 mr-1" />
|
||||
Sign In
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href="/sign-up">
|
||||
<LogIn className="w-4 h-4 mr-1" />
|
||||
Sign Up
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
41
apps/web/src/components/mode-theme.tsx
Normal file
41
apps/web/src/components/mode-theme.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")} className="cursor-pointer">
|
||||
<Sun className="mr-1" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")} className="cursor-pointer">
|
||||
<Moon className="mr-1" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")} className="cursor-pointer">
|
||||
<Monitor className="mr-1" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -12,7 +13,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
>
|
||||
{children}
|
||||
<NuqsAdapter>
|
||||
{children}
|
||||
</NuqsAdapter>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
30
apps/web/src/env.ts
Normal file
30
apps/web/src/env.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your server-side environment variables schema here.
|
||||
* This way you can ensure the app isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
DATABASE_URL: z.url(),
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your client-side environment variables schema here.
|
||||
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
*/
|
||||
experimental__runtimeEnv: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
skipValidation:
|
||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||
});
|
@@ -3,8 +3,9 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@workspace/ui/*": ["../../packages/ui/src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@workspace/ui/*": ["../../packages/ui/src/*"],
|
||||
"@workspace/db/*": ["../../packages/db/src/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
15
docker-compose.yaml
Normal file
15
docker-compose.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: webnovelfever
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
db_data:
|
@@ -6,7 +6,9 @@
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev",
|
||||
"lint": "turbo lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\""
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"db:push": "turbo -F @workspace/db push",
|
||||
"db:studio": "turbo -F @workspace/db studio"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@workspace/eslint-config": "workspace:*",
|
||||
@@ -19,4 +21,4 @@
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
1
packages/db/.env.default
Normal file
1
packages/db/.env.default
Normal file
@@ -0,0 +1 @@
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/repo_development"
|
1
packages/db/README.md
Normal file
1
packages/db/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# `db`
|
11
packages/db/drizzle.config.ts
Normal file
11
packages/db/drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './src/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
36
packages/db/package.json
Normal file
36
packages/db/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@workspace/db",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"migrate": "pnpm with-env drizzle-kit migrate",
|
||||
"generate": "pnpm with-env drizzle-kit generate",
|
||||
"push": "pnpm with-env drizzle-kit push",
|
||||
"studio": "pnpm with-env drizzle-kit studio",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"pg": "^8.16.3",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.4",
|
||||
"@workspace/eslint-config": "workspace:*",
|
||||
"@workspace/typescript-config": "workspace:*",
|
||||
"dotenv-cli": "catalog:",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"eslint": "^9.20.1",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
12
packages/db/src/database.ts
Normal file
12
packages/db/src/database.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as schema from "./schema";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
|
||||
export const client = new Pool({
|
||||
connectionString: process.env.DATABASE_URL!,
|
||||
});
|
||||
|
||||
export const db = drizzle({
|
||||
client,
|
||||
schema,
|
||||
});
|
2
packages/db/src/index.ts
Normal file
2
packages/db/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./schema";
|
||||
export * from "./database";
|
33
packages/db/src/schema.ts
Normal file
33
packages/db/src/schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { pgTable, serial, varchar, integer, pgEnum, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
//#region Enums
|
||||
export const BookTypeEnum = pgEnum("book_type", ["novel", "fanfic"]);
|
||||
export const LeadingGenderEnum = pgEnum("leading_gender", ["male", "female"]);
|
||||
//#endregion Enums
|
||||
|
||||
//#region Genre
|
||||
export const Genre = pgTable("genres", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
description: varchar("description").notNull(),
|
||||
});
|
||||
export const GenreTranslation = pgTable("genre_translations", {
|
||||
id: integer("id"),
|
||||
locale: varchar("locale").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
description: varchar("description"),
|
||||
}, (t) => ([
|
||||
primaryKey({ columns: [t.id, t.locale] }),
|
||||
]));
|
||||
// Relations
|
||||
export const GenreRelations = relations(Genre, ({ many }) => ({
|
||||
translations: many(GenreTranslation)
|
||||
}));
|
||||
export const GenreTranslationRelations = relations(GenreTranslation, ({ one }) => ({
|
||||
genre: one(Genre, {
|
||||
fields: [GenreTranslation.id],
|
||||
references: [Genre.id],
|
||||
}),
|
||||
}));
|
||||
//#endregion Genre
|
11
packages/db/tsconfig.json
Normal file
11
packages/db/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@workspace/typescript-config/nextjs.json",
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
@@ -19,7 +19,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-turbo": "^2.4.2",
|
||||
"globals": "^15.15.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "^8.24.1"
|
||||
}
|
||||
}
|
||||
}
|
@@ -7,6 +7,7 @@
|
||||
"lint": "eslint . --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -16,7 +17,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tw-animate-css": "^1.2.4",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.8",
|
||||
@@ -27,7 +28,7 @@
|
||||
"@workspace/eslint-config": "workspace:*",
|
||||
"@workspace/typescript-config": "workspace:*",
|
||||
"tailwindcss": "^4.0.8",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"exports": {
|
||||
"./globals.css": "./src/styles/globals.css",
|
||||
@@ -36,4 +37,4 @@
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./hooks/*": "./src/hooks/*.ts"
|
||||
}
|
||||
}
|
||||
}
|
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
92
packages/ui/src/components/card.tsx
Normal file
92
packages/ui/src/components/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
257
packages/ui/src/components/dropdown-menu.tsx
Normal file
257
packages/ui/src/components/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]: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 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 p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.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({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
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(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
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(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]: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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
21
packages/ui/src/components/input.tsx
Normal file
21
packages/ui/src/components/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
24
packages/ui/src/components/label.tsx
Normal file
24
packages/ui/src/components/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
2327
pnpm-lock.yaml
generated
2327
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
catalog:
|
||||
typescript: ^5.7.3
|
||||
zod: ^3.25.1
|
||||
dotenv-cli: ^9.0.0
|
||||
|
32
turbo.json
32
turbo.json
@@ -3,19 +3,39 @@
|
||||
"ui": "tui",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"inputs": [
|
||||
"$TURBO_DEFAULT$",
|
||||
".env*"
|
||||
],
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^lint"]
|
||||
"dependsOn": [
|
||||
"^lint"
|
||||
]
|
||||
},
|
||||
"check-types": {
|
||||
"dependsOn": ["^check-types"]
|
||||
"dependsOn": [
|
||||
"^check-types"
|
||||
]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"studio": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"push": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user