create for series

This commit is contained in:
Nicolas Cantu 2026-01-13 23:45:28 +01:00
parent db3b9b9b38
commit 8f3f62f7bf
21 changed files with 1903 additions and 52 deletions

View File

@ -8,7 +8,7 @@ interface AlbyInstallerProps {
function InfoIcon(): React.ReactElement {
return (
<svg
className="h-5 w-5 text-blue-400"
className="h-5 w-5 text-neon-cyan"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
@ -43,7 +43,7 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps)
href="https://getalby.com/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 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/50 text-sm font-medium rounded-lg text-neon-cyan bg-neon-cyan/20 hover:bg-neon-cyan/30 hover:shadow-glow-cyan focus:outline-none focus:ring-2 focus:ring-neon-cyan transition-all"
>
Install Alby
</a>
@ -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
</button>
@ -63,15 +63,15 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps)
function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps): React.ReactElement {
return (
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-blue-800">Alby Extension Required</h3>
<div className="mt-2 text-sm text-blue-700">
<h3 className="text-sm font-medium text-neon-cyan">Alby Extension Required</h3>
<div className="mt-2 text-sm text-cyber-accent">
<p>To make Lightning payments, please install the Alby browser extension.</p>
</div>
<InstallerActions
markInstalled={markInstalled}
{...(onInstalled ? { onInstalled } : {})}
/>
<div className="mt-3 text-xs text-blue-600">
<div className="mt-3 text-xs text-cyber-accent/70">
<p>Alby is a Lightning wallet that enables instant Bitcoin payments in your browser.</p>
</div>
</div>
@ -116,7 +116,7 @@ export function AlbyInstaller({ onInstalled }: AlbyInstallerProps): React.ReactE
}
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="bg-cyber-dark/50 border border-neon-cyan/30 rounded-lg p-4 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<InfoIcon />

View File

@ -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,8 +52,7 @@ function PaymentHeader({
}, [timeRemaining])
return (
<div className="flex justify-between items-center mb-4">
<div>
<div className="mb-4">
<h2 className="text-xl font-bold text-neon-cyan">{t('payment.modal.zapAmount', { amount })}</h2>
{timeLabel && (
<p className={`text-sm ${timeRemaining !== null && timeRemaining <= 60 ? 'text-red-400 font-semibold' : 'text-cyber-accent/70'}`}>
@ -62,10 +60,6 @@ function PaymentHeader({
</p>
)}
</div>
<button onClick={onClose} className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors">
×
</button>
</div>
)
}
@ -220,10 +214,15 @@ export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentMod
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto shadow-glow-cyan">
<Modal
isOpen
onClose={onClose}
title={t('payment.modal.zapAmount', { amount: invoice.amount })}
size="small"
aria-label={t('payment.modal.zapAmount', { amount: invoice.amount })}
>
<AlbyInstaller />
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} onClose={onClose} />
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} />
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
<PaymentActions
copied={copied}
@ -239,7 +238,6 @@ export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentMod
<p className="text-xs text-cyber-accent/70 mt-4 text-center">
{t('payment.modal.autoVerify')}
</p>
</div>
</div>
</Modal>
)
}

View File

@ -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)

View File

