mirror of
https://github.com/snowykami/web-tools.git
synced 2025-09-01 00:36:23 +00:00
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
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:
21
components.json
Normal file
21
components.json
Normal 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"
|
||||
}
|
26
package.json
26
package.json
@ -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
977
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
|
76
src/app/json-formatter/page.tsx
Normal file
76
src/app/json-formatter/page.tsx
Normal 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">
|
||||
← 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
124
src/app/page.tsx
124
src/app/page.tsx
@ -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>
|
||||
);
|
||||
}
|
||||
|
296
src/app/rt-guide/board-form.tsx
Normal file
296
src/app/rt-guide/board-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
0
src/app/rt-guide/display-board.tsx
Normal file
0
src/app/rt-guide/display-board.tsx
Normal 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
35
src/app/rt-guide/types.ts
Normal 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
20
src/components/nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
286
src/components/rt-guide/mtr-board-form.tsx
Normal file
286
src/components/rt-guide/mtr-board-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
95
src/components/rt-guide/mtr-display-board.tsx
Normal file
95
src/components/rt-guide/mtr-display-board.tsx
Normal 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>
|
||||
);
|
||||
}
|
72
src/components/rt-guide/mtr-train-info.tsx
Normal file
72
src/components/rt-guide/mtr-train-info.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
src/components/screenshot-taker.tsx
Normal file
43
src/components/screenshot-taker.tsx
Normal 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>
|
||||
);
|
||||
}
|
0
src/components/theme-provider.tsx
Normal file
0
src/components/theme-provider.tsx
Normal file
40
src/components/theme-toggle.tsx
Normal file
40
src/components/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal 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 }
|
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
71
src/components/ui/color-picker.tsx
Normal file
71
src/components/ui/color-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/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 "@/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
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/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 "@/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 }
|
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal 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 }
|
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal 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 }
|
31
src/components/ui/switch.tsx
Normal file
31
src/components/ui/switch.tsx
Normal 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 }
|
66
src/components/ui/tabs.tsx
Normal file
66
src/components/ui/tabs.tsx
Normal 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 }
|
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal 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
23
src/lib/tools.ts
Normal 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
6
src/lib/utils.ts
Normal 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))
|
||||
}
|
Reference in New Issue
Block a user