Spaces:
Running
Running
| /** | |
| * Preact Component Library (No Build) | |
| * Reusable components for DocuPDF templates | |
| * Import via ES modules from templates | |
| */ | |
| const { h, Fragment } = window.PreactLib || {}; | |
| const { useState, useEffect, useCallback, useMemo } = window.PreactLib || {}; | |
| // === Button Component === | |
| export function Button({ | |
| children, | |
| variant = 'primary', | |
| size = '', | |
| pill = false, | |
| disabled = false, | |
| loading = false, | |
| icon = null, | |
| onClick, | |
| className = '', | |
| ...props | |
| }) { | |
| const html = window.html; | |
| const variantClasses = { | |
| primary: 'btn-primary', | |
| secondary: 'btn-secondary', | |
| success: 'btn-success', | |
| danger: 'btn-danger', | |
| warning: 'btn-warning', | |
| info: 'btn-info', | |
| light: 'btn-light', | |
| dark: 'btn-dark', | |
| outline: 'btn-outline-primary', | |
| outlineSuccess: 'btn-outline-success', | |
| outlineDanger: 'btn-outline-danger' | |
| }; | |
| const sizeClasses = { | |
| sm: 'btn-sm', | |
| lg: 'btn-lg' | |
| }; | |
| const classes = [ | |
| 'btn', | |
| variantClasses[variant] || variantClasses.primary, | |
| sizeClasses[size] || '', | |
| pill ? 'btn-pill' : '', | |
| className | |
| ].filter(Boolean).join(' '); | |
| return html` | |
| <button | |
| class=${classes} | |
| disabled=${disabled || loading} | |
| onClick=${onClick} | |
| ...=${props} | |
| > | |
| ${loading ? html`<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>` : ''} | |
| ${icon ? html`<i class="${icon} me-2"></i>` : ''} | |
| ${children} | |
| </button> | |
| `; | |
| } | |
| // === Card Component === | |
| export function Card({ | |
| children, | |
| title = null, | |
| header = null, | |
| footer = null, | |
| className = '', | |
| hoverable = false, | |
| ...props | |
| }) { | |
| const html = window.html; | |
| const classes = [ | |
| 'card', | |
| 'bg-dark', | |
| 'text-white', | |
| hoverable ? 'card-hover' : '', | |
| className | |
| ].filter(Boolean).join(' '); | |
| return html` | |
| <div class=${classes} ...=${props}> | |
| ${(title || header) ? html` | |
| <div class="card-header"> | |
| ${header || html`<h5 class="mb-0">${title}</h5>`} | |
| </div> | |
| ` : ''} | |
| <div class="card-body"> | |
| ${children} | |
| </div> | |
| ${footer ? html`<div class="card-footer">${footer}</div>` : ''} | |
| </div> | |
| `; | |
| } | |
| // === Badge Component === | |
| export function Badge({ | |
| children, | |
| variant = 'secondary', | |
| className = '', | |
| ...props | |
| }) { | |
| const html = window.html; | |
| const variantClasses = { | |
| primary: 'bg-primary', | |
| secondary: 'bg-secondary', | |
| success: 'bg-success', | |
| danger: 'bg-danger', | |
| warning: 'bg-warning text-dark', | |
| info: 'bg-info text-dark', | |
| light: 'bg-light text-dark', | |
| dark: 'bg-dark' | |
| }; | |
| const classes = [ | |
| 'badge', | |
| variantClasses[variant] || variantClasses.secondary, | |
| className | |
| ].filter(Boolean).join(' '); | |
| return html` | |
| <span class=${classes} ...=${props}>${children}</span> | |
| `; | |
| } | |
| // === Alert Component === | |
| export function Alert({ | |
| children, | |
| variant = 'info', | |
| dismissible = false, | |
| onDismiss, | |
| className = '', | |
| ...props | |
| }) { | |
| const html = window.html; | |
| const [visible, setVisible] = useState(true); | |
| const variantClasses = { | |
| primary: 'alert-primary', | |
| secondary: 'alert-secondary', | |
| success: 'alert-success', | |
| danger: 'alert-danger', | |
| warning: 'alert-warning', | |
| info: 'alert-info', | |
| light: 'alert-light', | |
| dark: 'alert-dark' | |
| }; | |
| const classes = [ | |
| 'alert', | |
| variantClasses[variant] || variantClasses.info, | |
| dismissible ? 'alert-dismissible fade show' : '', | |
| className | |
| ].filter(Boolean).join(' '); | |
| if (!visible) return null; | |
| return html` | |
| <div class=${classes} role="alert" ...=${props}> | |
| ${children} | |
| ${dismissible ? html` | |
| <button | |
| type="button" | |
| class="btn-close" | |
| data-bs-dismiss="alert" | |
| aria-label="Close" | |
| onClick=${() => { | |
| setVisible(false); | |
| if (onDismiss) onDismiss(); | |
| }} | |
| ></button> | |
| ` : ''} | |
| </div> | |
| `; | |
| } | |
| // === Form Input Component === | |
| export function FormInput({ | |
| label = null, | |
| type = 'text', | |
| value = '', | |
| onInput, | |
| error = null, | |
| helpText = null, | |
| className = '', | |
| ...props | |
| }) { | |
| const html = window.html; | |
| return html` | |
| <div class="mb-3 ${className}"> | |
| ${label ? html`<label class="form-label">${label}</label>` : ''} | |
| <input | |
| type=${type} | |
| class="form-control ${error ? 'is-invalid' : ''}" | |
| value=${value} | |
| onInput=${onInput} | |
| ...=${props} | |
| /> | |
| ${error ? html`<div class="invalid-feedback">${error}</div>` : ''} | |
| ${helpText && !error ? html`<div class="form-text">${helpText}</div>` : ''} | |
| </div> | |
| `; | |
| } | |
| // === Form Select Component === | |
| export function FormSelect({ | |
| label = null, | |
| value = '', | |
| onChange, | |
| options = [], | |
| placeholder = 'Select...', | |
| error = null, | |
| className = '', | |
| ...props | |
| }) { | |
| const html = window.html; | |
| return html` | |
| <div class="mb-3 ${className}"> | |
| ${label ? html`<label class="form-label">${label}</label>` : ''} | |
| <select | |
| class="form-select ${error ? 'is-invalid' : ''}" | |
| value=${value} | |
| onChange=${onChange} | |
| ...=${props} | |
| > | |
| ${placeholder ? html`<option value="">${placeholder}</option>` : ''} | |
| ${options.map(opt => | |
| typeof opt === 'string' | |
| ? html`<option value=${opt}>${opt}</option>` | |
| : html`<option value=${opt.value}>${opt.label}</option>` | |
| )} | |
| </select> | |
| ${error ? html`<div class="invalid-feedback">${error}</div>` : ''} | |
| </div> | |
| `; | |
| } | |
| // === Modal Component === | |
| export function Modal({ | |
| show = false, | |
| title = '', | |
| children, | |
| footer = null, | |
| onClose, | |
| size = 'md', | |
| staticBackdrop = false, | |
| }) { | |
| const html = window.html; | |
| const modalRef = useRef(null); | |
| useEffect(() => { | |
| if (show && modalRef.current) { | |
| const bsModal = new bootstrap.Modal(modalRef.current, { | |
| backdrop: staticBackdrop ? 'static' : true, | |
| keyboard: !staticBackdrop | |
| }); | |
| bsModal.show(); | |
| const handleHidden = () => { | |
| if (onClose) onClose(); | |
| }; | |
| modalRef.current.addEventListener('hidden.bs.modal', handleHidden); | |
| return () => { | |
| modalRef.current.removeEventListener('hidden.bs.modal', handleHidden); | |
| bsModal.dispose(); | |
| }; | |
| } | |
| }, [show]); | |
| const sizeClasses = { | |
| sm: 'modal-sm', | |
| md: '', | |
| lg: 'modal-lg', | |
| xl: 'modal-xl' | |
| }; | |
| return html` | |
| <div | |
| class="modal fade" | |
| ref=${modalRef} | |
| tabIndex="-1" | |
| aria-hidden="true" | |
| > | |
| <div class="modal-dialog ${sizeClasses[size] || ''}"> | |
| <div class="modal-content"> | |
| ${title ? html` | |
| <div class="modal-header"> | |
| <h5 class="modal-title">${title}</h5> | |
| <button | |
| type="button" | |
| class="btn-close" | |
| data-bs-dismiss="modal" | |
| aria-label="Close" | |
| onClick=${onClose} | |
| ></button> | |
| </div> | |
| ` : ''} | |
| <div class="modal-body"> | |
| ${children} | |
| </div> | |
| ${footer ? html` | |
| <div class="modal-footer"> | |
| ${footer} | |
| </div> | |
| ` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| // === Spinner Component === | |
| export function Spinner({ | |
| variant = 'primary', | |
| size = 'md', | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| const sizeClasses = { | |
| sm: 'spinner-border-sm', | |
| md: 'spinner-border', | |
| lg: 'spinner-grow-lg' | |
| }; | |
| return html` | |
| <div | |
| class="${sizeClasses[size] || sizeClasses.md} text-${variant} ${className}" | |
| role="status" | |
| > | |
| <span class="visually-hidden">Loading...</span> | |
| </div> | |
| `; | |
| } | |
| // === Table Components === | |
| export function Table({ | |
| columns = [], | |
| data = [], | |
| keyField = 'id', | |
| onRowClick = null, | |
| className = '', | |
| striped = true, | |
| hover = true, | |
| renderCell = null, | |
| emptyMessage = 'No data available' | |
| }) { | |
| const html = window.html; | |
| return html` | |
| <div class="table-responsive ${className}"> | |
| <table class="table ${striped ? 'table-striped' : ''} ${hover ? 'table-hover' : ''}"> | |
| <thead> | |
| <tr> | |
| ${columns.map(col => html` | |
| <th scope="col" style="${col.style || ''}">${col.header}</th> | |
| `)} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${data.length === 0 ? html` | |
| <tr> | |
| <td colSpan=${columns.length} class="text-center py-4">${emptyMessage}</td> | |
| </tr> | |
| ` : data.map(row => html` | |
| <tr | |
| key=${row[keyField]} | |
| ${onRowClick ? `onClick=${() => onRowClick(row)}` : ''} | |
| style="${onRowClick ? 'cursor: pointer;' : ''}" | |
| > | |
| ${columns.map(col => html` | |
| <td> | |
| ${renderCell | |
| ? renderCell(row, col.key) | |
| : row[col.key] | |
| } | |
| </td> | |
| `)} | |
| </tr> | |
| `)} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| } | |
| // === Progress Bar Component === | |
| export function ProgressBar({ | |
| value = 0, | |
| max = 100, | |
| variant = 'primary', | |
| label = null, | |
| striped = false, | |
| animated = false, | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| const percentage = Math.min(100, Math.max(0, (value / max) * 100)); | |
| const variantClasses = { | |
| primary: 'bg-primary', | |
| success: 'bg-success', | |
| danger: 'bg-danger', | |
| warning: 'bg-warning', | |
| info: 'bg-info' | |
| }; | |
| return html` | |
| <div class="progress ${className}" role="progressbar"> | |
| <div | |
| class="progress-bar ${variantClasses[variant]} ${striped ? 'progress-bar-striped' : ''} ${animated ? 'progress-bar-animated' : ''}" | |
| style="width: ${percentage}%" | |
| > | |
| ${label !== null ? label : `${Math.round(percentage)}%`} | |
| </div> | |
| </div> | |
| `; | |
| } | |
| // === Tabs Component === | |
| export function Tabs({ | |
| tabs = [], | |
| activeTab = 0, | |
| onChange, | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| const [active, setActive] = useState(activeTab); | |
| const handleChange = (index) => { | |
| setActive(index); | |
| if (onChange) onChange(index, tabs[index]); | |
| }; | |
| return html` | |
| <ul class="nav nav-pills ${className}"> | |
| ${tabs.map((tab, index) => html` | |
| <li class="nav-item"> | |
| <button | |
| class="nav-link ${active === index ? 'active' : ''}" | |
| onClick=${() => handleChange(index)} | |
| > | |
| ${tab.icon ? html`<i class="${tab.icon} me-1"></i>` : ''} | |
| ${tab.label} | |
| </button> | |
| </li> | |
| `)} | |
| </ul> | |
| ${tabs[active]?.content} | |
| `; | |
| } | |
| // === Checkbox Component === | |
| export function Checkbox({ | |
| label = null, | |
| checked = false, | |
| onChange, | |
| disabled = false, | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| return html` | |
| <div class="form-check ${className}"> | |
| <input | |
| class="form-check-input" | |
| type="checkbox" | |
| checked=${checked} | |
| disabled=${disabled} | |
| onChange=${onChange} | |
| /> | |
| ${label ? html` | |
| <label class="form-check-label" onClick=${(e) => e.target.tagName !== 'INPUT' && onChange?.()}> | |
| ${label} | |
| </label> | |
| ` : ''} | |
| </div> | |
| `; | |
| } | |
| // === Toggle Switch Component === | |
| export function Toggle({ | |
| label = null, | |
| checked = false, | |
| onChange, | |
| disabled = false, | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| return html` | |
| <div class="form-check form-switch ${className}"> | |
| <input | |
| class="form-check-input" | |
| type="checkbox" | |
| role="switch" | |
| checked=${checked} | |
| disabled=${disabled} | |
| onChange=${onChange} | |
| /> | |
| ${label ? html`<label class="form-check-label">${label}</label>` : ''} | |
| </div> | |
| `; | |
| } | |
| // === File Upload Component === | |
| export function FileUpload({ | |
| accept = '*', | |
| multiple = false, | |
| onFilesSelected, | |
| children = null, | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| const inputRef = useRef(null); | |
| const handleChange = (e) => { | |
| const files = Array.from(e.target.files); | |
| if (onFilesSelected) onFilesSelected(files); | |
| // Reset input so same file can be selected again | |
| e.target.value = ''; | |
| }; | |
| const handleClick = () => { | |
| inputRef.current?.click(); | |
| }; | |
| return html` | |
| <> | |
| <input | |
| ref=${inputRef} | |
| type="file" | |
| accept=${accept} | |
| multiple=${multiple} | |
| onChange=${handleChange} | |
| style="display: none;" | |
| /> | |
| ${children | |
| ? children({ onClick: handleClick }) | |
| : html`<${Button} onClick=${handleClick}>Choose Files</${Button}>` | |
| } | |
| </> | |
| `; | |
| } | |
| // === Search Input Component === | |
| export function SearchInput({ | |
| value = '', | |
| onSearch, | |
| placeholder = 'Search...', | |
| debounceMs = 300, | |
| className = '' | |
| }) { | |
| const html = window.html; | |
| const [localValue, setLocalValue] = useState(value); | |
| useEffect(() => { | |
| const timer = setTimeout(() => { | |
| if (onSearch) onSearch(localValue); | |
| }, debounceMs); | |
| return () => clearTimeout(timer); | |
| }, [localValue]); | |
| return html` | |
| <div class="input-group ${className}"> | |
| <span class="input-group-text"><i class="bi bi-search"></i></span> | |
| <input | |
| type="text" | |
| class="form-control" | |
| placeholder=${placeholder} | |
| value=${localValue} | |
| onInput=${(e) => setLocalValue(e.target.value)} | |
| /> | |
| </div> | |
| `; | |
| } | |
| // Export all components | |
| export const Components = { | |
| Button, | |
| Card, | |
| Badge, | |
| Alert, | |
| FormInput, | |
| FormSelect, | |
| Modal, | |
| Spinner, | |
| Table, | |
| ProgressBar, | |
| Tabs, | |
| Checkbox, | |
| Toggle, | |
| FileUpload, | |
| SearchInput | |
| }; | |