ignore folder

This commit is contained in:
2026-04-21 06:30:48 +07:00
commit ca00b36f19
70 changed files with 3871 additions and 0 deletions

4
components/ui/Alert.tsx Normal file
View File

@ -0,0 +1,4 @@
export default function Alert({ message, type = "info" }: { message: string; type?: "info" | "success" | "danger" }) {
const variant = type === "danger" ? "danger" : type === "success" ? "success" : "info";
return <div className={`alert alert-${variant}`}>{message}</div>;
}

19
components/ui/Badge.tsx Normal file
View File

@ -0,0 +1,19 @@
export default function Badge({
variant,
children
}: {
variant: "success" | "danger" | "warning" | "secondary" | "info";
children: string;
}) {
const cls =
variant === "success"
? "bg-success"
: variant === "danger"
? "bg-danger"
: variant === "warning"
? "bg-warning"
: variant === "info"
? "bg-info"
: "bg-secondary";
return <span className={`badge ${cls}`}>{children}</span>;
}

View File

@ -0,0 +1,31 @@
import Link from "next/link";
export type BreadcrumbItem = {
label: string;
href?: string;
};
export default function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
if (!items.length) return null;
return (
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
{items.map((item, index) => (
<li
key={item.label + index}
className={`breadcrumb-item ${index === items.length - 1 ? "active" : ""}`}
>
{index === items.length - 1 || !item.href ? (
<span>{item.label}</span>
) : (
<Link href={item.href} className="text-decoration-none">
{item.label}
</Link>
)}
</li>
))}
</ol>
</nav>
);
}

14
components/ui/Card.tsx Normal file
View File

