Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
/**
* Component: Alert Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import { Modal } from './Modal';
import { Button } from './Button';
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
message: string;
buttonText?: string;
variant?: 'info' | 'warning' | 'success' | 'danger';
}
export function AlertModal({
isOpen,
onClose,
title,
message,
buttonText = 'OK',
variant = 'info',
}: AlertModalProps) {
const iconMap = {
info: (
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-6 h-6 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
success: (
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
danger: (
<svg className="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
<div className="space-y-6">
<div className="flex gap-4">
<div className="flex-shrink-0">
{iconMap[variant]}
</div>
<p className="text-gray-600 dark:text-gray-400 flex-1">{message}</p>
</div>
<div className="flex justify-end">
<Button onClick={onClose} variant="primary">
{buttonText}
</Button>
</div>
</div>
</Modal>
);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Component: Button
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils/cn';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
icon?: React.ReactNode;
}
export function Button({
children,
variant = 'primary',
size = 'md',
loading = false,
disabled,
icon,
className,
...props
}: ButtonProps) {
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-500 dark:hover:bg-blue-950',
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500 dark:text-gray-300 dark:hover:bg-gray-800',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<button
className={cn(
baseStyles,
variants[variant],
sizes[size],
className
)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg
className="animate-spin -ml-1 mr-2 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>
)}
{icon && !loading && <span className="mr-2">{icon}</span>}
{children}
</button>
);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Component: Confirmation Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import { Modal } from './Modal';
import { Button } from './Button';
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
isLoading?: boolean;
variant?: 'danger' | 'primary';
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
isLoading = false,
variant = 'primary',
}: ConfirmModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm" showCloseButton={false}>
<div className="space-y-6">
<p className="text-gray-600 dark:text-gray-400">{message}</p>
<div className="flex gap-3 justify-end">
<Button onClick={onClose} variant="outline" disabled={isLoading}>
{cancelText}
</Button>
<Button
onClick={onConfirm}
variant={variant}
loading={isLoading}
>
{confirmText}
</Button>
</div>
</div>
</Modal>
);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Component: Input
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { InputHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils/cn';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, id, ...props }, ref) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
const baseStyles =
'block w-full rounded-lg border px-4 py-2 text-base transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const stateStyles = error
? 'border-red-500 focus:border-red-500 focus:ring-red-500 text-red-900 placeholder-red-300 dark:text-red-100'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white';
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(baseStyles, stateStyles, className)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={
error
? `${inputId}-error`
: helperText
? `${inputId}-helper`
: undefined
}
{...props}
/>
{error && (
<p
id={`${inputId}-error`}
className="mt-2 text-sm text-red-600 dark:text-red-400"
>
{error}
</p>
)}
{helperText && !error && (
<p
id={`${inputId}-helper`}
className="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
{helperText}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
+111
View File
@@ -0,0 +1,111 @@
/**
* Component: Modal Dialog
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useEffect } from 'react';
import { cn } from '@/lib/utils/cn';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
showCloseButton?: boolean;
}
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
showCloseButton = true,
}: ModalProps) {
// Close on ESC key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEsc);
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl',
full: 'max-w-[95vw]',
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
/>
{/* Modal container */}
<div className="flex min-h-full items-center justify-center p-4">
<div
className={cn(
'relative w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl',
'transform transition-all',
sizeClasses[size]
)}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{title}
</h2>
{showCloseButton && (
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
{/* Content */}
<div className="px-6 py-4 max-h-[calc(100vh-200px)] overflow-y-auto">
{children}
</div>
</div>
</div>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Component: Pagination Component
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
}
export function Pagination({ currentPage, totalPages, onPageChange, className = '' }: PaginationProps) {
if (totalPages <= 1) {
return null;
}
const generatePageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 7; // Show max 7 page buttons
if (totalPages <= maxVisible) {
// Show all pages if total is less than max
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
// Show pages around current page
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
// Always show last page
pages.push(totalPages);
}
return pages;
};
const pageNumbers = generatePageNumbers();
return (
<div className={`flex items-center justify-center gap-2 ${className}`}>
{/* Previous Button */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Previous page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Page Numbers */}
<div className="flex items-center gap-1">
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
<span
key={`ellipsis-${index}`}
className="px-3 py-2 text-gray-500 dark:text-gray-400"
>
...
</span>
);
}
const pageNum = page as number;
const isActive = pageNum === currentPage;
return (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={`px-4 py-2 rounded-lg font-medium transition-colors
${
isActive
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
aria-label={`Page ${pageNum}`}
aria-current={isActive ? 'page' : undefined}
>
{pageNum}
</button>
);
})}
</div>
{/* Next Button */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
hover:bg-gray-50 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-label="Next page"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Component: Sticky Pagination with Progress Bar
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
interface StickyPaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
sectionRef: React.RefObject<HTMLElement | null>;
label: string; // e.g., "Popular Audiobooks"
}
export function StickyPagination({
currentPage,
totalPages,
onPageChange,
sectionRef,
label,
}: StickyPaginationProps) {
const [isVisible, setIsVisible] = useState(false);
const [jumpPage, setJumpPage] = useState(currentPage.toString());
// Update jump page input when current page changes externally
useEffect(() => {
setJumpPage(currentPage.toString());
}, [currentPage]);
// Intersection Observer to show/hide pagination based on section visibility
useEffect(() => {
if (!sectionRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
// Show pagination when section is in viewport
setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1);
},
{
threshold: [0, 0.1, 0.5, 1],
rootMargin: '-60px 0px -60px 0px', // Account for header/footer
}
);
observer.observe(sectionRef.current);
return () => observer.disconnect();
}, [sectionRef]);
if (totalPages <= 1) {
return null;
}
const handlePrevious = () => {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
};
const handleNext = () => {
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
};
const handleJumpSubmit = (e: React.FormEvent) => {
e.preventDefault();
const page = parseInt(jumpPage, 10);
if (!isNaN(page) && page >= 1 && page <= totalPages) {
onPageChange(page);
} else {
// Reset to current page if invalid
setJumpPage(currentPage.toString());
}
};
return (
<div
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
}`}
>
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5">
<div className="flex items-center gap-3">
{/* Section Label - Hidden on small screens */}
<div className="hidden md:block text-xs font-medium text-gray-600 dark:text-gray-400 pr-2 border-r border-gray-300 dark:border-gray-600">
{label}
</div>
{/* Previous Button */}
<button
onClick={handlePrevious}
disabled={currentPage === 1}
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
transition-colors"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
{/* Page Info & Jump */}
<div className="flex items-center gap-1.5">
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
Page
</span>
<form onSubmit={handleJumpSubmit} className="inline-flex">
<input
type="text"
value={jumpPage}
onChange={(e) => setJumpPage(e.target.value)}
onBlur={handleJumpSubmit}
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded
bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100
border border-gray-300 dark:border-gray-600
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
aria-label="Current page"
/>
</form>
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
of {totalPages}
</span>
</div>
{/* Next Button */}
<button
onClick={handleNext}
disabled={currentPage === totalPages}
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
transition-colors"
aria-label="Next page"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Component: Toast Notification System
* Documentation: documentation/frontend/components.md
*/
'use client';
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ToastContextType {
toasts: Toast[];
addToast: (message: string, type: ToastType, duration?: number) => void;
removeToast: (id: string) => void;
success: (message: string, duration?: number) => void;
error: (message: string, duration?: number) => void;
info: (message: string, duration?: number) => void;
warning: (message: string, duration?: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const addToast = useCallback(
(message: string, type: ToastType, duration: number = 5000) => {
const id = `toast-${Date.now()}-${Math.random()}`;
const newToast: Toast = { id, message, type, duration };
setToasts((prev) => [...prev, newToast]);
if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
},
[removeToast]
);
const success = useCallback(
(message: string, duration?: number) => addToast(message, 'success', duration),
[addToast]
);
const error = useCallback(
(message: string, duration?: number) => addToast(message, 'error', duration),
[addToast]
);
const info = useCallback(
(message: string, duration?: number) => addToast(message, 'info', duration),
[addToast]
);
const warning = useCallback(
(message: string, duration?: number) => addToast(message, 'warning', duration),
[addToast]
);
return (
<ToastContext.Provider
value={{
toasts,
addToast,
removeToast,
success,
error,
info,
warning,
}}
>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
function ToastContainer({
toasts,
onRemove,
}: {
toasts: Toast[];
onRemove: (id: string) => void;
}) {
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
))}
</div>
);
}
function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) => void }) {
const colors = {
success: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200',
error: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200',
warning: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200',
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200',
};
const icons = {
success: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
};
return (
<div
className={`flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[300px] max-w-md pointer-events-auto animate-slide-in-right ${
colors[toast.type]
}`}
>
<div className="flex-shrink-0">{icons[toast.type]}</div>
<div className="flex-1 text-sm font-medium">{toast.message}</div>
<button
onClick={() => onRemove(toast.id)}
className="flex-shrink-0 hover:opacity-70 transition-opacity"
aria-label="Close"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
);
}
/**
* Confirmation Dialog Hook
*/
export function useConfirm() {
return useCallback((message: string): Promise<boolean> => {
return new Promise((resolve) => {
const result = window.confirm(message);
resolve(result);
});
}, []);
}