feat: add MTR display board and train info components
Some checks failed
Build and Push Container Image, Deploy to Host / build-and-push-and-deploy (push) Has been cancelled

- Implemented MtrDisplayBoard component to show train schedules with weather information.
- Created MtrTrainInfo component to manage state and render MtrDisplayBoard.
- Added ScreenshotTaker component for downloading screenshots of the display board.
- Introduced theme toggle functionality with ThemeToggle component.
- Developed various UI components including Button, Card, ColorPicker, DropdownMenu, Input, Label, Popover, Select, Separator, Switch, Tabs, and Textarea.
- Added utility functions for class name merging and tool definitions.
This commit is contained in:
2025-08-14 01:31:45 +08:00
parent 7177efa6a5
commit 330cd84beb
32 changed files with 3078 additions and 122 deletions

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -9,19 +9,35 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"html-to-image": "^1.11.13",
"lucide-react": "^0.539.0",
"next": "15.4.6",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "19.1.0",
"next": "15.4.6"
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.4.6",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4",
"tw-animate-css": "^1.3.6",
"typescript": "^5"
}
}

977
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -0,0 +1,76 @@
"use client";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
import Link from "next/link";
export default function JsonFormatterPage() {
const [input, setInput] = useState("");
const [output, setOutput] = useState("");
const [error, setError] = useState("");
const formatJson = () => {
try {
const formatted = JSON.stringify(JSON.parse(input), null, 2);
setOutput(formatted);
setError("");
} catch (e: any) {
setError("Invalid JSON: " + e.message);
setOutput("");
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(output);
};
const clearText = () => {
setInput("");
setOutput("");
setError("");
};
return (
<div className="container mx-auto p-4">
<header className="mb-8">
<Link href="/" className="text-blue-500 hover:underline">
&larr; Back to Home
</Link>
<h1 className="text-4xl font-bold mt-4">JSON Formatter</h1>
</header>
<main className="grid md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="json-input">Input</label>
<Textarea
id="json-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Paste your JSON here"
className="h-96"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="json-output">Output</label>
<Textarea
id="json-output"
value={output}
readOnly
placeholder="Formatted JSON will appear here"
className="h-96"
/>
</div>
</main>
<footer className="mt-4 flex gap-2">
<Button onClick={formatJson}>Format JSON</Button>
<Button onClick={copyToClipboard} disabled={!output}>
Copy to Clipboard
</Button>
<Button onClick={clearText} variant="destructive">
Clear
</Button>
</footer>
{error && <p className="text-red-500 mt-4">{error}</p>}
</div>
);
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "next-themes";
import { Navbar } from "@/components/nav";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Web Tools",
description: "A collection of useful web tools.",
};
export default function RootLayout({
@ -23,11 +25,21 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="relative flex min-h-screen flex-col">
<Navbar />
<div className="flex-1">{children}</div>
</div>
</ThemeProvider>
</body>
</html>
);

View File

@ -1,103 +1,33 @@
import Image from "next/image";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { tools } from "@/lib/tools";
import Link from "next/link";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<main className="container mx-auto p-4 sm:p-6 md:p-8">
<header className="my-8">
<h1 className="text-4xl font-bold tracking-tight lg:text-5xl">
Web Tools
</h1>
</header>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tools.map((tool) => (
<Link href={tool.href} key={tool.href} passHref>
<Card className="h-full hover:bg-muted/50 transition-colors">
<CardHeader>
<CardTitle>{tool.title}</CardTitle>
<CardDescription>{tool.description}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</main>
);
}

View File

@ -0,0 +1,296 @@
"use client";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BoardData, TrainType } from "./types";
interface BoardFormProps {
data: BoardData;
setData: (data: BoardData) => void;
}
export function BoardForm({ data, setData }: BoardFormProps) {
const handleStatusBarChange = (field: string, value: string) => {
setData({ ...data, statusBar: { ...data.statusBar, [field]: value } });
};
const handleTrainChange = (index: number, field: string, value: any) => {
const newTrains = [...data.trains];
const trainToUpdate = { ...newTrains[index] };
if (field === "destination.en" || field === "destination.cn") {
const [, subkey] = field.split(".");
(trainToUpdate.destination as any)[subkey] = value;
} else {
(trainToUpdate as any)[field] = value;
}
newTrains[index] = trainToUpdate;
setData({ ...data, trains: newTrains });
};
const handleThemeChange = (field: string, value: string) => {
setData({ ...data, theme: { ...data.theme, [field]: value } });
};
const handleLangChange = (checked: boolean) => {
setData({ ...data, lang: checked ? "cn" : "en" });
};
return (
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle>Board Configuration</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="statusbar">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="statusbar">Status Bar</TabsTrigger>
<TabsTrigger value="trains">Train Info</TabsTrigger>
<TabsTrigger value="theme">Theme</TabsTrigger>
</TabsList>
{/* Status Bar Tab */}
<TabsContent value="statusbar" className="mt-4">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="weather">Weather</Label>
<Select
value={data.statusBar.weather}
onValueChange={(value) =>
handleStatusBarChange("weather", value)
}
>
<SelectTrigger id="weather">
<SelectValue placeholder="Select weather" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sunny">Sunny</SelectItem>
<SelectItem value="cloudy">Cloudy</SelectItem>
<SelectItem value="rainy">Rainy</SelectItem>
<SelectItem value="stormy">Stormy</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="temperature">Temperature (°C)</Label>
<Input
id="temperature"
type="number"
value={data.statusBar.temperature}
onChange={(e) =>
handleStatusBarChange("temperature", e.target.value)
}
/>
</div>
</div>
<div>
<Label htmlFor="time">Time</Label>
<Input
id="time"
type="text"
value={data.statusBar.time}
onChange={(e) => handleStatusBarChange("time", e.target.value)}
/>
</div>
</div>
</TabsContent>
{/* Trains Tab */}
<TabsContent value="trains" className="mt-4">
<div className="space-y-6">
{data.trains.map((train, index) => (
<div key={index}>
<Label className="text-lg font-semibold">
Train {index + 1}
</Label>
<div className="grid grid-cols-2 gap-4 mt-2">
<div>
<Label htmlFor={`dest-en-${index}`}>
Destination (EN)
</Label>
<Input
id={`dest-en-${index}`}
value={train.destination.en}
onChange={(e) =>
handleTrainChange(
index,
"destination.en",
e.target.value
)
}
/>
</div>
<div>
<Label htmlFor={`dest-cn-${index}`}>
Destination (CN)
</Label>
<Input
id={`dest-cn-${index}`}
value={train.destination.cn}
onChange={(e) =>
handleTrainChange(
index,
"destination.cn",
e.target.value
)
}
/>
</div>
<div>
<Label htmlFor={`platform-${index}`}>Platform</Label>
<Input
id={`platform-${index}`}
value={train.platform}
onChange={(e) =>
handleTrainChange(index, "platform", e.target.value)
}
/>
</div>
<div>
<Label htmlFor={`arrival-${index}`}>Arrival</Label>
<Input
id={`arrival-${index}`}
value={train.arrival}
onChange={(e) =>
handleTrainChange(index, "arrival", e.target.value)
}
/>
</div>
<div>
<Label htmlFor={`type-${index}`}>Type</Label>
<Select
value={train.type}
onValueChange={(value: TrainType) =>
handleTrainChange(index, "type", value)
}
>
<SelectTrigger id={`type-${index}`}>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="rapid">Rapid</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="through">Through</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{index < data.trains.length - 1 && (
<Separator className="mt-6" />
)}
</div>
))}
</div>
</TabsContent>
{/* Theme Tab */}
<TabsContent value="theme" className="mt-4">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="statusbar-color">Status Bar Color</Label>
<Input
id="statusbar-color"
type="color"
value={data.theme.statusBarColor}
onChange={(e) =>
handleThemeChange("statusBarColor", e.target.value)
}
/>
</div>
<div>
<Label htmlFor="text-color">Text Color</Label>
<Input
id="text-color"
type="color"
value={data.theme.textColor}
onChange={(e) =>
handleThemeChange("textColor", e.target.value)
}
/>
</div>
<div>
<Label htmlFor="odd-row-color">Odd Row Color</Label>
<Input
id="odd-row-color"
type="color"
value={data.theme.oddRowColor}
onChange={(e) =>
handleThemeChange("oddRowColor", e.target.value)
}
/>
</div>
<div>
<Label htmlFor="even-row-color">Even Row Color</Label>
<Input
id="even-row-color"
type="color"
value={data.theme.evenRowColor}
onChange={(e) =>
handleThemeChange("evenRowColor", e.target.value)
}
/>
</div>
</div>
<Separator className="my-4" />
<Label className="text-lg font-semibold">Train Type Colors</Label>
<div className="grid grid-cols-2 gap-4">
{(Object.keys(data.theme.trainTypeColors) as TrainType[]).map(
(type) => (
<div key={type}>
<Label htmlFor={`color-${type}`} className="capitalize">
{type}
</Label>
<Input
id={`color-${type}`}
type="color"
value={data.theme.trainTypeColors[type]}
onChange={(e) => {
const newColors = {
...data.theme.trainTypeColors,
[type]: e.target.value,
};
handleThemeChange("trainTypeColors", newColors as any);
}}
/>
</div>
)
)}
</div>
<Separator className="my-4" />
<div className="flex items-center space-x-2 mt-4">
<Switch
id="language-switch"
checked={data.lang === "cn"}
onCheckedChange={handleLangChange}
/>
<Label htmlFor="language-switch">
Display Chinese ()
</Label>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View File

View File

@ -1,5 +1,17 @@
export default function Home() {
import { MtrTrainInfo } from "@/components/rt-guide/mtr-train-info";
export default function RtGuidePage() {
return (
<></>
<div className="container mx-auto p-4">
<header className="text-center my-8">
<h1 className="text-4xl font-bold">Rail Transit Guide Generator</h1>
<p className="text-muted-foreground">
Customize the board and download it as an image.
</p>
</header>
<main>
<MtrTrainInfo />
</main>
</div>
);
}

35
src/app/rt-guide/types.ts Normal file
View File

@ -0,0 +1,35 @@
export type TrainType = "local" | "express" | "rapid" | "through";
export interface TrainInfo {
destination: {
en: string;
cn: string;
};
platform: string;
type: TrainType;
arrival: string;
}
export interface StatusBarInfo {
weather: "sunny" | "cloudy" | "rainy" | "stormy";
temperature: string;
time: string;
}
export interface ThemeInfo {
statusBarColor: string;
statusBarTextColor: string;
oddRowColor: string;
evenRowColor: string;
textColor: string;
platformBackgroundColor: string;
platformTextColor: string;
trainTypeColors: Record<TrainType, string>;
}
export interface BoardData {
statusBar: StatusBarInfo;
trains: TrainInfo[];
theme: ThemeInfo;
lang: "en" | "cn";
}

20
src/components/nav.tsx Normal file
View File

@ -0,0 +1,20 @@
import Link from "next/link";
import { ThemeToggle } from "./theme-toggle";
export function Navbar() {
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 flex h-14 px-4 items-center">
<div className="flex-1" />
<div className="flex-1 text-center">
<Link href="/" className="font-bold">
Web Tools
</Link>
</div>
<div className="flex flex-1 justify-end">
<ThemeToggle />
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,286 @@
"use client";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColorPicker } from "@/components/ui/color-picker";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BoardData, TrainType } from "@/app/rt-guide/types";
interface MtrBoardFormProps {
data: BoardData;
setData: (data: BoardData) => void;
}
export function MtrBoardForm({ data, setData }: MtrBoardFormProps) {
const handleStatusBarChange = (field: string, value: string) => {
setData({ ...data, statusBar: { ...data.statusBar, [field]: value } });
};
const handleTrainChange = (index: number, field: string, value: any) => {
const newTrains = [...data.trains];
const trainToUpdate = { ...newTrains[index] };
if (field === "destination.en" || field === "destination.cn") {
const [, subkey] = field.split(".");
(trainToUpdate.destination as any)[subkey] = value;
} else {
(trainToUpdate as any)[field] = value;
}
newTrains[index] = trainToUpdate;
setData({ ...data, trains: newTrains });
};
const handleThemeChange = (field: string, value: any) => {
setData({ ...data, theme: { ...data.theme, [field]: value } });
};
const handleLangChange = (checked: boolean) => {
setData({ ...data, lang: checked ? "cn" : "en" });
};
return (
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle>Board Configuration</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="statusbar">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="statusbar">Status Bar</TabsTrigger>
<TabsTrigger value="trains">Train Info</TabsTrigger>
<TabsTrigger value="theme">Theme</TabsTrigger>
</TabsList>
{/* Status Bar Tab */}
<TabsContent value="statusbar" className="mt-4">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="weather">Weather</Label>
<Select
value={data.statusBar.weather}
onValueChange={(value) =>
handleStatusBarChange("weather", value)
}
>
<SelectTrigger id="weather">
<SelectValue placeholder="Select weather" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sunny">Sunny</SelectItem>
<SelectItem value="cloudy">Cloudy</SelectItem>
<SelectItem value="rainy">Rainy</SelectItem>
<SelectItem value="stormy">Stormy</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="temperature">Temperature (°C)</Label>
<Input
id="temperature"
type="number"
value={data.statusBar.temperature}
onChange={(e) =>
handleStatusBarChange("temperature", e.target.value)
}
/>
</div>
</div>
<div>
<Label htmlFor="time">Time</Label>
<Input
id="time"
type="text"
value={data.statusBar.time}
onChange={(e) => handleStatusBarChange("time", e.target.value)}
/>
</div>
</div>
</TabsContent>
{/* Trains Tab */}
<TabsContent value="trains" className="mt-4">
<div className="space-y-6">
{data.trains.map((train, index) => (
<div key={index}>
<Label className="text-lg font-semibold">
Train {index + 1}
</Label>
<div className="grid grid-cols-2 gap-4 mt-2">
<div>
<Label htmlFor={`dest-en-${index}`}>
Destination (EN)
</Label>
<Input
id={`dest-en-${index}`}
value={train.destination.en}
onChange={(e) =>
handleTrainChange(
index,
"destination.en",
e.target.value
)
}
/>
</div>
<div>
<Label htmlFor={`dest-cn-${index}`}>
Destination (CN)
</Label>
<Input
id={`dest-cn-${index}`}
value={train.destination.cn}
onChange={(e) =>
handleTrainChange(
index,
"destination.cn",
e.target.value
)
}
/>
</div>
<div>
<Label htmlFor={`platform-${index}`}>Platform</Label>
<Input
id={`platform-${index}`}
value={train.platform}
onChange={(e) =>
handleTrainChange(index, "platform", e.target.value)
}
/>
</div>
<div>
<Label htmlFor={`arrival-${index}`}>Arrival</Label>
<Input
id={`arrival-${index}`}
value={train.arrival}
onChange={(e) =>
handleTrainChange(index, "arrival", e.target.value)
}
/>
</div>
<div>
<Label htmlFor={`type-${index}`}>Type</Label>
<Select
value={train.type}
onValueChange={(value: TrainType) =>
handleTrainChange(index, "type", value)
}
>
<SelectTrigger id={`type-${index}`}>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="rapid">Rapid</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="through">Through</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{index < data.trains.length - 1 && (
<Separator className="mt-6" />
)}
</div>
))}
</div>
</TabsContent>
{/* Theme Tab */}
<TabsContent value="theme" className="mt-4">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<ColorPicker
label="状态栏颜色"
value={data.theme.statusBarColor}
onChange={(value) => handleThemeChange("statusBarColor", value)}
/>
<ColorPicker
label="状态栏文本颜色"
value={data.theme.statusBarTextColor}
onChange={(value) => handleThemeChange("statusBarTextColor", value)}
/>
<ColorPicker
label="奇数行颜色"
value={data.theme.oddRowColor}
onChange={(value) => handleThemeChange("oddRowColor", value)}
/>
<ColorPicker
label="偶数行颜色"
value={data.theme.evenRowColor}
onChange={(value) => handleThemeChange("evenRowColor", value)}
/>
<ColorPicker
label="行文本颜色"
value={data.theme.textColor}
onChange={(value) => handleThemeChange("textColor", value)}
/>
<ColorPicker
label="月台背景颜色"
value={data.theme.platformBackgroundColor}
onChange={(value) => handleThemeChange("platformBackgroundColor", value)}
/>
<ColorPicker
label="月台文本颜色"
value={data.theme.platformTextColor}
onChange={(value) => handleThemeChange("platformTextColor", value)}
/>
</div>
<Separator className="my-4" />
<Label className="text-lg font-semibold">Train Type Colors</Label>
<div className="grid grid-cols-2 gap-4">
{(Object.keys(data.theme.trainTypeColors) as TrainType[]).map(
(type) => (
<ColorPicker
key={type}
label={type === "local" ? "普通" :
type === "express" ? "特快" :
type === "rapid" ? "快速" :
type === "through" ? "直通" : type}
value={data.theme.trainTypeColors[type]}
onChange={(value) => {
const newColors = {
...data.theme.trainTypeColors,
[type]: value,
};
handleThemeChange("trainTypeColors", newColors);
}}
/>
)
)}
</div>
<Separator className="my-4" />
<div className="flex items-center space-x-2 mt-4">
<Switch
id="language-switch"
checked={data.lang === "cn"}
onCheckedChange={handleLangChange}
/>
<Label htmlFor="language-switch">
Display Chinese ()
</Label>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import { Sun, Cloud, CloudRain, Zap } from "lucide-react";
import { BoardData, TrainType } from "@/app/rt-guide/types";
interface MtrDisplayBoardProps {
data: BoardData;
}
const weatherIcons = {
sunny: <Sun />,
cloudy: <Cloud />,
rainy: <CloudRain />,
stormy: <Zap />,
};
const trainTypeMap: Record<TrainType, { en: string; cn: string }> = {
local: { en: "Local", cn: "普通" },
express: { en: "Express", cn: "直快" },
rapid: { en: "Rapid", cn: "快速" },
through: { en: "Through", cn: "贯通" },
};
const formatArrival = (arrival: string, lang: "en" | "cn"): string => {
if (lang === "cn") {
if (arrival.toLowerCase() === "arriving") {
return "即将到达";
}
return arrival.replace("min", "分钟");
}
return arrival;
};
export function MtrDisplayBoard({ data }: MtrDisplayBoardProps) {
const { statusBar, trains, theme, lang } = data;
return (
<div
className="w-[600px] text-2xl font-bold shadow-lg"
style={{ fontFamily: "'Helvetica Neue', sans-serif" }}
>
{/* Status Bar */}
<div
className="flex items-center justify-between p-2"
style={{
backgroundColor: theme.statusBarColor,
color: theme.statusBarTextColor,
}}
>
<div className="flex items-center gap-2">
{weatherIcons[statusBar.weather]}
<span>{statusBar.temperature}°C</span>
</div>
<span>{statusBar.time}</span>
</div>
{/* Train List */}
<div>
{trains.map((train, index) => (
<div
key={index}
className="grid grid-cols-[3fr_1fr_1fr_1fr] items-center p-3"
style={{
backgroundColor:
index % 2 === 0 ? theme.oddRowColor : theme.evenRowColor,
color: theme.textColor,
}}
>
<span className="font-semibold">
{lang === "en" ? train.destination.en : train.destination.cn}
</span>
<span
className="text-center text-lg rounded-full px-2 py-1"
style={{ backgroundColor: theme.trainTypeColors[train.type] }}
>
{trainTypeMap[train.type][lang]}
</span>
<span
className="text-center rounded-full w-8 h-8 flex items-center justify-center mx-auto"
style={{
backgroundColor: theme.platformBackgroundColor,
color: theme.platformTextColor,
}}
>
{train.platform}
</span>
<span className="text-right">
{formatArrival(train.arrival, lang)}
</span>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { BoardData } from "@/app/rt-guide/types";
import { MtrDisplayBoard } from "./mtr-display-board";
import { MtrBoardForm } from "./mtr-board-form";
import { ScreenshotTaker } from "@/components/screenshot-taker";
const initialData: BoardData = {
statusBar: {
weather: "sunny",
temperature: "34",
time: "13:21",
},
trains: [
{
destination: { en: "Wangjiazhuang", cn: "王家庄" },
platform: "3",
type: "rapid",
arrival: "Arriving",
},
{
destination: { en: "Tangjiatuo", cn: "唐家坨" },
platform: "3",
type: "express",
arrival: "3 min",
},
{
destination: { en: "Yuetong North Road", cn: "悦港北路" },
platform: "3",
type: "local",
arrival: "7 min",
},
{
destination: { en: "Shiqiaopu", cn: "石桥铺" },
platform: "3",
type: "through",
arrival: "10 min",
},
],
theme: {
statusBarColor: "#0033A0",
statusBarTextColor: "#FFFFFF",
oddRowColor: "#0099CC",
evenRowColor: "#33CCCC",
textColor: "#FFFFFF",
platformBackgroundColor: "#00529B",
platformTextColor: "#FFFFFF",
trainTypeColors: {
local: "rgba(0, 0, 0, 0.2)",
express: "#D93A3A",
rapid: "#F2B705",
through: "#00A651",
},
},
lang: "en",
};
export function MtrTrainInfo() {
const [data, setData] = useState<BoardData>(initialData);
return (
<div className="grid md:grid-cols-2 gap-8">
<MtrBoardForm data={data} setData={setData} />
<div className="flex flex-col items-center">
<ScreenshotTaker fileName="mtr-guide.png">
<MtrDisplayBoard data={data} />
</ScreenshotTaker>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
"use client";
import { useRef, useCallback } from "react";
import { toPng } from "html-to-image";
import { Button } from "@/components/ui/button";
interface ScreenshotTakerProps {
children: React.ReactNode;
fileName?: string;
}
export function ScreenshotTaker({
children,
fileName = "download.png",
}: ScreenshotTakerProps) {
const ref = useRef<HTMLDivElement>(null);
const takeScreenshot = useCallback(() => {
if (ref.current === null) {
return;
}
toPng(ref.current, { cacheBust: true })
.then((dataUrl) => {
const link = document.createElement("a");
link.download = fileName;
link.href = dataUrl;
link.click();
})
.catch((err) => {
console.error("oops, something went wrong!", err);
});
}, [ref, fileName]);
return (
<div>
<div ref={ref}>{children}</div>
<Button onClick={takeScreenshot} className="mt-4">
Download Screenshot
</Button>
</div>
);
}

View File

View File

@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all 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",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/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,
}

View File

@ -0,0 +1,71 @@
"use client";
import * as React from "react";
import { HexColorPicker, HexColorInput } from "react-colorful";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
label?: string;
className?: string;
}
export function ColorPicker({
value,
onChange,
label,
className,
}: ColorPickerProps) {
return (
<div className={cn("grid gap-2", className)}>
{label && <Label>{label}</Label>}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn("w-full justify-start text-left font-normal", {
"h-8": !label,
})}
>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded ring-1 ring-inset ring-gray-200/20"
style={{ backgroundColor: value }}
/>
<HexColorInput
color={value}
onChange={onChange}
className="w-full bg-transparent outline-none"
/>
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64">
<div className="grid gap-4">
<HexColorPicker color={value} onChange={onChange} />
<div className="flex items-center gap-2">
<div
className="h-8 w-8 rounded ring-1 ring-inset ring-gray-200/20"
style={{ backgroundColor: value }}
/>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1"
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}

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

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"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 }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
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 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

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

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

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(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

23
src/lib/tools.ts Normal file
View File

@ -0,0 +1,23 @@
export interface Tool {
title: string;
description: string;
href: string;
}
export const tools: Tool[] = [
{
title: "Rail Transit Guide",
description: "轨道交通导视生成器",
href: "/rt-guide",
},
{
title: "URL Encoder/Decoder",
description: "Encode or decode URLs and strings.",
href: "/url-encoder",
},
{
title: "Base64 Encoder/Decoder",
description: "Encode or decode strings to and from Base64.",
href: "/base64-encoder",
},
];

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}