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:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Build output (regenerated by `npm run build`)
|
||||
/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Deps
|
||||
/node_modules/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
1
.next/cache/.previewinfo
vendored
Normal file
1
.next/cache/.previewinfo
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"previewModeId":"ef343e0fd89dc9ab6f08783ff0deef35","previewModeSigningKey":"2fa54fed7d2179894dacc11fb6965700299f5518a972c93780b2ec6a7bb6d0cd","previewModeEncryptionKey":"e6c3fa4101483282f8dd54a0c962f7e435cbc9aa3f389648bda2190fba138959","expireAt":1772117118591}
|
||||
1
.next/cache/.rscinfo
vendored
Normal file
1
.next/cache/.rscinfo
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"encryption.key":"rKSSp1jGbcTNnxCyUFDzQEDXMxC3YEQ2G/LDRFhRESE=","encryption.expire_at":1772117118521}
|
||||
6
.next/diagnostics/build-diagnostics.json
Normal file
6
.next/diagnostics/build-diagnostics.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"buildStage": "compile",
|
||||
"buildOptions": {
|
||||
"useBuildWorker": "true"
|
||||
}
|
||||
}
|
||||
1
.next/diagnostics/framework.json
Normal file
1
.next/diagnostics/framework.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"Next.js","version":"16.1.6"}
|
||||
1
.next/package.json
Normal file
1
.next/package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"type": "commonjs"}
|
||||
1
.next/trace
Normal file
1
.next/trace
Normal file
@@ -0,0 +1 @@
|
||||
[{"name":"generate-buildid","duration":378,"timestamp":2264145597921,"id":4,"parentId":1,"tags":{},"startTime":1770907518509,"traceId":"40900b3762bb777b"},{"name":"load-custom-routes","duration":863,"timestamp":2264145598524,"id":5,"parentId":1,"tags":{},"startTime":1770907518509,"traceId":"40900b3762bb777b"},{"name":"create-dist-dir","duration":716,"timestamp":2264145599441,"id":6,"parentId":1,"tags":{},"startTime":1770907518510,"traceId":"40900b3762bb777b"},{"name":"clean","duration":857,"timestamp":2264145601721,"id":7,"parentId":1,"tags":{},"startTime":1770907518513,"traceId":"40900b3762bb777b"},{"name":"collect-pages","duration":2777,"timestamp":2264145629026,"id":8,"parentId":1,"tags":{},"startTime":1770907518540,"traceId":"40900b3762bb777b"},{"name":"create-pages-mapping","duration":843,"timestamp":2264145680225,"id":9,"parentId":1,"tags":{},"startTime":1770907518591,"traceId":"40900b3762bb777b"},{"name":"generate-route-types","duration":16384,"timestamp":2264145681474,"id":10,"parentId":1,"tags":{},"startTime":1770907518592,"traceId":"40900b3762bb777b"},{"name":"public-dir-conflict-check","duration":1373,"timestamp":2264145698029,"id":11,"parentId":1,"tags":{},"startTime":1770907518609,"traceId":"40900b3762bb777b"},{"name":"generate-routes-manifest","duration":2758,"timestamp":2264145699647,"id":12,"parentId":1,"tags":{},"startTime":1770907518610,"traceId":"40900b3762bb777b"},{"name":"run-typescript","duration":6899691,"timestamp":2264145702908,"id":13,"parentId":1,"tags":{},"startTime":1770907518614,"traceId":"40900b3762bb777b"},{"name":"run-turbopack","duration":433948,"timestamp":2264152608591,"id":15,"parentId":1,"tags":{},"startTime":1770907525519,"traceId":"40900b3762bb777b"},{"name":"next-build","duration":7472002,"timestamp":2264145570565,"id":1,"tags":{"buildMode":"default","version":"16.1.6","bundler":"turbopack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1770907518481,"traceId":"40900b3762bb777b"}]
|
||||
1
.next/trace-build
Normal file
1
.next/trace-build
Normal file
@@ -0,0 +1 @@
|
||||
[{"name":"run-typescript","duration":6899691,"timestamp":2264145702908,"id":13,"parentId":1,"tags":{},"startTime":1770907518614,"traceId":"40900b3762bb777b"},{"name":"run-turbopack","duration":433948,"timestamp":2264152608591,"id":15,"parentId":1,"tags":{},"startTime":1770907525519,"traceId":"40900b3762bb777b"},{"name":"next-build","duration":7472002,"timestamp":2264145570565,"id":1,"tags":{"buildMode":"default","version":"16.1.6","bundler":"turbopack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1770907518481,"traceId":"40900b3762bb777b"}]
|
||||
0
.next/turbopack
Normal file
0
.next/turbopack
Normal file
64
.next/types/routes.d.ts
vendored
Normal file
64
.next/types/routes.d.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
// This file is generated automatically by Next.js
|
||||
// Do not edit this file manually
|
||||
|
||||
type AppRoutes = never
|
||||
type PageRoutes = "/" | "/Auth" | "/Chat" | "/Error" | "/Invoice" | "/Mail" | "/Search" | "/TaskManager" | "/UserProfile"
|
||||
type LayoutRoutes = never
|
||||
type RedirectRoutes = never
|
||||
type RewriteRoutes = never
|
||||
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes
|
||||
|
||||
|
||||
interface ParamMap {
|
||||
"/": {}
|
||||
"/Auth": {}
|
||||
"/Chat": {}
|
||||
"/Error": {}
|
||||
"/Invoice": {}
|
||||
"/Mail": {}
|
||||
"/Search": {}
|
||||
"/TaskManager": {}
|
||||
"/UserProfile": {}
|
||||
}
|
||||
|
||||
|
||||
export type ParamsOf<Route extends Routes> = ParamMap[Route]
|
||||
|
||||
interface LayoutSlotMap {
|
||||
}
|
||||
|
||||
|
||||
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap }
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Props for Next.js App Router page components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Page(props: PageProps<'/blog/[slug]'>) {
|
||||
* const { slug } = await props.params
|
||||
* return <div>Blog post: {slug}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface PageProps<AppRoute extends AppRoutes> {
|
||||
params: Promise<ParamMap[AppRoute]>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for Next.js App Router layout components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Layout(props: LayoutProps<'/dashboard'>) {
|
||||
* return <div>{props.children}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
|
||||
params: Promise<ParamMap[LayoutRoute]>
|
||||
children: React.ReactNode
|
||||
} & {
|
||||
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
|
||||
}
|
||||
}
|
||||
112
.next/types/validator.ts
Normal file
112
.next/types/validator.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// This file is generated automatically by Next.js
|
||||
// Do not edit this file manually
|
||||
// This file validates that all pages and layouts export the correct types
|
||||
|
||||
|
||||
|
||||
type PagesPageConfig = {
|
||||
default: React.ComponentType<any> | ((props: any) => React.ReactNode | Promise<React.ReactNode> | never | void)
|
||||
getStaticProps?: (context: any) => Promise<any> | any
|
||||
getStaticPaths?: (context: any) => Promise<any> | any
|
||||
getServerSideProps?: (context: any) => Promise<any> | any
|
||||
getInitialProps?: (context: any) => Promise<any> | any
|
||||
/**
|
||||
* Segment configuration for legacy Pages Router pages.
|
||||
* Validated at build-time by parsePagesSegmentConfig.
|
||||
*/
|
||||
config?: {
|
||||
maxDuration?: number
|
||||
runtime?: 'edge' | 'experimental-edge' | 'nodejs' | string // necessary unless config is exported as const
|
||||
regions?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Validate ../../src/pages/Auth.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends PagesPageConfig> = Specific
|
||||
const handler = {} as typeof import("../../src/pages/Auth.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../src/pages/Chat.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends PagesPageConfig> = Specific
|
||||
const handler = {} as typeof import("../../src/pages/Chat.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../src/pages/Error.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends PagesPageConfig> = Specific
|
||||
const handler = {} as typeof import("../../src/pages/Error.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../src/pages/Invoice.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends PagesPageConfig> = Specific
|
||||
const handler = {} as typeof import("../../src/pages/Invoice.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../src/pages/Mail.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends PagesPageConfig> = Specific
|
||||
const handler = {} as typeof import("../../src/pages/Mail.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../src/pages/Search.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends PagesPageConfig> = Specific
|
||||
const handler = {} as typeof import("../../src/pages/Search.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../src/pages/TaskManager.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends PagesPageConfig> = Specific
|
||||
const handler = {} as typeof import("../../src/pages/TaskManager.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../src/pages/UserProfile.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends PagesPageConfig> = Specific
|
||||
const handler = {} as typeof import("../../src/pages/UserProfile.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../src/pages/index.ts
|
||||
{
|
||||
type __IsExpected<Specific extends PagesPageConfig> = Specific
|
||||
const handler = {} as typeof import("../../src/pages/index.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
164
README.md
Normal file
164
README.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# @limitless/ui
|
||||
|
||||
Limitless Layout 3 (Detached Layout) inspired Bootstrap 5 React UI kit for Remix and Next.js. No jQuery, all components are React-first with Bootstrap-compatible markup and a thin theming layer (light/dark/material).
|
||||
|
||||
## Layout 3 Structure
|
||||
|
||||
This framework implements the **Layout 3 (Detached Layout)** from Limitless template:
|
||||
|
||||
```
|
||||
├── Navbar (full width, top)
|
||||
├── PageHeader (outside page-content)
|
||||
│ ├── breadcrumb-line
|
||||
│ └── page-header-content
|
||||
├── page-content pt-0
|
||||
│ ├── Main Sidebar (detached, left)
|
||||
│ ├── Secondary Sidebar (optional)
|
||||
│ ├── content-wrapper
|
||||
│ │ └── content
|
||||
│ └── Right Sidebar (optional)
|
||||
└── Footer (outside page-content, bottom)
|
||||
```
|
||||
|
||||
Key characteristics:
|
||||
- Sidebars appear as detached stand-alone components with shadows
|
||||
- Page header is placed outside page-content container
|
||||
- Footer is at the very bottom, outside page-content
|
||||
- Supports material theme styling with user menu
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npm install @limitless/ui bootstrap
|
||||
```
|
||||
|
||||
In your app entry (e.g., `root.tsx` in Remix, `_app.tsx` in Next):
|
||||
|
||||
```tsx
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import '@limitless/ui/dist/styles.css';
|
||||
|
||||
import { ThemeProvider, PageShell, Navbar, Sidebar, PageHeader, Footer } from '@limitless/ui';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider theme="light">
|
||||
<PageShell
|
||||
navbar={
|
||||
<Navbar
|
||||
brand={<img src="/logo.png" alt="Logo" />}
|
||||
endItems={/* user menu, notifications */}
|
||||
/>
|
||||
}
|
||||
pageHeader={
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
breadcrumbs={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Dashboard', active: true }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
mainSidebar={
|
||||
<Sidebar
|
||||
variant="main"
|
||||
color="light"
|
||||
items={[
|
||||
{ type: 'header', label: 'Main' },
|
||||
{ label: 'Dashboard', href: '/', active: true },
|
||||
{ label: 'Settings', href: '/settings' }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<Footer
|
||||
copyright="2024 Your Company"
|
||||
navItems={[{ label: 'Support', href: '/support' }]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Page content */}
|
||||
</PageShell>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Layout Components
|
||||
|
||||
- **PageShell**: Main layout wrapper implementing Layout 3 structure
|
||||
- **Navbar**: Top navigation bar with mobile togglers and user menu support
|
||||
- **Sidebar**: Detached sidebar with nav-sidebar navigation, user menu, submenus
|
||||
- **Footer**: Bottom footer bar with collapsible navigation
|
||||
- **PageHeader**: Page header with breadcrumb-line and header-elements
|
||||
|
||||
### UI Components
|
||||
|
||||
- **Card**: Cards with header-elements-inline support
|
||||
- **Button, Badge, Alert**: Bootstrap-compatible basic elements
|
||||
- **Tabs, Accordion, Collapse**: Interactive content panels
|
||||
- **Modal, Toast, Tooltip, Popover**: Overlays and notifications
|
||||
- **Table, DataTable**: Tables with TanStack support
|
||||
- **Form components**: FormGroup, FormControl, FormCheck, InputGroup, Select
|
||||
- **Advanced inputs**: DatePicker, ColorPicker, AdvancedSelect, DualListBox
|
||||
- **Wizard**: Multi-step form wizard
|
||||
|
||||
## Sidebar Navigation
|
||||
|
||||
The Sidebar component supports Layout 3 navigation structure:
|
||||
|
||||
```tsx
|
||||
<Sidebar
|
||||
variant="main"
|
||||
color="light"
|
||||
user={{
|
||||
name: 'John Doe',
|
||||
avatar: '/avatar.jpg',
|
||||
subtitle: 'Administrator',
|
||||
menuItems: [
|
||||
{ label: 'Profile', href: '/profile' },
|
||||
{ label: 'Logout', href: '/logout' }
|
||||
]
|
||||
}}
|
||||
items={[
|
||||
{ type: 'header', label: 'Navigation' },
|
||||
{ label: 'Dashboard', icon: <i className="icon-home4" />, href: '/', active: true },
|
||||
{
|
||||
type: 'submenu',
|
||||
label: 'Settings',
|
||||
icon: <i className="icon-cog" />,
|
||||
children: [
|
||||
{ label: 'General', href: '/settings/general' },
|
||||
{ label: 'Security', href: '/settings/security' }
|
||||
]
|
||||
},
|
||||
{ type: 'divider' }
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/theme`: theming utilities, CSS variables, `ThemeProvider`
|
||||
- `src/layout`: Layout 3 components (PageShell, Navbar, Sidebar, Footer)
|
||||
- `src/components`: Bootstrap-compatible UI components
|
||||
- `src/hooks`: shared hooks (disclosure/toggles)
|
||||
- `src/styles.css`: Layout 3 CSS including sidebar, navbar, page-header styles
|
||||
|
||||
## Conventions
|
||||
|
||||
- Bootstrap 5 tokens/classes, plus Limitless Layout 3 specific classes
|
||||
- React-first: no jQuery. Interactive behaviors use hooks or modern React
|
||||
- SSR-friendly: side effects live inside `useEffect`
|
||||
- TypeScript: full type definitions included
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
This repo uses `tsc` output. Consumers should tree-shake via their bundler.
|
||||
26
docs/MIGRATION_STATUS.md
Normal file
26
docs/MIGRATION_STATUS.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Migration Status
|
||||
|
||||
Tracking migration from Limitless (Bootstrap 4 + jQuery) to React + Bootstrap 5 (no jQuery).
|
||||
|
||||
Legend: Pending (not started), In-Progress, Done.
|
||||
|
||||
| Area | Components/Pages | Status | Notes / Replacement libs |
|
||||
| --- | --- | --- | --- |
|
||||
| Layout & Navigation | `layout_*`, `navbar_*`, `sidebar_*`, `navigation_*`, `content_page_header` | Pending | Rebuild with BS5, offcanvas for mobile, floating-ui for tooltips/popovers if needed |
|
||||
| Core UI | `components_*` (alerts, buttons, dropdowns, modals, navs/tabs/pills, pagination, progress, breadcrumbs, badges, media, scrollspy) | In-Progress | Added (`Button`, `Alert`, `Badge`, `Breadcrumbs`, `Card`, `Tabs`, `Collapse`, `Table`, `Dropdown`, `Pagination`, `Progress`, `ProgressStacked`, `Modal`, `ListGroup`, `PageHeader`, `Accordion`, `Nav`, `Tooltip`, `Popover`, `Toast`, `Media`, `Scrollspy`) |
|
||||
| Cards & Content | `content_cards*`, `content_helpers*`, `typography`, `syntax` | Pending | Map to BS5 cards + utility wrappers |
|
||||
| Forms (base) | `form_inputs*`, `form_input_groups`, `form_floating_labels`, `form_actions`, `checkboxes_radios`, `validation` | In-Progress | Added `FormGroup`, `FormControl`, `FormCheck`, `InputGroup`, `Select` |
|
||||
| Forms (advanced) | `form_select2`, `form_multiselect`, `form_tag_inputs`, `form_dual_listboxes`, `form_wizard` | In-Progress | Added `SelectSingle`, `MultiSelect`, `TagsSelect`, `AsyncSelect` (react-select); `DatePicker` (react-day-picker); `ColorPicker` (react-colorful); `Wizard`; `DualListBox` |
|
||||
| Tables | `table_*` | Pending | Headless BS5 tables |
|
||||
| Data grid | `datatable_*` | Pending | Replace with TanStack Table (sorting/filtering/pagination/virtual) |
|
||||
| Data grid (basic) | `datatable_*` | In-Progress | Added `DataTable` (TanStack-based) with sorting + simple pagination |
|
||||
| Charts | `d3_*`, `c3_*`, `echarts_*`, `google_*` | Pending | Prefer ECharts + D3 hooks; C3 optional via Recharts/D3 |
|
||||
| Calendars | `fullcalendar_*` | Pending | Use `@fullcalendar/react` |
|
||||
| Pickers | `picker_date`, `picker_color`, `picker_location` | Pending | `react-day-picker`/`react-date-range`, `react-colorful`, map autocomplete |
|
||||
| Uploaders | `uploader_*` | Pending | `react-dropzone`; `uppy` for advanced flows |
|
||||
| Editors | `editor_*` | Pending | CKEditor 5 React, TipTap/Quill, CodeMirror 6 |
|
||||
| Notifications/Dialogs | `extra_pnotify`, `extra_jgrowl_noty`, `extra_sweetalert` | Pending | `react-hot-toast`/`notistack`, `sweetalert2-react-content` |
|
||||
| Extensions | `extension_*`, `extra_*`, `animations_*` | Pending | BlockUI overlay, dnd-kit, react-easy-crop, context menu, rc-slider/noui, tree via react-sortable-tree-lite, idle/session hooks |
|
||||
| Widgets/Pages | dashboards, auth, errors, blog, ecommerce, mail, chat, task manager, job board, learning, user profiles, gallery, search, timelines, invoices, sitemap, FAQ/feed/knowledgebase, colors | Pending | Ship as example compositions/templates |
|
||||
| Maps | `maps_google_*`, `maps_vector` | Pending | `@react-google-maps/api`, `react-simple-maps`/`react-svg-map` |
|
||||
| Internationalization | `internationalization_*` | Pending | `react-i18next` examples |
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
1063
package-lock.json
generated
Normal file
1063
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
package.json
Normal file
62
package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@limitless/ui",
|
||||
"version": "0.1.0",
|
||||
"description": "Limitless-inspired Bootstrap 5 React UI kit (no jQuery), ready for Next.js/Remix.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./css": "./dist/styles.css",
|
||||
"./validation": {
|
||||
"import": "./dist/validation/index.js",
|
||||
"types": "./dist/validation/index.d.ts"
|
||||
},
|
||||
"./validation/server": {
|
||||
"import": "./dist/validation/server/index.js",
|
||||
"types": "./dist/validation/server/index.d.ts"
|
||||
},
|
||||
"./server": {
|
||||
"import": "./dist/server/index.js",
|
||||
"types": "./dist/server/index.d.ts"
|
||||
},
|
||||
"./genui/content": {
|
||||
"import": "./dist/genui/content.js",
|
||||
"types": "./dist/genui/content.d.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.24.8",
|
||||
"nodemailer": "^6.9.0",
|
||||
"@tanstack/react-table": "^8.13.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^9.0.8",
|
||||
"react-select": "^5.8.0"
|
||||
},
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json --noEmit false",
|
||||
"postbuild": "node ./scripts/postbuild.cjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bootstrap": "^5.3.3",
|
||||
"react": "^18.2.0 || ^19.0.0",
|
||||
"react-dom": "^18.2.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.55.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
66
scripts/postbuild.cjs
Normal file
66
scripts/postbuild.cjs
Normal file
@@ -0,0 +1,66 @@
|
||||
/* Post-build: copy CSS and fix ESM import extensions */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const distDir = path.join(__dirname, '..', 'dist');
|
||||
|
||||
// Copy CSS
|
||||
const cssSrc = path.join(__dirname, '..', 'src', 'styles.css');
|
||||
const cssDest = path.join(distDir, 'styles.css');
|
||||
fs.copyFileSync(cssSrc, cssDest);
|
||||
|
||||
// Fix ESM imports by adding .js extensions
|
||||
function fixImports(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
fixImports(fullPath);
|
||||
} else if (entry.name.endsWith('.js')) {
|
||||
let content = fs.readFileSync(fullPath, 'utf8');
|
||||
|
||||
// Function to determine the correct extension
|
||||
const addExtension = (match, prefix, importPath, quote) => {
|
||||
// Check if the import path already has .js extension
|
||||
if (importPath.endsWith('.js')) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Check if this is a directory with an index.js
|
||||
const baseDir = path.dirname(fullPath);
|
||||
const targetPath = path.resolve(baseDir, importPath);
|
||||
|
||||
// Check if it's a directory with index.js
|
||||
if (fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()) {
|
||||
if (fs.existsSync(path.join(targetPath, 'index.js'))) {
|
||||
return `${prefix}${importPath}/index.js${quote}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if adding .js makes it a valid file
|
||||
if (fs.existsSync(targetPath + '.js')) {
|
||||
return `${prefix}${importPath}.js${quote}`;
|
||||
}
|
||||
|
||||
return match;
|
||||
};
|
||||
|
||||
// Fix 'from' imports
|
||||
content = content.replace(
|
||||
/(from\s+['"])(\.\.?\/[^'"]+)(['"])/g,
|
||||
addExtension
|
||||
);
|
||||
|
||||
// Fix 'export * from' statements
|
||||
content = content.replace(
|
||||
/(export\s+\*\s+from\s+['"])(\.\.?\/[^'"]+)(['"])/g,
|
||||
addExtension
|
||||
);
|
||||
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fixImports(distDir);
|
||||
console.log('Post-build: CSS copied and ESM imports fixed');
|
||||
61
src/components/Accordion.tsx
Normal file
61
src/components/Accordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/AdvancedSelect.tsx
Normal file
31
src/components/AdvancedSelect.tsx
Normal 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
49
src/components/Alert.tsx
Normal 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
26
src/components/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/Breadcrumbs.tsx
Normal file
33
src/components/Breadcrumbs.tsx
Normal 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
42
src/components/Button.tsx
Normal 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
781
src/components/Calendar.tsx
Normal 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
91
src/components/Card.tsx
Normal 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
213
src/components/Carousel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
src/components/Collapse.tsx
Normal file
19
src/components/Collapse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/ColorPicker.tsx
Normal file
23
src/components/ColorPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
376
src/components/ContextMenu.tsx
Normal file
376
src/components/ContextMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
106
src/components/DataTable.tsx
Normal file
106
src/components/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/DatePicker.tsx
Normal file
12
src/components/DatePicker.tsx
Normal 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} />;
|
||||
}
|
||||
72
src/components/Dropdown.tsx
Normal file
72
src/components/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/DualListBox.tsx
Normal file
85
src/components/DualListBox.tsx
Normal 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}>
|
||||
>
|
||||
</Button>
|
||||
<Button variant="primary" onClick={moveLeft} disabled={rightSel.length === 0}>
|
||||
<
|
||||
</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
440
src/components/Embed.tsx
Normal 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
297
src/components/FAB.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
472
src/components/FileUpload.tsx
Normal file
472
src/components/FileUpload.tsx
Normal 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"
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ll-file-action ll-file-action-remove"
|
||||
onClick={() => handleRemove(file)}
|
||||
aria-label="Remove file"
|
||||
>
|
||||
×
|
||||
</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
164
src/components/Form.tsx
Normal 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
484
src/components/Gallery.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
463
src/components/IdleTimeout.tsx
Normal file
463
src/components/IdleTimeout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
442
src/components/ImageCropper.tsx
Normal file
442
src/components/ImageCropper.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
61
src/components/ListGroup.tsx
Normal file
61
src/components/ListGroup.tsx
Normal 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
25
src/components/Media.tsx
Normal 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
66
src/components/Modal.tsx
Normal 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
60
src/components/Nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
353
src/components/Notification.tsx
Normal file
353
src/components/Notification.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</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);
|
||||
},
|
||||
};
|
||||
208
src/components/Offcanvas.tsx
Normal file
208
src/components/Offcanvas.tsx
Normal 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">×</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>
|
||||
);
|
||||
100
src/components/PageHeader.tsx
Normal file
100
src/components/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/Pagination.tsx
Normal file
47
src/components/Pagination.tsx
Normal 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
232
src/components/Pills.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
76
src/components/Popover.tsx
Normal file
76
src/components/Popover.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
src/components/Progress.tsx
Normal file
50
src/components/Progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/ProgressStacked.tsx
Normal file
38
src/components/ProgressStacked.tsx
Normal 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
231
src/components/Rating.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
52
src/components/Scrollspy.tsx
Normal file
52
src/components/Scrollspy.tsx
Normal 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
518
src/components/Slider.tsx
Normal 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
529
src/components/Sortable.tsx
Normal 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
130
src/components/Spinner.tsx
Normal 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
285
src/components/Stepper.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
360
src/components/SweetAlert.tsx
Normal file
360
src/components/SweetAlert.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</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: {} });
|
||||
}
|
||||
};
|
||||
501
src/components/SyntaxHighlighter.tsx
Normal file
501
src/components/SyntaxHighlighter.tsx
Normal 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
31
src/components/Table.tsx
Normal 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
93
src/components/Tabs.tsx
Normal 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
460
src/components/TagInput.tsx
Normal 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}`}
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
223
src/components/Timeline.tsx
Normal file
223
src/components/Timeline.tsx
Normal 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
38
src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/Tooltip.tsx
Normal file
69
src/components/Tooltip.tsx
Normal 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
344
src/components/TreeView.tsx
Normal 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
577
src/components/Widget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
835
src/components/Widget/index.tsx
Normal file
835
src/components/Widget/index.tsx
Normal 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
92
src/components/Wizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/genui/components/Message.tsx
Normal file
118
src/genui/components/Message.tsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 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;
|
||||
285
src/genui/components/SearchResults.tsx
Normal file
285
src/genui/components/SearchResults.tsx
Normal 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;
|
||||
8
src/genui/components/index.ts
Normal file
8
src/genui/components/index.ts
Normal 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
191
src/genui/content.tsx
Normal 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
8
src/genui/hooks/index.ts
Normal 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
218
src/genui/hooks/useGenUI.ts
Normal 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;
|
||||
100
src/genui/hooks/useWebMCP.ts
Normal file
100
src/genui/hooks/useWebMCP.ts
Normal 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
20
src/genui/index.ts
Normal 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
219
src/genui/types.ts
Normal 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
160
src/genui/webmcp/index.ts
Normal 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
189
src/genui/webmcp/schemas.ts
Normal 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,
|
||||
};
|
||||
11
src/hooks/useDisclosure.ts
Normal file
11
src/hooks/useDisclosure.ts
Normal 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
84
src/index.ts
Normal 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
349
src/layout/AppShell.tsx
Normal 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
76
src/layout/Footer.tsx
Normal 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
177
src/layout/Navbar.tsx
Normal 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
73
src/layout/PageShell.tsx
Normal 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
319
src/layout/Sidebar.tsx
Normal 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
973
src/pages/Auth.tsx
Normal 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>© {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
753
src/pages/Chat.tsx
Normal 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
595
src/pages/Error.tsx
Normal 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
1010
src/pages/Invoice.tsx
Normal file
File diff suppressed because it is too large
Load Diff
813
src/pages/Mail.tsx
Normal file
813
src/pages/Mail.tsx
Normal 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><{message.from.email}></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
982
src/pages/Search.tsx
Normal 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
927
src/pages/TaskManager.tsx
Normal 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
858
src/pages/UserProfile.tsx
Normal 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
9
src/pages/index.ts
Normal 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
135
src/server/crypto.ts
Normal 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
199
src/server/email.ts
Normal 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
19
src/server/index.ts
Normal 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';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user