create for series
This commit is contained in:
parent
db3b9b9b38
commit
8f3f62f7bf
@ -8,7 +8,7 @@ interface AlbyInstallerProps {
|
|||||||
function InfoIcon(): React.ReactElement {
|
function InfoIcon(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-blue-400"
|
className="h-5 w-5 text-neon-cyan"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@ -43,7 +43,7 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps)
|
|||||||
href="https://getalby.com/"
|
href="https://getalby.com/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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
|
Install Alby
|
||||||
</a>
|
</a>
|
||||||
@ -51,7 +51,7 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps)
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void connect()
|
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
|
Already installed? Connect
|
||||||
</button>
|
</button>
|
||||||
@ -63,15 +63,15 @@ function InstallerActions({ onInstalled, markInstalled }: InstallerActionsProps)
|
|||||||
function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps): React.ReactElement {
|
function InstallerBody({ onInstalled, markInstalled }: InstallerActionsProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="ml-3 flex-1">
|
<div className="ml-3 flex-1">
|
||||||
<h3 className="text-sm font-medium text-blue-800">Alby Extension Required</h3>
|
<h3 className="text-sm font-medium text-neon-cyan">Alby Extension Required</h3>
|
||||||
<div className="mt-2 text-sm text-blue-700">
|
<div className="mt-2 text-sm text-cyber-accent">
|
||||||
<p>To make Lightning payments, please install the Alby browser extension.</p>
|
<p>To make Lightning payments, please install the Alby browser extension.</p>
|
||||||
</div>
|
</div>
|
||||||
<InstallerActions
|
<InstallerActions
|
||||||
markInstalled={markInstalled}
|
markInstalled={markInstalled}
|
||||||
{...(onInstalled ? { onInstalled } : {})}
|
{...(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>
|
<p>Alby is a Lightning wallet that enables instant Bitcoin payments in your browser.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -116,7 +116,7 @@ export function AlbyInstaller({ onInstalled }: AlbyInstallerProps): React.ReactE
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 items-start">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import QRCode from 'react-qr-code'
|
|||||||
import type { AlbyInvoice } from '@/types/alby'
|
import type { AlbyInvoice } from '@/types/alby'
|
||||||
import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
|
import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
|
||||||
import { AlbyInstaller } from './AlbyInstaller'
|
import { AlbyInstaller } from './AlbyInstaller'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
|
||||||
interface PaymentModalProps {
|
interface PaymentModalProps {
|
||||||
@ -34,11 +35,9 @@ function useInvoiceTimer(expiresAt?: number): number | null {
|
|||||||
function PaymentHeader({
|
function PaymentHeader({
|
||||||
amount,
|
amount,
|
||||||
timeRemaining,
|
timeRemaining,
|
||||||
onClose,
|
|
||||||
}: {
|
}: {
|
||||||
amount: number
|
amount: number
|
||||||
timeRemaining: number | null
|
timeRemaining: number | null
|
||||||
onClose: () => void
|
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const timeLabel = useMemo((): string | null => {
|
const timeLabel = useMemo((): string | null => {
|
||||||
if (timeRemaining === null) {
|
if (timeRemaining === null) {
|
||||||
@ -53,18 +52,13 @@ function PaymentHeader({
|
|||||||
}, [timeRemaining])
|
}, [timeRemaining])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="mb-4">
|
||||||
<div>
|
<h2 className="text-xl font-bold text-neon-cyan">{t('payment.modal.zapAmount', { amount })}</h2>
|
||||||
<h2 className="text-xl font-bold text-neon-cyan">{t('payment.modal.zapAmount', { amount })}</h2>
|
{timeLabel && (
|
||||||
{timeLabel && (
|
<p className={`text-sm ${timeRemaining !== null && timeRemaining <= 60 ? 'text-red-400 font-semibold' : 'text-cyber-accent/70'}`}>
|
||||||
<p className={`text-sm ${timeRemaining !== null && timeRemaining <= 60 ? 'text-red-400 font-semibold' : 'text-cyber-accent/70'}`}>
|
{t('payment.modal.timeRemaining', { time: timeLabel })}
|
||||||
{t('payment.modal.timeRemaining', { time: timeLabel })}
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button onClick={onClose} className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -220,26 +214,30 @@ export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentMod
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal
|
||||||
<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">
|
isOpen
|
||||||
<AlbyInstaller />
|
onClose={onClose}
|
||||||
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} onClose={onClose} />
|
title={t('payment.modal.zapAmount', { amount: invoice.amount })}
|
||||||
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
|
size="small"
|
||||||
<PaymentActions
|
aria-label={t('payment.modal.zapAmount', { amount: invoice.amount })}
|
||||||
copied={copied}
|
>
|
||||||
onCopy={handleCopy}
|
<AlbyInstaller />
|
||||||
onOpenWallet={handleOpenWalletSync}
|
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} />
|
||||||
/>
|
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
|
||||||
<ExpiredNotice show={timeRemaining !== null && timeRemaining <= 0} />
|
<PaymentActions
|
||||||
{errorMessage && (
|
copied={copied}
|
||||||
<p className="text-xs text-red-400 mt-3 text-center" role="alert">
|
onCopy={handleCopy}
|
||||||
{errorMessage}
|
onOpenWallet={handleOpenWalletSync}
|
||||||
</p>
|
/>
|
||||||
)}
|
<ExpiredNotice show={timeRemaining !== null && timeRemaining <= 0} />
|
||||||
<p className="text-xs text-cyber-accent/70 mt-4 text-center">
|
{errorMessage && (
|
||||||
{t('payment.modal.autoVerify')}
|
<p className="text-xs text-red-400 mt-3 text-center" role="alert">
|
||||||
|
{errorMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
</div>
|
<p className="text-xs text-cyber-accent/70 mt-4 text-center">
|
||||||
|
{t('payment.modal.autoVerify')}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export function ProfileSeriesBlock({ currentPubkey, onSelectSeries, selectedSeri
|
|||||||
const { pubkey, isUnlocked } = useNostrAuth()
|
const { pubkey, isUnlocked } = useNostrAuth()
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [refreshKey, setRefreshKey] = useState(0)
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
const isAuthor = pubkey === currentPubkey && isUnlocked
|
const isAuthor = pubkey !== null && pubkey === currentPubkey && isUnlocked
|
||||||
|
|
||||||
const handleSeriesCreated = (): void => {
|
const handleSeriesCreated = (): void => {
|
||||||
setRefreshKey((prev) => prev + 1)
|
setRefreshKey((prev) => prev + 1)
|
||||||
|
|||||||
@ -12,35 +12,37 @@ interface SeriesCardProps {
|
|||||||
export function SeriesCard({ series, onSelect, selected }: SeriesCardProps): React.ReactElement {
|
export function SeriesCard({ series, onSelect, selected }: SeriesCardProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`border rounded-lg p-4 bg-white shadow-sm ${
|
className={`border rounded-lg p-4 bg-cyber-dark transition-all ${
|
||||||
selected ? 'border-blue-500 ring-1 ring-blue-200' : 'border-gray-200'
|
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 && (
|
{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
|
<Image
|
||||||
src={series.coverUrl}
|
src={series.coverUrl}
|
||||||
alt={series.title}
|
alt={series.title}
|
||||||
className="object-cover rounded"
|
className="object-cover"
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-lg font-semibold">{series.title}</h3>
|
<h3 className="text-lg font-semibold text-neon-cyan mb-2">{series.title}</h3>
|
||||||
<p className="text-sm text-gray-700 line-clamp-3">{series.description}</p>
|
<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-gray-600">
|
<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>
|
<span>{series.category === 'science-fiction' ? t('category.science-fiction') : t('category.scientific-research')}</span>
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => onSelect(series.id)}
|
||||||
>
|
>
|
||||||
{t('common.open')}
|
{t('common.open')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-blue-600">
|
<div className="mt-2 text-xs text-neon-cyan/70">
|
||||||
<Link href={`/series/${series.id}`} className="underline">
|
<Link href={`/series/${series.id}`} className="hover:text-neon-cyan transition-colors underline">
|
||||||
{t('series.view')}
|
{t('series.view')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 {
|
.border-glow-green {
|
||||||
box-shadow: 0 0 5px #00ff41, 0 0 10px #00ff41, inset 0 0 5px #00ff41;
|
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