From 97f38b44f537e0bba951dcc7830594834cbb2bed Mon Sep 17 00:00:00 2001 From: typist <1003659191@qq.com> Date: Thu, 30 Oct 2025 09:22:29 +0800 Subject: [PATCH] feat: add-tool `currency` (#12) Co-authored-by: typist Reviewed-on: https://gitea.typist.cc/typist/litek/pulls/12 --- package.json | 3 + pnpm-lock.yaml | 85 ++++++ src/components/tool/currency.tsx | 463 +++++++++++++++++++++++++++++++ src/components/tool/index.tsx | 10 +- src/components/ui/badge.tsx | 46 +++ src/components/ui/card.tsx | 92 ++++++ src/components/ui/command.tsx | 184 ++++++++++++ src/components/ui/dialog.tsx | 141 ++++++++++ src/components/ui/label.tsx | 24 ++ src/components/ui/popover.tsx | 46 +++ 10 files changed, 1093 insertions(+), 1 deletion(-) create mode 100644 src/components/tool/currency.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/popover.tsx diff --git a/package.json b/package.json index 0b4d209..2efdb0e 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,15 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.16", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.548.0", "nanoid": "^5.1.6", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e02395f..e83f41b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-separator': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -38,6 +44,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) lucide-react: specifier: ^0.548.0 version: 0.548.0(react@19.2.0) @@ -1050,6 +1059,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-menu@2.1.16': resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} peerDependencies: @@ -1063,6 +1085,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -1703,6 +1738,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -4085,6 +4126,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -4111,6 +4161,29 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -4736,6 +4809,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 diff --git a/src/components/tool/currency.tsx b/src/components/tool/currency.tsx new file mode 100644 index 0000000..306d0e0 --- /dev/null +++ b/src/components/tool/currency.tsx @@ -0,0 +1,463 @@ +import { useState, useEffect, type FC } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { toast } from "sonner"; +import { Check, X, Plus } from "lucide-react"; + +interface Currency { + code: string; + name: string; + symbol: string; +} + +// Available currencies (supported by Frankfurter API) +const AVAILABLE_CURRENCIES: Currency[] = [ + { code: "USD", name: "US Dollar", symbol: "$" }, + { code: "CNY", name: "Chinese Yuan", symbol: "¥" }, + { code: "EUR", name: "Euro", symbol: "€" }, + { code: "GBP", name: "British Pound", symbol: "£" }, + { code: "JPY", name: "Japanese Yen", symbol: "¥" }, + { code: "HKD", name: "Hong Kong Dollar", symbol: "HK$" }, + { code: "AUD", name: "Australian Dollar", symbol: "A$" }, + { code: "CAD", name: "Canadian Dollar", symbol: "C$" }, + { code: "SGD", name: "Singapore Dollar", symbol: "S$" }, + { code: "CHF", name: "Swiss Franc", symbol: "CHF" }, + { code: "NZD", name: "New Zealand Dollar", symbol: "NZ$" }, + { code: "KRW", name: "South Korean Won", symbol: "₩" }, + { code: "THB", name: "Thai Baht", symbol: "฿" }, + { code: "MYR", name: "Malaysian Ringgit", symbol: "RM" }, + { code: "INR", name: "Indian Rupee", symbol: "₹" }, + { code: "BRL", name: "Brazilian Real", symbol: "R$" }, + { code: "MXN", name: "Mexican Peso", symbol: "MX$" }, + { code: "SEK", name: "Swedish Krona", symbol: "kr" }, + { code: "NOK", name: "Norwegian Krone", symbol: "kr" }, + { code: "DKK", name: "Danish Krone", symbol: "kr" }, + { code: "PLN", name: "Polish Złoty", symbol: "zł" }, + { code: "TRY", name: "Turkish Lira", symbol: "₺" }, + { code: "PHP", name: "Philippine Peso", symbol: "₱" }, + { code: "IDR", name: "Indonesian Rupiah", symbol: "Rp" }, + { code: "ILS", name: "Israeli New Shekel", symbol: "₪" }, + { code: "CZK", name: "Czech Koruna", symbol: "Kč" }, + { code: "RON", name: "Romanian Leu", symbol: "lei" }, + { code: "HUF", name: "Hungarian Forint", symbol: "Ft" }, + { code: "BGN", name: "Bulgarian Lev", symbol: "лв" }, + { code: "ISK", name: "Icelandic Króna", symbol: "kr" }, +]; + +// Required currencies (cannot be removed) - USD first, CNY second +const REQUIRED_CURRENCIES = ["USD", "CNY"]; + +const STORAGE_KEY = "selectedCurrencies"; +const RATES_CACHE_KEY = "currencyRatesCache"; + +const Tool: FC = () => { + const [amounts, setAmounts] = useState>({}); + const [selectedCurrencies, setSelectedCurrencies] = useState([]); + const [rates, setRates] = useState>({}); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + + // Load selected currencies from localStorage + useEffect(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed) && parsed.length >= 2) { + setSelectedCurrencies(parsed); + return; + } + } + } catch (error) { + console.error("Failed to load saved currencies:", error); + } + // Default: USD and CNY (USD first) + setSelectedCurrencies(["USD", "CNY"]); + }, []); + + // Save selected currencies to localStorage + useEffect(() => { + if (selectedCurrencies.length >= 2) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(selectedCurrencies)); + } catch (error) { + console.error("Failed to save currencies:", error); + } + } + }, [selectedCurrencies]); + + // Fetch exchange rates with date-based caching + useEffect(() => { + const fetchRates = async () => { + try { + // 1. Try to load from cache + const cached = localStorage.getItem(RATES_CACHE_KEY); + if (cached) { + try { + const { rates: cachedRates, date: cachedDate } = JSON.parse(cached); + + // Check if cached data is from today + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + + if (cachedDate === today) { + // Cache is valid (same date), use it directly + setRates(cachedRates); + setLoading(false); + return; + } else { + // Cache is outdated, show old data first then update in background + setRates(cachedRates); + setLoading(false); + // Continue to fetch new data below + } + } catch (e) { + console.error("Failed to parse cached rates:", e); + } + } + + // 2. Fetch latest data from network + setLoading(true); + const response = await fetch("https://api.frankfurter.app/latest?base=USD", { + cache: "no-cache", + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + const allRates: Record = { USD: 1, ...data.rates }; + const apiDate = data.date; // Date from API (YYYY-MM-DD format) + + // 3. Update state and cache + setRates(allRates); + localStorage.setItem( + RATES_CACHE_KEY, + JSON.stringify({ rates: allRates, date: apiDate }) + ); + } catch (error) { + // If cache exists, continue using it even if network fails + const cached = localStorage.getItem(RATES_CACHE_KEY); + if (cached) { + try { + const { rates: cachedRates } = JSON.parse(cached); + setRates(cachedRates); + toast.info("Using cached exchange rates (network request failed)"); + } catch (e) { + console.error("Failed to use cached rates:", e); + if (error instanceof Error) { + toast.error(`Failed to fetch rates: ${error.message}`); + } else { + toast.error("Failed to fetch rates"); + } + } + } else { + if (error instanceof Error) { + toast.error(`Failed to fetch rates: ${error.message}`); + } else { + toast.error("Failed to fetch rates"); + } + } + } finally { + setLoading(false); + } + }; + + fetchRates(); + }, []); + + // Initialize amounts after rates are loaded + useEffect(() => { + if (selectedCurrencies.length > 0 && Object.keys(rates).length > 0) { + if (Object.keys(amounts).length === 0) { + // Initial setup: 1 USD as base + const initialAmounts: Record = {}; + const baseAmountInUSD = 1; // 1 USD + + selectedCurrencies.forEach(code => { + if (rates[code]) { + const convertedAmount = baseAmountInUSD * rates[code]; + initialAmounts[code] = convertedAmount.toFixed(2); + } else { + initialAmounts[code] = ""; + } + }); + setAmounts(initialAmounts); + } + } + }, [selectedCurrencies, rates, amounts]); + + // Auto-update amounts when rates change + useEffect(() => { + if (Object.keys(rates).length > 0 && Object.keys(amounts).length > 0) { + // Find first currency with value as base + const baseCurrency = selectedCurrencies.find(code => amounts[code] && parseFloat(amounts[code]) > 0); + + if (baseCurrency && rates[baseCurrency]) { + const baseValue = parseFloat(amounts[baseCurrency]); + const amountInUSD = baseValue / rates[baseCurrency]; + + // Recalculate all currencies + const updatedAmounts: Record = {}; + selectedCurrencies.forEach(code => { + if (rates[code]) { + const convertedAmount = amountInUSD * rates[code]; + updatedAmounts[code] = convertedAmount.toFixed(2); + } else { + updatedAmounts[code] = amounts[code] || ""; + } + }); + + // Only update if values changed + const hasChanged = selectedCurrencies.some( + code => updatedAmounts[code] !== amounts[code] + ); + if (hasChanged) { + setAmounts(updatedAmounts); + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rates]); + + // Handle amount input change + const handleAmountChange = (currencyCode: string, value: string) => { + // Only allow numbers and decimal point + if (value !== "" && !/^\d*\.?\d*$/.test(value)) { + return; + } + + if (!rates[currencyCode]) { + setAmounts({ ...amounts, [currencyCode]: value }); + return; + } + + // Clear all if empty + if (value === "" || value === "0" || parseFloat(value) === 0) { + const newAmounts: Record = {}; + selectedCurrencies.forEach(code => { + newAmounts[code] = ""; + }); + setAmounts(newAmounts); + return; + } + + const inputValue = parseFloat(value); + + // Calculate other currencies + const newAmounts: Record = { [currencyCode]: value }; + + // Convert to USD first + const amountInUSD = inputValue / rates[currencyCode]; + + // Convert to other currencies + selectedCurrencies.forEach(code => { + if (code !== currencyCode && rates[code]) { + const convertedAmount = amountInUSD * rates[code]; + newAmounts[code] = convertedAmount.toFixed(2); + } + }); + + setAmounts(newAmounts); + }; + + // Add currency + const addCurrency = (currencyCode: string) => { + if (!selectedCurrencies.includes(currencyCode)) { + setSelectedCurrencies([...selectedCurrencies, currencyCode]); + + // Calculate initial amount for new currency + if (rates[currencyCode] && Object.keys(amounts).length > 0) { + const firstCurrency = selectedCurrencies[0]; + if (firstCurrency && amounts[firstCurrency] && rates[firstCurrency]) { + const firstAmount = parseFloat(amounts[firstCurrency]) || 0; + const amountInUSD = firstAmount / rates[firstCurrency]; + const newAmount = amountInUSD * rates[currencyCode]; + setAmounts({ ...amounts, [currencyCode]: newAmount.toFixed(2) }); + } + } + + toast.success(`Added ${currencyCode}`); + } + }; + + // Remove currency + const removeCurrency = (currencyCode: string) => { + if (REQUIRED_CURRENCIES.includes(currencyCode)) { + toast.error(`${currencyCode} is required and cannot be removed`); + return; + } + if (selectedCurrencies.length <= 2) { + toast.error("At least two currencies are required"); + return; + } + setSelectedCurrencies(selectedCurrencies.filter((c) => c !== currencyCode)); + + // Remove corresponding amount + const newAmounts = { ...amounts }; + delete newAmounts[currencyCode]; + setAmounts(newAmounts); + + toast.success(`Removed ${currencyCode}`); + }; + + // Get currency info + const getCurrency = (code: string): Currency | undefined => { + return AVAILABLE_CURRENCIES.find((c) => c.code === code); + }; + + return ( +
+ + {/* Currency selector */} +
+
+ + {/* Add currency button - fixed at top right */} + + + + + + + + + No currency found + + {AVAILABLE_CURRENCIES.map((currency) => { + const isSelected = selectedCurrencies.includes(currency.code); + const isRequired = REQUIRED_CURRENCIES.includes(currency.code); + return ( + { + if (isRequired) return; // Required currencies cannot be toggled + if (isSelected && selectedCurrencies.length > 2) { + removeCurrency(currency.code); + } else if (!isSelected) { + addCurrency(currency.code); + } + }} + > + + {currency.symbol} + {currency.code} + + ); + })} + + + + + +
+ + {/* Selected currency badges */} +
+ {selectedCurrencies.map((code) => { + const currency = getCurrency(code); + const isRequired = REQUIRED_CURRENCIES.includes(code); + return ( + { + if (!isRequired) { + removeCurrency(code); + } + }} + > + + {currency?.symbol} {code} + + {!isRequired && ( + + )} + + ); + })} +
+ + + {REQUIRED_CURRENCIES.map((c) => getCurrency(c)?.code).join(", ")} are required. At least two currencies needed. + +
+ + {/* Currency list */} + {loading ? ( +
+ Loading exchange rates... +
+ ) : ( +
+
Currency Amounts:
+
+ {selectedCurrencies.map((code) => { + const currency = getCurrency(code); + if (!currency) return null; + + return ( + + + +
+
{currency.symbol} {currency.code}
+
{currency.name}
+
+
+
+ + handleAmountChange(code, e.target.value)} + className="text-xl font-semibold" + /> + +
+ ); + })} +
+
+ )} + + {!loading && Object.keys(rates).length > 0 && ( +
+ Exchange rate data from:{" "} + + Frankfurter API + + {" "}(Based on European Central Bank data) +
+ )} +
+ ); +}; + +export default Tool; + diff --git a/src/components/tool/index.tsx b/src/components/tool/index.tsx index 58db165..794f72f 100644 --- a/src/components/tool/index.tsx +++ b/src/components/tool/index.tsx @@ -1,10 +1,11 @@ import { lazy, type ReactNode, type ComponentType } from 'react'; -import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react' +import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin, Coins } from 'lucide-react' // 懒加载工具组件 const UUID = lazy(() => import('./uuid')) const JSON = lazy(() => import('./json')) const Base64 = lazy(() => import('./base64')) +const Currency = lazy(() => import('./currency')) const DNS = lazy(() => import('./network/dns')) const Ping = lazy(() => import('./network/ping')) const TCPing = lazy(() => import('./network/tcping')) @@ -42,6 +43,13 @@ export const tools: Tool[] = [ icon: , component: Base64, }, + { + path: "currency", + name: "Currency Converter", + description: "Real-time currency exchange rates", + icon: , + component: Currency, + }, { path: "network", name: "Network Tools", diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..fd3a406 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +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 badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..8cb4ca7 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..6cb123b --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..fb5fbc3 --- /dev/null +++ b/src/components/ui/label.tsx @@ -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) { + return ( + + ) +} + +export { Label } diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..6d51b6c --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }