create for series
This commit is contained in:
parent
db3b9b9b38
commit
8f3f62f7bf
@ -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 />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
36
components/ui/Badge.tsx
Normal 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
107
components/ui/Button.tsx
Normal 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
73
components/ui/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
components/ui/EmptyState.tsx
Normal file
26
components/ui/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
components/ui/ErrorState.tsx
Normal file
42
components/ui/ErrorState.tsx
Normal 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
127
components/ui/Input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
components/ui/MobileMenu.tsx
Normal file
105
components/ui/MobileMenu.tsx
Normal 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
133
components/ui/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
components/ui/Skeleton.tsx
Normal file
44
components/ui/Skeleton.tsx
Normal 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
127
components/ui/Textarea.tsx
Normal 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
67
components/ui/Toast.tsx
Normal 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
11
components/ui/index.ts
Normal 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'
|
||||
82
docs/ui-typography-reference.md
Normal file
82
docs/ui-typography-reference.md
Normal 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>
|
||||
```
|
||||
146
features/ui-improvements-summary.md
Normal file
146
features/ui-improvements-summary.md
Normal 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
360
features/ui-improvements.md
Normal 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
299
features/ux-improvements.md
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user