feat(frontend): implement basic UI foundation (Issue #6)

This commit is contained in:
Wikid82
2025-11-19 10:53:55 -05:00
parent 9f74367ae6
commit d559a24c45
10 changed files with 286 additions and 13 deletions
+41 -12
View File
@@ -1,5 +1,7 @@
import { ReactNode } from 'react'
import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { ThemeToggle } from './ThemeToggle'
import { Button } from './ui/Button'
interface LayoutProps {
children: ReactNode
@@ -7,6 +9,7 @@ interface LayoutProps {
export default function Layout({ children }: LayoutProps) {
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const navigation = [
{ name: 'Dashboard', path: '/', icon: '📊' },
@@ -17,23 +20,41 @@ export default function Layout({ children }: LayoutProps) {
]
return (
<div className="min-h-screen bg-dark-bg flex">
{/* Sidebar */}
<aside className="w-60 bg-dark-sidebar border-r border-gray-800 flex flex-col">
<div className="p-6">
<h1 className="text-xl font-bold text-white">Caddy Proxy Manager+</h1>
<div className="min-h-screen bg-gray-50 dark:bg-dark-bg flex transition-colors duration-200">
{/* Mobile Header */}
<div className="lg:hidden fixed top-0 left-0 right-0 h-16 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 flex items-center justify-between px-4 z-40">
<h1 className="text-lg font-bold text-gray-900 dark:text-white">CPM+</h1>
<div className="flex items-center gap-2">
<ThemeToggle />
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? '✕' : '☰'}
</Button>
</div>
<nav className="flex-1 px-4 space-y-1">
</div>
{/* Sidebar */}
<aside className={`
fixed lg:static inset-y-0 left-0 z-30 w-64 transform transition-transform duration-200 ease-in-out
bg-white dark:bg-dark-sidebar border-r border-gray-200 dark:border-gray-800 flex flex-col
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<div className="p-6 hidden lg:flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">CPM+</h1>
<ThemeToggle />
</div>
<nav className="flex-1 px-4 space-y-1 mt-16 lg:mt-0">
{navigation.map((item) => {
const isActive = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-active text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
? 'bg-blue-100 text-blue-700 dark:bg-blue-active dark:text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="text-lg">{item.icon}</span>
@@ -42,15 +63,23 @@ export default function Layout({ children }: LayoutProps) {
)
})}
</nav>
<div className="p-4 border-t border-gray-800">
<div className="text-xs text-gray-500">
<div className="p-4 border-t border-gray-200 dark:border-gray-800">
<div className="text-xs text-gray-500 dark:text-gray-500">
Version 0.1.0
</div>
</div>
</aside>
{/* Overlay for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<main className="flex-1 overflow-auto">
<main className="flex-1 overflow-auto pt-16 lg:pt-0">
{children}
</main>
</div>
+12
View File
@@ -0,0 +1,12 @@
import { useTheme } from '../context/ThemeContext'
import { Button } from './ui/Button'
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<Button variant="ghost" size="sm" onClick={toggleTheme} title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
{theme === 'dark' ? '☀️' : '🌙'}
</Button>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
import { clsx } from 'clsx'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
children: ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
isLoading = false,
className,
children,
disabled,
...props
}: ButtonProps) {
const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-700 text-white hover:bg-gray-600 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'text-gray-400 hover:text-white hover:bg-gray-800 focus:ring-gray-500',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
}
return (
<button
className={clsx(
baseStyles,
variants[variant],
sizes[size],
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{children}
</button>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { ReactNode } from 'react'
import { clsx } from 'clsx'
interface CardProps {
children: ReactNode
className?: string
title?: string
description?: string
footer?: ReactNode
}
export function Card({ children, className, title, description, footer }: CardProps) {
return (
<div className={clsx('bg-dark-card rounded-lg border border-gray-800 overflow-hidden', className)}>
{(title || description) && (
<div className="px-6 py-4 border-b border-gray-800">
{title && <h3 className="text-lg font-medium text-white">{title}</h3>}
{description && <p className="mt-1 text-sm text-gray-400">{description}</p>}
</div>
)}
<div className="p-6">
{children}
</div>
{footer && (
<div className="px-6 py-4 bg-gray-900/50 border-t border-gray-800">
{footer}
</div>
)}
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { InputHTMLAttributes, forwardRef } from 'react'
import { clsx } from 'clsx'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{label}
</label>
)}
<input
ref={ref}
className={clsx(
'w-full bg-gray-900 border rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors',
error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-700 focus:ring-blue-500 focus:border-blue-500',
className
)}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-400">{error}</p>
)}
{helperText && !error && (
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'