diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 020f74f..149d12f 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -4324,6 +4324,21 @@ "ws": "^7.5.1" } }, + "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -8382,6 +8397,21 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/jayson/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/jayson/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx new file mode 100644 index 0000000..713d0e0 --- /dev/null +++ b/src/components/Breadcrumb.tsx @@ -0,0 +1,311 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { BreadcrumbProps, BreadcrumbItem } from "./breadcrumb.types"; + +// ─── Separator Icon (12–16px per spec) ──────────────────────────────────────── +const SeparatorIcon = () => ( + +); + +// ─── Ellipsis button shown when items are collapsed ─────────────────────────── +const EllipsisButton = ({ onClick }: { onClick: () => void }) => ( + +); + +// ─── Single breadcrumb item ──────────────────────────────────────────────────── +const BreadcrumbNode = ({ + item, + isLast, +}: { + item: BreadcrumbItem; + isLast: boolean; +}) => { + const Tag = item.href && !isLast ? "a" : "span"; + return ( + + + {item.icon && ( + + )} + {item.label} + + + ); +}; + +// ─── Main Component ──────────────────────────────────────────────────────────── +export default function Breadcrumb({ + items, + maxItems = 4, + theme = "dark", + className = "", +}: BreadcrumbProps) { + const [expanded, setExpanded] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const containerRef = useRef(null); + + // Detect mobile (<640px) + useEffect(() => { + const mq = window.matchMedia("(max-width: 639px)"); + setIsMobile(mq.matches); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + // Determine which items to render + const getVisibleItems = (): (BreadcrumbItem | "ellipsis")[] => { + // Mobile: always show only last 2 segments (unless expanded) + if (isMobile && !expanded && items.length > 2) { + return ["ellipsis", ...items.slice(-2)]; + } + // Desktop: collapse if exceeds maxItems (keep first + last N-2) + if (!expanded && items.length > maxItems) { + return [ + items[0], + "ellipsis", + ...items.slice(-(maxItems - 2)), + ]; + } + return items; + }; + + const visible = getVisibleItems(); + + return ( + <> + {/* Scoped styles */} + + + + + ); +} \ No newline at end of file diff --git a/src/components/avatar/Avatar.tsx b/src/components/avatar/Avatar.tsx new file mode 100644 index 0000000..d4677a1 --- /dev/null +++ b/src/components/avatar/Avatar.tsx @@ -0,0 +1,143 @@ +"use client"; + +import React from "react"; + +export type AvatarSize = 24 | 32 | 40 | 64; +export type AvatarVariant = "image" | "initials" | "placeholder"; + +export interface AvatarProps { + size?: AvatarSize; + src?: string; + name?: string; // used to derive initials + alt?: string; + variant?: AvatarVariant; + className?: string; + onClick?: () => void; +} + +const COLOR_MAP: Record = { + A: "#6366f1", B: "#8b5cf6", C: "#ec4899", D: "#14b8a6", + E: "#f59e0b", F: "#10b981", G: "#3b82f6", H: "#f97316", + I: "#6366f1", J: "#8b5cf6", K: "#ec4899", L: "#14b8a6", + M: "#f59e0b", N: "#10b981", O: "#3b82f6", P: "#f97316", + Q: "#6366f1", R: "#8b5cf6", S: "#ec4899", T: "#14b8a6", + U: "#f59e0b", V: "#10b981", W: "#3b82f6", X: "#f97316", + Y: "#6366f1", Z: "#8b5cf6", +}; + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length === 1) return parts[0][0]?.toUpperCase() ?? "?"; + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +function getColor(name: string): string { + const first = name.trim()[0]?.toUpperCase() ?? "A"; + return COLOR_MAP[first] ?? "#6366f1"; +} + +const FONT_SIZE: Record = { 24: 9, 32: 12, 40: 14, 64: 22 }; + +const PlaceholderIcon = ({ size }: { size: number }) => ( + +); + +export default function Avatar({ + size = 40, + src, + name, + alt, + variant, + className = "", + onClick, +}: AvatarProps) { + const [imgError, setImgError] = React.useState(false); + + // Resolve effective variant + const effectiveVariant: AvatarVariant = + variant ?? + (src && !imgError ? "image" : name ? "initials" : "placeholder"); + + const initials = name ? getInitials(name) : ""; + const bgColor = name ? getColor(name) : "#374151"; + const fontSize = FONT_SIZE[size]; + + const baseStyle: React.CSSProperties = { + width: size, + height: size, + borderRadius: "50%", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + flexShrink: 0, + cursor: onClick ? "pointer" : "default", + userSelect: "none", + border: "1.5px solid rgba(255,255,255,0.08)", + position: "relative", + transition: "opacity 0.15s", + }; + + if (effectiveVariant === "image" && src) { + return ( + + {alt setImgError(true)} + style={{ width: "100%", height: "100%", objectFit: "cover" }} + /> + + ); + } + + if (effectiveVariant === "initials" && initials) { + return ( + + + {initials} + + + ); + } + + // Placeholder + return ( + + + + ); +} \ No newline at end of file diff --git a/src/components/avatar/AvatarUploader.tsx b/src/components/avatar/AvatarUploader.tsx new file mode 100644 index 0000000..099bf30 --- /dev/null +++ b/src/components/avatar/AvatarUploader.tsx @@ -0,0 +1,96 @@ +"use client"; + +import React, { useState } from "react"; +import Avatar, { AvatarSize } from "./Avatar"; +import FileUpload, { UploadFile } from "./FileUpload"; +import ImageCrop, { CropResult } from "./ImageCrop"; + +type Step = "idle" | "upload" | "crop" | "done"; + +export interface AvatarUploaderProps { + name?: string; + size?: AvatarSize; + onSave?: (dataUrl: string) => void; +} + +export default function AvatarUploader({ name, size = 64, onSave }: AvatarUploaderProps) { + const [step, setStep] = useState("idle"); + const [previewSrc, setPreviewSrc] = useState(); + const [croppedSrc, setCroppedSrc] = useState(); + + const handleUploadComplete = (file: UploadFile) => { + if (file.previewUrl) { + setPreviewSrc(file.previewUrl); + setStep("crop"); + } + }; + + const handleCropConfirm = (result: CropResult) => { + if (result.dataUrl) { + setCroppedSrc(result.dataUrl); + onSave?.(result.dataUrl); + } + setStep("done"); + }; + + const reset = () => { + setStep("idle"); + setPreviewSrc(undefined); + setCroppedSrc(undefined); + }; + + return ( +
+ {/* Avatar preview row */} +
+ setStep("upload") : undefined} + /> +
+

