Compare commits

..

4 Commits

31 changed files with 2937 additions and 47 deletions

View File

@@ -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>
)
}

View File

@@ -1,6 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@workspace/ui"],
transpilePackages: [
"@workspace/ui",
"@workspace/db"
],
}
export default nextConfig

View File

@@ -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:"
}
}

View File

@@ -0,0 +1,7 @@
import { Button } from "@workspace/ui/components/button"
export default async function Page() {
return (
<></>
)
}

View 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}
</>
);
}

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -20,10 +20,10 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${fontSans.variable} ${fontMono.variable} font-sans antialiased `}
>
<body className={`${fontSans.variable} ${fontMono.variable} font-sans antialiased `}>
<div className="min-h-screen flex flex-col">
<Providers>{children}</Providers>
</div>
</body>
</html>
)

View 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>
);
}

View 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>
)
}

View File

@@ -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
>
<NuqsAdapter>
{children}
</NuqsAdapter>
</NextThemesProvider>
)
}

30
apps/web/src/env.ts Normal file
View 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",
});

View File

@@ -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
View 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:

View File

@@ -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:*",

1
packages/db/.env.default Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/repo_development"

1
packages/db/README.md Normal file
View File

@@ -0,0 +1 @@
# `db`

View 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
View 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:"
}
}

View 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
View File

@@ -0,0 +1,2 @@
export * from "./schema";
export * from "./database";

33
packages/db/src/schema.ts Normal file
View 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
View File

@@ -0,0 +1,11 @@
{
"extends": "@workspace/typescript-config/nextjs.json",
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -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"
}
}

View File

@@ -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",

View 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,
}

2327
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
packages:
- "apps/*"
- "packages/*"
catalog:
typescript: ^5.7.3
zod: ^3.25.1
dotenv-cli: ^9.0.0

View File

@@ -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
}
}
}