All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA combobox + listbox pattern for language selector, role=switch on glossary toggle, role=status live region on notifications, aria-labels on password toggles, visible-on-focus close buttons, RTL logical positioning (start-*). Trust: third-party Memento promo removed from translate page, sidebar and all 13 locales; fabricated stats (99.9%, Turbo, computed layout-integrity bars) replaced with real measurements incl. API estimated remaining time; silent download failure now surfaces an error notification. Honesty: fake 100-byte file injections removed (format chips are now informational); cancel-that-doesn't renamed 'Back to start' with hint; Enterprise contact placeholder replaced with contact@wordly.art. i18n: t() no longer returns raw keys (empty string + defaultValue support, ~30 dead || fallbacks now work); ~170 new keys EN+FR across new reviews/ teams namespaces, glossaries context tab, translate monitor, settings, services, pricing, landing, fileUploader; split-key italic titles replace lastIndexOf() surgery (zh/ja-safe); key-audit script added 0 missing. Flow: active job persisted across refresh with polling resume (24h TTL); client-side recent-jobs history with review links; review page linked from complete state; settings/services added to dashboard nav; Business/ Enterprise regain glossary access (tier gate unified). Typeset (sober-tool direction): 7.5-9px labels raised to 10-12px, /30 opacity to /45-/55, uppercase tracking reduced, trust footer legible, country flags removed from language switcher, localized dates. Cleanup: 5 orphaned translate components, dead site header/footer, fossil tailwind.config.js, PipelineStepper, duplicate pill+H1 titles, two-step confirm for cache clear, dead landing footer links. Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD (no regression, -3 warnings), detector 4 -> 3 findings.
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { cva, type VariantProps } from "class-variance-authority"
|
|
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const notificationVariants = cva(
|
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pe-8 shadow-lg transition-all duration-300 ease-out",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: "border-border bg-card text-foreground",
|
|
destructive: "border-destructive bg-destructive text-destructive-foreground",
|
|
success: "border-success bg-success text-success-foreground",
|
|
warning: "border-warning bg-warning text-warning-foreground",
|
|
info: "border-primary bg-primary text-primary-foreground",
|
|
glass: "glass text-foreground border-border/20",
|
|
},
|
|
size: {
|
|
default: "max-w-md",
|
|
sm: "max-w-sm",
|
|
lg: "max-w-lg",
|
|
xl: "max-w-xl",
|
|
full: "max-w-full",
|
|
},
|
|
position: {
|
|
"top-right": "fixed top-4 right-4 z-50",
|
|
"top-left": "fixed top-4 left-4 z-50",
|
|
"bottom-right": "fixed bottom-4 right-4 z-50",
|
|
"bottom-left": "fixed bottom-4 left-4 z-50",
|
|
"top-center": "fixed top-4 left-1/2 transform -translate-x-1/2 z-50",
|
|
"bottom-center": "fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50",
|
|
"center": "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50",
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: "default",
|
|
size: "default",
|
|
position: "top-right",
|
|
},
|
|
}
|
|
)
|
|
|
|
export interface NotificationProps
|
|
extends React.HTMLAttributes<HTMLDivElement>,
|
|
VariantProps<typeof notificationVariants> {
|
|
title?: string
|
|
description?: string
|
|
action?: React.ReactNode
|
|
icon?: React.ReactNode
|
|
loading?: boolean
|
|
closable?: boolean
|
|
autoClose?: boolean
|
|
duration?: number
|
|
onClose?: () => void
|
|
}
|
|
|
|
const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
|
({
|
|
className,
|
|
variant,
|
|
size,
|
|
position,
|
|
title,
|
|
description,
|
|
action,
|
|
icon,
|
|
loading = false,
|
|
closable = true,
|
|
autoClose = true,
|
|
duration = 5000,
|
|
onClose,
|
|
children,
|
|
...props
|
|
}, ref) => {
|
|
const [visible, setVisible] = React.useState(true)
|
|
const [progress, setProgress] = React.useState(100)
|
|
|
|
React.useEffect(() => {
|
|
if (autoClose && !loading) {
|
|
const startTime = Date.now()
|
|
const interval = setInterval(() => {
|
|
const elapsed = Date.now() - startTime
|
|
const remaining = Math.max(0, 100 - (elapsed / duration) * 100)
|
|
setProgress(remaining)
|
|
|
|
if (remaining === 0) {
|
|
clearInterval(interval)
|
|
setVisible(false)
|
|
onClose?.()
|
|
}
|
|
}, 50)
|
|
|
|
return () => clearInterval(interval)
|
|
}
|
|
}, [autoClose, loading, duration, onClose])
|
|
|
|
const handleClose = () => {
|
|
setVisible(false)
|
|
onClose?.()
|
|
}
|
|
|
|
const defaultIcons = {
|
|
default: <Info className="h-5 w-5" />,
|
|
destructive: <AlertCircle className="h-5 w-5" />,
|
|
success: <CheckCircle className="h-5 w-5" />,
|
|
warning: <AlertTriangle className="h-5 w-5" />,
|
|
info: <Info className="h-5 w-5" />,
|
|
glass: <Info className="h-5 w-5" />,
|
|
}
|
|
|
|
const displayIcon = icon || defaultIcons[variant as keyof typeof defaultIcons] || defaultIcons.default
|
|
|
|
if (!visible) return null
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
role="status"
|
|
aria-live="polite"
|
|
className={cn(notificationVariants({ variant, size, position }), className)}
|
|
{...props}
|
|
>
|
|
{/* Progress Bar for Auto-close */}
|
|
{autoClose && !loading && (
|
|
<div className="absolute top-0 left-0 h-1 bg-white/20">
|
|
<div
|
|
className="h-full bg-white/40 transition-all duration-100 ease-linear"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-3">
|
|
<div className="flex items-start gap-3">
|
|
{/* Icon */}
|
|
<div className={cn(
|
|
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
|
variant === "success" && "bg-success/20 text-success",
|
|
variant === "destructive" && "bg-destructive/20 text-destructive",
|
|
variant === "warning" && "bg-warning/20 text-warning",
|
|
variant === "info" && "bg-primary/20 text-primary",
|
|
variant === "default" && "bg-muted text-muted-foreground",
|
|
variant === "glass" && "bg-surface/50 text-foreground"
|
|
)}>
|
|
{loading ? (
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
) : (
|
|
displayIcon
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="grid gap-1 flex-1 min-w-0">
|
|
{title && (
|
|
<div className="text-sm font-semibold leading-none">
|
|
{title}
|
|
</div>
|
|
)}
|
|
{description && (
|
|
<div className="text-sm opacity-90 leading-relaxed">
|
|
{description}
|
|
</div>
|
|
)}
|
|
{children}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action */}
|
|
{action && (
|
|
<div className="flex-shrink-0">
|
|
{action}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Close Button */}
|
|
{closable && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClose}
|
|
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
aria-label="Close notification"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
Notification.displayName = "Notification"
|
|
|
|
// Notification Context
|
|
type NotificationItem = {
|
|
id: string
|
|
title?: string
|
|
description?: string
|
|
variant?: VariantProps<typeof notificationVariants>["variant"]
|
|
duration?: number
|
|
action?: React.ReactNode
|
|
icon?: React.ReactNode
|
|
closable?: boolean
|
|
autoClose?: boolean
|
|
}
|
|
|
|
interface NotificationContextType {
|
|
notifications: NotificationItem[]
|
|
notify: (notification: Omit<NotificationItem, "id">) => void
|
|
success: (notification: Omit<NotificationItem, "id" | "variant">) => void
|
|
error: (notification: Omit<NotificationItem, "id" | "variant">) => void
|
|
warning: (notification: Omit<NotificationItem, "id" | "variant">) => void
|
|
info: (notification: Omit<NotificationItem, "id" | "variant">) => void
|
|
dismiss: (id: string) => void
|
|
dismissAll: () => void
|
|
}
|
|
|
|
const NotificationContext = React.createContext<NotificationContextType | undefined>(undefined)
|
|
|
|
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
|
const [notifications, setNotifications] = React.useState<NotificationItem[]>([])
|
|
|
|
const notify = React.useCallback(
|
|
(notification: Omit<NotificationItem, "id">) => {
|
|
const id = Math.random().toString(36).substr(2, 9)
|
|
setNotifications(prev => [...prev, { ...notification, id }])
|
|
},
|
|
[]
|
|
)
|
|
|
|
const success = React.useCallback(
|
|
(notification: Omit<NotificationItem, "id" | "variant">) =>
|
|
notify({ ...notification, variant: "success" }),
|
|
[notify]
|
|
)
|
|
|
|
const error = React.useCallback(
|
|
(notification: Omit<NotificationItem, "id" | "variant">) =>
|
|
notify({ ...notification, variant: "destructive" }),
|
|
[notify]
|
|
)
|
|
|
|
const warning = React.useCallback(
|
|
(notification: Omit<NotificationItem, "id" | "variant">) =>
|
|
notify({ ...notification, variant: "warning" }),
|
|
[notify]
|
|
)
|
|
|
|
const info = React.useCallback(
|
|
(notification: Omit<NotificationItem, "id" | "variant">) =>
|
|
notify({ ...notification, variant: "info" }),
|
|
[notify]
|
|
)
|
|
|
|
const dismiss = React.useCallback((id: string) => {
|
|
setNotifications(prev => prev.filter(n => n.id !== id))
|
|
}, [])
|
|
|
|
const dismissAll = React.useCallback(() => {
|
|
setNotifications([])
|
|
}, [])
|
|
|
|
const value = React.useMemo(() => ({
|
|
notifications,
|
|
notify,
|
|
success,
|
|
error,
|
|
warning,
|
|
info,
|
|
dismiss,
|
|
dismissAll,
|
|
}), [notifications, notify, success, error, warning, info, dismiss, dismissAll])
|
|
|
|
return (
|
|
<NotificationContext.Provider value={value}>
|
|
{children}
|
|
<NotificationContainer />
|
|
</NotificationContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useNotification() {
|
|
const context = React.useContext(NotificationContext)
|
|
if (context === undefined) {
|
|
throw new Error("useNotification must be used within a NotificationProvider")
|
|
}
|
|
return context
|
|
}
|
|
|
|
// Notification Container
|
|
function NotificationContainer() {
|
|
const { notifications, dismiss } = useNotification()
|
|
|
|
return (
|
|
<div className="fixed top-0 right-0 z-50 flex flex-col-reverse p-4 space-y-2 pointer-events-none">
|
|
{notifications.map((notification) => (
|
|
<Notification
|
|
key={notification.id}
|
|
position="top-right"
|
|
variant={notification.variant}
|
|
title={notification.title}
|
|
description={notification.description}
|
|
action={notification.action}
|
|
icon={notification.icon}
|
|
closable={notification.closable}
|
|
autoClose={notification.autoClose}
|
|
duration={notification.duration}
|
|
onClose={() => dismiss(notification.id)}
|
|
className="pointer-events-auto animate-in slide-in-from-top-2"
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Skeleton Notification for Loading States
|
|
export const NotificationSkeleton = () => (
|
|
<div className="w-full max-w-md rounded-lg border border-border bg-card p-4 shadow-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-border skeleton" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-4 w-3/4 rounded bg-border skeleton" />
|
|
<div className="h-3 w-1/2 rounded bg-border skeleton" />
|
|
</div>
|
|
<div className="w-6 h-6 rounded bg-border skeleton" />
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
export { Notification, notificationVariants } |