@ -12,35 +12,37 @@ interface SeriesCardProps {
export function SeriesCard({ series, onSelect, selected }: SeriesCardProps): React.ReactElement {
return (
<div
className={`border rounded-lg p-4 bg-white shadow-sm ${
selected ? 'border-blue-500 ring-1 ring-blue-200' : 'border-gray-200'
className={`border rounded-lg p-4 bg-cyber-dark transition-all ${
selected
? 'border-neon-cyan ring-1 ring-neon-cyan/50 shadow-glow-cyan'
: 'border-neon-cyan/30 hover:border-neon-cyan/50 hover:shadow-glow-cyan'
}`}
>
{series.coverUrl && (
<div className="relative w-full h-40 mb-3">
<div className="relative w-full h-40 mb-3 rounded-lg overflow-hidden border border-neon-cyan/20">
<Image
src={series.coverUrl}
alt={series.title}
className="object-cover rounded"
className="object-cover"
fill
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
)}
<h3 className="text-lg font-semibold">{series.title}</h3>
<p className="text-sm text-gray-700 line-clamp-3">{series.description}</p>
<div className="mt-3 flex items-center justify-between text-sm text-gray-600">
<h3 className="text-lg font-semibold text-neon-cyan mb-2">{series.title}</h3>
<p className="text-sm text-cyber-accent line-clamp-3 mb-3">{series.description}</p>
<div className="mt-3 flex items-center justify-between text-sm text-cyber-accent/70">
<span>{series.category === 'science-fiction' ? t('category.science-fiction') : t('category.scientific-research')}</span>
<button
type="button"
className="px-3 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700"
className="px-3 py-1 text-sm rounded-lg bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 hover:shadow-glow-cyan transition-all font-medium"
onClick={() => onSelect(series.id)}
>
{t('common.open')}
</button>
</div>
<div className="mt-2 text-xs text-blue-600">
<Link href={`/series/${series.id}`} className="underline">
<div className="mt-2 text-xs text-neon-cyan/70">
<Link href={`/series/${series.id}`} className="hover:text-neon-cyan transition-colors underline">
{t('series.view')}
</Link>
</div>

36
components/ui/Badge.tsx Normal file
View File

@ -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 (
<span className={combinedClasses}>
{children}
</span>
)
}

107
components/ui/Button.tsx Normal file
View File

@ -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 (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)
}
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 (
<button
type={type}
onClick={onClick}
disabled={disabled || loading}
className={combinedClasses}
aria-label={ariaLabel}
aria-busy={loading}
>
{loading && (
<span className="mr-2">
<LoadingSpinner />
</span>
)}
{children}
</button>
)
}

73
components/ui/Card.tsx Normal file
View File

@ -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 (
<div
onClick={onClick}
className={combinedClasses}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}}
aria-label={ariaLabel}
>
{children}
</div>
)
}
return (
<div className={combinedClasses} aria-label={ariaLabel}>
{children}
</div>
)
}

View File

@ -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 (
<div className={`text-center py-12 ${className}`}>
{icon && <div className="mb-4 flex justify-center text-cyber-accent/50">{icon}</div>}
<h3 className="text-lg font-semibold text-neon-cyan mb-2">{title}</h3>
{description && <p className="text-sm text-cyber-accent/70 mb-4 max-w-md mx-auto">{description}</p>}
{action && <div className="flex justify-center">{action}</div>}
</div>
)
}

View File

@ -0,0 +1,42 @@
import type { ReactNode } from 'react'
interface ErrorStateProps {
message: string
action?: ReactNode
className?: string
}
function ErrorIcon(): React.ReactElement {
return (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)
}
export function ErrorState({ message, action, className = '' }: ErrorStateProps): React.ReactElement {
return (
<div className={`bg-red-900/20 border border-red-500/50 rounded-lg p-4 ${className}`} role="alert">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 text-red-400">
<ErrorIcon />
</div>
<div className="flex-1">
<p className="text-sm text-red-400 font-medium mb-2">{message}</p>
{action && <div className="mt-3">{action}</div>}
</div>
</div>
</div>
)
}

127
components/ui/Input.tsx Normal file
View File

@ -0,0 +1,127 @@
import { useMemo } from 'react'
import type { ReactNode } from 'react'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
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 (
<label htmlFor={inputId} className="block text-sm font-medium text-cyber-accent mb-1">
{label}
</label>
)
}
function InputIcons({ leftIcon, rightIcon }: { leftIcon?: ReactNode; rightIcon?: ReactNode }): React.ReactElement | null {
if (!leftIcon && !rightIcon) {
return null
}
return (
<>
{leftIcon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-cyber-accent/50">
{leftIcon}
</div>
)}
{rightIcon && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-cyber-accent/50">
{rightIcon}
</div>
)}
</>
)
}
function InputError({ inputId, error }: { inputId: string; error: string }): React.ReactElement {
return (
<p id={`${inputId}-error`} className="mt-1 text-sm text-red-400" role="alert">
{error}
</p>
)
}
function InputHelper({ inputId, helperText }: { inputId: string; helperText: string }): React.ReactElement {
return (
<p id={`${inputId}-helper`} className="mt-1 text-sm text-cyber-accent/70">
{helperText}
</p>
)
}
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 (
<div className="w-full">
{label && <InputLabel inputId={inputId} label={label} />}
<div className="relative">
<InputIcons leftIcon={leftIcon} rightIcon={rightIcon} />
<input
id={inputId}
className={inputClasses}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={ariaDescribedBy}
{...props}
/>
</div>
{error && <InputError inputId={inputId} error={error} />}
{helperText && !error && <InputHelper inputId={inputId} helperText={helperText} />}
</div>
)
}

View File

@ -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 (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{isOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
)
}
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 (
<>
<button
onClick={() => setIsOpen(!isOpen)}
className="md:hidden text-cyber-accent hover:text-neon-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded p-2"
aria-label={ariaLabel ?? 'Toggle menu'}
aria-expanded={isOpen}
aria-controls="mobile-menu"
>
<HamburgerIcon isOpen={isOpen} />
</button>
{isOpen && (
<>
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<div
id="mobile-menu"
className="fixed top-0 right-0 h-full w-80 max-w-[85vw] bg-cyber-dark border-l border-neon-cyan/30 shadow-glow-cyan z-50 transform transition-transform md:hidden overflow-y-auto"
role="dialog"
aria-modal="true"
aria-label={ariaLabel ?? 'Mobile menu'}
>
<div className="p-4">
<div className="flex justify-end mb-4">
<button
onClick={() => setIsOpen(false)}
className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
aria-label="Close menu"
>
×
</button>
</div>
<div className="space-y-4">{children}</div>
</div>
</div>
</>
)}
</>
)
}

133
components/ui/Modal.tsx Normal file
View File

@ -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 (
<button
onClick={onClose}
className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
aria-label="Close modal"
>
×
</button>
)
}
function ModalHeader({
title,
showCloseButton,
onClose,
}: {
title?: string
showCloseButton: boolean
onClose: () => void
}): React.ReactElement | null {
if (!title && !showCloseButton) {
return null
}
return (
<div className="flex justify-between items-center mb-4">
{title && <h2 className="text-xl font-bold text-neon-cyan">{title}</h2>}
{showCloseButton && <CloseButton onClose={onClose} />}
</div>
)
}
export function Modal({
children,
isOpen,
onClose,
title,
size = 'medium',
showCloseButton = true,
'aria-label': ariaLabel,
}: ModalProps): React.ReactElement | null {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(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 (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose()
}
}}
role="dialog"
aria-modal="true"
aria-label={ariaLabel ?? title}
>
<div
ref={modalRef}
className={`bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 w-full mx-4 max-h-[90vh] overflow-y-auto shadow-glow-cyan ${sizeClasses}`}
onClick={(e) => e.stopPropagation()}
>
<ModalHeader title={title} showCloseButton={showCloseButton} onClose={onClose} />
{children}
</div>
</div>
)
}

View File

@ -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 (
<div className={combinedClasses} style={style} aria-busy="true" aria-label="Loading">
<span className="sr-only">Loading...</span>
</div>
)
}

127
components/ui/Textarea.tsx Normal file
View File

@ -0,0 +1,127 @@
import { useMemo } from 'react'
import type { ReactNode } from 'react'
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
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 (
<label htmlFor={textareaId} className="block text-sm font-medium text-cyber-accent mb-1">
{label}
</label>
)
}
function TextareaIcons({ leftIcon, rightIcon }: { leftIcon?: ReactNode; rightIcon?: ReactNode }): React.ReactElement | null {
if (!leftIcon && !rightIcon) {
return null
}
return (
<>
{leftIcon && (
<div className="absolute top-3 left-0 pl-3 flex items-start pointer-events-none text-cyber-accent/50">
{leftIcon}
</div>
)}
{rightIcon && (
<div className="absolute top-3 right-0 pr-3 flex items-start text-cyber-accent/50">
{rightIcon}
</div>
)}
</>
)
}
function TextareaError({ textareaId, error }: { textareaId: string; error: string }): React.ReactElement {
return (
<p id={`${textareaId}-error`} className="mt-1 text-sm text-red-400" role="alert">
{error}
</p>
)
}
function TextareaHelper({ textareaId, helperText }: { textareaId: string; helperText: string }): React.ReactElement {
return (
<p id={`${textareaId}-helper`} className="mt-1 text-sm text-cyber-accent/70">
{helperText}
</p>
)
}
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 (
<div className="w-full">
{label && <TextareaLabel textareaId={textareaId} label={label} />}
<div className="relative">
<TextareaIcons leftIcon={leftIcon} rightIcon={rightIcon} />
<textarea
id={textareaId}
className={textareaClasses}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={ariaDescribedBy}
{...props}
/>
</div>
{error && <TextareaError textareaId={textareaId} error={error} />}
{helperText && !error && <TextareaHelper textareaId={textareaId} helperText={helperText} />}
</div>
)
}

67
components/ui/Toast.tsx Normal file
View File

@ -0,0 +1,67 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
export type ToastVariant = 'info' | 'success' | 'warning' | 'error'
interface ToastProps {
children: ReactNode
variant?: ToastVariant
duration?: number
onClose: () => void
'aria-label'?: string
}
function getVariantClasses(variant: ToastVariant): 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 Toast({
children,
variant = 'info',
duration = 5000,
onClose,
'aria-label': ariaLabel,
}: ToastProps): React.ReactElement {
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(() => {
onClose()
}, duration)
return () => clearTimeout(timer)
}
return undefined
}, [duration, onClose])
const variantClasses = getVariantClasses(variant)
const baseClasses = 'border rounded-lg p-4 shadow-lg flex items-center justify-between min-w-[300px] max-w-md'
const combinedClasses = `${baseClasses} ${variantClasses}`.trim()
return (
<div
className={combinedClasses}
role="alert"
aria-live="polite"
aria-label={ariaLabel}
>
<div className="flex-1">{children}</div>
<button
onClick={onClose}
className="ml-4 text-current hover:opacity-70 transition-opacity focus:outline-none focus:ring-2 focus:ring-current rounded"
aria-label="Close notification"
>
×
</button>
</div>
)
}

11
components/ui/index.ts Normal file
View File

@ -0,0 +1,11 @@
export { Button, type ButtonVariant, type ButtonSize } from './Button'
export { Card, type CardVariant } from './Card'
export { Input } from './Input'
export { Textarea } from './Textarea'
export { Badge, type BadgeVariant } from './Badge'
export { Skeleton } from './Skeleton'
export { Modal } from './Modal'
export { Toast, type ToastVariant } from './Toast'
export { MobileMenu } from './MobileMenu'
export { EmptyState } from './EmptyState'
export { ErrorState } from './ErrorState'

View File

@ -0,0 +1,82 @@
# Référence Typographie et Espacement
**Auteur** : Équipe 4NK
**Date** : 2025-01-27
## Système de tailles de texte
### Hiérarchie typographique
| Usage | Classe Tailwind | Taille | Exemple |
|-------|----------------|--------|---------|
| Métadonnées, labels | `text-xs` | 12px | Dates, catégories, badges |
| Texte secondaire | `text-sm` | 14px | Descriptions, helper text |
| Corps de texte | `text-base` | 16px | Contenu principal (défaut) |
| Sous-titres | `text-lg` | 18px | Titres de sections |
| Titres de sections | `text-xl` | 20px | Titres de modals |
| Titres principaux | `text-2xl` | 24px | Titres d'articles, pages |
| Titres hero | `text-3xl+` | 30px+ | Titres de landing |
### Poids de police
| Usage | Classe | Poids |
|-------|--------|-------|
| Texte courant | `font-normal` | 400 |
| Emphase légère | `font-medium` | 500 |
| Emphase moyenne | `font-semibold` | 600 |
| Titres, CTA | `font-bold` | 700 |
## Espacement
### Échelle Tailwind (4px base)
- `gap-2` : 8px - Espacement serré (icônes, badges)
- `gap-4` : 16px - Espacement standard (éléments de liste)
- `gap-6` : 24px - Espacement large (sections)
- `gap-8` : 32px - Espacement très large (sections principales)
### Padding
- Cards : `p-4` (compact) ou `p-6` (standard)
- Containers : `px-4 py-8` (mobile) ou `px-6 py-12` (desktop)
- Sections : `mb-8` ou `mb-12` entre sections
### Marges verticales
- Entre éléments liés : `mb-2` à `mb-4`
- Entre sections : `mb-8` à `mb-12`
- Espacement de page : `py-8` (mobile) ou `py-12` (desktop)
## Largeur de ligne optimale
Pour le contenu de lecture :
- Utiliser `max-w-prose` pour le contenu texte
- Largeur max ~65-75 caractères
- Padding horizontal : `px-4` (mobile) ou `px-6` (desktop)
## Exemples d'utilisation
### Titre de page
```tsx
<h1 className="text-2xl font-bold text-neon-cyan mb-4">Titre de page</h1>
```
### Titre de section
```tsx
<h2 className="text-lg font-semibold text-neon-cyan mb-2">Section</h2>
```
### Corps de texte
```tsx
<p className="text-base text-cyber-accent">Contenu principal</p>
```
### Texte secondaire
```tsx
<p className="text-sm text-cyber-accent/70">Description ou helper text</p>
```
### Métadonnées
```tsx
<span className="text-xs text-cyber-accent/50">Date, catégorie</span>
```

View File

@ -0,0 +1,146 @@
# Résumé des améliorations UI implémentées
**Auteur** : Équipe 4NK
**Date** : 2025-01-27
## Vue d'ensemble
Ce document résume toutes les améliorations UI qui ont été implémentées pour la plateforme zapwall.fr.
## Composants UI créés
### 1. Système de composants réutilisables (`components/ui/`)
#### Button (`Button.tsx`)
- Variantes : primary, secondary, success, danger, ghost
- Tailles : small, medium, large
- États : loading, disabled, focus
- Accessibilité : ARIA labels, focus ring
#### Card (`Card.tsx`)
- Variantes : default, interactive, selected, compact
- Support du clic avec navigation clavier
- Styles cohérents avec le thème cyber/neon
#### Input (`Input.tsx`)
- Support des labels, erreurs, helper text
- Icônes gauche/droite
- Validation visuelle avec états d'erreur
- Accessibilité ARIA complète
#### Textarea (`Textarea.tsx`)
- Même API que Input
- Resize vertical
- Validation visuelle
#### Badge (`Badge.tsx`)
- Variantes : info, success, warning, error
- Style cohérent avec le thème
#### Skeleton (`Skeleton.tsx`)
- Variantes : text, circular, rectangular
- Animation pulse
- Tailles personnalisables
#### Modal (`Modal.tsx`)
- Tailles : small, medium, large, full
- Focus trap
- Escape key support
- Click outside pour fermer
- Accessibilité ARIA complète
#### Toast (`Toast.tsx`)
- Variantes : info, success, warning, error
- Auto-dismiss configurable
- Accessibilité ARIA live
#### MobileMenu (`MobileMenu.tsx`)
- Menu hamburger responsive
- Drawer slide-in depuis la droite
- Overlay avec fermeture
- Navigation clavier complète
#### EmptyState (`EmptyState.tsx`)
- État vide avec icône optionnelle
- Titre et description
- Action optionnelle (CTA)
#### ErrorState (`ErrorState.tsx`)
- Affichage d'erreur avec icône
- Message clair
- Action de récupération optionnelle
### 2. Corrections de cohérence visuelle
#### SeriesCard
- Remplacement des couleurs blanches/grises par thème cyber/neon
- Style cohérent avec ArticleCard et AuthorCard
- États hover et selected améliorés
#### AlbyInstaller
- Remplacement des couleurs bleues génériques par thème neon/cyber
- Boutons avec style cohérent
- Container avec style cyber
### 3. Refactorisation
#### PaymentModal
- Migration vers le composant Modal réutilisable
- Style unifié
- Accessibilité améliorée
## Documentation créée
### Typographie et espacement (`docs/ui-typography-reference.md`)
- Système de tailles de texte documenté
- Hiérarchie typographique
- Guide d'espacement
- Exemples d'utilisation
## Animations et transitions
### CSS global (`styles/globals.css`)
- Animations : fadeIn, slideIn, slideInRight, scaleIn
- Classes utilitaires : animate-fade-in, animate-slide-in, etc.
- Transitions smooth pour couleurs et propriétés
## Fichiers modifiés
- `components/SeriesCard.tsx` : Correction cohérence visuelle
- `components/AlbyInstaller.tsx` : Correction cohérence visuelle
- `components/PaymentModal.tsx` : Migration vers Modal réutilisable
- `styles/globals.css` : Ajout animations et transitions
## Fichiers créés
- `components/ui/Button.tsx`
- `components/ui/Card.tsx`
- `components/ui/Input.tsx`
- `components/ui/Textarea.tsx`
- `components/ui/Badge.tsx`
- `components/ui/Skeleton.tsx`
- `components/ui/Modal.tsx`
- `components/ui/Toast.tsx`
- `components/ui/MobileMenu.tsx`
- `components/ui/EmptyState.tsx`
- `components/ui/ErrorState.tsx`
- `components/ui/index.ts`
- `docs/ui-typography-reference.md`
- `features/ui-improvements-summary.md`
## Prochaines étapes
Les composants UI de base sont maintenant en place. Les prochaines étapes recommandées :
1. **Migration progressive** : Remplacer progressivement les composants existants par les nouveaux composants UI
2. **Utilisation des composants** : Utiliser Button, Card, Input, etc. dans les nouveaux développements
3. **Tests** : Tester tous les composants sur différents appareils et navigateurs
4. **Documentation** : Créer des exemples d'utilisation pour chaque composant
## Notes
- Tous les composants respectent les règles de qualité du projet
- Accessibilité ARIA complète sur tous les composants
- Support clavier complet
- Responsive design intégré
- Thème cyber/neon cohérent

360
features/ui-improvements.md Normal file
View File

@ -0,0 +1,360 @@
# Améliorations UI - Documentation
**Auteur** : Équipe 4NK
**Date** : 2025-01-27
## Vue d'ensemble
Ce document liste les améliorations UI proposées pour la plateforme zapwall.fr. Ces améliorations visent à créer une interface plus cohérente, moderne et visuellement attrayante tout en respectant le thème cyberpunk/neon existant.
## 1. Design System et Cohérence Visuelle
### Système de couleurs unifié
- **Audit des couleurs** :
- Identifier tous les usages de couleurs non-thème (ex: `SeriesCard` utilise `bg-white`, `text-gray-700`)
- Remplacer par les couleurs du design system (cyber/neon)
- Créer une palette de couleurs documentée avec usages
- **Cohérence des états** :
- États hover cohérents sur tous les éléments interactifs
- États focus visibles et uniformes
- États disabled avec style cohérent
- États actifs/selected avec indicateurs visuels clairs
### Composants réutilisables
- **Créer une bibliothèque de composants** :
- `Button` : Variantes (primary, secondary, danger, ghost)
- `Card` : Variantes (default, hover, selected)
- `Input` : Variantes (text, search, textarea)
- `Badge` : Variantes (info, success, warning, error)
- `Modal` : Composant modal réutilisable avec overlay
- `Toast` : Système de notifications toast
- `Skeleton` : Loaders skeleton pour différents contenus
- **Documentation des composants** :
- Storybook ou documentation inline
- Exemples d'utilisation
- Props et variantes documentées
## 2. Typographie et Espacement
### Hiérarchie typographique
- **Système de tailles cohérent** :
- `text-xs` : Métadonnées, labels
- `text-sm` : Texte secondaire, descriptions
- `text-base` : Corps de texte principal
- `text-lg` : Sous-titres
- `text-xl` : Titres de sections
- `text-2xl` : Titres principaux
- `text-3xl+` : Titres hero
- **Poids de police** :
- `font-normal` : Texte courant
- `font-medium` : Emphase légère
- `font-semibold` : Emphase moyenne
- `font-bold` : Titres, CTA
- **Espacement cohérent** :
- Utiliser l'échelle Tailwind (4, 8, 12, 16, 24, 32...)
- Marges verticales cohérentes entre sections
- Padding cohérent dans les cards et containers
### Lisibilité améliorée
- **Contraste** :
- Vérifier tous les contrastes (WCAG AA minimum)
- Améliorer les contrastes faibles (ex: `text-cyber-accent/70`)
- Tester avec différents thèmes si applicable
- **Largeur de ligne optimale** :
- Limiter la largeur du texte à ~65-75 caractères
- Appliquer `max-w-prose` ou similaire pour le contenu
- Ajuster les espacements pour la lecture
## 3. Layout et Grille
### Système de grille cohérent
- **Containers** :
- Largeur max cohérente (`max-w-4xl`, `max-w-6xl`, etc.)
- Padding horizontal cohérent (`px-4`, `px-6`, `px-8`)
- Centrage automatique avec `mx-auto`
- **Grilles responsives** :
- `grid-cols-1` sur mobile
- `md:grid-cols-2` sur tablette
- `lg:grid-cols-3` ou `xl:grid-cols-4` sur desktop
- Gaps cohérents (`gap-4`, `gap-6`, `gap-8`)
### Responsive design amélioré
- **Breakpoints cohérents** :
- Mobile first : base styles
- `sm:` : 640px (petites tablettes)
- `md:` : 768px (tablettes)
- `lg:` : 1024px (desktop)
- `xl:` : 1280px (large desktop)
- **Navigation mobile** :
- Menu hamburger pour navigation sur mobile
- Drawer/sidebar pour filtres sur mobile
- Touch targets minimum 44x44px
## 4. Cards et Containers
### Style unifié des cards
- **Card de base** :
```tsx
className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark hover:border-neon-cyan/50 hover:shadow-glow-cyan transition-all"
```
- **Variantes** :
- Card simple : bordure subtile, pas de hover
- Card interactive : hover avec glow, cursor pointer
- Card selected : bordure plus visible, background différent
- Card compact : padding réduit pour listes denses
### Containers et sections
- **Sections** :
- Espacement vertical cohérent entre sections (`mb-8`, `mb-12`)
- Titres de section avec style cohérent
- Séparateurs visuels si nécessaire
- **Containers de contenu** :
- Background cohérent (`bg-cyber-dark` ou `bg-cyber-darker`)
- Bordures subtiles (`border-neon-cyan/20` ou `/30`)
- Padding cohérent selon le contexte
## 5. Buttons et Interactions
### Système de boutons
- **Variantes de boutons** :
- Primary : `bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border-neon-cyan/50`
- Secondary : `bg-cyber-light hover:bg-cyber-dark text-cyber-accent border-neon-cyan/30`
- Success : `bg-neon-green/20 hover:bg-neon-green/30 text-neon-green border-neon-green/50`
- Danger : `bg-red-500/20 hover:bg-red-500/30 text-red-400 border-red-500/50`
- Ghost : `bg-transparent hover:bg-cyber-light text-cyber-accent`
- **Tailles cohérentes** :
- Small : `px-3 py-1.5 text-sm`
- Medium : `px-4 py-2 text-base` (défaut)
- Large : `px-6 py-3 text-lg`
- **États** :
- Loading : spinner + texte "Chargement..."
- Disabled : `opacity-50 cursor-not-allowed`
- Focus : ring visible avec `focus:ring-2 focus:ring-neon-cyan`
### Micro-interactions
- **Transitions** :
- `transition-all` pour changements d'état
- `transition-colors` pour changements de couleur uniquement
- Durées cohérentes (150ms, 200ms, 300ms)
- **Hover effects** :
- Scale subtil sur les cards (`hover:scale-[1.02]`)
- Glow sur les boutons et éléments interactifs
- Changement de couleur progressif
## 6. Icônes et Illustrations
### Système d'icônes
- **Cohérence** :
- Utiliser une seule bibliothèque d'icônes (ex: Heroicons, Lucide)
- Tailles cohérentes (`w-4 h-4`, `w-5 h-5`, `w-6 h-6`)
- Couleurs cohérentes avec le thème
- **Icônes contextuelles** :
- Icônes pour les actions (éditer, supprimer, partager)
- Icônes pour les états (succès, erreur, chargement)
- Icônes pour la navigation (menu, fermer, retour)
### Illustrations et images
- **Placeholders** :
- Placeholder cohérent pour images manquantes
- Skeleton loaders pour images en chargement
- Aspect ratios cohérents
- **Optimisation** :
- Utiliser Next.js Image component partout
- Lazy loading pour images hors viewport
- Tailles appropriées selon le contexte
## 7. Animations et Transitions
### Animations subtiles
- **Apparition** :
- Fade in pour les éléments qui apparaissent
- Slide in pour les modals et overlays
- Stagger pour les listes (délai entre éléments)
- **Transitions de page** :
- Fade entre les pages
- Slide pour navigation latérale
- Transitions fluides sans jarring
### Feedback visuel
- **Loading states** :
- Spinners cohérents (taille, couleur, vitesse)
- Skeleton loaders pour contenu
- Progress bars pour actions longues
- **Confirmations** :
- Animations de succès (checkmark animé)
- Animations d'erreur (shake, pulse)
- Toast notifications avec animations
## 8. Modals et Overlays
### Style unifié
- **Modal de base** :
- Overlay : `bg-black bg-opacity-50`
- Container : `bg-cyber-dark border border-neon-cyan/30 rounded-lg`
- Padding cohérent : `p-6` ou `p-8`
- Max width selon contenu : `max-w-md`, `max-w-lg`, `max-w-xl`
- **Header de modal** :
- Titre avec style cohérent
- Bouton fermer visible et accessible
- Séparateur si nécessaire
- **Footer de modal** :
- Actions alignées à droite
- Bouton principal en dernier
- Espacement cohérent
### Accessibilité
- **Focus trap** : Garder le focus dans la modal
- **Escape key** : Fermer avec Escape
- **Click outside** : Option de fermer en cliquant sur l'overlay
- **ARIA** : Labels et rôles appropriés
## 9. Formulaires
### Style unifié
- **Inputs** :
- Style cohérent : `border border-neon-cyan/30 rounded-lg bg-cyber-dark text-cyber-accent`
- Focus : `focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan`
- Placeholder : `placeholder-cyber-accent/50`
- Erreur : `border-red-500/50` avec message d'erreur
- **Labels** :
- Style cohérent : `text-sm font-medium text-cyber-accent mb-1`
- Association avec `htmlFor` et `id`
- Required indicator si nécessaire
- **Groupes de champs** :
- Espacement vertical cohérent
- Groupement logique
- Validation visuelle
### Validation visuelle
- **États** :
- Default : bordure neutre
- Focus : bordure cyan avec ring
- Error : bordure rouge avec message
- Success : bordure verte (si applicable)
- **Messages d'erreur** :
- Position cohérente (sous le champ)
- Style cohérent : `text-sm text-red-400`
- Icône d'erreur si applicable
## 10. Navigation
### Header amélioré
- **Layout** :
- Logo/titre à gauche
- Navigation centrale (si applicable)
- Actions à droite (connexion, langue, etc.)
- **Style** :
- Background : `bg-cyber-dark border-b border-neon-cyan/30`
- Hauteur cohérente
- Sticky si nécessaire
### Navigation mobile
- **Menu hamburger** :
- Icône hamburger visible sur mobile
- Drawer/sidebar qui slide depuis la gauche ou droite
- Overlay sombre
- Animation fluide
- **Navigation verticale** :
- Items de menu avec style cohérent
- États hover/active visibles
- Séparateurs si nécessaire
## 11. États vides et erreurs
### États vides
- **Style cohérent** :
- Icône ou illustration
- Message clair et actionnable
- CTA si applicable
- Centré verticalement et horizontalement
- **Exemples** :
- "Aucun article trouvé" avec suggestion de recherche
- "Aucun auteur" avec lien pour créer
- "Liste vide" avec action pour ajouter
### États d'erreur
- **Style cohérent** :
- Container : `bg-red-900/20 border border-red-500/50 rounded-lg p-4`
- Texte : `text-red-400`
- Icône d'erreur si applicable
- Message clair avec action de récupération
## 12. Améliorations spécifiques
### Corrections de cohérence
- **SeriesCard** :
- Remplacer `bg-white`, `text-gray-700` par thème cyber/neon
- Appliquer le même style que `ArticleCard` et `AuthorCard`
- **AlbyInstaller** :
- Remplacer les couleurs bleues génériques par le thème
- Utiliser les couleurs neon/cyber
### Améliorations visuelles
- **Background grid** :
- Utiliser `bg-cyber-grid` sur certains containers pour effet cyber
- Optionnel, à utiliser avec parcimonie
- **Gradients** :
- Gradients subtils pour certains backgrounds
- Gradients sur les bordures pour effet neon
- **Shadows et glows** :
- Utiliser `shadow-glow-cyan` et `shadow-glow-green` de manière cohérente
- Ajouter des glows sur hover pour éléments interactifs
## Priorisation
### Priorité haute
1. Correction de cohérence (SeriesCard, AlbyInstaller)
2. Système de boutons unifié
3. Système de cards unifié
4. Typographie et espacement cohérents
5. Responsive design amélioré
### Priorité moyenne
6. Composants réutilisables (Button, Card, Input, etc.)
7. Modals et overlays unifiés
8. Formulaires avec validation visuelle
9. Navigation mobile
10. États vides et erreurs
### Priorité basse
11. Animations et transitions avancées
12. Système d'icônes complet
13. Background grid et effets visuels
14. Documentation des composants
15. Storybook ou documentation visuelle
## Notes d'implémentation
- Toutes les améliorations doivent respecter le thème cyberpunk/neon existant
- Maintenir la cohérence avec les couleurs définies dans `tailwind.config.js`
- Tester sur différents appareils et tailles d'écran
- Vérifier l'accessibilité (contraste, focus, ARIA)
- Documenter les nouveaux composants et patterns
- Respecter les règles de qualité du projet (pas de duplication, composants réutilisables)

299
features/ux-improvements.md Normal file
View File

@ -0,0 +1,299 @@
# Améliorations UX - Documentation
**Auteur** : Équipe 4NK
**Date** : 2025-01-27
## Vue d'ensemble
Ce document liste les améliorations UX proposées pour la plateforme zapwall.fr. Ces améliorations visent à optimiser l'expérience utilisateur en minimisant les clics, améliorant le feedback, et rendant l'interface plus intuitive.
## 1. Navigation et minimisation des clics
### Raccourcis clavier globaux
- **`/`** : Focus automatique sur la barre de recherche
- **`Esc`** : Fermer les modals et overlays
- **`Ctrl/Cmd + K`** : Ouvrir une recherche rapide (command palette)
- **Navigation par flèches** : Naviguer entre les articles dans les listes
- **`Tab`** : Navigation séquentielle améliorée avec focus visible
### Actions directes depuis les cartes d'articles
- Bouton "Débloquer" visible directement sur la carte (sans clic supplémentaire)
- Aperçu au survol avec tooltip ou preview expandable
- Lien direct vers l'auteur sans navigation intermédiaire
- Actions contextuelles au clic droit (si pertinent)
## 2. Feedback utilisateur et états de chargement
### Skeleton loaders
- Remplacer les messages "Loading..." par des skeleton loaders pour :
- Liste d'articles
- Cartes d'auteurs
- Contenu d'article
- Profil utilisateur
- Animation subtile pour indiquer le chargement
### Confirmations visuelles
- **Toast notifications** :
- Après déblocage réussi d'un article
- Après publication réussie
- Après paiement confirmé
- Après actions importantes (suppression, modification)
- **Indicateur visuel** pour contenu déjà débloqué :
- Badge "Débloqué" sur les articles
- Icône de cadenas ouvert
- Couleur différente ou bordure distinctive
- **Animation de succès** après paiement :
- Confetti subtil ou animation de validation
- Message de confirmation clair
### États de progression
- Barre de progression pour paiements en cours
- Indicateur de synchronisation plus visible (actuellement dans le header)
- Feedback immédiat sur les actions :
- Boutons avec état loading (spinner)
- Désactivation pendant le traitement
- Messages d'état contextuels
## 3. Recherche et filtres
### Recherche améliorée
- **Recherche en temps réel** avec suggestions :
- Autocomplétion basée sur les titres d'articles
- Suggestions d'auteurs
- Historique de recherche récente
- **Filtres combinables visibles** :
- Badges actifs montrant les filtres appliqués
- Compteur de résultats ("X articles trouvés")
- Indication claire quand aucun résultat
- **Historique de recherche** :
- Sauvegarder les recherches récentes dans IndexedDB
- Afficher les recherches populaires
### Filtres persistants
- Sauvegarder les préférences de filtres dans IndexedDB
- Restaurer les filtres au retour sur la page
- Options de filtres avancés (date, catégorie, auteur combinés)
## 4. Modal de paiement
### Simplification du flux
- **Bouton "Payer avec Alby" en priorité** :
- Plus grand et plus visible
- Couleur distinctive (neon-green)
- Positionné en haut de la modal
- **Auto-détection d'Alby** :
- Détecter si Alby est disponible
- Ouvrir automatiquement Alby si disponible
- Afficher un message si Alby n'est pas installé
- **Instructions étape par étape** :
- Guide visuel pour nouveaux utilisateurs
- Étapes numérotées claires
- Lien vers installation d'Alby si nécessaire
- **Option "Se souvenir de ce choix"** :
- Mémoriser la méthode de paiement préférée
- Pré-sélectionner la méthode au prochain paiement
### Amélioration visuelle
- **QR code amélioré** :
- Plus grand (minimum 300x300px)
- Meilleur contraste
- Bordure distinctive
- **Copie d'invoice** :
- Bouton de copie plus visible
- Confirmation visuelle immédiate (toast)
- Copie en un clic
- **Compte à rebours** :
- Plus visible (barre de progression circulaire)
- Alerte visuelle quand < 60 secondes
- Message d'expiration clair
## 5. Accessibilité clavier
### Navigation complète au clavier
- **Tab order logique** :
- Ordre de tabulation cohérent sur toutes les pages
- Skip links pour navigation rapide
- Focus visible sur tous les éléments interactifs
- **Navigation par flèches** :
- Flèches haut/bas dans les listes d'articles
- Flèches gauche/droite pour navigation entre sections
- Enter pour activer les éléments
- **Raccourcis documentés** :
- Aide accessible via `?` ou `Ctrl/Cmd + /`
- Modal d'aide avec tous les raccourcis
- Indication des raccourcis dans les tooltips
### ARIA amélioré
- **Labels ARIA** :
- Tous les boutons iconiques ont des labels
- Images avec alt text descriptif
- Formulaires avec labels associés
- **Régions ARIA** :
- `role="navigation"` pour le header
- `role="main"` pour le contenu principal
- `role="search"` pour la barre de recherche
- `role="complementary"` pour les filtres
- **Annonces screen reader** :
- `aria-live` pour changements d'état (paiement réussi, erreurs)
- Messages d'erreur annoncés
- Confirmations d'actions annoncées
## 6. Gestion d'erreurs
### Messages d'erreur actionnables
- **Messages clairs** :
- Langage simple et compréhensible
- Explication de l'erreur
- Impact sur l'utilisateur
- **Actions de récupération** :
- Bouton "Réessayer" pour erreurs réseau
- Bouton "Vérifier la connexion" si applicable
- Lien vers documentation si erreur complexe
- **Suggestions de solutions** :
- "Vérifiez votre connexion Alby"
- "Assurez-vous d'avoir des fonds suffisants"
- "Vérifiez votre connexion Internet"
### Récupération gracieuse
- **Retry automatique** :
- Retry avec backoff exponentiel pour erreurs temporaires
- Indicateur visuel des tentatives
- Option d'annuler le retry
- **Sauvegarde locale** :
- Sauvegarder les données en cas d'erreur réseau
- Reprendre où on s'est arrêté après reconnexion
- Indication des données en cache
## 7. Performance perçue
### Chargement progressif
- **Pagination ou infinite scroll** :
- Charger les articles par lots
- Indicateur de chargement pour les lots suivants
- Option de pagination classique
- **Lazy loading** :
- Images chargées à la demande
- Contenu lourd chargé progressivement
- **Préchargement** :
- Précharger les articles suivants en arrière-plan
- Précharger les images des articles visibles
### Transitions fluides
- **Animations subtiles** :
- Transitions de page (fade, slide)
- Animations d'apparition des éléments
- Micro-interactions sur les boutons
- **Feedback visuel immédiat** :
- Changement d'état instantané sur les interactions
- Loading states locaux (bouton, carte)
- Optimistic UI updates quand possible
## 8. Clarté des actions
### Hiérarchie visuelle
- **CTA principaux** :
- Plus visibles (couleur, taille, position)
- Contraste élevé
- Espacement approprié
- **Actions secondaires** :
- Moins proéminentes
- Style discret mais accessible
- **États désactivés** :
- Visuellement distincts
- Explication du pourquoi (tooltip)
### Contexte et aide
- **Tooltips explicatifs** :
- Sur les actions complexes
- Sur les icônes sans label
- Sur les fonctionnalités avancées
- **Icônes avec labels** :
- Labels textuels pour toutes les icônes
- Option d'afficher/masquer les labels
- **Guide de première utilisation** :
- Tour guidé pour nouveaux utilisateurs
- Points d'intérêt clairs
- Option de skip
## 9. Expérience de lecture
### Amélioration de l'affichage
- **Mode lecture** :
- Largeur optimale pour la lecture
- Typographie améliorée
- Espacement des lignes ajustable
- Taille de police ajustable
- **Navigation dans l'article** :
- Bouton "Retour en haut" pour longs articles
- Table des matières pour articles longs
- Indicateur de progression de lecture
- **Navigation entre articles** :
- Navigation précédent/suivant dans une série
- Suggestions d'articles similaires
- Articles de l'auteur en bas de page
### Partage et engagement
- **Boutons de partage** :
- Partage vers Nostr
- Copie du lien
- Partage vers réseaux sociaux (optionnel)
- **Suggestions** :
- Articles similaires
- Articles du même auteur
- Articles de la même catégorie
- **Indicateur de popularité** :
- Nombre de déblocages (si public)
- Badge "Populaire" pour articles avec beaucoup de déblocages
## 10. Profil et publications
### Gestion rapide
- **Actions rapides** :
- Éditer en un clic depuis la liste
- Supprimer avec confirmation
- Dupliquer un article
- **Vue d'ensemble** :
- Statistiques visuelles (nombre d'articles, déblocages)
- Graphiques de performance (optionnel)
- Vue d'ensemble des revenus (si applicable)
### Prévisualisation avant publication
- **Aperçu exact** :
- Prévisualisation WYSIWYG
- Vue exacte de l'article tel qu'il apparaîtra
- Test sur différents formats (mobile, desktop)
- **Validation avant publication** :
- Checklist de validation
- Vérification des champs requis
- Avertissements pour champs manquants
## Priorisation
### Priorité haute
1. Skeleton loaders
2. Toast notifications
3. Indicateur visuel pour contenu débloqué
4. Raccourcis clavier de base (`/`, `Esc`)
5. Amélioration de la modal de paiement
### Priorité moyenne
6. Recherche améliorée avec suggestions
7. Filtres persistants
8. Navigation clavier complète
9. ARIA amélioré
10. Messages d'erreur actionnables
### Priorité basse
11. Mode lecture
12. Partage et engagement
13. Guide de première utilisation
14. Prévisualisation avant publication
15. Chargement progressif avancé
## Notes d'implémentation
- Toutes les améliorations doivent respecter les règles de qualité du projet
- Aucun fallback ne doit être introduit
- Tous les changements doivent être accessibles (ARIA, clavier, contraste)
- Les améliorations doivent minimiser les clics selon les règles du projet
- La documentation doit être mise à jour avec chaque amélioration

View File

@ -66,4 +66,70 @@ body {
.border-glow-green {
box-shadow: 0 0 5px #00ff41, 0 0 10px #00ff41, inset 0 0 5px #00ff41;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
/* Transitions */
.transition-smooth {
transition: all 0.2s ease-in-out;
}
.transition-colors-smooth {
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
}