feat: add all application source code
- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
118
apps/web/src/components/ui/border-beam.tsx
Normal file
118
apps/web/src/components/ui/border-beam.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* BorderBeam - Magic UI 流光邊框特效
|
||||
* ===================================
|
||||
* 用於 CRITICAL 風險等級的審核卡片
|
||||
*
|
||||
* Features:
|
||||
* - 沿邊框流動的光束動畫
|
||||
* - 可配置顏色、速度、寬度
|
||||
* - 支援暫停/啟動
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type CSSProperties, type HTMLAttributes } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface BorderBeamProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 動畫持續時間 (秒) */
|
||||
duration?: number
|
||||
/** 邊框寬度 (px) */
|
||||
borderWidth?: number
|
||||
/** 光束顏色 - 起始色 */
|
||||
colorFrom?: string
|
||||
/** 光束顏色 - 結束色 */
|
||||
colorTo?: string
|
||||
/** 動畫延遲 (秒) */
|
||||
delay?: number
|
||||
/** 光束尺寸 (px) */
|
||||
size?: number
|
||||
/** 是否暫停動畫 */
|
||||
paused?: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function BorderBeam({
|
||||
className,
|
||||
duration = 6,
|
||||
borderWidth = 2,
|
||||
colorFrom = '#FF3300',
|
||||
colorTo = '#FF6B35',
|
||||
delay = 0,
|
||||
size = 200,
|
||||
paused = false,
|
||||
...props
|
||||
}: BorderBeamProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 rounded-xl overflow-hidden',
|
||||
'[mask-composite:intersect] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]',
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--duration': `${duration}s`,
|
||||
'--delay': `${delay}s`,
|
||||
'--color-from': colorFrom,
|
||||
'--color-to': colorTo,
|
||||
'--size': `${size}px`,
|
||||
padding: `${borderWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0',
|
||||
'bg-[conic-gradient(from_calc(var(--start,_0)*1turn),transparent_0%,var(--color-from)_10%,var(--color-to)_20%,transparent_30%)]',
|
||||
'[animation:border-beam_var(--duration)_linear_infinite]',
|
||||
paused && '[animation-play-state:paused]'
|
||||
)}
|
||||
style={{
|
||||
width: `calc(100% + var(--size))`,
|
||||
height: `calc(100% + var(--size))`,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animationDelay: `var(--delay)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CriticalGlow - CRITICAL 風險等級的脈動光暈
|
||||
*/
|
||||
export function CriticalGlow({
|
||||
className,
|
||||
intensity = 'medium',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { intensity?: 'low' | 'medium' | 'high' }) {
|
||||
const intensityStyles = {
|
||||
low: 'opacity-20',
|
||||
medium: 'opacity-40',
|
||||
high: 'opacity-60',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-xl pointer-events-none',
|
||||
'bg-gradient-to-br from-status-critical/20 via-transparent to-status-critical/10',
|
||||
intensityStyles[intensity],
|
||||
'animate-pulse',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
204
apps/web/src/components/ui/dot-matrix-bg.tsx
Normal file
204
apps/web/src/components/ui/dot-matrix-bg.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DotMatrixBg - 工業風點陣背景
|
||||
* ============================
|
||||
* Nothing.tech 明亮工業風設計
|
||||
*
|
||||
* Features:
|
||||
* - CSS Pattern 實作 (高效能)
|
||||
* - 可調整點陣密度與顏色
|
||||
* - 支援漸層遮罩
|
||||
* - 固定背景或跟隨滾動
|
||||
*
|
||||
* Usage:
|
||||
* <DotMatrixBg />
|
||||
* <main>...content...</main>
|
||||
*/
|
||||
|
||||
import { forwardRef, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface DotMatrixBgProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 點陣密度 (間距 px) */
|
||||
spacing?: number
|
||||
/** 點的大小 (px) */
|
||||
dotSize?: number
|
||||
/** 點的顏色 (CSS color) */
|
||||
dotColor?: string
|
||||
/** 背景顏色 */
|
||||
bgColor?: string
|
||||
/** 是否固定位置 (fixed) */
|
||||
fixed?: boolean
|
||||
/** 是否添加漸層遮罩 */
|
||||
fade?: 'none' | 'bottom' | 'edges'
|
||||
/** 透明度 (0-1) */
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const DotMatrixBg = forwardRef<HTMLDivElement, DotMatrixBgProps>(
|
||||
(
|
||||
{
|
||||
spacing = 24,
|
||||
dotSize = 1,
|
||||
dotColor = 'rgba(0, 0, 0, 0.08)',
|
||||
bgColor = '#FAFAFA',
|
||||
fixed = true,
|
||||
fade = 'none',
|
||||
opacity = 1,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// CSS Pattern for dot matrix
|
||||
const dotPattern = `radial-gradient(circle, ${dotColor} ${dotSize}px, transparent ${dotSize}px)`
|
||||
|
||||
// Fade gradients
|
||||
const fadeStyles: Record<typeof fade, string> = {
|
||||
none: '',
|
||||
bottom: 'mask-image: linear-gradient(to bottom, black 70%, transparent 100%)',
|
||||
edges: 'mask-image: radial-gradient(ellipse at center, black 50%, transparent 100%)',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
// Positioning
|
||||
fixed ? 'fixed' : 'absolute',
|
||||
'inset-0 -z-10',
|
||||
// Pointer events
|
||||
'pointer-events-none',
|
||||
// Custom
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
backgroundImage: dotPattern,
|
||||
backgroundSize: `${spacing}px ${spacing}px`,
|
||||
backgroundPosition: '0 0',
|
||||
opacity,
|
||||
...(fade !== 'none' && {
|
||||
WebkitMaskImage:
|
||||
fade === 'bottom'
|
||||
? 'linear-gradient(to bottom, black 70%, transparent 100%)'
|
||||
: 'radial-gradient(ellipse at center, black 50%, transparent 100%)',
|
||||
maskImage:
|
||||
fade === 'bottom'
|
||||
? 'linear-gradient(to bottom, black 70%, transparent 100%)'
|
||||
: 'radial-gradient(ellipse at center, black 50%, transparent 100%)',
|
||||
}),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
DotMatrixBg.displayName = 'DotMatrixBg'
|
||||
|
||||
// =============================================================================
|
||||
// Preset Variants
|
||||
// =============================================================================
|
||||
|
||||
/** 淺色點陣 (預設) */
|
||||
export function DotMatrixLight(props: Omit<DotMatrixBgProps, 'dotColor' | 'bgColor'>) {
|
||||
return (
|
||||
<DotMatrixBg
|
||||
dotColor="rgba(0, 0, 0, 0.06)"
|
||||
bgColor="#FAFAFA"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** 極淺點陣 (更subtle) */
|
||||
export function DotMatrixSubtle(props: Omit<DotMatrixBgProps, 'dotColor' | 'bgColor'>) {
|
||||
return (
|
||||
<DotMatrixBg
|
||||
dotColor="rgba(0, 0, 0, 0.03)"
|
||||
bgColor="#FFFFFF"
|
||||
spacing={20}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** 密集點陣 (更工業風) */
|
||||
export function DotMatrixDense(props: Omit<DotMatrixBgProps, 'spacing'>) {
|
||||
return (
|
||||
<DotMatrixBg
|
||||
spacing={16}
|
||||
dotSize={1}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Grid Lines Background (替代選項)
|
||||
// =============================================================================
|
||||
|
||||
export interface GridLinesBgProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 網格大小 (px) */
|
||||
gridSize?: number
|
||||
/** 線條顏色 */
|
||||
lineColor?: string
|
||||
/** 背景顏色 */
|
||||
bgColor?: string
|
||||
/** 是否固定 */
|
||||
fixed?: boolean
|
||||
}
|
||||
|
||||
export const GridLinesBg = forwardRef<HTMLDivElement, GridLinesBgProps>(
|
||||
(
|
||||
{
|
||||
gridSize = 40,
|
||||
lineColor = 'rgba(0, 0, 0, 0.04)',
|
||||
bgColor = '#FAFAFA',
|
||||
fixed = true,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const gridPattern = `
|
||||
linear-gradient(to right, ${lineColor} 1px, transparent 1px),
|
||||
linear-gradient(to bottom, ${lineColor} 1px, transparent 1px)
|
||||
`
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
fixed ? 'fixed' : 'absolute',
|
||||
'inset-0 -z-10 pointer-events-none',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
backgroundImage: gridPattern,
|
||||
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
GridLinesBg.displayName = 'GridLinesBg'
|
||||
224
apps/web/src/components/ui/glass-card.tsx
Normal file
224
apps/web/src/components/ui/glass-card.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GlassCard - 純白透光玻璃卡片
|
||||
* ============================
|
||||
* Nothing.tech 明亮工業風設計
|
||||
*
|
||||
* Features:
|
||||
* - backdrop-blur 毛玻璃效果
|
||||
* - 極淡灰色邊框 (border-black/5)
|
||||
* - Hover 浮動效果
|
||||
* - 支援多種尺寸與變體
|
||||
*
|
||||
* WCAG 2.1 AA: 背景對比度 4.5:1+
|
||||
*/
|
||||
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 子元素 */
|
||||
children: ReactNode
|
||||
/** 變體樣式 */
|
||||
variant?: 'default' | 'elevated' | 'copilot' | 'subtle'
|
||||
/** 內距尺寸 */
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
/** 是否啟用 hover 效果 */
|
||||
hoverable?: boolean
|
||||
/** 是否可點擊 (啟用 cursor-pointer) */
|
||||
clickable?: boolean
|
||||
/** 無障礙標籤 */
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Styles
|
||||
// =============================================================================
|
||||
|
||||
const variantStyles = {
|
||||
default: [
|
||||
// 白色毛玻璃
|
||||
'bg-white/70',
|
||||
'backdrop-blur-[16px]',
|
||||
'-webkit-backdrop-blur-[16px]',
|
||||
// 極淡邊框
|
||||
'border border-black/[0.05]',
|
||||
// 陰影
|
||||
'shadow-[0_4px_24px_rgba(0,0,0,0.06)]',
|
||||
// 圓角
|
||||
'rounded-xl',
|
||||
],
|
||||
elevated: [
|
||||
'bg-white/80',
|
||||
'backdrop-blur-[20px]',
|
||||
'-webkit-backdrop-blur-[20px]',
|
||||
'border border-black/[0.08]',
|
||||
'shadow-[0_8px_32px_rgba(0,0,0,0.10)]',
|
||||
'rounded-xl',
|
||||
],
|
||||
copilot: [
|
||||
// AI Copilot 面板 - 帶紫色調
|
||||
'bg-nothing-cream/90',
|
||||
'backdrop-blur-[20px]',
|
||||
'-webkit-backdrop-blur-[20px]',
|
||||
'border border-status-thinking/20',
|
||||
'shadow-[0_4px_24px_rgba(139,92,246,0.1)]',
|
||||
'rounded-xl',
|
||||
],
|
||||
subtle: [
|
||||
'bg-white/50',
|
||||
'backdrop-blur-[12px]',
|
||||
'-webkit-backdrop-blur-[12px]',
|
||||
'border border-black/[0.03]',
|
||||
'shadow-[0_2px_12px_rgba(0,0,0,0.04)]',
|
||||
'rounded-lg',
|
||||
],
|
||||
}
|
||||
|
||||
const paddingStyles = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
const hoverStyles = [
|
||||
'transition-all duration-300 ease-out',
|
||||
'hover:bg-white/80',
|
||||
'hover:shadow-[0_8px_32px_rgba(0,0,0,0.10)]',
|
||||
'hover:border-black/[0.08]',
|
||||
'hover:-translate-y-0.5',
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
hoverable = false,
|
||||
clickable = false,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base styles
|
||||
variantStyles[variant],
|
||||
paddingStyles[padding],
|
||||
// Hover effect
|
||||
hoverable && hoverStyles,
|
||||
// Clickable
|
||||
clickable && 'cursor-pointer',
|
||||
// Custom classes
|
||||
className
|
||||
)}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
GlassCard.displayName = 'GlassCard'
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
||||
export interface GlassCardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function GlassCardHeader({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: GlassCardHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between mb-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface GlassCardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
|
||||
children: ReactNode
|
||||
as?: 'h1' | 'h2' | 'h3' | 'h4'
|
||||
}
|
||||
|
||||
export function GlassCardTitle({
|
||||
children,
|
||||
as: Tag = 'h3',
|
||||
className,
|
||||
...props
|
||||
}: GlassCardTitleProps) {
|
||||
return (
|
||||
<Tag
|
||||
className={cn(
|
||||
'font-heading text-lg font-semibold text-nothing-gray-900',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
export interface GlassCardContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function GlassCardContent({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: GlassCardContentProps) {
|
||||
return (
|
||||
<div className={cn('flex-1', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface GlassCardFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function GlassCardFooter({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: GlassCardFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-4 pt-3 border-t border-nothing-gray-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
apps/web/src/components/ui/index.ts
Normal file
36
apps/web/src/components/ui/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* AWOOOI UI Component Library
|
||||
* ===========================
|
||||
* Nothing.tech 明亮工業風原子組件
|
||||
*
|
||||
* CPO-101: 原子組件庫
|
||||
*/
|
||||
|
||||
// Glass Card
|
||||
export {
|
||||
GlassCard,
|
||||
GlassCardHeader,
|
||||
GlassCardTitle,
|
||||
GlassCardContent,
|
||||
GlassCardFooter,
|
||||
type GlassCardProps,
|
||||
} from './glass-card'
|
||||
|
||||
// Status Orb
|
||||
export {
|
||||
StatusOrb,
|
||||
StatusOrbWithRing,
|
||||
StatusBadge,
|
||||
type StatusOrbProps,
|
||||
type StatusType,
|
||||
} from './status-orb'
|
||||
|
||||
// Dot Matrix Background
|
||||
export {
|
||||
DotMatrixBg,
|
||||
DotMatrixLight,
|
||||
DotMatrixSubtle,
|
||||
DotMatrixDense,
|
||||
GridLinesBg,
|
||||
type DotMatrixBgProps,
|
||||
} from './dot-matrix-bg'
|
||||
253
apps/web/src/components/ui/status-orb.tsx
Normal file
253
apps/web/src/components/ui/status-orb.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* StatusOrb - 狀態呼吸燈
|
||||
* ======================
|
||||
* Nothing.tech 明亮工業風設計
|
||||
*
|
||||
* Features:
|
||||
* - 10px 預設尺寸 (可調整)
|
||||
* - 4 種狀態: healthy (綠), warning (橘), critical (紅), thinking (紫)
|
||||
* - 平滑 pulse 動畫
|
||||
* - WCAG 2.1 AA 色彩對比度
|
||||
*
|
||||
* Accessibility:
|
||||
* - 使用 aria-label 描述狀態
|
||||
* - 色彩 + 動畫雙重指示 (不僅依賴顏色)
|
||||
*/
|
||||
|
||||
import { forwardRef, type HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type StatusType = 'healthy' | 'warning' | 'critical' | 'thinking' | 'idle' | 'syncing'
|
||||
|
||||
export interface StatusOrbProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
|
||||
/** 狀態類型 */
|
||||
status: StatusType
|
||||
/** 尺寸 (px) */
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
/** 是否顯示脈衝動畫 */
|
||||
pulse?: boolean
|
||||
/** 是否顯示外發光 */
|
||||
glow?: boolean
|
||||
/** 無障礙標籤 (i18n) */
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Status Configuration
|
||||
// =============================================================================
|
||||
|
||||
interface StatusConfig {
|
||||
/** 背景色 */
|
||||
bgColor: string
|
||||
/** 發光色 */
|
||||
glowColor: string
|
||||
/** 是否預設動畫 */
|
||||
defaultPulse: boolean
|
||||
/** 無障礙標籤 (預設) */
|
||||
label: string
|
||||
}
|
||||
|
||||
const statusConfig: Record<StatusType, StatusConfig> = {
|
||||
healthy: {
|
||||
bgColor: 'bg-status-healthy', // #22C55E
|
||||
glowColor: 'shadow-glow-green',
|
||||
defaultPulse: false,
|
||||
label: 'Healthy',
|
||||
},
|
||||
warning: {
|
||||
bgColor: 'bg-status-warning', // #F59E0B
|
||||
glowColor: 'shadow-[0_0_12px_rgba(245,158,11,0.5)]',
|
||||
defaultPulse: true,
|
||||
label: 'Warning',
|
||||
},
|
||||
critical: {
|
||||
bgColor: 'bg-status-critical', // #FF3300
|
||||
glowColor: 'shadow-glow-red',
|
||||
defaultPulse: true,
|
||||
label: 'Critical',
|
||||
},
|
||||
thinking: {
|
||||
bgColor: 'bg-status-thinking', // #8B5CF6
|
||||
glowColor: 'shadow-glow-purple',
|
||||
defaultPulse: true,
|
||||
label: 'Thinking',
|
||||
},
|
||||
idle: {
|
||||
bgColor: 'bg-status-idle', // #A3A3A3
|
||||
glowColor: '',
|
||||
defaultPulse: false,
|
||||
label: 'Idle',
|
||||
},
|
||||
syncing: {
|
||||
bgColor: 'bg-status-syncing', // #3B82F6
|
||||
glowColor: 'shadow-glow-blue',
|
||||
defaultPulse: true,
|
||||
label: 'Syncing',
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Size Configuration
|
||||
// =============================================================================
|
||||
|
||||
const sizeConfig = {
|
||||
xs: 'w-2 h-2', // 8px
|
||||
sm: 'w-2.5 h-2.5', // 10px (default per spec)
|
||||
md: 'w-3 h-3', // 12px
|
||||
lg: 'w-4 h-4', // 16px
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const StatusOrb = forwardRef<HTMLSpanElement, StatusOrbProps>(
|
||||
(
|
||||
{
|
||||
status,
|
||||
size = 'sm',
|
||||
pulse,
|
||||
glow = false,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const config = statusConfig[status]
|
||||
const shouldPulse = pulse ?? config.defaultPulse
|
||||
const label = ariaLabel || config.label
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
// Base
|
||||
'inline-block rounded-full',
|
||||
// Size
|
||||
sizeConfig[size],
|
||||
// Color
|
||||
config.bgColor,
|
||||
// Glow effect
|
||||
glow && config.glowColor,
|
||||
// Pulse animation
|
||||
shouldPulse && 'animate-pulse',
|
||||
// Transition
|
||||
'transition-all duration-300',
|
||||
// Custom
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
StatusOrb.displayName = 'StatusOrb'
|
||||
|
||||
// =============================================================================
|
||||
// Variant with Ring (更明顯的指示器)
|
||||
// =============================================================================
|
||||
|
||||
export interface StatusOrbWithRingProps extends StatusOrbProps {
|
||||
/** 是否顯示外環 */
|
||||
showRing?: boolean
|
||||
}
|
||||
|
||||
export const StatusOrbWithRing = forwardRef<HTMLSpanElement, StatusOrbWithRingProps>(
|
||||
({ showRing = true, size = 'sm', status, className, ...props }, ref) => {
|
||||
const config = statusConfig[status]
|
||||
const shouldPulse = props.pulse ?? config.defaultPulse
|
||||
|
||||
// Ring sizes
|
||||
const ringSizeConfig = {
|
||||
xs: 'w-4 h-4',
|
||||
sm: 'w-5 h-5',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
}
|
||||
|
||||
if (!showRing) {
|
||||
return <StatusOrb ref={ref} status={status} size={size} className={className} {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center',
|
||||
ringSizeConfig[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Outer ring (animated) */}
|
||||
{shouldPulse && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-full opacity-30 animate-ping',
|
||||
config.bgColor
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Inner orb */}
|
||||
<StatusOrb status={status} size={size} pulse={false} {...props} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
StatusOrbWithRing.displayName = 'StatusOrbWithRing'
|
||||
|
||||
// =============================================================================
|
||||
// Status Badge (狀態 + 文字標籤)
|
||||
// =============================================================================
|
||||
|
||||
export interface StatusBadgeProps extends StatusOrbProps {
|
||||
/** 顯示文字 (i18n) */
|
||||
label?: string
|
||||
/** 標籤位置 */
|
||||
labelPosition?: 'right' | 'left'
|
||||
}
|
||||
|
||||
export const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(
|
||||
(
|
||||
{
|
||||
status,
|
||||
label,
|
||||
labelPosition = 'right',
|
||||
size = 'sm',
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const config = statusConfig[status]
|
||||
const displayLabel = label || config.label
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2',
|
||||
labelPosition === 'left' && 'flex-row-reverse',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<StatusOrb status={status} size={size} {...props} />
|
||||
<span className="text-xs font-mono text-nothing-gray-600">
|
||||
{displayLabel}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
StatusBadge.displayName = 'StatusBadge'
|
||||
191
apps/web/src/components/ui/toast.tsx
Normal file
191
apps/web/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AWOOOI Toast - 全局通知提示
|
||||
* ============================
|
||||
* Nothing.tech 設計語言
|
||||
*
|
||||
* 用法:
|
||||
* import { toast, ToastContainer } from '@/components/ui/toast'
|
||||
* toast.success('操作成功')
|
||||
* toast.error('操作失敗')
|
||||
* toast.info('K8s 指令發送中...')
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, createContext, useContext } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'loading'
|
||||
|
||||
interface ToastItem {
|
||||
id: string
|
||||
type: ToastType
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: ToastItem[]
|
||||
addToast: (type: ToastType, message: string, duration?: number) => string
|
||||
removeToast: (id: string) => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(null)
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string, duration = 4000) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
setToasts((prev) => [...prev, { id, type, message, duration }])
|
||||
|
||||
if (duration > 0 && type !== 'loading') {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Toast Container
|
||||
// =============================================================================
|
||||
|
||||
function ToastContainer() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) return null
|
||||
|
||||
const { toasts, removeToast } = context
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Toast Item
|
||||
// =============================================================================
|
||||
|
||||
const TOAST_CONFIG: Record<ToastType, { icon: typeof CheckCircle2; bg: string; border: string; text: string }> = {
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-status-healthy/10',
|
||||
border: 'border-status-healthy/30',
|
||||
text: 'text-status-healthy',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
bg: 'bg-status-critical/10',
|
||||
border: 'border-status-critical/30',
|
||||
text: 'text-status-critical',
|
||||
},
|
||||
info: {
|
||||
icon: AlertCircle,
|
||||
bg: 'bg-status-thinking/10',
|
||||
border: 'border-status-thinking/30',
|
||||
text: 'text-status-thinking',
|
||||
},
|
||||
loading: {
|
||||
icon: Loader2,
|
||||
bg: 'bg-nothing-gray-100',
|
||||
border: 'border-nothing-gray-300',
|
||||
text: 'text-nothing-gray-700',
|
||||
},
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: { toast: ToastItem; onClose: () => void }) {
|
||||
const config = TOAST_CONFIG[toast.type]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg backdrop-blur-md',
|
||||
'animate-slide-in-up min-w-[280px] max-w-[400px]',
|
||||
config.bg,
|
||||
config.border
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-5 h-5 flex-shrink-0',
|
||||
config.text,
|
||||
toast.type === 'loading' && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<p className={cn('font-mono text-sm', config.text)}>{toast.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Global Toast API (for use outside React components)
|
||||
// =============================================================================
|
||||
|
||||
let globalAddToast: ((type: ToastType, message: string, duration?: number) => string) | null = null
|
||||
let globalRemoveToast: ((id: string) => void) | null = null
|
||||
|
||||
export function setGlobalToast(
|
||||
addFn: (type: ToastType, message: string, duration?: number) => string,
|
||||
removeFn: (id: string) => void
|
||||
) {
|
||||
globalAddToast = addFn
|
||||
globalRemoveToast = removeFn
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, duration?: number) => globalAddToast?.('success', message, duration),
|
||||
error: (message: string, duration?: number) => globalAddToast?.('error', message, duration),
|
||||
info: (message: string, duration?: number) => globalAddToast?.('info', message, duration),
|
||||
loading: (message: string) => globalAddToast?.('loading', message, 0),
|
||||
dismiss: (id: string) => globalRemoveToast?.(id),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Toast Initializer (放在 layout 中)
|
||||
// =============================================================================
|
||||
|
||||
export function ToastInitializer() {
|
||||
const { addToast, removeToast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalToast(addToast, removeToast)
|
||||
}, [addToast, removeToast])
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user