@ -0,0 +1,14 @@
export default function Card({
title,
children
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="card page-card">
<div className="card-header fw-bold">{title}</div>
<div className="card-body">{children}</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import Dialog from "./Dialog";
export default function ConfirmDialog({
open,
title,
message,
onConfirm,
onCancel,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
loading
}: {
open: boolean;
title: string;
message: string;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
confirmLabel?: string;
cancelLabel?: string;
loading?: boolean;
}) {
return (
<Dialog
open={open}
title={title}
onClose={onCancel}
footer={
<div className="d-flex justify-content-end gap-2">
<button className="btn btn-outline-secondary" onClick={onCancel} disabled={loading}>
{cancelLabel}
</button>
<button className="btn btn-danger" onClick={onConfirm} disabled={loading}>
{loading ? "Working..." : confirmLabel}
</button>
</div>
}
>
<p>{message}</p>
</Dialog>
);
}

View File

@ -0,0 +1,39 @@
export type DateRangeValue = {
from?: string;
to?: string;
};
export default function DateRange({
from,
to,
onFromChange,
onToChange
}: {
from?: string;
to?: string;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
}) {
return (
<div className="d-flex gap-2">
<label className="flex-fill">
<span className="form-label">From</span>
<input
type="datetime-local"
className="form-control"
value={from ?? ""}
onChange={(event) => onFromChange(event.target.value)}
/>
</label>
<label className="flex-fill">
<span className="form-label">To</span>
<input
type="datetime-local"
className="form-control"
value={to ?? ""}
onChange={(event) => onToChange(event.target.value)}
/>
</label>
</div>
);
}

60
components/ui/Dialog.tsx Normal file
View File

@ -0,0 +1,60 @@
import { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
type DialogProps = {
open: boolean;
title: string;
description?: string;
children: ReactNode;
onClose: () => void;
footer?: ReactNode;
size?: "sm" | "lg" | "xl";
loading?: boolean;
};
const sizeClass = {
sm: "modal-dialog modal-sm",
lg: "modal-dialog modal-lg",
xl: "modal-dialog modal-xl"
};
export default function Dialog({
open,
title,
description,
children,
onClose,
footer,
size = "sm",
loading
}: DialogProps) {
useEffect(() => {
if (!open) return;
const onEsc = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onEsc);
return () => window.removeEventListener("keydown", onEsc);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="modal d-block" style={{ background: "rgba(0,0,0,.4)" }}>
<div className={sizeClass[size]} style={{ marginTop: "6rem" }}>
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button className="btn-close" onClick={onClose} aria-label="close" />
</div>
<div className="modal-body">
{description && <p className="text-muted mb-3">{description}</p>}
{loading ? <div className="text-center text-muted">Loading...</div> : children}
</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
</div>,
document.body
);
}

33
components/ui/Drawer.tsx Normal file
View File

@ -0,0 +1,33 @@
import { ReactNode } from "react";
import { createPortal } from "react-dom";
export default function Drawer({
open,
title,
children,
onClose
}: {
open: boolean;
title: string;
children: ReactNode;
onClose: () => void;
}) {
if (!open) return null;
return createPortal(
<div className="modal d-block" style={{ background: "rgba(0,0,0,.35)" }} role="dialog">
<div className="modal-dialog modal-dialog-end">
<div className="modal-content h-100">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button className="btn-close" onClick={onClose} aria-label="close" />
</div>
<div className="modal-body p-0">
<div className="p-3">{children}</div>
</div>
</div>
</div>
</div>,
document.body
);
}

View File

@ -0,0 +1,18 @@
export default function EmptyState({
title,
description,
cta
}: {
title: string;
description?: string;
cta?: React.ReactNode;
}) {
return (
<div className="text-center py-5">
<div className="display-6 text-muted mb-2">🎯</div>
<h4>{title}</h4>
{description && <p className="text-muted">{description}</p>}
{cta}
</div>
);
}

View File

@ -0,0 +1,49 @@
import { ChangeEvent, useRef } from "react";
export type FileUploadProps = {
label: string;
accept?: string;
multiple?: boolean;
maxBytes?: number;
onFileSelect: (files: File[]) => void;
disabled?: boolean;
helperText?: string;
resetToken?: number;
};
export default function FileUpload({
label,
accept,
multiple,
maxBytes,
onFileSelect,
disabled,
helperText,
resetToken
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
const targetFiles = Array.from(event.target.files ?? []);
const files = maxBytes ? targetFiles.filter((file) => file.size <= maxBytes) : targetFiles;
onFileSelect(files);
event.target.value = "";
};
return (
<label className="d-block">
<span className="form-label">{label}</span>
<input
key={resetToken}
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
className="form-control"
onChange={onChange}
/>
{helperText && <small className="text-muted">{helperText}</small>}
</label>
);
}

View File

@ -0,0 +1,23 @@
import { ReactNode } from "react";
type Option = { label: string; value: string };
type Props = {
label: string;
children?: ReactNode;
type?: "text" | "password" | "checkbox" | "textarea" | "select";
value?: string | boolean;
onValueChange?: (value: string) => void;
options?: Option[];
placeholder?: string;
rows?: number;
};
export default function FormField({ label, children, type = "text", ...props }: Props) {
return (
<label className="form-label">
{label}
{children}
</label>
);
}

25
components/ui/Modal.tsx Normal file
View File

@ -0,0 +1,25 @@
import { ReactNode } from "react";
type ModalProps = {
isOpen: boolean;
title: string;
children: ReactNode;
onClose: () => void;
};
export default function Modal({ isOpen, title, children, onClose }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal d-block" style={{ background: "rgba(0,0,0,0.4)" }} role="dialog">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button className="btn-close" onClick={onClose} aria-label="close" />
</div>
<div className="modal-body">{children}</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import Breadcrumb, { BreadcrumbItem } from "./Breadcrumb";
import { ReactNode } from "react";
export default function PageHeader({
title,
description,
actions,
breadcrumb
}: {
title: string;
description?: string;
actions?: ReactNode;
breadcrumb?: BreadcrumbItem[];
}) {
return (
<div className="mb-3">
{breadcrumb && <Breadcrumb items={breadcrumb} />}
<div className="d-flex justify-content-between align-items-center">
<div>
<h2 className="mb-1">{title}</h2>
{description && <p className="text-muted mb-0">{description}</p>}
</div>
{actions}
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
type PaginationProps = {
page: number;
pageSize: number;
total: number;
onChange: (page: number) => void;
};
export default function Pagination({ page, pageSize, total, onChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
return (
<div className="d-flex justify-content-center gap-1">
<button className="btn btn-outline-secondary btn-sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>
Prev
</button>
<span className="px-2 d-flex align-items-center">
Page {page} / {totalPages}
</span>
<button
className="btn btn-outline-secondary btn-sm"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
>
Next
</button>
</div>
);
}

38
components/ui/Select.tsx Normal file
View File

@ -0,0 +1,38 @@
import { ChangeEvent } from "react";
export type SelectOption = {
value: string;
label: string;
};
export default function Select({
label,
options,
value,
onChange,
disabled
}: {
label: string;
options: SelectOption[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
return (
<label className="form-label">
{label}
<select
className="form-select"
value={value}
onChange={(event: ChangeEvent<HTMLSelectElement>) => onChange(event.target.value)}
disabled={disabled}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}

View File

@ -0,0 +1,8 @@
export default function Spinner({ label = "Loading..." }: { label?: string }) {
return (
<div className="d-flex align-items-center gap-2">
<div className="spinner-border spinner-border-sm text-primary" role="status" />
<span className="text-muted">{label}</span>
</div>
);
}

52
components/ui/Table.tsx Normal file
View File

@ -0,0 +1,52 @@
import { ReactNode } from "react";
export type TableColumn<T extends Record<string, unknown>> = {
key: keyof T | "actions" | string;
header: string;
render?: (row: T) => ReactNode;
};
type TableProps<T extends Record<string, unknown>> = {
columns: TableColumn<T>[];
data: T[];
loading?: boolean;
noDataText?: string;
};
export default function DataTable<T extends Record<string, unknown>>({
columns,
data,
loading,
noDataText = "No data"
}: TableProps<T>) {
return (
<div className="table-responsive">
{loading && (
<div className="py-4 text-center text-muted">Loading ...</div>
)}
{!loading && data.length === 0 && <div className="py-4 text-center text-muted">{noDataText}</div>}
{!loading && data.length > 0 && (
<table className="table table-striped table-hover">
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, idx) => (
<tr key={String((row as { id?: string })?.id ?? idx)}>
{columns.map((col) => (
<td key={String(col.key)} className="align-middle">
{col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key as keyof T] ?? "-")}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

35
components/ui/Tabs.tsx Normal file
View File

@ -0,0 +1,35 @@
import { ReactNode } from "react";
type TabItem = {
key: string;
title: string;
content: ReactNode;
};
type TabsProps = {
items: TabItem[];
active: string;
onChange: (key: string) => void;
};
export default function Tabs({ items, active, onChange }: TabsProps) {
return (
<div>
<ul className="nav nav-tabs mb-3">
{items.map((item) => (
<li key={item.key} className="nav-item">
<button
className={`nav-link ${item.key === active ? "active" : ""}`}
onClick={() => onChange(item.key)}
>
{item.title}
</button>
</li>
))}
</ul>
<div>
{items.find((item) => item.key === active)?.content}
</div>
</div>
);
}