mirror of
https://github.com/Nezumi-2711/s3-drive-storage-manage.git
synced 2026-09-22 13:48:31 +00:00
fix: improve the ui
This commit is contained in:
+6
-3
@@ -1,10 +1,13 @@
|
||||
import { QueryProvider } from "@/providers/QueryProvider"
|
||||
import { ThemeProvider } from "@/providers/ThemeProvider"
|
||||
import { AppRouter } from "@/router"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AppRouter />
|
||||
</QueryProvider>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<QueryProvider>
|
||||
<AppRouter />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "@/providers/ThemeProvider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string
|
||||
showLabel?: boolean
|
||||
variant?: "outline" | "ghost" | "default" | "secondary"
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
}
|
||||
|
||||
export function ThemeToggle({
|
||||
className,
|
||||
showLabel = false,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
}: ThemeToggleProps) {
|
||||
const { theme, resolvedTheme, toggleTheme } = useTheme()
|
||||
const isDark = resolvedTheme === "dark"
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-xl transition-all duration-200 cursor-pointer shrink-0",
|
||||
className
|
||||
)}
|
||||
title={`Current: ${theme} (click to switch to ${isDark ? "light" : "dark"} mode)`}
|
||||
aria-label={`Current theme: ${theme}. Click to switch to ${isDark ? "light" : "dark"} mode.`}
|
||||
>
|
||||
<div className="relative flex items-center justify-center">
|
||||
{isDark ? (
|
||||
<Moon className="h-4 w-4 text-cyan-400 transition-transform duration-200 hover:scale-110" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4 text-amber-500 transition-transform duration-200 hover:scale-110" />
|
||||
)}
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="capitalize hidden min-[360px]:inline ml-1.5 text-xs">
|
||||
{isDark ? "Dark" : "Light"}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Activity, AlertTriangle, Server, HardDrive, Cpu, AlertCircle, User, CheckCircle2 } from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { formatBytes } from "@/lib/format"
|
||||
import type { StatusResponse } from "../api/status.types"
|
||||
|
||||
interface SystemOverviewCardProps {
|
||||
status?: StatusResponse
|
||||
isLoading: boolean
|
||||
error?: Error | null
|
||||
}
|
||||
|
||||
export function SystemOverviewCard({ status, isLoading, error }: SystemOverviewCardProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md py-4 sm:py-5">
|
||||
<CardContent className="px-4 sm:px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 divide-y md:divide-y-0 md:divide-x divide-border/60">
|
||||
{/* Gateway Skeleton */}
|
||||
<div className="space-y-2.5 pb-3 md:pb-0">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-7 w-36" />
|
||||
<Skeleton className="h-4 w-44" />
|
||||
</div>
|
||||
{/* Storage Skeleton */}
|
||||
<div className="space-y-2.5 pt-3 md:pt-0 md:px-6">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
</div>
|
||||
{/* Multipart Skeleton */}
|
||||
<div className="space-y-2.5 pt-3 md:pt-0 md:pl-6">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-7 w-32" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !status) {
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-destructive/40 bg-card/90 backdrop-blur-sm shadow-md py-4 sm:py-5">
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-destructive" />
|
||||
<CardContent className="px-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-destructive/10 text-destructive shrink-0">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-destructive">Gateway Service Unavailable</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{error?.message || "Failed to query live status from Cloudflare Worker"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const isDegraded = status.gateway.status === "degraded" || !status.drive.connected
|
||||
const drive = status.drive
|
||||
const quota = drive.quota
|
||||
const isUnlimited = quota?.limit == null
|
||||
const percentUsed = quota?.percentUsed ?? 0
|
||||
const isMultipartEnabled = status.gateway.multipartEnabled ?? false
|
||||
const etagStyle = status.gateway.etagStyle ?? "md5"
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md hover:shadow-lg transition-all py-4 sm:py-5 group">
|
||||
{/* Top accent bar with unified status gradient */}
|
||||
<div
|
||||
className={`absolute top-0 left-0 right-0 h-1 ${
|
||||
isDegraded
|
||||
? "bg-gradient-to-r from-amber-500 to-red-500"
|
||||
: "bg-gradient-to-r from-emerald-500 via-blue-500 to-cyan-500"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<CardContent className="px-4 sm:px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-border/60">
|
||||
|
||||
{/* Section 1: Gateway Engine */}
|
||||
<div className="flex flex-col justify-between space-y-2 pb-4 md:pb-0 md:pr-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Gateway Engine
|
||||
</span>
|
||||
<div
|
||||
className={`p-1.5 rounded-lg ${
|
||||
isDegraded
|
||||
? "bg-amber-500/10 text-amber-500"
|
||||
: "bg-emerald-500/10 text-emerald-500"
|
||||
} group-hover:scale-105 transition-transform`}
|
||||
>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xl sm:text-2xl font-bold flex items-center gap-2 text-foreground">
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${
|
||||
isDegraded
|
||||
? "bg-amber-500 shadow-xs shadow-amber-500/50"
|
||||
: "bg-emerald-500 shadow-xs shadow-emerald-500/50 animate-pulse"
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">{isDegraded ? "Degraded" : "Online & Healthy"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground flex items-center justify-between gap-1 pt-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isDegraded ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
) : (
|
||||
<Activity className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{isDegraded
|
||||
? drive.error || "Drive connection degraded"
|
||||
: `Edge routing · Region: ${status.gateway.region}`}
|
||||
</span>
|
||||
</div>
|
||||
{status.gateway.docsEnabled && (
|
||||
<Badge variant="neutral" className="text-[10px] px-1.5 py-0 shrink-0">
|
||||
Docs
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: S3 Storage Backend (Google Drive) */}
|
||||
<div className="flex flex-col justify-between space-y-2 py-4 md:py-0 md:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
S3 Storage Backend
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-500 group-hover:scale-105 transition-transform">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!drive.connected ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xl font-bold text-foreground">Google Drive</div>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{drive.error || "Drive connection error"}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="text-xl sm:text-2xl font-bold text-foreground">
|
||||
{formatBytes(quota?.usage)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-medium">
|
||||
{isUnlimited ? "Unlimited" : `/ ${formatBytes(quota?.limit)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUnlimited && (
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={percentUsed}
|
||||
indicatorClassName={
|
||||
percentUsed > 90
|
||||
? "bg-red-500"
|
||||
: percentUsed > 75
|
||||
? "bg-amber-500"
|
||||
: "bg-gradient-to-r from-blue-600 to-cyan-500"
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground">
|
||||
<span>{percentUsed}% used</span>
|
||||
<span>{formatBytes(quota?.free)} free</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5 pt-0.5 truncate">
|
||||
<User className="h-3.5 w-3.5 text-blue-500 shrink-0" />
|
||||
<span className="truncate">
|
||||
{drive.account?.email || drive.account?.displayName || "Google Drive"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Multipart Uploads */}
|
||||
<div className="flex flex-col justify-between space-y-2 pt-4 md:pt-0 md:pl-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Multipart Uploads
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-cyan-500/10 text-cyan-500 group-hover:scale-105 transition-transform">
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xl sm:text-2xl font-bold text-foreground flex items-center justify-between">
|
||||
<span>{isMultipartEnabled ? "Enabled" : "Disabled"}</span>
|
||||
<Badge variant={isMultipartEnabled ? "success" : "neutral"} className="text-[10px] px-2 py-0.5">
|
||||
{etagStyle.toUpperCase()} ETag
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5 pt-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-cyan-500 shrink-0" />
|
||||
<span className="truncate">
|
||||
{isMultipartEnabled
|
||||
? "Durable Objects resumable state"
|
||||
: "ALLOW_MULTIPART disabled"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -73,6 +73,16 @@
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16.5px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
html {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
@@ -91,6 +101,12 @@ body {
|
||||
linear-gradient(to bottom, rgba(99, 102, 241, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.dark .bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(99, 102, 241, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(99, 102, 241, 0.08) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.bg-gradient-brand {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%);
|
||||
}
|
||||
|
||||
+9
-28
@@ -13,11 +13,10 @@ import { useAuth } from "@/features/auth/hooks/useAuth"
|
||||
import { useStatus } from "@/features/status/hooks/useStatus"
|
||||
import { useBucketStats } from "@/features/status/hooks/useBucketStats"
|
||||
import { statusKeys } from "@/features/status/api/status.keys"
|
||||
import { GatewayStatusCard } from "@/features/status/components/GatewayStatusCard"
|
||||
import { DriveQuotaCard } from "@/features/status/components/DriveQuotaCard"
|
||||
import { MultipartStatusCard } from "@/features/status/components/MultipartStatusCard"
|
||||
import { SystemOverviewCard } from "@/features/status/components/SystemOverviewCard"
|
||||
import { BucketStatsTable } from "@/features/status/components/BucketStatsTable"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ThemeToggle } from "@/components/ThemeToggle"
|
||||
import { formatRelativeTime } from "@/lib/format"
|
||||
|
||||
export default function Dashboard() {
|
||||
@@ -62,13 +61,6 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-background bg-grid-pattern flex flex-col overflow-x-hidden">
|
||||
{/* Background ambient colorful glow effects */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-[15%] -left-[10%] h-[500px] w-[500px] rounded-full bg-blue-500/10 blur-[130px]" />
|
||||
<div className="absolute top-[20%] -right-[15%] h-[550px] w-[550px] rounded-full bg-cyan-400/10 blur-[140px]" />
|
||||
<div className="absolute -bottom-[20%] left-[30%] h-[400px] w-[400px] rounded-full bg-indigo-500/10 blur-[130px]" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Header */}
|
||||
<header className="border-b border-border/70 bg-card/80 backdrop-blur-md sticky top-0 z-20 shadow-xs">
|
||||
<div className="max-w-6xl mx-auto px-3 sm:px-6 min-h-16 py-2.5 sm:py-0 flex items-center justify-between gap-3">
|
||||
@@ -106,6 +98,7 @@ export default function Dashboard() {
|
||||
<Lock className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="font-medium text-foreground/80">Admin Mode</span>
|
||||
</div>
|
||||
<ThemeToggle showLabel={false} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -170,24 +163,12 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status / Quick Overview cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<GatewayStatusCard
|
||||
status={statusData}
|
||||
isLoading={isStatusLoading}
|
||||
error={statusError}
|
||||
/>
|
||||
<DriveQuotaCard
|
||||
drive={statusData?.drive}
|
||||
isLoading={isStatusLoading}
|
||||
error={statusError}
|
||||
/>
|
||||
<MultipartStatusCard
|
||||
gateway={statusData?.gateway}
|
||||
isLoading={isStatusLoading}
|
||||
error={statusError}
|
||||
/>
|
||||
</div>
|
||||
{/* Unified Status / Quick Overview */}
|
||||
<SystemOverviewCard
|
||||
status={statusData}
|
||||
isLoading={isStatusLoading}
|
||||
error={statusError}
|
||||
/>
|
||||
|
||||
{/* Feature Capabilities & Specifications */}
|
||||
<div className="w-full">
|
||||
|
||||
+89
-126
@@ -1,19 +1,12 @@
|
||||
import React, { useState } from "react"
|
||||
import { useNavigate, Navigate } from "react-router"
|
||||
import { Eye, EyeOff, Lock, Database, ArrowRight, Loader2, AlertCircle, Sparkles, Shield, Cloud } from "lucide-react"
|
||||
import { Eye, EyeOff, Lock, Database, ArrowRight, Loader2, AlertCircle } from "lucide-react"
|
||||
import { useAuth } from "@/features/auth/hooks/useAuth"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { ThemeToggle } from "@/components/ThemeToggle"
|
||||
|
||||
function getErrorMessage(error: Error | null): string | null {
|
||||
if (!error) return null
|
||||
@@ -75,134 +68,104 @@ export default function SignIn() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background bg-grid-pattern p-4 sm:p-6 lg:p-8 overflow-hidden">
|
||||
{/* Background ambient colorful glow effects */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-[20%] -left-[10%] h-[550px] w-[550px] rounded-full bg-blue-500/15 blur-[130px]" />
|
||||
<div className="absolute top-[30%] -right-[15%] h-[500px] w-[500px] rounded-full bg-cyan-400/15 blur-[120px]" />
|
||||
<div className="absolute -bottom-[20%] left-[25%] h-[450px] w-[450px] rounded-full bg-indigo-500/12 blur-[140px]" />
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background bg-grid-pattern p-4 overflow-hidden">
|
||||
{/* Theme Toggle in top-right */}
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<ThemeToggle showLabel={false} />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Logo and Brand Header */}
|
||||
<div className="mb-8 text-center flex flex-col items-center">
|
||||
<div className="relative mb-4 group cursor-default">
|
||||
<div className="absolute -inset-1 rounded-2xl bg-gradient-to-r from-blue-600 via-indigo-500 to-cyan-400 opacity-70 blur-sm group-hover:opacity-100 transition duration-300" />
|
||||
<div className="relative inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-tr from-blue-600 via-indigo-600 to-cyan-500 text-white shadow-xl shadow-blue-500/25">
|
||||
<Database className="h-8 w-8 text-white stroke-[2.2]" />
|
||||
</div>
|
||||
<div className="absolute -top-1.5 -right-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-emerald-500 text-white ring-2 ring-background shadow-xs">
|
||||
<Cloud className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
{/* Brand & Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="inline-flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-xs mb-1">
|
||||
<Database className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-600 dark:text-blue-400 text-xs font-semibold mb-3">
|
||||
<Sparkles className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span>Cloudflare Worker & Drive Bridge</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-foreground sm:text-4xl">
|
||||
S3 Drive <span className="bg-gradient-to-r from-blue-600 via-indigo-600 to-cyan-500 bg-clip-text text-transparent">Storage</span>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
S3 Drive Storage
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground font-medium">
|
||||
High-performance management console for Google Drive S3 gateway
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter your management password to access the console
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sign In Card */}
|
||||
<Card className="border-border/70 bg-card/85 backdrop-blur-xl shadow-2xl shadow-blue-950/5 ring-1 ring-black/5 dark:ring-white/10 rounded-2xl overflow-hidden">
|
||||
<div className="h-1.5 w-full bg-gradient-to-r from-blue-500 via-indigo-500 to-cyan-400" />
|
||||
|
||||
<CardHeader className="space-y-1.5 pt-6 pb-4">
|
||||
<CardTitle className="text-xl font-bold text-center tracking-tight">
|
||||
Sign In to Console
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center text-xs text-muted-foreground">
|
||||
Enter your gateway access password to unlock management tools
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4 px-6">
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-start gap-2.5 rounded-xl border border-destructive/20 bg-destructive/10 p-3.5 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<div className="leading-relaxed font-medium">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Input Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-xs font-semibold text-foreground/80">
|
||||
Access Password
|
||||
</Label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3.5 pointer-events-none text-muted-foreground group-focus-within:text-blue-600 transition-colors">
|
||||
<Lock className="h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (signInError) resetSignInError()
|
||||
}}
|
||||
placeholder="Enter management password"
|
||||
className="pl-10 pr-10 h-11 text-sm bg-background/80 border-border/80 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/40 focus-visible:border-blue-500 transition-all"
|
||||
disabled={isSigningIn}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3.5 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-3 pb-7 px-6 flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 font-semibold rounded-xl text-sm"
|
||||
disabled={isSigningIn || !password.trim()}
|
||||
<div className="rounded-2xl border border-border/80 bg-card/90 backdrop-blur-md p-6 shadow-xl shadow-black/5 dark:shadow-black/30">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/10 p-3 text-xs text-destructive animate-in fade-in duration-200"
|
||||
role="alert"
|
||||
>
|
||||
{isSigningIn ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Verifying Credentials...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Unlock Console
|
||||
<ArrowRight className="h-4 w-4 ml-1 transition-transform group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<div className="leading-relaxed font-medium">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security badge footer */}
|
||||
<div className="mt-8 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<Shield className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span>Protected by S3 Drive Storage Gateway • SHA-256 Auth</span>
|
||||
{/* Password Input Field */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password" className="text-xs font-medium text-muted-foreground">
|
||||
Access Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-muted-foreground">
|
||||
<Lock className="h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (signInError) resetSignInError()
|
||||
}}
|
||||
placeholder="••••••••••••"
|
||||
className="pl-9 pr-9 h-10 text-sm bg-background/60 border-border rounded-lg focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:border-primary transition-all"
|
||||
disabled={isSigningIn}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10 font-medium rounded-lg text-sm mt-2"
|
||||
disabled={isSigningIn || !password.trim()}
|
||||
>
|
||||
{isSigningIn ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sign In
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Minimal Footer */}
|
||||
<p className="text-center text-[11px] text-muted-foreground/70">
|
||||
Protected by S3 Drive Gateway Auth
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as React from "react"
|
||||
|
||||
export type Theme = "light" | "dark" | "system"
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
resolvedTheme: "light" | "dark"
|
||||
setTheme: (theme: Theme) => void
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "s3-drive-ui-theme",
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey) as Theme | null
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
return stored
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable in some private browsing modes
|
||||
}
|
||||
return defaultTheme
|
||||
})
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
||||
if (theme === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||
}
|
||||
return theme
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
|
||||
const applyTheme = () => {
|
||||
const currentResolved =
|
||||
theme === "system" ? (mediaQuery.matches ? "dark" : "light") : theme
|
||||
|
||||
setResolvedTheme(currentResolved)
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(currentResolved)
|
||||
root.style.colorScheme = currentResolved
|
||||
}
|
||||
|
||||
applyTheme()
|
||||
|
||||
const listener = () => {
|
||||
if (theme === "system") {
|
||||
applyTheme()
|
||||
}
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", listener)
|
||||
return () => mediaQuery.removeEventListener("change", listener)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = React.useCallback(
|
||||
(newTheme: Theme) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, newTheme)
|
||||
} catch {
|
||||
// ignore storage write errors
|
||||
}
|
||||
setThemeState(newTheme)
|
||||
},
|
||||
[storageKey]
|
||||
)
|
||||
|
||||
const toggleTheme = React.useCallback(() => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [resolvedTheme, setTheme])
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
}),
|
||||
[theme, resolvedTheme, setTheme, toggleTheme]
|
||||
)
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextType {
|
||||
const context = React.useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user