+ {name ?? "Your avatar"} +

+

+ {step === "idle" && "Click avatar to upload"} + {step === "upload" && "Choose or drop an image"} + {step === "crop" && "Adjust the crop area"} + {step === "done" && "Avatar saved"} +

+
+ {(step === "upload" || step === "done") && ( + + )} +
+ + {step === "upload" && ( + + )} + + {step === "crop" && previewSrc && ( + setStep("upload")} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/avatar/FileUpload.tsx b/src/components/avatar/FileUpload.tsx new file mode 100644 index 0000000..5d34040 --- /dev/null +++ b/src/components/avatar/FileUpload.tsx @@ -0,0 +1,271 @@ +"use client"; + +import React, { useRef, useState, useCallback, useId } from "react"; + +export interface UploadFile { + id: string; + file: File; + progress: number; // 0–100 + status: "pending" | "uploading" | "done" | "cancelled" | "error"; + previewUrl?: string; +} + +export interface FileUploadProps { + accept?: string; // e.g. "image/*" + maxSizeMB?: number; + onUploadComplete?: (file: UploadFile) => void; + onCancel?: (fileId: string) => void; +} + +// Mock upload: increments progress over ~2s +function mockUpload( + fileId: string, + onProgress: (id: string, pct: number) => void, + onDone: (id: string) => void, + signal: AbortSignal +) { + let pct = 0; + const tick = () => { + if (signal.aborted) return; + pct = Math.min(100, pct + Math.random() * 18 + 4); + onProgress(fileId, Math.round(pct)); + if (pct < 100) setTimeout(tick, 120); + else onDone(fileId); + }; + setTimeout(tick, 80); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +const UploadIcon = () => ( + +); + +const CheckIcon = () => ( + +); + +const XIcon = ({ size = 14 }: { size?: number }) => ( + +); + +export default function FileUpload({ + accept = "image/*", + maxSizeMB = 5, + onUploadComplete, + onCancel, +}: FileUploadProps) { + const inputId = useId(); + const inputRef = useRef(null); + const [files, setFiles] = useState([]); + const [dragOver, setDragOver] = useState(false); + const abortMap = useRef>(new Map()); + + const startUpload = useCallback((file: File) => { + if (file.size > maxSizeMB * 1024 * 1024) { + alert(`File exceeds ${maxSizeMB}MB limit.`); + return; + } + const id = crypto.randomUUID(); + const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined; + const entry: UploadFile = { id, file, progress: 0, status: "uploading", previewUrl }; + + setFiles((prev) => [...prev, entry]); + + const ac = new AbortController(); + abortMap.current.set(id, ac); + + mockUpload( + id, + (fid, pct) => setFiles((prev) => prev.map((f) => f.id === fid ? { ...f, progress: pct } : f)), + (fid) => { + setFiles((prev) => prev.map((f) => f.id === fid ? { ...f, status: "done", progress: 100 } : f)); + abortMap.current.delete(fid); + const done = files.find((f) => f.id === fid) ?? entry; + onUploadComplete?.({ ...done, status: "done", progress: 100 }); + }, + ac.signal + ); + }, [files, maxSizeMB, onUploadComplete]); + + const handleFiles = (fileList: FileList | null) => { + if (!fileList) return; + Array.from(fileList).forEach(startUpload); + }; + + const cancel = (id: string) => { + abortMap.current.get(id)?.abort(); + abortMap.current.delete(id); + setFiles((prev) => prev.map((f) => f.id === id ? { ...f, status: "cancelled" } : f)); + onCancel?.(id); + }; + + const remove = (id: string) => { + setFiles((prev) => { + const f = prev.find((x) => x.id === id); + if (f?.previewUrl) URL.revokeObjectURL(f.previewUrl); + return prev.filter((x) => x.id !== id); + }); + }; + + return ( +
+ {/* Dropzone */} +
inputRef.current?.click()} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }} + onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { e.preventDefault(); setDragOver(false); handleFiles(e.dataTransfer.files); }} + style={{ + border: `1.5px dashed ${dragOver ? "#6366f1" : "#374151"}`, + borderRadius: 10, + padding: "28px 20px", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 8, + cursor: "pointer", + background: dragOver ? "rgba(99,102,241,0.06)" : "transparent", + transition: "border-color 0.15s, background 0.15s", + outline: "none", + }} + onFocus={(e) => (e.currentTarget.style.boxShadow = "0 0 0 2px #6366f1")} + onBlur={(e) => (e.currentTarget.style.boxShadow = "none")} + > + + + + + Drop files here or click to browse + + + {accept.replace("image/*", "Images")} · max {maxSizeMB}MB + +
+ + handleFiles(e.target.files)} + aria-hidden="true" + /> + + {/* File list */} + {files.length > 0 && ( +
    + {files.map((f) => ( +
  • + {/* Thumbnail */} +
    + {f.previewUrl + ? + : + } +
    + + {/* Info + bar */} +
    +
    + + {f.file.name} + + + {formatBytes(f.file.size)} + +
    + + {f.status === "uploading" && ( +
    +
    +
    + )} + + {f.status === "done" && ( + + uploaded + + )} + {f.status === "cancelled" && ( + cancelled + )} + {f.status === "error" && ( + upload failed + )} +
    + + {/* Action */} + {f.status === "uploading" ? ( + + ) : ( + + )} +
  • + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/avatar/ImageCrop.tsx b/src/components/avatar/ImageCrop.tsx new file mode 100644 index 0000000..222f3a3 --- /dev/null +++ b/src/components/avatar/ImageCrop.tsx @@ -0,0 +1,255 @@ +"use client"; + +import React, { useRef, useState, useCallback, useEffect } from "react"; + +export interface CropResult { + x: number; // 0–1 relative coords + y: number; + size: number; // 0–1, always square + dataUrl?: string; +} + +export interface ImageCropProps { + src: string; // image src to crop + outputSize?: number; // canvas output px (default 256) + onCropChange?: (crop: CropResult) => void; + onConfirm?: (result: CropResult) => void; + onCancel?: () => void; +} + +const HANDLE_HIT = 12; // px hit area per spec (8–12px) + +type Handle = "nw" | "ne" | "sw" | "se"; + +interface CropState { + x: number; y: number; size: number; +} + +export default function ImageCrop({ + src, + outputSize = 256, + onCropChange, + onConfirm, + onCancel, +}: ImageCropProps) { + const containerRef = useRef(null); + const imgRef = useRef(null); + const canvasRef = useRef(null); + const [crop, setCrop] = useState({ x: 0.1, y: 0.1, size: 0.8 }); + const [dragging, setDragging] = useState(null); + const dragStart = useRef<{ mx: number; my: number; crop: CropState } | null>(null); + const [ready, setReady] = useState(false); + + const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + + const updateCrop = useCallback((next: CropState) => { + const c = { + x: clamp(next.x, 0, 1 - next.size), + y: clamp(next.y, 0, 1 - next.size), + size: clamp(next.size, 0.1, 1), + }; + c.x = clamp(c.x, 0, 1 - c.size); + c.y = clamp(c.y, 0, 1 - c.size); + setCrop(c); + onCropChange?.({ ...c }); + }, [onCropChange]); + + const getRelative = useCallback((e: MouseEvent | TouchEvent) => { + const rect = containerRef.current!.getBoundingClientRect(); + const pt = "touches" in e ? e.touches[0] : e as MouseEvent; + return { + rx: (pt.clientX - rect.left) / rect.width, + ry: (pt.clientY - rect.top) / rect.height, + }; + }, []); + + const onMouseDown = useCallback((e: React.MouseEvent | React.TouchEvent, type: Handle | "move") => { + e.preventDefault(); + e.stopPropagation(); + const rect = containerRef.current!.getBoundingClientRect(); + const pt = "touches" in e ? e.touches[0] : e as React.MouseEvent; + dragStart.current = { + mx: (pt.clientX - rect.left) / rect.width, + my: (pt.clientY - rect.top) / rect.height, + crop: { ...crop }, + }; + setDragging(type); + }, [crop]); + + useEffect(() => { + const move = (e: MouseEvent | TouchEvent) => { + if (!dragging || !dragStart.current) return; + const { rx, ry } = getRelative(e); + const ds = dragStart.current; + const dx = rx - ds.mx; + const dy = ry - ds.my; + + if (dragging === "move") { + updateCrop({ ...ds.crop, x: ds.crop.x + dx, y: ds.crop.y + dy }); + return; + } + + // Corner handles — resize + let { x, y, size } = ds.crop; + const delta = Math.max(dx, dy); + + if (dragging === "se") { + updateCrop({ x, y, size: clamp(size + delta, 0.1, Math.min(1 - x, 1 - y)) }); + } else if (dragging === "nw") { + const ns = clamp(size - delta, 0.1, 1); + updateCrop({ x: x + (size - ns), y: y + (size - ns), size: ns }); + } else if (dragging === "ne") { + const ns = clamp(size + dx, 0.1, Math.min(1 - x, 1)); + updateCrop({ x, y: y + (size - ns), size: ns }); + } else if (dragging === "sw") { + const ns = clamp(size + dy, 0.1, Math.min(1, 1 - y)); + updateCrop({ x: x + (size - ns), y, size: ns }); + } + }; + + const up = () => { setDragging(null); dragStart.current = null; }; + + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + window.addEventListener("touchmove", move, { passive: false }); + window.addEventListener("touchend", up); + return () => { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", up); + window.removeEventListener("touchmove", move); + window.removeEventListener("touchend", up); + }; + }, [dragging, getRelative, updateCrop]); + + const handleConfirm = () => { + const img = imgRef.current; + const canvas = canvasRef.current; + if (!img || !canvas) return; + const ctx = canvas.getContext("2d")!; + canvas.width = outputSize; + canvas.height = outputSize; + ctx.drawImage( + img, + crop.x * img.naturalWidth, + crop.y * img.naturalHeight, + crop.size * img.naturalWidth, + crop.size * img.naturalHeight, + 0, 0, outputSize, outputSize + ); + const dataUrl = canvas.toDataURL("image/jpeg", 0.92); + onConfirm?.({ ...crop, dataUrl }); + }; + + // Crop overlay CSS values (%) + const left = `${crop.x * 100}%`; + const top = `${crop.y * 100}%`; + const size = `${crop.size * 100}%`; + + return ( +
+ {/* Crop canvas */} +
+ Crop preview setReady(true)} + style={{ display: "block", width: "100%", height: "auto", opacity: 0.45, pointerEvents: "none" }} + /> + + {ready && ( + <> + {/* Bright crop region */} +
onMouseDown(e, "move")} + onTouchStart={(e) => onMouseDown(e, "move")} + > + {/* Grid lines */} + {[33, 66].map((p) => ( + +
+
+ + ))} + + {/* Corner handles */} + {(["nw", "ne", "sw", "se"] as Handle[]).map((h) => ( +
onMouseDown(e, h)} + onTouchStart={(e) => onMouseDown(e, h)} + aria-label={`${h} resize handle`} + style={{ + position: "absolute", + width: HANDLE_HIT * 2, + height: HANDLE_HIT * 2, + ...(h.includes("n") ? { top: -HANDLE_HIT } : { bottom: -HANDLE_HIT }), + ...(h.includes("w") ? { left: -HANDLE_HIT } : { right: -HANDLE_HIT }), + cursor: h === "nw" || h === "se" ? "nwse-resize" : "nesw-resize", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 2, + }} + > +
+
+ ))} +
+ + )} +
+ + {/* Hidden canvas for output */} + + + {/* Actions */} +
+ {onCancel && ( + + )} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/datetime/DatePicker.tsx b/src/components/datetime/DatePicker.tsx new file mode 100644 index 0000000..01adcd5 --- /dev/null +++ b/src/components/datetime/DatePicker.tsx @@ -0,0 +1,184 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; + +export interface DatePickerProps { + value?: Date | null; + onChange?: (date: Date | null) => void; + placeholder?: string; + minDate?: Date; + maxDate?: Date; + disabled?: boolean; + className?: string; +} + +const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; +const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + +function isSameDay(a: Date, b: Date) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +function isDisabled(date: Date, min?: Date, max?: Date) { + if (min && date < new Date(min.getFullYear(), min.getMonth(), min.getDate())) return true; + if (max && date > new Date(max.getFullYear(), max.getMonth(), max.getDate())) return true; + return false; +} + +function buildGrid(year: number, month: number): (Date | null)[] { + const first = new Date(year, month, 1).getDay(); + const days: (Date | null)[] = []; + for (let i = 0; i < first; i++) days.push(null); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + for (let d = 1; d <= daysInMonth; d++) days.push(new Date(year, month, d)); + while (days.length < 42) days.push(null); + return days; +} + +const ChevLeft = () => ; +const ChevRight = () => ; +const CalIcon = () => ; + +export default function DatePicker({ value, onChange, placeholder = "Select date", minDate, maxDate, disabled }: DatePickerProps) { + const today = new Date(); + const [open, setOpen] = useState(false); + const [viewYear, setViewYear] = useState((value ?? today).getFullYear()); + const [viewMonth, setViewMonth] = useState((value ?? today).getMonth()); + const [focusedIdx, setFocusedIdx] = useState(null); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const grid = buildGrid(viewYear, viewMonth); + const formatted = value ? value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : ""; + + const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1); }; + const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1); }; + + const select = (date: Date) => { + if (isDisabled(date, minDate, maxDate)) return; + onChange?.(date); + setOpen(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen(true); } return; } + const activeDays = grid.filter(Boolean) as Date[]; + const cur = focusedIdx ?? 0; + if (e.key === "Escape") { setOpen(false); return; } + if (e.key === "ArrowRight") setFocusedIdx(Math.min(cur + 1, activeDays.length - 1)); + if (e.key === "ArrowLeft") setFocusedIdx(Math.max(cur - 1, 0)); + if (e.key === "ArrowDown") setFocusedIdx(Math.min(cur + 7, activeDays.length - 1)); + if (e.key === "ArrowUp") setFocusedIdx(Math.max(cur - 7, 0)); + if (e.key === "Enter" && focusedIdx !== null) select(activeDays[focusedIdx]); + e.preventDefault(); + }; + + return ( +
+ + + {open && ( +
+ {/* Month nav */} +
+ + {MONTHS[viewMonth]} {viewYear} + +
+ + {/* Day headers */} +
+ {DAYS.map(d => ( +
{d}
+ ))} +
+ + {/* Calendar grid — 7×6 */} +
+ {grid.map((date, i) => { + if (!date) return
; + const isToday = isSameDay(date, today); + const isSelected = value ? isSameDay(date, value) : false; + const dis = isDisabled(date, minDate, maxDate); + return ( + + ); + })} +
+ + {/* Today shortcut */} +
+ + +
+
+ )} +
+ ); +} + +const navBtnStyle: React.CSSProperties = { + background: "none", border: "none", cursor: "pointer", color: "#9ca3af", + display: "flex", alignItems: "center", justifyContent: "center", + width: 28, height: 28, borderRadius: 6, padding: 0, +}; +const actionBtnStyle: React.CSSProperties = { + background: "none", border: "none", cursor: "pointer", fontSize: 12, + color: "#6b7280", fontFamily: "inherit", padding: "2px 6px", borderRadius: 4, +}; \ No newline at end of file diff --git a/src/components/datetime/DateRangePicker.tsx b/src/components/datetime/DateRangePicker.tsx new file mode 100644 index 0000000..490e784 --- /dev/null +++ b/src/components/datetime/DateRangePicker.tsx @@ -0,0 +1,223 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; + +export interface DateRange { start: Date | null; end: Date | null; } + +export interface DateRangePickerProps { + value?: DateRange; + onChange?: (range: DateRange) => void; + minDate?: Date; + maxDate?: Date; + placeholder?: string; + disabled?: boolean; +} + +const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; +const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; +const MONTHS_FULL = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + +function sameDay(a: Date, b: Date) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +function inRange(d: Date, start: Date | null, end: Date | null): boolean { + if (!start || !end) return false; + const t = d.getTime(); + return t > start.getTime() && t < end.getTime(); +} + +function buildGrid(year: number, month: number): (Date | null)[] { + const first = new Date(year, month, 1).getDay(); + const days: (Date | null)[] = []; + for (let i = 0; i < first; i++) days.push(null); + const dim = new Date(year, month + 1, 0).getDate(); + for (let d = 1; d <= dim; d++) days.push(new Date(year, month, d)); + while (days.length < 42) days.push(null); + return days; +} + +const ChevLeft = () => ; +const ChevRight = () => ; +const CalIcon = () => ; + +function CalendarMonth({ + year, month, range, hovered, onSelect, onHover, +}: { + year: number; month: number; + range: DateRange; hovered: Date | null; + onSelect: (d: Date) => void; + onHover: (d: Date | null) => void; +}) { + const grid = buildGrid(year, month); + const today = new Date(); + const effectiveEnd = range.start && !range.end && hovered ? hovered : range.end; + + return ( +
+
+ {MONTHS_FULL[month]} {year} +
+
+ {DAYS.map(d =>
{d}
)} +
+
+ {grid.map((date, i) => { + if (!date) return
; + const isStart = range.start ? sameDay(date, range.start) : false; + const isEnd = effectiveEnd ? sameDay(date, effectiveEnd) : false; + const inR = inRange(date, range.start, effectiveEnd); + const isToday = sameDay(date, today); + return ( + + ); + })} +
+
+ ); +} + +export default function DateRangePicker({ value, onChange, minDate, maxDate, placeholder = "Select date range", disabled }: DateRangePickerProps) { + const today = new Date(); + const [open, setOpen] = useState(false); + const [range, setRange] = useState(value ?? { start: null, end: null }); + const [hovered, setHovered] = useState(null); + const [leftYear, setLeftYear] = useState(today.getFullYear()); + const [leftMonth, setLeftMonth] = useState(today.getMonth()); + const ref = useRef(null); + + const rightMonth = leftMonth === 11 ? 0 : leftMonth + 1; + const rightYear = leftMonth === 11 ? leftYear + 1 : leftYear; + + useEffect(() => { + const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const select = (date: Date) => { + if (!range.start || (range.start && range.end)) { + const next = { start: date, end: null }; + setRange(next); + } else { + const next = date < range.start + ? { start: date, end: range.start } + : { start: range.start, end: date }; + setRange(next); + onChange?.(next); + setOpen(false); + } + }; + + const prevMonth = () => { if (leftMonth === 0) { setLeftMonth(11); setLeftYear(y => y - 1); } else setLeftMonth(m => m - 1); }; + const nextMonth = () => { if (leftMonth === 11) { setLeftMonth(0); setLeftYear(y => y + 1); } else setLeftMonth(m => m + 1); }; + + const fmtDate = (d: Date | null) => d ? `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}` : ""; + const label = range.start + ? range.end ? `${fmtDate(range.start)} – ${fmtDate(range.end)}` : `${fmtDate(range.start)} – …` + : ""; + + return ( +
+ + + {open && ( +
+ {/* Nav */} +
+ + +
+ + {/* Dual calendar */} +
+ +
+ +
+ + {/* Legend */} +
+ + Start + + + End + + + Range + + +
+
+ )} +
+ ); +} + +const navStyle: React.CSSProperties = { + background: "none", border: "none", cursor: "pointer", color: "#9ca3af", + display: "flex", alignItems: "center", justifyContent: "center", + width: 28, height: 28, borderRadius: 6, padding: 0, +}; \ No newline at end of file diff --git a/src/components/datetime/TimePicker.tsx b/src/components/datetime/TimePicker.tsx new file mode 100644 index 0000000..726dbf7 --- /dev/null +++ b/src/components/datetime/TimePicker.tsx @@ -0,0 +1,171 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; + +export interface TimeValue { hours: number; minutes: number; } + +export interface TimePickerProps { + value?: TimeValue | null; + onChange?: (time: TimeValue | null) => void; + step?: 5 | 10 | 15; // minutes per spec + use24h?: boolean; + placeholder?: string; + disabled?: boolean; +} + +function fmt(h: number, m: number, use24h: boolean): string { + if (use24h) return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; + const period = h >= 12 ? "PM" : "AM"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return `${h12}:${String(m).padStart(2, "0")} ${period}`; +} + +function buildSlots(step: number, use24h: boolean): { label: string; value: TimeValue }[] { + const slots = []; + for (let h = 0; h < 24; h++) { + for (let m = 0; m < 60; m += step) { + slots.push({ label: fmt(h, m, use24h), value: { hours: h, minutes: m } }); + } + } + return slots; +} + +const ClockIcon = () => ( + +); + +export default function TimePicker({ value, onChange, step = 15, use24h = false, placeholder = "Select time", disabled }: TimePickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const ref = useRef(null); + const listRef = useRef(null); + + const slots = buildSlots(step, use24h); + const filtered = search ? slots.filter(s => s.label.toLowerCase().includes(search.toLowerCase())) : slots; + const formatted = value ? fmt(value.hours, value.minutes, use24h) : ""; + + useEffect(() => { + const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + // Scroll selected into view when opening + useEffect(() => { + if (open && value && listRef.current) { + const idx = slots.findIndex(s => s.value.hours === value.hours && s.value.minutes === value.minutes); + const item = listRef.current.children[idx] as HTMLElement; + item?.scrollIntoView({ block: "center" }); + } + }, [open]); + + const select = (tv: TimeValue) => { onChange?.(tv); setOpen(false); setSearch(""); }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + if (e.key === "ArrowDown" && !open) setOpen(true); + }; + + return ( +
+ + + {open && ( +
+ {/* Search/filter */} +
+ setSearch(e.target.value)} + placeholder="Filter..." + aria-label="Filter times" + style={{ + width: "100%", height: 30, borderRadius: 6, + border: "0.5px solid #374151", background: "#1f2937", + color: "#e5e7eb", fontSize: 12, fontFamily: "inherit", + padding: "0 8px", outline: "none", + }} + onFocus={e => (e.currentTarget.style.borderColor = "#6366f1")} + onBlur={e => (e.currentTarget.style.borderColor = "#374151")} + /> +
+ + {/* Time slot list — scrollable, 44px touch target */} +
    + {filtered.length === 0 && ( +
  • No results
  • + )} + {filtered.map((s, i) => { + const isSelected = value && s.value.hours === value.hours && s.value.minutes === value.minutes; + return ( +
  • select(s.value)} + style={{ + padding: "0 10px", + height: 36, // ≥36px pointer, approx 44px touch via line-height + display: "flex", alignItems: "center", + fontSize: 13, cursor: "pointer", borderRadius: 6, + background: isSelected ? "#6366f1" : "transparent", + color: isSelected ? "#fff" : "#d1d5db", + transition: "background 0.1s", + }} + onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = "#1f2937"; }} + onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = "transparent"; }} + > + {s.label} +
  • + ); + })} +
+ + {/* Clear */} + {value && ( +
+ +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/pagination-filters/FilterChips.tsx b/src/components/pagination-filters/FilterChips.tsx new file mode 100644 index 0000000..133e113 --- /dev/null +++ b/src/components/pagination-filters/FilterChips.tsx @@ -0,0 +1,139 @@ +"use client"; + +import React from "react"; + +export interface FilterOption { + id: string; + label: string; + count?: number; + group?: string; +} + +export interface FilterChipsProps { + options: FilterOption[]; + selected: string[]; + onChange: (selected: string[]) => void; + className?: string; +} + +const XIcon = () => ( + +); + +export default function FilterChips({ options, selected, onChange, className = "" }: FilterChipsProps) { + const toggle = (id: string) => { + onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]); + }; + + const clearAll = () => onChange([]); + const hasActive = selected.length > 0; + + return ( +
+ {options.map((opt) => { + const active = selected.includes(opt.id); + return ( + + ); + })} + + {/* Clear all — always visible when filters active */} + {hasActive && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/pagination-filters/Pagination.tsx b/src/components/pagination-filters/Pagination.tsx new file mode 100644 index 0000000..8ba2eaa --- /dev/null +++ b/src/components/pagination-filters/Pagination.tsx @@ -0,0 +1,182 @@ +"use client"; + +import React, { useState, useId } from "react"; + +export interface PaginationProps { + totalItems: number; + itemsPerPage?: number; + currentPage?: number; + onPageChange?: (page: number) => void; + showJump?: boolean; + className?: string; +} + +const ChevronLeft = () => ( + +); + +const ChevronRight = () => ( + +); + +function getPageNumbers(current: number, total: number): (number | "ellipsis")[] { + if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); + if (current <= 4) return [1, 2, 3, 4, 5, "ellipsis", total]; + if (current >= total - 3) return [1, "ellipsis", total - 4, total - 3, total - 2, total - 1, total]; + return [1, "ellipsis", current - 1, current, current + 1, "ellipsis", total]; +} + +const BTN_BASE: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minWidth: 36, + height: 36, + borderRadius: 8, + border: "0.5px solid", + fontSize: 13, + fontFamily: "inherit", + cursor: "pointer", + background: "transparent", + transition: "background 0.12s, color 0.12s, border-color 0.12s", + outline: "none", + padding: "0 8px", +}; + +export default function Pagination({ + totalItems, + itemsPerPage = 10, + currentPage: controlledPage, + onPageChange, + showJump = true, + className = "", +}: PaginationProps) { + const jumpId = useId(); + const [internalPage, setInternalPage] = useState(1); + const [jumpVal, setJumpVal] = useState(""); + + const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); + const current = controlledPage ?? internalPage; + + const go = (page: number) => { + const p = Math.max(1, Math.min(totalPages, page)); + if (!controlledPage) setInternalPage(p); + onPageChange?.(p); + }; + + const handleJump = (e: React.FormEvent) => { + e.preventDefault(); + const n = parseInt(jumpVal, 10); + if (!isNaN(n)) { go(n); setJumpVal(""); } + }; + + const pages = getPageNumbers(current, totalPages); + const start = (current - 1) * itemsPerPage + 1; + const end = Math.min(current * itemsPerPage, totalItems); + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/pagination-filters/TransactionList.tsx b/src/components/pagination-filters/TransactionList.tsx new file mode 100644 index 0000000..5710953 --- /dev/null +++ b/src/components/pagination-filters/TransactionList.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React from "react"; +import FilterChips from "./FilterChips"; +import Pagination from "./Pagination"; +import { useTransactionList, buildFilterOptions, MOCK_TRANSACTIONS } from "./useTransactionList"; + +const STATUS_COLORS: Record = { + completed: { bg: "rgba(16,185,129,0.12)", color: "#10b981" }, + pending: { bg: "rgba(245,158,11,0.12)", color: "#f59e0b" }, + failed: { bg: "rgba(239,68,68,0.12)", color: "#ef4444" }, + cancelled: { bg: "rgba(107,114,128,0.12)", color: "#6b7280" }, +}; + +export default function TransactionList() { + const { items, totalItems, page, setPage, selectedFilters, setSelectedFilters, itemsPerPage } = + useTransactionList(8); + + const filterOptions = buildFilterOptions(MOCK_TRANSACTIONS); + + return ( +
+ {/* Header */} +
+

Transactions

+ {totalItems} results +
+ + {/* Filters */} + + + {/* Table */} +
+ + + + {["Date", "Description", "Type", "Amount", "Status"].map((h) => ( + + ))} + + + + {items.map((tx) => ( + (e.currentTarget.style.background = "#161b22")} + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} + > + + + + + + + ))} + +
+ {h.toUpperCase()} +
{tx.date}{tx.description} + + {tx.type} + + + {tx.amount.toLocaleString()} {tx.currency} + + + {tx.status} + +
+ + {items.length === 0 && ( +
+ No transactions match the selected filters. +
+ )} +
+ + {/* Pagination */} + +
+ ); +} \ No newline at end of file diff --git a/src/hooks/DateRangePicker.ts b/src/hooks/DateRangePicker.ts new file mode 100644 index 0000000..609d3ff --- /dev/null +++ b/src/hooks/DateRangePicker.ts @@ -0,0 +1,4 @@ +export interface DateRange { + start: Date | null; + end: Date | null; +} \ No newline at end of file diff --git a/src/hooks/useDateFilter.ts b/src/hooks/useDateFilter.ts new file mode 100644 index 0000000..d8bfc2e --- /dev/null +++ b/src/hooks/useDateFilter.ts @@ -0,0 +1,63 @@ +"use client"; + +import { useMemo } from "react"; +import { DateRange } from "./DateRangePicker"; + +export interface DateFilterable { + date: string | Date; + [key: string]: unknown; +} + +/** + * useDateFilter — filters any dataset by a DateRange. + * Pass your full dataset and a range; get back filtered items. + * + * @example + * const { filtered, count } = useDateFilter(transactions, range); + */ +export function useDateFilter( + data: T[], + range: DateRange +): { filtered: T[]; count: number; hasFilter: boolean } { + const filtered = useMemo(() => { + if (!range.start && !range.end) return data; + return data.filter((item) => { + const d = item.date instanceof Date ? item.date : new Date(item.date); + if (isNaN(d.getTime())) return true; + const t = d.getTime(); + // ✅ Clone before calling .setHours() to avoid mutating the original Date objects + const startOk = + !range.start || t >= new Date(range.start).setHours(0, 0, 0, 0); + const endOk = + !range.end || t <= new Date(range.end).setHours(23, 59, 59, 999); + return startOk && endOk; + }); + }, [data, range.start, range.end]); + + return { + filtered, + count: filtered.length, + hasFilter: !!(range.start || range.end), + }; +} + +/** + * useTimeFilter — filters items by a time-of-day window. + * Useful for audit logs, transactions with timestamps. + */ +export function useTimeFilter( + data: T[], + from: { hours: number; minutes: number } | null, + to: { hours: number; minutes: number } | null +): T[] { + return useMemo(() => { + if (!from && !to) return data; + return data.filter((item) => { + const [h, m] = item.time.split(":").map(Number); + const mins = h * 60 + m; + const fromMins = from ? from.hours * 60 + from.minutes : 0; + const toMins = to ? to.hours * 60 + to.minutes : 1439; + return mins >= fromMins && mins <= toMins; + }); + }, [data, from, to]); +} \ No newline at end of file diff --git a/src/hooks/useTransactionList.ts b/src/hooks/useTransactionList.ts new file mode 100644 index 0000000..4226f22 --- /dev/null +++ b/src/hooks/useTransactionList.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useState, useMemo } from "react"; + +export type TxStatus = "completed" | "pending" | "failed" | "cancelled"; +export type TxType = "transfer" | "deposit" | "withdrawal" | "swap"; + +export interface Transaction { + id: string; + date: string; + description: string; + amount: number; + currency: string; + status: TxStatus; + type: TxType; + wallet: string; +} + +// Seeded mock data +const STATUSES: TxStatus[] = ["completed", "pending", "failed", "cancelled"]; +const TYPES: TxType[] = ["transfer", "deposit", "withdrawal", "swap"]; +const WALLETS = ["MetaMask", "Coinbase", "Ledger", "Trust"]; +const DESCS = [ + "ETH transfer to vault", "USDC deposit", "BTC withdrawal", "SOL swap", + "NFT purchase", "Gas fee refund", "Staking reward", "Bridge transfer", + "DEX swap", "Cold storage transfer", "Yield harvest", "Liquidity add", +]; + +function seed(n: number) { + let x = Math.sin(n) * 10000; + return x - Math.floor(x); +} + +export const MOCK_TRANSACTIONS: Transaction[] = Array.from({ length: 87 }, (_, i) => ({ + id: `tx-${i + 1}`, + date: new Date(2024, Math.floor(seed(i * 3) * 12), Math.floor(seed(i * 7) * 28) + 1).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }), + description: DESCS[Math.floor(seed(i * 11) * DESCS.length)], + amount: parseFloat((seed(i * 17) * 9800 + 10).toFixed(2)), + currency: ["ETH", "USDC", "BTC", "SOL"][Math.floor(seed(i * 13) * 4)], + status: STATUSES[Math.floor(seed(i * 5) * 4)], + type: TYPES[Math.floor(seed(i * 9) * 4)], + wallet: WALLETS[Math.floor(seed(i * 7) * 4)], +})); + +// Filter options with counts +export function buildFilterOptions(data: Transaction[]) { + const countBy = (key: keyof Transaction) => { + const map: Record = {}; + data.forEach((tx) => { const v = tx[key] as string; map[v] = (map[v] ?? 0) + 1; }); + return map; + }; + const statusCounts = countBy("status"); + const typeCounts = countBy("type"); + + return [ + ...STATUSES.map((s) => ({ id: `status:${s}`, label: s, count: statusCounts[s] ?? 0, group: "status" })), + ...TYPES.map((t) => ({ id: `type:${t}`, label: t, count: typeCounts[t] ?? 0, group: "type" })), + ]; +} + +export function useTransactionList(itemsPerPage = 8) { + const [selectedFilters, setSelectedFilters] = useState([]); + const [page, setPage] = useState(1); + + const filtered = useMemo(() => { + if (selectedFilters.length === 0) return MOCK_TRANSACTIONS; + return MOCK_TRANSACTIONS.filter((tx) => { + const statusFilters = selectedFilters.filter((f) => f.startsWith("status:")).map((f) => f.replace("status:", "")); + const typeFilters = selectedFilters.filter((f) => f.startsWith("type:")).map((f) => f.replace("type:", "")); + const statusOk = statusFilters.length === 0 || statusFilters.includes(tx.status); + const typeOk = typeFilters.length === 0 || typeFilters.includes(tx.type); + return statusOk && typeOk; + }); + }, [selectedFilters]); + + const paged = useMemo(() => { + const start = (page - 1) * itemsPerPage; + return filtered.slice(start, start + itemsPerPage); + }, [filtered, page, itemsPerPage]); + + const handleFilterChange = (next: string[]) => { + setSelectedFilters(next); + setPage(1); // reset to page 1 on filter change + }; + + return { + items: paged, + totalItems: filtered.length, + page, + setPage, + selectedFilters, + setSelectedFilters: handleFilterChange, + itemsPerPage, + }; +} \ No newline at end of file diff --git a/src/lib/routeMetadata.tsx b/src/lib/routeMetadata.tsx new file mode 100644 index 0000000..bd9332f --- /dev/null +++ b/src/lib/routeMetadata.tsx @@ -0,0 +1,88 @@ +"use client"; + +import React from "react"; +import { RouteMetadata } from "./breadcrumb.types"; + +// Mock icons as simple SVG components +export const HomeIcon = () => ( + + + + +); + +export const DashboardIcon = () => ( + + + + +); + +export const WalletIcon = () => ( + + + + +); + +export const AuditIcon = () => ( + + + + + +); + +export const SettingsIcon = () => ( + + + + + +); + +export const TransactionsIcon = () => ( + + + + + + +); + +// Mock route metadata registry +export const routeMetadata: Record = { + "/": { label: "Home", icon: , href: "/" }, + "/dashboard": { label: "Dashboard", icon: , href: "/dashboard" }, + "/wallet": { label: "Wallet", icon: , href: "/wallet" }, + "/wallet/connect": { label: "Connect Wallet", href: "/wallet/connect" }, + "/wallet/transactions": { label: "Transactions", icon: , href: "/wallet/transactions" }, + "/wallet/transactions/detail": { label: "Transaction Detail", href: "/wallet/transactions/detail" }, + "/audit": { label: "Audit", icon: , href: "/audit" }, + "/audit/reports": { label: "Reports", href: "/audit/reports" }, + "/audit/reports/2024": { label: "2024 Report", href: "/audit/reports/2024" }, + "/settings": { label: "Settings", icon: , href: "/settings" }, + "/settings/profile": { label: "Profile", href: "/settings/profile" }, +}; + +// Helper: build breadcrumb items from a pathname string +export function buildBreadcrumbsFromPath(pathname: string): import("./breadcrumb.types").BreadcrumbItem[] { + const segments = pathname.split("/").filter(Boolean); + const items: import("./breadcrumb.types").BreadcrumbItem[] = [ + { label: "Home", href: "/", icon: }, + ]; + + let cumulative = ""; + segments.forEach((seg, idx) => { + cumulative += `/${seg}`; + const meta = routeMetadata[cumulative]; + items.push({ + label: meta?.label ?? seg.charAt(0).toUpperCase() + seg.slice(1), + href: cumulative, + icon: meta?.icon, + isCurrentPage: idx === segments.length - 1, + }); + }); + + return items; +} \ No newline at end of file diff --git a/src/types/breadcrumb.types.ts b/src/types/breadcrumb.types.ts new file mode 100644 index 0000000..c6bb7e0 --- /dev/null +++ b/src/types/breadcrumb.types.ts @@ -0,0 +1,19 @@ +export interface BreadcrumbItem { + label: string; + href?: string; + icon?: React.ReactNode; + isCurrentPage?: boolean; +} + +export interface BreadcrumbProps { + items: BreadcrumbItem[]; + maxItems?: number; // max visible before collapsing (default: 4) + theme?: 'light' | 'dark'; + className?: string; +} + +export interface RouteMetadata { + label: string; + icon?: React.ReactNode; + href: string; +} \ No newline at end of file