Initial commit

This commit is contained in:
xixu-me committed 2025-11-12 19:57:52 +08:00
commit 4b6e2be336
27 files changed
+7174

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
+57
View File
@@ -0,0 +1,57 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build project
run: npm run build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: './dist'
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"arrowParens": "avoid",
"printWidth": 100,
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5"
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Xi Xu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+248
View File
@@ -0,0 +1,248 @@
# 🔑 UUID Playground
A modern, production-ready web application for generating and exploring UUIDs with a beautiful glassmorphism interface. Generate UUIDs and view them in multiple formats with one click.
[![Deploy to GitHub Pages](https://github.com/xixu-me/UUID-Playground/actions/workflows/deploy.yml/badge.svg)](https://github.com/xixu-me/UUID-Playground/actions/workflows/deploy.yml)
## ✨ Features
### Core Functionality
- **Multiple UUID Versions**: Generate UUIDv1 (time-based), UUIDv4 (random), and UUIDv7 (time-ordered)
- **8 Format Representations**: View each UUID in canonical, hex, braced, URN, decimal, binary, Base64, and URL-safe Base64 formats
- **One-Click Copy**: Copy any format to clipboard with visual feedback
- **UUID Validation**: Paste UUID strings (canonical, hex, braced, or URN) to normalize and view all representations
- **Lock Feature**: Prevent accidental regeneration with UUID lock toggle
- **Auto-Generate**: Automatically generate new UUIDs at configurable intervals (1-60 seconds)
- **Keyboard Shortcuts**: Press `Space` or `Enter` to generate new UUIDs
### UI/UX Excellence
- **Glassmorphism Design**: Modern translucent panels with blur effects
- **Light/Dark Themes**: Automatic theme detection with manual toggle (preference saved)
- **Smooth Animations**: GPU-accelerated animations with non-linear easing
- **Responsive Layout**: Optimized for desktop, tablet, and mobile devices
- **Toast Notifications**: Elegant feedback for all user actions
- **Accessibility**: Full keyboard navigation and ARIA labels
## 🚀 Live Demo
Visit the live application at: [https://uuid.xi-xu.me](https://uuid.xi-xu.me)
## 🛠️ Tech Stack
- **Framework**: React 18 with TypeScript
- **Build Tool**: Vite 5
- **Styling**: Pure CSS with CSS Variables
- **Testing**: Vitest
- **Deployment**: GitHub Actions → GitHub Pages
- **UUID Generation**: Web Crypto API
## 📦 Installation & Development
### Prerequisites
- Node.js 18+ (LTS recommended)
- npm or yarn
### Local Development
1. **Clone the repository**
```bash
git clone https://github.com/xixu-me/UUID-Playground.git
cd UUID-Playground
```
2. **Install dependencies**
```bash
npm install
```
3. **Start development server**
```bash
npm run dev
```
The app will be available at `http://localhost:5173`
4. **Run tests**
```bash
# Run tests once
npm test
# Run tests in watch mode
npm run test:watch
```
5. **Lint code**
```bash
npm run lint
```
6. **Build for production**
```bash
npm run build
```
Built files will be in the `dist/` directory
7. **Preview production build**
```bash
npm run preview
```
## 🚢 Deployment
### GitHub Pages Deployment
This repository is configured to automatically deploy to GitHub Pages using GitHub Actions.
#### Setup Instructions
1. **Enable GitHub Pages**
- Go to your repository settings
- Navigate to **Pages** section
- Under "Build and deployment":
- Source: **GitHub Actions**
2. **Push to main branch**
```bash
git push origin main
```
3. **Workflow automatically runs**
- Installs dependencies
- Runs tests
- Builds the project
- Deploys to GitHub Pages
4. **Access your site**
- Your site will be available at: `https://<username>.github.io/<repository-name>/`
#### Workflow Details
The deployment workflow (`.github/workflows/deploy.yml`):
- Triggers on push to `main` branch or manual dispatch
- Uses Node.js 20 LTS
- Caches npm dependencies for faster builds
- Runs tests before deployment
- Uploads build artifacts to GitHub Pages
- Deploys using official GitHub Actions
#### Customizing Repository Name
If you rename the repository, update the `base` path in `vite.config.ts`:
```typescript
export default defineConfig({
base: '/your-new-repo-name/',
// ... other config
})
```
## 🎨 Customization
### Changing Colors
Edit CSS variables in `src/styles/App.css`:
```css
[data-theme='light'] {
--accent-primary: #667eea; /* Primary accent color */
--accent-hover: #5a67d8; /* Hover state color */
/* ... other variables */
}
```
### Adding New UUID Formats
1. Add format conversion in `src/lib/uuid.ts`:
```typescript
export interface UUIDFormats {
// ... existing formats
yourNewFormat: string
}
```
2. Update `getAllFormats()` function to include new format
3. Add description in `App.tsx` formatDescriptions object
### Modifying Auto-Generate Limits
In `src/App.tsx`, change the min/max values:
```typescript
<input
type="number"
min="1" // Minimum seconds
max="60" // Maximum seconds
// ...
/>
```
## 🧪 Testing
The repository includes comprehensive tests for UUID utilities:
- **UUID Generation**: Validates correct version formatting
- **UUID Normalization**: Tests various input formats
- **Format Conversion**: Verifies all format representations
- **Version Detection**: Ensures correct UUID version identification
Run tests:
```bash
npm test # Run once
npm run test:watch # Watch mode
```
## 🔒 Security
- Uses native Web Crypto API for cryptographically secure random generation
- No external dependencies for UUID generation
- Client-side only - no data sent to servers
- No tracking or analytics
## ♿ Accessibility
- Full keyboard navigation support
- ARIA labels on interactive elements
- Sufficient color contrast ratios
- Respects `prefers-reduced-motion`
- Focus indicators on all interactive elements
## 📝 License
Copyright (c) Xi Xu. All rights reserved.
Licensed under the [MIT](LICENSE) license.
## 📚 Resources
- [UUID Specification (RFC 4122)](https://datatracker.ietf.org/doc/html/rfc4122)
- [UUID Version 7 Draft](https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format)
- [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)
- [Vite Documentation](https://vitejs.dev/)
- [React Documentation](https://react.dev/)
## 🙏 Acknowledgments
- Design inspired by modern glassmorphism trends
- UUID generation using Web Crypto API standards
- Built with Vite's lightning-fast tooling
---
Made with ❤️ and TypeScript
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Modern UUID Playground - Generate and explore UUID formats with a beautiful interface" />
<title>UUID Playground</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4940
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
{
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.3",
"prettier": "^3.3.2",
"typescript": "^5.5.3",
"vite": "^5.3.1",
"vitest": "^1.6.0"
},
"name": "uuid-playground",
"private": true,
"scripts": {
"build": "tsc && vite build",
"dev": "vite",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"type": "module",
"version": "1.0.0"
}
+1
View File
@@ -0,0 +1 @@
uuid.xi-xu.me
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<text x="50" y="70" font-family="Arial, sans-serif" font-size="60" font-weight="bold" text-anchor="middle" fill="white">🔑</text>
</svg>

After

Width:  |  Height:  |  Size: 513 B

+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState } from 'react'
import { FormatCard } from './components/FormatCard'
import { InfoPanel } from './components/InfoPanel'
import { ToastContainer } from './components/Toast'
import { ValidationPanel } from './components/ValidationPanel'
import { useKeyboard } from './hooks/useKeyboard'
import { useTheme } from './hooks/useTheme'
import { useToast } from './hooks/useToast'
import { generateUUID, getAllFormats, UUIDFormats, UUIDVersion } from './lib/uuid'
import './styles/App.css'
function App() {
const [currentUUID, setCurrentUUID] = useState<string>('')
const [formats, setFormats] = useState<UUIDFormats | null>(null)
const [isLocked, setIsLocked] = useState(false)
const [version, setVersion] = useState<UUIDVersion>('v4')
const [autoGenerate, setAutoGenerate] = useState(false)
const [autoInterval, setAutoInterval] = useState(5)
const { theme, toggleTheme } = useTheme()
const { toasts, showToast, removeToast } = useToast()
const handleGenerate = () => {
if (isLocked) {
showToast('UUID is locked', 'info')
return
}
const uuid = generateUUID(version)
setCurrentUUID(uuid)
setFormats(getAllFormats(uuid))
}
const handleCopy = async (value: string) => {
try {
await navigator.clipboard.writeText(value)
showToast('Copied to clipboard!', 'success')
} catch (error) {
showToast('Failed to copy', 'error')
console.error('Copy failed:', error)
}
}
const handleValidated = (validatedFormats: UUIDFormats) => {
if (isLocked) {
showToast('UUID is locked', 'info')
return
}
setCurrentUUID(validatedFormats.canonical)
setFormats(validatedFormats)
showToast('UUID loaded successfully', 'success')
}
// Generate initial UUID on mount
useEffect(() => {
handleGenerate()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Auto-generate functionality
useEffect(() => {
if (!autoGenerate || isLocked) return
const interval = setInterval(() => {
handleGenerate()
}, autoInterval * 1000)
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoGenerate, autoInterval, isLocked])
// Keyboard shortcuts
useKeyboard(handleGenerate, [' ', 'Space', 'Enter'])
const formatLabels: Record<string, string> = {
canonical: 'Canonical',
hex: 'Hex',
braced: 'Braced',
urn: 'URN',
decimal: 'Decimal',
binary: 'Binary',
base64: 'Base64',
base64url: 'Base64URL',
}
const formatDescriptions: Record<string, string> = {
canonical: 'Standard hyphenated format',
hex: 'Hexadecimal without hyphens',
braced: 'Microsoft GUID style',
urn: 'Uniform Resource Name format',
decimal: '128-bit integer representation',
binary: '128 bits grouped by 32',
base64: 'Standard Base64 encoding',
base64url: 'URL-safe Base64 encoding',
}
return (
<div className="app">
<div className="background-gradient"></div>
<header className="header">
<div className="header-content">
<h1 className="title">
<span className="title-icon">🔑</span>
UUID Playground
</h1>
<button
className="theme-toggle"
onClick={toggleTheme}
aria-label="Toggle theme"
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
</div>
</header>
<main className="main">
<div className="container">
{/* Main UUID Card */}
<div className="uuid-main-card">
<div className="uuid-display">
<div className="uuid-value">{currentUUID}</div>
</div>
<div className="controls">
<div className="control-group">
<label htmlFor="version-select" className="control-label">
Version:
</label>
<select
id="version-select"
className="version-select"
value={version}
onChange={e => setVersion(e.target.value as UUIDVersion)}
disabled={isLocked}
>
<option value="v4">UUID v4 (Random)</option>
<option value="v1">UUID v1 (Time-based)</option>
<option value="v7">UUID v7 (Time-ordered)</option>
</select>
</div>
<button
className="generate-button"
onClick={handleGenerate}
disabled={isLocked}
aria-label="Generate new UUID"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M17.5 10C17.5 14.1421 14.1421 17.5 10 17.5C5.85786 17.5 2.5 14.1421 2.5 10C2.5 5.85786 5.85786 2.5 10 2.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M17.5 5L10 10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
Generate
</button>
<button
className={`lock-button ${isLocked ? 'locked' : ''}`}
onClick={() => setIsLocked(!isLocked)}
aria-label={isLocked ? 'Unlock UUID' : 'Lock UUID'}
title={isLocked ? 'Unlock to allow changes' : 'Lock to prevent changes'}
>
{isLocked ? '🔒' : '🔓'}
</button>
</div>
<div className="auto-generate">
<label className="auto-generate-label">
<input
type="checkbox"
checked={autoGenerate}
onChange={e => setAutoGenerate(e.target.checked)}
disabled={isLocked}
/>
<span>Auto-generate every</span>
</label>
<input
type="number"
className="auto-interval-input"
min="1"
max="60"
value={autoInterval}
onChange={e => setAutoInterval(Math.max(1, Math.min(60, parseInt(e.target.value) || 5)))}
disabled={!autoGenerate || isLocked}
aria-label="Auto-generate interval in seconds"
/>
<span>seconds</span>
</div>
</div>
{/* Format Cards Grid */}
{formats && (
<div className="formats-grid">
{Object.entries(formats).map(([key, value]) => (
<FormatCard
key={key}
label={formatLabels[key]}
value={value}
description={formatDescriptions[key]}
onCopy={handleCopy}
/>
))}
</div>
)}
{/* Validation Panel */}
<ValidationPanel onValidated={handleValidated} />
{/* Info Panel */}
<InfoPanel />
</div>
</main>
<footer className="footer">
<p>
Built with React + TypeScript •{' '}
<a
href="https://github.com/xixu-me/UUID-Playground"
target="_blank"
rel="noopener noreferrer"
className="footer-link"
>
View Source
</a>
</p>
</footer>
<ToastContainer toasts={toasts} onClose={removeToast} />
</div>
)
}
export default App
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react'
interface FormatCardProps {
label: string
value: string
description?: string
onCopy: (value: string) => void
}
export function FormatCard({ label, value, description, onCopy }: FormatCardProps) {
const [copying, setCopying] = useState(false)
const handleCopy = async () => {
setCopying(true)
onCopy(value)
setTimeout(() => {
setCopying(false)
}, 300)
}
return (
<div className="format-card">
<div className="format-card-header">
<div>
<h3 className="format-label">{label}</h3>
{description && <p className="format-description">{description}</p>}
</div>
<button
className={`copy-button ${copying ? 'copying' : ''}`}
onClick={handleCopy}
aria-label={`Copy ${label}`}
>
{copying ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M13.5 4L6 11.5L2.5 8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect
x="5.5"
y="5.5"
width="8"
height="8"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.5 10.5H2.5C1.94772 10.5 1.5 10.0523 1.5 9.5V2.5C1.5 1.94772 1.94772 1.5 2.5 1.5H9.5C10.0523 1.5 10.5 1.94772 10.5 2.5V3.5"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
)}
</button>
</div>
<div className="format-value">{value}</div>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
export function InfoPanel() {
return (
<div className="info-panel">
<h3>About UUIDs</h3>
<p>
A <strong>Universally Unique Identifier (UUID)</strong> is a 128-bit number used to
uniquely identify information in computer systems. The probability of generating duplicate
UUIDs is extremely low, making them ideal for distributed systems.
</p>
<h4>UUID Versions</h4>
<ul>
<li>
<strong>v1:</strong> Time-based, includes timestamp and MAC address
</li>
<li>
<strong>v4:</strong> Random, most commonly used
</li>
<li>
<strong>v7:</strong> Time-ordered, sortable by creation time
</li>
</ul>
<h4>Format Representations</h4>
<p>
All formats shown represent the <strong>same 128-bit value</strong>, just encoded
differently. You can convert between any format while preserving the underlying UUID.
</p>
<div className="keyboard-shortcuts">
<h4>Keyboard Shortcuts</h4>
<kbd>Space</kbd> or <kbd>Enter</kbd> - Generate new UUID
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { Toast as ToastType } from '../hooks/useToast'
interface ToastProps {
toast: ToastType
onClose: (id: string) => void
}
export function Toast({ toast, onClose }: ToastProps) {
return (
<div className={`toast toast-${toast.type}`} onClick={() => onClose(toast.id)}>
<span>{toast.message}</span>
</div>
)
}
interface ToastContainerProps {
toasts: ToastType[]
onClose: (id: string) => void
}
export function ToastContainer({ toasts, onClose }: ToastContainerProps) {
return (
<div className="toast-container">
{toasts.map(toast => (
<Toast key={toast.id} toast={toast} onClose={onClose} />
))}
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { detectUUIDVersion, getAllFormats, normalizeUUID, UUIDFormats } from '../lib/uuid'
interface ValidationPanelProps {
onValidated: (formats: UUIDFormats) => void
}
export function ValidationPanel({ onValidated }: ValidationPanelProps) {
const [input, setInput] = useState('')
const [error, setError] = useState<string | null>(null)
const [version, setVersion] = useState<string | null>(null)
const handleValidate = () => {
if (!input.trim()) {
setError('Please enter a UUID')
setVersion(null)
return
}
const normalized = normalizeUUID(input)
if (!normalized) {
setError('Invalid UUID format')
setVersion(null)
return
}
setError(null)
const detectedVersion = detectUUIDVersion(normalized)
setVersion(detectedVersion)
onValidated(getAllFormats(normalized))
setInput('')
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleValidate()
}
}
return (
<div className="validation-panel">
<h3>Validate UUID</h3>
<p className="validation-description">
Paste a UUID string (canonical, hex, braced, or URN) to normalize and view all representations
</p>
<div className="validation-input-group">
<input
type="text"
className="validation-input"
placeholder="Paste UUID here..."
value={input}
onChange={e => setInput(e.target.value)}
onKeyPress={handleKeyPress}
aria-label="UUID validation input"
/>
<button className="validate-button" onClick={handleValidate}>
Validate
</button>
</div>
{error && <div className="validation-error">{error}</div>}
{version && <div className="validation-success">Valid UUID: {version}</div>}
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect } from 'react'
export function useKeyboard(callback: () => void, keys: string[]) {
useEffect(() => {
const handler = (event: KeyboardEvent) => {
// Don't trigger if user is typing in an input
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (keys.includes(event.key) || keys.includes(event.code)) {
event.preventDefault()
callback()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [callback, keys])
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react'
export type Theme = 'light' | 'dark'
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem('uuid-playground-theme')
if (stored === 'light' || stored === 'dark') {
return stored
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
})
useEffect(() => {
localStorage.setItem('uuid-playground-theme', theme)
document.documentElement.setAttribute('data-theme', theme)
}, [theme])
const toggleTheme = () => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'))
}
return { theme, toggleTheme }
}
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react'
export interface Toast {
id: string
message: string
type: 'success' | 'error' | 'info'
}
export function useToast() {
const [toasts, setToasts] = useState<Toast[]>([])
const showToast = useCallback((message: string, type: Toast['type'] = 'success') => {
const id = Math.random().toString(36).substring(2, 9)
const toast: Toast = { id, message, type }
setToasts(prev => [...prev, toast])
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 3000)
}, [])
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
return { toasts, showToast, removeToast }
}
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import {
detectUUIDVersion,
generateUUID,
generateUUIDv4,
getAllFormats,
normalizeUUID,
uuidToBytes,
} from './uuid'
describe('UUID Generation', () => {
it('should generate valid UUIDv4', () => {
const uuid = generateUUIDv4()
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
})
it('should generate unique UUIDs', () => {
const uuid1 = generateUUIDv4()
const uuid2 = generateUUIDv4()
expect(uuid1).not.toBe(uuid2)
})
it('should generate UUIDv1', () => {
const uuid = generateUUID('v1')
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-1[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
})
it('should generate UUIDv7', () => {
const uuid = generateUUID('v7')
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
})
})
describe('UUID Normalization', () => {
const validUUID = '550e8400-e29b-41d4-a716-446655440000'
it('should normalize canonical format', () => {
expect(normalizeUUID(validUUID)).toBe(validUUID)
})
it('should normalize uppercase UUID', () => {
expect(normalizeUUID(validUUID.toUpperCase())).toBe(validUUID)
})
it('should normalize hex format without hyphens', () => {
const hex = validUUID.replace(/-/g, '')
expect(normalizeUUID(hex)).toBe(validUUID)
})
it('should normalize braced format', () => {
expect(normalizeUUID(`{${validUUID}}`)).toBe(validUUID)
})
it('should normalize URN format', () => {
expect(normalizeUUID(`urn:uuid:${validUUID}`)).toBe(validUUID)
})
it('should return null for invalid UUID', () => {
expect(normalizeUUID('not-a-uuid')).toBeNull()
expect(normalizeUUID('550e8400-e29b-41d4-a716')).toBeNull()
expect(normalizeUUID('')).toBeNull()
})
it('should handle whitespace', () => {
expect(normalizeUUID(` ${validUUID} `)).toBe(validUUID)
})
})
describe('UUID Format Conversion', () => {
const testUUID = '550e8400-e29b-41d4-a716-446655440000'
it('should convert to all formats', () => {
const formats = getAllFormats(testUUID)
expect(formats.canonical).toBe(testUUID)
expect(formats.hex).toBe('550e8400e29b41d4a716446655440000')
expect(formats.braced).toBe(`{${testUUID}}`)
expect(formats.urn).toBe(`urn:uuid:${testUUID}`)
expect(formats.decimal).toBe('113059749145936325402354257176981405696')
expect(formats.binary).toMatch(/^[01 ]+$/)
expect(formats.base64).toBeTruthy()
expect(formats.base64url).toBeTruthy()
})
it('should generate valid base64', () => {
const formats = getAllFormats(testUUID)
// Base64 should be decodable
expect(formats.base64.length).toBeGreaterThan(0)
expect(formats.base64url.length).toBeGreaterThan(0)
})
it('should generate correct binary format', () => {
const formats = getAllFormats(testUUID)
// Should have 4 groups of 32 bits
const groups = formats.binary.split(' ')
expect(groups).toHaveLength(4)
groups.forEach(group => {
expect(group).toHaveLength(32)
expect(group).toMatch(/^[01]+$/)
})
})
})
describe('UUID Version Detection', () => {
it('should detect UUIDv4', () => {
const uuid = generateUUIDv4()
expect(detectUUIDVersion(uuid)).toBe('v4 (Random)')
})
it('should detect UUIDv1', () => {
const uuid = generateUUID('v1')
expect(detectUUIDVersion(uuid)).toBe('v1 (Time-based)')
})
it('should detect UUIDv7', () => {
const uuid = generateUUID('v7')
expect(detectUUIDVersion(uuid)).toBe('v7 (Time-ordered)')
})
})
describe('UUID to Bytes Conversion', () => {
it('should convert UUID to bytes', () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000'
const bytes = uuidToBytes(uuid)
expect(bytes).toBeInstanceOf(Uint8Array)
expect(bytes.length).toBe(16)
expect(bytes[0]).toBe(0x55)
expect(bytes[1]).toBe(0x0e)
expect(bytes[2]).toBe(0x84)
})
it('should throw on invalid UUID', () => {
expect(() => uuidToBytes('invalid')).toThrow()
})
})
+234
View File
@@ -0,0 +1,234 @@
/**
* UUID generation and conversion utilities
*/
export type UUIDVersion = 'v4' | 'v1' | 'v7'
export interface UUIDFormats {
canonical: string
hex: string
braced: string
urn: string
decimal: string
binary: string
base64: string
base64url: string
}
/**
* Generate a UUIDv4 using Web Crypto API
*/
export function generateUUIDv4(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
// Fallback implementation
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
// Set version (4) and variant bits
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
return bytesToUUID(bytes)
}
/**
* Generate a UUIDv1 (time-based)
*/
export function generateUUIDv1(): string {
const now = Date.now()
const timeHigh = BigInt(now) * 10000n + 0x01b21dd213814000n
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
// Set time fields
const timeLow = Number(timeHigh & 0xffffffffn)
const timeMid = Number((timeHigh >> 32n) & 0xffffn)
const timeHighAndVersion = Number((timeHigh >> 48n) & 0x0fffn) | 0x1000
bytes[0] = (timeLow >> 24) & 0xff
bytes[1] = (timeLow >> 16) & 0xff
bytes[2] = (timeLow >> 8) & 0xff
bytes[3] = timeLow & 0xff
bytes[4] = (timeMid >> 8) & 0xff
bytes[5] = timeMid & 0xff
bytes[6] = (timeHighAndVersion >> 8) & 0xff
bytes[7] = timeHighAndVersion & 0xff
// Set variant bits
bytes[8] = (bytes[8] & 0x3f) | 0x80
return bytesToUUID(bytes)
}
/**
* Generate a UUIDv7 (time-ordered)
*/
export function generateUUIDv7(): string {
const now = Date.now()
const bytes = new Uint8Array(16)
// Unix timestamp in milliseconds (48 bits)
bytes[0] = (now >> 40) & 0xff
bytes[1] = (now >> 32) & 0xff
bytes[2] = (now >> 24) & 0xff
bytes[3] = (now >> 16) & 0xff
bytes[4] = (now >> 8) & 0xff
bytes[5] = now & 0xff
// Random bits
crypto.getRandomValues(bytes.subarray(6))
// Set version (7) and variant bits
bytes[6] = (bytes[6] & 0x0f) | 0x70
bytes[8] = (bytes[8] & 0x3f) | 0x80
return bytesToUUID(bytes)
}
/**
* Generate UUID based on version
*/
export function generateUUID(version: UUIDVersion = 'v4'): string {
switch (version) {
case 'v1':
return generateUUIDv1()
case 'v7':
return generateUUIDv7()
case 'v4':
default:
return generateUUIDv4()
}
}
/**
* Convert bytes to UUID canonical format
*/
function bytesToUUID(bytes: Uint8Array): string {
const hex = Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/**
* Convert UUID string to bytes
*/
export function uuidToBytes(uuid: string): Uint8Array {
const hex = uuid.replace(/[^0-9a-f]/gi, '')
if (hex.length !== 32) {
throw new Error('Invalid UUID format')
}
const bytes = new Uint8Array(16)
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
}
return bytes
}
/**
* Validate and normalize UUID string
*/
export function normalizeUUID(input: string): string | null {
try {
// Remove common prefixes and wrappers
let cleaned = input.trim()
cleaned = cleaned.replace(/^urn:uuid:/i, '')
cleaned = cleaned.replace(/^[{(]|[})]$/g, '')
// Check if it's a valid hex string (with or without hyphens)
const hex = cleaned.replace(/[^0-9a-f]/gi, '')
if (hex.length !== 32) {
return null
}
// Validate it's valid hex
if (!/^[0-9a-f]{32}$/i.test(hex)) {
return null
}
// Return canonical format
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`.toLowerCase()
} catch {
return null
}
}
/**
* Get all format representations of a UUID
*/
export function getAllFormats(uuid: string): UUIDFormats {
const bytes = uuidToBytes(uuid)
const hex = uuid.replace(/-/g, '')
// Convert to decimal (128-bit number as string)
const decimal = bytes.reduce((acc, byte, i) => {
const shift = BigInt(15 - i) * 8n
return acc + (BigInt(byte) << shift)
}, 0n).toString()
// Convert to binary (grouped by 32 bits for readability)
const binaryStr = Array.from(bytes)
.map(b => b.toString(2).padStart(8, '0'))
.join('')
const binary = [
binaryStr.slice(0, 32),
binaryStr.slice(32, 64),
binaryStr.slice(64, 96),
binaryStr.slice(96, 128),
].join(' ')
// Convert to Base64
const base64 = btoa(String.fromCharCode(...bytes))
// Convert to URL-safe Base64
const base64url = base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
return {
canonical: uuid,
hex: hex.toLowerCase(),
braced: `{${uuid}}`,
urn: `urn:uuid:${uuid}`,
decimal,
binary,
base64,
base64url,
}
}
/**
* Detect UUID version
*/
export function detectUUIDVersion(uuid: string): string {
try {
const bytes = uuidToBytes(uuid)
const version = (bytes[6] >> 4) & 0x0f
switch (version) {
case 1:
return 'v1 (Time-based)'
case 2:
return 'v2 (DCE Security)'
case 3:
return 'v3 (MD5 hash)'
case 4:
return 'v4 (Random)'
case 5:
return 'v5 (SHA-1 hash)'
case 7:
return 'v7 (Time-ordered)'
default:
return `v${version} (Unknown)`
}
} catch {
return 'Unknown'
}
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+840
View File
@@ -0,0 +1,840 @@
/* ===== CSS Variables ===== */
:root {
--timing-ease: cubic-bezier(0.4, 0, 0.2, 1);
--timing-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
--timing-smooth: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
[data-theme='light'] {
--bg-primary: #f0f4f8;
--bg-gradient-1: #667eea;
--bg-gradient-2: #764ba2;
--bg-gradient-3: #f093fb;
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-border: rgba(255, 255, 255, 0.3);
--glass-shadow: rgba(31, 38, 135, 0.15);
--text-primary: #1a202c;
--text-secondary: #4a5568;
--text-muted: #718096;
--accent-primary: #667eea;
--accent-hover: #5a67d8;
--accent-light: rgba(102, 126, 234, 0.1);
--success: #48bb78;
--error: #f56565;
--info: #4299e1;
}
[data-theme='dark'] {
--bg-primary: #0f1419;
--bg-gradient-1: #2d3748;
--bg-gradient-2: #4a5568;
--bg-gradient-3: #667eea;
--glass-bg: rgba(26, 32, 44, 0.7);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-shadow: rgba(0, 0, 0, 0.3);
--text-primary: #f7fafc;
--text-secondary: #e2e8f0;
--text-muted: #a0aec0;
--accent-primary: #667eea;
--accent-hover: #7c3aed;
--accent-light: rgba(102, 126, 234, 0.2);
--success: #48bb78;
--error: #fc8181;
--info: #63b3ed;
}
/* ===== Base Styles ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s var(--timing-ease), color 0.3s var(--timing-ease);
}
code, pre, .format-value {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
/* ===== App Layout ===== */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
position: relative;
overflow-x: hidden;
}
.background-gradient {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
background: linear-gradient(
135deg,
var(--bg-gradient-1) 0%,
var(--bg-gradient-2) 50%,
var(--bg-gradient-3) 100%
);
opacity: 0.1;
animation: gradientShift 15s ease infinite;
}
@keyframes gradientShift {
0%, 100% { transform: scale(1) rotate(0deg); }
50% { transform: scale(1.1) rotate(5deg); }
}
/* ===== Header ===== */
.header {
padding: 2rem 1rem 1rem;
animation: slideDown 0.6s var(--timing-ease);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-primary), var(--bg-gradient-3));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: flex;
align-items: center;
gap: 0.75rem;
}
.title-icon {
font-size: 2rem;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.theme-toggle {
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
font-size: 1.5rem;
cursor: pointer;
transition: all 0.3s var(--timing-bounce);
box-shadow: 0 4px 6px var(--glass-shadow);
}
.theme-toggle:hover {
transform: scale(1.1) rotate(15deg);
box-shadow: 0 6px 12px var(--glass-shadow);
}
.theme-toggle:active {
transform: scale(0.95);
}
/* ===== Main Content ===== */
.main {
flex: 1;
padding: 2rem 1rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 2rem;
}
/* ===== Glass Card Base ===== */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 20px;
border: 1px solid var(--glass-border);
box-shadow: 0 8px 32px var(--glass-shadow);
padding: 2rem;
transition: all 0.3s var(--timing-ease);
}
.glass-card:hover {
box-shadow: 0 12px 40px var(--glass-shadow);
transform: translateY(-2px);
}
/* ===== UUID Main Card ===== */
.uuid-main-card {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 24px;
border: 1px solid var(--glass-border);
box-shadow: 0 12px 40px var(--glass-shadow);
padding: 2.5rem;
animation: fadeInUp 0.6s var(--timing-ease);
animation-delay: 0.1s;
animation-fill-mode: both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.uuid-display {
margin-bottom: 2rem;
text-align: center;
}
.uuid-value {
font-size: 1.75rem;
font-weight: 600;
color: var(--accent-primary);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', monospace;
letter-spacing: 0.02em;
padding: 1.5rem;
background: var(--accent-light);
border-radius: 12px;
animation: fadeIn 0.5s var(--timing-ease);
word-break: break-all;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* ===== Controls ===== */
.controls {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
}
.control-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-label {
font-size: 0.9rem;
color: var(--text-secondary);
font-weight: 500;
}
.version-select {
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s var(--timing-ease);
backdrop-filter: blur(10px);
}
.version-select:hover:not(:disabled) {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-light);
}
.version-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.generate-button {
padding: 0.75rem 2rem;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-hover));
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s var(--timing-ease);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
display: flex;
align-items: center;
gap: 0.5rem;
}
.generate-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.generate-button:active:not(:disabled) {
transform: translateY(0);
}
.generate-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.generate-button svg {
animation: rotate 2s linear infinite;
}
.generate-button:hover:not(:disabled) svg {
animation: rotate 0.6s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.lock-button {
padding: 0.75rem 1.25rem;
border-radius: 12px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
font-size: 1.25rem;
cursor: pointer;
transition: all 0.3s var(--timing-bounce);
backdrop-filter: blur(10px);
}
.lock-button:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px var(--glass-shadow);
}
.lock-button.locked {
background: var(--accent-light);
border-color: var(--accent-primary);
}
/* ===== Auto Generate ===== */
.auto-generate {
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: center;
padding: 1rem;
background: var(--accent-light);
border-radius: 12px;
}
.auto-generate-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.95rem;
color: var(--text-secondary);
}
.auto-generate-label input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
}
.auto-interval-input {
width: 60px;
padding: 0.5rem;
border-radius: 8px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
text-align: center;
font-weight: 600;
}
.auto-interval-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ===== Format Cards ===== */
.formats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
animation: fadeInUp 0.6s var(--timing-ease);
animation-delay: 0.2s;
animation-fill-mode: both;
}
.format-card {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 16px;
border: 1px solid var(--glass-border);
padding: 1.5rem;
transition: all 0.3s var(--timing-ease);
box-shadow: 0 4px 12px var(--glass-shadow);
}
.format-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px var(--glass-shadow);
border-color: var(--accent-primary);
}
.format-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
gap: 1rem;
}
.format-label {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.format-description {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0;
}
.copy-button {
padding: 0.5rem;
border-radius: 8px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s var(--timing-ease);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.copy-button:hover {
background: var(--accent-light);
color: var(--accent-primary);
border-color: var(--accent-primary);
transform: scale(1.05);
}
.copy-button.copying {
background: var(--success);
color: white;
border-color: var(--success);
animation: pulse 0.3s var(--timing-ease);
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
.format-value {
font-size: 0.9rem;
color: var(--text-secondary);
word-break: break-all;
line-height: 1.6;
padding: 1rem;
background: var(--accent-light);
border-radius: 8px;
border-left: 3px solid var(--accent-primary);
}
/* ===== Validation Panel ===== */
.validation-panel {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 20px;
border: 1px solid var(--glass-border);
padding: 2rem;
box-shadow: 0 8px 24px var(--glass-shadow);
animation: fadeInUp 0.6s var(--timing-ease);
animation-delay: 0.3s;
animation-fill-mode: both;
}
.validation-panel h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.validation-description {
color: var(--text-muted);
margin-bottom: 1.5rem;
}
.validation-input-group {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.validation-input {
flex: 1;
padding: 0.875rem 1.25rem;
border-radius: 12px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
font-size: 0.95rem;
transition: all 0.2s var(--timing-ease);
font-family: 'SF Mono', Monaco, monospace;
}
.validation-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-light);
}
.validate-button {
padding: 0.875rem 1.75rem;
border-radius: 12px;
border: none;
background: var(--accent-primary);
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s var(--timing-ease);
white-space: nowrap;
}
.validate-button:hover {
background: var(--accent-hover);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.validation-error {
padding: 0.875rem 1.25rem;
background: rgba(245, 101, 101, 0.1);
border-left: 3px solid var(--error);
border-radius: 8px;
color: var(--error);
font-weight: 500;
animation: shake 0.4s var(--timing-ease);
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
.validation-success {
padding: 0.875rem 1.25rem;
background: rgba(72, 187, 120, 0.1);
border-left: 3px solid var(--success);
border-radius: 8px;
color: var(--success);
font-weight: 500;
animation: slideInRight 0.3s var(--timing-ease);
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* ===== Info Panel ===== */
.info-panel {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 20px;
border: 1px solid var(--glass-border);
padding: 2rem;
box-shadow: 0 8px 24px var(--glass-shadow);
animation: fadeInUp 0.6s var(--timing-ease);
animation-delay: 0.4s;
animation-fill-mode: both;
}
.info-panel h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
color: var(--text-primary);
}
.info-panel h4 {
font-size: 1.1rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--text-primary);
}
.info-panel p {
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1rem;
}
.info-panel ul {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.info-panel li {
color: var(--text-secondary);
line-height: 1.8;
margin-bottom: 0.5rem;
}
.keyboard-shortcuts {
margin-top: 2rem;
padding: 1.5rem;
background: var(--accent-light);
border-radius: 12px;
}
.keyboard-shortcuts h4 {
margin-top: 0;
}
.keyboard-shortcuts kbd {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 6px;
font-family: monospace;
font-size: 0.9rem;
font-weight: 600;
box-shadow: 0 2px 4px var(--glass-shadow);
margin: 0 0.25rem;
}
/* ===== Toast Notifications ===== */
.toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
z-index: 1000;
}
.toast {
padding: 1rem 1.5rem;
border-radius: 12px;
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
box-shadow: 0 8px 24px var(--glass-shadow);
color: var(--text-primary);
font-weight: 500;
cursor: pointer;
animation: slideInUp 0.3s var(--timing-bounce);
min-width: 200px;
max-width: 400px;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.toast-success {
border-left: 4px solid var(--success);
}
.toast-error {
border-left: 4px solid var(--error);
}
.toast-info {
border-left: 4px solid var(--info);
}
.toast:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px var(--glass-shadow);
}
/* ===== Footer ===== */
.footer {
padding: 2rem 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
}
.footer-link {
color: var(--accent-primary);
text-decoration: none;
font-weight: 500;
transition: color 0.2s var(--timing-ease);
}
.footer-link:hover {
color: var(--accent-hover);
text-decoration: underline;
}
/* ===== Responsive Design ===== */
@media (max-width: 768px) {
.title {
font-size: 1.75rem;
}
.header {
padding: 1.5rem 1rem 0.5rem;
}
.uuid-main-card {
padding: 1.5rem;
}
.uuid-value {
font-size: 1.25rem;
padding: 1rem;
}
.controls {
flex-direction: column;
width: 100%;
}
.control-group,
.generate-button,
.lock-button {
width: 100%;
justify-content: center;
}
.formats-grid {
grid-template-columns: 1fr;
}
.validation-input-group {
flex-direction: column;
}
.validate-button {
width: 100%;
}
.toast-container {
left: 1rem;
right: 1rem;
bottom: 1rem;
}
.toast {
min-width: auto;
}
}
@media (max-width: 480px) {
.title {
font-size: 1.5rem;
}
.title-icon {
font-size: 1.5rem;
}
.theme-toggle {
width: 40px;
height: 40px;
font-size: 1.25rem;
}
}
/* ===== Accessibility ===== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
border-radius: 4px;
}
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
/* ===== Selection ===== */
::selection {
background: var(--accent-primary);
color: white;
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"isolatedModules": true,
"jsx": "react-jsx",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"noEmit": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"skipLibCheck": true,
/* Linting */
"strict": true,
"target": "ES2020",
"useDefineForClassFields": true
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"strict": true
},
"include": [
"vite.config.ts"
]
}
+12
View File
@@ -0,0 +1,12 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: '/',
test: {
globals: true,
environment: 'jsdom',
},
})
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: [],
},
})