chore: initialize @limitless/ui git repo + add AppShell

This brings the long-untracked @limitless/ui source tree under version
control. Until now /srv/k8s/templates/limitless-ui has been a plain
file: dependency consumed by gscChronos / gscCRM / gscAdmin, with
copies scattered across web/gsc{Portal,WWW,Aether,Register}/ and
apps/gsc{Meet,Share}/. None were git-tracked.

Treating /srv/k8s/templates/limitless-ui as the canonical going
forward; secondary copies should be replaced with this version
in their consumers' Dockerfiles when they next get touched.

Changes in this initial commit beyond the snapshot:
- Add src/layout/AppShell.tsx — runtime-loaded chrome (header,
  sidebar, footer) backed by gsc-shell-api. Public surface:
    AppShell, ShellProvider, useShell, ShellConfig types
  Framework-agnostic (no Next.js dep). Apps pass appKey + apiUrl +
  getToken; AppShell composes the existing PageShell / Navbar /
  Sidebar / Footer primitives with API data.
- Re-export AppShell from src/index.ts.
- Fix build script: `tsc -p tsconfig.json --noEmit false`. The bare
  `tsc` command was a no-op because tsconfig.json sets noEmit:true
  for typecheck speed. Existing dist/ only existed because of an
  earlier emit; clean rebuilds were silently broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-10 09:42:57 +02:00
commit cf068ce4ec
115 changed files with 36542 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import React, { createContext, useContext, useState } from 'react';
import { Collapse } from './Collapse';
type AccordionContextValue = {
activeKey: string | null;
toggle: (key: string) => void;
};
const AccordionContext = createContext<AccordionContextValue | null>(null);
export type AccordionProps = {
defaultActiveKey?: string | null;
alwaysOpen?: boolean;
children: React.ReactNode;
className?: string;
};
export function Accordion({ defaultActiveKey = null, alwaysOpen, children, className = '' }: AccordionProps) {
const [activeKey, setActiveKey] = useState<string | null>(defaultActiveKey);
const toggle = (key: string) => {
if (alwaysOpen) return;
setActiveKey(prev => (prev === key ? null : key));
};
return (
<AccordionContext.Provider value={{ activeKey, toggle }}>
<div className={['accordion', className].filter(Boolean).join(' ')}>{children}</div>
</AccordionContext.Provider>
);
}
export type AccordionItemProps = {
eventKey: string;
header: React.ReactNode;
children: React.ReactNode;
};
export function AccordionItem({ eventKey, header, children }: AccordionItemProps) {
const ctx = useContext(AccordionContext);
if (!ctx) throw new Error('AccordionItem must be used within Accordion');
const isOpen = ctx.activeKey === eventKey;
const handleClick = () => {
ctx.toggle(eventKey);
};
return (
<div className="accordion-item">
<h2 className="accordion-header">
<button className={`accordion-button ${isOpen ? '' : 'collapsed'}`} type="button" onClick={handleClick}>
{header}
</button>
</h2>
<Collapse isOpen={isOpen} className="accordion-collapse">
<div className="accordion-body">{children}</div>
</Collapse>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import Select, { Props as SelectProps, GroupBase } from 'react-select';
import Creatable from 'react-select/creatable';
import Async from 'react-select/async';
export type Option = { label: string; value: string };
export type SelectSingleProps = SelectProps<Option, false, GroupBase<Option>>;
export type SelectMultiProps = SelectProps<Option, true, GroupBase<Option>>;
export function SelectSingle(props: SelectSingleProps) {
return <Select {...props} isMulti={false} />;
}
export function MultiSelect(props: SelectMultiProps) {
return <Select {...props} isMulti />;
}
export type CreatableSelectProps = SelectProps<Option, true, GroupBase<Option>>;
export function TagsSelect(props: CreatableSelectProps) {
return <Creatable {...props} isMulti />;
}
export type AsyncSelectProps = React.ComponentProps<typeof Async<Option, false, GroupBase<Option>>> & {
isMulti?: boolean;
};
export function AsyncSelect(props: AsyncSelectProps) {
return <Async {...props} />;
}

49
src/components/Alert.tsx Normal file
View File

@@ -0,0 +1,49 @@
import React from 'react';
export type AlertVariant =
| 'primary'
| 'secondary'
| 'success'
| 'danger'
| 'warning'
| 'info'
| 'light'
| 'dark';
export type AlertProps = {
variant?: AlertVariant;
dismissible?: boolean;
onClose?: () => void;
className?: string;
icon?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
export function Alert({
variant = 'primary',
dismissible,
onClose,
className = '',
icon,
children,
...rest
}: AlertProps) {
const classes = ['alert', `alert-${variant}`, dismissible ? 'alert-dismissible fade show' : '', className]
.filter(Boolean)
.join(' ');
return (
<div role="alert" className={classes} {...rest}>
{icon ? <span className="me-2 align-middle">{icon}</span> : null}
<span className="align-middle">{children}</span>
{dismissible ? (
<button
type="button"
className="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
onClick={onClose}
/>
) : null}
</div>
);
}

26
src/components/Badge.tsx Normal file
View File

@@ -0,0 +1,26 @@
import React from 'react';
export type BadgeVariant =
| 'primary'
| 'secondary'
| 'success'
| 'danger'
| 'warning'
| 'info'
| 'light'
| 'dark';
export type BadgeProps = {
variant?: BadgeVariant;
pill?: boolean;
className?: string;
} & React.HTMLAttributes<HTMLSpanElement>;
export function Badge({ variant = 'primary', pill, className = '', children, ...rest }: BadgeProps) {
const classes = ['badge', `bg-${variant}`, pill ? 'rounded-pill' : '', className].filter(Boolean).join(' ');
return (
<span className={classes} {...rest}>
{children}
</span>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
export type BreadcrumbItem = {
label: React.ReactNode;
href?: string;
active?: boolean;
};
export type BreadcrumbsProps = {
items: BreadcrumbItem[];
className?: string;
};
export function Breadcrumbs({ items, className = '' }: BreadcrumbsProps) {
return (
<nav aria-label="breadcrumb">
<ol className={`breadcrumb ${className}`.trim()}>
{items.map((item, idx) => {
const isLast = idx === items.length - 1 || item.active;
return (
<li
key={idx}
className={`breadcrumb-item ${isLast ? 'active' : ''}`}
aria-current={isLast ? 'page' : undefined}
>
{isLast || !item.href ? item.label : <a href={item.href}>{item.label}</a>}
</li>
);
})}
</ol>
</nav>
);
}

42
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,42 @@
import React from 'react';
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'success'
| 'danger'
| 'warning'
| 'info'
| 'light'
| 'dark'
| 'link';
export type ButtonProps = {
variant?: ButtonVariant;
size?: 'sm' | 'lg';
outline?: boolean;
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
className?: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ variant = 'primary', size, outline = false, iconLeft, iconRight, className = '', children, ...rest },
ref
) => {
const variantClass = outline ? `btn-outline-${variant}` : `btn-${variant}`;
const sizeClass = size ? `btn-${size}` : '';
const classes = ['btn', variantClass, sizeClass, className].filter(Boolean).join(' ');
return (
<button ref={ref} className={classes} {...rest}>
{iconLeft ? <span className="me-2 d-inline-flex align-middle">{iconLeft}</span> : null}
<span className="align-middle">{children}</span>
{iconRight ? <span className="ms-2 d-inline-flex align-middle">{iconRight}</span> : null}
</button>
);
}
);
Button.displayName = 'Button';

781
src/components/Calendar.tsx Normal file
View File

@@ -0,0 +1,781 @@
import React, { useState, useMemo, useCallback } from 'react';
export interface CalendarEvent {
/** Unique identifier */
id: string;
/** Event title */
title: string;
/** Start date/time */
start: Date;
/** End date/time */
end?: Date;
/** All day event */
allDay?: boolean;
/** Event color */
color?: string;
/** Background color */
backgroundColor?: string;
/** Border color */
borderColor?: string;
/** Text color */
textColor?: string;
/** Custom class name */
className?: string;
/** Whether event is editable */
editable?: boolean;
/** Custom data */
extendedProps?: Record<string, unknown>;
}
export interface CalendarProps {
/** Calendar events */
events?: CalendarEvent[];
/** Initial date to display */
initialDate?: Date;
/** Initial view */
initialView?: 'month' | 'week' | 'day' | 'list';
/** Locale */
locale?: string;
/** First day of week (0 = Sunday, 1 = Monday) */
firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Show week numbers */
weekNumbers?: boolean;
/** Selectable dates */
selectable?: boolean;
/** Editable events */
editable?: boolean;
/** Show header navigation */
showNavigation?: boolean;
/** Height */
height?: number | string;
/** Callback when date is clicked */
onDateClick?: (date: Date) => void;
/** Callback when event is clicked */
onEventClick?: (event: CalendarEvent) => void;
/** Callback when date range is selected */
onSelect?: (start: Date, end: Date) => void;
/** Callback when event is dropped/moved */
onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd: Date) => void;
/** Callback when view changes */
onViewChange?: (view: string) => void;
/** Callback when navigating months */
onNavigate?: (date: Date) => void;
/** Custom event render */
eventContent?: (event: CalendarEvent) => React.ReactNode;
/** Custom day cell render */
dayCellContent?: (date: Date) => React.ReactNode;
/** Header toolbar config */
headerToolbar?: {
left?: string;
center?: string;
right?: string;
};
/** Additional CSS classes */
className?: string;
}
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
export const Calendar: React.FC<CalendarProps> = ({
events = [],
initialDate = new Date(),
initialView = 'month',
locale = 'en-US',
firstDayOfWeek = 0,
weekNumbers = false,
selectable = false,
editable = false,
showNavigation = true,
height = 'auto',
onDateClick,
onEventClick,
onSelect,
onViewChange,
onNavigate,
eventContent,
dayCellContent,
className = '',
}) => {
const [currentDate, setCurrentDate] = useState(initialDate);
const [currentView, setCurrentView] = useState(initialView);
const [selectedRange, setSelectedRange] = useState<{ start: Date | null; end: Date | null }>({
start: null,
end: null,
});
const [isSelecting, setIsSelecting] = useState(false);
// Get days array adjusted for first day of week
const adjustedDays = useMemo(() => {
const days = [...DAYS];
for (let i = 0; i < firstDayOfWeek; i++) {
days.push(days.shift()!);
}
return days;
}, [firstDayOfWeek]);
// Calculate calendar grid
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Adjust for first day of week
let startDay = firstDay.getDay() - firstDayOfWeek;
if (startDay < 0) startDay += 7;
const daysInMonth = lastDay.getDate();
const days: { date: Date; isCurrentMonth: boolean; isToday: boolean }[] = [];
// Previous month days
const prevMonthLastDay = new Date(year, month, 0).getDate();
for (let i = startDay - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonthLastDay - i),
isCurrentMonth: false,
isToday: false,
});
}
// Current month days
const today = new Date();
for (let i = 1; i <= daysInMonth; i++) {
const date = new Date(year, month, i);
days.push({
date,
isCurrentMonth: true,
isToday:
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear(),
});
}
// Next month days
const remainingDays = 42 - days.length; // 6 rows × 7 days
for (let i = 1; i <= remainingDays; i++) {
days.push({
date: new Date(year, month + 1, i),
isCurrentMonth: false,
isToday: false,
});
}
return days;
}, [currentDate, firstDayOfWeek]);
// Get week number
const getWeekNumber = (date: Date): number => {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
};
// Get events for a specific date
const getEventsForDate = useCallback((date: Date) => {
return events.filter((event) => {
const eventStart = new Date(event.start);
const eventEnd = event.end ? new Date(event.end) : eventStart;
const dateStart = new Date(date);
dateStart.setHours(0, 0, 0, 0);
const dateEnd = new Date(date);
dateEnd.setHours(23, 59, 59, 999);
return eventStart <= dateEnd && eventEnd >= dateStart;
});
}, [events]);
// Navigation handlers
const goToPrev = () => {
const newDate = new Date(currentDate);
if (currentView === 'month') {
newDate.setMonth(newDate.getMonth() - 1);
} else if (currentView === 'week') {
newDate.setDate(newDate.getDate() - 7);
} else if (currentView === 'day') {
newDate.setDate(newDate.getDate() - 1);
}
setCurrentDate(newDate);
onNavigate?.(newDate);
};
const goToNext = () => {
const newDate = new Date(currentDate);
if (currentView === 'month') {
newDate.setMonth(newDate.getMonth() + 1);
} else if (currentView === 'week') {
newDate.setDate(newDate.getDate() + 7);
} else if (currentView === 'day') {
newDate.setDate(newDate.getDate() + 1);
}
setCurrentDate(newDate);
onNavigate?.(newDate);
};
const goToToday = () => {
const today = new Date();
setCurrentDate(today);
onNavigate?.(today);
};
// View change handler
const handleViewChange = (view: 'month' | 'week' | 'day' | 'list') => {
setCurrentView(view);
onViewChange?.(view);
};
// Date click handler
const handleDateClick = (date: Date) => {
onDateClick?.(date);
};
// Selection handlers
const handleMouseDown = (date: Date) => {
if (!selectable) return;
setIsSelecting(true);
setSelectedRange({ start: date, end: date });
};
const handleMouseEnter = (date: Date) => {
if (!isSelecting || !selectedRange.start) return;
setSelectedRange((prev) => ({ ...prev, end: date }));
};
const handleMouseUp = () => {
if (!isSelecting || !selectedRange.start || !selectedRange.end) {
setIsSelecting(false);
return;
}
const start = selectedRange.start < selectedRange.end ? selectedRange.start : selectedRange.end;
const end = selectedRange.start < selectedRange.end ? selectedRange.end : selectedRange.start;
onSelect?.(start, end);
setIsSelecting(false);
setSelectedRange({ start: null, end: null });
};
// Check if date is in selected range
const isInSelectedRange = (date: Date) => {
if (!selectedRange.start || !selectedRange.end) return false;
const start = selectedRange.start < selectedRange.end ? selectedRange.start : selectedRange.end;
const end = selectedRange.start < selectedRange.end ? selectedRange.end : selectedRange.start;
return date >= start && date <= end;
};
// Format header title
const formatHeaderTitle = () => {
if (currentView === 'month') {
return `${MONTHS[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
}
if (currentView === 'week') {
const weekStart = new Date(currentDate);
weekStart.setDate(currentDate.getDate() - currentDate.getDay() + firstDayOfWeek);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
return `${weekStart.toLocaleDateString(locale, { month: 'short', day: 'numeric' })} - ${weekEnd.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' })}`;
}
if (currentView === 'day') {
return currentDate.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
}
return `${MONTHS[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
};
// Render event
const renderEvent = (event: CalendarEvent) => {
if (eventContent) {
return eventContent(event);
}
return (
<div
className={`ll-calendar-event ${event.className || ''}`}
style={{
backgroundColor: event.backgroundColor || event.color || 'var(--ll-primary)',
borderColor: event.borderColor || event.color || 'var(--ll-primary)',
color: event.textColor || '#fff',
}}
onClick={(e) => {
e.stopPropagation();
onEventClick?.(event);
}}
title={event.title}
>
<span className="ll-calendar-event-title">{event.title}</span>
</div>
);
};
// Render month view
const renderMonthView = () => (
<div className="ll-calendar-month">
<div className="ll-calendar-weekdays">
{weekNumbers && <div className="ll-calendar-weekday ll-calendar-week-number">Wk</div>}
{adjustedDays.map((day) => (
<div key={day} className="ll-calendar-weekday">
{day}
</div>
))}
</div>
<div className="ll-calendar-days" onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
{calendarDays.map((day, index) => {
const dayEvents = getEventsForDate(day.date);
const isSelected = isInSelectedRange(day.date);
return (
<React.Fragment key={index}>
{weekNumbers && index % 7 === 0 && (
<div className="ll-calendar-day ll-calendar-week-number">
{getWeekNumber(day.date)}
</div>
)}
<div
className={[
'll-calendar-day',
!day.isCurrentMonth && 'll-calendar-day-other',
day.isToday && 'll-calendar-day-today',
isSelected && 'll-calendar-day-selected',
].filter(Boolean).join(' ')}
onClick={() => handleDateClick(day.date)}
onMouseDown={() => handleMouseDown(day.date)}
onMouseEnter={() => handleMouseEnter(day.date)}
>
<div className="ll-calendar-day-number">
{dayCellContent ? dayCellContent(day.date) : day.date.getDate()}
</div>
<div className="ll-calendar-day-events">
{dayEvents.slice(0, 3).map((event) => (
<React.Fragment key={event.id}>{renderEvent(event)}</React.Fragment>
))}
{dayEvents.length > 3 && (
<div className="ll-calendar-more-events">+{dayEvents.length - 3} more</div>
)}
</div>
</div>
</React.Fragment>
);
})}
</div>
</div>
);
// Render week view
const renderWeekView = () => {
const weekStart = new Date(currentDate);
weekStart.setDate(currentDate.getDate() - currentDate.getDay() + firstDayOfWeek);
const weekDays = Array.from({ length: 7 }, (_, i) => {
const date = new Date(weekStart);
date.setDate(weekStart.getDate() + i);
return date;
});
const today = new Date();
return (
<div className="ll-calendar-week">
<div className="ll-calendar-week-header">
<div className="ll-calendar-time-gutter"></div>
{weekDays.map((date, i) => (
<div
key={i}
className={[
'll-calendar-week-day-header',
date.toDateString() === today.toDateString() && 'll-calendar-day-today',
].filter(Boolean).join(' ')}
>
<span className="ll-calendar-week-day-name">{adjustedDays[i]}</span>
<span className="ll-calendar-week-day-number">{date.getDate()}</span>
</div>
))}
</div>
<div className="ll-calendar-week-body">
<div className="ll-calendar-time-slots">
{Array.from({ length: 24 }, (_, hour) => (
<div key={hour} className="ll-calendar-time-slot">
<span className="ll-calendar-time-label">
{hour.toString().padStart(2, '0')}:00
</span>
</div>
))}
</div>
<div className="ll-calendar-week-grid">
{weekDays.map((date, dayIndex) => (
<div key={dayIndex} className="ll-calendar-week-column">
{Array.from({ length: 24 }, (_, hour) => (
<div
key={hour}
className="ll-calendar-week-cell"
onClick={() => {
const clickedDate = new Date(date);
clickedDate.setHours(hour);
handleDateClick(clickedDate);
}}
/>
))}
{getEventsForDate(date).map((event) => {
const startHour = new Date(event.start).getHours();
const endHour = event.end ? new Date(event.end).getHours() : startHour + 1;
const duration = endHour - startHour;
return (
<div
key={event.id}
className="ll-calendar-week-event"
style={{
top: `${(startHour / 24) * 100}%`,
height: `${(duration / 24) * 100}%`,
backgroundColor: event.backgroundColor || event.color || 'var(--ll-primary)',
color: event.textColor || '#fff',
}}
onClick={(e) => {
e.stopPropagation();
onEventClick?.(event);
}}
>
{event.title}
</div>
);
})}
</div>
))}
</div>
</div>
</div>
);
};
// Render day view
const renderDayView = () => {
const dayEvents = getEventsForDate(currentDate);
return (
<div className="ll-calendar-day-view">
<div className="ll-calendar-time-column">
{Array.from({ length: 24 }, (_, hour) => (
<div key={hour} className="ll-calendar-time-row">
<span className="ll-calendar-time-label">
{hour.toString().padStart(2, '0')}:00
</span>
<div
className="ll-calendar-time-cell"
onClick={() => {
const clickedDate = new Date(currentDate);
clickedDate.setHours(hour);
handleDateClick(clickedDate);
}}
/>
</div>
))}
</div>
<div className="ll-calendar-day-events-overlay">
{dayEvents.map((event) => {
const startHour = new Date(event.start).getHours();
const startMin = new Date(event.start).getMinutes();
const endHour = event.end ? new Date(event.end).getHours() : startHour + 1;
const endMin = event.end ? new Date(event.end).getMinutes() : 0;
const startPercent = ((startHour * 60 + startMin) / (24 * 60)) * 100;
const endPercent = ((endHour * 60 + endMin) / (24 * 60)) * 100;
return (
<div
key={event.id}
className="ll-calendar-day-event"
style={{
top: `${startPercent}%`,
height: `${endPercent - startPercent}%`,
backgroundColor: event.backgroundColor || event.color || 'var(--ll-primary)',
color: event.textColor || '#fff',
}}
onClick={() => onEventClick?.(event)}
>
<div className="ll-calendar-day-event-title">{event.title}</div>
<div className="ll-calendar-day-event-time">
{new Date(event.start).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}
{event.end && ` - ${new Date(event.end).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}`}
</div>
</div>
);
})}
</div>
</div>
);
};
// Render list view
const renderListView = () => {
const sortedEvents = [...events].sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
const groupedEvents: Record<string, CalendarEvent[]> = {};
sortedEvents.forEach((event) => {
const dateKey = new Date(event.start).toDateString();
if (!groupedEvents[dateKey]) {
groupedEvents[dateKey] = [];
}
groupedEvents[dateKey].push(event);
});
return (
<div className="ll-calendar-list">
{Object.entries(groupedEvents).map(([dateKey, dayEvents]) => (
<div key={dateKey} className="ll-calendar-list-day">
<div className="ll-calendar-list-date">
{new Date(dateKey).toLocaleDateString(locale, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className="ll-calendar-list-events">
{dayEvents.map((event) => (
<div
key={event.id}
className="ll-calendar-list-event"
onClick={() => onEventClick?.(event)}
>
<div
className="ll-calendar-list-event-dot"
style={{ backgroundColor: event.color || 'var(--ll-primary)' }}
/>
<div className="ll-calendar-list-event-time">
{event.allDay
? 'All day'
: new Date(event.start).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="ll-calendar-list-event-title">{event.title}</div>
</div>
))}
</div>
</div>
))}
{Object.keys(groupedEvents).length === 0 && (
<div className="ll-calendar-list-empty">No events to display</div>
)}
</div>
);
};
const classes = [
'll-calendar',
`ll-calendar-${currentView}`,
selectable && 'll-calendar-selectable',
className,
].filter(Boolean).join(' ');
return (
<div className={classes} style={{ height }}>
{showNavigation && (
<div className="ll-calendar-header">
<div className="ll-calendar-header-left">
<button type="button" className="ll-calendar-btn" onClick={goToToday}>
Today
</button>
<button type="button" className="ll-calendar-btn ll-calendar-nav" onClick={goToPrev}>
</button>
<button type="button" className="ll-calendar-btn ll-calendar-nav" onClick={goToNext}>
</button>
</div>
<div className="ll-calendar-header-center">
<h2 className="ll-calendar-title">{formatHeaderTitle()}</h2>
</div>
<div className="ll-calendar-header-right">
<div className="ll-calendar-view-buttons">
{(['month', 'week', 'day', 'list'] as const).map((view) => (
<button
key={view}
type="button"
className={`ll-calendar-btn ${currentView === view ? 'll-calendar-btn-active' : ''}`}
onClick={() => handleViewChange(view)}
>
{view.charAt(0).toUpperCase() + view.slice(1)}
</button>
))}
</div>
</div>
</div>
)}
<div className="ll-calendar-body">
{currentView === 'month' && renderMonthView()}
{currentView === 'week' && renderWeekView()}
{currentView === 'day' && renderDayView()}
{currentView === 'list' && renderListView()}
</div>
</div>
);
};
// Mini Calendar (Date Picker style)
export interface MiniCalendarProps {
/** Selected date */
value?: Date;
/** Callback when date is selected */
onChange?: (date: Date) => void;
/** Minimum selectable date */
minDate?: Date;
/** Maximum selectable date */
maxDate?: Date;
/** Disabled dates */
disabledDates?: Date[];
/** First day of week */
firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Show today button */
showToday?: boolean;
/** Additional CSS classes */
className?: string;
}
export const MiniCalendar: React.FC<MiniCalendarProps> = ({
value,
onChange,
minDate,
maxDate,
disabledDates = [],
firstDayOfWeek = 0,
showToday = true,
className = '',
}) => {
const [currentMonth, setCurrentMonth] = useState(value || new Date());
const isDateDisabled = (date: Date) => {
if (minDate && date < minDate) return true;
if (maxDate && date > maxDate) return true;
return disabledDates.some((d) => d.toDateString() === date.toDateString());
};
const handleDateSelect = (date: Date) => {
if (isDateDisabled(date)) return;
onChange?.(date);
};
const goToPrevMonth = () => {
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
};
const goToNextMonth = () => {
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
};
const goToToday = () => {
const today = new Date();
setCurrentMonth(today);
onChange?.(today);
};
// Get days array
const adjustedDays = useMemo(() => {
const days = [...DAYS];
for (let i = 0; i < firstDayOfWeek; i++) {
days.push(days.shift()!);
}
return days;
}, [firstDayOfWeek]);
const calendarDays = useMemo(() => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
let startDay = firstDay.getDay() - firstDayOfWeek;
if (startDay < 0) startDay += 7;
const days: { date: Date; isCurrentMonth: boolean }[] = [];
const prevMonthLastDay = new Date(year, month, 0).getDate();
for (let i = startDay - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonthLastDay - i),
isCurrentMonth: false,
});
}
for (let i = 1; i <= lastDay.getDate(); i++) {
days.push({
date: new Date(year, month, i),
isCurrentMonth: true,
});
}
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
days.push({
date: new Date(year, month + 1, i),
isCurrentMonth: false,
});
}
return days;
}, [currentMonth, firstDayOfWeek]);
const today = new Date();
return (
<div className={`ll-mini-calendar ${className}`}>
<div className="ll-mini-calendar-header">
<button type="button" className="ll-mini-calendar-nav" onClick={goToPrevMonth}>
</button>
<span className="ll-mini-calendar-title">
{MONTHS[currentMonth.getMonth()]} {currentMonth.getFullYear()}
</span>
<button type="button" className="ll-mini-calendar-nav" onClick={goToNextMonth}>
</button>
</div>
<div className="ll-mini-calendar-weekdays">
{adjustedDays.map((day) => (
<div key={day} className="ll-mini-calendar-weekday">
{day.slice(0, 2)}
</div>
))}
</div>
<div className="ll-mini-calendar-days">
{calendarDays.map((day, index) => {
const isSelected = value && day.date.toDateString() === value.toDateString();
const isToday = day.date.toDateString() === today.toDateString();
const isDisabled = isDateDisabled(day.date);
return (
<button
key={index}
type="button"
className={[
'll-mini-calendar-day',
!day.isCurrentMonth && 'll-mini-calendar-day-other',
isToday && 'll-mini-calendar-day-today',
isSelected && 'll-mini-calendar-day-selected',
isDisabled && 'll-mini-calendar-day-disabled',
].filter(Boolean).join(' ')}
onClick={() => handleDateSelect(day.date)}
disabled={isDisabled}
>
{day.date.getDate()}
</button>
);
})}
</div>
{showToday && (
<div className="ll-mini-calendar-footer">
<button type="button" className="ll-mini-calendar-today" onClick={goToToday}>
Today
</button>
</div>
)}
</div>
);
};

91
src/components/Card.tsx Normal file
View File

@@ -0,0 +1,91 @@
import React from 'react';
export type CardProps = {
/** Card title in header */
title?: React.ReactNode;
/** Header elements (actions on right side) */
headerElements?: React.ReactNode;
/** Custom header content (overrides title) */
header?: React.ReactNode;
/** Footer content */
footer?: React.ReactNode;
/** Additional className for card container */
className?: string;
/** Additional className for card body */
bodyClassName?: string;
/** Whether to add padding to body (default true) */
padded?: boolean;
/** Card content */
children: React.ReactNode;
};
/**
* Layout 3 card component with header-elements-inline support.
*/
export function Card({
title,
headerElements,
header,
footer,
className = '',
bodyClassName = '',
padded = true,
children
}: CardProps) {
const hasHeader = header || title || headerElements;
return (
<div className={`card ${className}`.trim()}>
{hasHeader && (
<div className={`card-header ${headerElements ? 'header-elements-inline' : ''}`}>
{header || (
<>
{title && <h6 className="card-title">{title}</h6>}
{headerElements && (
<div className="header-elements">
{headerElements}
</div>
)}
</>
)}
</div>
)}
<div className={`card-body ${padded ? '' : 'p-0'} ${bodyClassName}`.trim()}>
{children}
</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
export type CardHeaderActionsProps = {
children: React.ReactNode;
className?: string;
};
/**
* Card header list icons for actions (collapse, remove, etc.)
*/
export function CardHeaderActions({ children, className = '' }: CardHeaderActionsProps) {
return (
<div className={`list-icons ${className}`.trim()}>
{children}
</div>
);
}
export type CardHeaderActionProps = {
action: 'collapse' | 'remove' | 'reload';
onClick?: () => void;
};
/**
* Card header action button.
*/
export function CardHeaderAction({ action, onClick }: CardHeaderActionProps) {
return (
<a className="list-icons-item" data-action={action} onClick={onClick}>
{/* Icon is typically handled by CSS/icon font */}
</a>
);
}

213
src/components/Carousel.tsx Normal file
View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
export interface CarouselItem {
/** Image source */
src?: string;
/** Alt text for image */
alt?: string;
/** Caption title */
title?: string;
/** Caption description */
description?: string;
/** Custom content instead of image */
content?: React.ReactNode;
}
export interface CarouselProps {
/** Carousel items */
items: CarouselItem[];
/** Show indicators */
indicators?: boolean;
/** Show controls (prev/next) */
controls?: boolean;
/** Auto play */
autoPlay?: boolean;
/** Interval in ms */
interval?: number;
/** Pause on hover */
pauseOnHover?: boolean;
/** Enable keyboard navigation */
keyboard?: boolean;
/** Enable touch/swipe */
touch?: boolean;
/** Crossfade animation */
fade?: boolean;
/** Dark variant */
dark?: boolean;
/** Active index (controlled) */
activeIndex?: number;
/** Callback when slide changes */
onSlide?: (index: number) => void;
/** Additional CSS classes */
className?: string;
}
export const Carousel: React.FC<CarouselProps> = ({
items,
indicators = true,
controls = true,
autoPlay = false,
interval = 5000,
pauseOnHover = true,
keyboard = true,
touch = true,
fade = false,
dark = false,
activeIndex: controlledIndex,
onSlide,
className = '',
}) => {
const [currentIndex, setCurrentIndex] = useState(controlledIndex ?? 0);
const [isPaused, setIsPaused] = useState(false);
const [touchStart, setTouchStart] = useState<number | null>(null);
const carouselRef = useRef<HTMLDivElement>(null);
const activeIndex = controlledIndex ?? currentIndex;
const goToSlide = useCallback((index: number) => {
const newIndex = index < 0 ? items.length - 1 : index >= items.length ? 0 : index;
if (controlledIndex === undefined) {
setCurrentIndex(newIndex);
}
onSlide?.(newIndex);
}, [items.length, controlledIndex, onSlide]);
const goToPrev = useCallback(() => {
goToSlide(activeIndex - 1);
}, [activeIndex, goToSlide]);
const goToNext = useCallback(() => {
goToSlide(activeIndex + 1);
}, [activeIndex, goToSlide]);
// Auto play
useEffect(() => {
if (!autoPlay || isPaused) return;
const timer = setInterval(() => {
goToNext();
}, interval);
return () => clearInterval(timer);
}, [autoPlay, isPaused, interval, goToNext]);
// Keyboard navigation
useEffect(() => {
if (!keyboard) return;
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
goToPrev();
} else if (e.key === 'ArrowRight') {
goToNext();
}
};
const carousel = carouselRef.current;
carousel?.addEventListener('keydown', handleKeydown);
return () => carousel?.removeEventListener('keydown', handleKeydown);
}, [keyboard, goToPrev, goToNext]);
// Touch handling
const handleTouchStart = (e: React.TouchEvent) => {
if (!touch) return;
setTouchStart(e.touches[0].clientX);
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (!touch || touchStart === null) return;
const touchEnd = e.changedTouches[0].clientX;
const diff = touchStart - touchEnd;
if (Math.abs(diff) > 50) {
if (diff > 0) {
goToNext();
} else {
goToPrev();
}
}
setTouchStart(null);
};
const classes = [
'll-carousel',
fade && 'll-carousel-fade',
dark && 'll-carousel-dark',
className,
].filter(Boolean).join(' ');
return (
<div
ref={carouselRef}
className={classes}
tabIndex={keyboard ? 0 : undefined}
onMouseEnter={() => pauseOnHover && setIsPaused(true)}
onMouseLeave={() => pauseOnHover && setIsPaused(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Indicators */}
{indicators && items.length > 1 && (
<div className="ll-carousel-indicators">
{items.map((_, index) => (
<button
key={index}
type="button"
className={`ll-carousel-indicator ${index === activeIndex ? 'active' : ''}`}
onClick={() => goToSlide(index)}
aria-current={index === activeIndex}
aria-label={`Slide ${index + 1}`}
/>
))}
</div>
)}
{/* Slides */}
<div className="ll-carousel-inner">
{items.map((item, index) => (
<div
key={index}
className={`ll-carousel-item ${index === activeIndex ? 'active' : ''}`}
>
{item.content || (
<img
src={item.src}
alt={item.alt || ''}
className="ll-carousel-image"
/>
)}
{(item.title || item.description) && (
<div className="ll-carousel-caption">
{item.title && <h5>{item.title}</h5>}
{item.description && <p>{item.description}</p>}
</div>
)}
</div>
))}
</div>
{/* Controls */}
{controls && items.length > 1 && (
<>
<button
type="button"
className="ll-carousel-control ll-carousel-control-prev"
onClick={goToPrev}
aria-label="Previous"
>
<span className="ll-carousel-control-icon ll-carousel-control-prev-icon" aria-hidden="true" />
</button>
<button
type="button"
className="ll-carousel-control ll-carousel-control-next"
onClick={goToNext}
aria-label="Next"
>
<span className="ll-carousel-control-icon ll-carousel-control-next-icon" aria-hidden="true" />
</button>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,19 @@
import React from 'react';
export type CollapseProps = {
isOpen: boolean;
children: React.ReactNode;
className?: string;
};
/**
* Simple collapse wrapper that toggles Bootstrap's `collapse`/`show` classes.
* Does not animate height; relies on Bootstrap CSS to hide/show.
*/
export function Collapse({ isOpen, children, className = '' }: CollapseProps) {
return (
<div className={['collapse', isOpen ? 'show' : '', className].filter(Boolean).join(' ')}>
{children}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { HexColorPicker, HexColorInput } from 'react-colorful';
export type ColorPickerProps = {
color: string;
onChange: (color: string) => void;
showInput?: boolean;
className?: string;
};
export function ColorPicker({ color, onChange, showInput = true, className = '' }: ColorPickerProps) {
return (
<div className={className}>
<HexColorPicker color={color} onChange={onChange} />
{showInput ? (
<div className="mt-2 input-group input-group-sm" style={{ maxWidth: 200 }}>
<span className="input-group-text">#</span>
<HexColorInput className="form-control" color={color} onChange={onChange} prefixed={false} />
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,376 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
export interface ContextMenuItem {
/** Unique identifier */
id: string;
/** Menu item label */
label: React.ReactNode;
/** Icon */
icon?: React.ReactNode;
/** Keyboard shortcut display */
shortcut?: string;
/** Whether item is disabled */
disabled?: boolean;
/** Whether item is a divider */
divider?: boolean;
/** Sub-menu items */
children?: ContextMenuItem[];
/** Click handler */
onClick?: () => void;
/** Danger/destructive style */
danger?: boolean;
}
export interface ContextMenuProps {
/** Menu items */
items: ContextMenuItem[];
/** Target element (for right-click) */
children: React.ReactNode;
/** Callback when item is clicked */
onItemClick?: (item: ContextMenuItem) => void;
/** Callback when menu opens */
onOpen?: (position: { x: number; y: number }) => void;
/** Callback when menu closes */
onClose?: () => void;
/** Whether menu is disabled */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
/** Menu CSS classes */
menuClassName?: string;
}
interface MenuPosition {
x: number;
y: number;
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
items,
children,
onItemClick,
onOpen,
onClose,
disabled = false,
className = '',
menuClassName = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<MenuPosition>({ x: 0, y: 0 });
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Handle right-click
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
const x = e.clientX;
const y = e.clientY;
setPosition({ x, y });
setIsOpen(true);
setActiveSubmenu(null);
onOpen?.({ x, y });
}, [disabled, onOpen]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (!isOpen || !menuRef.current) return;
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let newX = position.x;
let newY = position.y;
if (position.x + rect.width > viewportWidth) {
newX = viewportWidth - rect.width - 10;
}
if (position.y + rect.height > viewportHeight) {
newY = viewportHeight - rect.height - 10;
}
if (newX !== position.x || newY !== position.y) {
setPosition({ x: newX, y: newY });
}
}, [isOpen, position]);
// Close on click outside
useEffect(() => {
if (!isOpen) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setIsOpen(false);
onClose?.();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
onClose?.();
}
};
const handleScroll = () => {
setIsOpen(false);
onClose?.();
};
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleEscape);
window.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleEscape);
window.removeEventListener('scroll', handleScroll, true);
};
}, [isOpen, onClose]);
// Handle item click
const handleItemClick = (item: ContextMenuItem) => {
if (item.disabled || item.divider) return;
if (item.children && item.children.length > 0) {
setActiveSubmenu(activeSubmenu === item.id ? null : item.id);
return;
}
item.onClick?.();
onItemClick?.(item);
setIsOpen(false);
onClose?.();
};
// Render menu item
const renderMenuItem = (item: ContextMenuItem, index: number) => {
if (item.divider) {
return <div key={`divider-${index}`} className="ll-context-menu-divider" />;
}
const hasSubmenu = item.children && item.children.length > 0;
const isSubmenuOpen = activeSubmenu === item.id;
const itemClasses = [
'll-context-menu-item',
item.disabled && 'll-context-menu-item-disabled',
item.danger && 'll-context-menu-item-danger',
hasSubmenu && 'll-context-menu-item-submenu',
isSubmenuOpen && 'll-context-menu-item-submenu-open',
].filter(Boolean).join(' ');
return (
<div
key={item.id}
className={itemClasses}
onClick={() => handleItemClick(item)}
onMouseEnter={() => hasSubmenu && setActiveSubmenu(item.id)}
role="menuitem"
aria-disabled={item.disabled}
>
{item.icon && <span className="ll-context-menu-icon">{item.icon}</span>}
<span className="ll-context-menu-label">{item.label}</span>
{item.shortcut && <span className="ll-context-menu-shortcut">{item.shortcut}</span>}
{hasSubmenu && (
<span className="ll-context-menu-arrow">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</span>
)}
{/* Submenu */}
{hasSubmenu && isSubmenuOpen && (
<div className="ll-context-menu-submenu">
{item.children!.map((child, i) => renderMenuItem(child, i))}
</div>
)}
</div>
);
};
return (
<div
ref={containerRef}
className={`ll-context-menu-container ${className}`}
onContextMenu={handleContextMenu}
>
{children}
{isOpen && (
<div
ref={menuRef}
className={`ll-context-menu ${menuClassName}`}
style={{
position: 'fixed',
left: position.x,
top: position.y,
}}
role="menu"
>
{items.map((item, index) => renderMenuItem(item, index))}
</div>
)}
</div>
);
};
// Programmatic context menu
export interface ContextMenuState {
isOpen: boolean;
position: MenuPosition;
items: ContextMenuItem[];
}
export const useContextMenu = () => {
const [state, setState] = useState<ContextMenuState>({
isOpen: false,
position: { x: 0, y: 0 },
items: [],
});
const open = useCallback((e: React.MouseEvent | MouseEvent, items: ContextMenuItem[]) => {
e.preventDefault();
setState({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
items,
});
}, []);
const close = useCallback(() => {
setState((prev) => ({ ...prev, isOpen: false }));
}, []);
return {
...state,
open,
close,
};
};
// Standalone context menu portal component
export interface ContextMenuPortalProps {
isOpen: boolean;
position: MenuPosition;
items: ContextMenuItem[];
onClose: () => void;
onItemClick?: (item: ContextMenuItem) => void;
className?: string;
}
export const ContextMenuPortal: React.FC<ContextMenuPortalProps> = ({
isOpen,
position,
items,
onClose,
onItemClick,
className = '',
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
// Close on click outside
useEffect(() => {
if (!isOpen) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, onClose]);
const handleItemClick = (item: ContextMenuItem) => {
if (item.disabled || item.divider) return;
if (item.children && item.children.length > 0) {
setActiveSubmenu(activeSubmenu === item.id ? null : item.id);
return;
}
item.onClick?.();
onItemClick?.(item);
onClose();
};
const renderMenuItem = (item: ContextMenuItem, index: number) => {
if (item.divider) {
return <div key={`divider-${index}`} className="ll-context-menu-divider" />;
}
const hasSubmenu = item.children && item.children.length > 0;
const isSubmenuOpen = activeSubmenu === item.id;
const itemClasses = [
'll-context-menu-item',
item.disabled && 'll-context-menu-item-disabled',
item.danger && 'll-context-menu-item-danger',
hasSubmenu && 'll-context-menu-item-submenu',
].filter(Boolean).join(' ');
return (
<div
key={item.id}
className={itemClasses}
onClick={() => handleItemClick(item)}
onMouseEnter={() => hasSubmenu && setActiveSubmenu(item.id)}
role="menuitem"
>
{item.icon && <span className="ll-context-menu-icon">{item.icon}</span>}
<span className="ll-context-menu-label">{item.label}</span>
{item.shortcut && <span className="ll-context-menu-shortcut">{item.shortcut}</span>}
{hasSubmenu && (
<>
<span className="ll-context-menu-arrow"></span>
{isSubmenuOpen && (
<div className="ll-context-menu-submenu">
{item.children!.map((child, i) => renderMenuItem(child, i))}
</div>
)}
</>
)}
</div>
);
};
if (!isOpen) return null;
return (
<div
ref={menuRef}
className={`ll-context-menu ${className}`}
style={{
position: 'fixed',
left: position.x,
top: position.y,
zIndex: 9999,
}}
role="menu"
>
{items.map((item, index) => renderMenuItem(item, index))}
</div>
);
};

View File

@@ -0,0 +1,106 @@
import React from 'react';
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable
} from '@tanstack/react-table';
import { Table } from './Table';
import { Button } from './Button';
import { Pagination } from './Pagination';
export type DataTableProps<T> = {
data: T[];
columns: ColumnDef<T, any>[];
initialSorting?: SortingState;
pageSize?: number;
className?: string;
};
export function DataTable<T extends object>({
data,
columns,
initialSorting = [],
pageSize = 10,
className = ''
}: DataTableProps<T>) {
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
const [pageIndex, setPageIndex] = React.useState(0);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel()
});
const pageCount = Math.ceil(data.length / pageSize);
const pageRows = table.getRowModel().rows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
const paginationItems = [];
for (let i = 0; i < pageCount; i++) {
paginationItems.push({
key: i,
label: (i + 1).toString(),
active: i === pageIndex,
onClick: () => setPageIndex(i)
});
}
return (
<div className={className}>
<Table striped hover responsive="md">
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
scope="col"
style={{ cursor: header.column.getCanSort() ? 'pointer' : undefined }}
onClick={header.column.getToggleSortingHandler()}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: ' 🔼',
desc: ' 🔽'
}[header.column.getIsSorted() as string] ?? null}
</th>
))}
</tr>
))}
</thead>
<tbody>
{pageRows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</Table>
<div className="d-flex justify-content-between align-items-center mt-3">
<div>
<Button variant="secondary" size="sm" onClick={() => setPageIndex(Math.max(0, pageIndex - 1))} disabled={pageIndex === 0}>
Prev
</Button>{' '}
<Button
variant="secondary"
size="sm"
onClick={() => setPageIndex(Math.min(pageCount - 1, pageIndex + 1))}
disabled={pageIndex >= pageCount - 1}
>
Next
</Button>
</div>
<Pagination items={paginationItems} size="sm" />
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { DayPicker, DayPickerProps } from 'react-day-picker';
export type DatePickerProps = DayPickerProps;
/**
* Wrapper around react-day-picker. Remember to import its base CSS in the app:
* import 'react-day-picker/dist/style.css';
*/
export function DatePicker(props: DatePickerProps) {
return <DayPicker {...props} />;
}

View File

@@ -0,0 +1,72 @@
import React, { useEffect, useRef, useState } from 'react';
export type DropdownProps = {
label: React.ReactNode;
children: React.ReactNode;
align?: 'start' | 'end';
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
className?: string;
menuClassName?: string;
split?: boolean;
};
export function Dropdown({
label,
children,
align = 'start',
variant = 'secondary',
className = '',
menuClassName = '',
split
}: DropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!ref.current) return;
if (ref.current.contains(e.target as Node)) return;
setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const toggle = () => setOpen(v => !v);
const menuClasses = ['dropdown-menu', open ? 'show' : '', align === 'end' ? 'dropdown-menu-end' : '', menuClassName]
.filter(Boolean)
.join(' ');
return (
<div className={`dropdown ${className}`.trim()} ref={ref}>
<div className="btn-group">
{split ? (
<>
<button type="button" className={`btn btn-${variant}`}>
{label}
</button>
<button
type="button"
className={`btn btn-${variant} dropdown-toggle dropdown-toggle-split`}
aria-expanded={open}
onClick={toggle}
>
<span className="visually-hidden">Toggle Dropdown</span>
</button>
</>
) : (
<button
type="button"
className={`btn btn-${variant} dropdown-toggle`}
aria-expanded={open}
onClick={toggle}
>
{label}
</button>
)}
</div>
<div className={menuClasses}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { Button } from './Button';
export type DualListOption = { value: string; label: string };
export type DualListBoxProps = {
available: DualListOption[];
selected: DualListOption[];
onChange: (nextSelected: DualListOption[]) => void;
className?: string;
size?: number;
};
/**
* Basic dual listbox: move options between available and selected.
*/
export function DualListBox({ available, selected, onChange, className = '', size = 10 }: DualListBoxProps) {
const [leftSel, setLeftSel] = React.useState<string[]>([]);
const [rightSel, setRightSel] = React.useState<string[]>([]);
const moveRight = () => {
const toMove = available.filter(opt => leftSel.includes(opt.value));
onChange([...selected, ...toMove]);
setLeftSel([]);
};
const moveLeft = () => {
const remaining = selected.filter(opt => !rightSel.includes(opt.value));
onChange(remaining);
setRightSel([]);
};
return (
<div className={`d-flex gap-3 ${className}`.trim()}>
<div className="flex-fill">
<label className="form-label">Available</label>
<select
multiple
size={size}
className="form-select"
value={leftSel}
onChange={e => {
const values = Array.from(e.target.selectedOptions).map(o => o.value);
setLeftSel(values);
}}
>
{available.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="d-flex flex-column justify-content-center gap-2">
<Button variant="primary" onClick={moveRight} disabled={leftSel.length === 0}>
&gt;
</Button>
<Button variant="primary" onClick={moveLeft} disabled={rightSel.length === 0}>
&lt;
</Button>
</div>
<div className="flex-fill">
<label className="form-label">Selected</label>
<select
multiple
size={size}
className="form-select"
value={rightSel}
onChange={e => {
const values = Array.from(e.target.selectedOptions).map(o => o.value);
setRightSel(values);
}}
>
{selected.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
}

440
src/components/Embed.tsx Normal file
View File

@@ -0,0 +1,440 @@
import React, { useState, useEffect, useRef } from 'react';
export interface EmbedProps {
/** Embed source URL */
src: string;
/** Aspect ratio */
aspectRatio?: '1:1' | '4:3' | '16:9' | '21:9' | string;
/** Custom width */
width?: number | string;
/** Custom height */
height?: number | string;
/** Title for accessibility */
title?: string;
/** Allow fullscreen */
allowFullScreen?: boolean;
/** Sandbox attributes for iframe */
sandbox?: string;
/** Loading attribute */
loading?: 'lazy' | 'eager';
/** Show loading placeholder */
showLoading?: boolean;
/** Custom loading component */
loadingComponent?: React.ReactNode;
/** Callback when loaded */
onLoad?: () => void;
/** Callback on error */
onError?: (error: Error) => void;
/** Additional CSS classes */
className?: string;
/** Additional iframe attributes */
iframeProps?: React.IframeHTMLAttributes<HTMLIFrameElement>;
}
export const Embed: React.FC<EmbedProps> = ({
src,
aspectRatio = '16:9',
width,
height,
title = 'Embedded content',
allowFullScreen = true,
sandbox,
loading = 'lazy',
showLoading = true,
loadingComponent,
onLoad,
onError,
className = '',
iframeProps = {},
}) => {
const [isLoading, setIsLoading] = useState(showLoading);
const [hasError, setHasError] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Parse aspect ratio
const getAspectRatioStyle = (): React.CSSProperties => {
if (width && height) {
return { width, height };
}
let ratio: number;
if (aspectRatio.includes(':')) {
const [w, h] = aspectRatio.split(':').map(Number);
ratio = (h / w) * 100;
} else {
ratio = parseFloat(aspectRatio);
}
return {
paddingBottom: `${ratio}%`,
};
};
const handleLoad = () => {
setIsLoading(false);
onLoad?.();
};
const handleError = () => {
setIsLoading(false);
setHasError(true);
onError?.(new Error('Failed to load embed'));
};
const classes = [
'll-embed',
isLoading && 'll-embed-loading',
hasError && 'll-embed-error',
className,
].filter(Boolean).join(' ');
const containerStyle = width && height ? { width, height } : getAspectRatioStyle();
return (
<div className={classes} style={containerStyle}>
{isLoading && (
<div className="ll-embed-loader">
{loadingComponent || (
<div className="ll-embed-spinner">
<div className="ll-embed-spinner-ring" />
</div>
)}
</div>
)}
{hasError && (
<div className="ll-embed-error-message">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<p>Failed to load content</p>
</div>
)}
<iframe
ref={iframeRef}
src={src}
title={title}
allowFullScreen={allowFullScreen}
sandbox={sandbox}
loading={loading}
onLoad={handleLoad}
onError={handleError}
className="ll-embed-iframe"
{...iframeProps}
/>
</div>
);
};
// Video Embed Component
export interface VideoEmbedProps {
/** Video URL or embed URL */
src: string;
/** Video provider (auto-detected if not specified) */
provider?: 'youtube' | 'vimeo' | 'dailymotion' | 'custom';
/** Auto-detect and convert video URLs to embed URLs */
autoEmbed?: boolean;
/** Autoplay video */
autoplay?: boolean;
/** Start time in seconds */
startTime?: number;
/** Loop video */
loop?: boolean;
/** Muted */
muted?: boolean;
/** Show player controls */
controls?: boolean;
/** Aspect ratio */
aspectRatio?: '1:1' | '4:3' | '16:9' | '21:9';
/** Poster image (for custom videos) */
poster?: string;
/** Additional CSS classes */
className?: string;
}
const getVideoEmbedUrl = (
url: string,
options: {
autoplay?: boolean;
startTime?: number;
loop?: boolean;
muted?: boolean;
controls?: boolean;
}
): { src: string; provider: string } => {
const { autoplay, startTime, loop, muted, controls = true } = options;
// YouTube
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]+)/);
if (youtubeMatch) {
const videoId = youtubeMatch[1];
const params = new URLSearchParams();
if (autoplay) params.set('autoplay', '1');
if (startTime) params.set('start', startTime.toString());
if (loop) params.set('loop', '1');
if (muted) params.set('mute', '1');
if (!controls) params.set('controls', '0');
return {
src: `https://www.youtube.com/embed/${videoId}?${params.toString()}`,
provider: 'youtube',
};
}
// Vimeo
const vimeoMatch = url.match(/(?:vimeo\.com\/(?:video\/)?|player\.vimeo\.com\/video\/)(\d+)/);
if (vimeoMatch) {
const videoId = vimeoMatch[1];
const params = new URLSearchParams();
if (autoplay) params.set('autoplay', '1');
if (loop) params.set('loop', '1');
if (muted) params.set('muted', '1');
return {
src: `https://player.vimeo.com/video/${videoId}?${params.toString()}`,
provider: 'vimeo',
};
}
// Dailymotion
const dailymotionMatch = url.match(/(?:dailymotion\.com\/(?:video\/|embed\/video\/)|dai\.ly\/)([a-zA-Z0-9]+)/);
if (dailymotionMatch) {
const videoId = dailymotionMatch[1];
const params = new URLSearchParams();
if (autoplay) params.set('autoplay', '1');
if (startTime) params.set('start', startTime.toString());
if (muted) params.set('mute', '1');
return {
src: `https://www.dailymotion.com/embed/video/${videoId}?${params.toString()}`,
provider: 'dailymotion',
};
}
return { src: url, provider: 'custom' };
};
export const VideoEmbed: React.FC<VideoEmbedProps> = ({
src,
provider,
autoEmbed = true,
autoplay = false,
startTime,
loop = false,
muted = false,
controls = true,
aspectRatio = '16:9',
poster,
className = '',
}) => {
const [embedUrl, setEmbedUrl] = useState(src);
const [detectedProvider, setDetectedProvider] = useState(provider || 'custom');
useEffect(() => {
if (autoEmbed) {
const result = getVideoEmbedUrl(src, { autoplay, startTime, loop, muted, controls });
setEmbedUrl(result.src);
setDetectedProvider(provider || result.provider as 'youtube' | 'vimeo' | 'dailymotion' | 'custom');
}
}, [src, autoEmbed, autoplay, startTime, loop, muted, controls, provider]);
// For custom videos, use HTML5 video element
if (detectedProvider === 'custom' && !src.includes('embed')) {
return (
<div className={`ll-video-embed ll-video-custom ${className}`}>
<video
src={src}
autoPlay={autoplay}
loop={loop}
muted={muted}
controls={controls}
poster={poster}
className="ll-video-element"
/>
</div>
);
}
return (
<Embed
src={embedUrl}
aspectRatio={aspectRatio}
title="Video player"
allowFullScreen
className={`ll-video-embed ll-video-${detectedProvider} ${className}`}
iframeProps={{
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
}}
/>
);
};
// Map Embed Component
export interface MapEmbedProps {
/** Map center latitude */
lat?: number;
/** Map center longitude */
lng?: number;
/** Search query (alternative to lat/lng) */
query?: string;
/** Zoom level (1-21) */
zoom?: number;
/** Map type */
mapType?: 'roadmap' | 'satellite' | 'terrain' | 'hybrid';
/** Custom embed URL */
src?: string;
/** Aspect ratio */
aspectRatio?: '1:1' | '4:3' | '16:9' | '21:9';
/** Additional CSS classes */
className?: string;
}
export const MapEmbed: React.FC<MapEmbedProps> = ({
lat,
lng,
query,
zoom = 14,
mapType = 'roadmap',
src,
aspectRatio = '16:9',
className = '',
}) => {
const getMapUrl = () => {
if (src) return src;
const params = new URLSearchParams();
if (query) {
params.set('q', query);
} else if (lat !== undefined && lng !== undefined) {
params.set('q', `${lat},${lng}`);
}
params.set('z', zoom.toString());
params.set('t', mapType === 'roadmap' ? 'm' : mapType === 'satellite' ? 'k' : mapType === 'terrain' ? 'p' : 'h');
params.set('output', 'embed');
return `https://maps.google.com/maps?${params.toString()}`;
};
return (
<Embed
src={getMapUrl()}
aspectRatio={aspectRatio}
title="Map"
allowFullScreen
className={`ll-map-embed ${className}`}
/>
);
};
// Social Media Embed Placeholder
export interface SocialEmbedProps {
/** Platform */
platform: 'twitter' | 'instagram' | 'facebook' | 'linkedin' | 'tiktok';
/** Post URL */
url: string;
/** Width */
width?: number | string;
/** Additional CSS classes */
className?: string;
}
export const SocialEmbed: React.FC<SocialEmbedProps> = ({
platform,
url,
width = '100%',
className = '',
}) => {
const [error, setError] = useState(false);
// Social embeds typically require their SDK/scripts
// This is a placeholder that shows the link
return (
<div className={`ll-social-embed ll-social-${platform} ${className}`} style={{ width }}>
{error ? (
<div className="ll-social-embed-error">
<p>Unable to load {platform} embed</p>
<a href={url} target="_blank" rel="noopener noreferrer">
View on {platform}
</a>
</div>
) : (
<div className="ll-social-embed-placeholder">
<div className="ll-social-embed-icon">
{platform === 'twitter' && '𝕏'}
{platform === 'instagram' && '📷'}
{platform === 'facebook' && 'f'}
{platform === 'linkedin' && 'in'}
{platform === 'tiktok' && '♪'}
</div>
<a href={url} target="_blank" rel="noopener noreferrer" className="ll-social-embed-link">
View on {platform.charAt(0).toUpperCase() + platform.slice(1)}
</a>
<p className="ll-social-embed-note">
For full embed support, include the {platform} embed script in your application.
</p>
</div>
)}
</div>
);
};
// Code Embed (for CodePen, CodeSandbox, etc.)
export interface CodeEmbedProps {
/** Platform */
platform: 'codepen' | 'codesandbox' | 'jsfiddle' | 'stackblitz';
/** Embed ID or slug */
id: string;
/** Username (for some platforms) */
user?: string;
/** Default tab */
defaultTab?: 'html' | 'css' | 'js' | 'result';
/** Theme */
theme?: 'light' | 'dark';
/** Editable */
editable?: boolean;
/** Height */
height?: number | string;
/** Additional CSS classes */
className?: string;
}
export const CodeEmbed: React.FC<CodeEmbedProps> = ({
platform,
id,
user,
defaultTab = 'result',
theme = 'dark',
editable = false,
height = 400,
className = '',
}) => {
const getEmbedUrl = () => {
switch (platform) {
case 'codepen':
return `https://codepen.io/${user}/embed/${id}?default-tab=${defaultTab}&theme-id=${theme}&editable=${editable}`;
case 'codesandbox':
return `https://codesandbox.io/embed/${id}?fontsize=14&hidenavigation=1&theme=${theme}`;
case 'jsfiddle':
return `https://jsfiddle.net/${user}/${id}/embedded/${defaultTab}/${theme}/`;
case 'stackblitz':
return `https://stackblitz.com/edit/${id}?embed=1&theme=${theme}`;
default:
return '';
}
};
return (
<Embed
src={getEmbedUrl()}
height={height}
title={`${platform} embed`}
allowFullScreen
className={`ll-code-embed ll-code-${platform} ${className}`}
iframeProps={{
allow: 'accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking',
}}
/>
);
};

297
src/components/FAB.tsx Normal file
View File

@@ -0,0 +1,297 @@
import React, { useState } from 'react';
export interface FABAction {
/** Unique identifier */
id: string;
/** Icon content */
icon: React.ReactNode;
/** Label/tooltip text */
label?: string;
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Click handler */
onClick?: () => void;
/** Whether action is disabled */
disabled?: boolean;
}
export interface FABProps {
/** Main button icon */
icon: React.ReactNode;
/** Icon when expanded (optional) */
expandedIcon?: React.ReactNode;
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Position on screen */
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-center' | 'top-center';
/** Actions to show on expand */
actions?: FABAction[];
/** Direction for actions */
direction?: 'up' | 'down' | 'left' | 'right';
/** Main button click handler */
onClick?: () => void;
/** Whether FAB is expanded (controlled) */
expanded?: boolean;
/** Callback when expanded state changes */
onExpandedChange?: (expanded: boolean) => void;
/** Expand on hover instead of click */
expandOnHover?: boolean;
/** Show labels for actions */
showLabels?: boolean;
/** Disabled state */
disabled?: boolean;
/** Tooltip/aria-label for main button */
label?: string;
/** Fixed position */
fixed?: boolean;
/** Additional CSS classes */
className?: string;
}
export const FAB: React.FC<FABProps> = ({
icon,
expandedIcon,
variant = 'primary',
size = 'md',
position = 'bottom-right',
actions = [],
direction = 'up',
onClick,
expanded: controlledExpanded,
onExpandedChange,
expandOnHover = false,
showLabels = true,
disabled = false,
label,
fixed = true,
className = '',
}) => {
const [internalExpanded, setInternalExpanded] = useState(false);
const hasActions = actions.length > 0;
const expanded = controlledExpanded ?? internalExpanded;
const handleToggle = () => {
if (disabled) return;
if (hasActions && !expandOnHover) {
const newExpanded = !expanded;
if (controlledExpanded === undefined) {
setInternalExpanded(newExpanded);
}
onExpandedChange?.(newExpanded);
}
onClick?.();
};
const handleMouseEnter = () => {
if (expandOnHover && hasActions && !disabled) {
if (controlledExpanded === undefined) {
setInternalExpanded(true);
}
onExpandedChange?.(true);
}
};
const handleMouseLeave = () => {
if (expandOnHover && hasActions && !disabled) {
if (controlledExpanded === undefined) {
setInternalExpanded(false);
}
onExpandedChange?.(false);
}
};
const handleActionClick = (action: FABAction) => {
if (action.disabled) return;
action.onClick?.();
// Close after action
if (controlledExpanded === undefined) {
setInternalExpanded(false);
}
onExpandedChange?.(false);
};
const containerClasses = [
'll-fab-container',
`ll-fab-${position}`,
`ll-fab-direction-${direction}`,
fixed && 'll-fab-fixed',
expanded && 'll-fab-expanded',
className,
].filter(Boolean).join(' ');
const buttonClasses = [
'll-fab',
`ll-fab-${variant}`,
`ll-fab-${size}`,
disabled && 'll-fab-disabled',
expanded && 'll-fab-active',
].filter(Boolean).join(' ');
return (
<div
className={containerClasses}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Actions */}
{hasActions && (
<div className="ll-fab-actions">
{actions.map((action, index) => {
const actionClasses = [
'll-fab-action',
`ll-fab-action-${action.variant || 'secondary'}`,
action.disabled && 'll-fab-action-disabled',
].filter(Boolean).join(' ');
return (
<div
key={action.id}
className={actionClasses}
style={{
transitionDelay: expanded ? `${index * 0.05}s` : '0s',
opacity: expanded ? 1 : 0,
transform: expanded ? 'scale(1)' : 'scale(0)',
}}
>
{showLabels && action.label && (
<span className="ll-fab-action-label">{action.label}</span>
)}
<button
type="button"
className="ll-fab-action-btn"
onClick={() => handleActionClick(action)}
disabled={action.disabled}
aria-label={action.label}
>
{action.icon}
</button>
</div>
);
})}
</div>
)}
{/* Main FAB button */}
<button
type="button"
className={buttonClasses}
onClick={handleToggle}
disabled={disabled}
aria-label={label}
aria-expanded={hasActions ? expanded : undefined}
>
<span className={`ll-fab-icon ${expanded && expandedIcon ? 'll-fab-icon-hidden' : ''}`}>
{icon}
</span>
{expandedIcon && (
<span className={`ll-fab-icon ll-fab-icon-expanded ${expanded ? '' : 'll-fab-icon-hidden'}`}>
{expandedIcon}
</span>
)}
</button>
{/* Backdrop */}
{expanded && hasActions && (
<div
className="ll-fab-backdrop"
onClick={() => {
if (controlledExpanded === undefined) {
setInternalExpanded(false);
}
onExpandedChange?.(false);
}}
/>
)}
</div>
);
};
// Mini FAB variant
export interface MiniFABProps {
/** Icon content */
icon: React.ReactNode;
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Click handler */
onClick?: () => void;
/** Disabled state */
disabled?: boolean;
/** Tooltip/aria-label */
label?: string;
/** Additional CSS classes */
className?: string;
}
export const MiniFAB: React.FC<MiniFABProps> = ({
icon,
variant = 'primary',
onClick,
disabled = false,
label,
className = '',
}) => {
const classes = [
'll-fab',
'll-fab-mini',
`ll-fab-${variant}`,
disabled && 'll-fab-disabled',
className,
].filter(Boolean).join(' ');
return (
<button
type="button"
className={classes}
onClick={onClick}
disabled={disabled}
aria-label={label}
>
{icon}
</button>
);
};
// Extended FAB with text
export interface ExtendedFABProps extends MiniFABProps {
/** Text label */
text: string;
/** Icon position */
iconPosition?: 'start' | 'end';
}
export const ExtendedFAB: React.FC<ExtendedFABProps> = ({
icon,
text,
variant = 'primary',
onClick,
disabled = false,
label,
iconPosition = 'start',
className = '',
}) => {
const classes = [
'll-fab',
'll-fab-extended',
`ll-fab-${variant}`,
disabled && 'll-fab-disabled',
className,
].filter(Boolean).join(' ');
return (
<button
type="button"
className={classes}
onClick={onClick}
disabled={disabled}
aria-label={label || text}
>
{iconPosition === 'start' && <span className="ll-fab-icon">{icon}</span>}
<span className="ll-fab-text">{text}</span>
{iconPosition === 'end' && <span className="ll-fab-icon">{icon}</span>}
</button>
);
};

View File

@@ -0,0 +1,472 @@
import React, { useState, useRef, useCallback, DragEvent, ChangeEvent } from 'react';
export interface UploadFile {
/** Unique identifier */
id: string;
/** Original file object */
file: File;
/** File name */
name: string;
/** File size in bytes */
size: number;
/** MIME type */
type: string;
/** Upload progress (0-100) */
progress: number;
/** Upload status */
status: 'pending' | 'uploading' | 'success' | 'error';
/** Error message if failed */
error?: string;
/** Preview URL (for images) */
preview?: string;
/** Server response */
response?: any;
}
export interface FileUploadProps {
/** Accepted file types (MIME types or extensions) */
accept?: string;
/** Allow multiple files */
multiple?: boolean;
/** Maximum file size in bytes */
maxSize?: number;
/** Minimum file size in bytes */
minSize?: number;
/** Maximum number of files */
maxFiles?: number;
/** Enable drag and drop */
dragDrop?: boolean;
/** Show file list */
showFileList?: boolean;
/** Show preview for images */
showPreview?: boolean;
/** Callback when files are selected */
onSelect?: (files: UploadFile[]) => void;
/** Callback for each file upload */
onUpload?: (file: UploadFile, updateProgress: (progress: number) => void) => Promise<any>;
/** Callback when file is removed */
onRemove?: (file: UploadFile) => void;
/** Callback on validation error */
onError?: (file: File, error: string) => void;
/** Custom validation */
validate?: (file: File) => boolean | string;
/** Auto upload after selection */
autoUpload?: boolean;
/** Disabled state */
disabled?: boolean;
/** Button text */
buttonText?: string;
/** Drag area text */
dragText?: string;
/** Drag active text */
dragActiveText?: string;
/** Additional CSS classes */
className?: string;
/** Button variant */
buttonVariant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' | 'outline-primary' | 'outline-secondary';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Custom dropzone content */
children?: React.ReactNode;
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileExtension = (filename: string): string => {
return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2).toLowerCase();
};
const generateId = (): string => {
return `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
export const FileUpload: React.FC<FileUploadProps> = ({
accept,
multiple = false,
maxSize,
minSize,
maxFiles,
dragDrop = true,
showFileList = true,
showPreview = true,
onSelect,
onUpload,
onRemove,
onError,
validate,
autoUpload = false,
disabled = false,
buttonText = 'Choose Files',
dragText = 'Drag and drop files here or click to browse',
dragActiveText = 'Drop files here...',
className = '',
buttonVariant = 'primary',
size = 'md',
children,
}) => {
const [files, setFiles] = useState<UploadFile[]>([]);
const [isDragActive, setIsDragActive] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Validate file
const validateFile = useCallback((file: File): string | null => {
// Check file type
if (accept) {
const acceptedTypes = accept.split(',').map((t) => t.trim());
const fileExt = `.${getFileExtension(file.name)}`;
const isValid = acceptedTypes.some((type) => {
if (type.startsWith('.')) {
return type.toLowerCase() === fileExt;
}
if (type.endsWith('/*')) {
return file.type.startsWith(type.slice(0, -1));
}
return file.type === type;
});
if (!isValid) {
return `File type not accepted. Accepted types: ${accept}`;
}
}
// Check file size
if (maxSize && file.size > maxSize) {
return `File size exceeds maximum (${formatFileSize(maxSize)})`;
}
if (minSize && file.size < minSize) {
return `File size is below minimum (${formatFileSize(minSize)})`;
}
// Custom validation
if (validate) {
const result = validate(file);
if (result !== true) {
return typeof result === 'string' ? result : 'File validation failed';
}
}
return null;
}, [accept, maxSize, minSize, validate]);
// Process selected files
const processFiles = useCallback(async (selectedFiles: FileList | File[]) => {
const fileArray = Array.from(selectedFiles);
// Check max files
if (maxFiles && files.length + fileArray.length > maxFiles) {
onError?.(fileArray[0], `Maximum ${maxFiles} files allowed`);
return;
}
const newFiles: UploadFile[] = [];
for (const file of fileArray) {
// Validate
const error = validateFile(file);
if (error) {
onError?.(file, error);
continue;
}
// Create upload file object
const uploadFile: UploadFile = {
id: generateId(),
file,
name: file.name,
size: file.size,
type: file.type,
progress: 0,
status: 'pending',
};
// Generate preview for images
if (showPreview && file.type.startsWith('image/')) {
uploadFile.preview = URL.createObjectURL(file);
}
newFiles.push(uploadFile);
}
if (newFiles.length === 0) return;
const updatedFiles = multiple ? [...files, ...newFiles] : newFiles;
setFiles(updatedFiles);
onSelect?.(newFiles);
// Auto upload
if (autoUpload && onUpload) {
for (const uploadFile of newFiles) {
await uploadSingleFile(uploadFile);
}
}
}, [files, maxFiles, multiple, validateFile, onError, onSelect, showPreview, autoUpload, onUpload]);
// Upload single file
const uploadSingleFile = useCallback(async (uploadFile: UploadFile) => {
if (!onUpload) return;
setFiles((prev) =>
prev.map((f) => (f.id === uploadFile.id ? { ...f, status: 'uploading' } : f))
);
const updateProgress = (progress: number) => {
setFiles((prev) =>
prev.map((f) => (f.id === uploadFile.id ? { ...f, progress } : f))
);
};
try {
const response = await onUpload(uploadFile, updateProgress);
setFiles((prev) =>
prev.map((f) =>
f.id === uploadFile.id
? { ...f, status: 'success', progress: 100, response }
: f
)
);
} catch (error) {
setFiles((prev) =>
prev.map((f) =>
f.id === uploadFile.id
? { ...f, status: 'error', error: error instanceof Error ? error.message : 'Upload failed' }
: f
)
);
}
}, [onUpload]);
// Handle file input change
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
processFiles(e.target.files);
}
// Reset input
if (inputRef.current) {
inputRef.current.value = '';
}
};
// Handle drag events
const handleDrag = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragActive(true);
}
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
if (disabled) return;
if (e.dataTransfer.files) {
processFiles(e.dataTransfer.files);
}
};
// Remove file
const handleRemove = (file: UploadFile) => {
// Revoke preview URL
if (file.preview) {
URL.revokeObjectURL(file.preview);
}
setFiles((prev) => prev.filter((f) => f.id !== file.id));
onRemove?.(file);
};
// Retry upload
const handleRetry = (file: UploadFile) => {
uploadSingleFile(file);
};
// Upload all pending files
const uploadAll = () => {
const pendingFiles = files.filter((f) => f.status === 'pending');
pendingFiles.forEach(uploadSingleFile);
};
// Open file dialog
const openFileDialog = () => {
if (!disabled) {
inputRef.current?.click();
}
};
const containerClasses = [
'll-file-upload',
`ll-file-upload-${size}`,
disabled && 'll-file-upload-disabled',
className,
].filter(Boolean).join(' ');
const dropzoneClasses = [
'll-file-dropzone',
isDragActive && 'll-file-dropzone-active',
disabled && 'll-file-dropzone-disabled',
].filter(Boolean).join(' ');
return (
<div className={containerClasses}>
{/* Hidden file input */}
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleInputChange}
disabled={disabled}
className="ll-file-input-hidden"
aria-hidden="true"
tabIndex={-1}
/>
{/* Dropzone */}
{dragDrop ? (
<div
className={dropzoneClasses}
onClick={openFileDialog}
onDrag={handleDrag}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDrag}
onDrop={handleDrop}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => e.key === 'Enter' && openFileDialog()}
aria-label="File upload dropzone"
>
{children || (
<div className="ll-file-dropzone-content">
<div className="ll-file-dropzone-icon">
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z" />
</svg>
</div>
<div className="ll-file-dropzone-text">
{isDragActive ? dragActiveText : dragText}
</div>
{accept && (
<div className="ll-file-dropzone-hint">
Accepted: {accept}
</div>
)}
{maxSize && (
<div className="ll-file-dropzone-hint">
Max size: {formatFileSize(maxSize)}
</div>
)}
</div>
)}
</div>
) : (
<button
type="button"
className={`ll-btn ll-btn-${buttonVariant}`}
onClick={openFileDialog}
disabled={disabled}
>
{buttonText}
</button>
)}
{/* File list */}
{showFileList && files.length > 0 && (
<ul className="ll-file-list">
{files.map((file) => (
<li key={file.id} className={`ll-file-item ll-file-item-${file.status}`}>
{/* Preview */}
{showPreview && file.preview && (
<div className="ll-file-preview">
<img src={file.preview} alt={file.name} />
</div>
)}
{/* File info */}
<div className="ll-file-info">
<div className="ll-file-name">{file.name}</div>
<div className="ll-file-meta">
<span className="ll-file-size">{formatFileSize(file.size)}</span>
{file.status === 'uploading' && (
<span className="ll-file-progress-text">{file.progress}%</span>
)}
{file.status === 'success' && (
<span className="ll-file-status-success">Uploaded</span>
)}
{file.status === 'error' && (
<span className="ll-file-status-error">{file.error}</span>
)}
</div>
{/* Progress bar */}
{file.status === 'uploading' && (
<div className="ll-file-progress">
<div
className="ll-file-progress-bar"
style={{ width: `${file.progress}%` }}
/>
</div>
)}
</div>
{/* Actions */}
<div className="ll-file-actions">
{file.status === 'error' && (
<button
type="button"
className="ll-file-action ll-file-action-retry"
onClick={() => handleRetry(file)}
aria-label="Retry upload"
>
&#8634;
</button>
)}
<button
type="button"
className="ll-file-action ll-file-action-remove"
onClick={() => handleRemove(file)}
aria-label="Remove file"
>
&times;
</button>
</div>
</li>
))}
</ul>
)}
{/* Upload button */}
{!autoUpload && onUpload && files.some((f) => f.status === 'pending') && (
<button
type="button"
className={`ll-btn ll-btn-${buttonVariant} ll-file-upload-btn`}
onClick={uploadAll}
>
Upload All
</button>
)}
</div>
);
};
// Dropzone alias
export const Dropzone = FileUpload;

164
src/components/Form.tsx Normal file
View File

@@ -0,0 +1,164 @@
import React from 'react';
export type FormGroupProps = {
label?: React.ReactNode;
helpText?: React.ReactNode;
invalidFeedback?: React.ReactNode;
validFeedback?: React.ReactNode;
invalid?: boolean;
valid?: boolean;
id?: string;
className?: string;
children: React.ReactNode;
};
export function FormGroup({
label,
helpText,
invalidFeedback,
validFeedback,
invalid,
valid,
id,
className = '',
children,
}: FormGroupProps) {
// Pass invalid/valid props to child if it's a form control
const enhancedChildren = React.isValidElement(children)
? React.cloneElement(children as any, { id, invalid, valid })
: children;
return (
<div className={`mb-3 ${className}`.trim()}>
{label ? (
<label htmlFor={id} className="form-label">
{label}
</label>
) : null}
{enhancedChildren}
{helpText ? <div className="form-text">{helpText}</div> : null}
{invalidFeedback ? <div className="invalid-feedback">{invalidFeedback}</div> : null}
{validFeedback ? <div className="valid-feedback">{validFeedback}</div> : null}
</div>
);
}
export type FormControlProps = React.InputHTMLAttributes<HTMLInputElement> & {
invalid?: boolean;
valid?: boolean;
as?: 'input' | 'textarea';
};
export const FormControl = React.forwardRef<HTMLInputElement | HTMLTextAreaElement, FormControlProps>(
({ className = '', invalid, valid, as = 'input', ...rest }, ref) => {
const classes = [
'form-control',
invalid ? 'is-invalid' : '',
valid && !invalid ? 'is-valid' : '',
className,
].filter(Boolean).join(' ');
if (as === 'textarea') {
return <textarea ref={ref as any} className={classes} {...(rest as any)} />;
}
return <input ref={ref as any} className={classes} {...rest} />;
}
);
FormControl.displayName = 'FormControl';
export type FormCheckProps = React.InputHTMLAttributes<HTMLInputElement> & {
label?: React.ReactNode;
inline?: boolean;
invalid?: boolean;
valid?: boolean;
invalidFeedback?: React.ReactNode;
validFeedback?: React.ReactNode;
type?: 'checkbox' | 'radio';
};
export function FormCheck({
label,
inline,
className = '',
invalid,
valid,
invalidFeedback,
validFeedback,
type = 'checkbox',
id,
...rest
}: FormCheckProps) {
const wrapperClasses = ['form-check', inline ? 'form-check-inline' : '', className].filter(Boolean).join(' ');
const inputClasses = [
'form-check-input',
invalid ? 'is-invalid' : '',
valid && !invalid ? 'is-valid' : '',
].filter(Boolean).join(' ');
return (
<div className={wrapperClasses}>
<input className={inputClasses} type={type} id={id} {...rest} />
{label ? (
<label className="form-check-label" htmlFor={id}>
{label}
</label>
) : null}
{invalid && invalidFeedback ? <div className="invalid-feedback">{invalidFeedback}</div> : null}
{invalid && !invalidFeedback ? <div className="invalid-feedback">Invalid</div> : null}
{valid && !invalid && validFeedback ? <div className="valid-feedback">{validFeedback}</div> : null}
</div>
);
}
export type SelectOption = { label: string; value: string } & Record<string, any>;
export type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
options: SelectOption[];
placeholder?: string;
invalid?: boolean;
valid?: boolean;
};
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ options, placeholder, className = '', invalid, valid, ...rest }, ref) => {
const classes = [
'form-select',
invalid ? 'is-invalid' : '',
valid && !invalid ? 'is-valid' : '',
className,
].filter(Boolean).join(' ');
return (
<select ref={ref} className={classes} {...rest}>
{placeholder ? (
<option value="" disabled={rest.required} hidden={rest.required}>
{placeholder}
</option>
) : null}
{options.map(({ value, label, ...optRest }) => (
<option key={value} value={value} {...optRest}>
{label}
</option>
))}
</select>
);
}
);
Select.displayName = 'Select';
export type InputGroupProps = {
prepend?: React.ReactNode;
append?: React.ReactNode;
children: React.ReactNode;
className?: string;
};
export function InputGroup({ prepend, append, children, className = '' }: InputGroupProps) {
return (
<div className={`input-group ${className}`.trim()}>
{prepend ? <span className="input-group-text">{prepend}</span> : null}
{children}
{append ? <span className="input-group-text">{append}</span> : null}
</div>
);
}

484
src/components/Gallery.tsx Normal file
View File

@@ -0,0 +1,484 @@
import React, { useState, useCallback, useEffect } from 'react';
export interface GalleryItem {
/** Unique identifier */
id: string;
/** Image source URL */
src: string;
/** Thumbnail URL (optional, uses src if not provided) */
thumbnail?: string;
/** Title */
title?: string;
/** Description */
description?: string;
/** Alt text */
alt?: string;
/** Custom data */
data?: Record<string, unknown>;
}
export interface GalleryProps {
/** Gallery items */
items: GalleryItem[];
/** Layout mode */
layout?: 'grid' | 'masonry' | 'justified';
/** Number of columns */
columns?: number | { sm?: number; md?: number; lg?: number; xl?: number };
/** Gap between items */
gap?: number;
/** Enable lightbox */
lightbox?: boolean;
/** Show item titles */
showTitles?: boolean;
/** Show item descriptions */
showDescriptions?: boolean;
/** Thumbnail aspect ratio */
aspectRatio?: 'square' | '4:3' | '16:9' | '3:2' | 'auto';
/** Hover effect */
hoverEffect?: 'none' | 'zoom' | 'fade' | 'slide' | 'overlay';
/** Lazy load images */
lazyLoad?: boolean;
/** Callback when item is clicked */
onItemClick?: (item: GalleryItem, index: number) => void;
/** Custom item render */
renderItem?: (item: GalleryItem, index: number) => React.ReactNode;
/** Additional CSS classes */
className?: string;
}
export const Gallery: React.FC<GalleryProps> = ({
items,
layout = 'grid',
columns = 4,
gap = 16,
lightbox = true,
showTitles = false,
showDescriptions = false,
aspectRatio = 'square',
hoverEffect = 'zoom',
lazyLoad = true,
onItemClick,
renderItem,
className = '',
}) => {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const handleItemClick = (item: GalleryItem, index: number) => {
if (lightbox) {
setActiveIndex(index);
setLightboxOpen(true);
}
onItemClick?.(item, index);
};
const closeLightbox = () => {
setLightboxOpen(false);
};
const goToPrev = useCallback(() => {
setActiveIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
}, [items.length]);
const goToNext = useCallback(() => {
setActiveIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
}, [items.length]);
// Keyboard navigation for lightbox
useEffect(() => {
if (!lightboxOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') goToPrev();
if (e.key === 'ArrowRight') goToNext();
};
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [lightboxOpen, goToPrev, goToNext]);
// Calculate columns based on responsive config
const getColumnsStyle = () => {
if (typeof columns === 'number') {
return { '--ll-gallery-columns': columns } as React.CSSProperties;
}
return {
'--ll-gallery-columns-sm': columns.sm || 2,
'--ll-gallery-columns-md': columns.md || 3,
'--ll-gallery-columns-lg': columns.lg || 4,
'--ll-gallery-columns-xl': columns.xl || 5,
} as React.CSSProperties;
};
const getAspectRatioClass = () => {
switch (aspectRatio) {
case 'square': return 'll-gallery-ratio-1-1';
case '4:3': return 'll-gallery-ratio-4-3';
case '16:9': return 'll-gallery-ratio-16-9';
case '3:2': return 'll-gallery-ratio-3-2';
default: return '';
}
};
const classes = [
'll-gallery',
`ll-gallery-${layout}`,
hoverEffect !== 'none' && `ll-gallery-hover-${hoverEffect}`,
className,
].filter(Boolean).join(' ');
return (
<>
<div
className={classes}
style={{
...getColumnsStyle(),
'--ll-gallery-gap': `${gap}px`,
} as React.CSSProperties}
>
{items.map((item, index) => {
if (renderItem) {
return (
<div key={item.id} onClick={() => handleItemClick(item, index)}>
{renderItem(item, index)}
</div>
);
}
return (
<div
key={item.id}
className={`ll-gallery-item ${getAspectRatioClass()}`}
onClick={() => handleItemClick(item, index)}
>
<div className="ll-gallery-item-inner">
<img
src={item.thumbnail || item.src}
alt={item.alt || item.title || ''}
loading={lazyLoad ? 'lazy' : undefined}
className="ll-gallery-image"
/>
{hoverEffect === 'overlay' && (
<div className="ll-gallery-overlay">
{item.title && <div className="ll-gallery-overlay-title">{item.title}</div>}
{item.description && (
<div className="ll-gallery-overlay-desc">{item.description}</div>
)}
</div>
)}
</div>
{(showTitles || showDescriptions) && (
<div className="ll-gallery-caption">
{showTitles && item.title && (
<div className="ll-gallery-title">{item.title}</div>
)}
{showDescriptions && item.description && (
<div className="ll-gallery-description">{item.description}</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Lightbox */}
{lightbox && lightboxOpen && (
<Lightbox
items={items}
activeIndex={activeIndex}
onClose={closeLightbox}
onPrev={goToPrev}
onNext={goToNext}
onIndexChange={setActiveIndex}
/>
)}
</>
);
};
// Lightbox Component
export interface LightboxProps {
/** Items to display */
items: GalleryItem[];
/** Current active index */
activeIndex: number;
/** Whether lightbox is open (for standalone use) */
isOpen?: boolean;
/** Close callback */
onClose: () => void;
/** Previous callback */
onPrev?: () => void;
/** Next callback */
onNext?: () => void;
/** Index change callback */
onIndexChange?: (index: number) => void;
/** Show thumbnails */
showThumbnails?: boolean;
/** Show counter */
showCounter?: boolean;
/** Show zoom controls */
showZoom?: boolean;
/** Enable slideshow */
slideshow?: boolean;
/** Slideshow interval in ms */
slideshowInterval?: number;
/** Animation type */
animation?: 'fade' | 'slide' | 'none';
/** Additional CSS classes */
className?: string;
}
export const Lightbox: React.FC<LightboxProps> = ({
items,
activeIndex,
isOpen = true,
onClose,
onPrev,
onNext,
onIndexChange,
showThumbnails = true,
showCounter = true,
showZoom = true,
slideshow = false,
slideshowInterval = 3000,
animation = 'fade',
className = '',
}) => {
const [zoom, setZoom] = useState(1);
const [isPlaying, setIsPlaying] = useState(slideshow);
const currentItem = items[activeIndex];
// Auto slideshow
useEffect(() => {
if (!isPlaying || !isOpen) return;
const timer = setInterval(() => {
onNext?.();
}, slideshowInterval);
return () => clearInterval(timer);
}, [isPlaying, isOpen, onNext, slideshowInterval]);
// Reset zoom when image changes
useEffect(() => {
setZoom(1);
}, [activeIndex]);
const handleZoomIn = () => {
setZoom((prev) => Math.min(prev + 0.5, 3));
};
const handleZoomOut = () => {
setZoom((prev) => Math.max(prev - 0.5, 0.5));
};
const handleResetZoom = () => {
setZoom(1);
};
const toggleSlideshow = () => {
setIsPlaying((prev) => !prev);
};
if (!isOpen) return null;
return (
<div className={`ll-lightbox ${className}`} onClick={onClose}>
<div className="ll-lightbox-backdrop" />
{/* Header */}
<div className="ll-lightbox-header" onClick={(e) => e.stopPropagation()}>
{showCounter && (
<div className="ll-lightbox-counter">
{activeIndex + 1} / {items.length}
</div>
)}
<div className="ll-lightbox-title">{currentItem?.title}</div>
<div className="ll-lightbox-actions">
{showZoom && (
<>
<button
type="button"
className="ll-lightbox-btn"
onClick={handleZoomOut}
disabled={zoom <= 0.5}
title="Zoom out"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35M8 11h6" />
</svg>
</button>
<button
type="button"
className="ll-lightbox-btn"
onClick={handleResetZoom}
title="Reset zoom"
>
{Math.round(zoom * 100)}%
</button>
<button
type="button"
className="ll-lightbox-btn"
onClick={handleZoomIn}
disabled={zoom >= 3}
title="Zoom in"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35M11 8v6M8 11h6" />
</svg>
</button>
</>
)}
{slideshow && (
<button
type="button"
className={`ll-lightbox-btn ${isPlaying ? 'll-lightbox-btn-active' : ''}`}
onClick={toggleSlideshow}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
)}
</button>
)}
<button type="button" className="ll-lightbox-btn ll-lightbox-close" onClick={onClose} title="Close">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Main content */}
<div className="ll-lightbox-content" onClick={(e) => e.stopPropagation()}>
{/* Previous button */}
{items.length > 1 && (
<button type="button" className="ll-lightbox-nav ll-lightbox-prev" onClick={onPrev} title="Previous">
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
)}
{/* Image */}
<div className={`ll-lightbox-image-container ll-lightbox-${animation}`}>
<img
src={currentItem?.src}
alt={currentItem?.alt || currentItem?.title || ''}
className="ll-lightbox-image"
style={{ transform: `scale(${zoom})` }}
/>
</div>
{/* Next button */}
{items.length > 1 && (
<button type="button" className="ll-lightbox-nav ll-lightbox-next" onClick={onNext} title="Next">
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
)}
</div>
{/* Footer with description */}
{currentItem?.description && (
<div className="ll-lightbox-footer" onClick={(e) => e.stopPropagation()}>
<p className="ll-lightbox-description">{currentItem.description}</p>
</div>
)}
{/* Thumbnails */}
{showThumbnails && items.length > 1 && (
<div className="ll-lightbox-thumbnails" onClick={(e) => e.stopPropagation()}>
{items.map((item, index) => (
<button
key={item.id}
type="button"
className={`ll-lightbox-thumbnail ${index === activeIndex ? 'll-lightbox-thumbnail-active' : ''}`}
onClick={() => onIndexChange?.(index)}
>
<img src={item.thumbnail || item.src} alt={item.alt || item.title || ''} />
</button>
))}
</div>
)}
</div>
);
};
// Masonry Gallery (alternative layout)
export interface MasonryGalleryProps extends Omit<GalleryProps, 'layout'> {
/** Column width for masonry */
columnWidth?: number;
}
export const MasonryGallery: React.FC<MasonryGalleryProps> = ({
columnWidth = 250,
...props
}) => {
return (
<Gallery
{...props}
layout="masonry"
aspectRatio="auto"
/>
);
};
// Photo Grid (simplified grid gallery)
export interface PhotoGridProps {
/** Array of image URLs */
images: string[];
/** Number of columns */
columns?: number;
/** Gap between images */
gap?: number;
/** Enable lightbox */
lightbox?: boolean;
/** Callback when image is clicked */
onImageClick?: (index: number) => void;
/** Additional CSS classes */
className?: string;
}
export const PhotoGrid: React.FC<PhotoGridProps> = ({
images,
columns = 3,
gap = 8,
lightbox = true,
onImageClick,
className = '',
}) => {
const items: GalleryItem[] = images.map((src, index) => ({
id: `photo-${index}`,
src,
}));
return (
<Gallery
items={items}
columns={columns}
gap={gap}
lightbox={lightbox}
onItemClick={(_, index) => onImageClick?.(index)}
className={className}
/>
);
};

View File

@@ -0,0 +1,463 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
export interface IdleTimeoutProps {
/** Idle timeout in milliseconds */
timeout: number;
/** Warning time before timeout (shows warning modal) */
warningTime?: number;
/** Events to listen for activity */
events?: string[];
/** Callback when user becomes idle */
onIdle?: () => void;
/** Callback when idle warning is shown */
onWarning?: (remainingTime: number) => void;
/** Callback when user becomes active again */
onActive?: () => void;
/** Callback when timeout expires */
onTimeout?: () => void;
/** Show warning modal */
showWarningModal?: boolean;
/** Warning modal title */
warningTitle?: string;
/** Warning modal message */
warningMessage?: string;
/** Stay active button text */
stayActiveText?: string;
/** Logout button text */
logoutText?: string;
/** Enable the timeout */
enabled?: boolean;
/** Additional CSS classes */
className?: string;
/** Children (optional) */
children?: React.ReactNode;
}
const DEFAULT_EVENTS = [
'mousemove',
'mousedown',
'keydown',
'touchstart',
'scroll',
'wheel',
'click',
];
export const IdleTimeout: React.FC<IdleTimeoutProps> = ({
timeout,
warningTime = 60000, // 1 minute warning
events = DEFAULT_EVENTS,
onIdle,
onWarning,
onActive,
onTimeout,
showWarningModal = true,
warningTitle = 'Session Timeout Warning',
warningMessage = 'Your session is about to expire due to inactivity.',
stayActiveText = 'Stay Active',
logoutText = 'Logout',
enabled = true,
className = '',
children,
}) => {
const [isIdle, setIsIdle] = useState(false);
const [showWarning, setShowWarning] = useState(false);
const [remainingTime, setRemainingTime] = useState(warningTime);
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
const warningTimerRef = useRef<NodeJS.Timeout | null>(null);
const countdownRef = useRef<NodeJS.Timeout | null>(null);
const lastActivityRef = useRef<number>(Date.now());
// Clear all timers
const clearTimers = useCallback(() => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
if (warningTimerRef.current) {
clearTimeout(warningTimerRef.current);
warningTimerRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
// Start countdown timer
const startCountdown = useCallback(() => {
setRemainingTime(warningTime);
countdownRef.current = setInterval(() => {
setRemainingTime((prev) => {
const newTime = prev - 1000;
if (newTime <= 0) {
clearInterval(countdownRef.current!);
countdownRef.current = null;
onTimeout?.();
return 0;
}
return newTime;
});
}, 1000);
}, [warningTime, onTimeout]);
// Start idle timer
const startIdleTimer = useCallback(() => {
clearTimers();
// First timer: time until warning
const timeUntilWarning = timeout - warningTime;
idleTimerRef.current = setTimeout(() => {
setIsIdle(true);
onIdle?.();
if (showWarningModal) {
setShowWarning(true);
onWarning?.(warningTime);
startCountdown();
}
// Second timer: warning period until timeout
warningTimerRef.current = setTimeout(() => {
if (!showWarningModal) {
onTimeout?.();
}
}, warningTime);
}, timeUntilWarning);
}, [timeout, warningTime, showWarningModal, onIdle, onWarning, onTimeout, clearTimers, startCountdown]);
// Handle user activity
const handleActivity = useCallback(() => {
lastActivityRef.current = Date.now();
if (isIdle) {
setIsIdle(false);
setShowWarning(false);
setRemainingTime(warningTime);
onActive?.();
}
startIdleTimer();
}, [isIdle, warningTime, onActive, startIdleTimer]);
// Stay active handler
const handleStayActive = useCallback(() => {
setShowWarning(false);
setIsIdle(false);
setRemainingTime(warningTime);
onActive?.();
startIdleTimer();
}, [warningTime, onActive, startIdleTimer]);
// Logout handler
const handleLogout = useCallback(() => {
clearTimers();
setShowWarning(false);
onTimeout?.();
}, [clearTimers, onTimeout]);
// Setup event listeners
useEffect(() => {
if (!enabled) {
clearTimers();
return;
}
// Add event listeners
events.forEach((event) => {
document.addEventListener(event, handleActivity, { passive: true });
});
// Start initial timer
startIdleTimer();
return () => {
events.forEach((event) => {
document.removeEventListener(event, handleActivity);
});
clearTimers();
};
}, [enabled, events, handleActivity, startIdleTimer, clearTimers]);
// Format remaining time
const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
return `${seconds}s`;
};
return (
<>
{children}
{/* Warning Modal */}
{showWarningModal && showWarning && (
<div className={`ll-idle-timeout-modal ${className}`}>
<div className="ll-idle-timeout-backdrop" />
<div className="ll-idle-timeout-dialog">
<div className="ll-idle-timeout-icon">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
<h3 className="ll-idle-timeout-title">{warningTitle}</h3>
<p className="ll-idle-timeout-message">{warningMessage}</p>
<div className="ll-idle-timeout-countdown">
<span className="ll-idle-timeout-time">{formatTime(remainingTime)}</span>
<span className="ll-idle-timeout-label">remaining</span>
</div>
<div className="ll-idle-timeout-progress">
<div
className="ll-idle-timeout-progress-bar"
style={{ width: `${(remainingTime / warningTime) * 100}%` }}
/>
</div>
<div className="ll-idle-timeout-actions">
<button
type="button"
className="ll-idle-timeout-btn ll-idle-timeout-btn-secondary"
onClick={handleLogout}
>
{logoutText}
</button>
<button
type="button"
className="ll-idle-timeout-btn ll-idle-timeout-btn-primary"
onClick={handleStayActive}
>
{stayActiveText}
</button>
</div>
</div>
</div>
)}
</>
);
};
// Hook for idle timeout management
export interface UseIdleTimeoutOptions {
timeout: number;
warningTime?: number;
events?: string[];
onIdle?: () => void;
onWarning?: (remainingTime: number) => void;
onActive?: () => void;
onTimeout?: () => void;
enabled?: boolean;
}
export interface UseIdleTimeoutReturn {
isIdle: boolean;
isWarning: boolean;
remainingTime: number;
lastActivity: number;
reset: () => void;
pause: () => void;
resume: () => void;
}
export const useIdleTimeout = ({
timeout,
warningTime = 60000,
events = DEFAULT_EVENTS,
onIdle,
onWarning,
onActive,
onTimeout,
enabled = true,
}: UseIdleTimeoutOptions): UseIdleTimeoutReturn => {
const [isIdle, setIsIdle] = useState(false);
const [isWarning, setIsWarning] = useState(false);
const [remainingTime, setRemainingTime] = useState(warningTime);
const [isPaused, setIsPaused] = useState(false);
const lastActivityRef = useRef<number>(Date.now());
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
const countdownRef = useRef<NodeJS.Timeout | null>(null);
const clearTimers = useCallback(() => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
const startTimer = useCallback(() => {
if (isPaused || !enabled) return;
clearTimers();
const timeUntilWarning = timeout - warningTime;
idleTimerRef.current = setTimeout(() => {
setIsIdle(true);
setIsWarning(true);
onIdle?.();
onWarning?.(warningTime);
setRemainingTime(warningTime);
countdownRef.current = setInterval(() => {
setRemainingTime((prev) => {
const newTime = prev - 1000;
if (newTime <= 0) {
clearInterval(countdownRef.current!);
onTimeout?.();
return 0;
}
return newTime;
});
}, 1000);
}, timeUntilWarning);
}, [timeout, warningTime, isPaused, enabled, onIdle, onWarning, onTimeout, clearTimers]);
const handleActivity = useCallback(() => {
if (isPaused || !enabled) return;
lastActivityRef.current = Date.now();
if (isIdle) {
setIsIdle(false);
setIsWarning(false);
setRemainingTime(warningTime);
onActive?.();
}
startTimer();
}, [isIdle, isPaused, enabled, warningTime, onActive, startTimer]);
const reset = useCallback(() => {
setIsIdle(false);
setIsWarning(false);
setRemainingTime(warningTime);
lastActivityRef.current = Date.now();
startTimer();
}, [warningTime, startTimer]);
const pause = useCallback(() => {
setIsPaused(true);
clearTimers();
}, [clearTimers]);
const resume = useCallback(() => {
setIsPaused(false);
startTimer();
}, [startTimer]);
useEffect(() => {
if (!enabled || isPaused) {
clearTimers();
return;
}
events.forEach((event) => {
document.addEventListener(event, handleActivity, { passive: true });
});
startTimer();
return () => {
events.forEach((event) => {
document.removeEventListener(event, handleActivity);
});
clearTimers();
};
}, [enabled, isPaused, events, handleActivity, startTimer, clearTimers]);
return {
isIdle,
isWarning,
remainingTime,
lastActivity: lastActivityRef.current,
reset,
pause,
resume,
};
};
// Session Timeout Component (simpler version)
export interface SessionTimeoutProps {
/** Session timeout in milliseconds */
timeout: number;
/** Callback when session expires */
onExpire: () => void;
/** Show countdown */
showCountdown?: boolean;
/** Countdown position */
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
/** Additional CSS classes */
className?: string;
}
export const SessionTimeout: React.FC<SessionTimeoutProps> = ({
timeout,
onExpire,
showCountdown = true,
position = 'bottom-right',
className = '',
}) => {
const [remaining, setRemaining] = useState(timeout);
const [visible, setVisible] = useState(false);
useEffect(() => {
const timer = setInterval(() => {
setRemaining((prev) => {
const newTime = prev - 1000;
if (newTime <= 0) {
clearInterval(timer);
onExpire();
return 0;
}
// Show countdown when 5 minutes remaining
if (newTime <= 300000 && !visible) {
setVisible(true);
}
return newTime;
});
}, 1000);
return () => clearInterval(timer);
}, [timeout, onExpire, visible]);
const formatTime = (ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
if (!showCountdown || !visible) return null;
return (
<div className={`ll-session-timeout ll-session-timeout-${position} ${className}`}>
<div className="ll-session-timeout-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
<div className="ll-session-timeout-text">
<span className="ll-session-timeout-label">Session expires in</span>
<span className="ll-session-timeout-time">{formatTime(remaining)}</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,442 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
export interface CropArea {
x: number;
y: number;
width: number;
height: number;
}
export interface ImageCropperProps {
/** Image source URL */
src: string;
/** Aspect ratio (width/height) */
aspectRatio?: number;
/** Minimum crop width */
minWidth?: number;
/** Minimum crop height */
minHeight?: number;
/** Maximum crop width */
maxWidth?: number;
/** Maximum crop height */
maxHeight?: number;
/** Initial crop area */
initialCrop?: Partial<CropArea>;
/** Callback when crop changes */
onChange?: (crop: CropArea) => void;
/** Callback when crop completes */
onComplete?: (croppedImage: Blob, crop: CropArea) => void;
/** Show crop preview */
showPreview?: boolean;
/** Preview shape */
previewShape?: 'rectangle' | 'circle';
/** Preview size */
previewSize?: number;
/** Crop shape */
cropShape?: 'rectangle' | 'circle';
/** Show grid */
showGrid?: boolean;
/** Grid type */
gridType?: 'none' | 'rule-of-thirds' | 'grid';
/** Zoom level (1-3) */
zoom?: number;
/** Enable zoom controls */
zoomable?: boolean;
/** Enable rotation */
rotatable?: boolean;
/** Rotation angle */
rotation?: number;
/** Output image format */
outputFormat?: 'image/jpeg' | 'image/png' | 'image/webp';
/** Output image quality (0-1) */
outputQuality?: number;
/** Additional CSS classes */
className?: string;
}
export const ImageCropper: React.FC<ImageCropperProps> = ({
src,
aspectRatio,
minWidth = 50,
minHeight = 50,
maxWidth,
maxHeight,
initialCrop,
onChange,
onComplete,
showPreview = false,
previewShape = 'rectangle',
previewSize = 150,
cropShape = 'rectangle',
showGrid = true,
gridType = 'rule-of-thirds',
zoom = 1,
zoomable = true,
rotatable = false,
rotation = 0,
outputFormat = 'image/jpeg',
outputQuality = 0.92,
className = '',
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [crop, setCrop] = useState<CropArea>({
x: initialCrop?.x ?? 0,
y: initialCrop?.y ?? 0,
width: initialCrop?.width ?? 100,
height: initialCrop?.height ?? 100,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeHandle, setResizeHandle] = useState<string | null>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
const [currentZoom, setCurrentZoom] = useState(zoom);
const [currentRotation, setCurrentRotation] = useState(rotation);
// Handle image load
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setImageSize({ width: img.naturalWidth, height: img.naturalHeight });
// Set initial crop
if (!initialCrop) {
const defaultWidth = Math.min(200, img.naturalWidth * 0.8);
const defaultHeight = aspectRatio
? defaultWidth / aspectRatio
: Math.min(200, img.naturalHeight * 0.8);
setCrop({
x: (img.naturalWidth - defaultWidth) / 2,
y: (img.naturalHeight - defaultHeight) / 2,
width: defaultWidth,
height: defaultHeight,
});
}
};
// Constrain crop to image bounds
const constrainCrop = useCallback((newCrop: CropArea): CropArea => {
let { x, y, width, height } = newCrop;
// Apply min/max constraints
width = Math.max(minWidth, width);
height = Math.max(minHeight, height);
if (maxWidth) width = Math.min(maxWidth, width);
if (maxHeight) height = Math.min(maxHeight, height);
// Apply aspect ratio
if (aspectRatio) {
const currentRatio = width / height;
if (currentRatio > aspectRatio) {
width = height * aspectRatio;
} else {
height = width / aspectRatio;
}
}
// Keep within bounds
x = Math.max(0, Math.min(x, imageSize.width - width));
y = Math.max(0, Math.min(y, imageSize.height - height));
return { x, y, width, height };
}, [aspectRatio, minWidth, minHeight, maxWidth, maxHeight, imageSize]);
// Handle mouse down on crop area
const handleCropMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
setDragStart({
x: e.clientX - crop.x,
y: e.clientY - crop.y,
});
};
// Handle mouse down on resize handle
const handleResizeMouseDown = (e: React.MouseEvent, handle: string) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeHandle(handle);
setDragStart({ x: e.clientX, y: e.clientY });
};
// Handle mouse move
useEffect(() => {
if (!isDragging && !isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newCrop = constrainCrop({
...crop,
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
setCrop(newCrop);
onChange?.(newCrop);
}
if (isResizing && resizeHandle) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
let newCrop = { ...crop };
switch (resizeHandle) {
case 'nw':
newCrop.x += deltaX;
newCrop.y += deltaY;
newCrop.width -= deltaX;
newCrop.height -= deltaY;
break;
case 'ne':
newCrop.y += deltaY;
newCrop.width += deltaX;
newCrop.height -= deltaY;
break;
case 'sw':
newCrop.x += deltaX;
newCrop.width -= deltaX;
newCrop.height += deltaY;
break;
case 'se':
newCrop.width += deltaX;
newCrop.height += deltaY;
break;
case 'n':
newCrop.y += deltaY;
newCrop.height -= deltaY;
break;
case 's':
newCrop.height += deltaY;
break;
case 'w':
newCrop.x += deltaX;
newCrop.width -= deltaX;
break;
case 'e':
newCrop.width += deltaX;
break;
}
newCrop = constrainCrop(newCrop);
setCrop(newCrop);
setDragStart({ x: e.clientX, y: e.clientY });
onChange?.(newCrop);
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
setResizeHandle(null);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, isResizing, resizeHandle, dragStart, crop, constrainCrop, onChange]);
// Generate cropped image
const getCroppedImage = useCallback(async (): Promise<Blob | null> => {
if (!imageRef.current || !canvasRef.current) return null;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const scaleX = imageRef.current.naturalWidth / imageRef.current.width;
const scaleY = imageRef.current.naturalHeight / imageRef.current.height;
canvas.width = crop.width;
canvas.height = crop.height;
ctx.drawImage(
imageRef.current,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
crop.width,
crop.height
);
return new Promise((resolve) => {
canvas.toBlob(
(blob) => resolve(blob),
outputFormat,
outputQuality
);
});
}, [crop, outputFormat, outputQuality]);
// Handle crop complete
const handleCropComplete = useCallback(async () => {
const blob = await getCroppedImage();
if (blob) {
onComplete?.(blob, crop);
}
}, [getCroppedImage, onComplete, crop]);
// Zoom handling
const handleZoomChange = (newZoom: number) => {
setCurrentZoom(Math.max(1, Math.min(3, newZoom)));
};
// Rotation handling
const handleRotate = (angle: number) => {
setCurrentRotation((prev) => (prev + angle) % 360);
};
const classes = [
'll-image-cropper',
cropShape === 'circle' && 'll-image-cropper-circle',
className,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<div
ref={containerRef}
className="ll-image-cropper-container"
style={{
transform: `scale(${currentZoom}) rotate(${currentRotation}deg)`,
}}
>
<img
ref={imageRef}
src={src}
alt="Crop source"
className="ll-image-cropper-image"
onLoad={handleImageLoad}
draggable={false}
/>
{/* Crop overlay */}
<div className="ll-image-cropper-overlay">
{/* Dark areas */}
<div className="ll-image-cropper-dark ll-image-cropper-dark-top" style={{ height: crop.y }} />
<div className="ll-image-cropper-dark ll-image-cropper-dark-left" style={{ top: crop.y, width: crop.x, height: crop.height }} />
<div className="ll-image-cropper-dark ll-image-cropper-dark-right" style={{ top: crop.y, left: crop.x + crop.width, height: crop.height }} />
<div className="ll-image-cropper-dark ll-image-cropper-dark-bottom" style={{ top: crop.y + crop.height }} />
</div>
{/* Crop box */}
<div
className={`ll-image-cropper-crop-box ${cropShape === 'circle' ? 'll-image-cropper-crop-circle' : ''}`}
style={{
left: crop.x,
top: crop.y,
width: crop.width,
height: crop.height,
}}
onMouseDown={handleCropMouseDown}
>
{/* Grid */}
{showGrid && gridType === 'rule-of-thirds' && (
<div className="ll-image-cropper-grid">
<div className="ll-image-cropper-grid-line ll-image-cropper-grid-h1" />
<div className="ll-image-cropper-grid-line ll-image-cropper-grid-h2" />
<div className="ll-image-cropper-grid-line ll-image-cropper-grid-v1" />
<div className="ll-image-cropper-grid-line ll-image-cropper-grid-v2" />
</div>
)}
{/* Resize handles */}
{['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'].map((handle) => (
<div
key={handle}
className={`ll-image-cropper-handle ll-image-cropper-handle-${handle}`}
onMouseDown={(e) => handleResizeMouseDown(e, handle)}
/>
))}
</div>
</div>
{/* Controls */}
<div className="ll-image-cropper-controls">
{zoomable && (
<div className="ll-image-cropper-zoom">
<button
type="button"
onClick={() => handleZoomChange(currentZoom - 0.1)}
disabled={currentZoom <= 1}
className="ll-image-cropper-btn"
>
-
</button>
<span className="ll-image-cropper-zoom-value">{Math.round(currentZoom * 100)}%</span>
<button
type="button"
onClick={() => handleZoomChange(currentZoom + 0.1)}
disabled={currentZoom >= 3}
className="ll-image-cropper-btn"
>
+
</button>
</div>
)}
{rotatable && (
<div className="ll-image-cropper-rotate">
<button
type="button"
onClick={() => handleRotate(-90)}
className="ll-image-cropper-btn"
>
</button>
<button
type="button"
onClick={() => handleRotate(90)}
className="ll-image-cropper-btn"
>
</button>
</div>
)}
{onComplete && (
<button
type="button"
onClick={handleCropComplete}
className="ll-image-cropper-btn ll-image-cropper-btn-primary"
>
Crop
</button>
)}
</div>
{/* Preview */}
{showPreview && (
<div
className={`ll-image-cropper-preview ${previewShape === 'circle' ? 'll-image-cropper-preview-circle' : ''}`}
style={{ width: previewSize, height: previewSize }}
>
<img
src={src}
alt="Preview"
style={{
width: imageSize.width * (previewSize / crop.width),
height: imageSize.height * (previewSize / crop.height),
marginLeft: -(crop.x * (previewSize / crop.width)),
marginTop: -(crop.y * (previewSize / crop.height)),
}}
/>
</div>
)}
{/* Hidden canvas for output */}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</div>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
export type ListGroupItemProps = {
active?: boolean;
disabled?: boolean;
action?: boolean;
href?: string;
onClick?: () => void;
className?: string;
children: React.ReactNode;
};
export function ListGroupItem({
active,
disabled,
action,
href,
onClick,
className = '',
children
}: ListGroupItemProps) {
const classes = [
'list-group-item',
action ? 'list-group-item-action' : '',
active ? 'active' : '',
disabled ? 'disabled' : '',
className
]
.filter(Boolean)
.join(' ');
if (href) {
return (
<a className={classes} href={href} onClick={onClick} aria-disabled={disabled}>
{children}
</a>
);
}
return (
<button className={classes} type="button" disabled={disabled} onClick={onClick}>
{children}
</button>
);
}
export type ListGroupProps = {
flush?: boolean;
horizontal?: boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
className?: string;
children: React.ReactNode;
};
export function ListGroup({ flush, horizontal, className = '', children }: ListGroupProps) {
const horizontalClass =
horizontal === true ? 'list-group-horizontal' : horizontal ? `list-group-horizontal-${horizontal}` : '';
const classes = ['list-group', flush ? 'list-group-flush' : '', horizontalClass, className]
.filter(Boolean)
.join(' ');
return <div className={classes}>{children}</div>;
}

25
src/components/Media.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
export type MediaProps = {
image?: React.ReactNode;
title?: React.ReactNode;
meta?: React.ReactNode;
className?: string;
children?: React.ReactNode;
};
/**
* Media object component (image + body).
*/
export function Media({ image, title, meta, className = '', children }: MediaProps) {
return (
<div className={['d-flex', className].filter(Boolean).join(' ')}>
{image ? <div className="flex-shrink-0 me-3">{image}</div> : null}
<div className="flex-grow-1">
{title ? <h6 className="mb-1">{title}</h6> : null}
{meta ? <div className="text-muted small mb-1">{meta}</div> : null}
{children}
</div>
</div>
);
}

66
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,66 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
export type ModalProps = {
open: boolean;
onClose?: () => void;
title?: React.ReactNode;
size?: 'sm' | 'lg' | 'xl';
centered?: boolean;
scrollable?: boolean;
children: React.ReactNode;
footer?: React.ReactNode;
};
/**
* Bootstrap-style modal rendered in a portal with backdrop.
*/
export function Modal({ open, onClose, title, size, centered, scrollable, children, footer }: ModalProps) {
const modalBody = (
<div className={`modal fade ${open ? 'show' : ''}`} style={{ display: open ? 'block' : 'none' }} role="dialog" aria-modal="true">
<div
className={[
'modal-dialog',
size ? `modal-${size}` : '',
centered ? 'modal-dialog-centered' : '',
scrollable ? 'modal-dialog-scrollable' : ''
]
.filter(Boolean)
.join(' ')}
>
<div className="modal-content">
{title ? (
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
{onClose ? (
<button type="button" className="btn-close" aria-label="Close" onClick={onClose}></button>
) : null}
</div>
) : null}
<div className="modal-body">{children}</div>
{footer ? <div className="modal-footer">{footer}</div> : null}
</div>
</div>
<div className={`modal-backdrop fade ${open ? 'show' : ''}`} />
</div>
);
useEffect(() => {
if (typeof document === 'undefined') return;
if (!open) return;
const body = document.body;
const previousOverflow = body.style.overflow;
body.style.overflow = 'hidden';
body.classList.add('modal-open');
return () => {
body.style.overflow = previousOverflow;
body.classList.remove('modal-open');
};
}, [open]);
if (typeof document === 'undefined') {
return null;
}
return createPortal(modalBody, document.body);
}

60
src/components/Nav.tsx Normal file
View File

@@ -0,0 +1,60 @@
import React from 'react';
export type NavItem = {
key: string;
label: React.ReactNode;
href?: string;
active?: boolean;
disabled?: boolean;
onClick?: () => void;
};
export type NavProps = {
items: NavItem[];
variant?: 'tabs' | 'pills' | 'underline';
fill?: boolean;
justify?: boolean;
vertical?: boolean;
className?: string;
};
export function Nav({ items, variant = 'tabs', fill, justify, vertical, className = '' }: NavProps) {
const classes = [
'nav',
variant === 'tabs' ? 'nav-tabs' : variant === 'pills' ? 'nav-pills' : 'nav-underline',
fill ? 'nav-fill' : '',
justify ? 'nav-justified' : '',
vertical ? 'flex-column' : '',
className
]
.filter(Boolean)
.join(' ');
return (
<ul className={classes}>
{items.map(item => (
<li className="nav-item" key={item.key}>
{item.href ? (
<a
className={`nav-link ${item.active ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`.trim()}
href={item.href}
onClick={item.onClick}
aria-disabled={item.disabled}
>
{item.label}
</a>
) : (
<button
type="button"
className={`nav-link ${item.active ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`.trim()}
onClick={item.onClick}
disabled={item.disabled}
>
{item.label}
</button>
)}
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,353 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
export type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'default';
export type NotificationPosition = 'top-right' | 'top-left' | 'top-center' | 'bottom-right' | 'bottom-left' | 'bottom-center';
export interface NotificationProps {
/** Unique identifier */
id: string;
/** Notification type */
type?: NotificationType;
/** Title text */
title?: React.ReactNode;
/** Message content */
message: React.ReactNode;
/** Custom icon */
icon?: React.ReactNode;
/** Show icon */
showIcon?: boolean;
/** Auto close after duration (ms) */
duration?: number;
/** Show close button */
closable?: boolean;
/** Pause on hover */
pauseOnHover?: boolean;
/** Show progress bar */
showProgress?: boolean;
/** Callback when closed */
onClose?: () => void;
/** Callback when clicked */
onClick?: () => void;
/** Additional CSS classes */
className?: string;
/** Custom actions */
actions?: React.ReactNode;
}
const defaultIcons: Record<NotificationType, React.ReactNode> = {
success: (
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
),
error: (
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
),
warning: (
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
</svg>
),
info: (
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" />
</svg>
),
default: (
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
</svg>
),
};
export const Notification: React.FC<NotificationProps> = ({
id,
type = 'default',
title,
message,
icon,
showIcon = true,
duration = 5000,
closable = true,
pauseOnHover = true,
showProgress = false,
onClose,
onClick,
className = '',
actions,
}) => {
const [isVisible, setIsVisible] = useState(true);
const [isPaused, setIsPaused] = useState(false);
const [progress, setProgress] = useState(100);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number>(0);
const remainingRef = useRef<number>(duration);
// Handle close
const handleClose = useCallback(() => {
setIsVisible(false);
setTimeout(() => {
onClose?.();
}, 300); // Allow exit animation
}, [onClose]);
// Timer logic
useEffect(() => {
if (duration <= 0 || !isVisible) return;
const startTimer = () => {
startTimeRef.current = Date.now();
timerRef.current = window.setTimeout(() => {
handleClose();
}, remainingRef.current);
// Progress update
if (showProgress) {
const progressInterval = window.setInterval(() => {
const elapsed = Date.now() - startTimeRef.current;
const newProgress = Math.max(0, ((remainingRef.current - elapsed) / duration) * 100);
setProgress(newProgress);
if (newProgress <= 0) {
clearInterval(progressInterval);
}
}, 50);
return () => clearInterval(progressInterval);
}
};
const pauseTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
remainingRef.current -= Date.now() - startTimeRef.current;
}
};
if (isPaused) {
pauseTimer();
} else {
startTimer();
}
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [duration, isPaused, isVisible, handleClose, showProgress]);
const handleMouseEnter = () => {
if (pauseOnHover) {
setIsPaused(true);
}
};
const handleMouseLeave = () => {
if (pauseOnHover) {
setIsPaused(false);
}
};
const classes = [
'll-notification',
`ll-notification-${type}`,
!isVisible && 'll-notification-exit',
className,
].filter(Boolean).join(' ');
return (
<div
className={classes}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onClick}
role="alert"
aria-live="polite"
>
{/* Icon */}
{showIcon && (
<div className="ll-notification-icon">
{icon || defaultIcons[type]}
</div>
)}
{/* Content */}
<div className="ll-notification-content">
{title && <div className="ll-notification-title">{title}</div>}
<div className="ll-notification-message">{message}</div>
{actions && <div className="ll-notification-actions">{actions}</div>}
</div>
{/* Close button */}
{closable && (
<button
type="button"
className="ll-notification-close"
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
aria-label="Close notification"
>
&times;
</button>
)}
{/* Progress bar */}
{showProgress && duration > 0 && (
<div className="ll-notification-progress">
<div
className="ll-notification-progress-bar"
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
);
};
// Notification Container
export interface NotificationContainerProps {
position?: NotificationPosition;
maxNotifications?: number;
className?: string;
children?: React.ReactNode;
}
export const NotificationContainer: React.FC<NotificationContainerProps> = ({
position = 'top-right',
className = '',
children,
}) => {
const classes = [
'll-notification-container',
`ll-notification-container-${position}`,
className,
].filter(Boolean).join(' ');
return <div className={classes}>{children}</div>;
};
// Notification Manager Hook
export interface NotificationOptions extends Omit<NotificationProps, 'id' | 'onClose'> {}
let notificationId = 0;
export const useNotifications = (position: NotificationPosition = 'top-right') => {
const [notifications, setNotifications] = useState<NotificationProps[]>([]);
const show = useCallback((options: NotificationOptions) => {
const id = `notification-${++notificationId}`;
const notification: NotificationProps = {
...options,
id,
onClose: () => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
},
};
setNotifications((prev) => [...prev, notification]);
return id;
}, []);
const success = useCallback((message: React.ReactNode, options?: Partial<NotificationOptions>) => {
return show({ message, type: 'success', ...options });
}, [show]);
const error = useCallback((message: React.ReactNode, options?: Partial<NotificationOptions>) => {
return show({ message, type: 'error', ...options });
}, [show]);
const warning = useCallback((message: React.ReactNode, options?: Partial<NotificationOptions>) => {
return show({ message, type: 'warning', ...options });
}, [show]);
const info = useCallback((message: React.ReactNode, options?: Partial<NotificationOptions>) => {
return show({ message, type: 'info', ...options });
}, [show]);
const close = useCallback((id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const closeAll = useCallback(() => {
setNotifications([]);
}, []);
const NotificationsRenderer = useCallback(() => (
<NotificationContainer position={position}>
{notifications.map((notification) => (
<Notification key={notification.id} {...notification} />
))}
</NotificationContainer>
), [notifications, position]);
return {
notifications,
show,
success,
error,
warning,
info,
close,
closeAll,
NotificationsRenderer,
};
};
// Static notification API (for imperative use)
type NotificationListener = (notifications: NotificationProps[]) => void;
const listeners: Set<NotificationListener> = new Set();
let staticNotifications: NotificationProps[] = [];
const notify = (notifications: NotificationProps[]) => {
staticNotifications = notifications;
listeners.forEach((listener) => listener(notifications));
};
export const notification = {
show: (options: NotificationOptions) => {
const id = `notification-${++notificationId}`;
const newNotification: NotificationProps = {
...options,
id,
onClose: () => {
notification.close(id);
},
};
notify([...staticNotifications, newNotification]);
return id;
},
success: (message: React.ReactNode, options?: Partial<NotificationOptions>) => {
return notification.show({ message, type: 'success', ...options });
},
error: (message: React.ReactNode, options?: Partial<NotificationOptions>) => {
return notification.show({ message, type: 'error', ...options });
},
warning: (message: React.ReactNode, options?: Partial<NotificationOptions>) => {
return notification.show({ message, type: 'warning', ...options });
},
info: (message: React.ReactNode, options?: Partial<NotificationOptions>) => {
return notification.show({ message, type: 'info', ...options });
},
close: (id: string) => {
notify(staticNotifications.filter((n) => n.id !== id));
},
closeAll: () => {
notify([]);
},
subscribe: (listener: NotificationListener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};

View File

@@ -0,0 +1,208 @@
import React, { useEffect, useRef, useCallback } from 'react';
export interface OffcanvasProps {
/** Whether the offcanvas is open */
isOpen: boolean;
/** Callback when offcanvas should close */
onClose: () => void;
/** Placement of the offcanvas */
placement?: 'start' | 'end' | 'top' | 'bottom';
/** Title for the header */
title?: React.ReactNode;
/** Show backdrop */
backdrop?: boolean | 'static';
/** Allow scrolling body when open */
scroll?: boolean;
/** Enable keyboard (Escape) to close */
keyboard?: boolean;
/** Additional CSS classes */
className?: string;
/** Header CSS classes */
headerClassName?: string;
/** Body CSS classes */
bodyClassName?: string;
/** Children content */
children?: React.ReactNode;
}
export const Offcanvas: React.FC<OffcanvasProps> = ({
isOpen,
onClose,
placement = 'start',
title,
backdrop = true,
scroll = false,
keyboard = true,
className = '',
headerClassName = '',
bodyClassName = '',
children,
}) => {
const offcanvasRef = useRef<HTMLDivElement>(null);
// Handle escape key
useEffect(() => {
if (!keyboard || !isOpen) return;
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [keyboard, isOpen, onClose]);
// Handle body scroll
useEffect(() => {
if (isOpen && !scroll) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen, scroll]);
// Handle backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (backdrop === 'static') return;
if (e.target === e.currentTarget) {
onClose();
}
}, [backdrop, onClose]);
// Focus trap
useEffect(() => {
if (!isOpen) return;
const offcanvas = offcanvasRef.current;
if (!offcanvas) return;
const focusableElements = offcanvas.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement?.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement?.focus();
e.preventDefault();
}
}
};
firstElement?.focus();
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}, [isOpen]);
const classes = [
'll-offcanvas',
`ll-offcanvas-${placement}`,
isOpen && 'll-offcanvas-show',
className,
].filter(Boolean).join(' ');
if (!isOpen && !backdrop) return null;
return (
<>
{/* Backdrop */}
{backdrop && (
<div
className={`ll-offcanvas-backdrop ${isOpen ? 'll-offcanvas-backdrop-show' : ''}`}
onClick={handleBackdropClick}
/>
)}
{/* Offcanvas */}
<div
ref={offcanvasRef}
className={classes}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'll-offcanvas-title' : undefined}
>
{/* Header */}
{title && (
<div className={`ll-offcanvas-header ${headerClassName}`}>
<h5 className="ll-offcanvas-title" id="ll-offcanvas-title">
{title}
</h5>
<button
type="button"
className="ll-offcanvas-close"
onClick={onClose}
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
)}
{/* Body */}
<div className={`ll-offcanvas-body ${bodyClassName}`}>
{children}
</div>
</div>
</>
);
};
export interface OffcanvasHeaderProps {
className?: string;
children?: React.ReactNode;
}
export const OffcanvasHeader: React.FC<OffcanvasHeaderProps> = ({
className = '',
children,
}) => (
<div className={`ll-offcanvas-header ${className}`}>
{children}
</div>
);
export interface OffcanvasTitleProps {
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
className?: string;
children?: React.ReactNode;
}
export const OffcanvasTitle: React.FC<OffcanvasTitleProps> = ({
as: Component = 'h5',
className = '',
children,
}) => (
<Component className={`ll-offcanvas-title ${className}`}>
{children}
</Component>
);
export interface OffcanvasBodyProps {
className?: string;
children?: React.ReactNode;
}
export const OffcanvasBody: React.FC<OffcanvasBodyProps> = ({
className = '',
children,
}) => (
<div className={`ll-offcanvas-body ${className}`}>
{children}
</div>
);

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { Breadcrumbs, BreadcrumbItem } from './Breadcrumbs';
export type PageHeaderProps = {
/** Page title */
title: React.ReactNode;
/** Subtitle shown after title */
subtitle?: React.ReactNode;
/** Icon before title */
icon?: React.ReactNode;
/** Breadcrumb items */
breadcrumbs?: BreadcrumbItem[];
/** Breadcrumb line actions (right side of breadcrumb line) */
breadcrumbActions?: React.ReactNode;
/** Header actions (buttons, dropdowns on right side of page title) */
actions?: React.ReactNode;
/** Additional className */
className?: string;
/** Show breadcrumb line (default true) */
showBreadcrumbLine?: boolean;
};
/**
* Layout 3 page header component.
*
* Structure:
* - breadcrumb-line (optional): breadcrumbs + header-elements
* - page-header-content: page-title + header-elements with actions
*/
export function PageHeader({
title,
subtitle,
icon,
breadcrumbs,
breadcrumbActions,
actions,
className = '',
showBreadcrumbLine = true
}: PageHeaderProps) {
return (
<div className={`page-header ${className}`.trim()}>
{/* Breadcrumb line */}
{showBreadcrumbLine && breadcrumbs && (
<div className="breadcrumb-line breadcrumb-line-light header-elements-md-inline">
<div className="d-flex">
<Breadcrumbs items={breadcrumbs} className="mb-0" />
<a href="#" className="header-elements-toggle text-default d-md-none">
<i className="icon-more"></i>
</a>
</div>
{breadcrumbActions && (
<div className="header-elements d-none">
<div className="breadcrumb justify-content-center">
{breadcrumbActions}
</div>
</div>
)}
</div>
)}
{/* Page header content */}
<div className="page-header-content header-elements-md-inline">
<div className="page-title d-flex">
<h4>
{icon && <span className="me-2">{icon}</span>}
<span className="fw-semibold">{title}</span>
{subtitle && <span> - {subtitle}</span>}
</h4>
<a href="#" className="header-elements-toggle text-default d-md-none">
<i className="icon-more"></i>
</a>
</div>
{actions && (
<div className="header-elements d-none mb-3 mb-md-0">
{actions}
</div>
)}
</div>
</div>
);
}
export type BreadcrumbElementProps = {
icon?: React.ReactNode;
children: React.ReactNode;
href?: string;
};
/**
* Breadcrumb action element for page header.
*/
export function BreadcrumbElement({ icon, children, href = '#' }: BreadcrumbElementProps) {
return (
<a href={href} className="breadcrumb-elements-item">
{icon && <span className="me-2">{icon}</span>}
{children}
</a>
);
}

View File

@@ -0,0 +1,47 @@
import React from 'react';
export type PaginationItem = {
key: React.Key;
label: React.ReactNode;
active?: boolean;
disabled?: boolean;
onClick?: () => void;
};
export type PaginationProps = {
items: PaginationItem[];
size?: 'sm' | 'lg';
className?: string;
ariaLabel?: string;
};
export function Pagination({ items, size, className = '', ariaLabel = 'Pagination' }: PaginationProps) {
const sizeClass = size ? `pagination-${size}` : '';
return (
<nav aria-label={ariaLabel}>
<ul className={['pagination', sizeClass, className].filter(Boolean).join(' ')}>
{items.map(item => (
<li
key={item.key}
className={[
'page-item',
item.active ? 'active' : '',
item.disabled ? 'disabled' : ''
]
.filter(Boolean)
.join(' ')}
>
<button
className="page-link"
type="button"
disabled={item.disabled}
onClick={item.onClick}
>
{item.label}
</button>
</li>
))}
</ul>
</nav>
);
}

232
src/components/Pills.tsx Normal file
View File

@@ -0,0 +1,232 @@
import React, { useState } from 'react';
export interface PillItem {
/** Unique identifier */
id: string;
/** Label text */
label: React.ReactNode;
/** Icon (optional) */
icon?: React.ReactNode;
/** Badge content (optional) */
badge?: React.ReactNode;
/** Badge variant */
badgeVariant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Whether the pill is disabled */
disabled?: boolean;
/** Tab panel content */
content?: React.ReactNode;
}
export interface PillsProps {
/** Pill items */
items: PillItem[];
/** Active pill ID (controlled) */
activeId?: string;
/** Default active pill ID */
defaultActiveId?: string;
/** Callback when active pill changes */
onChange?: (id: string) => void;
/** Layout variant */
variant?: 'pills' | 'pills-toolbar' | 'pills-bordered';
/** Fill available space */
fill?: boolean;
/** Justify content equally */
justified?: boolean;
/** Vertical layout */
vertical?: boolean;
/** Color variant */
color?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Show content panels */
showContent?: boolean;
/** Additional CSS classes for nav */
className?: string;
/** Additional CSS classes for content */
contentClassName?: string;
}
export const Pills: React.FC<PillsProps> = ({
items,
activeId: controlledActiveId,
defaultActiveId,
onChange,
variant = 'pills',
fill = false,
justified = false,
vertical = false,
color = 'primary',
size = 'md',
showContent = true,
className = '',
contentClassName = '',
}) => {
const [internalActiveId, setInternalActiveId] = useState(
defaultActiveId || (items.length > 0 ? items[0].id : '')
);
const activeId = controlledActiveId ?? internalActiveId;
const handleClick = (id: string, disabled?: boolean) => {
if (disabled) return;
if (controlledActiveId === undefined) {
setInternalActiveId(id);
}
onChange?.(id);
};
const navClasses = [
'll-nav',
'll-nav-pills',
variant === 'pills-toolbar' && 'll-nav-pills-toolbar',
variant === 'pills-bordered' && 'll-nav-pills-bordered',
fill && 'll-nav-fill',
justified && 'll-nav-justified',
vertical && 'll-nav-vertical',
`ll-nav-pills-${color}`,
size !== 'md' && `ll-nav-${size}`,
className,
].filter(Boolean).join(' ');
const activeItem = items.find(item => item.id === activeId);
return (
<div className={`ll-pills-container ${vertical ? 'll-pills-vertical' : ''}`}>
{/* Navigation */}
<ul className={navClasses} role="tablist">
{items.map((item) => (
<li key={item.id} className="ll-nav-item" role="presentation">
<button
type="button"
className={`ll-nav-link ${item.id === activeId ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`}
role="tab"
aria-selected={item.id === activeId}
aria-controls={showContent ? `ll-pills-panel-${item.id}` : undefined}
disabled={item.disabled}
onClick={() => handleClick(item.id, item.disabled)}
>
{item.icon && <span className="ll-nav-link-icon">{item.icon}</span>}
<span className="ll-nav-link-text">{item.label}</span>
{item.badge && (
<span className={`ll-badge ll-badge-${item.badgeVariant || 'secondary'} ms-2`}>
{item.badge}
</span>
)}
</button>
</li>
))}
</ul>
{/* Content panels */}
{showContent && (
<div className={`ll-pills-content ${contentClassName}`}>
{items.map((item) => (
<div
key={item.id}
id={`ll-pills-panel-${item.id}`}
className={`ll-pills-panel ${item.id === activeId ? 'active' : ''}`}
role="tabpanel"
aria-labelledby={`ll-pills-tab-${item.id}`}
hidden={item.id !== activeId}
>
{item.content}
</div>
))}
</div>
)}
</div>
);
};
// Individual Pill component for custom layouts
export interface PillProps {
/** Whether the pill is active */
active?: boolean;
/** Whether the pill is disabled */
disabled?: boolean;
/** Color variant */
color?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Click handler */
onClick?: () => void;
/** Additional CSS classes */
className?: string;
/** Children content */
children?: React.ReactNode;
}
export const Pill: React.FC<PillProps> = ({
active = false,
disabled = false,
color = 'primary',
onClick,
className = '',
children,
}) => {
const classes = [
'll-nav-link',
'll-pill',
active && 'active',
disabled && 'disabled',
`ll-pill-${color}`,
className,
].filter(Boolean).join(' ');
return (
<button
type="button"
className={classes}
disabled={disabled}
onClick={onClick}
role="tab"
aria-selected={active}
>
{children}
</button>
);
};
// Pill Nav wrapper
export interface PillNavProps {
/** Fill variant */
fill?: boolean;
/** Justified variant */
justified?: boolean;
/** Vertical layout */
vertical?: boolean;
/** Toolbar style */
toolbar?: boolean;
/** Bordered style */
bordered?: boolean;
/** Additional CSS classes */
className?: string;
/** Children */
children?: React.ReactNode;
}
export const PillNav: React.FC<PillNavProps> = ({
fill = false,
justified = false,
vertical = false,
toolbar = false,
bordered = false,
className = '',
children,
}) => {
const classes = [
'll-nav',
'll-nav-pills',
fill && 'll-nav-fill',
justified && 'll-nav-justified',
vertical && 'll-nav-vertical',
toolbar && 'll-nav-pills-toolbar',
bordered && 'll-nav-pills-bordered',
className,
].filter(Boolean).join(' ');
return (
<ul className={classes} role="tablist">
{children}
</ul>
);
};

View File

@@ -0,0 +1,76 @@
import React, { useState } from 'react';
import { useFloating, offset, shift, flip, arrow, Placement, Middleware } from '@floating-ui/react';
export type PopoverProps = {
title?: React.ReactNode;
content: React.ReactNode;
placement?: Placement;
children: React.ReactElement;
className?: string;
};
/**
* Popover using floating-ui. Controlled by hover/focus.
*/
export function Popover({ title, content, placement = 'top', children, className = '' }: PopoverProps) {
const [open, setOpen] = useState(false);
const [arrowEl, setArrowEl] = useState<HTMLElement | null>(null);
const middleware: Middleware[] = [offset(8), flip(), shift()];
if (arrowEl) middleware.push(arrow({ element: arrowEl }));
const { x, y, refs, strategy, middlewareData, placement: finalPlacement } = useFloating({
open,
onOpenChange: setOpen,
placement,
middleware
});
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right'
}[finalPlacement.split('-')[0]] as string;
return (
<>
{React.cloneElement(children, {
ref: refs.setReference,
onMouseEnter: () => setOpen(true),
onMouseLeave: () => setOpen(false),
onFocus: () => setOpen(true),
onBlur: () => setOpen(false)
})}
{open ? (
<div
ref={refs.setFloating}
className={['popover bs-popover-auto show', className].filter(Boolean).join(' ')}
role="tooltip"
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0
}}
>
{title ? (
<div className="popover-header">
{title}
<button type="button" className="btn-close float-end" aria-label="Close" onClick={() => setOpen(false)} />
</div>
) : null}
<div className="popover-body">{content}</div>
<div
ref={setArrowEl as any}
className="popover-arrow"
style={{
left: middlewareData.arrow?.x,
top: middlewareData.arrow?.y,
[staticSide]: '-4px'
}}
/>
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
export type ProgressProps = {
value: number;
min?: number;
max?: number;
striped?: boolean;
animated?: boolean;
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
height?: string | number;
label?: React.ReactNode;
className?: string;
};
export function Progress({
value,
min = 0,
max = 100,
striped,
animated,
variant = 'primary',
height,
label,
className = ''
}: ProgressProps) {
const percentage = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
const barClasses = [
'progress-bar',
striped ? 'progress-bar-striped' : '',
animated ? 'progress-bar-animated' : '',
variant ? `bg-${variant}` : ''
]
.filter(Boolean)
.join(' ');
return (
<div className={['progress', className].filter(Boolean).join(' ')} style={height ? { height } : undefined}>
<div
className={barClasses}
role="progressbar"
style={{ width: `${percentage}%` }}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
>
{label ?? <span className="visually-hidden">{percentage.toFixed(0)}%</span>}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
export type ProgressStackedProps = {
segments: {
value: number;
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
label?: React.ReactNode;
}[];
min?: number;
max?: number;
height?: string | number;
className?: string;
};
export function ProgressStacked({ segments, min = 0, max = 100, height, className = '' }: ProgressStackedProps) {
const range = max - min || 1;
return (
<div className={['progress', className].filter(Boolean).join(' ')} style={height ? { height } : undefined}>
{segments.map((seg, idx) => {
const pct = Math.max(0, Math.min(100, ((seg.value - min) / range) * 100));
return (
<div
key={idx}
className={['progress-bar', seg.variant ? `bg-${seg.variant}` : ''].filter(Boolean).join(' ')}
style={{ width: `${pct}%` }}
role="progressbar"
aria-valuenow={seg.value}
aria-valuemin={min}
aria-valuemax={max}
>
{seg.label ?? <span className="visually-hidden">{pct.toFixed(0)}%</span>}
</div>
);
})}
</div>
);
}

231
src/components/Rating.tsx Normal file
View File

@@ -0,0 +1,231 @@
import React, { useState, useCallback } from 'react';
export interface RatingProps {
/** Current value (controlled) */
value?: number;
/** Default value */
defaultValue?: number;
/** Maximum rating value */
max?: number;
/** Callback when value changes */
onChange?: (value: number) => void;
/** Allow half values */
allowHalf?: boolean;
/** Allow clearing (clicking same value) */
allowClear?: boolean;
/** Disabled state */
disabled?: boolean;
/** Read-only state */
readOnly?: boolean;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Color */
color?: string;
/** Empty color */
emptyColor?: string;
/** Custom icon */
icon?: React.ReactNode;
/** Custom empty icon */
emptyIcon?: React.ReactNode;
/** Custom half icon */
halfIcon?: React.ReactNode;
/** Character to display (alternative to icon) */
character?: React.ReactNode;
/** Show value text */
showValue?: boolean;
/** Custom value formatter */
formatValue?: (value: number) => string;
/** Tooltips for each value */
tooltips?: string[];
/** Additional CSS classes */
className?: string;
}
const StarIcon: React.FC<{ filled?: boolean; half?: boolean }> = ({ filled, half }) => (
<svg viewBox="0 0 24 24" width="1em" height="1em" fill={filled || half ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
{half ? (
<>
<defs>
<linearGradient id="half-star">
<stop offset="50%" stopColor="currentColor" />
<stop offset="50%" stopColor="transparent" />
</linearGradient>
</defs>
<path fill="url(#half-star)" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</>
) : (
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
)}
</svg>
);
export const Rating: React.FC<RatingProps> = ({
value: controlledValue,
defaultValue = 0,
max = 5,
onChange,
allowHalf = false,
allowClear = true,
disabled = false,
readOnly = false,
size = 'md',
color = '#ffc107',
emptyColor = '#e0e0e0',
icon,
emptyIcon,
halfIcon,
character,
showValue = false,
formatValue = (v) => v.toFixed(allowHalf ? 1 : 0),
tooltips,
className = '',
}) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const [hoverValue, setHoverValue] = useState<number | null>(null);
const value = controlledValue ?? internalValue;
const displayValue = hoverValue ?? value;
const handleClick = useCallback((newValue: number) => {
if (disabled || readOnly) return;
// Allow clear if clicking same value
const finalValue = allowClear && newValue === value ? 0 : newValue;
if (controlledValue === undefined) {
setInternalValue(finalValue);
}
onChange?.(finalValue);
}, [disabled, readOnly, allowClear, value, controlledValue, onChange]);
const handleMouseMove = useCallback((e: React.MouseEvent, index: number) => {
if (disabled || readOnly) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const isHalf = allowHalf && x < rect.width / 2;
setHoverValue(isHalf ? index + 0.5 : index + 1);
}, [disabled, readOnly, allowHalf]);
const handleMouseLeave = () => {
setHoverValue(null);
};
const renderStar = (index: number) => {
const starValue = index + 1;
const isFilled = displayValue >= starValue;
const isHalf = allowHalf && displayValue >= index + 0.5 && displayValue < starValue;
const isActive = !disabled && !readOnly;
let starIcon: React.ReactNode;
if (character) {
starIcon = character;
} else if (isHalf && halfIcon) {
starIcon = halfIcon;
} else if (isFilled && icon) {
starIcon = icon;
} else if (!isFilled && emptyIcon) {
starIcon = emptyIcon;
} else {
starIcon = <StarIcon filled={isFilled} half={isHalf} />;
}
const tooltip = tooltips?.[index];
return (
<span
key={index}
className={`ll-rating-star ${isFilled || isHalf ? 'll-rating-star-filled' : 'll-rating-star-empty'}`}
style={{
color: isFilled || isHalf ? color : emptyColor,
cursor: isActive ? 'pointer' : 'default',
}}
onClick={() => handleClick(allowHalf && isHalf ? index + 0.5 : starValue)}
onMouseMove={(e) => handleMouseMove(e, index)}
title={tooltip}
role="radio"
aria-checked={value === starValue}
>
{starIcon}
</span>
);
};
const classes = [
'll-rating',
`ll-rating-${size}`,
disabled && 'll-rating-disabled',
readOnly && 'll-rating-readonly',
className,
].filter(Boolean).join(' ');
return (
<div
className={classes}
onMouseLeave={handleMouseLeave}
role="radiogroup"
aria-label="Rating"
>
<div className="ll-rating-stars">
{Array.from({ length: max }, (_, i) => renderStar(i))}
</div>
{showValue && (
<span className="ll-rating-value">{formatValue(displayValue)}</span>
)}
</div>
);
};
// Heart Rating variant
export const HeartRating: React.FC<Omit<RatingProps, 'icon' | 'emptyIcon'>> = (props) => {
const HeartIcon = ({ filled }: { filled: boolean }) => (
<svg viewBox="0 0 24 24" width="1em" height="1em" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
return (
<Rating
{...props}
color={props.color || '#e91e63'}
icon={<HeartIcon filled />}
emptyIcon={<HeartIcon filled={false} />}
/>
);
};
// Emoji Rating variant
export interface EmojiRatingProps extends Omit<RatingProps, 'icon' | 'emptyIcon' | 'character' | 'max'> {
emojis?: string[];
}
export const EmojiRating: React.FC<EmojiRatingProps> = ({
emojis = ['😞', '😕', '😐', '🙂', '😄'],
...props
}) => {
const max = emojis.length;
return (
<div className="ll-emoji-rating">
<Rating
{...props}
max={max}
allowHalf={false}
icon={null}
emptyIcon={null}
/>
<div className="ll-emoji-rating-faces">
{emojis.map((emoji, index) => (
<span
key={index}
className={`ll-emoji-rating-face ${(props.value ?? props.defaultValue ?? 0) >= index + 1 ? 'active' : ''}`}
>
{emoji}
</span>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import React, { useEffect, useState } from 'react';
export type ScrollspyItem = {
id: string;
label: React.ReactNode;
};
export type ScrollspyProps = {
items: ScrollspyItem[];
offset?: number;
className?: string;
};
/**
* Simple scrollspy using IntersectionObserver.
* Requires target sections to have matching ids.
*/
export function Scrollspy({ items, offset = 0, className = '' }: ScrollspyProps) {
const [activeId, setActiveId] = useState<string | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: `-${offset}px 0px 0px 0px`, threshold: [0, 0.3, 0.6, 1] }
);
items.forEach(item => {
const el = document.getElementById(item.id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [items, offset]);
return (
<ul className={['nav nav-pills flex-column', className].filter(Boolean).join(' ')}>
{items.map(item => (
<li className="nav-item" key={item.id}>
<a className={`nav-link ${activeId === item.id ? 'active' : ''}`} href={`#${item.id}`}>
{item.label}
</a>
</li>
))}
</ul>
);
}

518
src/components/Slider.tsx Normal file
View File

@@ -0,0 +1,518 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
export interface SliderProps {
/** Current value (controlled) */
value?: number;
/** Default value */
defaultValue?: number;
/** Minimum value */
min?: number;
/** Maximum value */
max?: number;
/** Step increment */
step?: number;
/** Callback when value changes */
onChange?: (value: number) => void;
/** Callback when sliding ends */
onChangeEnd?: (value: number) => void;
/** Show current value tooltip */
showTooltip?: boolean | 'always' | 'hover' | 'drag';
/** Format tooltip value */
formatTooltip?: (value: number) => string;
/** Show tick marks */
showTicks?: boolean;
/** Custom tick values */
ticks?: number[];
/** Show tick labels */
showTickLabels?: boolean;
/** Format tick labels */
formatTickLabel?: (value: number) => string;
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Disabled state */
disabled?: boolean;
/** Orientation */
orientation?: 'horizontal' | 'vertical';
/** Additional CSS classes */
className?: string;
}
export const Slider: React.FC<SliderProps> = ({
value: controlledValue,
defaultValue = 0,
min = 0,
max = 100,
step = 1,
onChange,
onChangeEnd,
showTooltip = 'drag',
formatTooltip = (v) => String(v),
showTicks = false,
ticks,
showTickLabels = false,
formatTickLabel = (v) => String(v),
variant = 'primary',
size = 'md',
disabled = false,
orientation = 'horizontal',
className = '',
}) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const [isDragging, setIsDragging] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
const value = controlledValue ?? internalValue;
// Calculate percentage
const percentage = ((value - min) / (max - min)) * 100;
// Get value from position
const getValueFromPosition = useCallback((clientX: number, clientY: number): number => {
if (!trackRef.current) return value;
const rect = trackRef.current.getBoundingClientRect();
let ratio: number;
if (orientation === 'horizontal') {
ratio = (clientX - rect.left) / rect.width;
} else {
ratio = 1 - (clientY - rect.top) / rect.height;
}
ratio = Math.max(0, Math.min(1, ratio));
let newValue = min + ratio * (max - min);
// Snap to step
newValue = Math.round(newValue / step) * step;
newValue = Math.max(min, Math.min(max, newValue));
return newValue;
}, [min, max, step, value, orientation]);
// Handle mouse/touch move
const handleMove = useCallback((clientX: number, clientY: number) => {
if (disabled) return;
const newValue = getValueFromPosition(clientX, clientY);
if (controlledValue === undefined) {
setInternalValue(newValue);
}
onChange?.(newValue);
}, [disabled, getValueFromPosition, controlledValue, onChange]);
// Handle mouse down
const handleMouseDown = (e: React.MouseEvent) => {
if (disabled) return;
e.preventDefault();
setIsDragging(true);
handleMove(e.clientX, e.clientY);
};
// Handle touch start
const handleTouchStart = (e: React.TouchEvent) => {
if (disabled) return;
setIsDragging(true);
const touch = e.touches[0];
handleMove(touch.clientX, touch.clientY);
};
// Handle mouse/touch events
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
handleMove(e.clientX, e.clientY);
};
const handleTouchMove = (e: TouchEvent) => {
const touch = e.touches[0];
handleMove(touch.clientX, touch.clientY);
};
const handleEnd = () => {
setIsDragging(false);
onChangeEnd?.(value);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleEnd);
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleEnd);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleEnd);
};
}, [isDragging, handleMove, onChangeEnd, value]);
// Handle keyboard
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
let newValue = value;
const largeStep = step * 10;
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
newValue = Math.min(max, value + step);
break;
case 'ArrowLeft':
case 'ArrowDown':
newValue = Math.max(min, value - step);
break;
case 'PageUp':
newValue = Math.min(max, value + largeStep);
break;
case 'PageDown':
newValue = Math.max(min, value - largeStep);
break;
case 'Home':
newValue = min;
break;
case 'End':
newValue = max;
break;
default:
return;
}
e.preventDefault();
if (controlledValue === undefined) {
setInternalValue(newValue);
}
onChange?.(newValue);
onChangeEnd?.(newValue);
};
// Generate tick marks
const tickMarks = ticks || (showTicks ?
Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, i) => min + i * step) :
[]
);
// Determine tooltip visibility
const shouldShowTooltip =
showTooltip === 'always' ||
(showTooltip === 'hover' && (isHovering || isDragging)) ||
(showTooltip === 'drag' && isDragging) ||
(showTooltip === true && (isHovering || isDragging));
const classes = [
'll-slider',
`ll-slider-${orientation}`,
`ll-slider-${variant}`,
`ll-slider-${size}`,
disabled && 'll-slider-disabled',
className,
].filter(Boolean).join(' ');
const trackStyle = orientation === 'horizontal'
? { width: `${percentage}%` }
: { height: `${percentage}%` };
const thumbStyle = orientation === 'horizontal'
? { left: `${percentage}%` }
: { bottom: `${percentage}%` };
return (
<div className={classes}>
<div
ref={trackRef}
className="ll-slider-track-container"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div className="ll-slider-track">
<div className="ll-slider-track-fill" style={trackStyle} />
</div>
{/* Tick marks */}
{tickMarks.length > 0 && (
<div className="ll-slider-ticks">
{tickMarks.map((tick) => {
const tickPercent = ((tick - min) / (max - min)) * 100;
const tickStyle = orientation === 'horizontal'
? { left: `${tickPercent}%` }
: { bottom: `${tickPercent}%` };
return (
<div key={tick} className="ll-slider-tick" style={tickStyle}>
<div className="ll-slider-tick-mark" />
{showTickLabels && (
<div className="ll-slider-tick-label">
{formatTickLabel(tick)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Thumb */}
<div
className="ll-slider-thumb"
style={thumbStyle}
tabIndex={disabled ? -1 : 0}
role="slider"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-orientation={orientation}
aria-disabled={disabled}
onKeyDown={handleKeyDown}
>
{shouldShowTooltip && (
<div className="ll-slider-tooltip">
{formatTooltip(value)}
</div>
)}
</div>
</div>
</div>
);
};
// Range Slider
export interface RangeSliderProps extends Omit<SliderProps, 'value' | 'defaultValue' | 'onChange' | 'onChangeEnd'> {
/** Current values [min, max] (controlled) */
value?: [number, number];
/** Default values [min, max] */
defaultValue?: [number, number];
/** Callback when values change */
onChange?: (value: [number, number]) => void;
/** Callback when sliding ends */
onChangeEnd?: (value: [number, number]) => void;
/** Minimum gap between handles */
minGap?: number;
}
export const RangeSlider: React.FC<RangeSliderProps> = ({
value: controlledValue,
defaultValue = [25, 75],
min = 0,
max = 100,
step = 1,
onChange,
onChangeEnd,
minGap = 0,
showTooltip = 'drag',
formatTooltip = (v) => String(v),
showTicks = false,
ticks,
showTickLabels = false,
formatTickLabel = (v) => String(v),
variant = 'primary',
size = 'md',
disabled = false,
orientation = 'horizontal',
className = '',
}) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const [activeHandle, setActiveHandle] = useState<0 | 1 | null>(null);
const [isHovering, setIsHovering] = useState<0 | 1 | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const value = controlledValue ?? internalValue;
// Calculate percentages
const lowPercent = ((value[0] - min) / (max - min)) * 100;
const highPercent = ((value[1] - min) / (max - min)) * 100;
// Get value from position
const getValueFromPosition = useCallback((clientX: number, clientY: number): number => {
if (!trackRef.current) return 0;
const rect = trackRef.current.getBoundingClientRect();
let ratio: number;
if (orientation === 'horizontal') {
ratio = (clientX - rect.left) / rect.width;
} else {
ratio = 1 - (clientY - rect.top) / rect.height;
}
ratio = Math.max(0, Math.min(1, ratio));
let newValue = min + ratio * (max - min);
newValue = Math.round(newValue / step) * step;
return newValue;
}, [min, max, step, orientation]);
// Handle move
const handleMove = useCallback((clientX: number, clientY: number) => {
if (disabled || activeHandle === null) return;
const newValue = getValueFromPosition(clientX, clientY);
let newValues: [number, number] = [...value];
if (activeHandle === 0) {
newValues[0] = Math.min(newValue, value[1] - minGap);
newValues[0] = Math.max(min, newValues[0]);
} else {
newValues[1] = Math.max(newValue, value[0] + minGap);
newValues[1] = Math.min(max, newValues[1]);
}
if (controlledValue === undefined) {
setInternalValue(newValues);
}
onChange?.(newValues);
}, [disabled, activeHandle, getValueFromPosition, value, minGap, min, max, controlledValue, onChange]);
// Handle track click
const handleTrackClick = (e: React.MouseEvent) => {
if (disabled) return;
const clickValue = getValueFromPosition(e.clientX, e.clientY);
const distToLow = Math.abs(clickValue - value[0]);
const distToHigh = Math.abs(clickValue - value[1]);
const handle = distToLow <= distToHigh ? 0 : 1;
setActiveHandle(handle);
let newValues: [number, number] = [...value];
if (handle === 0) {
newValues[0] = Math.min(clickValue, value[1] - minGap);
newValues[0] = Math.max(min, newValues[0]);
} else {
newValues[1] = Math.max(clickValue, value[0] + minGap);
newValues[1] = Math.min(max, newValues[1]);
}
if (controlledValue === undefined) {
setInternalValue(newValues);
}
onChange?.(newValues);
};
// Handle mouse/touch events
useEffect(() => {
if (activeHandle === null) return;
const handleMouseMove = (e: MouseEvent) => handleMove(e.clientX, e.clientY);
const handleTouchMove = (e: TouchEvent) => handleMove(e.touches[0].clientX, e.touches[0].clientY);
const handleEnd = () => {
setActiveHandle(null);
onChangeEnd?.(value);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleEnd);
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleEnd);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleEnd);
};
}, [activeHandle, handleMove, onChangeEnd, value]);
// Tick marks
const tickMarks = ticks || (showTicks ?
Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, i) => min + i * step) :
[]
);
const classes = [
'll-slider',
'll-range-slider',
`ll-slider-${orientation}`,
`ll-slider-${variant}`,
`ll-slider-${size}`,
disabled && 'll-slider-disabled',
className,
].filter(Boolean).join(' ');
const fillStyle = orientation === 'horizontal'
? { left: `${lowPercent}%`, width: `${highPercent - lowPercent}%` }
: { bottom: `${lowPercent}%`, height: `${highPercent - lowPercent}%` };
const thumbStyles = [
orientation === 'horizontal' ? { left: `${lowPercent}%` } : { bottom: `${lowPercent}%` },
orientation === 'horizontal' ? { left: `${highPercent}%` } : { bottom: `${highPercent}%` },
];
const shouldShowTooltip = (handle: 0 | 1) =>
showTooltip === 'always' ||
(showTooltip === 'hover' && (isHovering === handle || activeHandle === handle)) ||
(showTooltip === 'drag' && activeHandle === handle) ||
(showTooltip === true && (isHovering === handle || activeHandle === handle));
return (
<div className={classes}>
<div
ref={trackRef}
className="ll-slider-track-container"
onClick={handleTrackClick}
>
<div className="ll-slider-track">
<div className="ll-slider-track-fill" style={fillStyle} />
</div>
{/* Tick marks */}
{tickMarks.length > 0 && (
<div className="ll-slider-ticks">
{tickMarks.map((tick) => {
const tickPercent = ((tick - min) / (max - min)) * 100;
const tickStyle = orientation === 'horizontal'
? { left: `${tickPercent}%` }
: { bottom: `${tickPercent}%` };
return (
<div key={tick} className="ll-slider-tick" style={tickStyle}>
<div className="ll-slider-tick-mark" />
{showTickLabels && (
<div className="ll-slider-tick-label">
{formatTickLabel(tick)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Thumbs */}
{[0, 1].map((handle) => (
<div
key={handle}
className={`ll-slider-thumb ${activeHandle === handle ? 'active' : ''}`}
style={thumbStyles[handle]}
tabIndex={disabled ? -1 : 0}
role="slider"
aria-valuemin={handle === 0 ? min : value[0]}
aria-valuemax={handle === 0 ? value[1] : max}
aria-valuenow={value[handle as 0 | 1]}
aria-orientation={orientation}
aria-disabled={disabled}
onMouseDown={(e) => { e.stopPropagation(); setActiveHandle(handle as 0 | 1); }}
onTouchStart={() => setActiveHandle(handle as 0 | 1)}
onMouseEnter={() => setIsHovering(handle as 0 | 1)}
onMouseLeave={() => setIsHovering(null)}
>
{shouldShowTooltip(handle as 0 | 1) && (
<div className="ll-slider-tooltip">
{formatTooltip(value[handle as 0 | 1])}
</div>
)}
</div>
))}
</div>
</div>
);
};

529
src/components/Sortable.tsx Normal file
View File

@@ -0,0 +1,529 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
export interface SortableItem {
id: string;
[key: string]: unknown;
}
export interface SortableProps<T extends SortableItem> {
/** Items to sort */
items: T[];
/** Callback when items are reordered */
onReorder: (items: T[]) => void;
/** Render function for each item */
renderItem: (item: T, index: number, isDragging: boolean) => React.ReactNode;
/** Direction of the list */
direction?: 'vertical' | 'horizontal';
/** Enable drag handle mode (only drag from handle) */
handle?: boolean;
/** Handle selector (CSS class) */
handleClass?: string;
/** Disabled state */
disabled?: boolean;
/** Animation duration in ms */
animationDuration?: number;
/** Ghost element opacity */
ghostOpacity?: number;
/** Callback when drag starts */
onDragStart?: (item: T, index: number) => void;
/** Callback when drag ends */
onDragEnd?: (item: T, fromIndex: number, toIndex: number) => void;
/** Additional CSS classes */
className?: string;
/** Item wrapper CSS classes */
itemClassName?: string;
}
export function Sortable<T extends SortableItem>({
items,
onReorder,
renderItem,
direction = 'vertical',
handle = false,
handleClass = 'll-sortable-handle',
disabled = false,
animationDuration = 200,
ghostOpacity = 0.5,
onDragStart,
onDragEnd,
className = '',
itemClassName = '',
}: SortableProps<T>) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dragItemRef = useRef<T | null>(null);
const handleDragStart = (e: React.DragEvent, item: T, index: number) => {
if (disabled) return;
// Check if drag started from handle
if (handle) {
const target = e.target as HTMLElement;
if (!target.closest(`.${handleClass}`)) {
e.preventDefault();
return;
}
}
dragItemRef.current = item;
setDragIndex(index);
// Set drag data
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.id);
// Create ghost element
const ghost = e.currentTarget.cloneNode(true) as HTMLElement;
ghost.style.opacity = String(ghostOpacity);
ghost.style.position = 'absolute';
ghost.style.top = '-1000px';
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
onDragStart?.(item, index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragIndex !== null && dragIndex !== index) {
setOverIndex(index);
}
};
const handleDragLeave = () => {
setOverIndex(null);
};
const handleDrop = (e: React.DragEvent, toIndex: number) => {
e.preventDefault();
if (dragIndex === null || dragItemRef.current === null) return;
const fromIndex = dragIndex;
const item = dragItemRef.current;
if (fromIndex !== toIndex) {
const newItems = [...items];
newItems.splice(fromIndex, 1);
newItems.splice(toIndex, 0, item);
onReorder(newItems);
onDragEnd?.(item, fromIndex, toIndex);
}
setDragIndex(null);
setOverIndex(null);
dragItemRef.current = null;
};
const handleDragEnd = () => {
setDragIndex(null);
setOverIndex(null);
dragItemRef.current = null;
};
const classes = [
'll-sortable',
`ll-sortable-${direction}`,
disabled && 'll-sortable-disabled',
className,
].filter(Boolean).join(' ');
return (
<div ref={containerRef} className={classes}>
{items.map((item, index) => {
const isDragging = dragIndex === index;
const isOver = overIndex === index;
const itemClasses = [
'll-sortable-item',
isDragging && 'll-sortable-item-dragging',
isOver && 'll-sortable-item-over',
itemClassName,
].filter(Boolean).join(' ');
return (
<div
key={item.id}
className={itemClasses}
draggable={!disabled}
onDragStart={(e) => handleDragStart(e, item, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
style={{
transition: `transform ${animationDuration}ms ease`,
}}
>
{renderItem(item, index, isDragging)}
</div>
);
})}
</div>
);
}
// Drag Handle Component
export interface DragHandleProps {
className?: string;
children?: React.ReactNode;
}
export const DragHandle: React.FC<DragHandleProps> = ({
className = '',
children,
}) => {
return (
<div className={`ll-sortable-handle ${className}`}>
{children || (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M3 15h18v-2H3v2zm0 4h18v-2H3v2zm0-8h18V9H3v2zm0-6v2h18V5H3z" />
</svg>
)}
</div>
);
};
// Sortable List (pre-styled sortable)
export interface SortableListProps<T extends SortableItem> {
items: T[];
onReorder: (items: T[]) => void;
renderContent: (item: T) => React.ReactNode;
showHandle?: boolean;
disabled?: boolean;
className?: string;
}
export function SortableList<T extends SortableItem>({
items,
onReorder,
renderContent,
showHandle = true,
disabled = false,
className = '',
}: SortableListProps<T>) {
return (
<Sortable
items={items}
onReorder={onReorder}
handle={showHandle}
disabled={disabled}
className={`ll-sortable-list ${className}`}
renderItem={(item, _, isDragging) => (
<div className={`ll-sortable-list-item ${isDragging ? 'll-sortable-list-item-dragging' : ''}`}>
{showHandle && <DragHandle />}
<div className="ll-sortable-list-content">
{renderContent(item)}
</div>
</div>
)}
/>
);
}
// Drag and Drop Container
export interface DragDropContainerProps {
/** Unique ID for the container */
id: string;
/** Accept items from these container IDs */
accept?: string[];
/** Callback when item is dropped */
onDrop?: (item: unknown, fromContainerId: string) => void;
/** Children */
children: React.ReactNode;
/** Additional CSS classes */
className?: string;
}
export const DragDropContainer: React.FC<DragDropContainerProps> = ({
id,
accept = [],
onDrop,
children,
className = '',
}) => {
const [isOver, setIsOver] = useState(false);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
const fromContainer = e.dataTransfer.types.includes('application/x-container');
if (fromContainer || accept.length === 0) {
e.dataTransfer.dropEffect = 'move';
setIsOver(true);
}
};
const handleDragLeave = () => {
setIsOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsOver(false);
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'));
if (accept.length === 0 || accept.includes(data.containerId)) {
onDrop?.(data.item, data.containerId);
}
} catch {
// Invalid data
}
};
const classes = [
'll-drag-drop-container',
isOver && 'll-drag-drop-container-over',
className,
].filter(Boolean).join(' ');
return (
<div
className={classes}
data-container-id={id}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{children}
</div>
);
};
// Draggable Item
export interface DraggableProps {
/** Item data */
item: unknown;
/** Container ID this item belongs to */
containerId: string;
/** Disabled state */
disabled?: boolean;
/** Children */
children: React.ReactNode;
/** Callback when drag starts */
onDragStart?: () => void;
/** Callback when drag ends */
onDragEnd?: () => void;
/** Additional CSS classes */
className?: string;
}
export const Draggable: React.FC<DraggableProps> = ({
item,
containerId,
disabled = false,
children,
onDragStart,
onDragEnd,
className = '',
}) => {
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = (e: React.DragEvent) => {
if (disabled) {
e.preventDefault();
return;
}
setIsDragging(true);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/json', JSON.stringify({ item, containerId }));
e.dataTransfer.setData('application/x-container', containerId);
onDragStart?.();
};
const handleDragEnd = () => {
setIsDragging(false);
onDragEnd?.();
};
const classes = [
'll-draggable',
isDragging && 'll-draggable-dragging',
disabled && 'll-draggable-disabled',
className,
].filter(Boolean).join(' ');
return (
<div
className={classes}
draggable={!disabled}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{children}
</div>
);
};
// Kanban Board Component
export interface KanbanColumn<T> {
id: string;
title: string;
items: T[];
}
export interface KanbanBoardProps<T extends SortableItem> {
/** Columns */
columns: KanbanColumn<T>[];
/** Callback when items are moved */
onMove: (
item: T,
fromColumnId: string,
toColumnId: string,
fromIndex: number,
toIndex: number
) => void;
/** Render function for items */
renderItem: (item: T, columnId: string) => React.ReactNode;
/** Render function for column header */
renderColumnHeader?: (column: KanbanColumn<T>) => React.ReactNode;
/** Allow reordering within columns */
allowReorder?: boolean;
/** Additional CSS classes */
className?: string;
}
export function KanbanBoard<T extends SortableItem>({
columns,
onMove,
renderItem,
renderColumnHeader,
allowReorder = true,
className = '',
}: KanbanBoardProps<T>) {
const [dragInfo, setDragInfo] = useState<{
item: T;
columnId: string;
index: number;
} | null>(null);
const handleDragStart = (item: T, columnId: string, index: number) => {
setDragInfo({ item, columnId, index });
};
const handleDrop = (toColumnId: string, toIndex: number) => {
if (!dragInfo) return;
const { item, columnId: fromColumnId, index: fromIndex } = dragInfo;
if (fromColumnId !== toColumnId || fromIndex !== toIndex) {
onMove(item, fromColumnId, toColumnId, fromIndex, toIndex);
}
setDragInfo(null);
};
return (
<div className={`ll-kanban-board ${className}`}>
{columns.map((column) => (
<div key={column.id} className="ll-kanban-column">
<div className="ll-kanban-column-header">
{renderColumnHeader ? (
renderColumnHeader(column)
) : (
<>
<span className="ll-kanban-column-title">{column.title}</span>
<span className="ll-kanban-column-count">{column.items.length}</span>
</>
)}
</div>
<div
className="ll-kanban-column-content"
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDrop={() => handleDrop(column.id, column.items.length)}
>
{column.items.map((item, index) => (
<div
key={item.id}
className={`ll-kanban-item ${dragInfo?.item.id === item.id ? 'll-kanban-item-dragging' : ''}`}
draggable
onDragStart={() => handleDragStart(item, column.id, index)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDrop={(e) => {
e.stopPropagation();
handleDrop(column.id, index);
}}
>
{renderItem(item, column.id)}
</div>
))}
{column.items.length === 0 && (
<div className="ll-kanban-empty">
Drop items here
</div>
)}
</div>
</div>
))}
</div>
);
}
// Hook for drag and drop state management
export interface UseDragDropOptions {
onDragStart?: (data: unknown) => void;
onDragEnd?: () => void;
onDrop?: (data: unknown) => void;
}
export const useDragDrop = (options: UseDragDropOptions = {}) => {
const [isDragging, setIsDragging] = useState(false);
const [isOver, setIsOver] = useState(false);
const dragDataRef = useRef<unknown>(null);
const dragProps = {
draggable: true,
onDragStart: (e: React.DragEvent, data: unknown) => {
setIsDragging(true);
dragDataRef.current = data;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify(data));
options.onDragStart?.(data);
},
onDragEnd: () => {
setIsDragging(false);
dragDataRef.current = null;
options.onDragEnd?.();
},
};
const dropProps = {
onDragOver: (e: React.DragEvent) => {
e.preventDefault();
setIsOver(true);
},
onDragLeave: () => {
setIsOver(false);
},
onDrop: (e: React.DragEvent) => {
e.preventDefault();
setIsOver(false);
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
options.onDrop?.(data);
} catch {
// Invalid data
}
},
};
return {
isDragging,
isOver,
dragProps,
dropProps,
};
};

130
src/components/Spinner.tsx Normal file
View File

@@ -0,0 +1,130 @@
import React from 'react';
export interface SpinnerProps {
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Spinner type */
type?: 'border' | 'grow';
/** Additional CSS classes */
className?: string;
/** Screen reader text */
srText?: string;
}
const sizeClasses = {
sm: 'll-spinner-sm',
md: '',
lg: 'll-spinner-lg',
};
const variantClasses = {
primary: 'll-spinner-primary',
secondary: 'll-spinner-secondary',
success: 'll-spinner-success',
danger: 'll-spinner-danger',
warning: 'll-spinner-warning',
info: 'll-spinner-info',
light: 'll-spinner-light',
dark: 'll-spinner-dark',
};
export const Spinner: React.FC<SpinnerProps> = ({
size = 'md',
variant = 'primary',
type = 'border',
className = '',
srText = 'Loading...',
}) => {
const baseClass = type === 'border' ? 'll-spinner-border' : 'll-spinner-grow';
const classes = [
baseClass,
sizeClasses[size],
variantClasses[variant],
className,
].filter(Boolean).join(' ');
return (
<div className={classes} role="status">
<span className="visually-hidden">{srText}</span>
</div>
);
};
export interface SpinnerOverlayProps {
/** Show the overlay */
show?: boolean;
/** Spinner props */
spinnerProps?: SpinnerProps;
/** Overlay text */
text?: string;
/** Additional CSS classes */
className?: string;
/** Children to overlay */
children?: React.ReactNode;
}
export const SpinnerOverlay: React.FC<SpinnerOverlayProps> = ({
show = true,
spinnerProps,
text,
className = '',
children,
}) => {
if (!show) return <>{children}</>;
return (
<div className={`ll-spinner-overlay-container ${className}`}>
{children}
<div className="ll-spinner-overlay">
<div className="ll-spinner-overlay-content">
<Spinner {...spinnerProps} />
{text && <span className="ll-spinner-overlay-text">{text}</span>}
</div>
</div>
</div>
);
};
export interface BlockUIProps {
/** Block the content */
blocked?: boolean;
/** Spinner props */
spinnerProps?: SpinnerProps;
/** Message to display */
message?: string;
/** Template for custom content */
template?: React.ReactNode;
/** Additional CSS classes */
className?: string;
/** Children to block */
children?: React.ReactNode;
}
export const BlockUI: React.FC<BlockUIProps> = ({
blocked = false,
spinnerProps,
message,
template,
className = '',
children,
}) => {
return (
<div className={`ll-blockui-container ${blocked ? 'll-blockui-blocked' : ''} ${className}`}>
{children}
{blocked && (
<div className="ll-blockui-overlay">
<div className="ll-blockui-content">
{template || (
<>
<Spinner {...spinnerProps} />
{message && <span className="ll-blockui-message">{message}</span>}
</>
)}
</div>
</div>
)}
</div>
);
};

285
src/components/Stepper.tsx Normal file
View File

@@ -0,0 +1,285 @@
import React from 'react';
export interface StepItem {
/** Unique identifier */
id: string;
/** Step title */
title: React.ReactNode;
/** Step description */
description?: React.ReactNode;
/** Custom icon */
icon?: React.ReactNode;
/** Whether step is disabled */
disabled?: boolean;
/** Custom status */
status?: 'wait' | 'process' | 'finish' | 'error';
/** Step content (for vertical stepper) */
content?: React.ReactNode;
}
export interface StepperProps {
/** Step items */
items: StepItem[];
/** Current active step index */
current?: number;
/** Callback when step is clicked */
onChange?: (index: number) => void;
/** Orientation */
orientation?: 'horizontal' | 'vertical';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Show step numbers instead of icons */
showNumbers?: boolean;
/** Allow clicking on steps */
clickable?: boolean;
/** Alternative label position (horizontal only) */
alternativeLabel?: boolean;
/** Connector style */
connector?: 'line' | 'arrow' | 'dashed' | 'none';
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
/** Show content inline (vertical only) */
showContent?: boolean;
/** Additional CSS classes */
className?: string;
}
const CheckIcon = () => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
);
const ErrorIcon = () => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
);
export const Stepper: React.FC<StepperProps> = ({
items,
current = 0,
onChange,
orientation = 'horizontal',
size = 'md',
showNumbers = true,
clickable = false,
alternativeLabel = false,
connector = 'line',
variant = 'primary',
showContent = false,
className = '',
}) => {
const getStepStatus = (index: number, item: StepItem): 'wait' | 'process' | 'finish' | 'error' => {
if (item.status) return item.status;
if (index < current) return 'finish';
if (index === current) return 'process';
return 'wait';
};
const handleStepClick = (index: number, item: StepItem) => {
if (!clickable || item.disabled) return;
onChange?.(index);
};
const classes = [
'll-stepper',
`ll-stepper-${orientation}`,
`ll-stepper-${size}`,
`ll-stepper-${variant}`,
alternativeLabel && orientation === 'horizontal' && 'll-stepper-alternative',
connector !== 'line' && `ll-stepper-connector-${connector}`,
className,
].filter(Boolean).join(' ');
return (
<div className={classes}>
{items.map((item, index) => {
const status = getStepStatus(index, item);
const isLast = index === items.length - 1;
const stepClasses = [
'll-step',
`ll-step-${status}`,
item.disabled && 'll-step-disabled',
clickable && !item.disabled && 'll-step-clickable',
].filter(Boolean).join(' ');
const renderIcon = () => {
if (item.icon) {
return <span className="ll-step-custom-icon">{item.icon}</span>;
}
if (status === 'finish') {
return <CheckIcon />;
}
if (status === 'error') {
return <ErrorIcon />;
}
if (showNumbers) {
return <span className="ll-step-number">{index + 1}</span>;
}
return <span className="ll-step-dot" />;
};
return (
<React.Fragment key={item.id}>
<div
className={stepClasses}
onClick={() => handleStepClick(index, item)}
role={clickable ? 'button' : undefined}
tabIndex={clickable && !item.disabled ? 0 : undefined}
aria-current={status === 'process' ? 'step' : undefined}
>
{/* Step indicator */}
<div className="ll-step-indicator">
<div className={`ll-step-icon ll-step-icon-${status}`}>
{renderIcon()}
</div>
</div>
{/* Step content */}
<div className="ll-step-content">
<div className="ll-step-title">{item.title}</div>
{item.description && (
<div className="ll-step-description">{item.description}</div>
)}
</div>
{/* Step panel content (vertical) */}
{orientation === 'vertical' && showContent && item.content && (
<div className={`ll-step-panel ${status === 'process' ? 'll-step-panel-active' : ''}`}>
{item.content}
</div>
)}
</div>
{/* Connector */}
{!isLast && connector !== 'none' && (
<div className={`ll-step-connector ll-step-connector-${index < current ? 'completed' : 'pending'}`}>
{connector === 'arrow' && (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
)}
</div>
)}
</React.Fragment>
);
})}
</div>
);
};
// Step navigation helpers
export interface StepperNavigationProps {
/** Current step index */
current: number;
/** Total steps */
total: number;
/** Go to previous step */
onPrev?: () => void;
/** Go to next step */
onNext?: () => void;
/** Finish action */
onFinish?: () => void;
/** Previous button text */
prevText?: string;
/** Next button text */
nextText?: string;
/** Finish button text */
finishText?: string;
/** Disable previous button */
disablePrev?: boolean;
/** Disable next button */
disableNext?: boolean;
/** Button variant */
variant?: 'primary' | 'secondary';
/** Additional CSS classes */
className?: string;
}
export const StepperNavigation: React.FC<StepperNavigationProps> = ({
current,
total,
onPrev,
onNext,
onFinish,
prevText = 'Previous',
nextText = 'Next',
finishText = 'Finish',
disablePrev = false,
disableNext = false,
variant = 'primary',
className = '',
}) => {
const isFirst = current === 0;
const isLast = current === total - 1;
return (
<div className={`ll-stepper-nav ${className}`}>
<button
type="button"
className="ll-stepper-nav-btn ll-stepper-nav-prev"
onClick={onPrev}
disabled={isFirst || disablePrev}
>
{prevText}
</button>
{isLast ? (
<button
type="button"
className={`ll-stepper-nav-btn ll-stepper-nav-finish ll-btn-${variant}`}
onClick={onFinish}
disabled={disableNext}
>
{finishText}
</button>
) : (
<button
type="button"
className={`ll-stepper-nav-btn ll-stepper-nav-next ll-btn-${variant}`}
onClick={onNext}
disabled={disableNext}
>
{nextText}
</button>
)}
</div>
);
};
// Hook for stepper state management
export const useStepper = (totalSteps: number, initialStep = 0) => {
const [current, setCurrent] = React.useState(initialStep);
const next = () => {
setCurrent((prev) => Math.min(prev + 1, totalSteps - 1));
};
const prev = () => {
setCurrent((prev) => Math.max(prev - 1, 0));
};
const goTo = (step: number) => {
setCurrent(Math.max(0, Math.min(step, totalSteps - 1)));
};
const reset = () => {
setCurrent(initialStep);
};
return {
current,
isFirst: current === 0,
isLast: current === totalSteps - 1,
next,
prev,
goTo,
reset,
};
};

View File

@@ -0,0 +1,360 @@
import React, { useEffect, useRef, useCallback } from 'react';
export type SweetAlertType = 'success' | 'error' | 'warning' | 'info' | 'question';
export interface SweetAlertButton {
text: string;
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
onClick?: () => void | Promise<void>;
closeOnClick?: boolean;
}
export interface SweetAlertProps {
/** Whether the alert is visible */
isOpen: boolean;
/** Callback when alert should close */
onClose: () => void;
/** Alert type/icon */
type?: SweetAlertType;
/** Title text */
title?: React.ReactNode;
/** Body text/content */
text?: React.ReactNode;
/** Custom content */
children?: React.ReactNode;
/** HTML content (use with caution) */
html?: string;
/** Confirm button config */
confirmButton?: SweetAlertButton | boolean;
/** Cancel button config */
cancelButton?: SweetAlertButton | boolean;
/** Custom buttons */
buttons?: SweetAlertButton[];
/** Show close button (X) */
showCloseButton?: boolean;
/** Allow clicking backdrop to close */
allowOutsideClick?: boolean;
/** Allow escape key to close */
allowEscapeKey?: boolean;
/** Custom icon content */
icon?: React.ReactNode;
/** Image URL */
imageUrl?: string;
/** Image alt text */
imageAlt?: string;
/** Footer content */
footer?: React.ReactNode;
/** Timer to auto-close (ms) */
timer?: number;
/** Show timer progress bar */
timerProgressBar?: boolean;
/** On confirm callback */
onConfirm?: () => void | Promise<void>;
/** On cancel callback */
onCancel?: () => void;
/** Additional CSS classes */
className?: string;
}
const iconMap: Record<SweetAlertType, React.ReactNode> = {
success: (
<div className="ll-swal-icon ll-swal-icon-success">
<span className="ll-swal-icon-line ll-swal-icon-line-tip" />
<span className="ll-swal-icon-line ll-swal-icon-line-long" />
<div className="ll-swal-icon-ring" />
</div>
),
error: (
<div className="ll-swal-icon ll-swal-icon-error">
<span className="ll-swal-icon-x-mark">
<span className="ll-swal-icon-line ll-swal-icon-line-left" />
<span className="ll-swal-icon-line ll-swal-icon-line-right" />
</span>
<div className="ll-swal-icon-ring" />
</div>
),
warning: (
<div className="ll-swal-icon ll-swal-icon-warning">
<span className="ll-swal-icon-body" />
<span className="ll-swal-icon-dot" />
<div className="ll-swal-icon-ring" />
</div>
),
info: (
<div className="ll-swal-icon ll-swal-icon-info">
<span className="ll-swal-icon-body" />
<span className="ll-swal-icon-dot" />
<div className="ll-swal-icon-ring" />
</div>
),
question: (
<div className="ll-swal-icon ll-swal-icon-question">
<span className="ll-swal-icon-question-mark">?</span>
<div className="ll-swal-icon-ring" />
</div>
),
};
export const SweetAlert: React.FC<SweetAlertProps> = ({
isOpen,
onClose,
type,
title,
text,
children,
html,
confirmButton = true,
cancelButton = false,
buttons,
showCloseButton = false,
allowOutsideClick = true,
allowEscapeKey = true,
icon,
imageUrl,
imageAlt,
footer,
timer,
timerProgressBar = false,
onConfirm,
onCancel,
className = '',
}) => {
const alertRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<number | null>(null);
const progressRef = useRef<HTMLDivElement>(null);
// Handle escape key
useEffect(() => {
if (!allowEscapeKey || !isOpen) return;
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel?.();
onClose();
}
};
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [allowEscapeKey, isOpen, onClose, onCancel]);
// Handle timer
useEffect(() => {
if (!timer || !isOpen) return;
const startTime = Date.now();
// Update progress bar
const updateProgress = () => {
if (progressRef.current) {
const elapsed = Date.now() - startTime;
const progress = Math.min((elapsed / timer) * 100, 100);
progressRef.current.style.width = `${100 - progress}%`;
}
};
const progressInterval = timerProgressBar
? window.setInterval(updateProgress, 10)
: null;
timerRef.current = window.setTimeout(() => {
onClose();
}, timer);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (progressInterval) clearInterval(progressInterval);
};
}, [timer, timerProgressBar, isOpen, onClose]);
// Handle body scroll
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Handle backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (!allowOutsideClick) return;
if (e.target === e.currentTarget) {
onCancel?.();
onClose();
}
}, [allowOutsideClick, onClose, onCancel]);
// Handle confirm
const handleConfirm = useCallback(async () => {
await onConfirm?.();
onClose();
}, [onConfirm, onClose]);
// Handle cancel
const handleCancel = useCallback(() => {
onCancel?.();
onClose();
}, [onCancel, onClose]);
// Build button config
const getButtonConfig = (btn: SweetAlertButton | boolean, defaultText: string, defaultVariant: string): SweetAlertButton | null => {
if (btn === false) return null;
if (btn === true) return { text: defaultText, variant: defaultVariant as SweetAlertButton['variant'] };
return btn;
};
const confirmBtnConfig = getButtonConfig(confirmButton, 'OK', 'primary');
const cancelBtnConfig = getButtonConfig(cancelButton, 'Cancel', 'secondary');
if (!isOpen) return null;
return (
<div className="ll-swal-overlay" onClick={handleBackdropClick}>
<div ref={alertRef} className={`ll-swal-container ${className}`} role="dialog" aria-modal="true">
{/* Timer progress bar */}
{timerProgressBar && timer && (
<div className="ll-swal-timer-progress">
<div ref={progressRef} className="ll-swal-timer-progress-bar" style={{ width: '100%' }} />
</div>
)}
{/* Close button */}
{showCloseButton && (
<button
type="button"
className="ll-swal-close"
onClick={handleCancel}
aria-label="Close"
>
&times;
</button>
)}
{/* Icon */}
{(icon || type) && (
<div className="ll-swal-icon-container">
{icon || (type && iconMap[type])}
</div>
)}
{/* Image */}
{imageUrl && (
<div className="ll-swal-image">
<img src={imageUrl} alt={imageAlt || ''} />
</div>
)}
{/* Title */}
{title && (
<h2 className="ll-swal-title">{title}</h2>
)}
{/* Content */}
{(text || html || children) && (
<div className="ll-swal-content">
{html ? (
<div dangerouslySetInnerHTML={{ __html: html }} />
) : (
text || children
)}
</div>
)}
{/* Buttons */}
<div className="ll-swal-actions">
{buttons ? (
buttons.map((btn, index) => (
<button
key={index}
type="button"
className={`ll-swal-btn ll-swal-btn-${btn.variant || 'primary'}`}
onClick={async () => {
await btn.onClick?.();
if (btn.closeOnClick !== false) onClose();
}}
>
{btn.text}
</button>
))
) : (
<>
{cancelBtnConfig && (
<button
type="button"
className={`ll-swal-btn ll-swal-btn-${cancelBtnConfig.variant || 'secondary'}`}
onClick={async () => {
await cancelBtnConfig.onClick?.();
handleCancel();
}}
>
{cancelBtnConfig.text}
</button>
)}
{confirmBtnConfig && (
<button
type="button"
className={`ll-swal-btn ll-swal-btn-${confirmBtnConfig.variant || 'primary'}`}
onClick={async () => {
await confirmBtnConfig.onClick?.();
handleConfirm();
}}
>
{confirmBtnConfig.text}
</button>
)}
</>
)}
</div>
{/* Footer */}
{footer && (
<div className="ll-swal-footer">
{footer}
</div>
)}
</div>
</div>
);
};
// Utility function to show alerts imperatively
export interface SweetAlertOptions extends Omit<SweetAlertProps, 'isOpen' | 'onClose'> {}
let alertRoot: HTMLDivElement | null = null;
let setAlertState: ((state: { isOpen: boolean; options: SweetAlertOptions }) => void) | null = null;
export const showAlert = (options: SweetAlertOptions): Promise<boolean> => {
return new Promise((resolve) => {
// This is a simplified implementation
// In a real app, you'd want to use a proper portal/context-based approach
const originalOnConfirm = options.onConfirm;
const originalOnCancel = options.onCancel;
if (setAlertState !== null) {
(setAlertState as (state: { isOpen: boolean; options: SweetAlertOptions }) => void)({
isOpen: true,
options: {
...options,
onConfirm: async () => {
await originalOnConfirm?.();
resolve(true);
},
onCancel: () => {
originalOnCancel?.();
resolve(false);
},
},
});
}
});
};
export const closeAlert = () => {
if (setAlertState !== null) {
(setAlertState as (state: { isOpen: boolean; options: SweetAlertOptions }) => void)({ isOpen: false, options: {} });
}
};

View File

@@ -0,0 +1,501 @@
import React, { useState, useMemo, useCallback } from 'react';
export type Language =
| 'javascript'
| 'typescript'
| 'jsx'
| 'tsx'
| 'html'
| 'css'
| 'scss'
| 'json'
| 'xml'
| 'markdown'
| 'python'
| 'java'
| 'csharp'
| 'cpp'
| 'go'
| 'rust'
| 'php'
| 'ruby'
| 'swift'
| 'kotlin'
| 'sql'
| 'bash'
| 'shell'
| 'yaml'
| 'plaintext';
export interface SyntaxHighlighterProps {
/** Code to highlight */
code: string;
/** Programming language */
language?: Language;
/** Show line numbers */
showLineNumbers?: boolean;
/** Starting line number */
startingLineNumber?: number;
/** Highlight specific lines */
highlightLines?: number[];
/** Theme */
theme?: 'light' | 'dark' | 'github' | 'monokai' | 'dracula' | 'nord';
/** Show copy button */
showCopyButton?: boolean;
/** Custom copy button text */
copyButtonText?: string;
/** Copied button text */
copiedButtonText?: string;
/** Wrap long lines */
wrapLines?: boolean;
/** Max height (scrollable) */
maxHeight?: number | string;
/** Show language label */
showLanguage?: boolean;
/** Callback after copy */
onCopy?: (code: string) => void;
/** Additional CSS classes */
className?: string;
}
// Token types for syntax highlighting
type TokenType =
| 'keyword'
| 'string'
| 'number'
| 'comment'
| 'function'
| 'operator'
| 'punctuation'
| 'variable'
| 'tag'
| 'attribute'
| 'property'
| 'selector'
| 'class'
| 'builtin'
| 'plain';
interface Token {
type: TokenType;
content: string;
}
// Language-specific keyword sets
const KEYWORDS: Record<string, string[]> = {
javascript: [
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
'do', 'switch', 'case', 'break', 'continue', 'new', 'this', 'class',
'extends', 'import', 'export', 'from', 'default', 'async', 'await',
'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of',
'true', 'false', 'null', 'undefined', 'void', 'delete', 'yield', 'static',
'get', 'set', 'super', 'constructor', 'debugger', 'with',
],
typescript: [
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while',
'do', 'switch', 'case', 'break', 'continue', 'new', 'this', 'class',
'extends', 'import', 'export', 'from', 'default', 'async', 'await',
'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of',
'true', 'false', 'null', 'undefined', 'void', 'delete', 'yield', 'static',
'interface', 'type', 'enum', 'implements', 'private', 'public', 'protected',
'readonly', 'abstract', 'as', 'is', 'keyof', 'never', 'any', 'unknown',
'namespace', 'module', 'declare', 'infer', 'asserts',
],
python: [
'def', 'class', 'return', 'if', 'elif', 'else', 'for', 'while', 'break',
'continue', 'pass', 'import', 'from', 'as', 'try', 'except', 'finally',
'raise', 'with', 'assert', 'yield', 'lambda', 'global', 'nonlocal',
'True', 'False', 'None', 'and', 'or', 'not', 'in', 'is', 'del', 'async', 'await',
],
java: [
'public', 'private', 'protected', 'static', 'final', 'abstract', 'class',
'interface', 'extends', 'implements', 'return', 'if', 'else', 'for', 'while',
'do', 'switch', 'case', 'break', 'continue', 'new', 'this', 'super',
'try', 'catch', 'finally', 'throw', 'throws', 'import', 'package',
'void', 'int', 'long', 'double', 'float', 'boolean', 'char', 'byte', 'short',
'true', 'false', 'null', 'instanceof', 'enum', 'synchronized', 'volatile',
],
css: [
'important', 'inherit', 'initial', 'unset', 'none', 'auto', 'block',
'inline', 'flex', 'grid', 'absolute', 'relative', 'fixed', 'sticky',
],
sql: [
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'INSERT', 'INTO', 'VALUES',
'UPDATE', 'SET', 'DELETE', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'INDEX',
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP', 'BY', 'ORDER',
'ASC', 'DESC', 'HAVING', 'LIMIT', 'OFFSET', 'UNION', 'ALL', 'DISTINCT',
'AS', 'NULL', 'IS', 'IN', 'LIKE', 'BETWEEN', 'EXISTS', 'CASE', 'WHEN',
'THEN', 'ELSE', 'END', 'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES',
],
};
// Simple tokenizer
const tokenize = (code: string, language: Language): Token[] => {
const tokens: Token[] = [];
const keywords = KEYWORDS[language] || KEYWORDS.javascript || [];
// Patterns for different languages
const patterns: { pattern: RegExp; type: TokenType }[] = [];
// Comments
if (['javascript', 'typescript', 'jsx', 'tsx', 'java', 'csharp', 'cpp', 'go', 'rust', 'swift', 'kotlin', 'css', 'scss', 'php'].includes(language)) {
patterns.push({ pattern: /\/\/[^\n]*|\/\*[\s\S]*?\*\//g, type: 'comment' });
}
if (['python', 'ruby', 'bash', 'shell', 'yaml'].includes(language)) {
patterns.push({ pattern: /#[^\n]*/g, type: 'comment' });
}
if (['html', 'xml', 'markdown'].includes(language)) {
patterns.push({ pattern: /<!--[\s\S]*?-->/g, type: 'comment' });
}
// Strings
patterns.push({ pattern: /(["'`])(?:(?!\1)[^\\]|\\.)*\1/g, type: 'string' });
// Numbers
patterns.push({ pattern: /\b\d+\.?\d*(?:[eE][+-]?\d+)?\b/g, type: 'number' });
patterns.push({ pattern: /\b0x[0-9a-fA-F]+\b/g, type: 'number' });
// HTML/XML tags
if (['html', 'xml', 'jsx', 'tsx'].includes(language)) {
patterns.push({ pattern: /<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[^>]*)?\/?>/g, type: 'tag' });
}
// CSS selectors
if (['css', 'scss'].includes(language)) {
patterns.push({ pattern: /[.#][a-zA-Z_][a-zA-Z0-9_-]*/g, type: 'selector' });
patterns.push({ pattern: /@[a-zA-Z]+/g, type: 'keyword' });
}
// Simple word-based tokenization
let remaining = code;
let position = 0;
while (remaining.length > 0) {
let matched = false;
// Try each pattern
for (const { pattern, type } of patterns) {
pattern.lastIndex = 0;
const match = pattern.exec(remaining);
if (match && match.index === 0) {
tokens.push({ type, content: match[0] });
remaining = remaining.slice(match[0].length);
position += match[0].length;
matched = true;
break;
}
}
if (!matched) {
// Check for keywords and identifiers
const wordMatch = remaining.match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/);
if (wordMatch) {
const word = wordMatch[0];
const type = keywords.includes(word) || keywords.includes(word.toUpperCase())
? 'keyword'
: /^[A-Z]/.test(word)
? 'class'
: 'plain';
tokens.push({ type, content: word });
remaining = remaining.slice(word.length);
position += word.length;
} else {
// Operators and punctuation
const opMatch = remaining.match(/^[+\-*/%=<>!&|^~?:;,.()[\]{}]+/);
if (opMatch) {
tokens.push({ type: 'operator', content: opMatch[0] });
remaining = remaining.slice(opMatch[0].length);
position += opMatch[0].length;
} else {
// Single character (whitespace or unknown)
tokens.push({ type: 'plain', content: remaining[0] });
remaining = remaining.slice(1);
position += 1;
}
}
}
}
return tokens;
};
export const SyntaxHighlighter: React.FC<SyntaxHighlighterProps> = ({
code,
language = 'plaintext',
showLineNumbers = true,
startingLineNumber = 1,
highlightLines = [],
theme = 'dark',
showCopyButton = true,
copyButtonText = 'Copy',
copiedButtonText = 'Copied!',
wrapLines = false,
maxHeight,
showLanguage = true,
onCopy,
className = '',
}) => {
const [copied, setCopied] = useState(false);
// Tokenize and render code
const renderedCode = useMemo(() => {
const lines = code.split('\n');
const highlightSet = new Set(highlightLines);
return lines.map((line, index) => {
const lineNumber = startingLineNumber + index;
const isHighlighted = highlightSet.has(lineNumber);
const tokens = tokenize(line, language);
return {
lineNumber,
isHighlighted,
tokens,
};
});
}, [code, language, highlightLines, startingLineNumber]);
// Copy to clipboard
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
onCopy?.(code);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}, [code, onCopy]);
const classes = [
'll-syntax-highlighter',
`ll-syntax-theme-${theme}`,
wrapLines && 'll-syntax-wrap',
className,
].filter(Boolean).join(' ');
return (
<div className={classes}>
{/* Header */}
{(showLanguage || showCopyButton) && (
<div className="ll-syntax-header">
{showLanguage && (
<span className="ll-syntax-language">{language}</span>
)}
{showCopyButton && (
<button
type="button"
className={`ll-syntax-copy ${copied ? 'll-syntax-copied' : ''}`}
onClick={handleCopy}
>
{copied ? (
<>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
{copiedButtonText}
</>
) : (
<>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
{copyButtonText}
</>
)}
</button>
)}
</div>
)}
{/* Code */}
<div
className="ll-syntax-code"
style={{ maxHeight: maxHeight ? `${maxHeight}px` : undefined }}
>
<pre>
<code>
{renderedCode.map(({ lineNumber, isHighlighted, tokens }) => (
<div
key={lineNumber}
className={`ll-syntax-line ${isHighlighted ? 'll-syntax-line-highlighted' : ''}`}
>
{showLineNumbers && (
<span className="ll-syntax-line-number">{lineNumber}</span>
)}
<span className="ll-syntax-line-content">
{tokens.map((token, i) => (
<span key={i} className={`ll-syntax-token ll-syntax-${token.type}`}>
{token.content}
</span>
))}
</span>
</div>
))}
</code>
</pre>
</div>
</div>
);
};
// Code Block Component (simpler version)
export interface CodeBlockProps {
/** Code content */
children: string;
/** Language */
language?: Language;
/** Title/filename */
title?: string;
/** Show copy button */
copyable?: boolean;
/** Additional CSS classes */
className?: string;
}
export const CodeBlock: React.FC<CodeBlockProps> = ({
children,
language = 'plaintext',
title,
copyable = true,
className = '',
}) => {
return (
<div className={`ll-code-block ${className}`}>
{title && (
<div className="ll-code-block-title">{title}</div>
)}
<SyntaxHighlighter
code={children.trim()}
language={language}
showCopyButton={copyable}
showLanguage={!title}
showLineNumbers={false}
/>
</div>
);
};
// Inline Code Component
export interface InlineCodeProps {
/** Code content */
children: React.ReactNode;
/** Additional CSS classes */
className?: string;
}
export const InlineCode: React.FC<InlineCodeProps> = ({
children,
className = '',
}) => {
return (
<code className={`ll-inline-code ${className}`}>
{children}
</code>
);
};
// Diff Viewer Component
export interface DiffLine {
type: 'add' | 'remove' | 'context';
content: string;
oldLineNumber?: number;
newLineNumber?: number;
}
export interface DiffViewerProps {
/** Old code */
oldCode?: string;
/** New code */
newCode?: string;
/** Pre-computed diff lines */
diff?: DiffLine[];
/** Language for syntax highlighting */
language?: Language;
/** View mode */
viewMode?: 'split' | 'unified';
/** Theme */
theme?: 'light' | 'dark';
/** Additional CSS classes */
className?: string;
}
export const DiffViewer: React.FC<DiffViewerProps> = ({
oldCode = '',
newCode = '',
diff,
language = 'plaintext',
viewMode = 'unified',
theme = 'dark',
className = '',
}) => {
// Simple diff computation if not provided
const diffLines = useMemo((): DiffLine[] => {
if (diff) return diff;
const oldLines = oldCode.split('\n');
const newLines = newCode.split('\n');
const result: DiffLine[] = [];
let oldIdx = 0;
let newIdx = 0;
// Simple line-by-line comparison
while (oldIdx < oldLines.length || newIdx < newLines.length) {
const oldLine = oldLines[oldIdx];
const newLine = newLines[newIdx];
if (oldIdx >= oldLines.length) {
result.push({ type: 'add', content: newLine, newLineNumber: newIdx + 1 });
newIdx++;
} else if (newIdx >= newLines.length) {
result.push({ type: 'remove', content: oldLine, oldLineNumber: oldIdx + 1 });
oldIdx++;
} else if (oldLine === newLine) {
result.push({ type: 'context', content: oldLine, oldLineNumber: oldIdx + 1, newLineNumber: newIdx + 1 });
oldIdx++;
newIdx++;
} else {
result.push({ type: 'remove', content: oldLine, oldLineNumber: oldIdx + 1 });
result.push({ type: 'add', content: newLine, newLineNumber: newIdx + 1 });
oldIdx++;
newIdx++;
}
}
return result;
}, [oldCode, newCode, diff]);
const classes = [
'll-diff-viewer',
`ll-diff-${viewMode}`,
`ll-syntax-theme-${theme}`,
className,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<pre>
<code>
{diffLines.map((line, index) => (
<div key={index} className={`ll-diff-line ll-diff-line-${line.type}`}>
<span className="ll-diff-gutter">
{line.type === 'add' && '+'}
{line.type === 'remove' && '-'}
{line.type === 'context' && ' '}
</span>
<span className="ll-diff-line-number ll-diff-old-number">
{line.oldLineNumber || ''}
</span>
<span className="ll-diff-line-number ll-diff-new-number">
{line.newLineNumber || ''}
</span>
<span className="ll-diff-content">{line.content}</span>
</div>
))}
</code>
</pre>
</div>
);
};

31
src/components/Table.tsx Normal file
View File

@@ -0,0 +1,31 @@
import React from 'react';
export type TableProps = {
striped?: boolean;
bordered?: boolean;
hover?: boolean;
responsive?: boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
className?: string;
children: React.ReactNode;
};
export function Table({ striped, bordered, hover, responsive, className = '', children }: TableProps) {
const tableClasses = [
'table',
striped ? 'table-striped' : '',
bordered ? 'table-bordered' : '',
hover ? 'table-hover' : '',
className
]
.filter(Boolean)
.join(' ');
const table = <table className={tableClasses}>{children}</table>;
if (responsive) {
const responsiveClass = responsive === true ? 'table-responsive' : `table-responsive-${responsive}`;
return <div className={responsiveClass}>{table}</div>;
}
return table;
}

93
src/components/Tabs.tsx Normal file
View File

@@ -0,0 +1,93 @@
import React, { useMemo, useState } from 'react';
export type TabItem = {
key: string;
title: React.ReactNode;
content: React.ReactNode;
disabled?: boolean;
};
export type TabsProps = {
items: TabItem[];
defaultActiveKey?: string;
activeKey?: string;
onChange?: (key: string) => void;
variant?: 'tabs' | 'pills';
fill?: boolean;
justify?: boolean;
className?: string;
navClassName?: string;
contentClassName?: string;
};
export function Tabs({
items,
defaultActiveKey,
activeKey,
onChange,
variant = 'tabs',
fill,
justify,
className = '',
navClassName = '',
contentClassName = ''
}: TabsProps) {
const fallbackKey = items.find(i => !i.disabled)?.key;
const [internalKey, setInternalKey] = useState<string | undefined>(defaultActiveKey ?? fallbackKey);
const currentKey = activeKey ?? internalKey ?? fallbackKey;
const navClasses = useMemo(() => {
return [
'nav',
variant === 'tabs' ? 'nav-tabs' : 'nav-pills',
fill ? 'nav-fill' : '',
justify ? 'nav-justified' : '',
navClassName
]
.filter(Boolean)
.join(' ');
}, [variant, fill, justify, navClassName]);
const handleSelect = (key: string, disabled?: boolean) => {
if (disabled) return;
if (!activeKey) {
setInternalKey(key);
}
onChange?.(key);
};
return (
<div className={className}>
<ul className={navClasses}>
{items.map(item => {
const isActive = item.key === currentKey;
return (
<li className="nav-item" key={item.key}>
<button
type="button"
className={`nav-link ${isActive ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`.trim()}
onClick={() => handleSelect(item.key, item.disabled)}
>
{item.title}
</button>
</li>
);
})}
</ul>
<div className={`tab-content ${contentClassName}`.trim()}>
{items.map(item => {
const isActive = item.key === currentKey;
return (
<div
key={item.key}
className={`tab-pane fade ${isActive ? 'show active' : ''}`.trim()}
role="tabpanel"
>
{isActive ? item.content : null}
</div>
);
})}
</div>
</div>
);
}

460
src/components/TagInput.tsx Normal file
View File

@@ -0,0 +1,460 @@
import React, { useState, useRef, useCallback, KeyboardEvent } from 'react';
export interface Tag {
/** Unique identifier */
id: string;
/** Display text */
text: string;
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Custom color (hex or rgb) */
color?: string;
/** Whether tag is removable */
removable?: boolean;
/** Custom data */
data?: any;
}
export interface TagInputProps {
/** Current tags (controlled) */
value?: Tag[];
/** Default tags */
defaultValue?: Tag[];
/** Callback when tags change */
onChange?: (tags: Tag[]) => void;
/** Callback when a tag is added */
onAdd?: (tag: Tag) => void;
/** Callback when a tag is removed */
onRemove?: (tag: Tag) => void;
/** Placeholder text */
placeholder?: string;
/** Maximum number of tags */
maxTags?: number;
/** Minimum length for tag text */
minLength?: number;
/** Maximum length for tag text */
maxLength?: number;
/** Allow duplicate tags */
allowDuplicates?: boolean;
/** Keys that trigger tag creation */
separatorKeys?: string[];
/** Characters that trigger tag creation */
separatorChars?: string[];
/** Validate tag before adding */
validate?: (text: string) => boolean | string;
/** Transform text before creating tag */
transform?: (text: string) => string;
/** Generate tag ID */
generateId?: (text: string) => string;
/** Default tag variant */
defaultVariant?: Tag['variant'];
/** Allow editing tags */
editable?: boolean;
/** Disabled state */
disabled?: boolean;
/** Read-only state */
readOnly?: boolean;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Show clear all button */
clearable?: boolean;
/** Suggestions for autocomplete */
suggestions?: string[];
/** Additional CSS classes */
className?: string;
/** Input CSS classes */
inputClassName?: string;
/** Tag CSS classes */
tagClassName?: string;
}
export const TagInput: React.FC<TagInputProps> = ({
value: controlledValue,
defaultValue = [],
onChange,
onAdd,
onRemove,
placeholder = 'Add a tag...',
maxTags,
minLength = 1,
maxLength,
allowDuplicates = false,
separatorKeys = ['Enter', 'Tab'],
separatorChars = [','],
validate,
transform = (text) => text.trim(),
generateId = () => `tag-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
defaultVariant = 'primary',
editable = false,
disabled = false,
readOnly = false,
size = 'md',
clearable = false,
suggestions = [],
className = '',
inputClassName = '',
tagClassName = '',
}) => {
const [internalTags, setInternalTags] = useState<Tag[]>(defaultValue);
const [inputValue, setInputValue] = useState('');
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const [activeSuggestion, setActiveSuggestion] = useState(-1);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tags = controlledValue ?? internalTags;
// Filter suggestions
const filteredSuggestions = suggestions.filter(
(s) => s.toLowerCase().includes(inputValue.toLowerCase()) &&
(allowDuplicates || !tags.some((t) => t.text.toLowerCase() === s.toLowerCase()))
);
// Update tags
const updateTags = useCallback((newTags: Tag[]) => {
if (controlledValue === undefined) {
setInternalTags(newTags);
}
onChange?.(newTags);
}, [controlledValue, onChange]);
// Add tag
const addTag = useCallback((text: string) => {
const transformedText = transform(text);
// Validation
if (transformedText.length < minLength) {
setError(`Tag must be at least ${minLength} characters`);
return false;
}
if (maxLength && transformedText.length > maxLength) {
setError(`Tag must be at most ${maxLength} characters`);
return false;
}
if (!allowDuplicates && tags.some((t) => t.text.toLowerCase() === transformedText.toLowerCase())) {
setError('Tag already exists');
return false;
}
if (maxTags && tags.length >= maxTags) {
setError(`Maximum ${maxTags} tags allowed`);
return false;
}
if (validate) {
const result = validate(transformedText);
if (result !== true) {
setError(typeof result === 'string' ? result : 'Invalid tag');
return false;
}
}
const newTag: Tag = {
id: generateId(transformedText),
text: transformedText,
variant: defaultVariant,
removable: true,
};
const newTags = [...tags, newTag];
updateTags(newTags);
onAdd?.(newTag);
setError(null);
return true;
}, [transform, minLength, maxLength, allowDuplicates, tags, maxTags, validate, generateId, defaultVariant, updateTags, onAdd]);
// Remove tag
const removeTag = useCallback((index: number) => {
const tagToRemove = tags[index];
if (!tagToRemove.removable && tagToRemove.removable !== undefined) return;
const newTags = tags.filter((_, i) => i !== index);
updateTags(newTags);
onRemove?.(tagToRemove);
}, [tags, updateTags, onRemove]);
// Handle input change
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Check for separator characters
for (const char of separatorChars) {
if (value.includes(char)) {
const parts = value.split(char).filter(Boolean);
parts.forEach((part) => {
if (part.trim()) {
addTag(part);
}
});
setInputValue('');
return;
}
}
setInputValue(value);
setShowSuggestions(value.length > 0 && filteredSuggestions.length > 0);
setActiveSuggestion(-1);
setError(null);
};
// Handle key down
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
// Handle suggestion navigation
if (showSuggestions && filteredSuggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveSuggestion((prev) => Math.min(prev + 1, filteredSuggestions.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveSuggestion((prev) => Math.max(prev - 1, -1));
return;
}
if (e.key === 'Enter' && activeSuggestion >= 0) {
e.preventDefault();
if (addTag(filteredSuggestions[activeSuggestion])) {
setInputValue('');
setShowSuggestions(false);
}
return;
}
}
// Handle tag creation
if (separatorKeys.includes(e.key)) {
if (inputValue.trim()) {
e.preventDefault();
if (addTag(inputValue)) {
setInputValue('');
setShowSuggestions(false);
}
}
return;
}
// Handle backspace to remove last tag
if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
removeTag(tags.length - 1);
return;
}
// Handle escape
if (e.key === 'Escape') {
setShowSuggestions(false);
setActiveSuggestion(-1);
}
};
// Handle suggestion click
const handleSuggestionClick = (suggestion: string) => {
if (addTag(suggestion)) {
setInputValue('');
setShowSuggestions(false);
inputRef.current?.focus();
}
};
// Handle tag edit
const handleTagEdit = (index: number, newText: string) => {
if (!editable) return;
const transformedText = transform(newText);
if (transformedText.length < minLength) return;
const newTags = [...tags];
newTags[index] = { ...newTags[index], text: transformedText };
updateTags(newTags);
setEditingIndex(null);
};
// Clear all tags
const clearAll = () => {
updateTags([]);
setInputValue('');
inputRef.current?.focus();
};
const containerClasses = [
'll-tag-input',
`ll-tag-input-${size}`,
disabled && 'll-tag-input-disabled',
readOnly && 'll-tag-input-readonly',
error && 'll-tag-input-error',
className,
].filter(Boolean).join(' ');
return (
<div className={containerClasses} ref={containerRef}>
<div
className="ll-tag-input-container"
onClick={() => inputRef.current?.focus()}
>
{/* Tags */}
{tags.map((tag, index) => (
<span
key={tag.id}
className={`ll-tag ll-tag-${tag.variant || defaultVariant} ${tagClassName}`}
style={tag.color ? { backgroundColor: tag.color } : undefined}
>
{editingIndex === index ? (
<input
type="text"
className="ll-tag-edit-input"
defaultValue={tag.text}
autoFocus
onBlur={(e) => handleTagEdit(index, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleTagEdit(index, e.currentTarget.value);
}
if (e.key === 'Escape') {
setEditingIndex(null);
}
}}
/>
) : (
<>
<span
className="ll-tag-text"
onDoubleClick={() => editable && setEditingIndex(index)}
>
{tag.text}
</span>
{tag.removable !== false && !readOnly && !disabled && (
<button
type="button"
className="ll-tag-remove"
onClick={(e) => {
e.stopPropagation();
removeTag(index);
}}
aria-label={`Remove ${tag.text}`}
>
&times;
</button>
)}
</>
)}
</span>
))}
{/* Input */}
{!readOnly && (!maxTags || tags.length < maxTags) && (
<input
ref={inputRef}
type="text"
className={`ll-tag-input-field ${inputClassName}`}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(inputValue.length > 0 && filteredSuggestions.length > 0)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
placeholder={tags.length === 0 ? placeholder : ''}
disabled={disabled}
aria-describedby={error ? 'll-tag-input-error' : undefined}
/>
)}
{/* Clear button */}
{clearable && tags.length > 0 && !disabled && !readOnly && (
<button
type="button"
className="ll-tag-input-clear"
onClick={clearAll}
aria-label="Clear all tags"
>
&times;
</button>
)}
</div>
{/* Suggestions dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<ul className="ll-tag-suggestions">
{filteredSuggestions.map((suggestion, index) => (
<li
key={suggestion}
className={`ll-tag-suggestion ${index === activeSuggestion ? 'active' : ''}`}
onClick={() => handleSuggestionClick(suggestion)}
onMouseEnter={() => setActiveSuggestion(index)}
>
{suggestion}
</li>
))}
</ul>
)}
{/* Error message */}
{error && (
<div className="ll-tag-input-error-message" id="ll-tag-input-error" role="alert">
{error}
</div>
)}
</div>
);
};
// Simple Tag component for standalone use
export interface SimpleTagProps {
/** Tag text */
children: React.ReactNode;
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Outline style */
outline?: boolean;
/** Rounded (pill) style */
rounded?: boolean;
/** Show remove button */
removable?: boolean;
/** Remove callback */
onRemove?: () => void;
/** Click callback */
onClick?: () => void;
/** Additional CSS classes */
className?: string;
}
export const SimpleTag: React.FC<SimpleTagProps> = ({
children,
variant = 'primary',
outline = false,
rounded = false,
removable = false,
onRemove,
onClick,
className = '',
}) => {
const classes = [
'll-tag',
`ll-tag-${variant}`,
outline && 'll-tag-outline',
rounded && 'll-tag-rounded',
onClick && 'll-tag-clickable',
className,
].filter(Boolean).join(' ');
return (
<span className={classes} onClick={onClick}>
<span className="ll-tag-text">{children}</span>
{removable && (
<button
type="button"
className="ll-tag-remove"
onClick={(e) => {
e.stopPropagation();
onRemove?.();
}}
aria-label="Remove tag"
>
&times;
</button>
)}
</span>
);
};

223
src/components/Timeline.tsx Normal file
View File

@@ -0,0 +1,223 @@
import React from 'react';
export interface TimelineItem {
/** Unique identifier */
id: string;
/** Title/heading */
title?: React.ReactNode;
/** Content/description */
content?: React.ReactNode;
/** Date/time text */
date?: React.ReactNode;
/** Icon for the marker */
icon?: React.ReactNode;
/** Color variant for marker */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Custom marker content */
marker?: React.ReactNode;
/** Opposite content (for alternate layout) */
opposite?: React.ReactNode;
/** Whether this item is active */
active?: boolean;
/** Custom CSS classes */
className?: string;
}
export interface TimelineProps {
/** Timeline items */
items: TimelineItem[];
/** Layout mode */
mode?: 'left' | 'right' | 'alternate' | 'center';
/** Whether line should be dashed */
dashed?: boolean;
/** Reverse order */
reverse?: boolean;
/** Color variant for default markers */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Show connector line */
showLine?: boolean;
/** Additional CSS classes */
className?: string;
/** Render custom item */
renderItem?: (item: TimelineItem, index: number) => React.ReactNode;
}
const defaultIcon = (
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<circle cx="12" cy="12" r="6" />
</svg>
);
export const Timeline: React.FC<TimelineProps> = ({
items,
mode = 'left',
dashed = false,
reverse = false,
variant = 'primary',
size = 'md',
showLine = true,
className = '',
renderItem,
}) => {
const orderedItems = reverse ? [...items].reverse() : items;
const classes = [
'll-timeline',
`ll-timeline-${mode}`,
`ll-timeline-${size}`,
dashed && 'll-timeline-dashed',
!showLine && 'll-timeline-no-line',
className,
].filter(Boolean).join(' ');
return (
<div className={classes}>
{orderedItems.map((item, index) => {
if (renderItem) {
return <div key={item.id}>{renderItem(item, index)}</div>;
}
const itemVariant = item.variant || variant;
const isAlternate = mode === 'alternate';
const isRight = mode === 'right' || (isAlternate && index % 2 === 1);
const itemClasses = [
'll-timeline-item',
`ll-timeline-item-${itemVariant}`,
item.active && 'll-timeline-item-active',
isRight && 'll-timeline-item-right',
item.className,
].filter(Boolean).join(' ');
return (
<div key={item.id} className={itemClasses}>
{/* Opposite content for alternate mode */}
{isAlternate && (
<div className="ll-timeline-opposite">
{item.opposite || item.date}
</div>
)}
{/* Marker */}
<div className={`ll-timeline-marker ll-timeline-marker-${itemVariant}`}>
{item.marker || (
<span className="ll-timeline-marker-icon">
{item.icon || defaultIcon}
</span>
)}
</div>
{/* Content */}
<div className="ll-timeline-content">
{!isAlternate && item.date && (
<div className="ll-timeline-date">{item.date}</div>
)}
{item.title && (
<div className="ll-timeline-title">{item.title}</div>
)}
{item.content && (
<div className="ll-timeline-text">{item.content}</div>
)}
</div>
</div>
);
})}
</div>
);
};
// Horizontal Timeline
export interface HorizontalTimelineProps {
/** Timeline items */
items: TimelineItem[];
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Active item index */
activeIndex?: number;
/** Callback when item is clicked */
onItemClick?: (item: TimelineItem, index: number) => void;
/** Additional CSS classes */
className?: string;
}
export const HorizontalTimeline: React.FC<HorizontalTimelineProps> = ({
items,
variant = 'primary',
size = 'md',
activeIndex,
onItemClick,
className = '',
}) => {
const classes = [
'll-timeline-horizontal',
`ll-timeline-${size}`,
className,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<div className="ll-timeline-horizontal-line" />
<div className="ll-timeline-horizontal-items">
{items.map((item, index) => {
const itemVariant = item.variant || variant;
const isActive = activeIndex !== undefined ? index <= activeIndex : item.active;
const isCurrent = activeIndex === index;
const itemClasses = [
'll-timeline-horizontal-item',
`ll-timeline-horizontal-item-${itemVariant}`,
isActive && 'll-timeline-horizontal-item-active',
isCurrent && 'll-timeline-horizontal-item-current',
].filter(Boolean).join(' ');
return (
<div
key={item.id}
className={itemClasses}
onClick={() => onItemClick?.(item, index)}
style={{ cursor: onItemClick ? 'pointer' : 'default' }}
>
<div className={`ll-timeline-horizontal-marker ll-timeline-marker-${itemVariant}`}>
{item.marker || (
<span className="ll-timeline-marker-icon">
{item.icon || defaultIcon}
</span>
)}
</div>
<div className="ll-timeline-horizontal-content">
{item.date && <div className="ll-timeline-date">{item.date}</div>}
{item.title && <div className="ll-timeline-title">{item.title}</div>}
</div>
</div>
);
})}
</div>
</div>
);
};
// Timeline connector helper
export interface TimelineConnectorProps {
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
dashed?: boolean;
className?: string;
}
export const TimelineConnector: React.FC<TimelineConnectorProps> = ({
variant = 'primary',
dashed = false,
className = '',
}) => {
const classes = [
'll-timeline-connector',
`ll-timeline-connector-${variant}`,
dashed && 'll-timeline-connector-dashed',
className,
].filter(Boolean).join(' ');
return <div className={classes} />;
};

38
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,38 @@
import React, { useEffect } from 'react';
export type ToastProps = {
show: boolean;
onClose?: () => void;
delay?: number;
autohide?: boolean;
title?: React.ReactNode;
time?: React.ReactNode;
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
};
/**
* Simple toast. For stacks, render multiple Toast components inside a position container.
*/
export function Toast({ show, onClose, delay = 5000, autohide, title, time, children, variant }: ToastProps) {
useEffect(() => {
if (!autohide || !show) return;
const t = setTimeout(() => onClose?.(), delay);
return () => clearTimeout(t);
}, [autohide, show, delay, onClose]);
if (!show) return null;
return (
<div className="toast show" role="alert" aria-live="assertive" aria-atomic="true">
{(title || onClose || time) && (
<div className={`toast-header ${variant ? `bg-${variant} text-white` : ''}`.trim()}>
{title ? <strong className="me-auto">{title}</strong> : null}
{time ? <small className="text-muted">{time}</small> : null}
{onClose ? <button type="button" className="btn-close ms-2" onClick={onClose}></button> : null}
</div>
)}
<div className="toast-body">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { useFloating, offset, shift, flip, arrow, Placement, Middleware } from '@floating-ui/react';
export type TooltipProps = {
content: React.ReactNode;
placement?: Placement;
children: React.ReactElement;
className?: string;
};
/**
* Minimal tooltip using floating-ui.
*/
export function Tooltip({ content, placement = 'top', children, className = '' }: TooltipProps) {
const [open, setOpen] = useState(false);
const [arrowEl, setArrowEl] = useState<HTMLElement | null>(null);
const middleware: Middleware[] = [offset(8), flip(), shift()];
if (arrowEl) middleware.push(arrow({ element: arrowEl }));
const { x, y, refs, strategy, middlewareData, placement: finalPlacement } = useFloating({
open,
onOpenChange: setOpen,
placement,
middleware
});
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right'
}[finalPlacement.split('-')[0]] as string;
return (
<>
{React.cloneElement(children, {
ref: refs.setReference,
onMouseEnter: () => setOpen(true),
onMouseLeave: () => setOpen(false),
onFocus: () => setOpen(true),
onBlur: () => setOpen(false)
})}
{open ? (
<div
ref={refs.setFloating}
className={['tooltip bs-tooltip-auto show', className].filter(Boolean).join(' ')}
role="tooltip"
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0
}}
>
<div className="tooltip-inner">{content}</div>
<div
ref={setArrowEl as any}
className="tooltip-arrow"
style={{
left: middlewareData.arrow?.x,
top: middlewareData.arrow?.y,
[staticSide]: '-4px'
}}
/>
</div>
) : null}
</>
);
}

344
src/components/TreeView.tsx Normal file
View File

@@ -0,0 +1,344 @@
import React, { useState, useCallback } from 'react';
export interface TreeNode {
/** Unique identifier */
id: string;
/** Node label */
label: React.ReactNode;
/** Child nodes */
children?: TreeNode[];
/** Icon for the node */
icon?: React.ReactNode;
/** Whether node is initially expanded */
expanded?: boolean;
/** Whether node is disabled */
disabled?: boolean;
/** Whether node is selectable */
selectable?: boolean;
/** Custom data */
data?: any;
}
export interface TreeViewProps {
/** Tree data */
data: TreeNode[];
/** Selected node IDs (controlled) */
selectedIds?: string[];
/** Default selected node IDs */
defaultSelectedIds?: string[];
/** Expanded node IDs (controlled) */
expandedIds?: string[];
/** Default expanded node IDs */
defaultExpandedIds?: string[];
/** Selection mode */
selectionMode?: 'single' | 'multiple' | 'none';
/** Callback when selection changes */
onSelect?: (selectedIds: string[], node: TreeNode) => void;
/** Callback when node expands/collapses */
onToggle?: (expandedIds: string[], node: TreeNode) => void;
/** Callback when node is clicked */
onNodeClick?: (node: TreeNode) => void;
/** Show checkboxes */
showCheckbox?: boolean;
/** Show connecting lines */
showLines?: boolean;
/** Show icons */
showIcons?: boolean;
/** Custom expand icon */
expandIcon?: React.ReactNode;
/** Custom collapse icon */
collapseIcon?: React.ReactNode;
/** Custom leaf icon */
leafIcon?: React.ReactNode;
/** Enable drag and drop */
draggable?: boolean;
/** Callback when node is dropped */
onDrop?: (dragNode: TreeNode, dropNode: TreeNode, position: 'before' | 'after' | 'inside') => void;
/** Filter function */
filter?: (node: TreeNode) => boolean;
/** Additional CSS classes */
className?: string;
}
interface TreeNodeComponentProps {
node: TreeNode;
level: number;
selectedIds: string[];
expandedIds: string[];
selectionMode: 'single' | 'multiple' | 'none';
showCheckbox: boolean;
showLines: boolean;
showIcons: boolean;
expandIcon: React.ReactNode;
collapseIcon: React.ReactNode;
leafIcon: React.ReactNode;
draggable: boolean;
onSelect: (node: TreeNode) => void;
onToggle: (node: TreeNode) => void;
onNodeClick?: (node: TreeNode) => void;
filter?: (node: TreeNode) => boolean;
}
const defaultExpandIcon = (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
);
const defaultCollapseIcon = (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
</svg>
);
const defaultLeafIcon = (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
</svg>
);
const defaultFolderIcon = (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
</svg>
);
const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
node,
level,
selectedIds,
expandedIds,
selectionMode,
showCheckbox,
showLines,
showIcons,
expandIcon,
collapseIcon,
leafIcon,
draggable,
onSelect,
onToggle,
onNodeClick,
filter,
}) => {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedIds.includes(node.id);
const isSelected = selectedIds.includes(node.id);
const isDisabled = node.disabled;
// Filter children
const filteredChildren = hasChildren && filter
? node.children!.filter(filter)
: node.children;
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (hasChildren) {
onToggle(node);
}
};
const handleSelect = (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && selectionMode !== 'none' && node.selectable !== false) {
onSelect(node);
}
onNodeClick?.(node);
};
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
if (!isDisabled && selectionMode !== 'none') {
onSelect(node);
}
};
const nodeClasses = [
'll-tree-node',
isSelected && 'll-tree-node-selected',
isDisabled && 'll-tree-node-disabled',
hasChildren && 'll-tree-node-parent',
!hasChildren && 'll-tree-node-leaf',
].filter(Boolean).join(' ');
const contentClasses = [
'll-tree-node-content',
showLines && 'll-tree-node-lines',
].filter(Boolean).join(' ');
return (
<li className={nodeClasses}>
<div
className={contentClasses}
style={{ paddingLeft: `${level * 1.5}rem` }}
onClick={handleSelect}
draggable={draggable && !isDisabled}
>
{/* Expand/collapse toggle */}
<span
className={`ll-tree-toggle ${hasChildren ? '' : 'll-tree-toggle-hidden'}`}
onClick={handleToggle}
>
{hasChildren && (isExpanded ? collapseIcon : expandIcon)}
</span>
{/* Checkbox */}
{showCheckbox && selectionMode !== 'none' && (
<label className="ll-tree-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={handleCheckboxChange}
disabled={isDisabled}
/>
<span className="ll-tree-checkbox-mark" />
</label>
)}
{/* Icon */}
{showIcons && (
<span className="ll-tree-icon">
{node.icon || (hasChildren ? defaultFolderIcon : leafIcon)}
</span>
)}
{/* Label */}
<span className="ll-tree-label">{node.label}</span>
</div>
{/* Children */}
{hasChildren && isExpanded && filteredChildren && filteredChildren.length > 0 && (
<ul className="ll-tree-children">
{filteredChildren.map((child) => (
<TreeNodeComponent
key={child.id}
node={child}
level={level + 1}
selectedIds={selectedIds}
expandedIds={expandedIds}
selectionMode={selectionMode}
showCheckbox={showCheckbox}
showLines={showLines}
showIcons={showIcons}
expandIcon={expandIcon}
collapseIcon={collapseIcon}
leafIcon={leafIcon}
draggable={draggable}
onSelect={onSelect}
onToggle={onToggle}
onNodeClick={onNodeClick}
filter={filter}
/>
))}
</ul>
)}
</li>
);
};
export const TreeView: React.FC<TreeViewProps> = ({
data,
selectedIds: controlledSelectedIds,
defaultSelectedIds = [],
expandedIds: controlledExpandedIds,
defaultExpandedIds = [],
selectionMode = 'single',
onSelect,
onToggle,
onNodeClick,
showCheckbox = false,
showLines = false,
showIcons = true,
expandIcon = defaultExpandIcon,
collapseIcon = defaultCollapseIcon,
leafIcon = defaultLeafIcon,
draggable = false,
filter,
className = '',
}) => {
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(defaultSelectedIds);
const [internalExpandedIds, setInternalExpandedIds] = useState<string[]>(defaultExpandedIds);
const selectedIds = controlledSelectedIds ?? internalSelectedIds;
const expandedIds = controlledExpandedIds ?? internalExpandedIds;
const handleSelect = useCallback((node: TreeNode) => {
let newSelectedIds: string[];
if (selectionMode === 'single') {
newSelectedIds = selectedIds.includes(node.id) ? [] : [node.id];
} else if (selectionMode === 'multiple') {
newSelectedIds = selectedIds.includes(node.id)
? selectedIds.filter((id) => id !== node.id)
: [...selectedIds, node.id];
} else {
return;
}
if (controlledSelectedIds === undefined) {
setInternalSelectedIds(newSelectedIds);
}
onSelect?.(newSelectedIds, node);
}, [selectedIds, selectionMode, controlledSelectedIds, onSelect]);
const handleToggle = useCallback((node: TreeNode) => {
const newExpandedIds = expandedIds.includes(node.id)
? expandedIds.filter((id) => id !== node.id)
: [...expandedIds, node.id];
if (controlledExpandedIds === undefined) {
setInternalExpandedIds(newExpandedIds);
}
onToggle?.(newExpandedIds, node);
}, [expandedIds, controlledExpandedIds, onToggle]);
// Filter root nodes
const filteredData = filter ? data.filter(filter) : data;
const classes = [
'll-tree',
showLines && 'll-tree-lines',
className,
].filter(Boolean).join(' ');
return (
<ul className={classes} role="tree">
{filteredData.map((node) => (
<TreeNodeComponent
key={node.id}
node={node}
level={0}
selectedIds={selectedIds}
expandedIds={expandedIds}
selectionMode={selectionMode}
showCheckbox={showCheckbox}
showLines={showLines}
showIcons={showIcons}
expandIcon={expandIcon}
collapseIcon={collapseIcon}
leafIcon={leafIcon}
draggable={draggable}
onSelect={handleSelect}
onToggle={handleToggle}
onNodeClick={onNodeClick}
filter={filter}
/>
))}
</ul>
);
};
// Helper to expand all nodes
export const getAllNodeIds = (nodes: TreeNode[]): string[] => {
const ids: string[] = [];
const traverse = (nodeList: TreeNode[]) => {
nodeList.forEach((node) => {
ids.push(node.id);
if (node.children) {
traverse(node.children);
}
});
};
traverse(nodes);
return ids;
};

577
src/components/Widget.tsx Normal file
View File

@@ -0,0 +1,577 @@
import React from 'react';
// Stat Widget - For displaying statistics/metrics
export interface StatWidgetProps {
/** Main value/number */
value: React.ReactNode;
/** Label/title */
label: string;
/** Icon */
icon?: React.ReactNode;
/** Icon position */
iconPosition?: 'left' | 'right' | 'top';
/** Trend indicator */
trend?: {
value: number | string;
direction: 'up' | 'down' | 'neutral';
label?: string;
};
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Size */
size?: 'sm' | 'md' | 'lg';
/** Footer content */
footer?: React.ReactNode;
/** Click handler */
onClick?: () => void;
/** Additional CSS classes */
className?: string;
}
export const StatWidget: React.FC<StatWidgetProps> = ({
value,
label,
icon,
iconPosition = 'left',
trend,
variant = 'primary',
size = 'md',
footer,
onClick,
className = '',
}) => {
const classes = [
'll-stat-widget',
`ll-stat-widget-${variant}`,
`ll-stat-widget-${size}`,
`ll-stat-widget-icon-${iconPosition}`,
onClick && 'll-stat-widget-clickable',
className,
].filter(Boolean).join(' ');
const renderTrend = () => {
if (!trend) return null;
const trendClasses = [
'll-stat-widget-trend',
`ll-stat-widget-trend-${trend.direction}`,
].join(' ');
return (
<div className={trendClasses}>
<span className="ll-stat-widget-trend-icon">
{trend.direction === 'up' && '↑'}
{trend.direction === 'down' && '↓'}
{trend.direction === 'neutral' && '→'}
</span>
<span className="ll-stat-widget-trend-value">{trend.value}</span>
{trend.label && <span className="ll-stat-widget-trend-label">{trend.label}</span>}
</div>
);
};
return (
<div className={classes} onClick={onClick} role={onClick ? 'button' : undefined}>
<div className="ll-stat-widget-body">
{icon && iconPosition !== 'right' && (
<div className="ll-stat-widget-icon">{icon}</div>
)}
<div className="ll-stat-widget-content">
<div className="ll-stat-widget-value">{value}</div>
<div className="ll-stat-widget-label">{label}</div>
{renderTrend()}
</div>
{icon && iconPosition === 'right' && (
<div className="ll-stat-widget-icon">{icon}</div>
)}
</div>
{footer && <div className="ll-stat-widget-footer">{footer}</div>}
</div>
);
};
// Progress Widget - Stat with progress bar
export interface ProgressWidgetProps extends Omit<StatWidgetProps, 'trend'> {
/** Progress value (0-100) */
progress: number;
/** Progress bar color */
progressColor?: string;
/** Show progress percentage */
showProgressLabel?: boolean;
}
export const ProgressWidget: React.FC<ProgressWidgetProps> = ({
progress,
progressColor,
showProgressLabel = true,
footer,
...statProps
}) => {
const progressBar = (
<div className="ll-progress-widget-bar">
<div className="ll-progress-widget-track">
<div
className="ll-progress-widget-fill"
style={{
width: `${Math.min(100, Math.max(0, progress))}%`,
backgroundColor: progressColor,
}}
/>
</div>
{showProgressLabel && (
<span className="ll-progress-widget-label">{progress}%</span>
)}
</div>
);
return (
<StatWidget
{...statProps}
footer={
<>
{progressBar}
{footer}
</>
}
className={`ll-progress-widget ${statProps.className || ''}`}
/>
);
};
// Icon Widget - Simple icon with label
export interface IconWidgetProps {
/** Icon */
icon: React.ReactNode;
/** Label */
label?: string;
/** Description */
description?: string;
/** Color variant */
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark';
/** Size */
size?: 'sm' | 'md' | 'lg';
/** Shape */
shape?: 'square' | 'rounded' | 'circle';
/** Filled background */
filled?: boolean;
/** Click handler */
onClick?: () => void;
/** Additional CSS classes */
className?: string;
}
export const IconWidget: React.FC<IconWidgetProps> = ({
icon,
label,
description,
variant = 'primary',
size = 'md',
shape = 'rounded',
filled = true,
onClick,
className = '',
}) => {
const classes = [
'll-icon-widget',
`ll-icon-widget-${variant}`,
`ll-icon-widget-${size}`,
`ll-icon-widget-${shape}`,
filled && 'll-icon-widget-filled',
onClick && 'll-icon-widget-clickable',
className,
].filter(Boolean).join(' ');
return (
<div className={classes} onClick={onClick} role={onClick ? 'button' : undefined}>
<div className="ll-icon-widget-icon">{icon}</div>
{(label || description) && (
<div className="ll-icon-widget-content">
{label && <div className="ll-icon-widget-label">{label}</div>}
{description && <div className="ll-icon-widget-description">{description}</div>}
</div>
)}
</div>
);
};
// Content Widget - Card-like widget for various content
export interface ContentWidgetProps {
/** Widget title */
title?: React.ReactNode;
/** Widget subtitle */
subtitle?: React.ReactNode;
/** Header icon */
icon?: React.ReactNode;
/** Header actions */
actions?: React.ReactNode;
/** Widget content */
children?: React.ReactNode;
/** Footer content */
footer?: React.ReactNode;
/** Loading state */
loading?: boolean;
/** Color variant for header */
variant?: 'default' | 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
/** Compact mode */
compact?: boolean;
/** No padding in body */
noPadding?: boolean;
/** Additional CSS classes */
className?: string;
}
export const ContentWidget: React.FC<ContentWidgetProps> = ({
title,
subtitle,
icon,
actions,
children,
footer,
loading = false,
variant = 'default',
compact = false,
noPadding = false,
className = '',
}) => {
const classes = [
'll-content-widget',
`ll-content-widget-${variant}`,
compact && 'll-content-widget-compact',
noPadding && 'll-content-widget-no-padding',
loading && 'll-content-widget-loading',
className,
].filter(Boolean).join(' ');
return (
<div className={classes}>
{(title || icon || actions) && (
<div className="ll-content-widget-header">
{icon && <div className="ll-content-widget-icon">{icon}</div>}
<div className="ll-content-widget-title-group">
{title && <h3 className="ll-content-widget-title">{title}</h3>}
{subtitle && <div className="ll-content-widget-subtitle">{subtitle}</div>}
</div>
{actions && <div className="ll-content-widget-actions">{actions}</div>}
</div>
)}
{loading ? (
<div className="ll-content-widget-loader">
<div className="ll-content-widget-spinner" />
</div>
) : (
children && <div className="ll-content-widget-body">{children}</div>
)}
{footer && <div className="ll-content-widget-footer">{footer}</div>}
</div>
);
};
// List Widget - Widget with list items
export interface ListWidgetItem {
id: string;
icon?: React.ReactNode;
avatar?: string;
title: React.ReactNode;
subtitle?: React.ReactNode;
value?: React.ReactNode;
badge?: React.ReactNode;
onClick?: () => void;
}
export interface ListWidgetProps {
/** Widget title */
title?: string;
/** List items */
items: ListWidgetItem[];
/** Max items to show */
maxItems?: number;
/** Show "View All" link */
showViewAll?: boolean;
/** View All click handler */
onViewAll?: () => void;
/** Empty state message */
emptyMessage?: string;
/** Loading state */
loading?: boolean;
/** Dividers between items */
dividers?: boolean;
/** Hover effect */
hoverable?: boolean;
/** Additional CSS classes */
className?: string;
}
export const ListWidget: React.FC<ListWidgetProps> = ({
title,
items,
maxItems,
showViewAll = false,
onViewAll,
emptyMessage = 'No items',
loading = false,
dividers = true,
hoverable = true,
className = '',
}) => {
const displayItems = maxItems ? items.slice(0, maxItems) : items;
const hasMore = maxItems && items.length > maxItems;
return (
<ContentWidget
title={title}
loading={loading}
noPadding
actions={
showViewAll || hasMore ? (
<button type="button" className="ll-list-widget-view-all" onClick={onViewAll}>
View All {hasMore && `(${items.length})`}
</button>
) : undefined
}
className={`ll-list-widget ${className}`}
>
{displayItems.length === 0 ? (
<div className="ll-list-widget-empty">{emptyMessage}</div>
) : (
<ul className={`ll-list-widget-items ${dividers ? 'll-list-widget-dividers' : ''}`}>
{displayItems.map((item) => (
<li
key={item.id}
className={`ll-list-widget-item ${hoverable && item.onClick ? 'll-list-widget-item-hoverable' : ''}`}
onClick={item.onClick}
role={item.onClick ? 'button' : undefined}
>
{item.avatar && (
<img src={item.avatar} alt="" className="ll-list-widget-avatar" />
)}
{item.icon && !item.avatar && (
<div className="ll-list-widget-item-icon">{item.icon}</div>
)}
<div className="ll-list-widget-item-content">
<div className="ll-list-widget-item-title">{item.title}</div>
{item.subtitle && (
<div className="ll-list-widget-item-subtitle">{item.subtitle}</div>
)}
</div>
{item.value && (
<div className="ll-list-widget-item-value">{item.value}</div>
)}
{item.badge && (
<div className="ll-list-widget-item-badge">{item.badge}</div>
)}
</li>
))}
</ul>
)}
</ContentWidget>
);
};
// Chart Widget - Placeholder for chart content
export interface ChartWidgetProps {
/** Widget title */
title?: string;
/** Subtitle */
subtitle?: string;
/** Chart component */
children: React.ReactNode;
/** Legend content */
legend?: React.ReactNode;
/** Header actions */
actions?: React.ReactNode;
/** Loading state */
loading?: boolean;
/** Height */
height?: number | string;
/** Additional CSS classes */
className?: string;
}
export const ChartWidget: React.FC<ChartWidgetProps> = ({
title,
subtitle,
children,
legend,
actions,
loading = false,
height = 300,
className = '',
}) => {
return (
<ContentWidget
title={title}
subtitle={subtitle}
actions={actions}
loading={loading}
className={`ll-chart-widget ${className}`}
>
<div className="ll-chart-widget-content" style={{ height }}>
{children}
</div>
{legend && <div className="ll-chart-widget-legend">{legend}</div>}
</ContentWidget>
);
};
// Profile Widget - User profile display
export interface ProfileWidgetProps {
/** User name */
name: string;
/** User role/title */
role?: string;
/** Avatar URL */
avatar?: string;
/** Cover image URL */
coverImage?: string;
/** Status */
status?: 'online' | 'offline' | 'away' | 'busy';
/** Stats to display */
stats?: Array<{ label: string; value: string | number }>;
/** Action buttons */
actions?: React.ReactNode;
/** Layout variant */
layout?: 'vertical' | 'horizontal';
/** Additional CSS classes */
className?: string;
}
export const ProfileWidget: React.FC<ProfileWidgetProps> = ({
name,
role,
avatar,
coverImage,
status,
stats,
actions,
layout = 'vertical',
className = '',
}) => {
const classes = [
'll-profile-widget',
`ll-profile-widget-${layout}`,
className,
].filter(Boolean).join(' ');
return (
<div className={classes}>
{coverImage && (
<div
className="ll-profile-widget-cover"
style={{ backgroundImage: `url(${coverImage})` }}
/>
)}
<div className="ll-profile-widget-body">
<div className="ll-profile-widget-avatar-wrapper">
{avatar ? (
<img src={avatar} alt={name} className="ll-profile-widget-avatar" />
) : (
<div className="ll-profile-widget-avatar ll-profile-widget-avatar-placeholder">
{name.charAt(0).toUpperCase()}
</div>
)}
{status && (
<span className={`ll-profile-widget-status ll-profile-widget-status-${status}`} />
)}
</div>
<div className="ll-profile-widget-info">
<h4 className="ll-profile-widget-name">{name}</h4>
{role && <p className="ll-profile-widget-role">{role}</p>}
</div>
{stats && stats.length > 0 && (
<div className="ll-profile-widget-stats">
{stats.map((stat, index) => (
<div key={index} className="ll-profile-widget-stat">
<span className="ll-profile-widget-stat-value">{stat.value}</span>
<span className="ll-profile-widget-stat-label">{stat.label}</span>
</div>
))}
</div>
)}
{actions && <div className="ll-profile-widget-actions">{actions}</div>}
</div>
</div>
);
};
// Weather Widget
export interface WeatherWidgetProps {
/** Location name */
location: string;
/** Current temperature */
temperature: number;
/** Temperature unit */
unit?: 'C' | 'F';
/** Weather condition */
condition: string;
/** Weather icon */
icon?: React.ReactNode;
/** Humidity */
humidity?: number;
/** Wind speed */
windSpeed?: number;
/** Forecast */
forecast?: Array<{
day: string;
high: number;
low: number;
icon?: React.ReactNode;
}>;
/** Additional CSS classes */
className?: string;
}
export const WeatherWidget: React.FC<WeatherWidgetProps> = ({
location,
temperature,
unit = 'C',
condition,
icon,
humidity,
windSpeed,
forecast,
className = '',
}) => {
return (
<div className={`ll-weather-widget ${className}`}>
<div className="ll-weather-widget-current">
<div className="ll-weather-widget-main">
{icon && <div className="ll-weather-widget-icon">{icon}</div>}
<div className="ll-weather-widget-temp">
{temperature}°{unit}
</div>
</div>
<div className="ll-weather-widget-info">
<div className="ll-weather-widget-location">{location}</div>
<div className="ll-weather-widget-condition">{condition}</div>
{(humidity !== undefined || windSpeed !== undefined) && (
<div className="ll-weather-widget-details">
{humidity !== undefined && <span>Humidity: {humidity}%</span>}
{windSpeed !== undefined && <span>Wind: {windSpeed} km/h</span>}
</div>
)}
</div>
</div>
{forecast && forecast.length > 0 && (
<div className="ll-weather-widget-forecast">
{forecast.map((day, index) => (
<div key={index} className="ll-weather-widget-forecast-day">
<div className="ll-weather-widget-forecast-name">{day.day}</div>
{day.icon && <div className="ll-weather-widget-forecast-icon">{day.icon}</div>}
<div className="ll-weather-widget-forecast-temps">
<span className="ll-weather-widget-forecast-high">{day.high}°</span>
<span className="ll-weather-widget-forecast-low">{day.low}°</span>
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,835 @@
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
// ============================================================================
// TYPES
// ============================================================================
export type WidgetVariant = 'default' | 'primary' | 'success' | 'danger' | 'warning' | 'info' | 'indigo' | 'blue' | 'teal' | 'purple' | 'pink' | 'orange';
export interface StatWidgetProps {
/** Widget title/label */
title: string;
/** Main value to display */
value: string | number;
/** Icon class name (e.g., 'icon-pointer') */
icon?: string;
/** Icon element (alternative to icon class) */
iconElement?: React.ReactNode;
/** Color variant */
variant?: WidgetVariant;
/** Icon position */
iconPosition?: 'left' | 'right';
/** Whether to use solid background color */
solid?: boolean;
/** Optional trend indicator */
trend?: {
value: number;
direction: 'up' | 'down';
label?: string;
};
/** Click handler */
onClick?: () => void;
/** Additional CSS class */
className?: string;
}
export interface ProgressWidgetProps {
/** Widget title */
title: string;
/** Subtitle/description */
subtitle?: string;
/** Progress value (0-100) */
progress: number;
/** Progress label */
progressLabel?: string;
/** Progress sublabel */
progressSublabel?: string;
/** Icon class name */
icon?: string;
/** Icon element (alternative to icon class) */
iconElement?: React.ReactNode;
/** Color variant */
variant?: WidgetVariant;
/** Progress bar height */
progressHeight?: number;
/** Additional CSS class */
className?: string;
}
export interface ChartWidgetProps {
/** Widget title */
title?: string;
/** Subtitle/description */
subtitle?: string;
/** Chart type */
type: 'area' | 'bar' | 'line' | 'donut' | 'pie' | 'sparkline';
/** Chart data */
data: ChartDataPoint[];
/** Chart height in pixels */
height?: number;
/** Color for the chart */
color?: string;
/** Multiple colors for pie/donut charts */
colors?: string[];
/** Whether to animate on load */
animate?: boolean;
/** Animation duration in ms */
animationDuration?: number;
/** Show tooltip on hover */
showTooltip?: boolean;
/** Tooltip formatter */
tooltipFormatter?: (value: number, label?: string) => string;
/** Header content */
header?: React.ReactNode;
/** Footer content */
footer?: React.ReactNode;
/** Additional CSS class */
className?: string;
}
export interface ChartDataPoint {
label?: string;
value: number;
date?: string | Date;
color?: string;
}
export interface ContentWidgetProps {
/** Widget title */
title?: string;
/** Header actions */
headerActions?: React.ReactNode;
/** Widget content */
children: React.ReactNode;
/** Footer content */
footer?: React.ReactNode;
/** Whether to remove padding */
noPadding?: boolean;
/** Additional CSS class */
className?: string;
}
export interface UserWidgetProps {
/** User name */
name: string;
/** User role/title */
role?: string;
/** User avatar URL */
avatar?: string;
/** Background image or color */
background?: string;
/** Background color variant */
variant?: WidgetVariant;
/** User details */
details?: Array<{ label: string; value: string | React.ReactNode }>;
/** Social links */
socialLinks?: Array<{ icon: string; href: string }>;
/** Action buttons */
actions?: React.ReactNode;
/** Additional CSS class */
className?: string;
}
export interface MessageListItem {
id: string;
avatar?: string;
name: string;
message: string;
time: string;
badge?: number;
online?: boolean;
}
export interface MessageListWidgetProps {
/** Widget title */
title?: string;
/** Header extra content (e.g., trend indicator) */
headerExtra?: React.ReactNode;
/** Messages to display */
messages: MessageListItem[];
/** Tab configuration for multiple views */
tabs?: Array<{ id: string; label: string; messages: MessageListItem[] }>;
/** Chart to display above messages */
chart?: ChartWidgetProps;
/** Message click handler */
onMessageClick?: (message: MessageListItem) => void;
/** Additional CSS class */
className?: string;
}
// ============================================================================
// UTILITY HOOKS
// ============================================================================
/**
* Hook for responsive container width tracking
*/
export function useContainerWidth(ref: React.RefObject<HTMLElement | null>): number {
const [width, setWidth] = useState(0);
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.contentRect.width);
}
});
observer.observe(ref.current);
setWidth(ref.current.getBoundingClientRect().width);
return () => observer.disconnect();
}, [ref]);
return width;
}
/**
* Format large numbers with abbreviations
*/
export function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
}
return num.toLocaleString();
}
// ============================================================================
// STAT WIDGET
// ============================================================================
export const StatWidget: React.FC<StatWidgetProps> = ({
title,
value,
icon,
iconElement,
variant = 'default',
iconPosition = 'left',
solid = false,
trend,
onClick,
className = '',
}) => {
const variantClass = variant !== 'default' ? `ll-stat-widget--${variant}` : '';
const solidClass = solid ? 'll-stat-widget--solid' : '';
const clickableClass = onClick ? 'll-stat-widget--clickable' : '';
const positionClass = iconPosition === 'right' ? 'll-stat-widget--icon-right' : '';
const iconContent = iconElement || (icon && <i className={`${icon} icon-3x`} />);
return (
<div
className={`ll-stat-widget ${variantClass} ${solidClass} ${clickableClass} ${positionClass} ${className}`.trim()}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
<div className="ll-stat-widget__content">
{iconPosition === 'left' && iconContent && (
<div className="ll-stat-widget__icon">{iconContent}</div>
)}
<div className="ll-stat-widget__info">
<h3 className="ll-stat-widget__value">
{typeof value === 'number' ? formatNumber(value) : value}
</h3>
<span className="ll-stat-widget__title">{title}</span>
</div>
{iconPosition === 'right' && iconContent && (
<div className="ll-stat-widget__icon">{iconContent}</div>
)}
</div>
{trend && (
<div className={`ll-stat-widget__trend ll-stat-widget__trend--${trend.direction}`}>
<i className={trend.direction === 'up' ? 'icon-arrow-up22' : 'icon-arrow-down22'} />
<span>{trend.value}%</span>
{trend.label && <span className="ll-stat-widget__trend-label">{trend.label}</span>}
</div>
)}
</div>
);
};
// ============================================================================
// PROGRESS WIDGET
// ============================================================================
export const ProgressWidget: React.FC<ProgressWidgetProps> = ({
title,
subtitle,
progress,
progressLabel,
progressSublabel,
icon,
iconElement,
variant = 'primary',
progressHeight = 2,
className = '',
}) => {
const variantClass = `ll-progress-widget--${variant}`;
const iconContent = iconElement || (icon && <i className={`${icon} icon-2x`} />);
return (
<div className={`ll-progress-widget ${variantClass} ${className}`.trim()}>
<div className="ll-progress-widget__header">
<div className="ll-progress-widget__info">
<h6 className="ll-progress-widget__title">{title}</h6>
{subtitle && <span className="ll-progress-widget__subtitle">{subtitle}</span>}
</div>
{iconContent && <div className="ll-progress-widget__icon">{iconContent}</div>}
</div>
<div className="ll-progress-widget__bar" style={{ height: `${progressHeight}px` }}>
<div
className="ll-progress-widget__fill"
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
/>
</div>
{(progressLabel || progressSublabel) && (
<div className="ll-progress-widget__footer">
{progressSublabel && <span>{progressSublabel}</span>}
{progressLabel && <span className="ll-progress-widget__progress-value">{progressLabel}</span>}
</div>
)}
</div>
);
};
// ============================================================================
// CHART WIDGET - Pure React/SVG Charts (No jQuery)
// ============================================================================
export const ChartWidget: React.FC<ChartWidgetProps> = ({
title,
subtitle,
type,
data,
height = 200,
color = '#26A69A',
colors,
animate = true,
animationDuration = 1000,
showTooltip = true,
tooltipFormatter,
header,
footer,
className = '',
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const width = useContainerWidth(containerRef);
const [animationProgress, setAnimationProgress] = useState(animate ? 0 : 1);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
// Animation effect
useEffect(() => {
if (!animate) return;
const startTime = Date.now();
let animationFrame: number;
const updateAnimation = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(1, elapsed / animationDuration);
setAnimationProgress(progress);
if (progress < 1) {
animationFrame = requestAnimationFrame(updateAnimation);
}
};
animationFrame = requestAnimationFrame(updateAnimation);
return () => cancelAnimationFrame(animationFrame);
}, [animate, animationDuration, data]);
// Calculate scales
const maxValue = useMemo(() => Math.max(...data.map((d) => d.value)), [data]);
const minValue = useMemo(() => Math.min(...data.map((d) => d.value)), [data]);
// Default tooltip formatter
const defaultFormatter = useCallback(
(value: number, label?: string) => `${label ? label + ': ' : ''}${formatNumber(value)}`,
[]
);
const formatter = tooltipFormatter || defaultFormatter;
// Render different chart types
const renderChart = () => {
if (width === 0) return null;
switch (type) {
case 'area':
return renderAreaChart();
case 'line':
return renderLineChart();
case 'bar':
return renderBarChart();
case 'sparkline':
return renderSparklineChart();
case 'donut':
case 'pie':
return renderPieChart();
default:
return null;
}
};
const renderAreaChart = () => {
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const xScale = (i: number) => padding.left + (i / (data.length - 1)) * chartWidth;
const yScale = (v: number) => padding.top + chartHeight - ((v - minValue) / (maxValue - minValue || 1)) * chartHeight;
const pathData = data
.map((d, i) => {
const x = xScale(i);
const y = yScale(d.value * animationProgress);
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
const areaPath = `${pathData} L ${xScale(data.length - 1)} ${height - padding.bottom} L ${padding.left} ${height - padding.bottom} Z`;
return (
<svg width={width} height={height} className="ll-chart-widget__svg">
<defs>
<linearGradient id={`area-gradient-${color.replace('#', '')}`} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="0.8" />
<stop offset="100%" stopColor={color} stopOpacity="0.1" />
</linearGradient>
</defs>
<path
d={areaPath}
fill={`url(#area-gradient-${color.replace('#', '')})`}
className="ll-chart-widget__area"
/>
<path d={pathData} fill="none" stroke={color} strokeWidth="2" className="ll-chart-widget__line" />
{showTooltip &&
data.map((d, i) => (
<circle
key={i}
cx={xScale(i)}
cy={yScale(d.value * animationProgress)}
r={hoveredIndex === i ? 6 : 4}
fill={color}
className="ll-chart-widget__point"
onMouseEnter={(e) => {
setHoveredIndex(i);
setTooltipPosition({ x: e.clientX, y: e.clientY });
}}
onMouseLeave={() => setHoveredIndex(null)}
/>
))}
</svg>
);
};
const renderLineChart = () => {
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const xScale = (i: number) => padding.left + (i / (data.length - 1)) * chartWidth;
const yScale = (v: number) => padding.top + chartHeight - ((v - minValue) / (maxValue - minValue || 1)) * chartHeight;
const pathData = data
.map((d, i) => {
const x = xScale(i);
const y = yScale(d.value * animationProgress);
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
return (
<svg width={width} height={height} className="ll-chart-widget__svg">
<path d={pathData} fill="none" stroke={color} strokeWidth="2" className="ll-chart-widget__line" />
{showTooltip &&
data.map((d, i) => (
<circle
key={i}
cx={xScale(i)}
cy={yScale(d.value * animationProgress)}
r={hoveredIndex === i ? 6 : 4}
fill={color}
className="ll-chart-widget__point"
onMouseEnter={(e) => {
setHoveredIndex(i);
setTooltipPosition({ x: e.clientX, y: e.clientY });
}}
onMouseLeave={() => setHoveredIndex(null)}
/>
))}
</svg>
);
};
const renderBarChart = () => {
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const barGap = 0.3;
const barWidth = (chartWidth / data.length) * (1 - barGap);
const barSpacing = chartWidth / data.length;
return (
<svg width={width} height={height} className="ll-chart-widget__svg">
{data.map((d, i) => {
const barHeight = ((d.value / maxValue) * chartHeight * animationProgress);
const x = padding.left + i * barSpacing + (barSpacing - barWidth) / 2;
const y = padding.top + chartHeight - barHeight;
return (
<rect
key={i}
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={d.color || color}
rx={2}
className={`ll-chart-widget__bar ${hoveredIndex === i ? 'll-chart-widget__bar--hovered' : ''}`}
onMouseEnter={(e) => {
setHoveredIndex(i);
setTooltipPosition({ x: e.clientX, y: e.clientY });
}}
onMouseLeave={() => setHoveredIndex(null)}
/>
);
})}
</svg>
);
};
const renderSparklineChart = () => {
const chartHeight = height;
const xScale = (i: number) => (i / (data.length - 1)) * width;
const yScale = (v: number) => chartHeight - ((v - minValue) / (maxValue - minValue || 1)) * chartHeight;
const pathData = data
.map((d, i) => {
const x = xScale(i);
const y = yScale(d.value * animationProgress);
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
return (
<svg width={width} height={height} className="ll-chart-widget__svg ll-chart-widget__svg--sparkline">
<path d={pathData} fill="none" stroke={color} strokeWidth="1.5" />
</svg>
);
};
const renderPieChart = () => {
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(centerX, centerY) - 10;
const innerRadius = type === 'donut' ? radius * 0.6 : 0;
const total = data.reduce((sum, d) => sum + d.value, 0);
let currentAngle = -Math.PI / 2;
const defaultColors = [
'#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0',
'#00BCD4', '#FF5722', '#795548', '#607D8B', '#3F51B5',
];
const chartColors = colors || defaultColors;
const slices = data.map((d, i) => {
const sliceAngle = (d.value / total) * 2 * Math.PI * animationProgress;
const startAngle = currentAngle;
const endAngle = currentAngle + sliceAngle;
currentAngle = endAngle;
const x1 = centerX + radius * Math.cos(startAngle);
const y1 = centerY + radius * Math.sin(startAngle);
const x2 = centerX + radius * Math.cos(endAngle);
const y2 = centerY + radius * Math.sin(endAngle);
const ix1 = centerX + innerRadius * Math.cos(startAngle);
const iy1 = centerY + innerRadius * Math.sin(startAngle);
const ix2 = centerX + innerRadius * Math.cos(endAngle);
const iy2 = centerY + innerRadius * Math.sin(endAngle);
const largeArc = sliceAngle > Math.PI ? 1 : 0;
const path =
type === 'donut'
? `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} L ${ix2} ${iy2} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${ix1} ${iy1} Z`
: `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
return (
<path
key={i}
d={path}
fill={d.color || chartColors[i % chartColors.length]}
className={`ll-chart-widget__slice ${hoveredIndex === i ? 'll-chart-widget__slice--hovered' : ''}`}
onMouseEnter={(e) => {
setHoveredIndex(i);
setTooltipPosition({ x: e.clientX, y: e.clientY });
}}
onMouseLeave={() => setHoveredIndex(null)}
/>
);
});
return (
<svg width={width} height={height} className="ll-chart-widget__svg">
{slices}
</svg>
);
};
return (
<div className={`ll-chart-widget ${className}`.trim()}>
{(title || subtitle || header) && (
<div className="ll-chart-widget__header">
{header || (
<>
{title && <h6 className="ll-chart-widget__title">{title}</h6>}
{subtitle && <span className="ll-chart-widget__subtitle">{subtitle}</span>}
</>
)}
</div>
)}
<div ref={containerRef} className="ll-chart-widget__container">
{renderChart()}
{showTooltip && hoveredIndex !== null && (
<div
className="ll-chart-widget__tooltip"
style={{
position: 'fixed',
left: tooltipPosition.x + 10,
top: tooltipPosition.y - 30,
}}
>
{formatter(data[hoveredIndex].value, data[hoveredIndex].label)}
</div>
)}
</div>
{footer && <div className="ll-chart-widget__footer">{footer}</div>}
</div>
);
};
// ============================================================================
// CONTENT WIDGET
// ============================================================================
export const ContentWidget: React.FC<ContentWidgetProps> = ({
title,
headerActions,
children,
footer,
noPadding = false,
className = '',
}) => {
return (
<div className={`ll-content-widget ${className}`.trim()}>
{(title || headerActions) && (
<div className="ll-content-widget__header">
{title && <h6 className="ll-content-widget__title">{title}</h6>}
{headerActions && <div className="ll-content-widget__actions">{headerActions}</div>}
</div>
)}
<div className={`ll-content-widget__body ${noPadding ? 'll-content-widget__body--no-padding' : ''}`}>
{children}
</div>
{footer && <div className="ll-content-widget__footer">{footer}</div>}
</div>
);
};
// ============================================================================
// USER WIDGET
// ============================================================================
export const UserWidget: React.FC<UserWidgetProps> = ({
name,
role,
avatar,
background,
variant = 'primary',
details,
socialLinks,
actions,
className = '',
}) => {
const variantClass = `ll-user-widget--${variant}`;
const hasBackground = background?.startsWith('http') || background?.startsWith('/') || background?.startsWith('data:');
return (
<div className={`ll-user-widget ${variantClass} ${className}`.trim()}>
<div
className="ll-user-widget__header"
style={hasBackground ? { backgroundImage: `url(${background})` } : undefined}
>
<div className="ll-user-widget__avatar-wrapper">
{avatar ? (
<img src={avatar} alt={name} className="ll-user-widget__avatar" />
) : (
<div className="ll-user-widget__avatar ll-user-widget__avatar--placeholder">
{name.charAt(0).toUpperCase()}
</div>
)}
{actions && <div className="ll-user-widget__avatar-actions">{actions}</div>}
</div>
<h6 className="ll-user-widget__name">{name}</h6>
{role && <span className="ll-user-widget__role">{role}</span>}
{socialLinks && socialLinks.length > 0 && (
<div className="ll-user-widget__social">
{socialLinks.map((link, i) => (
<a key={i} href={link.href} className="ll-user-widget__social-link">
<i className={link.icon} />
</a>
))}
</div>
)}
</div>
{details && details.length > 0 && (
<div className="ll-user-widget__details">
{details.map((detail, i) => (
<div key={i} className="ll-user-widget__detail">
<span className="ll-user-widget__detail-label">{detail.label}</span>
<span className="ll-user-widget__detail-value">{detail.value}</span>
</div>
))}
</div>
)}
</div>
);
};
// ============================================================================
// MESSAGE LIST WIDGET
// ============================================================================
export const MessageListWidget: React.FC<MessageListWidgetProps> = ({
title,
headerExtra,
messages,
tabs,
chart,
onMessageClick,
className = '',
}) => {
const [activeTab, setActiveTab] = useState(tabs?.[0]?.id || '');
const currentMessages = tabs ? tabs.find((t) => t.id === activeTab)?.messages || [] : messages;
return (
<div className={`ll-message-list-widget ${className}`.trim()}>
{(title || headerExtra) && (
<div className="ll-message-list-widget__header">
{title && <h6 className="ll-message-list-widget__title">{title}</h6>}
{headerExtra && <div className="ll-message-list-widget__header-extra">{headerExtra}</div>}
</div>
)}
{chart && (
<div className="ll-message-list-widget__chart">
<ChartWidget {...chart} />
</div>
)}
{tabs && tabs.length > 0 && (
<div className="ll-message-list-widget__tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`ll-message-list-widget__tab ${activeTab === tab.id ? 'll-message-list-widget__tab--active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
)}
<div className="ll-message-list-widget__list">
{currentMessages.map((msg) => (
<div
key={msg.id}
className={`ll-message-list-widget__item ${onMessageClick ? 'll-message-list-widget__item--clickable' : ''}`}
onClick={() => onMessageClick?.(msg)}
>
<div className="ll-message-list-widget__avatar-container">
{msg.avatar ? (
<img src={msg.avatar} alt={msg.name} className="ll-message-list-widget__avatar" />
) : (
<div className="ll-message-list-widget__avatar ll-message-list-widget__avatar--placeholder">
{msg.name.charAt(0).toUpperCase()}
</div>
)}
{msg.badge !== undefined && msg.badge > 0 && (
<span className="ll-message-list-widget__badge">{msg.badge}</span>
)}
{msg.online !== undefined && (
<span className={`ll-message-list-widget__status ${msg.online ? 'll-message-list-widget__status--online' : ''}`} />
)}
</div>
<div className="ll-message-list-widget__content">
<div className="ll-message-list-widget__meta">
<span className="ll-message-list-widget__name">{msg.name}</span>
<span className="ll-message-list-widget__time">{msg.time}</span>
</div>
<p className="ll-message-list-widget__message">{msg.message}</p>
</div>
</div>
))}
</div>
</div>
);
};
// ============================================================================
// QUICK STATS GRID
// ============================================================================
export interface QuickStatsProps {
stats: Array<{
label: string;
value: string | number;
icon?: string;
variant?: WidgetVariant;
trend?: { value: number; direction: 'up' | 'down' };
}>;
columns?: 2 | 3 | 4;
className?: string;
}
export const QuickStats: React.FC<QuickStatsProps> = ({
stats,
columns = 4,
className = '',
}) => {
return (
<div className={`ll-quick-stats ll-quick-stats--cols-${columns} ${className}`.trim()}>
{stats.map((stat, i) => (
<StatWidget
key={i}
title={stat.label}
value={stat.value}
icon={stat.icon}
variant={stat.variant}
trend={stat.trend}
/>
))}
</div>
);
};
// Default export
export default {
StatWidget,
ProgressWidget,
ChartWidget,
ContentWidget,
UserWidget,
MessageListWidget,
QuickStats,
useContainerWidth,
formatNumber,
};

92
src/components/Wizard.tsx Normal file
View File

@@ -0,0 +1,92 @@
import React, { useMemo, useState } from 'react';
import { Button } from './Button';
export type WizardStep = {
key: string;
title: React.ReactNode;
description?: React.ReactNode;
render: (ctx: { isActive: boolean }) => React.ReactNode;
};
export type WizardProps = {
steps: WizardStep[];
initialStep?: string;
onFinish?: () => void;
onChange?: (key: string) => void;
className?: string;
};
/**
* Simple stepper wizard with next/prev and final callback.
*/
export function Wizard({ steps, initialStep, onFinish, onChange, className = '' }: WizardProps) {
const stepOrder = useMemo(() => steps.map(s => s.key), [steps]);
const initial = initialStep && stepOrder.includes(initialStep) ? initialStep : stepOrder[0];
const [current, setCurrent] = useState(initial);
const currentIdx = stepOrder.indexOf(current);
const goTo = (key: string) => {
setCurrent(key);
onChange?.(key);
};
const next = () => {
const nextKey = stepOrder[currentIdx + 1];
if (nextKey) goTo(nextKey);
else onFinish?.();
};
const prev = () => {
const prevKey = stepOrder[currentIdx - 1];
if (prevKey) goTo(prevKey);
};
return (
<div className={`wizard ${className}`.trim()}>
<div className="nav nav-pills mb-3">
{steps.map((step, idx) => {
const isActive = step.key === current;
const isDone = stepOrder.indexOf(step.key) < currentIdx;
return (
<button
key={step.key}
className={`nav-link ${isActive ? 'active' : ''}`}
type="button"
onClick={() => goTo(step.key)}
>
<span className="me-2">{idx + 1}.</span>
<span>{step.title}</span>
{step.description ? <div className="small text-muted">{step.description}</div> : null}
{isDone ? <span className="badge bg-success ms-2">Done</span> : null}
</button>
);
})}
</div>
<div className="card">
<div className="card-body">
{steps.map(step => (
<div key={step.key} hidden={step.key !== current}>
{step.render({ isActive: step.key === current })}
</div>
))}
</div>
<div className="card-footer d-flex justify-content-between">
<Button variant="secondary" onClick={prev} disabled={currentIdx === 0}>
Previous
</Button>
{currentIdx < stepOrder.length - 1 ? (
<Button variant="primary" onClick={next}>
Next
</Button>
) : (
<Button variant="success" onClick={onFinish}>
Finish
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
/**
* GenUI Message Component
*
* Renders chat messages with markdown support.
* Works with both human and AI messages.
*/
import React from 'react';
export interface MessageProps {
/** Message role */
role: 'user' | 'assistant' | 'system';
/** Message content (supports markdown) */
content: string;
/** Optional timestamp */
timestamp?: number;
/** Additional CSS class */
className?: string;
/** Whether to render as markdown (default: true for assistant) */
markdown?: boolean;
}
/**
* Simple markdown-to-HTML converter for common patterns
* For full markdown support, apps should use a library like react-markdown
*/
function simpleMarkdown(text: string): string {
return text
// Escape HTML
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Code blocks
.replace(/```(\w+)?\n([\s\S]+?)```/g, '<pre><code>$2</code></pre>')
// Inline code
.replace(/`(.+?)`/g, '<code>$1</code>')
// Links
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Line breaks
.replace(/\n/g, '<br />');
}
/**
* Message component for GenUI chat interface
*/
export function Message({
role,
content,
timestamp,
className = '',
markdown,
}: MessageProps): React.ReactElement {
const shouldRenderMarkdown = markdown ?? role === 'assistant';
const roleStyles: Record<string, React.CSSProperties> = {
user: {
backgroundColor: 'var(--bs-primary, #0d6efd)',
color: 'white',
marginLeft: 'auto',
borderRadius: '1rem 1rem 0.25rem 1rem',
},
assistant: {
backgroundColor: 'var(--bs-light, #f8f9fa)',
color: 'var(--bs-body-color, #212529)',
marginRight: 'auto',
borderRadius: '1rem 1rem 1rem 0.25rem',
},
system: {
backgroundColor: 'var(--bs-warning-bg-subtle, #fff3cd)',
color: 'var(--bs-warning-text-emphasis, #664d03)',
margin: '0 auto',
borderRadius: '0.5rem',
fontSize: '0.875rem',
},
};
const baseStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
maxWidth: '80%',
wordBreak: 'break-word',
...roleStyles[role],
};
return (
<div
className={`genui-message genui-message-${role} ${className}`}
style={baseStyle}
data-role={role}
>
{shouldRenderMarkdown ? (
<div
className="genui-message-content"
dangerouslySetInnerHTML={{ __html: simpleMarkdown(content) }}
/>
) : (
<div className="genui-message-content">{content}</div>
)}
{timestamp && (
<div
className="genui-message-time"
style={{
fontSize: '0.75rem',
opacity: 0.7,
marginTop: '0.25rem',
}}
>
{new Date(timestamp).toLocaleTimeString()}
</div>
)}
</div>
);
}
export default Message;

View File

@@ -0,0 +1,285 @@
/**
* GenUI Search Results Component
*
* Generic component for displaying search results from WebMCP tools.
* Can be customized with render props for app-specific styling.
*/
import React from 'react';
import type { SearchResult } from '../types';
export interface SearchResultsProps {
/** Array of search results */
results: SearchResult[];
/** Optional title for the results section */
title?: string;
/** Whether results are loading */
loading?: boolean;
/** Error message if search failed */
error?: string | null;
/** Custom render function for each result */
renderResult?: (result: SearchResult, index: number) => React.ReactNode;
/** Called when a result is clicked */
onResultClick?: (result: SearchResult) => void;
/** Additional CSS class */
className?: string;
/** Maximum results to display (default: 10) */
maxResults?: number;
/** Empty state message */
emptyMessage?: string;
}
/**
* Default result renderer
*/
function DefaultResultItem({
result,
onClick,
}: {
result: SearchResult;
onClick?: (result: SearchResult) => void;
}): React.ReactElement {
const handleClick = () => {
if (onClick) {
onClick(result);
} else if (result.url) {
window.location.href = result.url;
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
};
return (
<div
className="genui-search-result"
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
style={{
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--bs-border-color, #dee2e6)',
cursor: 'pointer',
transition: 'background-color 0.15s ease-in-out',
}}
>
<div className="d-flex justify-content-between align-items-start">
<div style={{ flex: 1, minWidth: 0 }}>
<h6
className="mb-1"
style={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{result.title}
</h6>
<p
className="mb-1 text-muted"
style={{
fontSize: '0.875rem',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{result.description}
</p>
<div className="d-flex gap-2 align-items-center">
<span
className="badge"
style={{
backgroundColor: 'var(--bs-secondary-bg, #e9ecef)',
color: 'var(--bs-secondary-color, #6c757d)',
fontSize: '0.75rem',
fontWeight: 500,
}}
>
{result.type}
</span>
{result.relevance > 0 && (
<span
style={{
fontSize: '0.75rem',
color: 'var(--bs-secondary-color, #6c757d)',
}}
>
{Math.round(result.relevance)}% match
</span>
)}
</div>
</div>
</div>
</div>
);
}
/**
* Loading skeleton for search results
*/
function LoadingSkeleton(): React.ReactElement {
return (
<div className="genui-search-loading">
{[1, 2, 3].map((i) => (
<div
key={i}
className="genui-search-skeleton"
style={{
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--bs-border-color, #dee2e6)',
}}
>
<div
className="skeleton-line"
style={{
height: '1rem',
width: '60%',
backgroundColor: 'var(--bs-secondary-bg, #e9ecef)',
borderRadius: '0.25rem',
marginBottom: '0.5rem',
animation: 'pulse 1.5s infinite',
}}
/>
<div
className="skeleton-line"
style={{
height: '0.875rem',
width: '90%',
backgroundColor: 'var(--bs-secondary-bg, #e9ecef)',
borderRadius: '0.25rem',
marginBottom: '0.25rem',
animation: 'pulse 1.5s infinite',
}}
/>
<div
className="skeleton-line"
style={{
height: '0.75rem',
width: '30%',
backgroundColor: 'var(--bs-secondary-bg, #e9ecef)',
borderRadius: '0.25rem',
animation: 'pulse 1.5s infinite',
}}
/>
</div>
))}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`}</style>
</div>
);
}
/**
* Search Results component for GenUI
*/
export function SearchResults({
results,
title,
loading = false,
error = null,
renderResult,
onResultClick,
className = '',
maxResults = 10,
emptyMessage = 'No results found',
}: SearchResultsProps): React.ReactElement {
const displayResults = results.slice(0, maxResults);
return (
<div
className={`genui-search-results ${className}`}
style={{
backgroundColor: 'var(--bs-body-bg, #fff)',
borderRadius: '0.5rem',
border: '1px solid var(--bs-border-color, #dee2e6)',
overflow: 'hidden',
}}
>
{title && (
<div
className="genui-search-header"
style={{
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--bs-border-color, #dee2e6)',
backgroundColor: 'var(--bs-tertiary-bg, #f8f9fa)',
}}
>
<h5 className="mb-0" style={{ fontSize: '1rem', fontWeight: 600 }}>
{title}
</h5>
</div>
)}
{loading && <LoadingSkeleton />}
{error && (
<div
className="genui-search-error alert alert-danger m-3"
role="alert"
>
{error}
</div>
)}
{!loading && !error && displayResults.length === 0 && (
<div
className="genui-search-empty"
style={{
padding: '2rem',
textAlign: 'center',
color: 'var(--bs-secondary-color, #6c757d)',
}}
>
{emptyMessage}
</div>
)}
{!loading && !error && displayResults.length > 0 && (
<div className="genui-search-list">
{displayResults.map((result, index) =>
renderResult ? (
<React.Fragment key={result.id}>
{renderResult(result, index)}
</React.Fragment>
) : (
<DefaultResultItem
key={result.id}
result={result}
onClick={onResultClick}
/>
)
)}
</div>
)}
{!loading && results.length > maxResults && (
<div
className="genui-search-more"
style={{
padding: '0.5rem 1rem',
textAlign: 'center',
fontSize: '0.875rem',
color: 'var(--bs-secondary-color, #6c757d)',
borderTop: '1px solid var(--bs-border-color, #dee2e6)',
}}
>
Showing {maxResults} of {results.length} results
</div>
)}
</div>
);
}
export default SearchResults;

View File

@@ -0,0 +1,8 @@
/**
* GenUI Components
*
* Reusable UI components for GenUI integration.
*/
export { Message, type MessageProps } from './Message';
export { SearchResults, type SearchResultsProps } from './SearchResults';

191
src/genui/content.tsx Normal file
View File

@@ -0,0 +1,191 @@
"use client";
import React from "react";
/**
* GenUI Content Renderers
*
* Render functions for structured AI/skill output data.
* Used by @gsc/chat ActionRenderer and GenUIModal.
*/
/* ── Table ────────────────────────────────────────────────────────────────── */
export function renderTable(data: unknown, compact?: boolean): React.ReactNode {
if (!data) return null;
// Array of objects → table with headers from keys
if (Array.isArray(data) && data.length > 0 && typeof data[0] === "object") {
const rows = data as Record<string, unknown>[];
const keys = Object.keys(rows[0]);
return (
<div className={`table-responsive ${compact ? "small" : ""}`}>
<table className={`table table-bordered table-striped mb-0 ${compact ? "table-sm" : ""}`}>
<thead>
<tr>
{keys.map((k) => (
<th key={k}>{formatHeader(k)}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{keys.map((k) => (
<td key={k}>{formatCell(row[k])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// Single object → key/value table
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
const entries = Object.entries(data as Record<string, unknown>);
return (
<div className={`table-responsive ${compact ? "small" : ""}`}>
<table className={`table table-bordered mb-0 ${compact ? "table-sm" : ""}`}>
<tbody>
{entries.map(([key, val]) => (
<tr key={key}>
<th style={{ width: "30%" }}>{formatHeader(key)}</th>
<td>{formatCell(val)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
return <pre className="mb-0 small">{String(data)}</pre>;
}
/* ── Card Grid ────────────────────────────────────────────────────────────── */
export function renderCardGrid(data: unknown, compact?: boolean): React.ReactNode {
if (!data || !Array.isArray(data)) return null;
const items = data as Record<string, unknown>[];
return (
<div className={`row g-2 ${compact ? "small" : ""}`}>
{items.map((item, i) => {
const title = (item.name || item.title || item.label || `Item ${i + 1}`) as string;
const entries = Object.entries(item).filter(
([k]) => !["name", "title", "label", "id"].includes(k),
);
return (
<div key={i} className={compact ? "col-12" : "col-sm-6 col-lg-4"}>
<div className="card border h-100">
<div className="card-body p-2">
<h6 className={`card-title ${compact ? "mb-1 small fw-semibold" : "mb-2"}`}>
{title}
</h6>
{entries.slice(0, compact ? 2 : 6).map(([key, val]) => (
<div key={key} className="d-flex justify-content-between small">
<span className="text-muted">{formatHeader(key)}</span>
<span>{formatCell(val)}</span>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
);
}
/* ── List Group ───────────────────────────────────────────────────────────── */
export function renderListGroup(data: unknown, compact?: boolean): React.ReactNode {
if (!data) return null;
// Array of primitives
if (Array.isArray(data) && (data.length === 0 || typeof data[0] !== "object")) {
return (
<ul className={`list-group list-group-flush ${compact ? "small" : ""}`}>
{(data as unknown[]).map((item, i) => (
<li key={i} className="list-group-item px-0 py-1">
{String(item)}
</li>
))}
</ul>
);
}
// Array of objects
if (Array.isArray(data)) {
const items = data as Record<string, unknown>[];
return (
<ul className={`list-group list-group-flush ${compact ? "small" : ""}`}>
{items.map((item, i) => {
const title = (item.name || item.title || item.label || "") as string;
const desc = (item.description || item.value || item.status || "") as string;
return (
<li key={i} className="list-group-item px-0 py-1 d-flex justify-content-between align-items-center">
<span className="fw-medium">{title || `#${i + 1}`}</span>
{desc && <span className="text-muted small">{String(desc)}</span>}
</li>
);
})}
</ul>
);
}
return <pre className="mb-0 small">{String(data)}</pre>;
}
/* ── Code Block ───────────────────────────────────────────────────────────── */
export function renderCodeBlock(data: unknown): React.ReactNode {
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return (
<pre
className="bg-light border rounded p-2 mb-0 small"
style={{ maxHeight: 400, overflow: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
<code>{text}</code>
</pre>
);
}
/* ── Raw JSON ─────────────────────────────────────────────────────────────── */
export function renderRawJson(data: unknown): React.ReactNode {
const text = JSON.stringify(data, null, 2);
return (
<pre
className="bg-light border rounded p-2 mb-0 small font-monospace"
style={{ maxHeight: 400, overflow: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
<code>{text}</code>
</pre>
);
}
/* ── Helpers ──────────────────────────────────────────────────────────────── */
function formatHeader(key: string): string {
return key
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/^./, (c) => c.toUpperCase());
}
function formatCell(value: unknown): React.ReactNode {
if (value == null) return <span className="text-muted"></span>;
if (typeof value === "boolean")
return <i className={value ? "ph-check text-success" : "ph-x text-danger"}></i>;
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}

8
src/genui/hooks/index.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* GenUI Hooks
*
* React hooks for GenUI integration.
*/
export { useGenUI, type UseGenUIOptions, type UseGenUIResult } from './useGenUI';
export { useWebMCP, type UseWebMCPOptions, type UseWebMCPResult } from './useWebMCP';

218
src/genui/hooks/useGenUI.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* useGenUI Hook
*
* React hook for managing GenUI chat state and API interactions.
*/
import { useState, useCallback, useRef } from 'react';
import type { GenUIMessage, GenUIResponse, GenUIActionType } from '../types';
export interface UseGenUIOptions {
/** API endpoint for GenUI chat */
endpoint?: string;
/** Initial messages */
initialMessages?: GenUIMessage[];
/** Called when a new action is received */
onAction?: (action: GenUIActionType, data?: unknown) => void;
/** Called when an error occurs */
onError?: (error: Error) => void;
/** Custom fetch function */
fetchFn?: typeof fetch;
}
export interface UseGenUIResult {
/** Current messages */
messages: GenUIMessage[];
/** Whether a request is in progress */
loading: boolean;
/** Last error, if any */
error: Error | null;
/** Send a message */
sendMessage: (content: string) => Promise<void>;
/** Clear all messages */
clearMessages: () => void;
/** Add a message directly */
addMessage: (message: Omit<GenUIMessage, 'id' | 'timestamp'>) => void;
/** Last action type received */
lastAction: GenUIActionType | null;
/** Last action data received */
lastActionData: unknown;
}
/**
* Generate a unique message ID
*/
function generateId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Hook for managing GenUI chat interactions
*
* @example
* ```tsx
* const { messages, loading, sendMessage } = useGenUI({
* endpoint: '/api/genui',
* onAction: (action, data) => {
* if (action === 'SHOW_SEARCH_RESULTS') {
* setSearchResults(data);
* }
* },
* });
*
* const handleSubmit = (text: string) => {
* sendMessage(text);
* };
* ```
*/
export function useGenUI(options: UseGenUIOptions = {}): UseGenUIResult {
const {
endpoint = '/api/genui',
initialMessages = [],
onAction,
onError,
fetchFn = fetch,
} = options;
const [messages, setMessages] = useState<GenUIMessage[]>(initialMessages);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [lastAction, setLastAction] = useState<GenUIActionType | null>(null);
const [lastActionData, setLastActionData] = useState<unknown>(null);
// Track abort controller for request cancellation
const abortControllerRef = useRef<AbortController | null>(null);
/**
* Add a message to the chat
*/
const addMessage = useCallback(
(message: Omit<GenUIMessage, 'id' | 'timestamp'>) => {
const newMessage: GenUIMessage = {
...message,
id: generateId(),
timestamp: Date.now(),
};
setMessages((prev) => [...prev, newMessage]);
return newMessage;
},
[]
);
/**
* Clear all messages
*/
const clearMessages = useCallback(() => {
setMessages([]);
setError(null);
setLastAction(null);
setLastActionData(null);
}, []);
/**
* Send a message to the GenUI API
*/
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim()) return;
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Add user message
const userMessage: GenUIMessage = {
id: generateId(),
role: 'user',
content: content.trim(),
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMessage]);
// Start loading
setLoading(true);
setError(null);
// Create abort controller
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const response = await fetchFn(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: content.trim(),
history: messages.map((m) => ({
role: m.role,
content: m.content,
})),
}),
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data: GenUIResponse = await response.json();
// Add assistant message
const assistantMessage: GenUIMessage = {
id: generateId(),
role: 'assistant',
content: data.message,
action: data.action,
data: data.data,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, assistantMessage]);
// Update action state
if (data.action) {
setLastAction(data.action);
setLastActionData(data.data);
onAction?.(data.action, data.data);
}
} catch (err) {
// Ignore abort errors
if (err instanceof Error && err.name === 'AbortError') {
return;
}
const error = err instanceof Error ? err : new Error('Unknown error');
setError(error);
onError?.(error);
// Add error message
const errorMessage: GenUIMessage = {
id: generateId(),
role: 'system',
content: `Error: ${error.message}`,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setLoading(false);
abortControllerRef.current = null;
}
},
[endpoint, messages, fetchFn, onAction, onError]
);
return {
messages,
loading,
error,
sendMessage,
clearMessages,
addMessage,
lastAction,
lastActionData,
};
}
export default useGenUI;

View File

@@ -0,0 +1,100 @@
/**
* useWebMCP Hook
*
* React hook for checking WebMCP availability and managing tool registration.
*/
import { useState, useEffect, useCallback } from 'react';
import { isWebMCPAvailable, registerWebMCPTools, type ToolWithHandler } from '../webmcp';
export interface UseWebMCPOptions {
/** Tools to register when WebMCP becomes available */
tools?: ToolWithHandler[];
/** Whether to automatically register tools */
autoRegister?: boolean;
}
export interface UseWebMCPResult {
/** Whether WebMCP is available in the browser */
available: boolean;
/** Whether tools have been registered */
registered: boolean;
/** Manually register tools */
register: (tools: ToolWithHandler[]) => () => void;
/** Unregister all registered tools */
unregister: () => void;
}
/**
* Hook to check WebMCP availability and manage tool registration
*
* @example
* ```tsx
* const { available, registered } = useWebMCP({
* tools: myTools,
* autoRegister: true,
* });
*
* if (available && registered) {
* console.log('WebMCP tools are active');
* }
* ```
*/
export function useWebMCP(options: UseWebMCPOptions = {}): UseWebMCPResult {
const { tools = [], autoRegister = true } = options;
const [available, setAvailable] = useState(false);
const [registered, setRegistered] = useState(false);
const [cleanup, setCleanup] = useState<(() => void) | null>(null);
// Check availability on mount
useEffect(() => {
setAvailable(isWebMCPAvailable());
}, []);
// Register function
const register = useCallback((toolsToRegister: ToolWithHandler[]) => {
if (!isWebMCPAvailable()) {
console.warn('[useWebMCP] WebMCP not available');
return () => {};
}
const cleanupFn = registerWebMCPTools(toolsToRegister);
setCleanup(() => cleanupFn);
setRegistered(true);
return cleanupFn;
}, []);
// Unregister function
const unregister = useCallback(() => {
if (cleanup) {
cleanup();
setCleanup(null);
setRegistered(false);
}
}, [cleanup]);
// Auto-register tools if enabled
useEffect(() => {
if (autoRegister && available && tools.length > 0 && !registered) {
const cleanupFn = registerWebMCPTools(tools);
setCleanup(() => cleanupFn);
setRegistered(true);
return () => {
cleanupFn();
setRegistered(false);
};
}
return undefined;
}, [autoRegister, available, tools, registered]);
return {
available,
registered,
register,
unregister,
};
}
export default useWebMCP;

20
src/genui/index.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* GenUI Module
*
* Shared utilities, components, and hooks for GenUI/AI integration
* across all GSC applications (www, admin, portal).
*
* @module genui
*/
// Core types
export * from './types';
// WebMCP utilities
export * from './webmcp';
// Components
export * from './components';
// Hooks
export * from './hooks';

219
src/genui/types.ts Normal file
View File

@@ -0,0 +1,219 @@
/**
* GenUI Core Type Definitions
*
* Shared types for GenUI/AI integration across all Remix apps.
* These types are framework-agnostic and can be used by admin, portal, and other apps.
*/
// ============================================================================
// Content Types
// ============================================================================
/**
* Base content item interface
* All app-specific content types should extend this
*/
export interface ContentItem {
id: string;
type: string;
title: string;
description: string;
url: string;
metadata?: Record<string, unknown>;
}
/**
* Search result with relevance scoring
*/
export interface SearchResult {
type: string;
id: string;
title: string;
description: string;
url: string;
relevance: number;
}
/**
* Content type filter options
*/
export type ContentType = 'all' | string;
// ============================================================================
// Client Detection Types
// ============================================================================
/**
* Client types for hybrid web architecture
* - human-browser: Traditional browser client (v1 mode)
* - ai-agent-mcp: AI agent with WebMCP support (v3 mode)
* - ai-agent-basic: AI agent without MCP (v3 mode, llms.txt only)
*/
export type ClientType = 'human-browser' | 'ai-agent-mcp' | 'ai-agent-basic';
// ============================================================================
// GenUI Response Types
// ============================================================================
/**
* GenUI action types - apps can extend with their own actions
*/
export type GenUIActionType =
| 'TEXT_ONLY'
| 'SHOW_SEARCH_RESULTS'
| string;
/**
* GenUI API response structure
*/
export interface GenUIResponse<T = unknown> {
action: GenUIActionType | null;
message: string;
data?: T;
}
/**
* GenUI chat message
*/
export interface GenUIMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
action?: GenUIActionType | null;
data?: unknown;
timestamp?: number;
}
// ============================================================================
// WebMCP Types
// ============================================================================
/**
* WebMCP tool response format
*/
export interface WebMCPToolResponse {
content: Array<{ type: 'text'; text: string }>;
}
/**
* WebMCP tool definition
*/
export interface WebMCPToolDefinition {
name: string;
description: string;
inputSchema: {
type: 'object';
properties: Record<string, unknown>;
required?: readonly string[];
};
}
/**
* WebMCP tool registration
*/
export interface WebMCPToolRegistration {
unregister: () => void;
}
/**
* WebMCP model context interface (browser API)
*/
export interface WebMCPModelContext {
registerTool: (tool: {
name: string;
description: string;
inputSchema: object;
execute: (args: Record<string, unknown>) => Promise<WebMCPToolResponse>;
}) => WebMCPToolRegistration;
}
// ============================================================================
// Content API Interface
// ============================================================================
/**
* Generic Content API interface
* Apps implement this with their specific content types
*/
export interface ContentAPI<T extends ContentItem = ContentItem> {
/**
* Search content with optional type filter
*/
search(query: string, type?: ContentType): Promise<SearchResult[]>;
/**
* Get a specific item by type and ID
*/
getItem(type: string, id: string): Promise<T | null>;
/**
* List all items of a specific type
*/
listItems(type: string): Promise<T[]>;
/**
* Get available content types
*/
getContentTypes(): string[];
}
// ============================================================================
// Discovery Types
// ============================================================================
/**
* llms.txt API response structure
*/
export interface LLMsAPIResponse {
name: string;
description: string;
baseUrl: string;
locale: string;
lastUpdated: string;
webmcp: {
available: boolean;
tools: string[];
endpoint: string;
};
navigation: Array<{
label: string;
href: string;
description?: string;
}>;
[key: string]: unknown; // Allow app-specific content sections
}
/**
* GenUI configuration for app
*/
export interface GenUIConfig {
enabled: boolean;
webmcpAvailable: boolean;
tools: string[];
chatEndpoint: string;
llmsEndpoint: string;
}
// ============================================================================
// Utility Types
// ============================================================================
/**
* Search relevance calculation options
*/
export interface SearchOptions {
exactMatchBonus?: number;
wordMatchScore?: number;
titleBonus?: number;
maxResults?: number;
}
/**
* Default search options
*/
export const DEFAULT_SEARCH_OPTIONS: Required<SearchOptions> = {
exactMatchBonus: 100,
wordMatchScore: 10,
titleBonus: 50,
maxResults: 50,
};

160
src/genui/webmcp/index.ts Normal file
View File

@@ -0,0 +1,160 @@
/**
* WebMCP Registration Utilities
*
* Provides utilities for registering WebMCP tools with the browser's
* navigator.modelContext API for AI agent interaction.
*/
import type {
WebMCPToolDefinition,
WebMCPToolResponse,
WebMCPToolRegistration,
WebMCPModelContext,
} from '../types';
// ============================================================================
// Type Augmentation
// ============================================================================
declare global {
interface Navigator {
modelContext?: WebMCPModelContext;
}
}
// ============================================================================
// Availability Check
// ============================================================================
/**
* Check if WebMCP is available in the browser
*/
export function isWebMCPAvailable(): boolean {
return typeof navigator !== 'undefined' && 'modelContext' in navigator;
}
// ============================================================================
// Tool Registration
// ============================================================================
/**
* Register a single WebMCP tool
*/
export function registerWebMCPTool(
tool: WebMCPToolDefinition,
execute: (args: Record<string, unknown>) => Promise<WebMCPToolResponse>
): WebMCPToolRegistration | null {
if (!isWebMCPAvailable() || !navigator.modelContext) {
return null;
}
return navigator.modelContext.registerTool({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
execute,
});
}
/**
* Tool handler function type
*/
export type ToolHandler<TArgs = Record<string, unknown>, TResult = unknown> = (
args: TArgs
) => Promise<TResult>;
/**
* Tool with handler definition
*/
export interface ToolWithHandler<TArgs = Record<string, unknown>> {
definition: WebMCPToolDefinition;
handler: ToolHandler<TArgs>;
}
/**
* Register multiple WebMCP tools
* Returns a cleanup function to unregister all tools
*/
export function registerWebMCPTools(
tools: ToolWithHandler[]
): () => void {
if (!isWebMCPAvailable() || !navigator.modelContext) {
console.warn('[WebMCP] Not available in this browser');
return () => {};
}
const registrations: WebMCPToolRegistration[] = [];
for (const tool of tools) {
const registration = navigator.modelContext.registerTool({
name: tool.definition.name,
description: tool.definition.description,
inputSchema: tool.definition.inputSchema,
async execute(args) {
try {
const result = await tool.handler(args);
return {
content: [{
type: 'text' as const,
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
}],
};
} catch (error) {
return {
content: [{
type: 'text' as const,
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
}],
};
}
},
});
registrations.push(registration);
}
// Log registration in development
if (process.env.NODE_ENV === 'development') {
console.log(`[WebMCP] Registered ${tools.length} tools:`, tools.map(t => t.definition.name));
}
// Return cleanup function
return () => {
registrations.forEach(reg => reg.unregister());
if (process.env.NODE_ENV === 'development') {
console.log('[WebMCP] Tools unregistered');
}
};
}
// ============================================================================
// Helper for Creating Tool Response
// ============================================================================
/**
* Create a WebMCP tool response from data
*/
export function createToolResponse(data: unknown): WebMCPToolResponse {
return {
content: [{
type: 'text',
text: typeof data === 'string' ? data : JSON.stringify(data, null, 2),
}],
};
}
/**
* Create an error response
*/
export function createErrorResponse(error: Error | string): WebMCPToolResponse {
const message = typeof error === 'string' ? error : error.message;
return {
content: [{
type: 'text',
text: `Error: ${message}`,
}],
};
}
// Re-export schemas
export * from './schemas';

189
src/genui/webmcp/schemas.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* WebMCP JSON Schema Helpers
*
* Utilities for defining WebMCP tool input schemas.
* Uses JSON Schema format compatible with the WebMCP specification.
*/
import type { WebMCPToolDefinition } from '../types';
// ============================================================================
// Schema Builder Types
// ============================================================================
export interface SchemaProperty {
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
description?: string;
enum?: readonly string[];
items?: SchemaProperty;
properties?: Record<string, SchemaProperty>;
default?: unknown;
}
export interface SchemaDefinition {
properties: Record<string, SchemaProperty>;
required?: readonly string[];
}
// ============================================================================
// Schema Builders
// ============================================================================
/**
* Create a string property schema
*/
export function stringProp(description: string, options?: {
enum?: readonly string[];
default?: string;
}): SchemaProperty {
return {
type: 'string',
description,
...(options?.enum && { enum: options.enum }),
...(options?.default !== undefined && { default: options.default }),
};
}
/**
* Create a number property schema
*/
export function numberProp(description: string, options?: {
default?: number;
}): SchemaProperty {
return {
type: 'number',
description,
...(options?.default !== undefined && { default: options.default }),
};
}
/**
* Create a boolean property schema
*/
export function booleanProp(description: string, options?: {
default?: boolean;
}): SchemaProperty {
return {
type: 'boolean',
description,
...(options?.default !== undefined && { default: options.default }),
};
}
/**
* Create an array property schema
*/
export function arrayProp(description: string, items: SchemaProperty): SchemaProperty {
return {
type: 'array',
description,
items,
};
}
/**
* Create an object property schema
*/
export function objectProp(
description: string,
properties: Record<string, SchemaProperty>
): SchemaProperty {
return {
type: 'object',
description,
properties,
};
}
// ============================================================================
// Tool Definition Builder
// ============================================================================
/**
* Create a WebMCP tool definition
*/
export function defineTool(
name: string,
description: string,
schema: SchemaDefinition
): WebMCPToolDefinition {
return {
name,
description,
inputSchema: {
type: 'object' as const,
properties: schema.properties,
required: schema.required,
},
};
}
// ============================================================================
// Common Tool Schemas
// ============================================================================
/**
* Common search tool schema
*/
export const searchSchema: SchemaDefinition = {
properties: {
query: {
type: 'string',
description: 'Search query to find relevant content',
},
contentType: {
type: 'string',
description: 'Filter results by content type (default: all)',
},
limit: {
type: 'number',
description: 'Maximum number of results to return',
default: 10,
},
},
required: ['query'] as const,
};
/**
* Common list items schema
*/
export const listSchema: SchemaDefinition = {
properties: {
type: {
type: 'string',
description: 'Type of items to list',
},
limit: {
type: 'number',
description: 'Maximum number of items to return',
default: 50,
},
offset: {
type: 'number',
description: 'Number of items to skip',
default: 0,
},
},
required: [] as const,
};
/**
* Common get item schema
*/
export const getItemSchema: SchemaDefinition = {
properties: {
id: {
type: 'string',
description: 'Unique identifier of the item',
},
},
required: ['id'] as const,
};
/**
* Empty schema for tools with no parameters
*/
export const emptySchema: SchemaDefinition = {
properties: {},
required: [] as const,
};

View File

@@ -0,0 +1,11 @@
import { useCallback, useState } from 'react';
export function useDisclosure(initial = false) {
const [isOpen, setIsOpen] = useState(initial);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen(v => !v), []);
return { isOpen, open, close, toggle };
}

84
src/index.ts Normal file
View File

@@ -0,0 +1,84 @@
// Core exports
export * from './theme/ThemeProvider';
// Layout (Layout 3 - Detached Layout)
export * from './layout/PageShell';
export * from './layout/Navbar';
export * from './layout/Sidebar';
export * from './layout/Footer';
export * from './layout/AppShell';
// Components
export * from './components/Alert';
export * from './components/Accordion';
export * from './components/AdvancedSelect';
export * from './components/Badge';
export * from './components/Breadcrumbs';
export * from './components/Button';
export * from './components/Card';
export * from './components/Collapse';
export * from './components/ColorPicker';
export * from './components/DataTable';
export * from './components/DatePicker';
export * from './components/DualListBox';
export * from './components/Dropdown';
export * from './components/Form';
export * from './components/ListGroup';
export * from './components/Media';
export * from './components/Modal';
export * from './components/Nav';
export * from './components/PageHeader';
export * from './components/Pagination';
export * from './components/Progress';
export * from './components/ProgressStacked';
export * from './components/Popover';
export * from './components/Scrollspy';
export * from './components/Table';
export * from './components/Tabs';
export * from './components/Toast';
export * from './components/Tooltip';
export * from './components/Wizard';
// New High Priority Components
export * from './components/Spinner';
export * from './components/Carousel';
export * from './components/Offcanvas';
export * from './components/SweetAlert';
export * from './components/Pills';
export * from './components/Slider';
export * from './components/TagInput';
export * from './components/FileUpload';
// Medium Priority Components
export * from './components/TreeView';
export * from './components/Timeline';
export * from './components/FAB';
export * from './components/ContextMenu';
export * from './components/Notification';
export * from './components/Rating';
export * from './components/Stepper';
export * from './components/ImageCropper';
// Low Priority Components
export * from './components/Calendar';
export * from './components/Gallery';
export * from './components/Embed';
export * from './components/SyntaxHighlighter';
export * from './components/Widget';
export * from './components/IdleTimeout';
export * from './components/Sortable';
// Hooks
export * from './hooks/useDisclosure';
// Validation
export * from './validation';
// Pages - Application page templates
export * from './pages';
// GenUI - AI/WebMCP integration utilities
// Exported as namespace to avoid naming conflicts with pages module
export * as genui from './genui';
// Note: styles are provided at dist/styles.css after build.

349
src/layout/AppShell.tsx Normal file
View File

@@ -0,0 +1,349 @@
"use client";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Navbar } from "./Navbar";
import { Sidebar, type SidebarNavItem } from "./Sidebar";
import { Footer, type FooterNavItem } from "./Footer";
import { PageShell } from "./PageShell";
// ─── ShellConfig types — mirror gsc-shell-api's response DTO ──────────────────
export type ShellMenuZone = "topbar" | "sidebar" | "footer" | "user-menu";
export type ShellMenuItem = {
id: string;
key: string;
translationKey: string;
href: string;
icon?: string;
isExternal?: boolean;
children?: ShellMenuItem[];
};
export type ShellApp = {
key: string;
displayName: string;
baseUrl: string;
};
export type ShellBranding = {
logoUrl: string;
productName: string;
footerHtml?: string;
brandColor?: string;
};
export type ShellUser = {
id: string;
email?: string;
displayName: string;
givenName?: string;
familyName?: string;
tenantId?: string;
roles: string[];
};
export type ShellConfig = {
version: number;
app: ShellApp;
branding: ShellBranding;
user: ShellUser;
menus: Partial<Record<ShellMenuZone, ShellMenuItem[]>>;
};
// ─── Context ──────────────────────────────────────────────────────────────────
type ShellContextValue = {
config: ShellConfig | null;
loading: boolean;
error: Error | null;
refresh: () => Promise<void>;
};
const ShellContext = createContext<ShellContextValue | null>(null);
export function useShell(): ShellContextValue {
const ctx = useContext(ShellContext);
if (!ctx) {
throw new Error("useShell must be used inside <AppShell> / <ShellProvider>");
}
return ctx;
}
// ─── Provider — handles fetch + revalidation + cache ──────────────────────────
export type ShellProviderProps = {
/** App identifier as registered in shell-api (e.g. "gsc-crm") */
appKey: string;
/** Base URL of gsc-shell-api (e.g. "https://shell-api.gosec.internal") */
apiUrl: string;
/** Returns a fresh Keycloak access token. Called on each fetch. */
getToken: () => Promise<string>;
/** Optional: a snapshot of ShellConfig to render before the first fetch finishes. */
initialConfig?: ShellConfig;
/** Optional: revalidate this often (ms). Default 5 minutes. */
revalidateMs?: number;
children: React.ReactNode;
};
export function ShellProvider({
appKey,
apiUrl,
getToken,
initialConfig,
revalidateMs = 5 * 60 * 1000,
children,
}: ShellProviderProps) {
const [config, setConfig] = useState<ShellConfig | null>(initialConfig ?? null);
const [loading, setLoading] = useState<boolean>(!initialConfig);
const [error, setError] = useState<Error | null>(null);
const [etag, setEtag] = useState<string | null>(null);
const fetcher = useCallback(async () => {
setLoading(true);
try {
const token = await getToken();
const res = await fetch(`${apiUrl}/api/v1/shell/${encodeURIComponent(appKey)}`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
...(etag ? { "If-None-Match": etag } : {}),
},
credentials: "omit",
});
if (res.status === 304) {
// Cached config still valid
setError(null);
return;
}
if (!res.ok) {
throw new Error(`shell-api ${res.status}: ${await res.text()}`);
}
const next = (await res.json()) as ShellConfig;
setConfig(next);
setError(null);
const newEtag = res.headers.get("ETag");
if (newEtag) setEtag(newEtag);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setLoading(false);
}
}, [apiUrl, appKey, getToken, etag]);
useEffect(() => {
void fetcher();
if (!revalidateMs) return;
const id = window.setInterval(() => {
void fetcher();
}, revalidateMs);
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appKey, apiUrl]);
const value = useMemo<ShellContextValue>(
() => ({ config, loading, error, refresh: fetcher }),
[config, loading, error, fetcher],
);
return <ShellContext.Provider value={value}>{children}</ShellContext.Provider>;
}
// ─── AppShell — composite that renders chrome from ShellConfig ─────────────────
export type AppShellProps = {
/** Same args as ShellProvider — AppShell wraps it. */
appKey: string;
apiUrl: string;
getToken: () => Promise<string>;
initialConfig?: ShellConfig;
revalidateMs?: number;
/** Current pathname for active-route highlight. Default: window.location.pathname. */
currentPath?: string;
/** Translate a `translation_key` to a display string. Default: returns the key. */
translate?: (key: string) => string;
/** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */
onSignOut?: () => void;
/** Optional: rendered inside <PageHeader> slot. */
pageHeader?: React.ReactNode;
/** Page content. */
children: React.ReactNode;
/** Override the wrapper className. */
className?: string;
};
export function AppShell(props: AppShellProps) {
return (
<ShellProvider
appKey={props.appKey}
apiUrl={props.apiUrl}
getToken={props.getToken}
initialConfig={props.initialConfig}
revalidateMs={props.revalidateMs}
>
<ShellChrome
currentPath={props.currentPath}
translate={props.translate}
onSignOut={props.onSignOut}
pageHeader={props.pageHeader}
className={props.className}
>
{props.children}
</ShellChrome>
</ShellProvider>
);
}
type ShellChromeProps = Pick<AppShellProps, "currentPath" | "translate" | "onSignOut" | "pageHeader" | "className" | "children">;
function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className, children }: ShellChromeProps) {
const { config, error } = useShell();
const t = translate ?? ((k: string) => k);
const path = currentPath ?? (typeof window !== "undefined" ? window.location.pathname : "/");
// Fallback chrome if config never loaded — minimal so the app still renders.
if (!config) {
return (
<PageShell
navbar={
<Navbar
brand={<span className="navbar-brand-text"></span>}
brandHref="/"
showSidebarToggle={false}
/>
}
className={className}
>
{error ? (
<div className="alert alert-warning mt-3">
Chrome unavailable: {error.message}. Showing app content only.
</div>
) : null}
{children}
</PageShell>
);
}
const sidebarItems = (config.menus.sidebar ?? []).map((m) => toSidebarNavItem(m, path, t));
const footerNavItems: FooterNavItem[] = (config.menus.footer ?? []).map((m) => ({
label: t(m.translationKey),
href: m.href,
icon: m.icon ? <i className={m.icon} /> : undefined,
}));
const navbarBrand = (
<a className="d-flex align-items-center gap-2" href={config.app.baseUrl} style={{ color: "inherit", textDecoration: "none" }}>
<img src={config.branding.logoUrl} alt="" height={24} />
<span className="fw-semibold">{config.branding.productName}</span>
</a>
);
const userMenuItems = (config.menus["user-menu"] ?? []).map((m) => {
const isLogout = m.key === "logout";
return (
<a
key={m.id}
href={m.href}
target={m.isExternal ? "_blank" : undefined}
rel={m.isExternal ? "noopener noreferrer" : undefined}
className="dropdown-item"
onClick={isLogout && onSignOut ? (e) => { e.preventDefault(); onSignOut(); } : undefined}
>
{m.icon ? <i className={`${m.icon} me-2`} /> : null}
{t(m.translationKey)}
</a>
);
});
const navbarEnd = (
<ul className="navbar-nav flex-row">
<li className="nav-item dropdown">
<button
type="button"
className="navbar-nav-link d-flex align-items-center"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span className="d-none d-md-inline me-2">{config.user.displayName || t("menu.account")}</span>
<i className="ph-user-circle" />
</button>
<div className="dropdown-menu dropdown-menu-end">{userMenuItems}</div>
</li>
</ul>
);
return (
<PageShell
className={className}
navbar={
<Navbar
brand={navbarBrand}
brandHref={config.app.baseUrl}
endItems={navbarEnd}
showSidebarToggle
/>
}
pageHeader={pageHeader}
mainSidebar={
<Sidebar
variant="main"
color="light"
user={{
name: config.user.displayName || "—",
subtitle: config.user.email,
}}
items={sidebarItems}
/>
}
footer={
<Footer
copyright={
config.branding.footerHtml ? (
<span dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }} />
) : (
<>© {new Date().getFullYear()} GoSec Cloud</>
)
}
navItems={footerNavItems}
/>
}
>
{children}
</PageShell>
);
}
function toSidebarNavItem(
m: ShellMenuItem,
currentPath: string,
t: (k: string) => string,
): SidebarNavItem {
const active = isActiveHref(m.href, currentPath);
const item: SidebarNavItem = {
type: m.children && m.children.length > 0 ? "submenu" : "link",
label: t(m.translationKey),
href: m.isExternal ? m.href : m.href,
iconClass: m.icon,
active,
};
if (m.children && m.children.length > 0) {
item.children = m.children.map((c) => toSidebarNavItem(c, currentPath, t));
// submenu open if any descendant is active
item.isOpen = item.children.some((c) => c.active || (c.children?.some((g) => g.active) ?? false));
}
return item;
}
function isActiveHref(href: string, currentPath: string): boolean {
if (!href) return false;
// Strip locale prefix from current path so /en/dashboard matches /dashboard
const stripped = currentPath.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
if (href === "/") return stripped === "/";
return stripped === href || stripped.startsWith(`${href}/`);
}

76
src/layout/Footer.tsx Normal file
View File

@@ -0,0 +1,76 @@
import React from 'react';
export type FooterNavItem = {
label: React.ReactNode;
href?: string;
icon?: React.ReactNode;
className?: string;
onClick?: () => void;
};
export type FooterProps = {
/** Copyright text or content */
copyright?: React.ReactNode;
/** Navigation items on the right side */
navItems?: FooterNavItem[];
/** Additional className */
className?: string;
/** Navbar variant: 'light' or 'dark' */
variant?: 'light' | 'dark';
/** Expand breakpoint */
expandBreakpoint?: 'sm' | 'md' | 'lg' | 'xl';
/** Unique ID for the collapsible footer */
collapseId?: string;
};
/**
* Layout 3 footer component.
* Uses navbar classes for consistency with Limitless design.
*/
export function Footer({
copyright,
navItems,
className = '',
variant = 'light',
expandBreakpoint = 'lg',
collapseId = 'navbar-footer'
}: FooterProps) {
const footerClasses = `navbar navbar-expand-${expandBreakpoint} navbar-${variant} ${className}`.trim();
return (
<div className={footerClasses}>
<div className={`text-center d-${expandBreakpoint}-none w-100`}>
<button
type="button"
className="navbar-toggler dropdown-toggle"
data-bs-toggle="collapse"
data-bs-target={`#${collapseId}`}
>
<i className="icon-unfold me-2"></i>
Footer
</button>
</div>
<div className="navbar-collapse collapse" id={collapseId}>
{copyright && <span className="navbar-text">{copyright}</span>}
{navItems && navItems.length > 0 && (
<ul className={`navbar-nav ms-${expandBreakpoint}-auto`}>
{navItems.map((item, idx) => (
<li key={idx} className="nav-item">
<a
href={item.href || '#'}
className={`navbar-nav-link ${item.className || ''}`.trim()}
onClick={item.onClick}
>
{item.icon && <span className="me-2">{item.icon}</span>}
{item.label}
</a>
</li>
))}
</ul>
)}
</div>
</div>
);
}

177
src/layout/Navbar.tsx Normal file
View File

@@ -0,0 +1,177 @@
import React from 'react';
export type NavbarProps = {
/** Brand/logo content */
brand?: React.ReactNode;
/** Brand link href */
brandHref?: string;
/** Left side nav items (after brand) */
startItems?: React.ReactNode;
/** Right side nav items */
endItems?: React.ReactNode;
/** Additional className */
className?: string;
/** Navbar color variant: 'dark' or 'light' */
variant?: 'dark' | 'light';
/** Background color class (e.g., 'bg-indigo', 'bg-primary') */
bg?: string;
/** Show mobile sidebar toggle button */
showSidebarToggle?: boolean;
/** Navbar expand breakpoint */
expandBreakpoint?: 'sm' | 'md' | 'lg' | 'xl';
/** Unique ID for the collapsible navbar */
collapseId?: string;
};
/**
* Layout 3 navbar component.
* Uses navbar-nav-link class for links, supports mobile sidebar togglers.
*/
export function Navbar({
brand,
brandHref = '#',
startItems,
endItems,
className = '',
variant = 'dark',
bg = 'bg-indigo',
showSidebarToggle = true,
expandBreakpoint = 'md',
collapseId = 'navbar-mobile'
}: NavbarProps) {
const navbarClasses = `navbar navbar-expand-${expandBreakpoint} navbar-${variant} ${bg} ${className}`.trim();
return (
<div className={navbarClasses}>
{/* Brand */}
{brand && (
<div className="navbar-brand wmin-200">
<a href={brandHref} className="d-inline-block">
{brand}
</a>
</div>
)}
{/* Mobile togglers */}
<div className="d-md-none">
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target={`#${collapseId}`}>
<i className="icon-tree5"></i>
</button>
{showSidebarToggle && (
<button className="navbar-toggler sidebar-mobile-main-toggle" type="button">
<i className="icon-paragraph-justify3"></i>
</button>
)}
</div>
{/* Collapsible content */}
<div className="collapse navbar-collapse" id={collapseId}>
{/* Left nav items */}
<ul className="navbar-nav">
{showSidebarToggle && (
<li className="nav-item">
<a href="#" className="navbar-nav-link sidebar-control sidebar-main-toggle d-none d-md-block">
<i className="icon-paragraph-justify3"></i>
</a>
</li>
)}
{startItems}
</ul>
{/* Right nav items */}
<ul className="navbar-nav ms-md-auto">
{endItems}
</ul>
</div>
</div>
);
}
export type NavbarNavItemProps = {
children: React.ReactNode;
href?: string;
active?: boolean;
className?: string;
onClick?: () => void;
};
/**
* Navbar navigation item using navbar-nav-link class.
*/
export function NavbarNavItem({ children, href = '#', active, className = '', onClick }: NavbarNavItemProps) {
return (
<li className={`nav-item ${className}`.trim()}>
<a href={href} className={`navbar-nav-link ${active ? 'active' : ''}`} onClick={onClick}>
{children}
</a>
</li>
);
}
export type NavbarDropdownProps = {
trigger: React.ReactNode;
children: React.ReactNode;
align?: 'left' | 'right';
className?: string;
};
/**
* Navbar dropdown menu component.
*/
export function NavbarDropdown({ trigger, children, align = 'right', className = '' }: NavbarDropdownProps) {
return (
<li className={`nav-item dropdown ${className}`.trim()}>
<a href="#" className="navbar-nav-link dropdown-toggle" data-bs-toggle="dropdown">
{trigger}
</a>
<div className={`dropdown-menu ${align === 'right' ? 'dropdown-menu-end' : ''}`}>
{children}
</div>
</li>
);
}
export type NavbarUserMenuProps = {
name: string;
avatar?: string;
menuItems: Array<{
label: React.ReactNode;
href?: string;
icon?: React.ReactNode;
divider?: boolean;
onClick?: () => void;
}>;
className?: string;
};
/**
* Navbar user dropdown menu component.
*/
export function NavbarUserMenu({ name, avatar, menuItems, className = '' }: NavbarUserMenuProps) {
return (
<li className={`nav-item dropdown dropdown-user ${className}`.trim()}>
<a href="#" className="navbar-nav-link d-flex align-items-center dropdown-toggle" data-bs-toggle="dropdown">
{avatar ? (
<img src={avatar} className="rounded-circle me-2" height="34" alt="" />
) : (
<span className="rounded-circle bg-secondary d-inline-flex align-items-center justify-content-center me-2" style={{ width: 34, height: 34 }}>
<span className="text-white small">{name.charAt(0).toUpperCase()}</span>
</span>
)}
<span>{name}</span>
</a>
<div className="dropdown-menu dropdown-menu-end">
{menuItems.map((item, idx) =>
item.divider ? (
<div key={idx} className="dropdown-divider"></div>
) : (
<a key={idx} href={item.href || '#'} className="dropdown-item" onClick={item.onClick}>
{item.icon && <span className="me-2">{item.icon}</span>}
{item.label}
</a>
)
)}
</div>
</li>
);
}

73
src/layout/PageShell.tsx Normal file
View File

@@ -0,0 +1,73 @@
import React from 'react';
export type PageShellProps = {
/** Main navbar element. Set to false to hide. */
navbar?: React.ReactNode;
/** Page header with breadcrumbs and title. Placed outside page-content per Layout 3 spec. */
pageHeader?: React.ReactNode;
/** Main sidebar (left). Detached style in Layout 3. */
mainSidebar?: React.ReactNode;
/** Secondary sidebar (after main sidebar). */
secondarySidebar?: React.ReactNode;
/** Right sidebar. */
rightSidebar?: React.ReactNode;
/** Footer element. Placed outside page-content per Layout 3 spec. */
footer?: React.ReactNode;
/** Additional className for the root container */
className?: string;
/** Main content */
children: React.ReactNode;
};
/**
* Layout 3 (Detached Layout) page shell.
*
* Structure:
* - navbar (full width, top)
* - page-header (outside page-content, contains breadcrumb-line + page-header-content)
* - page-content pt-0 (flex container with sidebars and content-wrapper)
* - footer (outside page-content, bottom)
*/
export function PageShell({
navbar,
pageHeader,
mainSidebar,
secondarySidebar,
rightSidebar,
footer,
className = '',
children
}: PageShellProps) {
return (
<div className={className}>
{/* Main navbar */}
{navbar}
{/* Page header - outside page-content per Layout 3 */}
{pageHeader}
{/* Page content */}
<div className="page-content pt-0">
{/* Main sidebar */}
{mainSidebar}
{/* Secondary sidebar */}
{secondarySidebar}
{/* Main content */}
<div className="content-wrapper">
{/* Content area */}
<div className="content">
{children}
</div>
</div>
{/* Right sidebar */}
{rightSidebar}
</div>
{/* Footer - outside page-content per Layout 3 */}
{footer}
</div>
);
}

319
src/layout/Sidebar.tsx Normal file
View File

@@ -0,0 +1,319 @@
import React, { useState } from 'react';
export type SidebarNavItem = {
type?: 'link' | 'header' | 'divider' | 'submenu';
label?: React.ReactNode;
href?: string;
icon?: React.ReactNode;
iconClass?: string;
badge?: React.ReactNode;
active?: boolean;
disabled?: boolean;
children?: SidebarNavItem[];
onClick?: () => void;
/** Whether submenu is open (controlled externally) */
isOpen?: boolean;
/** Callback to toggle submenu open state */
onToggle?: () => void;
/** Custom link component (e.g., NavLink from Remix) */
LinkComponent?: React.ComponentType<any>;
};
export type SidebarUserInfo = {
name: string;
subtitle?: string;
avatar?: string;
menuItems?: SidebarNavItem[];
};
export type SidebarProps = {
/** Sidebar variant: 'main' | 'secondary' | 'right' */
variant?: 'main' | 'secondary' | 'right';
/** Light or dark color scheme */
color?: 'light' | 'dark';
/** User info block (Layout 3 material style) */
user?: SidebarUserInfo;
/** Optional header content at top of sidebar */
header?: React.ReactNode;
/** Navigation items */
items: SidebarNavItem[];
/** Additional className */
className?: string;
/** Mobile toggler title */
mobileTitle?: string;
/** Sidebar expand breakpoint */
expandBreakpoint?: 'sm' | 'md' | 'lg' | 'xl';
/** Whether sidebar is collapsed */
collapsed?: boolean;
/** Callback when mobile sidebar close is clicked */
onMobileClose?: () => void;
/** Custom link component for all nav items (e.g., NavLink from Remix) */
LinkComponent?: React.ComponentType<any>;
/** Current pathname for active state detection */
pathname?: string;
};
/**
* Layout 3 detached sidebar with nav-sidebar navigation.
* Supports user menu, headers, dividers, and multi-level submenus.
*/
export function Sidebar({
variant = 'main',
color = 'light',
user,
header,
items,
className = '',
mobileTitle = 'Main sidebar',
expandBreakpoint = 'lg',
collapsed = false,
onMobileClose,
LinkComponent,
pathname = '',
}: SidebarProps) {
const variantClass = variant === 'main' ? 'sidebar-main' : variant === 'secondary' ? 'sidebar-secondary' : 'sidebar-right';
const colorClass = color === 'dark' ? 'sidebar-dark' : 'sidebar-light';
const expandClass = `sidebar-expand-${expandBreakpoint}`;
return (
<div className={`sidebar ${colorClass} ${variantClass} ${expandClass} align-self-start ${className}`.trim()}>
{/* Sidebar mobile toggler */}
<div className="sidebar-mobile-toggler text-center">
<a href="#" className="sidebar-mobile-main-toggle" onClick={(e) => { e.preventDefault(); onMobileClose?.(); }}>
<i className="ph-arrow-left"></i>
</a>
<span className="fw-semibold">{mobileTitle}</span>
<a href="#" className="sidebar-mobile-expand">
<i className="ph-arrows-out-simple"></i>
</a>
</div>
{/* Sidebar content */}
<div className="sidebar-content">
{header && <div className="sidebar-section">{header}</div>}
{/* User menu (material style) */}
{user && <SidebarUserMenu user={user} />}
{/* Navigation */}
<div className="card card-sidebar-mobile">
<div className="card-body p-0">
<ul className="nav nav-sidebar" data-nav-type="accordion">
{items.map((item, idx) => (
<SidebarNavNode key={item.href || item.label?.toString() || idx} item={item} collapsed={collapsed} LinkComponent={LinkComponent} pathname={pathname} />
))}
</ul>
</div>
</div>
</div>
</div>
);
}
function SidebarUserMenu({ user }: { user: SidebarUserInfo }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="sidebar-user-material">
<div className="sidebar-user-material-body card-img-top">
<div className="card-body text-center">
<a href="#">
{user.avatar ? (
<img src={user.avatar} className="img-fluid rounded-circle shadow-2 mb-3" width="80" height="80" alt="" />
) : (
<div className="rounded-circle bg-secondary d-inline-flex align-items-center justify-content-center mb-3" style={{ width: 80, height: 80 }}>
<span className="text-white h4 mb-0">{user.name.charAt(0).toUpperCase()}</span>
</div>
)}
</a>
<h6 className="mb-0 text-white text-shadow-dark">{user.name}</h6>
{user.subtitle && <span className="font-size-sm text-white text-shadow-dark">{user.subtitle}</span>}
</div>
<div className="sidebar-user-material-footer">
<a
href="#user-nav"
className="d-flex justify-content-between align-items-center text-shadow-dark dropdown-toggle"
onClick={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
>
<span>My account</span>
</a>
</div>
</div>
<div className={`collapse ${isOpen ? 'show' : ''}`} id="user-nav">
{user.menuItems && (
<ul className="nav nav-sidebar">
{user.menuItems.map((item, idx) => (
<SidebarNavNode key={idx} item={item} />
))}
</ul>
)}
</div>
</div>
);
}
function SidebarNavNode({
item,
level = 0,
collapsed = false,
LinkComponent,
pathname = '',
}: {
item: SidebarNavItem;
level?: number;
collapsed?: boolean;
LinkComponent?: React.ComponentType<any>;
pathname?: string;
}) {
// Check if any child is active (current path matches)
const hasActiveChild = item.children?.some(child => child.href && pathname.startsWith(child.href)) || false;
// Use external isOpen if provided, otherwise fall back to local state
const [localIsOpen, setLocalIsOpen] = useState(hasActiveChild);
const isOpen = item.isOpen !== undefined ? item.isOpen : localIsOpen;
if (item.type === 'header') {
if (collapsed) return null;
return (
<li className="nav-item-header">
<div className="text-uppercase font-size-xs line-height-xs">{item.label}</div>
{item.icon && <i className="ph-list" title={String(item.label)}></i>}
</li>
);
}
if (item.type === 'divider') {
return <li className="nav-item-divider"></li>;
}
const hasChildren = item.children && item.children.length > 0;
const isSubmenu = item.type === 'submenu' || hasChildren;
// Determine if item is active based on pathname
const isItemActive = item.active ?? (item.href
? pathname.startsWith(item.href)
: item.children?.some(child => child.href && pathname.startsWith(child.href)) || false);
const linkClasses = [
'nav-link',
'd-flex',
'align-items-center',
isItemActive ? 'active' : '',
item.disabled ? 'disabled' : '',
collapsed ? 'justify-content-center' : '',
].filter(Boolean).join(' ');
const handleClick = (e: React.MouseEvent) => {
if (isSubmenu) {
e.preventDefault();
if (item.onToggle) {
item.onToggle();
} else {
setLocalIsOpen(!isOpen);
}
}
if (item.onClick) {
item.onClick();
}
};
// Render icon
const renderIcon = () => {
if (item.icon) {
return <span className={collapsed ? '' : 'me-2'}>{item.icon}</span>;
}
if (item.iconClass) {
return <i className={`${item.iconClass} ${collapsed ? '' : 'me-2'}`}></i>;
}
return null;
};
if (isSubmenu) {
// Use button for submenu toggle to avoid anchor navigation issues
const handleToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (collapsed) {
if (item.onClick) {
item.onClick();
}
return;
}
if (item.onToggle) {
item.onToggle();
} else {
setLocalIsOpen(prev => !prev);
}
if (item.onClick) {
item.onClick();
}
};
return (
<li className={`nav-item nav-item-submenu ${isOpen ? 'nav-item-open' : ''}`}>
<button
type="button"
className={`${linkClasses} w-100 text-start border-0 bg-transparent`}
title={collapsed ? String(item.label) : undefined}
aria-expanded={isOpen}
onClick={handleToggle}
>
{renderIcon()}
{!collapsed && (
<>
<span className="flex-grow-1">{item.label}</span>
<i className={`ph-caret-${isOpen ? 'up' : 'down'} ms-auto`}></i>
</>
)}
</button>
<ul
className="nav nav-group-sub"
data-submenu-title={String(item.label)}
>
{item.children!.map((child, idx) => (
<SidebarNavNode
key={child.href || child.label?.toString() || idx}
item={child}
level={level + 1}
collapsed={collapsed}
LinkComponent={LinkComponent}
pathname={pathname}
/>
))}
</ul>
</li>
);
}
// Use custom LinkComponent if provided, otherwise use <a>
const Link = LinkComponent || 'a';
const linkProps = LinkComponent
? {
to: item.href || '#',
className: linkClasses,
title: collapsed ? String(item.label) : undefined,
onClick: (e: React.MouseEvent) => {
// Stop propagation to prevent parent handlers from interfering
e.stopPropagation();
if (item.onClick) {
item.onClick();
}
// Don't prevent default - let navigation proceed
},
}
: { href: item.href || '#', className: linkClasses, onClick: handleClick, title: collapsed ? String(item.label) : undefined };
return (
<li className="nav-item">
<Link {...linkProps}>
{renderIcon()}
{!collapsed && <span>{item.label}</span>}
{!collapsed && item.badge && <span className="badge ms-auto align-self-center">{item.badge}</span>}
</Link>
</li>
);
}
// Re-export for backwards compatibility
export type SidebarItem = SidebarNavItem;

973
src/pages/Auth.tsx Normal file
View File

@@ -0,0 +1,973 @@
import React, { useState } from 'react';
// Base Auth Layout
export interface AuthLayoutProps {
/** Page title */
title?: string;
/** Page subtitle */
subtitle?: string;
/** Logo element */
logo?: React.ReactNode;
/** Background variant */
background?: 'default' | 'image' | 'gradient' | 'transparent';
/** Background image URL (when background='image') */
backgroundImage?: string;
/** Show footer */
showFooter?: boolean;
/** Footer content */
footer?: React.ReactNode;
/** Children content */
children: React.ReactNode;
/** Additional CSS classes */
className?: string;
}
export const AuthLayout: React.FC<AuthLayoutProps> = ({
title,
subtitle,
logo,
background = 'default',
backgroundImage,
showFooter = true,
footer,
children,
className = '',
}) => {
const classes = [
'll-auth-layout',
`ll-auth-layout-${background}`,
className,
].filter(Boolean).join(' ');
const style: React.CSSProperties = {};
if (background === 'image' && backgroundImage) {
style.backgroundImage = `url(${backgroundImage})`;
}
return (
<div className={classes} style={style}>
<div className="ll-auth-wrapper">
<div className="ll-auth-content">
{logo && <div className="ll-auth-logo">{logo}</div>}
{(title || subtitle) && (
<div className="ll-auth-header">
{title && <h1 className="ll-auth-title">{title}</h1>}
{subtitle && <p className="ll-auth-subtitle">{subtitle}</p>}
</div>
)}
{children}
</div>
{showFooter && (
<div className="ll-auth-footer">
{footer || (
<p>&copy; {new Date().getFullYear()} Your Company. All rights reserved.</p>
)}
</div>
)}
</div>
</div>
);
};
// Login Page
export interface LoginPageProps {
/** Logo element */
logo?: React.ReactNode;
/** Page title */
title?: string;
/** Page subtitle */
subtitle?: string;
/** Show remember me checkbox */
showRememberMe?: boolean;
/** Show forgot password link */
showForgotPassword?: boolean;
/** Forgot password URL */
forgotPasswordUrl?: string;
/** Show register link */
showRegisterLink?: boolean;
/** Register URL */
registerUrl?: string;
/** Show social login buttons */
showSocialLogin?: boolean;
/** Social login providers */
socialProviders?: Array<{
name: string;
icon: React.ReactNode;
onClick: () => void;
}>;
/** Login submit handler */
onSubmit?: (data: { username: string; password: string; rememberMe: boolean }) => void;
/** Loading state */
loading?: boolean;
/** Error message */
error?: string;
/** Background variant */
background?: 'default' | 'image' | 'gradient' | 'transparent';
/** Background image */
backgroundImage?: string;
/** Additional CSS classes */
className?: string;
}
export const LoginPage: React.FC<LoginPageProps> = ({
logo,
title = 'Sign In',
subtitle = 'Enter your credentials to access your account',
showRememberMe = true,
showForgotPassword = true,
forgotPasswordUrl = '/forgot-password',
showRegisterLink = true,
registerUrl = '/register',
showSocialLogin = false,
socialProviders = [],
onSubmit,
loading = false,
error,
background = 'default',
backgroundImage,
className = '',
}) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit?.({ username, password, rememberMe });
};
return (
<AuthLayout
logo={logo}
background={background}
backgroundImage={backgroundImage}
className={`ll-login-page ${className}`}
>
<div className="ll-auth-card">
<div className="ll-auth-card-header">
<h2 className="ll-auth-card-title">{title}</h2>
{subtitle && <p className="ll-auth-card-subtitle">{subtitle}</p>}
</div>
<div className="ll-auth-card-body">
{error && (
<div className="ll-auth-error">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="ll-form-group">
<label className="ll-form-label">Username or Email</label>
<div className="ll-input-group ll-input-group-icon">
<span className="ll-input-icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<input
type="text"
className="ll-form-input"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
</div>
<div className="ll-form-group">
<label className="ll-form-label">Password</label>
<div className="ll-input-group ll-input-group-icon">
<span className="ll-input-icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
<input
type="password"
className="ll-form-input"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</div>
{(showRememberMe || showForgotPassword) && (
<div className="ll-auth-options">
{showRememberMe && (
<label className="ll-checkbox">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
/>
<span className="ll-checkbox-label">Remember me</span>
</label>
)}
{showForgotPassword && (
<a href={forgotPasswordUrl} className="ll-auth-link">
Forgot password?
</a>
)}
</div>
)}
<button
type="submit"
className="ll-btn ll-btn-primary ll-btn-block"
disabled={loading}
>
{loading ? (
<>
<span className="ll-spinner ll-spinner-sm" />
Signing in...
</>
) : (
'Sign In'
)}
</button>
</form>
{showSocialLogin && socialProviders.length > 0 && (
<>
<div className="ll-auth-divider">
<span>Or continue with</span>
</div>
<div className="ll-social-login">
{socialProviders.map((provider, index) => (
<button
key={index}
type="button"
className="ll-btn ll-btn-outline ll-social-btn"
onClick={provider.onClick}
>
{provider.icon}
<span>{provider.name}</span>
</button>
))}
</div>
</>
)}
</div>
{showRegisterLink && (
<div className="ll-auth-card-footer">
<p>
Don't have an account?{' '}
<a href={registerUrl} className="ll-auth-link">
Sign up
</a>
</p>
</div>
)}
</div>
</AuthLayout>
);
};
// Register Page
export interface RegisterPageProps {
/** Logo element */
logo?: React.ReactNode;
/** Page title */
title?: string;
/** Page subtitle */
subtitle?: string;
/** Show terms checkbox */
showTerms?: boolean;
/** Terms URL */
termsUrl?: string;
/** Privacy URL */
privacyUrl?: string;
/** Show login link */
showLoginLink?: boolean;
/** Login URL */
loginUrl?: string;
/** Show social signup */
showSocialSignup?: boolean;
/** Social providers */
socialProviders?: Array<{
name: string;
icon: React.ReactNode;
onClick: () => void;
}>;
/** Registration submit handler */
onSubmit?: (data: {
name: string;
email: string;
password: string;
confirmPassword: string;
agreeTerms: boolean;
}) => void;
/** Loading state */
loading?: boolean;
/** Error message */
error?: string;
/** Background variant */
background?: 'default' | 'image' | 'gradient' | 'transparent';
/** Background image */
backgroundImage?: string;
/** Additional CSS classes */
className?: string;
}
export const RegisterPage: React.FC<RegisterPageProps> = ({
logo,
title = 'Create Account',
subtitle = 'Fill in the form below to create your account',
showTerms = true,
termsUrl = '/terms',
privacyUrl = '/privacy',
showLoginLink = true,
loginUrl = '/login',
showSocialSignup = false,
socialProviders = [],
onSubmit,
loading = false,
error,
background = 'default',
backgroundImage,
className = '',
}) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [agreeTerms, setAgreeTerms] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit?.({ name, email, password, confirmPassword, agreeTerms });
};
return (
<AuthLayout
logo={logo}
background={background}
backgroundImage={backgroundImage}
className={`ll-register-page ${className}`}
>
<div className="ll-auth-card">
<div className="ll-auth-card-header">
<h2 className="ll-auth-card-title">{title}</h2>
{subtitle && <p className="ll-auth-card-subtitle">{subtitle}</p>}
</div>
<div className="ll-auth-card-body">
{error && (
<div className="ll-auth-error">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="ll-form-group">
<label className="ll-form-label">Full Name</label>
<div className="ll-input-group ll-input-group-icon">
<span className="ll-input-icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<input
type="text"
className="ll-form-input"
placeholder="Enter your full name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
</div>
<div className="ll-form-group">
<label className="ll-form-label">Email</label>
<div className="ll-input-group ll-input-group-icon">
<span className="ll-input-icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
</span>
<input
type="email"
className="ll-form-input"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
</div>
<div className="ll-form-group">
<label className="ll-form-label">Password</label>
<div className="ll-input-group ll-input-group-icon">
<span className="ll-input-icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
<input
type="password"
className="ll-form-input"
placeholder="Create a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</div>
<div className="ll-form-group">
<label className="ll-form-label">Confirm Password</label>
<div className="ll-input-group ll-input-group-icon">
<span className="ll-input-icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
<input
type="password"
className="ll-form-input"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
</div>
{showTerms && (
<div className="ll-form-group">
<label className="ll-checkbox">
<input
type="checkbox"
checked={agreeTerms}
onChange={(e) => setAgreeTerms(e.target.checked)}
required
/>
<span className="ll-checkbox-label">
I agree to the{' '}
<a href={termsUrl} className="ll-auth-link">Terms of Service</a>
{' '}and{' '}
<a href={privacyUrl} className="ll-auth-link">Privacy Policy</a>
</span>
</label>
</div>
)}
<button
type="submit"
className="ll-btn ll-btn-primary ll-btn-block"
disabled={loading}
>
{loading ? (
<>
<span className="ll-spinner ll-spinner-sm" />
Creating account...
</>
) : (
'Create Account'
)}
</button>
</form>
{showSocialSignup && socialProviders.length > 0 && (
<>
<div className="ll-auth-divider">
<span>Or sign up with</span>
</div>
<div className="ll-social-login">
{socialProviders.map((provider, index) => (
<button
key={index}
type="button"
className="ll-btn ll-btn-outline ll-social-btn"
onClick={provider.onClick}
>
{provider.icon}
<span>{provider.name}</span>
</button>
))}
</div>
</>
)}
</div>
{showLoginLink && (
<div className="ll-auth-card-footer">
<p>
Already have an account?{' '}
<a href={loginUrl} className="ll-auth-link">
Sign in
</a>
</p>
</div>
)}
</div>
</AuthLayout>
);
};
// Password Recovery Page
export interface PasswordRecoveryPageProps {
/** Logo element */
logo?: React.ReactNode;
/** Page title */
title?: string;
/** Page subtitle */
subtitle?: string;
/** Login URL */
loginUrl?: string;
/** Submit handler */
onSubmit?: (email: string) => void;
/** Loading state */
loading?: boolean;
/** Error message */
error?: string;
/** Success message */
success?: string;
/** Background variant */
background?: 'default' | 'image' | 'gradient' | 'transparent';
/** Background image */
backgroundImage?: string;
/** Additional CSS classes */
className?: string;
}
export const PasswordRecoveryPage: React.FC<PasswordRecoveryPageProps> = ({
logo,
title = 'Reset Password',
subtitle = "Enter your email address and we'll send you a link to reset your password",
loginUrl = '/login',
onSubmit,
loading = false,
error,
success,
background = 'default',
backgroundImage,
className = '',
}) => {
const [email, setEmail] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit?.(email);
};
return (
<AuthLayout
logo={logo}
background={background}
backgroundImage={backgroundImage}
className={`ll-password-recovery-page ${className}`}
>
<div className="ll-auth-card">
<div className="ll-auth-card-header">
<div className="ll-auth-icon">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h2 className="ll-auth-card-title">{title}</h2>
{subtitle && <p className="ll-auth-card-subtitle">{subtitle}</p>}
</div>
<div className="ll-auth-card-body">
{error && (
<div className="ll-auth-error">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
{error}
</div>
)}
{success && (
<div className="ll-auth-success">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
{success}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="ll-form-group">
<label className="ll-form-label">Email Address</label>
<div className="ll-input-group ll-input-group-icon">
<span className="ll-input-icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
</span>
<input
type="email"
className="ll-form-input"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
</div>
<button
type="submit"
className="ll-btn ll-btn-primary ll-btn-block"
disabled={loading}
>
{loading ? (
<>
<span className="ll-spinner ll-spinner-sm" />
Sending...
</>
) : (
'Send Reset Link'
)}
</button>
</form>
</div>
<div className="ll-auth-card-footer">
<a href={loginUrl} className="ll-auth-link">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
Back to Sign In
</a>
</div>
</div>
</AuthLayout>
);
};
// Lock Screen Page
export interface LockScreenPageProps {
/** User name */
userName: string;
/** User avatar */
userAvatar?: string;
/** Submit handler */
onSubmit?: (password: string) => void;
/** Sign out handler */
onSignOut?: () => void;
/** Loading state */
loading?: boolean;
/** Error message */
error?: string;
/** Background variant */
background?: 'default' | 'image' | 'gradient' | 'transparent';
/** Background image */
backgroundImage?: string;
/** Additional CSS classes */
className?: string;
}
export const LockScreenPage: React.FC<LockScreenPageProps> = ({
userName,
userAvatar,
onSubmit,
onSignOut,
loading = false,
error,
background = 'default',
backgroundImage,
className = '',
}) => {
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit?.(password);
};
return (
<AuthLayout
background={background}
backgroundImage={backgroundImage}
className={`ll-lock-screen-page ${className}`}
>
<div className="ll-auth-card ll-lock-screen-card">
<div className="ll-auth-card-header">
<div className="ll-lock-screen-avatar">
{userAvatar ? (
<img src={userAvatar} alt={userName} />
) : (
<div className="ll-lock-screen-avatar-placeholder">
{userName.charAt(0).toUpperCase()}
</div>
)}
<div className="ll-lock-screen-status" />
</div>
<h2 className="ll-auth-card-title">{userName}</h2>
<p className="ll-auth-card-subtitle">Your session has been locked</p>
</div>
<div className="ll-auth-card-body">
{error && (
<div className="ll-auth-error">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="ll-form-group">
<div className="ll-input-group ll-input-group-icon">
<span className="ll-input-icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
<input
type="password"
className="ll-form-input"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
/>
</div>
</div>
<button
type="submit"
className="ll-btn ll-btn-primary ll-btn-block"
disabled={loading}
>
{loading ? (
<>
<span className="ll-spinner ll-spinner-sm" />
Unlocking...
</>
) : (
'Unlock'
)}
</button>
</form>
</div>
{onSignOut && (
<div className="ll-auth-card-footer">
<button
type="button"
className="ll-auth-link ll-btn-link"
onClick={onSignOut}
>
Sign in as a different user
</button>
</div>
)}
</div>
</AuthLayout>
);
};
// Two-Factor Auth Page
export interface TwoFactorAuthPageProps {
/** Logo element */
logo?: React.ReactNode;
/** Page title */
title?: string;
/** Page subtitle */
subtitle?: string;
/** Number of code digits */
codeLength?: number;
/** Submit handler */
onSubmit?: (code: string) => void;
/** Resend code handler */
onResend?: () => void;
/** Back handler */
onBack?: () => void;
/** Loading state */
loading?: boolean;
/** Error message */
error?: string;
/** Resend cooldown in seconds */
resendCooldown?: number;
/** Background variant */
background?: 'default' | 'image' | 'gradient' | 'transparent';
/** Background image */
backgroundImage?: string;
/** Additional CSS classes */
className?: string;
}
export const TwoFactorAuthPage: React.FC<TwoFactorAuthPageProps> = ({
logo,
title = 'Two-Factor Authentication',
subtitle = 'Enter the 6-digit code from your authenticator app',
codeLength = 6,
onSubmit,
onResend,
onBack,
loading = false,
error,
resendCooldown = 0,
background = 'default',
backgroundImage,
className = '',
}) => {
const [code, setCode] = useState<string[]>(Array(codeLength).fill(''));
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
const handleChange = (index: number, value: string) => {
if (value.length > 1) {
// Handle paste
const digits = value.replace(/\D/g, '').split('').slice(0, codeLength);
const newCode = [...code];
digits.forEach((digit, i) => {
if (index + i < codeLength) {
newCode[index + i] = digit;
}
});
setCode(newCode);
const nextIndex = Math.min(index + digits.length, codeLength - 1);
inputRefs.current[nextIndex]?.focus();
} else {
const newCode = [...code];
newCode[index] = value.replace(/\D/g, '');
setCode(newCode);
if (value && index < codeLength - 1) {
inputRefs.current[index + 1]?.focus();
}
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const fullCode = code.join('');
if (fullCode.length === codeLength) {
onSubmit?.(fullCode);
}
};
return (
<AuthLayout
logo={logo}
background={background}
backgroundImage={backgroundImage}
className={`ll-2fa-page ${className}`}
>
<div className="ll-auth-card">
<div className="ll-auth-card-header">
<div className="ll-auth-icon">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</div>
<h2 className="ll-auth-card-title">{title}</h2>
{subtitle && <p className="ll-auth-card-subtitle">{subtitle}</p>}
</div>
<div className="ll-auth-card-body">
{error && (
<div className="ll-auth-error">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="ll-2fa-inputs">
{Array.from({ length: codeLength }).map((_, index) => (
<input
key={index}
ref={(el) => { inputRefs.current[index] = el; }}
type="text"
inputMode="numeric"
maxLength={codeLength}
className="ll-2fa-input"
value={code[index]}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
autoFocus={index === 0}
/>
))}
</div>
<button
type="submit"
className="ll-btn ll-btn-primary ll-btn-block"
disabled={loading || code.join('').length !== codeLength}
>
{loading ? (
<>
<span className="ll-spinner ll-spinner-sm" />
Verifying...
</>
) : (
'Verify'
)}
</button>
</form>
{onResend && (
<div className="ll-2fa-resend">
{resendCooldown > 0 ? (
<span className="ll-text-muted">
Resend code in {resendCooldown}s
</span>
) : (
<button
type="button"
className="ll-btn-link"
onClick={onResend}
>
Didn't receive a code? Resend
</button>
)}
</div>
)}
</div>
{onBack && (
<div className="ll-auth-card-footer">
<button
type="button"
className="ll-auth-link ll-btn-link"
onClick={onBack}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
Back to login
</button>
</div>
)}
</div>
</AuthLayout>
);
};

753
src/pages/Chat.tsx Normal file
View File

@@ -0,0 +1,753 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
// Types
export interface ChatUser {
id: string;
name: string;
avatar?: string;
status?: 'online' | 'offline' | 'away' | 'busy';
lastSeen?: Date;
}
export interface ChatMessage {
id: string;
senderId: string;
content: string;
timestamp: Date;
type?: 'text' | 'image' | 'file' | 'system';
fileUrl?: string;
fileName?: string;
isRead?: boolean;
}
export interface ChatConversation {
id: string;
participants: ChatUser[];
lastMessage?: ChatMessage;
unreadCount?: number;
isGroup?: boolean;
groupName?: string;
groupAvatar?: string;
}
// Chat Layout
export interface ChatLayoutProps {
/** Sidebar content (conversations list) */
sidebar?: React.ReactNode;
/** Main chat area */
children: React.ReactNode;
/** Show sidebar on mobile */
showSidebar?: boolean;
/** Toggle sidebar handler */
onToggleSidebar?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatLayout: React.FC<ChatLayoutProps> = ({
sidebar,
children,
showSidebar = true,
onToggleSidebar,
className = '',
}) => {
return (
<div className={`ll-chat-layout ${className}`}>
<div className={`ll-chat-sidebar ${showSidebar ? 'll-chat-sidebar-open' : ''}`}>
{sidebar}
</div>
<div className="ll-chat-main">
{onToggleSidebar && (
<button className="ll-chat-sidebar-toggle" onClick={onToggleSidebar}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
</svg>
</button>
)}
{children}
</div>
</div>
);
};
// Chat Sidebar / Conversations List
export interface ChatConversationsListProps {
/** Conversations to display */
conversations: ChatConversation[];
/** Current user */
currentUser?: ChatUser;
/** Active conversation ID */
activeId?: string;
/** Conversation click handler */
onConversationClick?: (conversation: ChatConversation) => void;
/** Search value */
searchValue?: string;
/** Search change handler */
onSearchChange?: (value: string) => void;
/** New chat handler */
onNewChat?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatConversationsList: React.FC<ChatConversationsListProps> = ({
conversations,
currentUser,
activeId,
onConversationClick,
searchValue = '',
onSearchChange,
onNewChat,
className = '',
}) => {
const getConversationName = (conversation: ChatConversation) => {
if (conversation.isGroup && conversation.groupName) {
return conversation.groupName;
}
const otherParticipant = conversation.participants.find(
(p) => p.id !== currentUser?.id
);
return otherParticipant?.name || 'Unknown';
};
const getConversationAvatar = (conversation: ChatConversation) => {
if (conversation.isGroup && conversation.groupAvatar) {
return conversation.groupAvatar;
}
const otherParticipant = conversation.participants.find(
(p) => p.id !== currentUser?.id
);
return otherParticipant?.avatar;
};
const getConversationStatus = (conversation: ChatConversation) => {
if (conversation.isGroup) return undefined;
const otherParticipant = conversation.participants.find(
(p) => p.id !== currentUser?.id
);
return otherParticipant?.status;
};
const formatTime = (date?: Date) => {
if (!date) return '';
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return date.toLocaleDateString([], { weekday: 'short' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
const filteredConversations = conversations.filter((conv) => {
if (!searchValue) return true;
const name = getConversationName(conv).toLowerCase();
return name.includes(searchValue.toLowerCase());
});
return (
<div className={`ll-chat-conversations ${className}`}>
{currentUser && (
<div className="ll-chat-user-header">
<div className="ll-chat-user-avatar">
{currentUser.avatar ? (
<img src={currentUser.avatar} alt={currentUser.name} />
) : (
<span>{currentUser.name.charAt(0).toUpperCase()}</span>
)}
<span className={`ll-chat-status ll-chat-status-${currentUser.status || 'offline'}`} />
</div>
<div className="ll-chat-user-info">
<span className="ll-chat-user-name">{currentUser.name}</span>
<span className="ll-chat-user-status-text">{currentUser.status || 'Offline'}</span>
</div>
{onNewChat && (
<button className="ll-chat-new-btn" onClick={onNewChat} title="New chat">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" />
</svg>
</button>
)}
</div>
)}
{onSearchChange && (
<div className="ll-chat-search">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<input
type="text"
placeholder="Search conversations..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
)}
<div className="ll-chat-conversations-list">
{filteredConversations.map((conversation) => {
const name = getConversationName(conversation);
const avatar = getConversationAvatar(conversation);
const status = getConversationStatus(conversation);
return (
<div
key={conversation.id}
className={`ll-chat-conversation-item ${
activeId === conversation.id ? 'll-chat-conversation-active' : ''
} ${conversation.unreadCount ? 'll-chat-conversation-unread' : ''}`}
onClick={() => onConversationClick?.(conversation)}
>
<div className="ll-chat-conversation-avatar">
{avatar ? (
<img src={avatar} alt={name} />
) : (
<span>{name.charAt(0).toUpperCase()}</span>
)}
{status && (
<span className={`ll-chat-status ll-chat-status-${status}`} />
)}
</div>
<div className="ll-chat-conversation-content">
<div className="ll-chat-conversation-header">
<span className="ll-chat-conversation-name">{name}</span>
{conversation.lastMessage && (
<span className="ll-chat-conversation-time">
{formatTime(conversation.lastMessage.timestamp)}
</span>
)}
</div>
{conversation.lastMessage && (
<div className="ll-chat-conversation-preview">
{conversation.lastMessage.content}
</div>
)}
</div>
{conversation.unreadCount && conversation.unreadCount > 0 && (
<span className="ll-chat-conversation-badge">
{conversation.unreadCount > 99 ? '99+' : conversation.unreadCount}
</span>
)}
</div>
);
})}
{filteredConversations.length === 0 && (
<div className="ll-chat-conversations-empty">
No conversations found
</div>
)}
</div>
</div>
);
};
// Chat Window
export interface ChatWindowProps {
/** Messages to display */
messages: ChatMessage[];
/** Current user ID */
currentUserId: string;
/** Participants map (id -> user) */
participants: Record<string, ChatUser>;
/** Conversation info */
conversation?: ChatConversation;
/** Loading state */
loading?: boolean;
/** Typing indicator users */
typingUsers?: ChatUser[];
/** Message input value */
inputValue?: string;
/** Input change handler */
onInputChange?: (value: string) => void;
/** Send message handler */
onSendMessage?: (content: string) => void;
/** Back button handler */
onBack?: () => void;
/** Info button handler */
onInfo?: () => void;
/** Load more handler (infinite scroll) */
onLoadMore?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatWindow: React.FC<ChatWindowProps> = ({
messages,
currentUserId,
participants,
conversation,
loading = false,
typingUsers = [],
inputValue = '',
onInputChange,
onSendMessage,
onBack,
onInfo,
onLoadMore,
className = '',
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [localInput, setLocalInput] = useState(inputValue);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
useEffect(() => {
setLocalInput(inputValue);
}, [inputValue]);
const handleInputChange = (value: string) => {
setLocalInput(value);
onInputChange?.(value);
};
const handleSend = () => {
if (localInput.trim()) {
onSendMessage?.(localInput.trim());
setLocalInput('');
onInputChange?.('');
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatDate = (date: Date) => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday';
} else {
return date.toLocaleDateString([], {
weekday: 'long',
month: 'long',
day: 'numeric',
});
}
};
const getOtherParticipant = () => {
if (!conversation) return null;
return conversation.participants.find((p) => p.id !== currentUserId);
};
const otherUser = getOtherParticipant();
// Group messages by date
const groupedMessages: { date: string; messages: ChatMessage[] }[] = [];
let currentDate = '';
messages.forEach((msg) => {
const dateStr = msg.timestamp.toDateString();
if (dateStr !== currentDate) {
currentDate = dateStr;
groupedMessages.push({ date: formatDate(msg.timestamp), messages: [] });
}
groupedMessages[groupedMessages.length - 1].messages.push(msg);
});
return (
<div className={`ll-chat-window ${className}`}>
<div className="ll-chat-window-header">
{onBack && (
<button className="ll-chat-window-back" onClick={onBack}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
)}
{conversation && (
<div className="ll-chat-window-info">
<div className="ll-chat-window-avatar">
{conversation.isGroup ? (
conversation.groupAvatar ? (
<img src={conversation.groupAvatar} alt={conversation.groupName} />
) : (
<span>{conversation.groupName?.charAt(0).toUpperCase()}</span>
)
) : otherUser?.avatar ? (
<img src={otherUser.avatar} alt={otherUser.name} />
) : (
<span>{otherUser?.name.charAt(0).toUpperCase()}</span>
)}
{!conversation.isGroup && otherUser?.status && (
<span className={`ll-chat-status ll-chat-status-${otherUser.status}`} />
)}
</div>
<div className="ll-chat-window-details">
<span className="ll-chat-window-name">
{conversation.isGroup ? conversation.groupName : otherUser?.name}
</span>
<span className="ll-chat-window-status">
{conversation.isGroup
? `${conversation.participants.length} participants`
: otherUser?.status === 'online'
? 'Online'
: otherUser?.lastSeen
? `Last seen ${formatTime(otherUser.lastSeen)}`
: 'Offline'}
</span>
</div>
</div>
)}
<div className="ll-chat-window-actions">
{onInfo && (
<button className="ll-chat-window-action" onClick={onInfo}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" />
</svg>
</button>
)}
</div>
</div>
<div className="ll-chat-window-messages" ref={messagesContainerRef}>
{loading && (
<div className="ll-chat-loading">
<div className="ll-chat-loading-spinner" />
</div>
)}
{onLoadMore && messages.length > 0 && (
<button className="ll-chat-load-more" onClick={onLoadMore}>
Load earlier messages
</button>
)}
{groupedMessages.map((group, groupIndex) => (
<div key={groupIndex} className="ll-chat-message-group">
<div className="ll-chat-date-separator">
<span>{group.date}</span>
</div>
{group.messages.map((message, msgIndex) => {
const isOwn = message.senderId === currentUserId;
const sender = participants[message.senderId];
const showAvatar =
!isOwn &&
(msgIndex === 0 ||
group.messages[msgIndex - 1]?.senderId !== message.senderId);
return (
<div
key={message.id}
className={`ll-chat-message ${isOwn ? 'll-chat-message-own' : 'll-chat-message-other'}`}
>
{!isOwn && showAvatar && (
<div className="ll-chat-message-avatar">
{sender?.avatar ? (
<img src={sender.avatar} alt={sender.name} />
) : (
<span>{sender?.name?.charAt(0).toUpperCase() || '?'}</span>
)}
</div>
)}
{!isOwn && !showAvatar && <div className="ll-chat-message-avatar-placeholder" />}
<div className="ll-chat-message-content">
{!isOwn && showAvatar && conversation?.isGroup && (
<span className="ll-chat-message-sender">{sender?.name}</span>
)}
{message.type === 'image' && message.fileUrl && (
<img
src={message.fileUrl}
alt="Shared image"
className="ll-chat-message-image"
/>
)}
{message.type === 'file' && message.fileUrl && (
<a href={message.fileUrl} className="ll-chat-message-file" download>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
</svg>
<span>{message.fileName || 'File'}</span>
</a>
)}
{(message.type === 'text' || !message.type) && (
<div className="ll-chat-message-bubble">
{message.content}
</div>
)}
{message.type === 'system' && (
<div className="ll-chat-message-system">
{message.content}
</div>
)}
<span className="ll-chat-message-time">
{formatTime(message.timestamp)}
{isOwn && message.isRead && (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z" />
</svg>
)}
</span>
</div>
</div>
);
})}
</div>
))}
{typingUsers.length > 0 && (
<div className="ll-chat-typing">
<div className="ll-chat-typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<span>
{typingUsers.map((u) => u.name).join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing...
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="ll-chat-window-input">
<button className="ll-chat-input-action" title="Attach file">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
</svg>
</button>
<textarea
placeholder="Type a message..."
value={localInput}
onChange={(e) => handleInputChange(e.target.value)}
onKeyPress={handleKeyPress}
rows={1}
/>
<button className="ll-chat-input-action" title="Emoji">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
</button>
<button
className="ll-chat-send-btn"
onClick={handleSend}
disabled={!localInput.trim()}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
</div>
</div>
);
};
// Chat User Info Panel
export interface ChatUserInfoProps {
/** User to display */
user: ChatUser;
/** Shared media (optional) */
sharedMedia?: { type: string; url: string; name?: string }[];
/** Close handler */
onClose?: () => void;
/** Block user handler */
onBlock?: () => void;
/** Delete conversation handler */
onDeleteConversation?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatUserInfo: React.FC<ChatUserInfoProps> = ({
user,
sharedMedia = [],
onClose,
onBlock,
onDeleteConversation,
className = '',
}) => {
return (
<div className={`ll-chat-user-info-panel ${className}`}>
<div className="ll-chat-user-info-header">
<h3>Contact Info</h3>
{onClose && (
<button className="ll-chat-user-info-close" onClick={onClose}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
</div>
<div className="ll-chat-user-info-content">
<div className="ll-chat-user-info-avatar">
{user.avatar ? (
<img src={user.avatar} alt={user.name} />
) : (
<span>{user.name.charAt(0).toUpperCase()}</span>
)}
</div>
<h4 className="ll-chat-user-info-name">{user.name}</h4>
<span className={`ll-chat-user-info-status ll-chat-user-info-status-${user.status || 'offline'}`}>
{user.status || 'Offline'}
</span>
{sharedMedia.length > 0 && (
<div className="ll-chat-shared-media">
<h5>Shared Media</h5>
<div className="ll-chat-shared-media-grid">
{sharedMedia.slice(0, 9).map((media, index) => (
<a key={index} href={media.url} className="ll-chat-shared-media-item">
{media.type === 'image' ? (
<img src={media.url} alt={media.name || 'Shared media'} />
) : (
<div className="ll-chat-shared-file">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
</svg>
</div>
)}
</a>
))}
</div>
</div>
)}
<div className="ll-chat-user-info-actions">
{onBlock && (
<button className="ll-chat-user-info-action ll-chat-user-info-action-danger" onClick={onBlock}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z" />
</svg>
Block User
</button>
)}
{onDeleteConversation && (
<button className="ll-chat-user-info-action ll-chat-user-info-action-danger" onClick={onDeleteConversation}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
Delete Conversation
</button>
)}
</div>
</div>
</div>
);
};
// Empty Chat State
export interface ChatEmptyStateProps {
/** Title */
title?: string;
/** Description */
description?: string;
/** Action button text */
actionText?: string;
/** Action handler */
onAction?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ChatEmptyState: React.FC<ChatEmptyStateProps> = ({
title = 'No conversation selected',
description = 'Select a conversation from the list or start a new chat',
actionText,
onAction,
className = '',
}) => {
return (
<div className={`ll-chat-empty-state ${className}`}>
<div className="ll-chat-empty-icon">
<svg viewBox="0 0 24 24" width="64" height="64" fill="currentColor">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" />
</svg>
</div>
<h3>{title}</h3>
<p>{description}</p>
{actionText && onAction && (
<button className="ll-chat-empty-action" onClick={onAction}>
{actionText}
</button>
)}
</div>
);
};
// Hook for chat state management
export interface UseChatOptions {
initialMessages?: ChatMessage[];
currentUserId: string;
}
export const useChat = ({ initialMessages = [], currentUserId }: UseChatOptions) => {
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [inputValue, setInputValue] = useState('');
const sendMessage = useCallback((content: string) => {
const newMessage: ChatMessage = {
id: Date.now().toString(),
senderId: currentUserId,
content,
timestamp: new Date(),
type: 'text',
};
setMessages((prev) => [...prev, newMessage]);
return newMessage;
}, [currentUserId]);
const addMessage = useCallback((message: ChatMessage) => {
setMessages((prev) => [...prev, message]);
}, []);
const markAsRead = useCallback((messageIds: string[]) => {
setMessages((prev) =>
prev.map((msg) =>
messageIds.includes(msg.id) ? { ...msg, isRead: true } : msg
)
);
}, []);
return {
messages,
inputValue,
setInputValue,
sendMessage,
addMessage,
markAsRead,
setMessages,
};
};

595
src/pages/Error.tsx Normal file
View File

@@ -0,0 +1,595 @@
import React from 'react';
// Base Error Layout
export interface ErrorLayoutProps {
/** Error code */
code?: string | number;
/** Error title */
title: string;
/** Error message */
message?: string;
/** Icon element */
icon?: React.ReactNode;
/** Primary action */
primaryAction?: {
label: string;
onClick?: () => void;
href?: string;
};
/** Secondary action */
secondaryAction?: {
label: string;
onClick?: () => void;
href?: string;
};
/** Show search bar */
showSearch?: boolean;
/** Search handler */
onSearch?: (query: string) => void;
/** Footer content */
footer?: React.ReactNode;
/** Additional content */
children?: React.ReactNode;
/** Additional CSS classes */
className?: string;
}
export const ErrorLayout: React.FC<ErrorLayoutProps> = ({
code,
title,
message,
icon,
primaryAction,
secondaryAction,
showSearch = false,
onSearch,
footer,
children,
className = '',
}) => {
const [searchQuery, setSearchQuery] = React.useState('');
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
onSearch?.(searchQuery);
};
return (
<div className={`ll-error-page ${className}`}>
<div className="ll-error-content">
{icon && <div className="ll-error-icon">{icon}</div>}
{code && <div className="ll-error-code">{code}</div>}
<h1 className="ll-error-title">{title}</h1>
{message && <p className="ll-error-message">{message}</p>}
{children}
{showSearch && (
<form className="ll-error-search" onSubmit={handleSearch}>
<input
type="text"
placeholder="Search for pages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="ll-error-search-input"
/>
<button type="submit" className="ll-error-search-btn">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
</form>
)}
<div className="ll-error-actions">
{primaryAction && (
primaryAction.href ? (
<a href={primaryAction.href} className="ll-btn ll-btn-primary">
{primaryAction.label}
</a>
) : (
<button
type="button"
className="ll-btn ll-btn-primary"
onClick={primaryAction.onClick}
>
{primaryAction.label}
</button>
)
)}
{secondaryAction && (
secondaryAction.href ? (
<a href={secondaryAction.href} className="ll-btn ll-btn-outline">
{secondaryAction.label}
</a>
) : (
<button
type="button"
className="ll-btn ll-btn-outline"
onClick={secondaryAction.onClick}
>
{secondaryAction.label}
</button>
)
)}
</div>
</div>
{footer && <div className="ll-error-footer">{footer}</div>}
</div>
);
};
// 404 Not Found Page
export interface NotFoundPageProps {
/** Custom title */
title?: string;
/** Custom message */
message?: string;
/** Home URL */
homeUrl?: string;
/** Show search */
showSearch?: boolean;
/** Search handler */
onSearch?: (query: string) => void;
/** Go back handler */
onGoBack?: () => void;
/** Additional CSS classes */
className?: string;
}
export const NotFoundPage: React.FC<NotFoundPageProps> = ({
title = 'Page Not Found',
message = "The page you're looking for doesn't exist or has been moved.",
homeUrl = '/',
showSearch = true,
onSearch,
onGoBack,
className = '',
}) => {
return (
<ErrorLayout
code="404"
title={title}
message={message}
showSearch={showSearch}
onSearch={onSearch}
icon={
<svg viewBox="0 0 24 24" width="80" height="80" fill="none" stroke="currentColor" strokeWidth="1">
<circle cx="12" cy="12" r="10" />
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
<line x1="9" y1="9" x2="9.01" y2="9" strokeWidth="3" strokeLinecap="round" />
<line x1="15" y1="9" x2="15.01" y2="9" strokeWidth="3" strokeLinecap="round" />
</svg>
}
primaryAction={{
label: 'Go to Homepage',
href: homeUrl,
}}
secondaryAction={onGoBack ? {
label: 'Go Back',
onClick: onGoBack,
} : undefined}
className={`ll-404-page ${className}`}
/>
);
};
// 403 Forbidden Page
export interface ForbiddenPageProps {
/** Custom title */
title?: string;
/** Custom message */
message?: string;
/** Home URL */
homeUrl?: string;
/** Contact URL */
contactUrl?: string;
/** Additional CSS classes */
className?: string;
}
export const ForbiddenPage: React.FC<ForbiddenPageProps> = ({
title = 'Access Denied',
message = "You don't have permission to access this page.",
homeUrl = '/',
contactUrl,
className = '',
}) => {
return (
<ErrorLayout
code="403"
title={title}
message={message}
icon={
<svg viewBox="0 0 24 24" width="80" height="80" fill="none" stroke="currentColor" strokeWidth="1">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
}
primaryAction={{
label: 'Go to Homepage',
href: homeUrl,
}}
secondaryAction={contactUrl ? {
label: 'Contact Support',
href: contactUrl,
} : undefined}
className={`ll-403-page ${className}`}
/>
);
};
// 500 Server Error Page
export interface ServerErrorPageProps {
/** Custom title */
title?: string;
/** Custom message */
message?: string;
/** Home URL */
homeUrl?: string;
/** Retry handler */
onRetry?: () => void;
/** Report handler */
onReport?: () => void;
/** Error details (for developers) */
errorDetails?: string;
/** Show error details */
showErrorDetails?: boolean;
/** Additional CSS classes */
className?: string;
}
export const ServerErrorPage: React.FC<ServerErrorPageProps> = ({
title = 'Server Error',
message = 'Something went wrong on our end. Please try again later.',
homeUrl = '/',
onRetry,
onReport,
errorDetails,
showErrorDetails = false,
className = '',
}) => {
const [showDetails, setShowDetails] = React.useState(false);
return (
<ErrorLayout
code="500"
title={title}
message={message}
icon={
<svg viewBox="0 0 24 24" width="80" height="80" fill="none" stroke="currentColor" strokeWidth="1">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
}
primaryAction={onRetry ? {
label: 'Try Again',
onClick: onRetry,
} : {
label: 'Go to Homepage',
href: homeUrl,
}}
secondaryAction={onReport ? {
label: 'Report Issue',
onClick: onReport,
} : undefined}
className={`ll-500-page ${className}`}
>
{showErrorDetails && errorDetails && (
<div className="ll-error-details">
<button
type="button"
className="ll-error-details-toggle"
onClick={() => setShowDetails(!showDetails)}
>
{showDetails ? 'Hide' : 'Show'} Error Details
</button>
{showDetails && (
<pre className="ll-error-details-content">{errorDetails}</pre>
)}
</div>
)}
</ErrorLayout>
);
};
// 503 Service Unavailable Page
export interface ServiceUnavailablePageProps {
/** Custom title */
title?: string;
/** Custom message */
message?: string;
/** Estimated downtime */
estimatedTime?: string;
/** Retry handler */
onRetry?: () => void;
/** Status page URL */
statusPageUrl?: string;
/** Additional CSS classes */
className?: string;
}
export const ServiceUnavailablePage: React.FC<ServiceUnavailablePageProps> = ({
title = 'Service Unavailable',
message = "We're currently performing maintenance. Please check back soon.",
estimatedTime,
onRetry,
statusPageUrl,
className = '',
}) => {
return (
<ErrorLayout
code="503"
title={title}
message={message}
icon={
<svg viewBox="0 0 24 24" width="80" height="80" fill="none" stroke="currentColor" strokeWidth="1">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
}
primaryAction={onRetry ? {
label: 'Refresh Page',
onClick: onRetry,
} : undefined}
secondaryAction={statusPageUrl ? {
label: 'Check Status',
href: statusPageUrl,
} : undefined}
className={`ll-503-page ${className}`}
>
{estimatedTime && (
<p className="ll-error-estimated-time">
Estimated time: <strong>{estimatedTime}</strong>
</p>
)}
</ErrorLayout>
);
};
// Offline Page
export interface OfflinePageProps {
/** Custom title */
title?: string;
/** Custom message */
message?: string;
/** Retry handler */
onRetry?: () => void;
/** Additional CSS classes */
className?: string;
}
export const OfflinePage: React.FC<OfflinePageProps> = ({
title = "You're Offline",
message = 'Please check your internet connection and try again.',
onRetry,
className = '',
}) => {
return (
<ErrorLayout
title={title}
message={message}
icon={
<svg viewBox="0 0 24 24" width="80" height="80" fill="none" stroke="currentColor" strokeWidth="1">
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55" />
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39" />
<path d="M10.71 5.05A16 16 0 0 1 22.58 9" />
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<line x1="12" y1="20" x2="12.01" y2="20" />
</svg>
}
primaryAction={onRetry ? {
label: 'Try Again',
onClick: onRetry,
} : undefined}
className={`ll-offline-page ${className}`}
/>
);
};
// Coming Soon Page
export interface ComingSoonPageProps {
/** Page title */
title?: string;
/** Page message */
message?: string;
/** Launch date */
launchDate?: Date;
/** Show countdown */
showCountdown?: boolean;
/** Show email subscription */
showSubscription?: boolean;
/** Subscribe handler */
onSubscribe?: (email: string) => void;
/** Social links */
socialLinks?: Array<{
name: string;
icon: React.ReactNode;
url: string;
}>;
/** Background image */
backgroundImage?: string;
/** Additional CSS classes */
className?: string;
}
export const ComingSoonPage: React.FC<ComingSoonPageProps> = ({
title = 'Coming Soon',
message = "We're working hard to bring you something amazing. Stay tuned!",
launchDate,
showCountdown = true,
showSubscription = true,
onSubscribe,
socialLinks = [],
backgroundImage,
className = '',
}) => {
const [email, setEmail] = React.useState('');
const [countdown, setCountdown] = React.useState({ days: 0, hours: 0, minutes: 0, seconds: 0 });
React.useEffect(() => {
if (!launchDate || !showCountdown) return;
const calculateCountdown = () => {
const now = new Date().getTime();
const target = launchDate.getTime();
const diff = target - now;
if (diff <= 0) {
setCountdown({ days: 0, hours: 0, minutes: 0, seconds: 0 });
return;
}
setCountdown({
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
hours: Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
minutes: Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)),
seconds: Math.floor((diff % (1000 * 60)) / 1000),
});
};
calculateCountdown();
const timer = setInterval(calculateCountdown, 1000);
return () => clearInterval(timer);
}, [launchDate, showCountdown]);
const handleSubscribe = (e: React.FormEvent) => {
e.preventDefault();
onSubscribe?.(email);
setEmail('');
};
const style: React.CSSProperties = backgroundImage
? { backgroundImage: `url(${backgroundImage})` }
: {};
return (
<div className={`ll-coming-soon-page ${className}`} style={style}>
<div className="ll-coming-soon-content">
<h1 className="ll-coming-soon-title">{title}</h1>
<p className="ll-coming-soon-message">{message}</p>
{showCountdown && launchDate && (
<div className="ll-countdown">
<div className="ll-countdown-item">
<span className="ll-countdown-value">{countdown.days}</span>
<span className="ll-countdown-label">Days</span>
</div>
<div className="ll-countdown-item">
<span className="ll-countdown-value">{countdown.hours}</span>
<span className="ll-countdown-label">Hours</span>
</div>
<div className="ll-countdown-item">
<span className="ll-countdown-value">{countdown.minutes}</span>
<span className="ll-countdown-label">Minutes</span>
</div>
<div className="ll-countdown-item">
<span className="ll-countdown-value">{countdown.seconds}</span>
<span className="ll-countdown-label">Seconds</span>
</div>
</div>
)}
{showSubscription && (
<form className="ll-coming-soon-form" onSubmit={handleSubscribe}>
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="ll-coming-soon-input"
required
/>
<button type="submit" className="ll-btn ll-btn-primary">
Notify Me
</button>
</form>
)}
{socialLinks.length > 0 && (
<div className="ll-coming-soon-social">
{socialLinks.map((link, index) => (
<a
key={index}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="ll-coming-soon-social-link"
title={link.name}
>
{link.icon}
</a>
))}
</div>
)}
</div>
</div>
);
};
// Under Construction Page
export interface UnderConstructionPageProps {
/** Page title */
title?: string;
/** Page message */
message?: string;
/** Progress percentage */
progress?: number;
/** Home URL */
homeUrl?: string;
/** Contact URL */
contactUrl?: string;
/** Additional CSS classes */
className?: string;
}
export const UnderConstructionPage: React.FC<UnderConstructionPageProps> = ({
title = 'Under Construction',
message = "We're building something great. Please check back soon!",
progress,
homeUrl = '/',
contactUrl,
className = '',
}) => {
return (
<ErrorLayout
title={title}
message={message}
icon={
<svg viewBox="0 0 24 24" width="80" height="80" fill="none" stroke="currentColor" strokeWidth="1">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
}
primaryAction={{
label: 'Go to Homepage',
href: homeUrl,
}}
secondaryAction={contactUrl ? {
label: 'Contact Us',
href: contactUrl,
} : undefined}
className={`ll-under-construction-page ${className}`}
>
{progress !== undefined && (
<div className="ll-construction-progress">
<div className="ll-construction-progress-bar">
<div
className="ll-construction-progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<span className="ll-construction-progress-label">{progress}% Complete</span>
</div>
)}
</ErrorLayout>
);
};

1010
src/pages/Invoice.tsx Normal file

File diff suppressed because it is too large Load Diff

813
src/pages/Mail.tsx Normal file
View File

@@ -0,0 +1,813 @@
import React, { useState, useCallback } from 'react';
// Types
export interface MailMessage {
id: string;
from: {
name: string;
email: string;
avatar?: string;
};
to?: {
name: string;
email: string;
}[];
subject: string;
preview: string;
body?: string;
date: Date;
isRead?: boolean;
isStarred?: boolean;
hasAttachment?: boolean;
attachments?: MailAttachment[];
labels?: string[];
folder?: string;
}
export interface MailAttachment {
id: string;
name: string;
size: string;
type: string;
url?: string;
}
export interface MailFolder {
id: string;
name: string;
icon?: React.ReactNode;
count?: number;
color?: string;
}
export interface MailLabel {
id: string;
name: string;
color: string;
}
// Mail Layout
export interface MailLayoutProps {
/** Sidebar content (folders, labels) */
sidebar?: React.ReactNode;
/** Main content area */
children: React.ReactNode;
/** Compose button handler */
onCompose?: () => void;
/** Show sidebar */
showSidebar?: boolean;
/** Additional CSS classes */
className?: string;
}
export const MailLayout: React.FC<MailLayoutProps> = ({
sidebar,
children,
onCompose,
showSidebar = true,
className = '',
}) => {
return (
<div className={`ll-mail-layout ${className}`}>
{showSidebar && (
<div className="ll-mail-sidebar">
{onCompose && (
<button className="ll-mail-compose-btn" onClick={onCompose}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
Compose
</button>
)}
{sidebar}
</div>
)}
<div className="ll-mail-content">
{children}
</div>
</div>
);
};
// Mail Sidebar
export interface MailSidebarProps {
/** Folders list */
folders?: MailFolder[];
/** Labels list */
labels?: MailLabel[];
/** Active folder ID */
activeFolder?: string;
/** Folder click handler */
onFolderClick?: (folder: MailFolder) => void;
/** Label click handler */
onLabelClick?: (label: MailLabel) => void;
/** Additional CSS classes */
className?: string;
}
export const MailSidebar: React.FC<MailSidebarProps> = ({
folders = [],
labels = [],
activeFolder,
onFolderClick,
onLabelClick,
className = '',
}) => {
return (
<div className={`ll-mail-sidebar-content ${className}`}>
{folders.length > 0 && (
<div className="ll-mail-folders">
<div className="ll-mail-section-title">Folders</div>
<ul className="ll-mail-folder-list">
{folders.map((folder) => (
<li
key={folder.id}
className={`ll-mail-folder-item ${activeFolder === folder.id ? 'll-mail-folder-active' : ''}`}
onClick={() => onFolderClick?.(folder)}
>
{folder.icon && <span className="ll-mail-folder-icon">{folder.icon}</span>}
<span className="ll-mail-folder-name">{folder.name}</span>
{folder.count !== undefined && folder.count > 0 && (
<span className="ll-mail-folder-count">{folder.count}</span>
)}
</li>
))}
</ul>
</div>
)}
{labels.length > 0 && (
<div className="ll-mail-labels">
<div className="ll-mail-section-title">Labels</div>
<ul className="ll-mail-label-list">
{labels.map((label) => (
<li
key={label.id}
className="ll-mail-label-item"
onClick={() => onLabelClick?.(label)}
>
<span
className="ll-mail-label-dot"
style={{ backgroundColor: label.color }}
/>
<span className="ll-mail-label-name">{label.name}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
};
// Mail List
export interface MailListProps {
/** Messages to display */
messages: MailMessage[];
/** Selected message IDs */
selectedIds?: string[];
/** Selection change handler */
onSelectionChange?: (ids: string[]) => void;
/** Message click handler */
onMessageClick?: (message: MailMessage) => void;
/** Star toggle handler */
onStarToggle?: (message: MailMessage) => void;
/** Show checkboxes */
showCheckboxes?: boolean;
/** Loading state */
loading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
}
export const MailList: React.FC<MailListProps> = ({
messages,
selectedIds = [],
onSelectionChange,
onMessageClick,
onStarToggle,
showCheckboxes = true,
loading = false,
emptyMessage = 'No messages',
className = '',
}) => {
const handleSelectAll = useCallback(() => {
if (selectedIds.length === messages.length) {
onSelectionChange?.([]);
} else {
onSelectionChange?.(messages.map((m) => m.id));
}
}, [selectedIds, messages, onSelectionChange]);
const handleSelectMessage = useCallback((id: string) => {
if (selectedIds.includes(id)) {
onSelectionChange?.(selectedIds.filter((sid) => sid !== id));
} else {
onSelectionChange?.([...selectedIds, id]);
}
}, [selectedIds, onSelectionChange]);
const formatDate = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return date.toLocaleDateString([], { weekday: 'short' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
if (loading) {
return (
<div className={`ll-mail-list ll-mail-list-loading ${className}`}>
<div className="ll-mail-loading-spinner" />
</div>
);
}
if (messages.length === 0) {
return (
<div className={`ll-mail-list ll-mail-list-empty ${className}`}>
<div className="ll-mail-empty-icon">
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
</svg>
</div>
<p className="ll-mail-empty-text">{emptyMessage}</p>
</div>
);
}
return (
<div className={`ll-mail-list ${className}`}>
{showCheckboxes && (
<div className="ll-mail-list-header">
<label className="ll-mail-checkbox">
<input
type="checkbox"
checked={selectedIds.length === messages.length && messages.length > 0}
onChange={handleSelectAll}
/>
<span className="ll-mail-checkbox-mark" />
</label>
<span className="ll-mail-list-info">
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${messages.length} messages`}
</span>
</div>
)}
<div className="ll-mail-messages">
{messages.map((message) => (
<div
key={message.id}
className={`ll-mail-message ${!message.isRead ? 'll-mail-message-unread' : ''} ${
selectedIds.includes(message.id) ? 'll-mail-message-selected' : ''
}`}
>
{showCheckboxes && (
<label className="ll-mail-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.includes(message.id)}
onChange={() => handleSelectMessage(message.id)}
/>
<span className="ll-mail-checkbox-mark" />
</label>
)}
<button
className={`ll-mail-star ${message.isStarred ? 'll-mail-star-active' : ''}`}
onClick={(e) => {
e.stopPropagation();
onStarToggle?.(message);
}}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
</button>
<div className="ll-mail-message-content" onClick={() => onMessageClick?.(message)}>
<div className="ll-mail-message-avatar">
{message.from.avatar ? (
<img src={message.from.avatar} alt={message.from.name} />
) : (
<span className="ll-mail-message-avatar-placeholder">
{message.from.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="ll-mail-message-info">
<div className="ll-mail-message-header">
<span className="ll-mail-message-sender">{message.from.name}</span>
<span className="ll-mail-message-date">{formatDate(message.date)}</span>
</div>
<div className="ll-mail-message-subject">{message.subject}</div>
<div className="ll-mail-message-preview">{message.preview}</div>
</div>
{message.hasAttachment && (
<div className="ll-mail-message-attachment">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
</svg>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
};
// Mail Toolbar
export interface MailToolbarProps {
/** Selected count */
selectedCount?: number;
/** Archive handler */
onArchive?: () => void;
/** Delete handler */
onDelete?: () => void;
/** Mark as read handler */
onMarkRead?: () => void;
/** Mark as unread handler */
onMarkUnread?: () => void;
/** Move handler */
onMove?: () => void;
/** Refresh handler */
onRefresh?: () => void;
/** Search value */
searchValue?: string;
/** Search change handler */
onSearchChange?: (value: string) => void;
/** Additional CSS classes */
className?: string;
}
export const MailToolbar: React.FC<MailToolbarProps> = ({
selectedCount = 0,
onArchive,
onDelete,
onMarkRead,
onMarkUnread,
onMove,
onRefresh,
searchValue = '',
onSearchChange,
className = '',
}) => {
return (
<div className={`ll-mail-toolbar ${className}`}>
<div className="ll-mail-toolbar-actions">
{selectedCount > 0 ? (
<>
{onArchive && (
<button className="ll-mail-toolbar-btn" onClick={onArchive} title="Archive">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z" />
</svg>
</button>
)}
{onDelete && (
<button className="ll-mail-toolbar-btn ll-mail-toolbar-btn-danger" onClick={onDelete} title="Delete">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
)}
{onMarkRead && (
<button className="ll-mail-toolbar-btn" onClick={onMarkRead} title="Mark as read">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
</svg>
</button>
)}
{onMarkUnread && (
<button className="ll-mail-toolbar-btn" onClick={onMarkUnread} title="Mark as unread">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M22 8.98V18c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2h10.1c-.06.32-.1.66-.1 1 0 1.48.65 2.79 1.67 3.71L12 11 4 6v2l8 5 5.3-3.32c.54.2 1.1.32 1.7.32 1.13 0 2.16-.39 3-1.02zM16 5c0 1.66 1.34 3 3 3s3-1.34 3-3-1.34-3-3-3-3 1.34-3 3z" />
</svg>
</button>
)}
{onMove && (
<button className="ll-mail-toolbar-btn" onClick={onMove} title="Move to">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z" />
</svg>
</button>
)}
</>
) : (
onRefresh && (
<button className="ll-mail-toolbar-btn" onClick={onRefresh} title="Refresh">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
</svg>
</button>
)
)}
</div>
{onSearchChange && (
<div className="ll-mail-search">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<input
type="text"
placeholder="Search mail..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
)}
</div>
);
};
// Mail Read View
export interface MailReadProps {
/** Message to display */
message: MailMessage;
/** Back button handler */
onBack?: () => void;
/** Reply handler */
onReply?: () => void;
/** Reply all handler */
onReplyAll?: () => void;
/** Forward handler */
onForward?: () => void;
/** Delete handler */
onDelete?: () => void;
/** Star toggle handler */
onStarToggle?: () => void;
/** Additional CSS classes */
className?: string;
}
export const MailRead: React.FC<MailReadProps> = ({
message,
onBack,
onReply,
onReplyAll,
onForward,
onDelete,
onStarToggle,
className = '',
}) => {
const formatDate = (date: Date) => {
return date.toLocaleDateString([], {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className={`ll-mail-read ${className}`}>
<div className="ll-mail-read-header">
{onBack && (
<button className="ll-mail-read-back" onClick={onBack}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
)}
<div className="ll-mail-read-actions">
{onReply && (
<button className="ll-mail-read-btn" onClick={onReply} title="Reply">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z" />
</svg>
</button>
)}
{onReplyAll && (
<button className="ll-mail-read-btn" onClick={onReplyAll} title="Reply All">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M7 8V5l-7 7 7 7v-3l-4-4 4-4zm6 1V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z" />
</svg>
</button>
)}
{onForward && (
<button className="ll-mail-read-btn" onClick={onForward} title="Forward">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M14 9V5l7 7-7 7v-4.1c-5 0-8.5 1.6-11 5.1 1-5 4-10 11-11z" />
</svg>
</button>
)}
{onDelete && (
<button className="ll-mail-read-btn ll-mail-read-btn-danger" onClick={onDelete} title="Delete">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
)}
{onStarToggle && (
<button
className={`ll-mail-read-btn ${message.isStarred ? 'll-mail-read-btn-starred' : ''}`}
onClick={onStarToggle}
title={message.isStarred ? 'Remove star' : 'Add star'}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
</button>
)}
</div>
</div>
<div className="ll-mail-read-subject">
<h2>{message.subject}</h2>
{message.labels && message.labels.length > 0 && (
<div className="ll-mail-read-labels">
{message.labels.map((label, index) => (
<span key={index} className="ll-mail-read-label">{label}</span>
))}
</div>
)}
</div>
<div className="ll-mail-read-meta">
<div className="ll-mail-read-avatar">
{message.from.avatar ? (
<img src={message.from.avatar} alt={message.from.name} />
) : (
<span className="ll-mail-read-avatar-placeholder">
{message.from.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="ll-mail-read-info">
<div className="ll-mail-read-sender">
<strong>{message.from.name}</strong>
<span>&lt;{message.from.email}&gt;</span>
</div>
<div className="ll-mail-read-recipients">
to {message.to?.map((r) => r.name).join(', ') || 'me'}
</div>
</div>
<div className="ll-mail-read-date">
{formatDate(message.date)}
</div>
</div>
<div className="ll-mail-read-body">
{message.body || message.preview}
</div>
{message.attachments && message.attachments.length > 0 && (
<div className="ll-mail-read-attachments">
<div className="ll-mail-read-attachments-header">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
</svg>
<span>{message.attachments.length} attachment{message.attachments.length > 1 ? 's' : ''}</span>
</div>
<div className="ll-mail-read-attachments-list">
{message.attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.url}
className="ll-mail-attachment"
download
>
<div className="ll-mail-attachment-icon">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
</svg>
</div>
<div className="ll-mail-attachment-info">
<span className="ll-mail-attachment-name">{attachment.name}</span>
<span className="ll-mail-attachment-size">{attachment.size}</span>
</div>
</a>
))}
</div>
</div>
)}
</div>
);
};
// Mail Compose
export interface MailComposeProps {
/** Initial values */
initialTo?: string;
initialSubject?: string;
initialBody?: string;
/** Send handler */
onSend?: (data: { to: string; cc?: string; bcc?: string; subject: string; body: string }) => void;
/** Save draft handler */
onSaveDraft?: (data: { to: string; cc?: string; bcc?: string; subject: string; body: string }) => void;
/** Discard handler */
onDiscard?: () => void;
/** Close handler */
onClose?: () => void;
/** Show as modal */
isModal?: boolean;
/** Additional CSS classes */
className?: string;
}
export const MailCompose: React.FC<MailComposeProps> = ({
initialTo = '',
initialSubject = '',
initialBody = '',
onSend,
onSaveDraft,
onDiscard,
onClose,
isModal = false,
className = '',
}) => {
const [to, setTo] = useState(initialTo);
const [cc, setCc] = useState('');
const [bcc, setBcc] = useState('');
const [subject, setSubject] = useState(initialSubject);
const [body, setBody] = useState(initialBody);
const [showCc, setShowCc] = useState(false);
const [showBcc, setShowBcc] = useState(false);
const handleSend = () => {
onSend?.({ to, cc, bcc, subject, body });
};
const handleSaveDraft = () => {
onSaveDraft?.({ to, cc, bcc, subject, body });
};
return (
<div className={`ll-mail-compose ${isModal ? 'll-mail-compose-modal' : ''} ${className}`}>
<div className="ll-mail-compose-header">
<h3>New Message</h3>
{onClose && (
<button className="ll-mail-compose-close" onClick={onClose}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
</div>
<div className="ll-mail-compose-form">
<div className="ll-mail-compose-field">
<label>To</label>
<div className="ll-mail-compose-input-row">
<input
type="email"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="Recipients"
/>
<div className="ll-mail-compose-cc-toggle">
{!showCc && (
<button onClick={() => setShowCc(true)}>Cc</button>
)}
{!showBcc && (
<button onClick={() => setShowBcc(true)}>Bcc</button>
)}
</div>
</div>
</div>
{showCc && (
<div className="ll-mail-compose-field">
<label>Cc</label>
<input
type="email"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="Cc recipients"
/>
</div>
)}
{showBcc && (
<div className="ll-mail-compose-field">
<label>Bcc</label>
<input
type="email"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="Bcc recipients"
/>
</div>
)}
<div className="ll-mail-compose-field">
<label>Subject</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Subject"
/>
</div>
<div className="ll-mail-compose-body">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your message..."
/>
</div>
</div>
<div className="ll-mail-compose-footer">
<div className="ll-mail-compose-actions-left">
<button className="ll-mail-compose-btn-primary" onClick={handleSend}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
Send
</button>
{onSaveDraft && (
<button className="ll-mail-compose-btn" onClick={handleSaveDraft}>
Save Draft
</button>
)}
</div>
<div className="ll-mail-compose-actions-right">
{onDiscard && (
<button className="ll-mail-compose-btn ll-mail-compose-btn-danger" onClick={onDiscard}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
)}
</div>
</div>
</div>
);
};
// Default Mail Folders
export const defaultMailFolders: MailFolder[] = [
{
id: 'inbox',
name: 'Inbox',
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 3H4.99c-1.11 0-1.98.89-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.11-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" />
</svg>
),
},
{
id: 'sent',
name: 'Sent',
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
),
},
{
id: 'drafts',
name: 'Drafts',
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M21.99 8c0-.72-.37-1.35-.94-1.7L12 1 2.95 6.3C2.38 6.65 2 7.28 2 8v10c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2l-.01-10zM12 13L3.74 7.84 12 3l8.26 4.84L12 13z" />
</svg>
),
},
{
id: 'spam',
name: 'Spam',
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
),
},
{
id: 'trash',
name: 'Trash',
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
),
},
];
// Default Mail Labels
export const defaultMailLabels: MailLabel[] = [
{ id: 'work', name: 'Work', color: '#3b82f6' },
{ id: 'personal', name: 'Personal', color: '#10b981' },
{ id: 'important', name: 'Important', color: '#ef4444' },
{ id: 'social', name: 'Social', color: '#8b5cf6' },
];

982
src/pages/Search.tsx Normal file
View File

@@ -0,0 +1,982 @@
import React, { useState, useCallback } from 'react';
// Types
export interface SearchResult {
id: string;
type: 'page' | 'user' | 'image' | 'video' | 'file' | 'product';
title: string;
description?: string;
url?: string;
image?: string;
meta?: Record<string, unknown>;
}
export interface SearchFilter {
id: string;
label: string;
type: 'checkbox' | 'radio' | 'range' | 'select';
options?: { value: string; label: string; count?: number }[];
value?: unknown;
}
export interface SearchSuggestion {
id: string;
text: string;
type?: 'recent' | 'popular' | 'suggestion';
}
// Search Layout
export interface SearchLayoutProps {
/** Sidebar with filters */
sidebar?: React.ReactNode;
/** Main results area */
children: React.ReactNode;
/** Show sidebar */
showSidebar?: boolean;
/** Additional CSS classes */
className?: string;
}
export const SearchLayout: React.FC<SearchLayoutProps> = ({
sidebar,
children,
showSidebar = true,
className = '',
}) => {
return (
<div className={`ll-search-layout ${className}`}>
{showSidebar && sidebar && (
<div className="ll-search-sidebar">{sidebar}</div>
)}
<div className="ll-search-main">{children}</div>
</div>
);
};
// Search Box
export interface SearchBoxProps {
/** Search value */
value: string;
/** Value change handler */
onChange: (value: string) => void;
/** Submit handler */
onSubmit?: (value: string) => void;
/** Placeholder text */
placeholder?: string;
/** Show search button */
showButton?: boolean;
/** Button text */
buttonText?: string;
/** Suggestions */
suggestions?: SearchSuggestion[];
/** Suggestion click handler */
onSuggestionClick?: (suggestion: SearchSuggestion) => void;
/** Clear handler */
onClear?: () => void;
/** Loading state */
loading?: boolean;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Additional CSS classes */
className?: string;
}
export const SearchBox: React.FC<SearchBoxProps> = ({
value,
onChange,
onSubmit,
placeholder = 'Search...',
showButton = true,
buttonText = 'Search',
suggestions = [],
onSuggestionClick,
onClear,
loading = false,
size = 'md',
className = '',
}) => {
const [showSuggestions, setShowSuggestions] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setShowSuggestions(false);
onSubmit?.(value);
};
const handleSuggestionClick = (suggestion: SearchSuggestion) => {
onChange(suggestion.text);
setShowSuggestions(false);
onSuggestionClick?.(suggestion);
};
return (
<div className={`ll-search-box ll-search-box-${size} ${className}`}>
<form onSubmit={handleSubmit} className="ll-search-box-form">
<div className="ll-search-box-input-wrapper">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" className="ll-search-box-icon">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
placeholder={placeholder}
className="ll-search-box-input"
/>
{loading && <div className="ll-search-box-spinner" />}
{value && onClear && !loading && (
<button
type="button"
className="ll-search-box-clear"
onClick={() => {
onClear();
onChange('');
}}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
</div>
{showButton && (
<button type="submit" className="ll-search-box-button">
{buttonText}
</button>
)}
</form>
{showSuggestions && suggestions.length > 0 && (
<div className="ll-search-suggestions">
{suggestions.map((suggestion) => (
<button
key={suggestion.id}
className="ll-search-suggestion"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion.type === 'recent' && (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z" />
</svg>
)}
{suggestion.type === 'popular' && (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z" />
</svg>
)}
{(!suggestion.type || suggestion.type === 'suggestion') && (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
)}
<span>{suggestion.text}</span>
</button>
))}
</div>
)}
</div>
);
};
// Search Filters
export interface SearchFiltersProps {
/** Filters configuration */
filters: SearchFilter[];
/** Filter change handler */
onFilterChange: (filterId: string, value: unknown) => void;
/** Clear all handler */
onClearAll?: () => void;
/** Collapsible sections */
collapsible?: boolean;
/** Additional CSS classes */
className?: string;
}
export const SearchFilters: React.FC<SearchFiltersProps> = ({
filters,
onFilterChange,
onClearAll,
collapsible = true,
className = '',
}) => {
const [expandedSections, setExpandedSections] = useState<string[]>(
filters.map((f) => f.id)
);
const toggleSection = (id: string) => {
if (!collapsible) return;
setExpandedSections((prev) =>
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]
);
};
const hasActiveFilters = filters.some((f) => {
if (Array.isArray(f.value)) return f.value.length > 0;
return f.value !== undefined && f.value !== '';
});
return (
<div className={`ll-search-filters ${className}`}>
<div className="ll-search-filters-header">
<h3>Filters</h3>
{hasActiveFilters && onClearAll && (
<button className="ll-search-filters-clear" onClick={onClearAll}>
Clear all
</button>
)}
</div>
{filters.map((filter) => {
const isExpanded = expandedSections.includes(filter.id);
return (
<div key={filter.id} className="ll-search-filter-section">
<button
className="ll-search-filter-header"
onClick={() => toggleSection(filter.id)}
>
<span>{filter.label}</span>
{collapsible && (
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="currentColor"
className={`ll-search-filter-toggle ${isExpanded ? 'll-search-filter-expanded' : ''}`}
>
<path d="M7 10l5 5 5-5z" />
</svg>
)}
</button>
{isExpanded && (
<div className="ll-search-filter-content">
{filter.type === 'checkbox' && filter.options && (
<div className="ll-search-filter-checkboxes">
{filter.options.map((option) => (
<label key={option.value} className="ll-search-filter-checkbox">
<input
type="checkbox"
checked={Array.isArray(filter.value) && filter.value.includes(option.value)}
onChange={(e) => {
const currentValues = Array.isArray(filter.value) ? filter.value : [];
const newValues = e.target.checked
? [...currentValues, option.value]
: currentValues.filter((v) => v !== option.value);
onFilterChange(filter.id, newValues);
}}
/>
<span className="ll-search-filter-checkbox-label">
{option.label}
{option.count !== undefined && (
<span className="ll-search-filter-count">({option.count})</span>
)}
</span>
</label>
))}
</div>
)}
{filter.type === 'radio' && filter.options && (
<div className="ll-search-filter-radios">
{filter.options.map((option) => (
<label key={option.value} className="ll-search-filter-radio">
<input
type="radio"
name={filter.id}
checked={filter.value === option.value}
onChange={() => onFilterChange(filter.id, option.value)}
/>
<span className="ll-search-filter-radio-label">
{option.label}
{option.count !== undefined && (
<span className="ll-search-filter-count">({option.count})</span>
)}
</span>
</label>
))}
</div>
)}
{filter.type === 'select' && filter.options && (
<select
className="ll-search-filter-select"
value={filter.value as string || ''}
onChange={(e) => onFilterChange(filter.id, e.target.value)}
>
<option value="">All</option>
{filter.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
</div>
)}
</div>
);
})}
</div>
);
};
// Search Results Header
export interface SearchResultsHeaderProps {
/** Total results count */
totalResults: number;
/** Search query */
query?: string;
/** View mode */
viewMode?: 'list' | 'grid';
/** View mode change handler */
onViewModeChange?: (mode: 'list' | 'grid') => void;
/** Sort by value */
sortBy?: string;
/** Sort options */
sortOptions?: { value: string; label: string }[];
/** Sort change handler */
onSortChange?: (value: string) => void;
/** Additional CSS classes */
className?: string;
}
export const SearchResultsHeader: React.FC<SearchResultsHeaderProps> = ({
totalResults,
query,
viewMode = 'list',
onViewModeChange,
sortBy,
sortOptions = [],
onSortChange,
className = '',
}) => {
return (
<div className={`ll-search-results-header ${className}`}>
<div className="ll-search-results-info">
<span className="ll-search-results-count">
{totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''}
</span>
{query && (
<span className="ll-search-results-query">
for "<strong>{query}</strong>"
</span>
)}
</div>
<div className="ll-search-results-controls">
{sortOptions.length > 0 && onSortChange && (
<div className="ll-search-results-sort">
<label>Sort by:</label>
<select value={sortBy || ''} onChange={(e) => onSortChange(e.target.value)}>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
{onViewModeChange && (
<div className="ll-search-results-view-toggle">
<button
className={`ll-search-view-btn ${viewMode === 'list' ? 'll-search-view-btn-active' : ''}`}
onClick={() => onViewModeChange('list')}
title="List view"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg>
</button>
<button
className={`ll-search-view-btn ${viewMode === 'grid' ? 'll-search-view-btn-active' : ''}`}
onClick={() => onViewModeChange('grid')}
title="Grid view"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M4 11h5V5H4v6zm0 7h5v-6H4v6zm6 0h5v-6h-5v6zm6 0h5v-6h-5v6zm-6-7h5V5h-5v6zm6-6v6h5V5h-5z" />
</svg>
</button>
</div>
)}
</div>
</div>
);
};
// Search Results List
export interface SearchResultsListProps {
/** Results to display */
results: SearchResult[];
/** Result click handler */
onResultClick?: (result: SearchResult) => void;
/** View mode */
viewMode?: 'list' | 'grid';
/** Loading state */
loading?: boolean;
/** Highlight query */
highlightQuery?: string;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
}
export const SearchResultsList: React.FC<SearchResultsListProps> = ({
results,
onResultClick,
viewMode = 'list',
loading = false,
highlightQuery,
emptyMessage = 'No results found',
className = '',
}) => {
const highlightText = (text: string, query?: string) => {
if (!query || !text) return text;
const regex = new RegExp(`(${query})`, 'gi');
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? <mark key={i}>{part}</mark> : part
);
};
if (loading) {
return (
<div className={`ll-search-results ll-search-results-loading ${className}`}>
<div className="ll-search-results-spinner" />
</div>
);
}
if (results.length === 0) {
return (
<div className={`ll-search-results ll-search-results-empty ${className}`}>
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<p>{emptyMessage}</p>
</div>
);
}
return (
<div className={`ll-search-results ll-search-results-${viewMode} ${className}`}>
{results.map((result) => (
<div
key={result.id}
className="ll-search-result"
onClick={() => onResultClick?.(result)}
>
{result.image && (
<div className="ll-search-result-image">
<img src={result.image} alt={result.title} />
</div>
)}
<div className="ll-search-result-content">
<h4 className="ll-search-result-title">
{highlightText(result.title, highlightQuery)}
</h4>
{result.url && (
<span className="ll-search-result-url">{result.url}</span>
)}
{result.description && (
<p className="ll-search-result-description">
{highlightText(result.description, highlightQuery)}
</p>
)}
<span className="ll-search-result-type">{result.type}</span>
</div>
</div>
))}
</div>
);
};
// Search Results Users
export interface SearchResultsUsersProps {
/** Users to display */
users: {
id: string;
name: string;
avatar?: string;
title?: string;
location?: string;
email?: string;
}[];
/** User click handler */
onUserClick?: (userId: string) => void;
/** Follow handler */
onFollow?: (userId: string) => void;
/** Get follow status */
getFollowStatus?: (userId: string) => boolean;
/** View mode */
viewMode?: 'list' | 'grid';
/** Loading state */
loading?: boolean;
/** Additional CSS classes */
className?: string;
}
export const SearchResultsUsers: React.FC<SearchResultsUsersProps> = ({
users,
onUserClick,
onFollow,
getFollowStatus,
viewMode = 'list',
loading = false,
className = '',
}) => {
if (loading) {
return (
<div className={`ll-search-users ll-search-users-loading ${className}`}>
<div className="ll-search-users-spinner" />
</div>
);
}
if (users.length === 0) {
return (
<div className={`ll-search-users ll-search-users-empty ${className}`}>
<p>No users found</p>
</div>
);
}
return (
<div className={`ll-search-users ll-search-users-${viewMode} ${className}`}>
{users.map((user) => (
<div
key={user.id}
className="ll-search-user"
onClick={() => onUserClick?.(user.id)}
>
<div className="ll-search-user-avatar">
{user.avatar ? (
<img src={user.avatar} alt={user.name} />
) : (
<span>{user.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="ll-search-user-info">
<h4 className="ll-search-user-name">{user.name}</h4>
{user.title && <p className="ll-search-user-title">{user.title}</p>}
{user.location && (
<p className="ll-search-user-location">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
</svg>
{user.location}
</p>
)}
</div>
{onFollow && (
<button
className={`ll-search-user-follow ${getFollowStatus?.(user.id) ? 'll-search-user-following' : ''}`}
onClick={(e) => {
e.stopPropagation();
onFollow(user.id);
}}
>
{getFollowStatus?.(user.id) ? 'Following' : 'Follow'}
</button>
)}
</div>
))}
</div>
);
};
// Search Results Images
export interface SearchResultsImagesProps {
/** Images to display */
images: {
id: string;
src: string;
alt?: string;
width?: number;
height?: number;
title?: string;
}[];
/** Image click handler */
onImageClick?: (imageId: string) => void;
/** Columns count */
columns?: number;
/** Loading state */
loading?: boolean;
/** Additional CSS classes */
className?: string;
}
export const SearchResultsImages: React.FC<SearchResultsImagesProps> = ({
images,
onImageClick,
columns = 4,
loading = false,
className = '',
}) => {
if (loading) {
return (
<div className={`ll-search-images ll-search-images-loading ${className}`}>
<div className="ll-search-images-spinner" />
</div>
);
}
if (images.length === 0) {
return (
<div className={`ll-search-images ll-search-images-empty ${className}`}>
<p>No images found</p>
</div>
);
}
return (
<div
className={`ll-search-images ${className}`}
style={{ '--columns': columns } as React.CSSProperties}
>
{images.map((image) => (
<div
key={image.id}
className="ll-search-image"
onClick={() => onImageClick?.(image.id)}
>
<img src={image.src} alt={image.alt || image.title || ''} />
{image.title && (
<div className="ll-search-image-overlay">
<span>{image.title}</span>
</div>
)}
</div>
))}
</div>
);
};
// Search Results Videos
export interface SearchResultsVideosProps {
/** Videos to display */
videos: {
id: string;
thumbnail: string;
title: string;
duration?: string;
views?: number;
channel?: string;
uploadDate?: Date;
}[];
/** Video click handler */
onVideoClick?: (videoId: string) => void;
/** View mode */
viewMode?: 'list' | 'grid';
/** Loading state */
loading?: boolean;
/** Additional CSS classes */
className?: string;
}
export const SearchResultsVideos: React.FC<SearchResultsVideosProps> = ({
videos,
onVideoClick,
viewMode = 'grid',
loading = false,
className = '',
}) => {
const formatViews = (views?: number) => {
if (!views) return '';
if (views >= 1000000) return `${(views / 1000000).toFixed(1)}M views`;
if (views >= 1000) return `${(views / 1000).toFixed(1)}K views`;
return `${views} views`;
};
const formatDate = (date?: Date) => {
if (!date) return '';
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
};
if (loading) {
return (
<div className={`ll-search-videos ll-search-videos-loading ${className}`}>
<div className="ll-search-videos-spinner" />
</div>
);
}
if (videos.length === 0) {
return (
<div className={`ll-search-videos ll-search-videos-empty ${className}`}>
<p>No videos found</p>
</div>
);
}
return (
<div className={`ll-search-videos ll-search-videos-${viewMode} ${className}`}>
{videos.map((video) => (
<div
key={video.id}
className="ll-search-video"
onClick={() => onVideoClick?.(video.id)}
>
<div className="ll-search-video-thumbnail">
<img src={video.thumbnail} alt={video.title} />
{video.duration && (
<span className="ll-search-video-duration">{video.duration}</span>
)}
<div className="ll-search-video-play">
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
<div className="ll-search-video-info">
<h4 className="ll-search-video-title">{video.title}</h4>
{video.channel && (
<span className="ll-search-video-channel">{video.channel}</span>
)}
<div className="ll-search-video-meta">
{video.views !== undefined && (
<span>{formatViews(video.views)}</span>
)}
{video.uploadDate && (
<span>{formatDate(video.uploadDate)}</span>
)}
</div>
</div>
</div>
))}
</div>
);
};
// Search Pagination
export interface SearchPaginationProps {
/** Current page */
currentPage: number;
/** Total pages */
totalPages: number;
/** Page change handler */
onPageChange: (page: number) => void;
/** Show page numbers */
showPageNumbers?: boolean;
/** Max visible pages */
maxVisiblePages?: number;
/** Additional CSS classes */
className?: string;
}
export const SearchPagination: React.FC<SearchPaginationProps> = ({
currentPage,
totalPages,
onPageChange,
showPageNumbers = true,
maxVisiblePages = 5,
className = '',
}) => {
const getVisiblePages = () => {
const pages: number[] = [];
const half = Math.floor(maxVisiblePages / 2);
let start = Math.max(1, currentPage - half);
let end = Math.min(totalPages, start + maxVisiblePages - 1);
if (end - start + 1 < maxVisiblePages) {
start = Math.max(1, end - maxVisiblePages + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
};
const visiblePages = getVisiblePages();
return (
<div className={`ll-search-pagination ${className}`}>
<button
className="ll-search-pagination-btn ll-search-pagination-prev"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
</svg>
Previous
</button>
{showPageNumbers && (
<div className="ll-search-pagination-pages">
{visiblePages[0] > 1 && (
<>
<button
className="ll-search-pagination-page"
onClick={() => onPageChange(1)}
>
1
</button>
{visiblePages[0] > 2 && (
<span className="ll-search-pagination-ellipsis">...</span>
)}
</>
)}
{visiblePages.map((page) => (
<button
key={page}
className={`ll-search-pagination-page ${currentPage === page ? 'll-search-pagination-active' : ''}`}
onClick={() => onPageChange(page)}
>
{page}
</button>
))}
{visiblePages[visiblePages.length - 1] < totalPages && (
<>
{visiblePages[visiblePages.length - 1] < totalPages - 1 && (
<span className="ll-search-pagination-ellipsis">...</span>
)}
<button
className="ll-search-pagination-page"
onClick={() => onPageChange(totalPages)}
>
{totalPages}
</button>
</>
)}
</div>
)}
<button
className="ll-search-pagination-btn ll-search-pagination-next"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
Next
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</button>
</div>
);
};
// Search Tabs
export interface SearchTabsProps {
/** Tabs configuration */
tabs: { id: string; label: string; count?: number }[];
/** Active tab */
activeTab: string;
/** Tab change handler */
onTabChange: (tabId: string) => void;
/** Additional CSS classes */
className?: string;
}
export const SearchTabs: React.FC<SearchTabsProps> = ({
tabs,
activeTab,
onTabChange,
className = '',
}) => {
return (
<div className={`ll-search-tabs ${className}`}>
{tabs.map((tab) => (
<button
key={tab.id}
className={`ll-search-tab ${activeTab === tab.id ? 'll-search-tab-active' : ''}`}
onClick={() => onTabChange(tab.id)}
>
{tab.label}
{tab.count !== undefined && (
<span className="ll-search-tab-count">{tab.count.toLocaleString()}</span>
)}
</button>
))}
</div>
);
};
// Hook for search state
export interface UseSearchOptions {
initialQuery?: string;
initialFilters?: Record<string, unknown>;
onSearch?: (query: string, filters: Record<string, unknown>) => void;
}
export const useSearch = ({
initialQuery = '',
initialFilters = {},
onSearch,
}: UseSearchOptions = {}) => {
const [query, setQuery] = useState(initialQuery);
const [filters, setFilters] = useState(initialFilters);
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalResults, setTotalResults] = useState(0);
const search = useCallback(() => {
setLoading(true);
onSearch?.(query, filters);
}, [query, filters, onSearch]);
const updateFilter = useCallback((key: string, value: unknown) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const clearFilters = useCallback(() => {
setFilters({});
}, []);
const reset = useCallback(() => {
setQuery('');
setFilters({});
setResults([]);
setCurrentPage(1);
setTotalPages(1);
setTotalResults(0);
}, []);
return {
query,
setQuery,
filters,
setFilters,
updateFilter,
clearFilters,
results,
setResults,
loading,
setLoading,
currentPage,
setCurrentPage,
totalPages,
setTotalPages,
totalResults,
setTotalResults,
search,
reset,
};
};

927
src/pages/TaskManager.tsx Normal file
View File

@@ -0,0 +1,927 @@
import React, { useState, useCallback } from 'react';
// Types
export interface Task {
id: string;
title: string;
description?: string;
status: 'todo' | 'in_progress' | 'review' | 'done';
priority: 'low' | 'medium' | 'high' | 'urgent';
dueDate?: Date;
assignee?: TaskUser;
tags?: string[];
attachments?: TaskAttachment[];
comments?: TaskComment[];
createdAt: Date;
updatedAt?: Date;
completedAt?: Date;
progress?: number;
}
export interface TaskUser {
id: string;
name: string;
avatar?: string;
email?: string;
}
export interface TaskAttachment {
id: string;
name: string;
url: string;
type: string;
size: string;
}
export interface TaskComment {
id: string;
user: TaskUser;
content: string;
createdAt: Date;
}
export interface TaskColumn {
id: string;
title: string;
status: Task['status'];
color?: string;
}
// Task Layout
export interface TaskLayoutProps {
/** Sidebar content */
sidebar?: React.ReactNode;
/** Main content */
children: React.ReactNode;
/** Show sidebar */
showSidebar?: boolean;
/** Additional CSS classes */
className?: string;
}
export const TaskLayout: React.FC<TaskLayoutProps> = ({
sidebar,
children,
showSidebar = true,
className = '',
}) => {
return (
<div className={`ll-task-layout ${className}`}>
{showSidebar && sidebar && (
<div className="ll-task-sidebar">{sidebar}</div>
)}
<div className="ll-task-content">{children}</div>
</div>
);
};
// Task Sidebar
export interface TaskSidebarProps {
/** Projects/Categories */
projects?: { id: string; name: string; color?: string; count?: number }[];
/** Active project ID */
activeProjectId?: string;
/** Project click handler */
onProjectClick?: (projectId: string) => void;
/** Add project handler */
onAddProject?: () => void;
/** Filters */
filters?: { id: string; name: string; icon?: React.ReactNode; count?: number }[];
/** Active filter ID */
activeFilterId?: string;
/** Filter click handler */
onFilterClick?: (filterId: string) => void;
/** Additional CSS classes */
className?: string;
}
export const TaskSidebar: React.FC<TaskSidebarProps> = ({
projects = [],
activeProjectId,
onProjectClick,
onAddProject,
filters = [],
activeFilterId,
onFilterClick,
className = '',
}) => {
type FilterItem = { id: string; name: string; icon?: React.ReactNode; count?: number };
const defaultFilters: FilterItem[] = filters.length > 0 ? filters : [
{
id: 'all',
name: 'All Tasks',
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z" />
</svg>
),
},
{
id: 'today',
name: 'Today',
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm-8 4H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z" />
</svg>
),
},
{
id: 'upcoming',
name: 'Upcoming',
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
</svg>
),
},
{
id: 'completed',
name: 'Completed',
icon: (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
),
},
];
return (
<div className={`ll-task-sidebar-content ${className}`}>
<div className="ll-task-filters">
<ul className="ll-task-filter-list">
{defaultFilters.map((filter) => (
<li
key={filter.id}
className={`ll-task-filter-item ${activeFilterId === filter.id ? 'll-task-filter-active' : ''}`}
onClick={() => onFilterClick?.(filter.id)}
>
{filter.icon && <span className="ll-task-filter-icon">{filter.icon}</span>}
<span className="ll-task-filter-name">{filter.name}</span>
{filter.count !== undefined && (
<span className="ll-task-filter-count">{filter.count}</span>
)}
</li>
))}
</ul>
</div>
{projects.length > 0 && (
<div className="ll-task-projects">
<div className="ll-task-projects-header">
<span>Projects</span>
{onAddProject && (
<button className="ll-task-add-project" onClick={onAddProject}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
</button>
)}
</div>
<ul className="ll-task-project-list">
{projects.map((project) => (
<li
key={project.id}
className={`ll-task-project-item ${activeProjectId === project.id ? 'll-task-project-active' : ''}`}
onClick={() => onProjectClick?.(project.id)}
>
<span
className="ll-task-project-color"
style={{ backgroundColor: project.color || '#6366f1' }}
/>
<span className="ll-task-project-name">{project.name}</span>
{project.count !== undefined && (
<span className="ll-task-project-count">{project.count}</span>
)}
</li>
))}
</ul>
</div>
)}
</div>
);
};
// Task Board (Kanban)
export interface TaskBoardProps {
/** Tasks to display */
tasks: Task[];
/** Columns configuration */
columns?: TaskColumn[];
/** Task click handler */
onTaskClick?: (task: Task) => void;
/** Task move handler */
onTaskMove?: (taskId: string, newStatus: Task['status']) => void;
/** Add task handler */
onAddTask?: (status: Task['status']) => void;
/** Additional CSS classes */
className?: string;
}
export const TaskBoard: React.FC<TaskBoardProps> = ({
tasks,
columns,
onTaskClick,
onTaskMove,
onAddTask,
className = '',
}) => {
const defaultColumns: TaskColumn[] = columns || [
{ id: 'todo', title: 'To Do', status: 'todo', color: '#6b7280' },
{ id: 'in_progress', title: 'In Progress', status: 'in_progress', color: '#3b82f6' },
{ id: 'review', title: 'Review', status: 'review', color: '#f59e0b' },
{ id: 'done', title: 'Done', status: 'done', color: '#10b981' },
];
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
const handleDragStart = (task: Task) => {
setDraggedTask(task);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDrop = (status: Task['status']) => {
if (draggedTask && draggedTask.status !== status) {
onTaskMove?.(draggedTask.id, status);
}
setDraggedTask(null);
};
const getTasksByStatus = (status: Task['status']) => {
return tasks.filter((task) => task.status === status);
};
const getPriorityColor = (priority: Task['priority']) => {
switch (priority) {
case 'urgent':
return '#ef4444';
case 'high':
return '#f97316';
case 'medium':
return '#eab308';
case 'low':
return '#22c55e';
default:
return '#6b7280';
}
};
const formatDueDate = (date?: Date) => {
if (!date) return '';
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) return 'Overdue';
if (days === 0) return 'Today';
if (days === 1) return 'Tomorrow';
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
return (
<div className={`ll-task-board ${className}`}>
{defaultColumns.map((column) => {
const columnTasks = getTasksByStatus(column.status);
return (
<div
key={column.id}
className="ll-task-column"
onDragOver={handleDragOver}
onDrop={() => handleDrop(column.status)}
>
<div className="ll-task-column-header">
<span
className="ll-task-column-indicator"
style={{ backgroundColor: column.color }}
/>
<span className="ll-task-column-title">{column.title}</span>
<span className="ll-task-column-count">{columnTasks.length}</span>
</div>
<div className="ll-task-column-content">
{columnTasks.map((task) => (
<div
key={task.id}
className={`ll-task-card ${draggedTask?.id === task.id ? 'll-task-card-dragging' : ''}`}
draggable
onDragStart={() => handleDragStart(task)}
onClick={() => onTaskClick?.(task)}
>
<div className="ll-task-card-header">
<span
className="ll-task-card-priority"
style={{ backgroundColor: getPriorityColor(task.priority) }}
/>
{task.tags && task.tags.length > 0 && (
<div className="ll-task-card-tags">
{task.tags.slice(0, 2).map((tag, index) => (
<span key={index} className="ll-task-card-tag">{tag}</span>
))}
</div>
)}
</div>
<h4 className="ll-task-card-title">{task.title}</h4>
{task.description && (
<p className="ll-task-card-description">{task.description}</p>
)}
{task.progress !== undefined && (
<div className="ll-task-card-progress">
<div
className="ll-task-card-progress-bar"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
<div className="ll-task-card-footer">
{task.dueDate && (
<span className={`ll-task-card-due ${new Date(task.dueDate) < new Date() ? 'll-task-card-overdue' : ''}`}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
</svg>
{formatDueDate(task.dueDate)}
</span>
)}
<div className="ll-task-card-meta">
{task.attachments && task.attachments.length > 0 && (
<span className="ll-task-card-attachments">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
</svg>
{task.attachments.length}
</span>
)}
{task.comments && task.comments.length > 0 && (
<span className="ll-task-card-comments">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM18 14H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z" />
</svg>
{task.comments.length}
</span>
)}
</div>
{task.assignee && (
<div className="ll-task-card-assignee">
{task.assignee.avatar ? (
<img src={task.assignee.avatar} alt={task.assignee.name} />
) : (
<span>{task.assignee.name.charAt(0).toUpperCase()}</span>
)}
</div>
)}
</div>
</div>
))}
{onAddTask && (
<button
className="ll-task-add-btn"
onClick={() => onAddTask(column.status)}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
Add Task
</button>
)}
</div>
</div>
);
})}
</div>
);
};
// Task List View
export interface TaskListProps {
/** Tasks to display */
tasks: Task[];
/** Task click handler */
onTaskClick?: (task: Task) => void;
/** Task checkbox handler */
onTaskToggle?: (task: Task) => void;
/** Sort by field */
sortBy?: 'dueDate' | 'priority' | 'title' | 'createdAt';
/** Sort direction */
sortDirection?: 'asc' | 'desc';
/** Group by field */
groupBy?: 'status' | 'priority' | 'dueDate' | 'none';
/** Show completed tasks */
showCompleted?: boolean;
/** Additional CSS classes */
className?: string;
}
export const TaskList: React.FC<TaskListProps> = ({
tasks,
onTaskClick,
onTaskToggle,
sortBy = 'dueDate',
sortDirection = 'asc',
groupBy = 'none',
showCompleted = true,
className = '',
}) => {
const filteredTasks = showCompleted
? tasks
: tasks.filter((t) => t.status !== 'done');
const sortTasks = (tasksToSort: Task[]) => {
return [...tasksToSort].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'dueDate':
comparison = (a.dueDate?.getTime() || 0) - (b.dueDate?.getTime() || 0);
break;
case 'priority':
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
comparison = priorityOrder[a.priority] - priorityOrder[b.priority];
break;
case 'title':
comparison = a.title.localeCompare(b.title);
break;
case 'createdAt':
comparison = a.createdAt.getTime() - b.createdAt.getTime();
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
};
const groupTasks = () => {
if (groupBy === 'none') {
return [{ title: '', tasks: sortTasks(filteredTasks) }];
}
const groups: Record<string, Task[]> = {};
filteredTasks.forEach((task) => {
let key = '';
switch (groupBy) {
case 'status':
key = task.status;
break;
case 'priority':
key = task.priority;
break;
case 'dueDate':
if (!task.dueDate) {
key = 'No due date';
} else {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (task.dueDate.toDateString() === today.toDateString()) {
key = 'Today';
} else if (task.dueDate.toDateString() === tomorrow.toDateString()) {
key = 'Tomorrow';
} else if (task.dueDate < today) {
key = 'Overdue';
} else {
key = 'Upcoming';
}
}
break;
}
if (!groups[key]) groups[key] = [];
groups[key].push(task);
});
return Object.entries(groups).map(([title, groupTasks]) => ({
title: title.charAt(0).toUpperCase() + title.slice(1).replace('_', ' '),
tasks: sortTasks(groupTasks),
}));
};
const getPriorityColor = (priority: Task['priority']) => {
switch (priority) {
case 'urgent': return '#ef4444';
case 'high': return '#f97316';
case 'medium': return '#eab308';
case 'low': return '#22c55e';
default: return '#6b7280';
}
};
const formatDueDate = (date?: Date) => {
if (!date) return '';
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) return 'Overdue';
if (days === 0) return 'Today';
if (days === 1) return 'Tomorrow';
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
const groupedTasks = groupTasks();
return (
<div className={`ll-task-list ${className}`}>
{groupedTasks.map((group, groupIndex) => (
<div key={groupIndex} className="ll-task-list-group">
{group.title && (
<div className="ll-task-list-group-header">
<span>{group.title}</span>
<span className="ll-task-list-group-count">{group.tasks.length}</span>
</div>
)}
<div className="ll-task-list-items">
{group.tasks.map((task) => (
<div
key={task.id}
className={`ll-task-list-item ${task.status === 'done' ? 'll-task-list-item-done' : ''}`}
>
<label className="ll-task-checkbox" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={task.status === 'done'}
onChange={() => onTaskToggle?.(task)}
/>
<span className="ll-task-checkbox-mark" />
</label>
<div
className="ll-task-list-item-content"
onClick={() => onTaskClick?.(task)}
>
<div className="ll-task-list-item-main">
<span
className="ll-task-list-item-priority"
style={{ backgroundColor: getPriorityColor(task.priority) }}
/>
<span className="ll-task-list-item-title">{task.title}</span>
</div>
<div className="ll-task-list-item-meta">
{task.tags && task.tags.length > 0 && (
<div className="ll-task-list-item-tags">
{task.tags.map((tag, index) => (
<span key={index} className="ll-task-list-item-tag">{tag}</span>
))}
</div>
)}
{task.dueDate && (
<span className={`ll-task-list-item-due ${new Date(task.dueDate) < new Date() ? 'll-task-list-item-overdue' : ''}`}>
{formatDueDate(task.dueDate)}
</span>
)}
{task.assignee && (
<div className="ll-task-list-item-assignee">
{task.assignee.avatar ? (
<img src={task.assignee.avatar} alt={task.assignee.name} />
) : (
<span>{task.assignee.name.charAt(0).toUpperCase()}</span>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
))}
{filteredTasks.length === 0 && (
<div className="ll-task-list-empty">
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM17.99 9l-1.41-1.42-6.59 6.59-2.58-2.57-1.42 1.41 4 3.99z" />
</svg>
<p>No tasks found</p>
</div>
)}
</div>
);
};
// Task Detail Modal/Panel
export interface TaskDetailProps {
/** Task to display */
task: Task;
/** Users available for assignment */
users?: TaskUser[];
/** Close handler */
onClose?: () => void;
/** Save handler */
onSave?: (task: Task) => void;
/** Delete handler */
onDelete?: () => void;
/** Add comment handler */
onAddComment?: (content: string) => void;
/** Additional CSS classes */
className?: string;
}
export const TaskDetail: React.FC<TaskDetailProps> = ({
task,
users = [],
onClose,
onSave,
onDelete,
onAddComment,
className = '',
}) => {
const [editedTask, setEditedTask] = useState(task);
const [newComment, setNewComment] = useState('');
const handleChange = (field: keyof Task, value: unknown) => {
setEditedTask((prev) => ({ ...prev, [field]: value }));
};
const handleSave = () => {
onSave?.(editedTask);
};
const handleAddComment = () => {
if (newComment.trim()) {
onAddComment?.(newComment.trim());
setNewComment('');
}
};
const formatDate = (date: Date) => {
return date.toLocaleDateString([], {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className={`ll-task-detail ${className}`}>
<div className="ll-task-detail-header">
<h3>Task Details</h3>
<div className="ll-task-detail-actions">
{onDelete && (
<button className="ll-task-detail-btn ll-task-detail-btn-danger" onClick={onDelete}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
)}
{onClose && (
<button className="ll-task-detail-btn" onClick={onClose}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
</div>
</div>
<div className="ll-task-detail-content">
<div className="ll-task-detail-field">
<label>Title</label>
<input
type="text"
value={editedTask.title}
onChange={(e) => handleChange('title', e.target.value)}
/>
</div>
<div className="ll-task-detail-field">
<label>Description</label>
<textarea
value={editedTask.description || ''}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="Add a description..."
/>
</div>
<div className="ll-task-detail-row">
<div className="ll-task-detail-field">
<label>Status</label>
<select
value={editedTask.status}
onChange={(e) => handleChange('status', e.target.value)}
>
<option value="todo">To Do</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="done">Done</option>
</select>
</div>
<div className="ll-task-detail-field">
<label>Priority</label>
<select
value={editedTask.priority}
onChange={(e) => handleChange('priority', e.target.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div className="ll-task-detail-row">
<div className="ll-task-detail-field">
<label>Due Date</label>
<input
type="date"
value={editedTask.dueDate?.toISOString().split('T')[0] || ''}
onChange={(e) =>
handleChange('dueDate', e.target.value ? new Date(e.target.value) : undefined)
}
/>
</div>
<div className="ll-task-detail-field">
<label>Assignee</label>
<select
value={editedTask.assignee?.id || ''}
onChange={(e) => {
const user = users.find((u) => u.id === e.target.value);
handleChange('assignee', user);
}}
>
<option value="">Unassigned</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</div>
</div>
{editedTask.attachments && editedTask.attachments.length > 0 && (
<div className="ll-task-detail-attachments">
<label>Attachments</label>
<div className="ll-task-detail-attachments-list">
{editedTask.attachments.map((attachment) => (
<a key={attachment.id} href={attachment.url} className="ll-task-detail-attachment">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
</svg>
<span>{attachment.name}</span>
<span className="ll-task-detail-attachment-size">{attachment.size}</span>
</a>
))}
</div>
</div>
)}
{editedTask.comments && editedTask.comments.length > 0 && (
<div className="ll-task-detail-comments">
<label>Comments</label>
<div className="ll-task-detail-comments-list">
{editedTask.comments.map((comment) => (
<div key={comment.id} className="ll-task-detail-comment">
<div className="ll-task-detail-comment-avatar">
{comment.user.avatar ? (
<img src={comment.user.avatar} alt={comment.user.name} />
) : (
<span>{comment.user.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="ll-task-detail-comment-content">
<div className="ll-task-detail-comment-header">
<span className="ll-task-detail-comment-author">{comment.user.name}</span>
<span className="ll-task-detail-comment-date">{formatDate(comment.createdAt)}</span>
</div>
<p>{comment.content}</p>
</div>
</div>
))}
</div>
</div>
)}
{onAddComment && (
<div className="ll-task-detail-add-comment">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Write a comment..."
/>
<button onClick={handleAddComment} disabled={!newComment.trim()}>
Add Comment
</button>
</div>
)}
</div>
{onSave && (
<div className="ll-task-detail-footer">
<button className="ll-task-detail-save" onClick={handleSave}>
Save Changes
</button>
</div>
)}
</div>
);
};
// Task Toolbar
export interface TaskToolbarProps {
/** Add task handler */
onAddTask?: () => void;
/** View mode */
viewMode?: 'board' | 'list' | 'grid';
/** View mode change handler */
onViewModeChange?: (mode: 'board' | 'list' | 'grid') => void;
/** Search value */
searchValue?: string;
/** Search change handler */
onSearchChange?: (value: string) => void;
/** Filter handler */
onFilter?: () => void;
/** Additional CSS classes */
className?: string;
}
export const TaskToolbar: React.FC<TaskToolbarProps> = ({
onAddTask,
viewMode = 'board',
onViewModeChange,
searchValue = '',
onSearchChange,
onFilter,
className = '',
}) => {
return (
<div className={`ll-task-toolbar ${className}`}>
<div className="ll-task-toolbar-left">
{onAddTask && (
<button className="ll-task-toolbar-add" onClick={onAddTask}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
Add Task
</button>
)}
</div>
<div className="ll-task-toolbar-right">
{onSearchChange && (
<div className="ll-task-toolbar-search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<input
type="text"
placeholder="Search tasks..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
)}
{onFilter && (
<button className="ll-task-toolbar-btn" onClick={onFilter}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z" />
</svg>
</button>
)}
{onViewModeChange && (
<div className="ll-task-toolbar-views">
<button
className={`ll-task-toolbar-view ${viewMode === 'board' ? 'll-task-toolbar-view-active' : ''}`}
onClick={() => onViewModeChange('board')}
title="Board view"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M4 5v13h17V5H4zm10 2v9h-3V7h3zM6 7h3v9H6V7zm13 9h-3V7h3v9z" />
</svg>
</button>
<button
className={`ll-task-toolbar-view ${viewMode === 'list' ? 'll-task-toolbar-view-active' : ''}`}
onClick={() => onViewModeChange('list')}
title="List view"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg>
</button>
<button
className={`ll-task-toolbar-view ${viewMode === 'grid' ? 'll-task-toolbar-view-active' : ''}`}
onClick={() => onViewModeChange('grid')}
title="Grid view"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M4 11h5V5H4v6zm0 7h5v-6H4v6zm6 0h5v-6h-5v6zm6 0h5v-6h-5v6zm-6-7h5V5h-5v6zm6-6v6h5V5h-5z" />
</svg>
</button>
</div>
)}
</div>
</div>
);
};

858
src/pages/UserProfile.tsx Normal file
View File

@@ -0,0 +1,858 @@
import React, { useState } from 'react';
// Types
export interface UserProfileData {
id: string;
firstName: string;
lastName: string;
email: string;
phone?: string;
avatar?: string;
coverImage?: string;
bio?: string;
location?: string;
website?: string;
company?: string;
jobTitle?: string;
socialLinks?: {
twitter?: string;
facebook?: string;
linkedin?: string;
github?: string;
instagram?: string;
};
stats?: {
followers?: number;
following?: number;
posts?: number;
projects?: number;
};
joinedAt?: Date;
isVerified?: boolean;
badges?: UserBadge[];
}
export interface UserBadge {
id: string;
name: string;
icon?: React.ReactNode;
color?: string;
}
export interface UserActivity {
id: string;
type: 'post' | 'comment' | 'like' | 'follow' | 'project' | 'achievement';
content: string;
timestamp: Date;
link?: string;
image?: string;
}
// Profile Layout
export interface ProfileLayoutProps {
/** Profile header content */
header?: React.ReactNode;
/** Sidebar content */
sidebar?: React.ReactNode;
/** Main content */
children: React.ReactNode;
/** Layout variant */
variant?: 'standard' | 'cover' | 'tabbed';
/** Additional CSS classes */
className?: string;
}
export const ProfileLayout: React.FC<ProfileLayoutProps> = ({
header,
sidebar,
children,
variant = 'standard',
className = '',
}) => {
return (
<div className={`ll-profile-layout ll-profile-layout-${variant} ${className}`}>
{header && <div className="ll-profile-header-wrapper">{header}</div>}
<div className="ll-profile-body">
{sidebar && <div className="ll-profile-sidebar">{sidebar}</div>}
<div className="ll-profile-main">{children}</div>
</div>
</div>
);
};
// Profile Header
export interface ProfileHeaderProps {
/** User data */
user: UserProfileData;
/** Show cover image */
showCover?: boolean;
/** Edit profile handler */
onEditProfile?: () => void;
/** Follow handler */
onFollow?: () => void;
/** Message handler */
onMessage?: () => void;
/** Is following */
isFollowing?: boolean;
/** Is own profile */
isOwnProfile?: boolean;
/** Additional CSS classes */
className?: string;
}
export const ProfileHeader: React.FC<ProfileHeaderProps> = ({
user,
showCover = true,
onEditProfile,
onFollow,
onMessage,
isFollowing = false,
isOwnProfile = false,
className = '',
}) => {
const fullName = `${user.firstName} ${user.lastName}`;
return (
<div className={`ll-profile-header ${showCover ? 'll-profile-header-with-cover' : ''} ${className}`}>
{showCover && (
<div
className="ll-profile-cover"
style={user.coverImage ? { backgroundImage: `url(${user.coverImage})` } : undefined}
/>
)}
<div className="ll-profile-header-content">
<div className="ll-profile-avatar-section">
<div className="ll-profile-avatar">
{user.avatar ? (
<img src={user.avatar} alt={fullName} />
) : (
<span>{user.firstName.charAt(0)}{user.lastName.charAt(0)}</span>
)}
{user.isVerified && (
<span className="ll-profile-verified">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
</svg>
</span>
)}
</div>
</div>
<div className="ll-profile-info">
<div className="ll-profile-name-section">
<h1 className="ll-profile-name">{fullName}</h1>
{user.badges && user.badges.length > 0 && (
<div className="ll-profile-badges">
{user.badges.map((badge) => (
<span
key={badge.id}
className="ll-profile-badge"
style={badge.color ? { backgroundColor: badge.color } : undefined}
title={badge.name}
>
{badge.icon || badge.name.charAt(0)}
</span>
))}
</div>
)}
</div>
{user.jobTitle && (
<p className="ll-profile-title">
{user.jobTitle}
{user.company && <span> at {user.company}</span>}
</p>
)}
{user.location && (
<p className="ll-profile-location">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
</svg>
{user.location}
</p>
)}
{user.stats && (
<div className="ll-profile-stats">
{user.stats.followers !== undefined && (
<div className="ll-profile-stat">
<span className="ll-profile-stat-value">{formatNumber(user.stats.followers)}</span>
<span className="ll-profile-stat-label">Followers</span>
</div>
)}
{user.stats.following !== undefined && (
<div className="ll-profile-stat">
<span className="ll-profile-stat-value">{formatNumber(user.stats.following)}</span>
<span className="ll-profile-stat-label">Following</span>
</div>
)}
{user.stats.posts !== undefined && (
<div className="ll-profile-stat">
<span className="ll-profile-stat-value">{formatNumber(user.stats.posts)}</span>
<span className="ll-profile-stat-label">Posts</span>
</div>
)}
{user.stats.projects !== undefined && (
<div className="ll-profile-stat">
<span className="ll-profile-stat-value">{formatNumber(user.stats.projects)}</span>
<span className="ll-profile-stat-label">Projects</span>
</div>
)}
</div>
)}
</div>
<div className="ll-profile-actions">
{isOwnProfile ? (
onEditProfile && (
<button className="ll-profile-btn ll-profile-btn-secondary" onClick={onEditProfile}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
Edit Profile
</button>
)
) : (
<>
{onFollow && (
<button
className={`ll-profile-btn ${isFollowing ? 'll-profile-btn-secondary' : 'll-profile-btn-primary'}`}
onClick={onFollow}
>
{isFollowing ? 'Following' : 'Follow'}
</button>
)}
{onMessage && (
<button className="ll-profile-btn ll-profile-btn-secondary" onClick={onMessage}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
</svg>
Message
</button>
)}
</>
)}
</div>
</div>
</div>
);
};
// Profile Sidebar Info
export interface ProfileSidebarProps {
/** User data */
user: UserProfileData;
/** Additional CSS classes */
className?: string;
}
export const ProfileSidebar: React.FC<ProfileSidebarProps> = ({
user,
className = '',
}) => {
return (
<div className={`ll-profile-sidebar-content ${className}`}>
{user.bio && (
<div className="ll-profile-section">
<h3 className="ll-profile-section-title">About</h3>
<p className="ll-profile-bio">{user.bio}</p>
</div>
)}
<div className="ll-profile-section">
<h3 className="ll-profile-section-title">Info</h3>
<ul className="ll-profile-info-list">
<li>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
</svg>
<span>{user.email}</span>
</li>
{user.phone && (
<li>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
</svg>
<span>{user.phone}</span>
</li>
)}
{user.location && (
<li>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
</svg>
<span>{user.location}</span>
</li>
)}
{user.website && (
<li>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95c-.32-1.25-.78-2.45-1.38-3.56 1.84.63 3.37 1.91 4.33 3.56zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2 0 .68.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56-1.84-.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2 0-.68.07-1.35.16-2h4.68c.09.65.16 1.32.16 2 0 .68-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95c-.96 1.65-2.49 2.93-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2 0-.68-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z" />
</svg>
<a href={user.website} target="_blank" rel="noopener noreferrer">{user.website}</a>
</li>
)}
{user.joinedAt && (
<li>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm-8 4H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z" />
</svg>
<span>Joined {formatDate(user.joinedAt)}</span>
</li>
)}
</ul>
</div>
{user.socialLinks && Object.keys(user.socialLinks).some((key) => user.socialLinks?.[key as keyof typeof user.socialLinks]) && (
<div className="ll-profile-section">
<h3 className="ll-profile-section-title">Social</h3>
<div className="ll-profile-social-links">
{user.socialLinks.twitter && (
<a href={user.socialLinks.twitter} target="_blank" rel="noopener noreferrer" className="ll-profile-social-link">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M22.46 6c-.85.38-1.78.64-2.75.76 1-.6 1.76-1.55 2.12-2.68-.93.55-1.96.95-3.06 1.17-.88-.94-2.13-1.53-3.51-1.53-2.66 0-4.81 2.16-4.81 4.81 0 .38.04.75.13 1.1-4-.2-7.58-2.11-9.96-5.02-.42.72-.66 1.56-.66 2.46 0 1.68.85 3.16 2.14 4.02-.79-.02-1.53-.24-2.18-.6v.06c0 2.35 1.67 4.31 3.88 4.76-.4.1-.83.16-1.27.16-.31 0-.62-.03-.92-.08.63 1.96 2.45 3.39 4.61 3.43-1.69 1.32-3.83 2.1-6.15 2.1-.4 0-.8-.02-1.19-.07 2.19 1.4 4.78 2.22 7.57 2.22 9.07 0 14.02-7.52 14.02-14.02 0-.21 0-.43-.01-.64.96-.69 1.79-1.56 2.45-2.55z" />
</svg>
</a>
)}
{user.socialLinks.facebook && (
<a href={user.socialLinks.facebook} target="_blank" rel="noopener noreferrer" className="ll-profile-social-link">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M22 12c0-5.52-4.48-10-10-10S2 6.48 2 12c0 4.84 3.44 8.87 8 9.8V15H8v-3h2V9.5C10 7.57 11.57 6 13.5 6H16v3h-2c-.55 0-1 .45-1 1v2h3v3h-3v6.95c5.05-.5 9-4.76 9-9.95z" />
</svg>
</a>
)}
{user.socialLinks.linkedin && (
<a href={user.socialLinks.linkedin} target="_blank" rel="noopener noreferrer" className="ll-profile-social-link">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14m-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93h2.79M6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69 1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37h2.77z" />
</svg>
</a>
)}
{user.socialLinks.github && (
<a href={user.socialLinks.github} target="_blank" rel="noopener noreferrer" className="ll-profile-social-link">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2z" />
</svg>
</a>
)}
{user.socialLinks.instagram && (
<a href={user.socialLinks.instagram} target="_blank" rel="noopener noreferrer" className="ll-profile-social-link">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z" />
</svg>
</a>
)}
</div>
</div>
)}
</div>
);
};
// Profile Tabs
export interface ProfileTabsProps {
/** Tabs configuration */
tabs: { id: string; label: string; count?: number }[];
/** Active tab ID */
activeTab: string;
/** Tab change handler */
onTabChange: (tabId: string) => void;
/** Additional CSS classes */
className?: string;
}
export const ProfileTabs: React.FC<ProfileTabsProps> = ({
tabs,
activeTab,
onTabChange,
className = '',
}) => {
return (
<div className={`ll-profile-tabs ${className}`}>
{tabs.map((tab) => (
<button
key={tab.id}
className={`ll-profile-tab ${activeTab === tab.id ? 'll-profile-tab-active' : ''}`}
onClick={() => onTabChange(tab.id)}
>
{tab.label}
{tab.count !== undefined && (
<span className="ll-profile-tab-count">{tab.count}</span>
)}
</button>
))}
</div>
);
};
// Profile Activity Feed
export interface ProfileActivityProps {
/** Activities to display */
activities: UserActivity[];
/** Loading state */
loading?: boolean;
/** Load more handler */
onLoadMore?: () => void;
/** Additional CSS classes */
className?: string;
}
export const ProfileActivity: React.FC<ProfileActivityProps> = ({
activities,
loading = false,
onLoadMore,
className = '',
}) => {
const getActivityIcon = (type: UserActivity['type']) => {
switch (type) {
case 'post':
return (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z" />
</svg>
);
case 'comment':
return (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z" />
</svg>
);
case 'like':
return (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
);
case 'follow':
return (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
);
case 'project':
return (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z" />
</svg>
);
case 'achievement':
return (
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94.63 1.5 1.98 2.63 3.61 2.96V19H7v2h10v-2h-4v-3.1c1.63-.33 2.98-1.46 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z" />
</svg>
);
default:
return null;
}
};
return (
<div className={`ll-profile-activity ${className}`}>
{activities.map((activity) => (
<div key={activity.id} className="ll-profile-activity-item">
<div className={`ll-profile-activity-icon ll-profile-activity-icon-${activity.type}`}>
{getActivityIcon(activity.type)}
</div>
<div className="ll-profile-activity-content">
<p>{activity.content}</p>
{activity.image && (
<img src={activity.image} alt="" className="ll-profile-activity-image" />
)}
<span className="ll-profile-activity-time">{formatTimeAgo(activity.timestamp)}</span>
</div>
</div>
))}
{loading && (
<div className="ll-profile-activity-loading">
<div className="ll-profile-activity-spinner" />
</div>
)}
{onLoadMore && !loading && (
<button className="ll-profile-activity-load-more" onClick={onLoadMore}>
Load More
</button>
)}
{activities.length === 0 && !loading && (
<div className="ll-profile-activity-empty">
<p>No activity yet</p>
</div>
)}
</div>
);
};
// User Card (for user lists)
export interface UserCardProps {
/** User data */
user: UserProfileData;
/** Click handler */
onClick?: () => void;
/** Follow handler */
onFollow?: () => void;
/** Is following */
isFollowing?: boolean;
/** Variant */
variant?: 'default' | 'compact' | 'horizontal';
/** Additional CSS classes */
className?: string;
}
export const UserCard: React.FC<UserCardProps> = ({
user,
onClick,
onFollow,
isFollowing = false,
variant = 'default',
className = '',
}) => {
const fullName = `${user.firstName} ${user.lastName}`;
return (
<div
className={`ll-user-card ll-user-card-${variant} ${className}`}
onClick={onClick}
>
<div className="ll-user-card-avatar">
{user.avatar ? (
<img src={user.avatar} alt={fullName} />
) : (
<span>{user.firstName.charAt(0)}{user.lastName.charAt(0)}</span>
)}
</div>
<div className="ll-user-card-info">
<h4 className="ll-user-card-name">
{fullName}
{user.isVerified && (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" className="ll-user-card-verified">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
</svg>
)}
</h4>
{user.jobTitle && <p className="ll-user-card-title">{user.jobTitle}</p>}
{variant !== 'compact' && user.location && (
<p className="ll-user-card-location">{user.location}</p>
)}
</div>
{onFollow && (
<button
className={`ll-user-card-follow ${isFollowing ? 'll-user-card-following' : ''}`}
onClick={(e) => {
e.stopPropagation();
onFollow();
}}
>
{isFollowing ? 'Following' : 'Follow'}
</button>
)}
</div>
);
};
// User List
export interface UserListProps {
/** Users to display */
users: UserProfileData[];
/** User click handler */
onUserClick?: (user: UserProfileData) => void;
/** Follow handler */
onFollow?: (user: UserProfileData) => void;
/** Get follow status */
getFollowStatus?: (userId: string) => boolean;
/** Variant */
variant?: 'default' | 'compact' | 'grid';
/** Loading state */
loading?: boolean;
/** Empty message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
}
export const UserList: React.FC<UserListProps> = ({
users,
onUserClick,
onFollow,
getFollowStatus,
variant = 'default',
loading = false,
emptyMessage = 'No users found',
className = '',
}) => {
if (loading) {
return (
<div className={`ll-user-list ll-user-list-loading ${className}`}>
<div className="ll-user-list-spinner" />
</div>
);
}
if (users.length === 0) {
return (
<div className={`ll-user-list ll-user-list-empty ${className}`}>
<p>{emptyMessage}</p>
</div>
);
}
return (
<div className={`ll-user-list ll-user-list-${variant} ${className}`}>
{users.map((user) => (
<UserCard
key={user.id}
user={user}
onClick={() => onUserClick?.(user)}
onFollow={onFollow ? () => onFollow(user) : undefined}
isFollowing={getFollowStatus?.(user.id)}
variant={variant === 'grid' ? 'default' : variant === 'compact' ? 'compact' : 'horizontal'}
/>
))}
</div>
);
};
// Edit Profile Form
export interface EditProfileFormProps {
/** User data */
user: UserProfileData;
/** Save handler */
onSave?: (user: UserProfileData) => void;
/** Cancel handler */
onCancel?: () => void;
/** Avatar change handler */
onAvatarChange?: (file: File) => void;
/** Cover change handler */
onCoverChange?: (file: File) => void;
/** Additional CSS classes */
className?: string;
}
export const EditProfileForm: React.FC<EditProfileFormProps> = ({
user,
onSave,
onCancel,
onAvatarChange,
onCoverChange,
className = '',
}) => {
const [formData, setFormData] = useState(user);
const handleChange = (field: keyof UserProfileData, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSocialChange = (field: keyof NonNullable<UserProfileData['socialLinks']>, value: string) => {
setFormData((prev) => ({
...prev,
socialLinks: { ...prev.socialLinks, [field]: value },
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave?.(formData);
};
return (
<form className={`ll-edit-profile-form ${className}`} onSubmit={handleSubmit}>
<div className="ll-edit-profile-section">
<h3>Basic Information</h3>
<div className="ll-edit-profile-avatars">
<div className="ll-edit-profile-avatar">
<label>Profile Photo</label>
<div className="ll-edit-profile-avatar-preview">
{formData.avatar ? (
<img src={formData.avatar} alt="Avatar" />
) : (
<span>{formData.firstName.charAt(0)}{formData.lastName.charAt(0)}</span>
)}
</div>
{onAvatarChange && (
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onAvatarChange(e.target.files[0])}
/>
)}
</div>
</div>
<div className="ll-edit-profile-row">
<div className="ll-edit-profile-field">
<label>First Name</label>
<input
type="text"
value={formData.firstName}
onChange={(e) => handleChange('firstName', e.target.value)}
required
/>
</div>
<div className="ll-edit-profile-field">
<label>Last Name</label>
<input
type="text"
value={formData.lastName}
onChange={(e) => handleChange('lastName', e.target.value)}
required
/>
</div>
</div>
<div className="ll-edit-profile-field">
<label>Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
required
/>
</div>
<div className="ll-edit-profile-field">
<label>Phone</label>
<input
type="tel"
value={formData.phone || ''}
onChange={(e) => handleChange('phone', e.target.value)}
/>
</div>
<div className="ll-edit-profile-field">
<label>Bio</label>
<textarea
value={formData.bio || ''}
onChange={(e) => handleChange('bio', e.target.value)}
placeholder="Tell us about yourself..."
rows={4}
/>
</div>
</div>
<div className="ll-edit-profile-section">
<h3>Work</h3>
<div className="ll-edit-profile-row">
<div className="ll-edit-profile-field">
<label>Job Title</label>
<input
type="text"
value={formData.jobTitle || ''}
onChange={(e) => handleChange('jobTitle', e.target.value)}
/>
</div>
<div className="ll-edit-profile-field">
<label>Company</label>
<input
type="text"
value={formData.company || ''}
onChange={(e) => handleChange('company', e.target.value)}
/>
</div>
</div>
<div className="ll-edit-profile-row">
<div className="ll-edit-profile-field">
<label>Location</label>
<input
type="text"
value={formData.location || ''}
onChange={(e) => handleChange('location', e.target.value)}
/>
</div>
<div className="ll-edit-profile-field">
<label>Website</label>
<input
type="url"
value={formData.website || ''}
onChange={(e) => handleChange('website', e.target.value)}
/>
</div>
</div>
</div>
<div className="ll-edit-profile-section">
<h3>Social Links</h3>
<div className="ll-edit-profile-field">
<label>Twitter</label>
<input
type="url"
value={formData.socialLinks?.twitter || ''}
onChange={(e) => handleSocialChange('twitter', e.target.value)}
placeholder="https://twitter.com/username"
/>
</div>
<div className="ll-edit-profile-field">
<label>LinkedIn</label>
<input
type="url"
value={formData.socialLinks?.linkedin || ''}
onChange={(e) => handleSocialChange('linkedin', e.target.value)}
placeholder="https://linkedin.com/in/username"
/>
</div>
<div className="ll-edit-profile-field">
<label>GitHub</label>
<input
type="url"
value={formData.socialLinks?.github || ''}
onChange={(e) => handleSocialChange('github', e.target.value)}
placeholder="https://github.com/username"
/>
</div>
</div>
<div className="ll-edit-profile-actions">
{onCancel && (
<button type="button" className="ll-edit-profile-btn-secondary" onClick={onCancel}>
Cancel
</button>
)}
<button type="submit" className="ll-edit-profile-btn-primary">
Save Changes
</button>
</div>
</form>
);
};
// Utility functions
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
function formatDate(date: Date): string {
return date.toLocaleDateString([], { month: 'long', year: 'numeric' });
}
function formatTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 7) {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'Just now';
}

9
src/pages/index.ts Normal file
View File

@@ -0,0 +1,9 @@
// Pages - Application page templates
export * from './Auth';
export * from './Error';
export * from './Mail';
export * from './Chat';
export * from './TaskManager';
export * from './UserProfile';
export * from './Invoice';
export * from './Search';

135
src/server/crypto.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Encryption utilities for secure token generation
* Uses AES-256-GCM for authenticated encryption
*/
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
/**
* Get encryption key from environment
* Key must be 32 bytes (64 hex characters)
*/
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
throw new Error('ENCRYPTION_KEY environment variable is not set');
}
if (key.length !== 64) {
throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
}
return Buffer.from(key, 'hex');
}
/**
* Encrypt data object to a URL-safe base64 string
*/
export function encryptData(data: object): string {
const key = getEncryptionKey();
const text = JSON.stringify(data);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Combine: iv (16 bytes) + authTag (16 bytes) + encrypted data
const combined = Buffer.concat([iv, authTag, Buffer.from(encrypted, 'hex')]);
// Return as URL-safe base64
return combined.toString('base64url');
}
/**
* Decrypt a URL-safe base64 token back to data object
* Returns null if decryption fails (invalid/tampered token)
*/
export function decryptData<T = Record<string, unknown>>(token: string): T | null {
try {
const key = getEncryptionKey();
const buffer = Buffer.from(token, 'base64url');
if (buffer.length < 33) {
return null; // Too short to be valid
}
const iv = buffer.subarray(0, 16);
const authTag = buffer.subarray(16, 32);
const encrypted = buffer.subarray(32);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, undefined, 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted) as T;
} catch {
return null;
}
}
/**
* Check if a token with timestamp has expired
*/
export function isTokenExpired(timestamp: number, maxAgeHours: number): boolean {
const now = Date.now();
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
return now - timestamp > maxAgeMs;
}
/**
* Create a verification token with embedded timestamp
*/
export function createVerificationToken(data: {
firstName: string;
lastName: string;
email: string;
organization?: string;
}): string {
return encryptData({
...data,
timestamp: Date.now(),
});
}
/**
* Verify and decode a verification token
* Returns null if invalid or expired (24 hours)
*/
export function verifyToken(token: string): {
firstName: string;
lastName: string;
email: string;
organization?: string;
timestamp: number;
} | null {
const data = decryptData<{
firstName: string;
lastName: string;
email: string;
organization?: string;
timestamp: number;
}>(token);
if (!data) {
return null;
}
if (!data.timestamp || isTokenExpired(data.timestamp, 24)) {
return null;
}
return data;
}
/**
* Generate a random encryption key (for initial setup)
* Run: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
*/
export function generateEncryptionKey(): string {
return crypto.randomBytes(32).toString('hex');
}

199
src/server/email.ts Normal file
View File

@@ -0,0 +1,199 @@
/**
* Email utilities for sending transactional emails
* Uses nodemailer with SMTP configuration
*/
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
// Cached transporter instance
let transporter: Transporter | null = null;
/**
* Get or create SMTP transporter
*/
function getTransporter(): Transporter {
if (transporter) {
return transporter;
}
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || '587', 10);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host) {
throw new Error('SMTP_HOST environment variable is not set');
}
transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user && pass ? { user, pass } : undefined,
});
return transporter;
}
/**
* Get the sender email address
*/
function getSender(): string {
return process.env.SMTP_FROM || 'noreply@gosec.cloud';
}
export interface VerificationEmailParams {
to: string;
firstName: string;
verificationLink: string;
locale?: 'en' | 'de' | 'fr';
}
// Email templates per locale
const templates = {
en: {
subject: 'Verify your GoSec Cloud account',
greeting: (name: string) => `Hello ${name},`,
intro: 'Thank you for registering with GoSec Cloud. Please verify your email address by clicking the button below.',
button: 'Verify Email Address',
expiry: 'This link will expire in 24 hours.',
ignore: "If you didn't create an account, you can safely ignore this email.",
footer: 'GoSec Cloud - Secure Cloud Solutions',
},
de: {
subject: 'Bestätigen Sie Ihr GoSec Cloud-Konto',
greeting: (name: string) => `Hallo ${name},`,
intro: 'Vielen Dank für Ihre Registrierung bei GoSec Cloud. Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf die Schaltfläche unten klicken.',
button: 'E-Mail-Adresse bestätigen',
expiry: 'Dieser Link läuft in 24 Stunden ab.',
ignore: 'Wenn Sie kein Konto erstellt haben, können Sie diese E-Mail ignorieren.',
footer: 'GoSec Cloud - Sichere Cloud-Lösungen',
},
fr: {
subject: 'Vérifiez votre compte GoSec Cloud',
greeting: (name: string) => `Bonjour ${name},`,
intro: "Merci de vous être inscrit sur GoSec Cloud. Veuillez vérifier votre adresse e-mail en cliquant sur le bouton ci-dessous.",
button: "Vérifier l'adresse e-mail",
expiry: 'Ce lien expirera dans 24 heures.',
ignore: "Si vous n'avez pas créé de compte, vous pouvez ignorer cet e-mail.",
footer: 'GoSec Cloud - Solutions Cloud Sécurisées',
},
};
/**
* Generate HTML email template
*/
function generateEmailHtml(params: VerificationEmailParams): string {
const t = templates[params.locale || 'en'];
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${t.subject}</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<!-- Header -->
<tr>
<td style="padding: 40px 40px 20px; text-align: center; border-bottom: 1px solid #e5e5e5;">
<h1 style="margin: 0; font-size: 24px; font-weight: 600; color: #333;">GoSec Cloud</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px; font-size: 16px; color: #333;">${t.greeting(params.firstName)}</p>
<p style="margin: 0 0 30px; font-size: 16px; color: #555; line-height: 1.5;">${t.intro}</p>
<!-- Button -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 20px 0;">
<a href="${params.verificationLink}" style="display: inline-block; padding: 14px 32px; background-color: #2196F3; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 500; border-radius: 6px;">${t.button}</a>
</td>
</tr>
</table>
<p style="margin: 30px 0 0; font-size: 14px; color: #888;">${t.expiry}</p>
<p style="margin: 15px 0 0; font-size: 14px; color: #888;">${t.ignore}</p>
<!-- Link fallback -->
<p style="margin: 30px 0 0; font-size: 12px; color: #999; word-break: break-all;">
${params.verificationLink}
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 20px 40px; text-align: center; border-top: 1px solid #e5e5e5; background-color: #fafafa; border-radius: 0 0 8px 8px;">
<p style="margin: 0; font-size: 12px; color: #888;">${t.footer}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}
/**
* Generate plain text email
*/
function generateEmailText(params: VerificationEmailParams): string {
const t = templates[params.locale || 'en'];
return `
${t.greeting(params.firstName)}
${t.intro}
${t.button}: ${params.verificationLink}
${t.expiry}
${t.ignore}
--
${t.footer}
`.trim();
}
/**
* Send verification email
*/
export async function sendVerificationEmail(params: VerificationEmailParams): Promise<void> {
const transport = getTransporter();
const t = templates[params.locale || 'en'];
await transport.sendMail({
from: getSender(),
to: params.to,
subject: t.subject,
text: generateEmailText(params),
html: generateEmailHtml(params),
});
}
/**
* Test SMTP connection
*/
export async function testSmtpConnection(): Promise<boolean> {
try {
const transport = getTransporter();
await transport.verify();
return true;
} catch {
return false;
}
}

19
src/server/index.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Server-side utilities for @limitless/ui
* These should only be imported in server-side code (*.server.ts files)
*/
export {
encryptData,
decryptData,
isTokenExpired,
createVerificationToken,
verifyToken,
generateEncryptionKey,
} from './crypto';
export {
sendVerificationEmail,
testSmtpConnection,
type VerificationEmailParams,
} from './email';

10715
src/styles.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
export type LimitlessTheme = 'light' | 'dark' | 'material';
type ThemeContextValue = {
theme: LimitlessTheme;
setTheme: (next: LimitlessTheme) => void;
};
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export type ThemeProviderProps = {
initialTheme?: LimitlessTheme;
autoApplyBodyClass?: boolean;
children: React.ReactNode;
};
/**
* Provides CSS variable-driven theming.
* Adds `ll-theme-${theme}` to <body> for palette switching.
*/
export function ThemeProvider({
initialTheme = 'light',
autoApplyBodyClass = true,
children
}: ThemeProviderProps) {
const [theme, setTheme] = useState<LimitlessTheme>(initialTheme);
useEffect(() => {
if (!autoApplyBodyClass) return;
if (typeof document === 'undefined') return;
const className = `ll-theme-${theme}`;
document.body.classList.add(className);
return () => {
document.body.classList.remove(className);
};
}, [theme, autoApplyBodyClass]);
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return ctx;
}

View File

@@ -0,0 +1,296 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { EuropeanAddress, AddressSuggestion, UseAddressAutocompleteOptions } from '../types';
// European country codes for Google Places API
const DEFAULT_EUROPEAN_COUNTRIES = [
'at', 'be', 'bg', 'hr', 'cy', 'cz', 'dk', 'ee', 'fi', 'fr',
'de', 'gr', 'hu', 'ie', 'it', 'lv', 'lt', 'lu', 'mt', 'nl',
'pl', 'pt', 'ro', 'sk', 'si', 'es', 'se', 'gb', 'ch', 'no',
'is', 'li', 'al', 'ad', 'ba', 'me', 'mk', 'rs', 'ua', 'md',
];
export interface UseAddressAutocompleteReturn {
query: string;
setQuery: (value: string) => void;
suggestions: AddressSuggestion[];
isLoading: boolean;
error: string | undefined;
selectSuggestion: (placeId: string) => Promise<EuropeanAddress | null>;
clearSuggestions: () => void;
inputProps: {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onBlur: () => void;
autoComplete: 'off';
};
}
// Parse Google address components into structured format
function parseAddressComponents(
components: google.maps.GeocoderAddressComponent[]
): EuropeanAddress {
const get = (type: string) =>
components.find((c) => c.types.includes(type))?.long_name || '';
const getShort = (type: string) =>
components.find((c) => c.types.includes(type))?.short_name || '';
return {
street: get('route'),
houseNumber: get('street_number'),
postalCode: get('postal_code'),
city: get('locality') || get('postal_town') || get('sublocality'),
state: get('administrative_area_level_1'),
country: getShort('country'),
};
}
/**
* Hook for address autocomplete using Google Places API
*/
export function useAddressAutocomplete(
options: UseAddressAutocompleteOptions = {}
): UseAddressAutocompleteReturn {
const {
apiKey,
countries = DEFAULT_EUROPEAN_COUNTRIES,
debounceMs = 300,
language = 'en',
onSelect,
} = options;
const [query, setQueryState] = useState('');
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const autocompleteService = useRef<google.maps.places.AutocompleteService | null>(null);
const placesService = useRef<google.maps.places.PlacesService | null>(null);
const sessionToken = useRef<google.maps.places.AutocompleteSessionToken | null>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const placesDiv = useRef<HTMLDivElement | null>(null);
// Initialize Google Places service
useEffect(() => {
// Check if Google Maps API is loaded
if (typeof window !== 'undefined' && window.google?.maps?.places) {
autocompleteService.current = new google.maps.places.AutocompleteService();
// Create a hidden div for PlacesService (it requires a map or element)
if (!placesDiv.current) {
placesDiv.current = document.createElement('div');
placesDiv.current.style.display = 'none';
document.body.appendChild(placesDiv.current);
}
placesService.current = new google.maps.places.PlacesService(placesDiv.current);
sessionToken.current = new google.maps.places.AutocompleteSessionToken();
}
return () => {
if (placesDiv.current && placesDiv.current.parentNode) {
placesDiv.current.parentNode.removeChild(placesDiv.current);
}
};
}, []);
// Fetch suggestions from Google Places
const fetchSuggestions = useCallback(
async (input: string) => {
if (!input || input.length < 3) {
setSuggestions([]);
return;
}
if (!autocompleteService.current) {
// Google Maps API not loaded
setError('Google Maps API not loaded. Please include the script in your page.');
return;
}
setIsLoading(true);
setError(undefined);
try {
const request: google.maps.places.AutocompletionRequest = {
input,
componentRestrictions: { country: countries },
types: ['address'],
sessionToken: sessionToken.current || undefined,
};
autocompleteService.current.getPlacePredictions(
request,
(predictions, status) => {
setIsLoading(false);
if (status === google.maps.places.PlacesServiceStatus.OK && predictions) {
setSuggestions(
predictions.map((p) => ({
placeId: p.place_id,
description: p.description,
mainText: p.structured_formatting.main_text,
secondaryText: p.structured_formatting.secondary_text,
}))
);
} else if (status === google.maps.places.PlacesServiceStatus.ZERO_RESULTS) {
setSuggestions([]);
} else if (status !== google.maps.places.PlacesServiceStatus.OK) {
setError('Failed to fetch address suggestions');
setSuggestions([]);
}
}
);
} catch (err) {
setIsLoading(false);
setError(err instanceof Error ? err.message : 'Address lookup failed');
setSuggestions([]);
}
},
[countries]
);
// Debounced query update
const setQuery = useCallback(
(value: string) => {
setQueryState(value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
fetchSuggestions(value);
}, debounceMs);
},
[debounceMs, fetchSuggestions]
);
// Select a suggestion and get full address details
const selectSuggestion = useCallback(
async (placeId: string): Promise<EuropeanAddress | null> => {
if (!placesService.current) {
setError('Google Maps API not loaded');
return null;
}
return new Promise((resolve) => {
placesService.current!.getDetails(
{
placeId,
fields: ['address_components', 'formatted_address', 'geometry'],
sessionToken: sessionToken.current || undefined,
},
(place, status) => {
// Create new session token after place selection
sessionToken.current = new google.maps.places.AutocompleteSessionToken();
if (status === google.maps.places.PlacesServiceStatus.OK && place) {
const address = parseAddressComponents(place.address_components || []);
// Update query with formatted address
setQueryState(place.formatted_address || '');
setSuggestions([]);
// Call onSelect callback
onSelect?.(address);
resolve(address);
} else {
setError('Failed to get address details');
resolve(null);
}
}
);
});
},
[onSelect]
);
// Clear suggestions
const clearSuggestions = useCallback(() => {
setSuggestions([]);
}, []);
// Handle input change
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
},
[setQuery]
);
// Handle blur
const handleBlur = useCallback(() => {
// Delay clearing to allow click on suggestion
setTimeout(() => {
clearSuggestions();
}, 200);
}, [clearSuggestions]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
return {
query,
setQuery,
suggestions,
isLoading,
error,
selectSuggestion,
clearSuggestions,
inputProps: {
value: query,
onChange: handleChange,
onBlur: handleBlur,
autoComplete: 'off' as const,
},
};
}
/**
* Load Google Maps Places API script dynamically
*/
export function loadGoogleMapsScript(apiKey: string, language = 'en'): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof window === 'undefined') {
reject(new Error('Cannot load script on server side'));
return;
}
// Check if already loaded
if (window.google?.maps?.places) {
resolve();
return;
}
// Check if script is already being loaded
const existingScript = document.querySelector(
'script[src*="maps.googleapis.com/maps/api/js"]'
);
if (existingScript) {
existingScript.addEventListener('load', () => resolve());
existingScript.addEventListener('error', () =>
reject(new Error('Failed to load Google Maps API'))
);
return;
}
// Create and append script
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&language=${language}`;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Google Maps API'));
document.head.appendChild(script);
});
}

View File

@@ -0,0 +1,190 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type {
ValidatorFn,
ValidationResult,
ValidationStatus,
UseFieldValidationOptions,
} from '../types';
export interface UseFieldValidationReturn {
value: string;
setValue: (value: string) => void;
status: ValidationStatus;
error: string | undefined;
isValid: boolean;
touched: boolean;
dirty: boolean;
validate: () => Promise<ValidationResult>;
reset: () => void;
inputProps: {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur: () => void;
invalid: boolean;
valid: boolean;
};
}
/**
* Hook for validating a single field
*/
export function useFieldValidation(
options: UseFieldValidationOptions
): UseFieldValidationReturn {
const { validators, initialValue = '', debounceMs = 300 } = options;
const [value, setValueState] = useState(initialValue);
const [status, setStatus] = useState<ValidationStatus>('idle');
const [error, setError] = useState<string | undefined>(undefined);
const [touched, setTouched] = useState(false);
const [dirty, setDirty] = useState(false);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const validatorsRef = useRef(validators);
// Update validators ref when they change
useEffect(() => {
validatorsRef.current = validators;
}, [validators]);
// Run all validators
const runValidation = useCallback(async (val: string): Promise<ValidationResult> => {
for (const validator of validatorsRef.current) {
const result = await validator(val);
if (!result.valid) {
return result;
}
}
return { valid: true };
}, []);
// Validate the current value
const validate = useCallback(async (): Promise<ValidationResult> => {
setStatus('validating');
try {
const result = await runValidation(value);
if (result.valid) {
setStatus('valid');
setError(undefined);
} else {
setStatus('invalid');
setError(result.message);
}
return result;
} catch (err) {
setStatus('invalid');
const message = err instanceof Error ? err.message : 'Validation failed';
setError(message);
return { valid: false, message };
}
}, [value, runValidation]);
// Debounced validation
const debouncedValidate = useCallback(
(val: string) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
setStatus('validating');
try {
const result = await runValidation(val);
if (result.valid) {
setStatus('valid');
setError(undefined);
} else {
setStatus('invalid');
setError(result.message);
}
} catch (err) {
setStatus('invalid');
setError(err instanceof Error ? err.message : 'Validation failed');
}
}, debounceMs);
},
[debounceMs, runValidation]
);
// Set value and trigger validation
const setValue = useCallback(
(newValue: string) => {
setValueState(newValue);
setDirty(true);
if (touched) {
debouncedValidate(newValue);
}
},
[touched, debouncedValidate]
);
// Handle input change
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setValue(e.target.value);
},
[setValue]
);
// Handle blur - mark as touched and validate
const handleBlur = useCallback(() => {
setTouched(true);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
validate();
}, [validate]);
// Reset to initial state
const reset = useCallback(() => {
setValueState(initialValue);
setStatus('idle');
setError(undefined);
setTouched(false);
setDirty(false);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
}, [initialValue]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
const isValid = status === 'valid';
const showInvalid = touched && status === 'invalid';
const showValid = touched && status === 'valid';
return {
value,
setValue,
status,
error,
isValid,
touched,
dirty,
validate,
reset,
inputProps: {
value,
onChange: handleChange,
onBlur: handleBlur,
invalid: showInvalid,
valid: showValid,
},
};
}

View File

@@ -0,0 +1,408 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import type {
FormSchema,
FieldState,
ValidationResult,
ValidationStatus,
UseValidationOptions,
} from '../types';
export interface UseValidationReturn {
// Field states
fields: Record<string, FieldState>;
// Form state
isValid: boolean;
isDirty: boolean;
isSubmitting: boolean;
// Get props for FormControl
getFieldProps: (name: string) => {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur: () => void;
invalid: boolean;
valid: boolean;
'aria-invalid': boolean;
'aria-describedby': string;
};
// Get state for FormGroup
getFieldState: (name: string) => {
invalid: boolean;
valid: boolean;
invalidFeedback: string | undefined;
};
// Actions
setFieldValue: (name: string, value: string) => void;
validateField: (name: string) => Promise<ValidationResult>;
validateForm: () => Promise<boolean>;
resetForm: () => void;
resetField: (name: string) => void;
setSubmitting: (value: boolean) => void;
// Form submission handler
handleSubmit: (
onValid: (values: Record<string, string>) => void | Promise<void>
) => (e: React.FormEvent) => void;
// Get all values
getValues: () => Record<string, string>;
}
// Create initial field state
function createFieldState(value: string = ''): FieldState {
return {
value,
status: 'idle',
touched: false,
dirty: false,
error: undefined,
isValid: false,
};
}
/**
* Hook for form-level validation
*/
export function useValidation(options: UseValidationOptions): UseValidationReturn {
const { schema, initialValues = {}, validateOnMount = false } = options;
// Initialize fields from schema
const initialFields = useMemo(() => {
const fields: Record<string, FieldState> = {};
for (const name of Object.keys(schema)) {
fields[name] = createFieldState(initialValues[name] || '');
}
return fields;
}, []);
const [fields, setFields] = useState<Record<string, FieldState>>(initialFields);
const [isSubmitting, setIsSubmitting] = useState(false);
const schemaRef = useRef(schema);
const debounceRefs = useRef<Record<string, NodeJS.Timeout>>({});
// Update schema ref when it changes
useEffect(() => {
schemaRef.current = schema;
}, [schema]);
// Get all current values
const getValues = useCallback((): Record<string, string> => {
const values: Record<string, string> = {};
for (const [name, field] of Object.entries(fields)) {
values[name] = field.value;
}
return values;
}, [fields]);
// Run validators for a field
const runFieldValidation = useCallback(
async (name: string, value: string): Promise<ValidationResult> => {
const fieldSchema = schemaRef.current[name];
if (!fieldSchema) {
return { valid: true };
}
const allValues = getValues();
allValues[name] = value; // Use the new value
for (const validator of fieldSchema.validators) {
const result = await validator(value, allValues);
if (!result.valid) {
return result;
}
}
return { valid: true };
},
[getValues]
);
// Validate a single field
const validateField = useCallback(
async (name: string): Promise<ValidationResult> => {
const field = fields[name];
if (!field) {
return { valid: true };
}
setFields((prev) => ({
...prev,
[name]: { ...prev[name], status: 'validating' },
}));
try {
const result = await runFieldValidation(name, field.value);
setFields((prev) => ({
...prev,
[name]: {
...prev[name],
status: result.valid ? 'valid' : 'invalid',
error: result.message,
isValid: result.valid,
},
}));
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Validation failed';
setFields((prev) => ({
...prev,
[name]: {
...prev[name],
status: 'invalid',
error: message,
isValid: false,
},
}));
return { valid: false, message };
}
},
[fields, runFieldValidation]
);
// Debounced field validation
const debouncedValidateField = useCallback(
(name: string, value: string) => {
const fieldSchema = schemaRef.current[name];
const debounceMs = fieldSchema?.debounceMs ?? 300;
if (debounceRefs.current[name]) {
clearTimeout(debounceRefs.current[name]);
}
debounceRefs.current[name] = setTimeout(async () => {
setFields((prev) => ({
...prev,
[name]: { ...prev[name], status: 'validating' },
}));
try {
const result = await runFieldValidation(name, value);
setFields((prev) => ({
...prev,
[name]: {
...prev[name],
status: result.valid ? 'valid' : 'invalid',
error: result.message,
isValid: result.valid,
},
}));
} catch (err) {
setFields((prev) => ({
...prev,
[name]: {
...prev[name],
status: 'invalid',
error: err instanceof Error ? err.message : 'Validation failed',
isValid: false,
},
}));
}
}, debounceMs);
},
[runFieldValidation]
);
// Set field value
const setFieldValue = useCallback(
(name: string, value: string) => {
setFields((prev) => ({
...prev,
[name]: {
...prev[name],
value,
dirty: true,
},
}));
// Validate if field was touched
const field = fields[name];
if (field?.touched) {
debouncedValidateField(name, value);
}
},
[fields, debouncedValidateField]
);
// Handle input change
const handleChange = useCallback(
(name: string) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFieldValue(name, e.target.value);
},
[setFieldValue]
);
// Handle blur
const handleBlur = useCallback(
(name: string) => () => {
setFields((prev) => ({
...prev,
[name]: { ...prev[name], touched: true },
}));
// Clear debounce and validate immediately
if (debounceRefs.current[name]) {
clearTimeout(debounceRefs.current[name]);
}
validateField(name);
},
[validateField]
);
// Get props for FormControl
const getFieldProps = useCallback(
(name: string) => {
const field = fields[name] || createFieldState();
const showInvalid = field.touched && field.status === 'invalid';
const showValid = field.touched && field.status === 'valid';
return {
value: field.value,
onChange: handleChange(name),
onBlur: handleBlur(name),
invalid: showInvalid,
valid: showValid,
'aria-invalid': showInvalid,
'aria-describedby': `${name}-feedback`,
};
},
[fields, handleChange, handleBlur]
);
// Get state for FormGroup
const getFieldState = useCallback(
(name: string) => {
const field = fields[name] || createFieldState();
const showInvalid = field.touched && field.status === 'invalid';
const showValid = field.touched && field.status === 'valid';
return {
invalid: showInvalid,
valid: showValid,
invalidFeedback: showInvalid ? field.error : undefined,
};
},
[fields]
);
// Validate entire form
const validateForm = useCallback(async (): Promise<boolean> => {
const fieldNames = Object.keys(schemaRef.current);
const results = await Promise.all(fieldNames.map((name) => validateField(name)));
return results.every((r) => r.valid);
}, [validateField]);
// Reset a single field
const resetField = useCallback(
(name: string) => {
setFields((prev) => ({
...prev,
[name]: createFieldState(initialValues[name] || ''),
}));
if (debounceRefs.current[name]) {
clearTimeout(debounceRefs.current[name]);
}
},
[initialValues]
);
// Reset entire form
const resetForm = useCallback(() => {
const newFields: Record<string, FieldState> = {};
for (const name of Object.keys(schemaRef.current)) {
newFields[name] = createFieldState(initialValues[name] || '');
}
setFields(newFields);
setIsSubmitting(false);
// Clear all debounce timers
for (const timer of Object.values(debounceRefs.current)) {
clearTimeout(timer);
}
debounceRefs.current = {};
}, [initialValues]);
// Handle form submission
const handleSubmit = useCallback(
(onValid: (values: Record<string, string>) => void | Promise<void>) =>
async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
// Mark all fields as touched
setFields((prev) => {
const updated: Record<string, FieldState> = {};
for (const [name, field] of Object.entries(prev)) {
updated[name] = { ...field, touched: true };
}
return updated;
});
const isFormValid = await validateForm();
if (isFormValid) {
try {
await onValid(getValues());
} finally {
setIsSubmitting(false);
}
} else {
setIsSubmitting(false);
}
},
[validateForm, getValues]
);
// Compute form-level state
const isValid = useMemo(() => {
return Object.values(fields).every((f) => f.status === 'valid' || f.status === 'idle');
}, [fields]);
const isDirty = useMemo(() => {
return Object.values(fields).some((f) => f.dirty);
}, [fields]);
// Validate on mount if requested
useEffect(() => {
if (validateOnMount) {
validateForm();
}
}, [validateOnMount]);
// Cleanup on unmount
useEffect(() => {
return () => {
for (const timer of Object.values(debounceRefs.current)) {
clearTimeout(timer);
}
};
}, []);
return {
fields,
isValid,
isDirty,
isSubmitting,
getFieldProps,
getFieldState,
setFieldValue,
validateField,
validateForm,
resetForm,
resetField,
setSubmitting: setIsSubmitting,
handleSubmit,
getValues,
};
}

88
src/validation/index.ts Normal file
View File

@@ -0,0 +1,88 @@
// Hooks
export { useValidation } from './hooks/useValidation';
export { useFieldValidation } from './hooks/useFieldValidation';
export {
useAddressAutocomplete,
loadGoogleMapsScript,
} from './hooks/useAddressAutocomplete';
// Validators - namespaced export
export * as validators from './validators';
// Individual validator exports for tree-shaking
export {
// Format validators
required,
minLength,
maxLength,
pattern,
matches,
email,
url,
tel,
number,
range,
date,
time,
datetimeLocal,
month,
week,
color,
search,
password,
getPasswordStrength,
file,
checkbox,
radio,
// Security validators
noInjection,
noSqlInjection,
noScriptInjection,
noHtmlInjection,
noPromptInjection,
detectInjectionType,
SQL_INJECTION_PATTERNS,
SCRIPT_INJECTION_PATTERNS,
HTML_INJECTION_PATTERNS,
PROMPT_INJECTION_PATTERNS,
// Address validators
postalCode,
europeanPostalCode,
city,
streetAddress,
houseNumber,
europeanCountry,
europeanAddress,
getCountryName,
getPostalCodeHint,
POSTAL_CODE_PATTERNS,
EU_COUNTRIES,
EEA_COUNTRIES,
EUROPEAN_COUNTRIES,
} from './validators';
// Types
export type {
ValidationStatus,
ValidationResult,
FieldState,
FormState,
ValidatorFn,
FieldSchema,
FormSchema,
EuropeanAddress,
AddressSuggestion,
SecurityValidationOptions,
PasswordStrength,
PasswordOptions,
PhoneOptions,
NumberOptions,
DateOptions,
TimeOptions,
FileOptions,
UseValidationOptions,
UseFieldValidationOptions,
UseAddressAutocompleteOptions,
ServerValidationSchema,
ServerValidationResult,
} from './types';

View File

@@ -0,0 +1,26 @@
// Server-side validation utilities
export {
validateValue,
validateFormData,
validationErrorResponse,
createSchema,
transforms,
} from './validateFormData';
// Sanitization utilities
export {
escapeHtml,
stripHtml,
sanitizeHtml,
escapeSql,
removeNullBytes,
normalizeWhitespace,
removeControlChars,
sanitizeFilename,
sanitizeUrl,
sanitizeInput,
sanitizeFormData,
} from './sanitize';
// Re-export validators for server-side use
export * from '../validators';

View File

@@ -0,0 +1,212 @@
/**
* HTML entities map for escaping
*/
const HTML_ENTITIES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;',
};
/**
* Escape HTML entities to prevent XSS
*/
export function escapeHtml(input: string): string {
return input.replace(/[&<>"'`=\/]/g, (char) => HTML_ENTITIES[char] || char);
}
/**
* Remove all HTML tags from input
*/
export function stripHtml(input: string): string {
return input.replace(/<[^>]*>/g, '');
}
/**
* Sanitize HTML - remove dangerous tags but keep safe ones
*/
export function sanitizeHtml(
input: string,
options: { allowedTags?: string[] } = {}
): string {
const { allowedTags = [] } = options;
if (allowedTags.length === 0) {
// No tags allowed - strip everything
return stripHtml(input);
}
// Build pattern for allowed tags
const allowedPattern = allowedTags.join('|');
const tagPattern = new RegExp(
`<(?!\/?(?:${allowedPattern})(?:\\s|>))[^>]*>`,
'gi'
);
// Remove disallowed tags
let result = input.replace(tagPattern, '');
// Remove event handlers from allowed tags
result = result.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '');
result = result.replace(/\s*on\w+\s*=\s*\S+/gi, '');
// Remove javascript: and data: URLs
result = result.replace(/\s*href\s*=\s*["']?\s*javascript:/gi, ' href="');
result = result.replace(/\s*src\s*=\s*["']?\s*javascript:/gi, ' src="');
result = result.replace(/\s*href\s*=\s*["']?\s*data:/gi, ' href="');
return result;
}
/**
* Escape SQL special characters
* Note: Use parameterized queries when possible - this is a fallback
*/
export function escapeSql(input: string): string {
return input
.replace(/'/g, "''")
.replace(/\\/g, '\\\\')
.replace(/\x00/g, '\\0')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\x1a/g, '\\Z');
}
/**
* Remove null bytes and other dangerous characters
*/
export function removeNullBytes(input: string): string {
return input.replace(/\x00/g, '');
}
/**
* Normalize whitespace (trim and collapse multiple spaces)
*/
export function normalizeWhitespace(input: string): string {
return input.trim().replace(/\s+/g, ' ');
}
/**
* Remove control characters
*/
export function removeControlChars(input: string): string {
// Keep newlines and tabs, remove other control chars
return input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
}
/**
* Sanitize filename to prevent path traversal
*/
export function sanitizeFilename(input: string): string {
return input
.replace(/\.\./g, '') // Remove path traversal
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '') // Remove invalid chars
.replace(/^\.+/, '') // Remove leading dots
.trim();
}
/**
* Sanitize URL to ensure it's safe
*/
export function sanitizeUrl(input: string): string {
const trimmed = input.trim().toLowerCase();
// Block dangerous protocols
if (
trimmed.startsWith('javascript:') ||
trimmed.startsWith('data:') ||
trimmed.startsWith('vbscript:')
) {
return '';
}
return input.trim();
}
/**
* General input sanitization - applies common sanitizations
*/
export function sanitizeInput(
input: string,
options: {
stripHtml?: boolean;
normalizeWhitespace?: boolean;
removeControlChars?: boolean;
maxLength?: number;
} = {}
): string {
const {
stripHtml: shouldStripHtml = true,
normalizeWhitespace: shouldNormalize = true,
removeControlChars: shouldRemoveControl = true,
maxLength,
} = options;
let result = input;
// Remove null bytes first
result = removeNullBytes(result);
// Remove control characters
if (shouldRemoveControl) {
result = removeControlChars(result);
}
// Strip HTML
if (shouldStripHtml) {
result = stripHtml(result);
}
// Normalize whitespace
if (shouldNormalize) {
result = normalizeWhitespace(result);
}
// Truncate if needed
if (maxLength && result.length > maxLength) {
result = result.slice(0, maxLength);
}
return result;
}
/**
* Sanitize an entire object of form values
*/
export function sanitizeFormData<T extends Record<string, string>>(
data: T,
options: {
fields?: Record<
string,
{
stripHtml?: boolean;
normalizeWhitespace?: boolean;
maxLength?: number;
}
>;
default?: {
stripHtml?: boolean;
normalizeWhitespace?: boolean;
};
} = {}
): T {
const result: Record<string, string> = {};
const defaultOpts = {
stripHtml: true,
normalizeWhitespace: true,
removeControlChars: true,
...options.default,
};
for (const [key, value] of Object.entries(data)) {
const fieldOpts = options.fields?.[key] || {};
result[key] = sanitizeInput(value, { ...defaultOpts, ...fieldOpts });
}
return result as T;
}

View File

@@ -0,0 +1,101 @@
import type { ValidatorFn, ServerValidationSchema, ServerValidationResult } from '../types';
/**
* Run a list of validators on a single value
*/
export async function validateValue(
value: string,
validators: ValidatorFn[],
allValues?: Record<string, string>
): Promise<{ valid: boolean; message?: string }> {
for (const validator of validators) {
const result = await validator(value, allValues);
if (!result.valid) {
return result;
}
}
return { valid: true };
}
/**
* Validate form data against a schema
* Used in Remix action functions
*/
export async function validateFormData<T = Record<string, string>>(
formData: FormData,
schema: ServerValidationSchema
): Promise<ServerValidationResult<T>> {
const errors: Record<string, string> = {};
const data: Record<string, unknown> = {};
// Extract all form values first
const values: Record<string, string> = {};
for (const fieldName of Object.keys(schema)) {
const value = formData.get(fieldName);
values[fieldName] = typeof value === 'string' ? value : '';
}
// Validate each field
for (const [fieldName, fieldSchema] of Object.entries(schema)) {
const value = values[fieldName];
// Run validators
const result = await validateValue(value, fieldSchema.validators, values);
if (!result.valid) {
errors[fieldName] = result.message || 'Invalid value';
} else {
// Apply transform if provided
if (fieldSchema.transform) {
data[fieldName] = fieldSchema.transform(value);
} else {
data[fieldName] = value;
}
}
}
if (Object.keys(errors).length > 0) {
return { success: false, errors };
}
return { success: true, data: data as T };
}
/**
* Create a JSON error response for validation errors
* Compatible with Remix's json() response
*/
export function validationErrorResponse(
errors: Record<string, string>,
status = 400
): { errors: Record<string, string>; status: number } {
return { errors, status };
}
/**
* Helper to create a validation schema for common patterns
*/
export function createSchema(
fields: Record<string, ValidatorFn[]>
): ServerValidationSchema {
const schema: ServerValidationSchema = {};
for (const [name, validators] of Object.entries(fields)) {
schema[name] = { validators };
}
return schema;
}
/**
* Common transforms for form data
*/
export const transforms = {
trim: (value: string) => value.trim(),
lowercase: (value: string) => value.toLowerCase(),
uppercase: (value: string) => value.toUpperCase(),
toNumber: (value: string) => parseFloat(value),
toInteger: (value: string) => parseInt(value, 10),
toBoolean: (value: string) => value === 'true' || value === 'on' || value === '1',
toDate: (value: string) => new Date(value),
toArray: (separator = ',') => (value: string) =>
value.split(separator).map((s) => s.trim()).filter(Boolean),
};

160
src/validation/types.ts Normal file
View File

@@ -0,0 +1,160 @@
// Validation status for fields
export type ValidationStatus = 'idle' | 'validating' | 'valid' | 'invalid';
// Result of a single validation
export interface ValidationResult {
valid: boolean;
message?: string;
code?: string; // For i18n support
}
// State of a single field
export interface FieldState {
value: string;
status: ValidationStatus;
touched: boolean;
dirty: boolean;
error?: string;
isValid: boolean;
}
// State of the entire form
export interface FormState {
fields: Record<string, FieldState>;
isValid: boolean;
isSubmitting: boolean;
isDirty: boolean;
}
// Validator function signature
export type ValidatorFn = (
value: string,
formValues?: Record<string, string>
) => ValidationResult | Promise<ValidationResult>;
// Schema for a single field
export interface FieldSchema {
validators: ValidatorFn[];
validateOnChange?: boolean; // default: true
validateOnBlur?: boolean; // default: true
debounceMs?: number; // default: 300
}
// Schema for the entire form
export type FormSchema = Record<string, FieldSchema>;
// European address structure
export interface EuropeanAddress {
street: string;
houseNumber?: string;
postalCode: string;
city: string;
state?: string;
country: string; // ISO 3166-1 alpha-2 code
}
// Address suggestion from Google Places
export interface AddressSuggestion {
placeId: string;
description: string;
mainText: string;
secondaryText: string;
structured?: EuropeanAddress;
}
// Security validation options
export interface SecurityValidationOptions {
allowHtml?: boolean;
allowedTags?: string[];
checkSqlInjection?: boolean;
checkScriptInjection?: boolean;
checkPromptInjection?: boolean;
}
// Password strength result
export interface PasswordStrength {
score: number; // 0-4
label: 'weak' | 'fair' | 'good' | 'strong' | 'very-strong';
feedback: string[];
}
// Password validation options
export interface PasswordOptions {
minLength?: number; // default: 8
requireUppercase?: boolean; // default: true
requireLowercase?: boolean; // default: true
requireNumbers?: boolean; // default: true
requireSymbols?: boolean; // default: true
minScore?: number; // 0-4, default: 2
}
// Phone validation options
export interface PhoneOptions {
country?: string; // ISO code for specific country format
allowInternational?: boolean;
}
// Number validation options
export interface NumberOptions {
min?: number;
max?: number;
integer?: boolean;
}
// Date validation options
export interface DateOptions {
min?: string | Date;
max?: string | Date;
format?: string;
}
// Time validation options
export interface TimeOptions {
min?: string;
max?: string;
}
// File validation options
export interface FileOptions {
accept?: string[]; // MIME types or extensions
maxSize?: number; // bytes
maxFiles?: number;
}
// useValidation hook options
export interface UseValidationOptions {
schema: FormSchema;
initialValues?: Record<string, string>;
validateOnMount?: boolean;
}
// useFieldValidation hook options
export interface UseFieldValidationOptions {
validators: ValidatorFn[];
initialValue?: string;
debounceMs?: number;
}
// useAddressAutocomplete hook options
export interface UseAddressAutocompleteOptions {
apiKey?: string;
countries?: string[]; // ISO codes, default: European countries
debounceMs?: number;
language?: string;
onSelect?: (address: EuropeanAddress) => void;
}
// Server validation schema
export interface ServerValidationSchema {
[fieldName: string]: {
validators: ValidatorFn[];
transform?: (value: string) => unknown;
};
}
// Server validation result
export interface ServerValidationResult<T = Record<string, string>> {
success: boolean;
data?: T;
errors?: Record<string, string>;
}

View File

@@ -0,0 +1,370 @@
import type { ValidatorFn, ValidationResult } from '../types';
// Helper to create a validation result
const valid = (): ValidationResult => ({ valid: true });
const invalid = (message: string, code?: string): ValidationResult => ({
valid: false,
message,
code,
});
// ============================================================================
// European Postal Code Patterns
// ============================================================================
export const POSTAL_CODE_PATTERNS: Record<string, RegExp> = {
// EU Member States
AT: /^\d{4}$/, // Austria
BE: /^\d{4}$/, // Belgium
BG: /^\d{4}$/, // Bulgaria
HR: /^\d{5}$/, // Croatia
CY: /^\d{4}$/, // Cyprus
CZ: /^\d{3}\s?\d{2}$/, // Czech Republic
DK: /^\d{4}$/, // Denmark
EE: /^\d{5}$/, // Estonia
FI: /^\d{5}$/, // Finland
FR: /^\d{5}$/, // France
DE: /^\d{5}$/, // Germany
GR: /^\d{3}\s?\d{2}$/, // Greece
HU: /^\d{4}$/, // Hungary
IE: /^[A-Z]\d{2}\s?[A-Z\d]{4}$/i, // Ireland (Eircode)
IT: /^\d{5}$/, // Italy
LV: /^LV-\d{4}$/i, // Latvia
LT: /^LT-\d{5}$/i, // Lithuania
LU: /^\d{4}$/, // Luxembourg
MT: /^[A-Z]{3}\s?\d{2,4}$/i, // Malta
NL: /^\d{4}\s?[A-Z]{2}$/i, // Netherlands
PL: /^\d{2}-\d{3}$/, // Poland
PT: /^\d{4}-\d{3}$/, // Portugal
RO: /^\d{6}$/, // Romania
SK: /^\d{3}\s?\d{2}$/, // Slovakia
SI: /^\d{4}$/, // Slovenia
ES: /^\d{5}$/, // Spain
SE: /^\d{3}\s?\d{2}$/, // Sweden
// EEA & EFTA
IS: /^\d{3}$/, // Iceland
LI: /^\d{4}$/, // Liechtenstein
NO: /^\d{4}$/, // Norway
CH: /^\d{4}$/, // Switzerland
// UK (post-Brexit but still relevant)
GB: /^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i, // United Kingdom
// Other European countries
AL: /^\d{4}$/, // Albania
AD: /^AD\d{3}$/i, // Andorra
BA: /^\d{5}$/, // Bosnia and Herzegovina
BY: /^\d{6}$/, // Belarus
GI: /^GX11\s?1AA$/i, // Gibraltar
MC: /^980\d{2}$/, // Monaco
MD: /^MD-?\d{4}$/i, // Moldova
ME: /^\d{5}$/, // Montenegro
MK: /^\d{4}$/, // North Macedonia
RS: /^\d{5,6}$/, // Serbia
SM: /^4789\d$/, // San Marino
TR: /^\d{5}$/, // Turkey
UA: /^\d{5}$/, // Ukraine
VA: /^00120$/, // Vatican City
XK: /^\d{5}$/, // Kosovo
};
// Country names for error messages
const COUNTRY_NAMES: Record<string, string> = {
AT: 'Austria',
BE: 'Belgium',
BG: 'Bulgaria',
HR: 'Croatia',
CY: 'Cyprus',
CZ: 'Czech Republic',
DK: 'Denmark',
EE: 'Estonia',
FI: 'Finland',
FR: 'France',
DE: 'Germany',
GR: 'Greece',
HU: 'Hungary',
IE: 'Ireland',
IT: 'Italy',
LV: 'Latvia',
LT: 'Lithuania',
LU: 'Luxembourg',
MT: 'Malta',
NL: 'Netherlands',
PL: 'Poland',
PT: 'Portugal',
RO: 'Romania',
SK: 'Slovakia',
SI: 'Slovenia',
ES: 'Spain',
SE: 'Sweden',
IS: 'Iceland',
LI: 'Liechtenstein',
NO: 'Norway',
CH: 'Switzerland',
GB: 'United Kingdom',
AL: 'Albania',
AD: 'Andorra',
BA: 'Bosnia and Herzegovina',
BY: 'Belarus',
GI: 'Gibraltar',
MC: 'Monaco',
MD: 'Moldova',
ME: 'Montenegro',
MK: 'North Macedonia',
RS: 'Serbia',
SM: 'San Marino',
TR: 'Turkey',
UA: 'Ukraine',
VA: 'Vatican City',
XK: 'Kosovo',
};
// EU member state codes
export const EU_COUNTRIES = [
'AT',
'BE',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'DE',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'PL',
'PT',
'RO',
'SK',
'SI',
'ES',
'SE',
];
// EEA countries (EU + Iceland, Liechtenstein, Norway)
export const EEA_COUNTRIES = [...EU_COUNTRIES, 'IS', 'LI', 'NO'];
// All European countries
export const EUROPEAN_COUNTRIES = Object.keys(POSTAL_CODE_PATTERNS);
// ============================================================================
// Validators
// ============================================================================
/**
* Validates postal code format for a specific country
*/
export function postalCode(country: string, message?: string): ValidatorFn {
const countryUpper = country.toUpperCase();
const pattern = POSTAL_CODE_PATTERNS[countryUpper];
const countryName = COUNTRY_NAMES[countryUpper] || country;
return (value: string) => {
if (!value) return valid();
if (!pattern) {
return invalid(`Unknown country code: ${country}`, 'address.unknownCountry');
}
if (!pattern.test(value.trim())) {
return invalid(
message || `Please enter a valid postal code for ${countryName}`,
'address.postalCode'
);
}
return valid();
};
}
/**
* Validates postal code for any European country (auto-detects format)
*/
export function europeanPostalCode(message = 'Please enter a valid European postal code'): ValidatorFn {
return (value: string) => {
if (!value) return valid();
const trimmed = value.trim();
// Try to match against any European postal code pattern
for (const [, pattern] of Object.entries(POSTAL_CODE_PATTERNS)) {
if (pattern.test(trimmed)) {
return valid();
}
}
return invalid(message, 'address.postalCode');
};
}
/**
* Validates city name (basic validation)
*/
export function city(message = 'Please enter a valid city name'): ValidatorFn {
// Allow letters, spaces, hyphens, apostrophes, and common diacritics
const cityRegex = /^[\p{L}\s\-''.]+$/u;
return (value: string) => {
if (!value) return valid();
const trimmed = value.trim();
if (trimmed.length < 2) {
return invalid('City name is too short', 'address.city.short');
}
if (trimmed.length > 100) {
return invalid('City name is too long', 'address.city.long');
}
if (!cityRegex.test(trimmed)) {
return invalid(message, 'address.city');
}
return valid();
};
}
/**
* Validates street address (basic validation)
*/
export function streetAddress(message = 'Please enter a valid street address'): ValidatorFn {
// Allow letters, numbers, spaces, and common punctuation
const streetRegex = /^[\p{L}\p{N}\s\-''.,:\/\\#]+$/u;
return (value: string) => {
if (!value) return valid();
const trimmed = value.trim();
if (trimmed.length < 3) {
return invalid('Street address is too short', 'address.street.short');
}
if (trimmed.length > 200) {
return invalid('Street address is too long', 'address.street.long');
}
if (!streetRegex.test(trimmed)) {
return invalid(message, 'address.street');
}
return valid();
};
}
/**
* Validates house/building number
*/
export function houseNumber(message = 'Please enter a valid house number'): ValidatorFn {
// Allow numbers, letters (for 12A, 12B), and some punctuation
const houseRegex = /^[\p{L}\p{N}\s\-\/]+$/u;
return (value: string) => {
if (!value) return valid();
const trimmed = value.trim();
if (trimmed.length > 20) {
return invalid('House number is too long', 'address.houseNumber.long');
}
if (!houseRegex.test(trimmed)) {
return invalid(message, 'address.houseNumber');
}
return valid();
};
}
/**
* Validates European country code
*/
export function europeanCountry(
options: { euOnly?: boolean; eeaOnly?: boolean } = {}
): ValidatorFn {
const { euOnly = false, eeaOnly = false } = options;
return (value: string) => {
if (!value) return valid();
const countryUpper = value.toUpperCase().trim();
if (euOnly) {
if (!EU_COUNTRIES.includes(countryUpper)) {
return invalid('Please select an EU member state', 'address.country.eu');
}
} else if (eeaOnly) {
if (!EEA_COUNTRIES.includes(countryUpper)) {
return invalid('Please select an EEA country', 'address.country.eea');
}
} else {
if (!EUROPEAN_COUNTRIES.includes(countryUpper)) {
return invalid('Please select a European country', 'address.country.europe');
}
}
return valid();
};
}
/**
* Combined European address validator
* Validates that all address fields are properly formatted
*/
export function europeanAddress(options: { country?: string } = {}): ValidatorFn {
return (value: string, formValues?: Record<string, string>): ValidationResult => {
if (!value) return valid();
// If country is provided, validate postal code for that country
const country = options.country || formValues?.country;
if (country) {
const postalValidator = postalCode(country);
const result = postalValidator(value) as ValidationResult;
if (!result.valid) return result;
}
return valid();
};
}
/**
* Utility: Get country name from ISO code
*/
export function getCountryName(code: string): string | undefined {
return COUNTRY_NAMES[code.toUpperCase()];
}
/**
* Utility: Get postal code format hint for a country
*/
export function getPostalCodeHint(country: string): string {
const hints: Record<string, string> = {
AT: '1234',
BE: '1234',
DE: '12345',
FR: '12345',
GB: 'SW1A 1AA',
NL: '1234 AB',
PL: '12-345',
PT: '1234-567',
CH: '1234',
IE: 'A65 F4E2',
CZ: '123 45',
SE: '123 45',
IT: '12345',
ES: '12345',
};
return hints[country.toUpperCase()] || '';
}

View File

@@ -0,0 +1,526 @@
import type {
ValidatorFn,
ValidationResult,
PasswordOptions,
PasswordStrength,
PhoneOptions,
NumberOptions,
DateOptions,
TimeOptions,
FileOptions,
} from '../types';
// Helper to create a validation result
const valid = (): ValidationResult => ({ valid: true });
const invalid = (message: string, code?: string): ValidationResult => ({
valid: false,
message,
code,
});
// ============================================================================
// Core Validators
// ============================================================================
/**
* Validates that a field is not empty
*/
export function required(message = 'This field is required'): ValidatorFn {
return (value: string) => {
if (!value || value.trim() === '') {
return invalid(message, 'required');
}
return valid();
};
}
/**
* Validates minimum length
*/
export function minLength(min: number, message?: string): ValidatorFn {
return (value: string) => {
if (value.length < min) {
return invalid(message || `Must be at least ${min} characters`, 'minLength');
}
return valid();
};
}
/**
* Validates maximum length
*/
export function maxLength(max: number, message?: string): ValidatorFn {
return (value: string) => {
if (value.length > max) {
return invalid(message || `Must be at most ${max} characters`, 'maxLength');
}
return valid();
};
}
/**
* Validates against a regex pattern
*/
export function pattern(regex: RegExp, message = 'Invalid format'): ValidatorFn {
return (value: string) => {
if (!value) return valid(); // Let required() handle empty
if (!regex.test(value)) {
return invalid(message, 'pattern');
}
return valid();
};
}
/**
* Validates that field matches another field (e.g., confirm password)
*/
export function matches(fieldName: string, message?: string): ValidatorFn {
return (value: string, formValues?: Record<string, string>) => {
if (!formValues) return valid();
if (value !== formValues[fieldName]) {
return invalid(message || `Must match ${fieldName}`, 'matches');
}
return valid();
};
}
// ============================================================================
// Input Type Validators
// ============================================================================
/**
* Validates email format (RFC 5322 simplified)
*/
export function email(message = 'Please enter a valid email address'): ValidatorFn {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return (value: string) => {
if (!value) return valid();
if (!emailRegex.test(value)) {
return invalid(message, 'email');
}
return valid();
};
}
/**
* Validates URL format
*/
export function url(message = 'Please enter a valid URL'): ValidatorFn {
return (value: string) => {
if (!value) return valid();
try {
new URL(value);
return valid();
} catch {
return invalid(message, 'url');
}
};
}
/**
* Phone number patterns by country
*/
const PHONE_PATTERNS: Record<string, RegExp> = {
DE: /^(\+49|0)[1-9]\d{1,14}$/, // Germany
FR: /^(\+33|0)[1-9]\d{8}$/, // France
GB: /^(\+44|0)[1-9]\d{9,10}$/, // UK
NL: /^(\+31|0)[1-9]\d{8}$/, // Netherlands
BE: /^(\+32|0)[1-9]\d{7,8}$/, // Belgium
IT: /^(\+39|0)[0-9]{6,12}$/, // Italy
ES: /^(\+34|0)?[6-9]\d{8}$/, // Spain
AT: /^(\+43|0)[1-9]\d{3,12}$/, // Austria
CH: /^(\+41|0)[1-9]\d{8}$/, // Switzerland
PL: /^(\+48|0)?[1-9]\d{8}$/, // Poland
INTL: /^\+[1-9]\d{6,14}$/, // International
};
/**
* Validates phone number format
*/
export function tel(options: PhoneOptions = {}): ValidatorFn {
const { country, allowInternational = true } = options;
return (value: string) => {
if (!value) return valid();
const cleaned = value.replace(/[\s\-\(\)]/g, '');
if (country && PHONE_PATTERNS[country]) {
if (!PHONE_PATTERNS[country].test(cleaned)) {
return invalid(`Please enter a valid ${country} phone number`, 'tel');
}
return valid();
}
// Try all patterns
if (allowInternational && PHONE_PATTERNS.INTL.test(cleaned)) {
return valid();
}
for (const pat of Object.values(PHONE_PATTERNS)) {
if (pat.test(cleaned)) return valid();
}
return invalid('Please enter a valid phone number', 'tel');
};
}
/**
* Validates numeric input
*/
export function number(options: NumberOptions = {}): ValidatorFn {
const { min, max, integer } = options;
return (value: string) => {
if (!value) return valid();
const num = parseFloat(value);
if (isNaN(num)) {
return invalid('Please enter a valid number', 'number');
}
if (integer && !Number.isInteger(num)) {
return invalid('Please enter a whole number', 'number.integer');
}
if (min !== undefined && num < min) {
return invalid(`Value must be at least ${min}`, 'number.min');
}
if (max !== undefined && num > max) {
return invalid(`Value must be at most ${max}`, 'number.max');
}
return valid();
};
}
/**
* Validates range input (always requires min/max)
*/
export function range(min: number, max: number): ValidatorFn {
return (value: string) => {
if (!value) return valid();
const num = parseFloat(value);
if (isNaN(num) || num < min || num > max) {
return invalid(`Value must be between ${min} and ${max}`, 'range');
}
return valid();
};
}
/**
* Validates date input
*/
export function date(options: DateOptions = {}): ValidatorFn {
const { min, max } = options;
return (value: string) => {
if (!value) return valid();
const dateValue = new Date(value);
if (isNaN(dateValue.getTime())) {
return invalid('Please enter a valid date', 'date');
}
if (min) {
const minDate = typeof min === 'string' ? new Date(min) : min;
if (dateValue < minDate) {
return invalid(`Date must be on or after ${minDate.toLocaleDateString()}`, 'date.min');
}
}
if (max) {
const maxDate = typeof max === 'string' ? new Date(max) : max;
if (dateValue > maxDate) {
return invalid(`Date must be on or before ${maxDate.toLocaleDateString()}`, 'date.max');
}
}
return valid();
};
}
/**
* Validates time input (HH:MM format)
*/
export function time(options: TimeOptions = {}): ValidatorFn {
const { min, max } = options;
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
return (value: string) => {
if (!value) return valid();
if (!timeRegex.test(value)) {
return invalid('Please enter a valid time (HH:MM)', 'time');
}
if (min && value < min) {
return invalid(`Time must be at or after ${min}`, 'time.min');
}
if (max && value > max) {
return invalid(`Time must be at or before ${max}`, 'time.max');
}
return valid();
};
}
/**
* Validates datetime-local input
*/
export function datetimeLocal(options: DateOptions = {}): ValidatorFn {
const { min, max } = options;
return (value: string) => {
if (!value) return valid();
const dateValue = new Date(value);
if (isNaN(dateValue.getTime())) {
return invalid('Please enter a valid date and time', 'datetimeLocal');
}
if (min) {
const minDate = typeof min === 'string' ? new Date(min) : min;
if (dateValue < minDate) {
return invalid('Date and time is too early', 'datetimeLocal.min');
}
}
if (max) {
const maxDate = typeof max === 'string' ? new Date(max) : max;
if (dateValue > maxDate) {
return invalid('Date and time is too late', 'datetimeLocal.max');
}
}
return valid();
};
}
/**
* Validates month input (YYYY-MM format)
*/
export function month(options: DateOptions = {}): ValidatorFn {
const { min, max } = options;
const monthRegex = /^\d{4}-(0[1-9]|1[0-2])$/;
return (value: string) => {
if (!value) return valid();
if (!monthRegex.test(value)) {
return invalid('Please enter a valid month (YYYY-MM)', 'month');
}
if (min && value < min) {
return invalid('Month is too early', 'month.min');
}
if (max && value > max) {
return invalid('Month is too late', 'month.max');
}
return valid();
};
}
/**
* Validates week input (YYYY-Www format)
*/
export function week(options: { min?: string; max?: string } = {}): ValidatorFn {
const { min, max } = options;
const weekRegex = /^\d{4}-W(0[1-9]|[1-4][0-9]|5[0-3])$/;
return (value: string) => {
if (!value) return valid();
if (!weekRegex.test(value)) {
return invalid('Please enter a valid week (YYYY-Www)', 'week');
}
if (min && value < min) {
return invalid('Week is too early', 'week.min');
}
if (max && value > max) {
return invalid('Week is too late', 'week.max');
}
return valid();
};
}
/**
* Validates color input (hex format)
*/
export function color(message = 'Please enter a valid color'): ValidatorFn {
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
return (value: string) => {
if (!value) return valid();
if (!hexRegex.test(value)) {
return invalid(message, 'color');
}
return valid();
};
}
/**
* Validates search input (just length constraints)
*/
export function search(options: { minLength?: number; maxLength?: number } = {}): ValidatorFn {
const { minLength: min = 0, maxLength: max } = options;
return (value: string) => {
if (!value) return valid();
if (value.length < min) {
return invalid(`Search must be at least ${min} characters`, 'search.minLength');
}
if (max && value.length > max) {
return invalid(`Search must be at most ${max} characters`, 'search.maxLength');
}
return valid();
};
}
/**
* Calculate password strength
*/
export function getPasswordStrength(password: string): PasswordStrength {
const feedback: string[] = [];
let score = 0;
if (password.length >= 8) score++;
else feedback.push('Use at least 8 characters');
if (password.length >= 12) score++;
if (/[a-z]/.test(password)) score += 0.5;
else feedback.push('Add lowercase letters');
if (/[A-Z]/.test(password)) score += 0.5;
else feedback.push('Add uppercase letters');
if (/\d/.test(password)) score += 0.5;
else feedback.push('Add numbers');
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score += 0.5;
else feedback.push('Add special characters');
// Penalize common patterns
if (/^[a-zA-Z]+$/.test(password)) score -= 0.5;
if (/^[0-9]+$/.test(password)) score -= 0.5;
if (/(.)\1{2,}/.test(password)) {
score -= 0.5;
feedback.push('Avoid repeated characters');
}
score = Math.max(0, Math.min(4, Math.round(score)));
const labels: PasswordStrength['label'][] = ['weak', 'fair', 'good', 'strong', 'very-strong'];
return {
score,
label: labels[score],
feedback,
};
}
/**
* Validates password strength
*/
export function password(options: PasswordOptions = {}): ValidatorFn {
const {
minLength: minLen = 8,
requireUppercase = true,
requireLowercase = true,
requireNumbers = true,
requireSymbols = true,
minScore = 2,
} = options;
return (value: string) => {
if (!value) return valid();
if (value.length < minLen) {
return invalid(`Password must be at least ${minLen} characters`, 'password.minLength');
}
if (requireLowercase && !/[a-z]/.test(value)) {
return invalid('Password must contain a lowercase letter', 'password.lowercase');
}
if (requireUppercase && !/[A-Z]/.test(value)) {
return invalid('Password must contain an uppercase letter', 'password.uppercase');
}
if (requireNumbers && !/\d/.test(value)) {
return invalid('Password must contain a number', 'password.number');
}
if (requireSymbols && !/[!@#$%^&*(),.?":{}|<>]/.test(value)) {
return invalid('Password must contain a special character', 'password.symbol');
}
const strength = getPasswordStrength(value);
if (strength.score < minScore) {
return invalid(`Password is too weak. ${strength.feedback[0] || ''}`, 'password.weak');
}
return valid();
};
}
/**
* Validates file input
*/
export function file(options: FileOptions = {}): ValidatorFn {
const { accept, maxSize, maxFiles = 1 } = options;
return (value: string) => {
// File validation is typically done on the File object, not string value
// This validator checks if a file was selected (value is filename)
if (!value) return valid();
// For client-side, we'd need access to the actual File object
// This is a basic filename check
if (accept && accept.length > 0) {
const ext = '.' + value.split('.').pop()?.toLowerCase();
const hasValidExt = accept.some((a) => {
if (a.startsWith('.')) return a.toLowerCase() === ext;
// MIME type matching would require actual file access
return true;
});
if (!hasValidExt) {
return invalid(`File type not allowed. Accepted: ${accept.join(', ')}`, 'file.type');
}
}
return valid();
};
}
/**
* Validates checkbox (checked state)
*/
export function checkbox(options: { required?: boolean } = {}): ValidatorFn {
const { required: isRequired = false } = options;
return (value: string) => {
// Checkbox value is typically "true"/"false" or "on"/""
const isChecked = value === 'true' || value === 'on' || value === '1';
if (isRequired && !isChecked) {
return invalid('This checkbox is required', 'checkbox.required');
}
return valid();
};
}
/**
* Validates radio button selection
*/
export function radio(options: { required?: boolean } = {}): ValidatorFn {
const { required: isRequired = false } = options;
return (value: string) => {
if (isRequired && !value) {
return invalid('Please select an option', 'radio.required');
}
return valid();
};
}

View File

@@ -0,0 +1,56 @@
// Format validators
export {
required,
minLength,
maxLength,
pattern,
matches,
email,
url,
tel,
number,
range,
date,
time,
datetimeLocal,
month,
week,
color,
search,
password,
getPasswordStrength,
file,
checkbox,
radio,
} from './format';
// Security validators
export {
noInjection,
noSqlInjection,
noScriptInjection,
noHtmlInjection,
noPromptInjection,
detectInjectionType,
SQL_INJECTION_PATTERNS,
SCRIPT_INJECTION_PATTERNS,
HTML_INJECTION_PATTERNS,
PROMPT_INJECTION_PATTERNS,
} from './security';
// Address validators
export {
postalCode,
europeanPostalCode,
city,
streetAddress,
houseNumber,
europeanCountry,
europeanAddress,
getCountryName,
getPostalCodeHint,
POSTAL_CODE_PATTERNS,
EU_COUNTRIES,
EEA_COUNTRIES,
EUROPEAN_COUNTRIES,
} from './address';

View File

@@ -0,0 +1,379 @@
import type { ValidatorFn, ValidationResult, SecurityValidationOptions } from '../types';
// Helper to create a validation result
const valid = (): ValidationResult => ({ valid: true });
const invalid = (message: string, code?: string): ValidationResult => ({
valid: false,
message,
code,
});
// ============================================================================
// SQL Injection Patterns
// ============================================================================
export const SQL_INJECTION_PATTERNS: RegExp[] = [
// Basic SQL keywords with suspicious context
/('\s*(OR|AND)\s*'?\s*['"=])/i,
/(--\s*$|--\s+)/,
/(;\s*(DROP|DELETE|UPDATE|INSERT|ALTER|CREATE|TRUNCATE))/i,
/(\bUNION\b.*\bSELECT\b)/i,
/(\/\*.*\*\/)/,
// Common injection payloads
/('\s*OR\s+'?1'?\s*=\s*'?1)/i,
/('\s*OR\s+''=')/i,
/(admin'--)/i,
/('\s*;\s*--)/i,
// Encoded variations
/(0x[0-9a-fA-F]+)/,
/(CHAR\s*\(\s*\d+\s*\))/i,
/(CONCAT\s*\()/i,
// Boolean-based
/('\s*AND\s+\d+\s*=\s*\d+)/i,
/(BENCHMARK\s*\()/i,
/(SLEEP\s*\()/i,
// Stacked queries
/(;\s*SELECT\s+)/i,
/(;\s*EXEC\s+)/i,
/(xp_cmdshell)/i,
];
// ============================================================================
// Script/XSS Injection Patterns
// ============================================================================
export const SCRIPT_INJECTION_PATTERNS: RegExp[] = [
// Script tags
/<script\b[^>]*>/i,
/<\/script>/i,
// Event handlers
/\bon\w+\s*=/i, // onclick=, onerror=, onload=, etc.
// JavaScript protocol
/javascript\s*:/i,
/vbscript\s*:/i,
/data\s*:\s*text\/html/i,
// Expression and eval
/expression\s*\(/i,
/eval\s*\(/i,
/Function\s*\(/i,
// DOM manipulation
/(document\s*\.\s*(cookie|location|write))/i,
/(window\s*\.\s*(location|open))/i,
/(innerHTML\s*=)/i,
/(outerHTML\s*=)/i,
// Base64 encoded scripts
/(atob\s*\()/i,
/(btoa\s*\()/i,
// SVG-based XSS
/<svg\b[^>]*\bon\w+/i,
/<img\b[^>]*\bon\w+/i,
/<iframe\b/i,
/<embed\b/i,
/<object\b/i,
// CSS-based attacks
/(url\s*\(\s*['"]?javascript)/i,
/(behavior\s*:)/i,
/(-moz-binding\s*:)/i,
];
// ============================================================================
// HTML Injection Patterns (dangerous tags)
// ============================================================================
export const HTML_INJECTION_PATTERNS: RegExp[] = [
/<script\b/i,
/<iframe\b/i,
/<embed\b/i,
/<object\b/i,
/<form\b/i,
/<input\b[^>]*type\s*=\s*['"]?hidden/i,
/<link\b/i,
/<meta\b/i,
/<base\b/i,
/<style\b/i,
];
// ============================================================================
// AI Prompt Injection Patterns
// ============================================================================
export const PROMPT_INJECTION_PATTERNS: RegExp[] = [
// Direct instruction overrides
/ignore\s+(all\s+)?previous\s+instructions?/i,
/forget\s+(all\s+)?previous\s+instructions?/i,
/disregard\s+(all\s+)?previous\s+instructions?/i,
/override\s+(all\s+)?previous\s+instructions?/i,
/bypass\s+(all\s+)?previous\s+instructions?/i,
/skip\s+(all\s+)?previous\s+instructions?/i,
/do\s+not\s+follow\s+(previous\s+)?instructions?/i,
// System prompt markers
/system\s*:/i,
/\[system\]/i,
/<<\s*system\s*>>/i,
/\{system\}/i,
/\[\[system\]\]/i,
/<system>/i,
/system\s+prompt\s*:/i,
// Role-playing attacks
/you\s+are\s+now\s+(a|an|the)\s+/i,
/pretend\s+(to\s+be|you\s+are|you're)\s+/i,
/act\s+as\s+(a|an|the|if)\s+/i,
/roleplay\s+as\s+/i,
/imagine\s+you\s+are\s+/i,
/from\s+now\s+on\s*,?\s+you\s+are/i,
/behave\s+as\s+(a|an|the|if)\s+/i,
/switch\s+to\s+\w+\s+mode/i,
// Instruction injection
/\[\s*inst(ruction)?\s*\]/i,
/<<\s*inst(ruction)?\s*>>/i,
/\{inst(ruction)?\}/i,
/new\s+instructions?\s*:/i,
/updated?\s+instructions?\s*:/i,
/real\s+instructions?\s*:/i,
/actual\s+instructions?\s*:/i,
/important\s+instructions?\s*:/i,
// Jailbreak attempts
/jailbreak/i,
/\bdan\s+mode\b/i,
/developer\s+mode/i,
/unrestricted\s+mode/i,
/uncensored\s+mode/i,
/god\s+mode/i,
/admin\s+mode/i,
/debug\s+mode/i,
/maintenance\s+mode/i,
/sudo\s+mode/i,
/root\s+access/i,
// Prompt leaking
/reveal\s+(your\s+)?(system\s+)?instructions?/i,
/show\s+(me\s+)?(your\s+)?(system\s+)?prompt/i,
/what\s+(is|are)\s+your\s+(system\s+)?instructions?/i,
/what\s+is\s+your\s+(system\s+)?prompt/i,
/repeat\s+(your\s+)?instructions?/i,
/print\s+(your\s+)?instructions?/i,
/display\s+(your\s+)?instructions?/i,
/output\s+(your\s+)?instructions?/i,
/tell\s+me\s+(your\s+)?(system\s+)?prompt/i,
/leak\s+(your\s+)?prompt/i,
// Context manipulation
/\[context\]/i,
/<<\s*context\s*>>/i,
/\{context\}/i,
/end\s+of\s+(context|prompt|instructions?)/i,
/start\s+of\s+(new\s+)?(context|prompt|instructions?)/i,
/---+\s*(new|actual|real)\s+(context|prompt|instructions?)/i,
/\*\*\*+\s*(new|actual|real)\s+(context|prompt|instructions?)/i,
// Multi-turn manipulation
/^assistant\s*:/im,
/^user\s*:/im,
/^human\s*:/im,
/^ai\s*:/im,
/^bot\s*:/im,
/^claude\s*:/im,
/^gpt\s*:/im,
/^chatgpt\s*:/im,
// Delimiter injection
/```\s*(system|instruction|prompt)/i,
/###\s*(system|instruction|prompt)/i,
/<\|im_start\|>/i,
/<\|im_end\|>/i,
/<\|endoftext\|>/i,
// Ignore safety
/ignore\s+(all\s+)?safety/i,
/bypass\s+(all\s+)?safety/i,
/disable\s+(all\s+)?filters?/i,
/turn\s+off\s+(all\s+)?filters?/i,
/remove\s+(all\s+)?restrictions?/i,
/no\s+restrictions?/i,
/without\s+restrictions?/i,
];
// ============================================================================
// Validators
// ============================================================================
/**
* Validates against SQL injection patterns
*/
export function noSqlInjection(message = 'Input contains potentially unsafe content'): ValidatorFn {
return (value: string) => {
if (!value) return valid();
for (const pattern of SQL_INJECTION_PATTERNS) {
if (pattern.test(value)) {
return invalid(message, 'security.sql');
}
}
return valid();
};
}
/**
* Validates against script/XSS injection patterns
*/
export function noScriptInjection(message = 'Input contains potentially unsafe content'): ValidatorFn {
return (value: string) => {
if (!value) return valid();
for (const pattern of SCRIPT_INJECTION_PATTERNS) {
if (pattern.test(value)) {
return invalid(message, 'security.script');
}
}
return valid();
};
}
/**
* Validates against HTML injection (blocks dangerous tags)
*/
export function noHtmlInjection(options: { allowedTags?: string[] } = {}): ValidatorFn {
const { allowedTags = [] } = options;
const allowedSet = new Set(allowedTags.map((t) => t.toLowerCase()));
return (value: string) => {
if (!value) return valid();
// If no tags allowed, check for any HTML
if (allowedSet.size === 0 && /<[a-z][\s\S]*>/i.test(value)) {
return invalid('HTML is not allowed in this field', 'security.html');
}
// Check specifically dangerous tags
for (const pattern of HTML_INJECTION_PATTERNS) {
if (pattern.test(value)) {
// Extract tag name and check if allowed
const match = value.match(/<(\w+)/);
if (match && !allowedSet.has(match[1].toLowerCase())) {
return invalid('Input contains potentially unsafe HTML', 'security.html');
}
}
}
return valid();
};
}
/**
* Validates against AI prompt injection patterns
*/
export function noPromptInjection(message = 'Input contains potentially unsafe content'): ValidatorFn {
return (value: string) => {
if (!value) return valid();
for (const pattern of PROMPT_INJECTION_PATTERNS) {
if (pattern.test(value)) {
return invalid(message, 'security.prompt');
}
}
return valid();
};
}
/**
* Combined security validator - checks all injection types
*/
export function noInjection(options: SecurityValidationOptions = {}): ValidatorFn {
const {
allowHtml = false,
allowedTags = [],
checkSqlInjection = true,
checkScriptInjection = true,
checkPromptInjection = true,
} = options;
return (value: string) => {
if (!value) return valid();
// SQL injection check
if (checkSqlInjection) {
for (const pattern of SQL_INJECTION_PATTERNS) {
if (pattern.test(value)) {
return invalid('Input contains potentially unsafe content', 'security.sql');
}
}
}
// Script/XSS injection check
if (checkScriptInjection) {
for (const pattern of SCRIPT_INJECTION_PATTERNS) {
if (pattern.test(value)) {
return invalid('Input contains potentially unsafe content', 'security.script');
}
}
}
// HTML injection check
if (!allowHtml) {
const allowedSet = new Set(allowedTags.map((t) => t.toLowerCase()));
for (const pattern of HTML_INJECTION_PATTERNS) {
if (pattern.test(value)) {
const match = value.match(/<(\w+)/);
if (match && !allowedSet.has(match[1].toLowerCase())) {
return invalid('Input contains potentially unsafe HTML', 'security.html');
}
}
}
}
// Prompt injection check
if (checkPromptInjection) {
for (const pattern of PROMPT_INJECTION_PATTERNS) {
if (pattern.test(value)) {
return invalid('Input contains potentially unsafe content', 'security.prompt');
}
}
}
return valid();
};
}
/**
* Utility to check if a value contains any injection pattern
* Returns the type of injection detected or null if safe
*/
export function detectInjectionType(value: string): 'sql' | 'script' | 'html' | 'prompt' | null {
if (!value) return null;
for (const pattern of SQL_INJECTION_PATTERNS) {
if (pattern.test(value)) return 'sql';
}
for (const pattern of SCRIPT_INJECTION_PATTERNS) {
if (pattern.test(value)) return 'script';
}
for (const pattern of HTML_INJECTION_PATTERNS) {
if (pattern.test(value)) return 'html';
}
for (const pattern of PROMPT_INJECTION_PATTERNS) {
if (pattern.test(value)) return 'prompt';
}
return null;
}