diff --git a/components/AlbyInstaller.tsx b/components/AlbyInstaller.tsx index 7eb5ac7..60cca4c 100644 --- a/components/AlbyInstaller.tsx +++ b/components/AlbyInstaller.tsx @@ -8,7 +8,7 @@ interface AlbyInstallerProps { function InfoIcon(): React.ReactElement { return ( Install Alby @@ -51,7 +51,7 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps) onClick={() => { void connect() }} - className="inline-flex items-center justify-center px-4 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" + className="inline-flex items-center justify-center px-4 py-2 border border-neon-cyan/30 text-sm font-medium rounded-lg text-cyber-accent bg-cyber-light hover:bg-cyber-dark hover:border-neon-cyan/50 focus:outline-none focus:ring-2 focus:ring-neon-cyan transition-all" > Already installed? Connect @@ -63,15 +63,15 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps) function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps): React.ReactElement { return (
-

Alby Extension Required

-
+

Alby Extension Required

+

To make Lightning payments, please install the Alby browser extension.

-
+

Alby is a Lightning wallet that enables instant Bitcoin payments in your browser.

@@ -116,7 +116,7 @@ export function AlbyInstaller({ onInstalled }: AlbyInstallerProps): React.ReactE } return ( -
+
diff --git a/components/PaymentModal.tsx b/components/PaymentModal.tsx index 917d265..46e8528 100644 --- a/components/PaymentModal.tsx +++ b/components/PaymentModal.tsx @@ -3,6 +3,7 @@ import QRCode from 'react-qr-code' import type { AlbyInvoice } from '@/types/alby' import { getAlbyService, isWebLNAvailable } from '@/lib/alby' import { AlbyInstaller } from './AlbyInstaller' +import { Modal } from './ui/Modal' import { t } from '@/lib/i18n' interface PaymentModalProps { @@ -34,11 +35,9 @@ function useInvoiceTimer(expiresAt?: number): number | null { function PaymentHeader({ amount, timeRemaining, - onClose, }: { amount: number timeRemaining: number | null - onClose: () => void }): React.ReactElement { const timeLabel = useMemo((): string | null => { if (timeRemaining === null) { @@ -53,18 +52,13 @@ function PaymentHeader({ }, [timeRemaining]) return ( -
-
-

{t('payment.modal.zapAmount', { amount })}

- {timeLabel && ( -

- {t('payment.modal.timeRemaining', { time: timeLabel })} -

- )} -
- +
+

{t('payment.modal.zapAmount', { amount })}

+ {timeLabel && ( +

+ {t('payment.modal.timeRemaining', { time: timeLabel })} +

+ )}
) } @@ -220,26 +214,30 @@ export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentMod } return ( -
-
- - - - - - {errorMessage && ( -

- {errorMessage} -

- )} -

- {t('payment.modal.autoVerify')} + + + + + + + {errorMessage && ( +

+ {errorMessage}

-
-
+ )} +

+ {t('payment.modal.autoVerify')} +

+ ) } diff --git a/components/ProfileSeriesBlock.tsx b/components/ProfileSeriesBlock.tsx index 194d3df..be372c2 100644 --- a/components/ProfileSeriesBlock.tsx +++ b/components/ProfileSeriesBlock.tsx @@ -15,7 +15,7 @@ export function ProfileSeriesBlock({ currentPubkey, onSelectSeries, selectedSeri const { pubkey, isUnlocked } = useNostrAuth() const [showCreateModal, setShowCreateModal] = useState(false) const [refreshKey, setRefreshKey] = useState(0) - const isAuthor = pubkey === currentPubkey && isUnlocked + const isAuthor = pubkey !== null && pubkey === currentPubkey && isUnlocked const handleSeriesCreated = (): void => { setRefreshKey((prev) => prev + 1) diff --git a/components/SeriesCard.tsx b/components/SeriesCard.tsx index 7736eaf..8da48a7 100644 --- a/components/SeriesCard.tsx +++ b/components/SeriesCard.tsx @@ -12,35 +12,37 @@ interface SeriesCardProps { export function SeriesCard({ series, onSelect, selected }: SeriesCardProps): React.ReactElement { return (
{series.coverUrl && ( -
+
{series.title}
)} -

{series.title}

-

{series.description}

-
+

{series.title}

+

{series.description}

+
{series.category === 'science-fiction' ? t('category.science-fiction') : t('category.scientific-research')}
-
- +
+ {t('series.view')}
diff --git a/components/ui/Badge.tsx b/components/ui/Badge.tsx new file mode 100644 index 0000000..0a9de1a --- /dev/null +++ b/components/ui/Badge.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' + +export type BadgeVariant = 'info' | 'success' | 'warning' | 'error' + +interface BadgeProps { + children: ReactNode + variant?: BadgeVariant + className?: string +} + +function getVariantClasses(variant: BadgeVariant): string { + switch (variant) { + case 'info': + return 'bg-neon-cyan/20 text-neon-cyan border-neon-cyan/50' + case 'success': + return 'bg-neon-green/20 text-neon-green border-neon-green/50' + case 'warning': + return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50' + case 'error': + return 'bg-red-500/20 text-red-400 border-red-500/50' + default: + return 'bg-neon-cyan/20 text-neon-cyan border-neon-cyan/50' + } +} + +export function Badge({ children, variant = 'info', className = '' }: BadgeProps): React.ReactElement { + const variantClasses = getVariantClasses(variant) + const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border' + const combinedClasses = `${baseClasses} ${variantClasses} ${className}`.trim() + + return ( + + {children} + + ) +} diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx new file mode 100644 index 0000000..715aea4 --- /dev/null +++ b/components/ui/Button.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from 'react' + +export type ButtonVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'ghost' +export type ButtonSize = 'small' | 'medium' | 'large' + +interface ButtonProps { + children: ReactNode + variant?: ButtonVariant + size?: ButtonSize + disabled?: boolean + loading?: boolean + onClick?: () => void + type?: 'button' | 'submit' | 'reset' + className?: string + 'aria-label'?: string +} + +function getVariantClasses(variant: ButtonVariant): string { + switch (variant) { + case 'primary': + return 'bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border-neon-cyan/50 hover:shadow-glow-cyan' + case 'secondary': + return 'bg-cyber-light hover:bg-cyber-dark text-cyber-accent border-neon-cyan/30 hover:border-neon-cyan/50' + case 'success': + return 'bg-neon-green/20 hover:bg-neon-green/30 text-neon-green border-neon-green/50 hover:shadow-glow-green' + case 'danger': + return 'bg-red-500/20 hover:bg-red-500/30 text-red-400 border-red-500/50' + case 'ghost': + return 'bg-transparent hover:bg-cyber-light text-cyber-accent border-transparent hover:border-neon-cyan/30' + default: + return 'bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border-neon-cyan/50 hover:shadow-glow-cyan' + } +} + +function getSizeClasses(size: ButtonSize): string { + switch (size) { + case 'small': + return 'px-3 py-1.5 text-sm' + case 'medium': + return 'px-4 py-2 text-base' + case 'large': + return 'px-6 py-3 text-lg' + default: + return 'px-4 py-2 text-base' + } +} + +function LoadingSpinner(): React.ReactElement { + return ( + + + + + ) +} + +export function Button({ + children, + variant = 'primary', + size = 'medium', + disabled = false, + loading = false, + onClick, + type = 'button', + className = '', + 'aria-label': ariaLabel, +}: ButtonProps): React.ReactElement { + const variantClasses = getVariantClasses(variant) + const sizeClasses = getSizeClasses(size) + const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-all border focus:outline-none focus:ring-2 focus:ring-neon-cyan disabled:opacity-50 disabled:cursor-not-allowed' + + const combinedClasses = `${baseClasses} ${variantClasses} ${sizeClasses} ${className}`.trim() + + return ( + + ) +} diff --git a/components/ui/Card.tsx b/components/ui/Card.tsx new file mode 100644 index 0000000..c3fb70c --- /dev/null +++ b/components/ui/Card.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react' + +export type CardVariant = 'default' | 'interactive' | 'selected' | 'compact' + +interface CardProps { + children: ReactNode + variant?: CardVariant + className?: string + onClick?: () => void + 'aria-label'?: string +} + +function getVariantClasses(variant: CardVariant, hasOnClick: boolean): string { + const baseClasses = 'border rounded-lg bg-cyber-dark' + + switch (variant) { + case 'default': + return `${baseClasses} border-neon-cyan/30` + case 'interactive': + return `${baseClasses} border-neon-cyan/30 hover:border-neon-cyan/50 hover:shadow-glow-cyan transition-all ${hasOnClick ? 'cursor-pointer' : ''}` + case 'selected': + return `${baseClasses} border-neon-cyan ring-1 ring-neon-cyan/50 shadow-glow-cyan` + case 'compact': + return `${baseClasses} border-neon-cyan/30 p-4` + default: + return `${baseClasses} border-neon-cyan/30` + } +} + +function getPaddingClasses(variant: CardVariant): string { + if (variant === 'compact') { + return 'p-4' + } + return 'p-6' +} + +export function Card({ + children, + variant = 'default', + className = '', + onClick, + 'aria-label': ariaLabel, +}: CardProps): React.ReactElement { + const variantClasses = getVariantClasses(variant, onClick !== undefined) + const paddingClasses = getPaddingClasses(variant) + const combinedClasses = `${variantClasses} ${paddingClasses} ${className}`.trim() + + if (onClick) { + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + }} + aria-label={ariaLabel} + > + {children} +
+ ) + } + + return ( +
+ {children} +
+ ) +} diff --git a/components/ui/EmptyState.tsx b/components/ui/EmptyState.tsx new file mode 100644 index 0000000..c7aa3b5 --- /dev/null +++ b/components/ui/EmptyState.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' + +interface EmptyStateProps { + icon?: ReactNode + title: string + description?: string + action?: ReactNode + className?: string +} + +export function EmptyState({ + icon, + title, + description, + action, + className = '', +}: EmptyStateProps): React.ReactElement { + return ( +
+ {icon &&
{icon}
} +

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ) +} diff --git a/components/ui/ErrorState.tsx b/components/ui/ErrorState.tsx new file mode 100644 index 0000000..da0ba00 --- /dev/null +++ b/components/ui/ErrorState.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' + +interface ErrorStateProps { + message: string + action?: ReactNode + className?: string +} + +function ErrorIcon(): React.ReactElement { + return ( + + + + ) +} + +export function ErrorState({ message, action, className = '' }: ErrorStateProps): React.ReactElement { + return ( +
+
+
+ +
+
+

{message}

+ {action &&
{action}
} +
+
+
+ ) +} diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx new file mode 100644 index 0000000..01df683 --- /dev/null +++ b/components/ui/Input.tsx @@ -0,0 +1,127 @@ +import { useMemo } from 'react' +import type { ReactNode } from 'react' + +interface InputProps extends React.InputHTMLAttributes { + label?: string + error?: string + helperText?: string + leftIcon?: ReactNode + rightIcon?: ReactNode +} + +function generateId(prefix: string, providedId?: string): string { + if (providedId) { + return providedId + } + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 5)}` +} + +function getErrorClasses(error: string | undefined): string { + if (!error) { + return '' + } + return 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' +} + +function getInputClasses(params: { + error: string | undefined + leftIcon: ReactNode | undefined + rightIcon: ReactNode | undefined + className: string +}): string { + const baseClasses = 'block w-full px-3 py-2 border rounded-lg bg-cyber-dark text-cyber-accent placeholder-cyber-accent/50 focus:outline-none focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan transition-colors' + const errorClasses = getErrorClasses(params.error) + const paddingLeft = params.leftIcon ? 'pl-10' : '' + const paddingRight = params.rightIcon ? 'pr-10' : '' + return `${baseClasses} ${errorClasses} ${paddingLeft} ${paddingRight} ${params.className}`.trim() +} + +function getAriaDescribedBy(inputId: string, error: string | undefined, helperText: string | undefined): string | undefined { + if (error) { + return `${inputId}-error` + } + if (helperText) { + return `${inputId}-helper` + } + return undefined +} + +function InputLabel({ inputId, label }: { inputId: string; label: string }): React.ReactElement { + return ( + + ) +} + +function InputIcons({ leftIcon, rightIcon }: { leftIcon?: ReactNode; rightIcon?: ReactNode }): React.ReactElement | null { + if (!leftIcon && !rightIcon) { + return null + } + return ( + <> + {leftIcon && ( +
+ {leftIcon} +
+ )} + {rightIcon && ( +
+ {rightIcon} +
+ )} + + ) +} + +function InputError({ inputId, error }: { inputId: string; error: string }): React.ReactElement { + return ( + + ) +} + +function InputHelper({ inputId, helperText }: { inputId: string; helperText: string }): React.ReactElement { + return ( +

+ {helperText} +

+ ) +} + +export function Input({ + label, + error, + helperText, + leftIcon, + rightIcon, + className = '', + id, + ...props +}: InputProps): React.ReactElement { + const inputId = useMemo(() => generateId('input', id), [id]) + const inputClasses = useMemo( + () => getInputClasses({ error, leftIcon, rightIcon, className }), + [error, leftIcon, rightIcon, className] + ) + const ariaDescribedBy = useMemo(() => getAriaDescribedBy(inputId, error, helperText), [inputId, error, helperText]) + + return ( +
+ {label && } +
+ + +
+ {error && } + {helperText && !error && } +
+ ) +} diff --git a/components/ui/MobileMenu.tsx b/components/ui/MobileMenu.tsx new file mode 100644 index 0000000..4ece8df --- /dev/null +++ b/components/ui/MobileMenu.tsx @@ -0,0 +1,105 @@ +import { useState, useEffect } from 'react' +import type { ReactNode } from 'react' + +interface MobileMenuProps { + children: ReactNode + 'aria-label'?: string +} + +function HamburgerIcon({ isOpen }: { isOpen: boolean }): React.ReactElement { + return ( + + {isOpen ? ( + + ) : ( + + )} + + ) +} + +export function MobileMenu({ children, 'aria-label': ariaLabel }: MobileMenuProps): React.ReactElement { + const [isOpen, setIsOpen] = useState(false) + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + useEffect(() => { + const handleEscape = (e: KeyboardEvent): void => { + if (e.key === 'Escape' && isOpen) { + setIsOpen(false) + } + } + + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [isOpen]) + + return ( + <> + + {isOpen && ( + <> +
setIsOpen(false)} + aria-hidden="true" + /> + + + )} + + ) +} diff --git a/components/ui/Modal.tsx b/components/ui/Modal.tsx new file mode 100644 index 0000000..5822ccf --- /dev/null +++ b/components/ui/Modal.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef } from 'react' +import type { ReactNode } from 'react' + +interface ModalProps { + children: ReactNode + isOpen: boolean + onClose: () => void + title?: string + size?: 'small' | 'medium' | 'large' | 'full' + showCloseButton?: boolean + 'aria-label'?: string +} + +function getSizeClasses(size: ModalProps['size']): string { + switch (size) { + case 'small': + return 'max-w-md' + case 'medium': + return 'max-w-lg' + case 'large': + return 'max-w-xl' + case 'full': + return 'max-w-full mx-4' + default: + return 'max-w-md' + } +} + +function CloseButton({ onClose }: { onClose: () => void }): React.ReactElement { + return ( + + ) +} + +function ModalHeader({ + title, + showCloseButton, + onClose, +}: { + title?: string + showCloseButton: boolean + onClose: () => void +}): React.ReactElement | null { + if (!title && !showCloseButton) { + return null + } + return ( +
+ {title &&

{title}

} + {showCloseButton && } +
+ ) +} + +export function Modal({ + children, + isOpen, + onClose, + title, + size = 'medium', + showCloseButton = true, + 'aria-label': ariaLabel, +}: ModalProps): React.ReactElement | null { + const modalRef = useRef(null) + const previousFocusRef = useRef(null) + + useEffect(() => { + if (isOpen) { + previousFocusRef.current = document.activeElement as HTMLElement + const firstFocusable = modalRef.current?.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) as HTMLElement | null + if (firstFocusable) { + firstFocusable.focus() + } + } else { + previousFocusRef.current?.focus() + } + }, [isOpen]) + + useEffect(() => { + const handleEscape = (e: KeyboardEvent): void => { + if (e.key === 'Escape' && isOpen) { + onClose() + } + } + + if (isOpen) { + document.addEventListener('keydown', handleEscape) + document.body.style.overflow = 'hidden' + } + + return () => { + document.removeEventListener('keydown', handleEscape) + document.body.style.overflow = '' + } + }, [isOpen, onClose]) + + if (!isOpen) { + return null + } + + const sizeClasses = getSizeClasses(size) + + return ( +
{ + if (e.target === e.currentTarget) { + onClose() + } + }} + role="dialog" + aria-modal="true" + aria-label={ariaLabel ?? title} + > +
e.stopPropagation()} + > + + {children} +
+
+ ) +} diff --git a/components/ui/Skeleton.tsx b/components/ui/Skeleton.tsx new file mode 100644 index 0000000..abb3f3a --- /dev/null +++ b/components/ui/Skeleton.tsx @@ -0,0 +1,44 @@ +interface SkeletonProps { + className?: string + variant?: 'text' | 'circular' | 'rectangular' + width?: string | number + height?: string | number +} + +function getVariantClasses(variant: SkeletonProps['variant']): string { + switch (variant) { + case 'circular': + return 'rounded-full' + case 'rectangular': + return 'rounded-lg' + case 'text': + default: + return 'rounded' + } +} + +export function Skeleton({ + className = '', + variant = 'text', + width, + height, +}: SkeletonProps): React.ReactElement { + const variantClasses = getVariantClasses(variant) + const baseClasses = 'bg-cyber-light animate-pulse' + const style: React.CSSProperties = {} + + if (width) { + style.width = typeof width === 'number' ? `${width}px` : width + } + if (height) { + style.height = typeof height === 'number' ? `${height}px` : height + } + + const combinedClasses = `${baseClasses} ${variantClasses} ${className}`.trim() + + return ( +
+ Loading... +
+ ) +} diff --git a/components/ui/Textarea.tsx b/components/ui/Textarea.tsx new file mode 100644 index 0000000..e41a1b6 --- /dev/null +++ b/components/ui/Textarea.tsx @@ -0,0 +1,127 @@ +import { useMemo } from 'react' +import type { ReactNode } from 'react' + +interface TextareaProps extends React.TextareaHTMLAttributes { + label?: string + error?: string + helperText?: string + leftIcon?: ReactNode + rightIcon?: ReactNode +} + +function generateId(prefix: string, providedId?: string): string { + if (providedId) { + return providedId + } + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 5)}` +} + +function getErrorClasses(error: string | undefined): string { + if (!error) { + return '' + } + return 'border-red-500/50 focus:border-red-500 focus:ring-red-500/50' +} + +function getTextareaClasses(params: { + error: string | undefined + leftIcon: ReactNode | undefined + rightIcon: ReactNode | undefined + className: string +}): string { + const baseClasses = 'block w-full px-3 py-2 border rounded-lg bg-cyber-dark text-cyber-accent placeholder-cyber-accent/50 focus:outline-none focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan transition-colors resize-y' + const errorClasses = getErrorClasses(params.error) + const paddingLeft = params.leftIcon ? 'pl-10' : '' + const paddingRight = params.rightIcon ? 'pr-10' : '' + return `${baseClasses} ${errorClasses} ${paddingLeft} ${paddingRight} ${params.className}`.trim() +} + +function getAriaDescribedBy(textareaId: string, error: string | undefined, helperText: string | undefined): string | undefined { + if (error) { + return `${textareaId}-error` + } + if (helperText) { + return `${textareaId}-helper` + } + return undefined +} + +function TextareaLabel({ textareaId, label }: { textareaId: string; label: string }): React.ReactElement { + return ( + + ) +} + +function TextareaIcons({ leftIcon, rightIcon }: { leftIcon?: ReactNode; rightIcon?: ReactNode }): React.ReactElement | null { + if (!leftIcon && !rightIcon) { + return null + } + return ( + <> + {leftIcon && ( +
+ {leftIcon} +
+ )} + {rightIcon && ( +
+ {rightIcon} +
+ )} + + ) +} + +function TextareaError({ textareaId, error }: { textareaId: string; error: string }): React.ReactElement { + return ( + + ) +} + +function TextareaHelper({ textareaId, helperText }: { textareaId: string; helperText: string }): React.ReactElement { + return ( +

+ {helperText} +

+ ) +} + +export function Textarea({ + label, + error, + helperText, + leftIcon, + rightIcon, + className = '', + id, + ...props +}: TextareaProps): React.ReactElement { + const textareaId = useMemo(() => generateId('textarea', id), [id]) + const textareaClasses = useMemo( + () => getTextareaClasses({ error, leftIcon, rightIcon, className }), + [error, leftIcon, rightIcon, className] + ) + const ariaDescribedBy = useMemo(() => getAriaDescribedBy(textareaId, error, helperText), [textareaId, error, helperText]) + + return ( +
+ {label && } +
+ +