@lateralus-ai/shipping-ui 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ModalPanel.d.ts +4 -0
- package/dist/components/PdfViewer/ImageViewer.d.ts +1 -1
- package/dist/components/PdfViewer/PdfViewer.d.ts +1 -1
- package/dist/components/PdfViewer/usePageManagement.d.ts +5 -1
- package/dist/components/SearchModal.d.ts +24 -0
- package/dist/components/Tabs.d.ts +13 -0
- package/dist/components/icons/SettingsIcon.d.ts +3 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/defect-report.pdf +0 -0
- package/dist/example.pdf +0 -0
- package/dist/index.cjs +21 -10
- package/dist/index.esm.js +17860 -1055
- package/dist/material-theme.d.ts +26 -0
- package/dist/stories/SearchModal.d.ts +1 -0
- package/dist/style.css +1 -0
- package/dist/tailwind-theme.d.ts +12 -0
- package/package.json +2 -1
- package/src/components/ModalPanel.tsx +13 -0
- package/src/components/PdfViewer/ImageViewer.tsx +8 -3
- package/src/components/PdfViewer/PdfViewer.tsx +138 -17
- package/src/components/PdfViewer/usePageManagement.ts +22 -7
- package/src/components/SearchModal.tsx +320 -0
- package/src/components/Tabs.tsx +43 -0
- package/src/components/icons/SettingsIcon.tsx +33 -0
- package/src/components/index.ts +1 -0
- package/src/material-theme.ts +30 -0
- package/src/stories/ModalHeader.stories.tsx +36 -0
- package/src/stories/PDFViewer.stories.tsx +1 -1
- package/src/stories/SearchModal.stories.tsx +132 -0
- package/src/stories/SearchModal.tsx +82 -0
- package/src/stories/Tabs.stories.tsx +51 -0
- package/src/styles/tabs.css +55 -0
- package/src/tailwind-theme.ts +12 -0
package/dist/material-theme.d.ts
CHANGED
|
@@ -413,4 +413,30 @@ export declare const materialTheme: {
|
|
|
413
413
|
};
|
|
414
414
|
};
|
|
415
415
|
};
|
|
416
|
+
tabsHeader: {
|
|
417
|
+
defaultProps: {
|
|
418
|
+
className: string;
|
|
419
|
+
};
|
|
420
|
+
};
|
|
421
|
+
tab: {
|
|
422
|
+
defaultProps: {
|
|
423
|
+
className: string;
|
|
424
|
+
};
|
|
425
|
+
styles: {
|
|
426
|
+
base: {
|
|
427
|
+
indicator: {
|
|
428
|
+
bg: string;
|
|
429
|
+
borderRadius: string;
|
|
430
|
+
boxShadow: string;
|
|
431
|
+
};
|
|
432
|
+
};
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
tabPanel: {
|
|
436
|
+
styles: {
|
|
437
|
+
base: {
|
|
438
|
+
p: string;
|
|
439
|
+
};
|
|
440
|
+
};
|
|
441
|
+
};
|
|
416
442
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SearchModals: () => import("react/jsx-runtime").JSX.Element;
|
package/dist/style.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--react-pdf-annotation-layer: 1;--annotation-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,<svg width='1px' height='1px' xmlns='http://www.w3.org/2000/svg'><rect width='100%' height='100%' style='fill:rgba(0, 54, 255, 0.13);'/></svg>");--input-focus-border-color: Highlight;--input-focus-outline: 1px solid Canvas;--input-unfocused-border-color: transparent;--input-disabled-border-color: transparent;--input-hover-border-color: black;--link-outline: none}@media screen and (forced-colors: active){:root{--input-focus-border-color: CanvasText;--input-unfocused-border-color: ActiveText;--input-disabled-border-color: GrayText;--input-hover-border-color: Highlight;--link-outline: 1.5px solid LinkText}.annotationLayer .textWidgetAnnotation :is(input,textarea):required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:required{outline:1.5px solid selectedItem}.annotationLayer .linkAnnotation:hover{-webkit-backdrop-filter:invert(100%);backdrop-filter:invert(100%)}}.annotationLayer{position:absolute;top:0;left:0;pointer-events:none;transform-origin:0 0;z-index:3}.annotationLayer[data-main-rotation="90"] .norotate{transform:rotate(270deg) translate(-100%)}.annotationLayer[data-main-rotation="180"] .norotate{transform:rotate(180deg) translate(-100%,-100%)}.annotationLayer[data-main-rotation="270"] .norotate{transform:rotate(90deg) translateY(-100%)}.annotationLayer canvas{position:absolute;width:100%;height:100%}.annotationLayer section{position:absolute;text-align:initial;pointer-events:auto;box-sizing:border-box;margin:0;transform-origin:0 0}.annotationLayer .linkAnnotation{outline:var(--link-outline)}.textLayer.selecting~.annotationLayer section{pointer-events:none}.annotationLayer :is(.linkAnnotation,.buttonWidgetAnnotation.pushButton)>a{position:absolute;font-size:1em;top:0;left:0;width:100%;height:100%}.annotationLayer :is(.linkAnnotation,.buttonWidgetAnnotation.pushButton)>a:hover{opacity:.2;background:#ff0;box-shadow:0 2px 10px #ff0}.annotationLayer .textAnnotation img{position:absolute;cursor:pointer;width:100%;height:100%;top:0;left:0}.annotationLayer .textWidgetAnnotation :is(input,textarea),.annotationLayer .choiceWidgetAnnotation select,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input{background-image:var(--annotation-unfocused-field-background);border:2px solid var(--input-unfocused-border-color);box-sizing:border-box;font:calc(9px * var(--total-scale-factor)) sans-serif;height:100%;margin:0;vertical-align:top;width:100%}.annotationLayer .textWidgetAnnotation :is(input,textarea):required,.annotationLayer .choiceWidgetAnnotation select:required,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:required{outline:1.5px solid red}.annotationLayer .choiceWidgetAnnotation select option{padding:0}.annotationLayer .buttonWidgetAnnotation.radioButton input{border-radius:50%}.annotationLayer .textWidgetAnnotation textarea{resize:none}.annotationLayer .textWidgetAnnotation :is(input,textarea)[disabled],.annotationLayer .choiceWidgetAnnotation select[disabled],.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input[disabled]{background:none;border:2px solid var(--input-disabled-border-color);cursor:not-allowed}.annotationLayer .textWidgetAnnotation :is(input,textarea):hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input:hover{border:2px solid var(--input-hover-border-color)}.annotationLayer .textWidgetAnnotation :is(input,textarea):hover,.annotationLayer .choiceWidgetAnnotation select:hover,.annotationLayer .buttonWidgetAnnotation.checkBox input:hover{border-radius:2px}.annotationLayer .textWidgetAnnotation :is(input,textarea):focus,.annotationLayer .choiceWidgetAnnotation select:focus{background:none;border:2px solid var(--input-focus-border-color);border-radius:2px;outline:var(--input-focus-outline)}.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) :focus{background-image:none;background-color:transparent}.annotationLayer .buttonWidgetAnnotation.checkBox :focus{border:2px solid var(--input-focus-border-color);border-radius:2px;outline:var(--input-focus-outline)}.annotationLayer .buttonWidgetAnnotation.radioButton :focus{border:2px solid var(--input-focus-border-color);outline:var(--input-focus-outline)}.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:before,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:after,.annotationLayer .buttonWidgetAnnotation.radioButton input:checked:before{background-color:CanvasText;content:"";display:block;position:absolute}.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:before,.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:after{height:80%;left:45%;width:1px}.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:before{transform:rotate(45deg)}.annotationLayer .buttonWidgetAnnotation.checkBox input:checked:after{transform:rotate(-45deg)}.annotationLayer .buttonWidgetAnnotation.radioButton input:checked:before{border-radius:50%;height:50%;left:30%;top:20%;width:50%}.annotationLayer .textWidgetAnnotation input.comb{font-family:monospace;padding-left:2px;padding-right:0}.annotationLayer .textWidgetAnnotation input.comb:focus{width:103%}.annotationLayer .buttonWidgetAnnotation:is(.checkBox,.radioButton) input{-webkit-appearance:none;-moz-appearance:none;appearance:none}.annotationLayer .popupTriggerArea{height:100%;width:100%}.annotationLayer .fileAttachmentAnnotation .popupTriggerArea{position:absolute}.annotationLayer .popupWrapper{position:absolute;font-size:calc(9px * var(--total-scale-factor));width:100%;min-width:calc(180px * var(--total-scale-factor));pointer-events:none}.annotationLayer .popup{position:absolute;max-width:calc(180px * var(--total-scale-factor));background-color:#ff9;box-shadow:0 calc(2px * var(--total-scale-factor)) calc(5px * var(--total-scale-factor)) #888;border-radius:calc(2px * var(--total-scale-factor));padding:calc(6px * var(--total-scale-factor));margin-left:calc(5px * var(--total-scale-factor));cursor:pointer;font:message-box;white-space:normal;word-wrap:break-word;pointer-events:auto}.annotationLayer .popup>*{font-size:calc(9px * var(--total-scale-factor))}.annotationLayer .popup h1{display:inline-block}.annotationLayer .popupDate{display:inline-block;margin-left:calc(5px * var(--total-scale-factor))}.annotationLayer .popupContent{border-top:1px solid rgba(51,51,51,1);margin-top:calc(2px * var(--total-scale-factor));padding-top:calc(2px * var(--total-scale-factor))}.annotationLayer .richText>*{white-space:pre-wrap;font-size:calc(9px * var(--total-scale-factor))}.annotationLayer .highlightAnnotation,.annotationLayer .underlineAnnotation,.annotationLayer .squigglyAnnotation,.annotationLayer .strikeoutAnnotation,.annotationLayer .freeTextAnnotation,.annotationLayer .lineAnnotation svg line,.annotationLayer .squareAnnotation svg rect,.annotationLayer .circleAnnotation svg ellipse,.annotationLayer .polylineAnnotation svg polyline,.annotationLayer .polygonAnnotation svg polygon,.annotationLayer .caretAnnotation,.annotationLayer .inkAnnotation svg polyline,.annotationLayer .stampAnnotation,.annotationLayer .fileAttachmentAnnotation{cursor:pointer}.annotationLayer section svg{position:absolute;width:100%;height:100%;top:0;left:0}.annotationLayer .annotationTextContent{position:absolute;width:100%;height:100%;opacity:0;color:transparent;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:none}.annotationLayer .annotationTextContent span{width:100%;display:inline-block}:root{--react-pdf-text-layer: 1;--highlight-bg-color: rgba(180, 0, 170, 1);--highlight-selected-bg-color: rgba(0, 100, 0, 1)}@media screen and (forced-colors: active){:root{--highlight-bg-color: Highlight;--highlight-selected-bg-color: ButtonText}}[data-main-rotation="90"]{transform:rotate(90deg) translateY(-100%)}[data-main-rotation="180"]{transform:rotate(180deg) translate(-100%,-100%)}[data-main-rotation="270"]{transform:rotate(270deg) translate(-100%)}.textLayer{position:absolute;text-align:initial;top:0;right:0;bottom:0;left:0;overflow:hidden;line-height:1;-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none;forced-color-adjust:none;transform-origin:0 0;z-index:2}.textLayer :is(span,br){color:transparent;position:absolute;white-space:pre;cursor:text;margin:0;transform-origin:0 0}.textLayer span.markedContent{top:0;height:0}.textLayer .highlight{margin:-1px;padding:1px;background-color:var(--highlight-bg-color);border-radius:4px}.textLayer .highlight.appended{position:initial}.textLayer .highlight.begin{border-radius:4px 0 0 4px}.textLayer .highlight.end{border-radius:0 4px 4px 0}.textLayer .highlight.middle{border-radius:0}.textLayer .highlight.selected{background-color:var(--highlight-selected-bg-color)}.textLayer br::-moz-selection{background:transparent}.textLayer br::selection{background:transparent}.textLayer .endOfContent{display:block;position:absolute;top:100%;right:0;bottom:0;left:0;z-index:-1;cursor:default;-webkit-user-select:none;-moz-user-select:none;user-select:none}.textLayer.selecting .endOfContent{top:0}.hiddenCanvasElement{position:absolute;top:0;left:0;width:0;height:0;display:none}.tabs-container{width:100%;min-width:400px}.tabs-header{display:flex;border-bottom:2px solid #e5e7eb;margin-bottom:16px}.tab-button{padding:12px 24px;background:none;border:none;cursor:pointer;font-size:14px;font-weight:500;color:#6b7280;position:relative;transition:color .2s}.tab-button:hover{color:#374151}.tab-button.active{color:#3b82f6}.tab-button.active:after{content:"";position:absolute;bottom:-2px;left:0;right:0;height:2px;background-color:#3b82f6}.tab-content{animation:fadeIn .3s ease-in-out}@keyframes fadeIn{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
package/dist/tailwind-theme.d.ts
CHANGED
|
@@ -71,6 +71,18 @@ export declare const theme: {
|
|
|
71
71
|
"accordion-up": string;
|
|
72
72
|
};
|
|
73
73
|
colors: {
|
|
74
|
+
brand: {
|
|
75
|
+
50: string;
|
|
76
|
+
100: string;
|
|
77
|
+
200: string;
|
|
78
|
+
300: string;
|
|
79
|
+
400: string;
|
|
80
|
+
500: string;
|
|
81
|
+
600: string;
|
|
82
|
+
700: string;
|
|
83
|
+
800: string;
|
|
84
|
+
900: string;
|
|
85
|
+
};
|
|
74
86
|
gray: {
|
|
75
87
|
50: string;
|
|
76
88
|
100: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lateralus-ai/shipping-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Shared UI theme and components for Lateralus shipping applications",
|
|
5
5
|
"main": "dist/index.cjs",
|
|
6
6
|
"module": "dist/index.esm.js",
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@iconify/react": "^6.0.2",
|
|
84
84
|
"@react-hooks-library/core": "^0.6.2",
|
|
85
|
+
"react-hotkeys-hook": "^5.1.0",
|
|
85
86
|
"react-pdf": "^10.1.0",
|
|
86
87
|
"react-router": "^7.9.3"
|
|
87
88
|
}
|
|
@@ -31,6 +31,19 @@ const Header = ({
|
|
|
31
31
|
);
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
interface BodyProps {
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Body = ({ children, className = "" }: PropsWithChildren<BodyProps>) => {
|
|
39
|
+
return (
|
|
40
|
+
<div className={`w-full overflow-auto ${className}`}>
|
|
41
|
+
{children}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
34
46
|
export const ModalPanel = {
|
|
35
47
|
Header,
|
|
48
|
+
Body,
|
|
36
49
|
};
|
|
@@ -7,6 +7,7 @@ import { useZoom } from "./useZoom";
|
|
|
7
7
|
import { useRotation } from "./useRotation";
|
|
8
8
|
import { usePanning } from "./usePanning";
|
|
9
9
|
import { usePageManagement } from "./usePageManagement";
|
|
10
|
+
import { cn } from "../../utils/cn";
|
|
10
11
|
|
|
11
12
|
interface ImageViewerProps {
|
|
12
13
|
onClose: () => void;
|
|
@@ -16,24 +17,28 @@ interface ImageViewerProps {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export const ImageViewer = ({
|
|
20
|
+
className,
|
|
19
21
|
onClose,
|
|
20
22
|
totalPages,
|
|
21
23
|
getImageSrc,
|
|
24
|
+
documentUrl,
|
|
22
25
|
title = "PDF Viewer",
|
|
23
26
|
}: ImageViewerProps) => {
|
|
24
27
|
const [zoom, zoomActions] = useZoom();
|
|
25
28
|
const [rotation, rotationActions] = useRotation();
|
|
26
29
|
const [{ pan, isDragging }, panActions] = usePanning();
|
|
27
|
-
const [currentPage, pageActions] = usePageManagement(totalPages);
|
|
30
|
+
const [{ currentPage }, pageActions] = usePageManagement(totalPages);
|
|
28
31
|
|
|
29
32
|
const rightButtons = (
|
|
30
33
|
<IconButton variant="text" color="gray">
|
|
31
|
-
<
|
|
34
|
+
<a href={documentUrl} target="_blank" rel="noopener noreferrer">
|
|
35
|
+
<ExpandIcon className="size-4" />
|
|
36
|
+
</a>
|
|
32
37
|
</IconButton>
|
|
33
38
|
);
|
|
34
39
|
|
|
35
40
|
return (
|
|
36
|
-
<div className="shadow rounded-t-lg">
|
|
41
|
+
<div className={cn("shadow rounded-t-lg flex flex-col h-full", className)}>
|
|
37
42
|
<ModalPanel.Header onClose={onClose} right={rightButtons}>
|
|
38
43
|
{title}
|
|
39
44
|
</ModalPanel.Header>
|
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { IconButton, ButtonGroup, Tooltip } from "@material-tailwind/react";
|
|
2
|
+
import { Document, Page, pdfjs } from "react-pdf";
|
|
3
3
|
import { ModalPanel } from "../ModalPanel";
|
|
4
4
|
import ExpandIcon from "../icons/ExpandIcon";
|
|
5
|
-
import {
|
|
5
|
+
import { Icon } from "@iconify/react";
|
|
6
6
|
import { cn } from "../../utils/cn";
|
|
7
|
+
import "react-pdf/dist/Page/AnnotationLayer.css";
|
|
8
|
+
import "react-pdf/dist/Page/TextLayer.css";
|
|
9
|
+
import { useState, ChangeEvent, useMemo } from "react";
|
|
10
|
+
import { useZoom } from "./useZoom";
|
|
11
|
+
import { useRotation } from "./useRotation";
|
|
12
|
+
import { usePageManagement } from "./usePageManagement";
|
|
13
|
+
import { usePanning } from "./usePanning";
|
|
7
14
|
|
|
8
|
-
|
|
15
|
+
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
|
16
|
+
|
|
17
|
+
interface PdfViewerProps extends React.HTMLProps<HTMLDivElement> {
|
|
9
18
|
onClose: () => void;
|
|
10
19
|
src: string;
|
|
11
20
|
title?: string;
|
|
@@ -17,8 +26,11 @@ export const PdfViewer = ({
|
|
|
17
26
|
title = "PDF Viewer",
|
|
18
27
|
className,
|
|
19
28
|
}: PdfViewerProps) => {
|
|
20
|
-
const
|
|
21
|
-
const
|
|
29
|
+
const [zoom, zoomActions] = useZoom();
|
|
30
|
+
const [rotation, rotationActions] = useRotation();
|
|
31
|
+
const [{ currentPage, totalPages }, pageActions] = usePageManagement();
|
|
32
|
+
const [{ pan, isDragging }, panActions] = usePanning();
|
|
33
|
+
|
|
22
34
|
|
|
23
35
|
const rightButtons = (
|
|
24
36
|
<IconButton variant="text" color="gray">
|
|
@@ -34,21 +46,130 @@ export const PdfViewer = ({
|
|
|
34
46
|
{title}
|
|
35
47
|
</ModalPanel.Header>
|
|
36
48
|
|
|
37
|
-
<div className="
|
|
38
|
-
<div
|
|
49
|
+
<div className="grid grow h-full overflow-hidden">
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"col-start-1 row-start-1 bg-gray-200 h-full overflow-hidden select-none p-8",
|
|
53
|
+
isDragging ? "cursor-grabbing" : "cursor-grab"
|
|
54
|
+
)}
|
|
55
|
+
onMouseDown={panActions.handleMouseDown}
|
|
56
|
+
onMouseMove={panActions.handleMouseMove}
|
|
57
|
+
onMouseUp={panActions.handleMouseUp}
|
|
58
|
+
onMouseLeave={panActions.handleMouseUp}
|
|
59
|
+
style={{
|
|
60
|
+
userSelect: 'none',
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
39
63
|
<div
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
style={{
|
|
65
|
+
transform: `translate(${pan.x}px, ${pan.y}px)`,
|
|
66
|
+
transition: isDragging ? 'none' : 'transform 0.1s',
|
|
67
|
+
pointerEvents: 'none',
|
|
68
|
+
}}
|
|
42
69
|
>
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
<Document
|
|
71
|
+
file={src}
|
|
72
|
+
onLoadSuccess={({ numPages }) => {
|
|
73
|
+
pageActions.setTotalPages(numPages);
|
|
74
|
+
}}
|
|
75
|
+
scale={zoom / 100}
|
|
76
|
+
rotate={rotation}
|
|
77
|
+
>
|
|
78
|
+
<Page
|
|
79
|
+
pageNumber={currentPage}
|
|
80
|
+
renderTextLayer={true}
|
|
81
|
+
renderAnnotationLayer={true}
|
|
82
|
+
/>
|
|
83
|
+
</Document>
|
|
50
84
|
</div>
|
|
51
85
|
</div>
|
|
86
|
+
|
|
87
|
+
<div className="col-start-1 row-start-1 self-end p-4 flex gap-2 justify-between w-full z-10">
|
|
88
|
+
<ButtonGroup className="divide-x-0 h-[52px]">
|
|
89
|
+
<IconButton
|
|
90
|
+
variant="filled"
|
|
91
|
+
color="white"
|
|
92
|
+
className="py-[26px]"
|
|
93
|
+
onClick={zoomActions.zoomOut}
|
|
94
|
+
>
|
|
95
|
+
<Icon icon="lucide:minus" />
|
|
96
|
+
</IconButton>
|
|
97
|
+
<Tooltip content="Click to reset zoom and pan">
|
|
98
|
+
<button
|
|
99
|
+
className="!bg-white text-center cursor-pointer w-[60px]"
|
|
100
|
+
onClick={() => {
|
|
101
|
+
zoomActions.reset();
|
|
102
|
+
panActions.reset();
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
{zoom}%
|
|
106
|
+
</button>
|
|
107
|
+
</Tooltip>
|
|
108
|
+
<IconButton
|
|
109
|
+
variant="filled"
|
|
110
|
+
color="white"
|
|
111
|
+
className="py-[26px]"
|
|
112
|
+
onClick={zoomActions.zoomIn}
|
|
113
|
+
>
|
|
114
|
+
<Icon icon="lucide:plus" />
|
|
115
|
+
</IconButton>
|
|
116
|
+
</ButtonGroup>
|
|
117
|
+
<ButtonGroup className="divide-x-0 h-[52px]">
|
|
118
|
+
<IconButton
|
|
119
|
+
variant="filled"
|
|
120
|
+
color="white"
|
|
121
|
+
className="py-[26px]"
|
|
122
|
+
onClick={rotationActions.rotateClockwise}
|
|
123
|
+
>
|
|
124
|
+
<Icon icon="lucide:iteration-cw" />
|
|
125
|
+
</IconButton>
|
|
126
|
+
|
|
127
|
+
<IconButton
|
|
128
|
+
variant="filled"
|
|
129
|
+
color="white"
|
|
130
|
+
className="py-[26px]"
|
|
131
|
+
onClick={rotationActions.rotateCounterClockwise}
|
|
132
|
+
>
|
|
133
|
+
<Icon icon="lucide:iteration-ccw" />
|
|
134
|
+
</IconButton>
|
|
135
|
+
</ButtonGroup>
|
|
136
|
+
<ButtonGroup className="divide-x-0 ">
|
|
137
|
+
<IconButton
|
|
138
|
+
variant="filled"
|
|
139
|
+
color="white"
|
|
140
|
+
className="py-[26px]"
|
|
141
|
+
onClick={pageActions.prevPage}
|
|
142
|
+
disabled={currentPage === 1}
|
|
143
|
+
>
|
|
144
|
+
<Icon icon="lucide:chevron-left" />
|
|
145
|
+
</IconButton>
|
|
146
|
+
<input
|
|
147
|
+
className="bg-white pl-4 flex items-center w-[50px] text-center focus:outline-none w-[60px]"
|
|
148
|
+
value={currentPage}
|
|
149
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
150
|
+
const page = parseInt(e.target.value);
|
|
151
|
+
if (!isNaN(page)) {
|
|
152
|
+
pageActions.goToPage(page);
|
|
153
|
+
}
|
|
154
|
+
}}
|
|
155
|
+
type="number"
|
|
156
|
+
min="1"
|
|
157
|
+
max={totalPages}
|
|
158
|
+
/>
|
|
159
|
+
<div className="flex items-center bg-white px-2">
|
|
160
|
+
of {totalPages}
|
|
161
|
+
</div>
|
|
162
|
+
<IconButton
|
|
163
|
+
variant="filled"
|
|
164
|
+
color="white"
|
|
165
|
+
className="py-[26px]"
|
|
166
|
+
onClick={pageActions.nextPage}
|
|
167
|
+
disabled={currentPage === totalPages}
|
|
168
|
+
>
|
|
169
|
+
<Icon icon="lucide:chevron-right" />
|
|
170
|
+
</IconButton>
|
|
171
|
+
</ButtonGroup>
|
|
172
|
+
</div>
|
|
52
173
|
</div>
|
|
53
174
|
</div>
|
|
54
175
|
);
|
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
2
|
|
|
3
|
-
export const usePageManagement = (
|
|
3
|
+
export const usePageManagement = (initialTotalPages: number = 0) => {
|
|
4
4
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
|
5
|
+
const [totalPages, setTotalPages] = useState<number>(initialTotalPages);
|
|
5
6
|
|
|
6
7
|
const actions = {
|
|
7
|
-
nextPage: (
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
nextPage: useCallback(() => {
|
|
9
|
+
setCurrentPage((prev) => {
|
|
10
|
+
return prev < totalPages ? prev + 1 : prev;
|
|
11
|
+
});
|
|
12
|
+
}, [totalPages]),
|
|
13
|
+
prevPage: useCallback(() => {
|
|
14
|
+
setCurrentPage((prev) => Math.max(prev - 1, 1));
|
|
15
|
+
}, []),
|
|
16
|
+
goToPage: useCallback(
|
|
17
|
+
(page: number) => {
|
|
18
|
+
setCurrentPage(() => Math.max(1, Math.min(page, totalPages)));
|
|
19
|
+
},
|
|
20
|
+
[totalPages],
|
|
21
|
+
),
|
|
22
|
+
setTotalPages: useCallback((pages: number) => {
|
|
23
|
+
setTotalPages(pages);
|
|
24
|
+
setCurrentPage((prev) => Math.min(prev, pages));
|
|
25
|
+
}, []),
|
|
11
26
|
};
|
|
12
27
|
|
|
13
|
-
return [currentPage, actions] as const;
|
|
28
|
+
return [{ currentPage, totalPages }, actions] as const;
|
|
14
29
|
};
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { Dialog, DialogBody } from "@material-tailwind/react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useHotkeys } from "react-hotkeys-hook";
|
|
4
|
+
import { useMediaQuery } from "@uidotdev/usehooks";
|
|
5
|
+
import { cn } from "../utils/cn";
|
|
6
|
+
|
|
7
|
+
export const formatDateReport = (date: string | Date) => {
|
|
8
|
+
const formattedDate = new Date(date);
|
|
9
|
+
return formattedDate.toLocaleDateString("en-US", {
|
|
10
|
+
month: "long",
|
|
11
|
+
day: "numeric",
|
|
12
|
+
year: "numeric",
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const highlightText = (text: string, searchTerm: string) => {
|
|
17
|
+
if (!searchTerm) return text;
|
|
18
|
+
|
|
19
|
+
const regex = new RegExp(`(\\b${searchTerm}\\b)`, "gi");
|
|
20
|
+
const parts = text.split(regex);
|
|
21
|
+
const firstMatchIndex = parts.findIndex((part) => regex.test(part));
|
|
22
|
+
|
|
23
|
+
if (firstMatchIndex === -1) return text;
|
|
24
|
+
|
|
25
|
+
if (firstMatchIndex === 0) {
|
|
26
|
+
return parts.map((part, i) =>
|
|
27
|
+
regex.test(part) ? (
|
|
28
|
+
<span key={i} className="text-gray-900 font-medium">
|
|
29
|
+
{part}
|
|
30
|
+
</span>
|
|
31
|
+
) : (
|
|
32
|
+
part
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const matchedPart = parts[firstMatchIndex];
|
|
38
|
+
const beforeMatch = parts.slice(0, firstMatchIndex).join("");
|
|
39
|
+
const afterMatch = parts.slice(firstMatchIndex + 1).join("");
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<span key="match" className="text-gray-900 font-medium">
|
|
44
|
+
{beforeMatch && <>...</>}
|
|
45
|
+
{matchedPart}
|
|
46
|
+
</span>
|
|
47
|
+
{afterMatch && <span key="after"> {afterMatch}</span>}
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface SearchItem {
|
|
53
|
+
id: string;
|
|
54
|
+
title: string;
|
|
55
|
+
subtitle?: string;
|
|
56
|
+
icon?: React.ReactNode;
|
|
57
|
+
metadata?: Record<string, any>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SearchModalProps {
|
|
61
|
+
open: boolean;
|
|
62
|
+
onClose: () => void;
|
|
63
|
+
items?: SearchItem[];
|
|
64
|
+
loading?: boolean;
|
|
65
|
+
placeholder?: string;
|
|
66
|
+
onSearch?: (query: string) => void;
|
|
67
|
+
onSelect?: (item: SearchItem) => void;
|
|
68
|
+
filterOptions?: string[];
|
|
69
|
+
selectedFilter?: string;
|
|
70
|
+
onFilterChange?: (filter: string) => void;
|
|
71
|
+
recentlyViewedLabel?: string;
|
|
72
|
+
noResultsText?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SearchFilters = ({
|
|
76
|
+
filters,
|
|
77
|
+
selected,
|
|
78
|
+
onChange,
|
|
79
|
+
}: {
|
|
80
|
+
filters: string[];
|
|
81
|
+
selected: string;
|
|
82
|
+
onChange: (filter: string) => void;
|
|
83
|
+
}) => {
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex gap-2 px-2">
|
|
86
|
+
{filters.map((filter) => (
|
|
87
|
+
<button
|
|
88
|
+
key={filter}
|
|
89
|
+
onClick={() => onChange(filter)}
|
|
90
|
+
className={cn(
|
|
91
|
+
"px-3 py-1 text-sm rounded-full transition-colors",
|
|
92
|
+
selected === filter
|
|
93
|
+
? "bg-brand-600 text-white"
|
|
94
|
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{filter}
|
|
98
|
+
</button>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const SearchSkeleton = () => {
|
|
105
|
+
return (
|
|
106
|
+
<div className="p-4 flex flex-col gap-[6px]">
|
|
107
|
+
{[...Array(8)].map((_, i) => (
|
|
108
|
+
<div key={i} className="flex items-center gap-3 animate-pulse py-2">
|
|
109
|
+
<div className="size-3 bg-gray-100 rounded-full" />
|
|
110
|
+
<div className={cn(i % 2 === 0 ? "w-[250px]" : "w-[320px]","h-3 bg-gray-100 rounded-full")} />
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const SearchItemComponent = ({
|
|
118
|
+
item,
|
|
119
|
+
isActive,
|
|
120
|
+
onClick,
|
|
121
|
+
searchTerm,
|
|
122
|
+
}: {
|
|
123
|
+
item: SearchItem;
|
|
124
|
+
isActive: boolean;
|
|
125
|
+
onClick: () => void;
|
|
126
|
+
searchTerm: string;
|
|
127
|
+
}) => {
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
onClick={onClick}
|
|
131
|
+
className={cn(
|
|
132
|
+
"flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors",
|
|
133
|
+
isActive ? "bg-gray-100" : "hover:bg-gray-50",
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{item.icon && <div className="shrink-0 text-gray-600">{item.icon}</div>}
|
|
137
|
+
<div className="flex-1 min-w-0">
|
|
138
|
+
<div className="text-sm font-medium text-gray-900 truncate">
|
|
139
|
+
{item.title}
|
|
140
|
+
</div>
|
|
141
|
+
{item.subtitle && (
|
|
142
|
+
<div className="text-sm text-gray-600 truncate">
|
|
143
|
+
{typeof item.subtitle === "string"
|
|
144
|
+
? highlightText(item.subtitle, searchTerm)
|
|
145
|
+
: item.subtitle}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const SearchModal = ({
|
|
154
|
+
open,
|
|
155
|
+
onClose,
|
|
156
|
+
items = [],
|
|
157
|
+
loading = false,
|
|
158
|
+
placeholder = "Search...",
|
|
159
|
+
onSearch,
|
|
160
|
+
onSelect,
|
|
161
|
+
filterOptions = [],
|
|
162
|
+
selectedFilter = "All",
|
|
163
|
+
onFilterChange,
|
|
164
|
+
recentlyViewedLabel = "Recently viewed",
|
|
165
|
+
noResultsText = "No results found.",
|
|
166
|
+
}: SearchModalProps) => {
|
|
167
|
+
const [searchValue, setSearchValue] = useState("");
|
|
168
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
169
|
+
const isMobile = useMediaQuery("only screen and (max-width: 768px)");
|
|
170
|
+
const itemRefs = useRef<HTMLDivElement[]>([]);
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
itemRefs.current = itemRefs.current.slice(0, items.length);
|
|
174
|
+
}, [items]);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (activeIndex > items.length) {
|
|
178
|
+
setActiveIndex(-1);
|
|
179
|
+
}
|
|
180
|
+
}, [items, activeIndex]);
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
const currentItem = itemRefs.current[activeIndex];
|
|
184
|
+
currentItem?.scrollIntoView({
|
|
185
|
+
behavior: "smooth",
|
|
186
|
+
block: "nearest",
|
|
187
|
+
});
|
|
188
|
+
}, [activeIndex]);
|
|
189
|
+
|
|
190
|
+
useHotkeys(
|
|
191
|
+
"ArrowUp",
|
|
192
|
+
() => {
|
|
193
|
+
if (items.length === 0) return;
|
|
194
|
+
setActiveIndex((prev) => (prev <= 0 ? items.length - 1 : prev - 1));
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
preventDefault: true,
|
|
198
|
+
enableOnFormTags: true,
|
|
199
|
+
enabled: open,
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
useHotkeys(
|
|
204
|
+
"ArrowDown",
|
|
205
|
+
() => {
|
|
206
|
+
if (items.length === 0) return;
|
|
207
|
+
setActiveIndex((prev) => (prev >= items.length - 1 ? 0 : prev + 1));
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
preventDefault: true,
|
|
211
|
+
enableOnFormTags: true,
|
|
212
|
+
enabled: open,
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
useHotkeys(
|
|
217
|
+
"Enter",
|
|
218
|
+
() => {
|
|
219
|
+
if (activeIndex >= 0 && activeIndex < items.length) {
|
|
220
|
+
handleItemSelect(items[activeIndex]);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
enableOnFormTags: true,
|
|
225
|
+
preventDefault: true,
|
|
226
|
+
enabled: open,
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const handleSearchChange = (value: string) => {
|
|
231
|
+
setSearchValue(value);
|
|
232
|
+
onSearch?.(value);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handleItemSelect = (item: SearchItem) => {
|
|
236
|
+
onSelect?.(item);
|
|
237
|
+
handleClose();
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const handleClose = () => {
|
|
241
|
+
setSearchValue("");
|
|
242
|
+
setActiveIndex(-1);
|
|
243
|
+
onClose();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const showFilters = filterOptions.length > 0 && searchValue;
|
|
247
|
+
const showRecentlyViewed = !searchValue && items.length > 0;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<Dialog
|
|
251
|
+
open={open}
|
|
252
|
+
size="lg"
|
|
253
|
+
handler={handleClose}
|
|
254
|
+
className={cn(
|
|
255
|
+
isMobile && "w-screen m-0",
|
|
256
|
+
"focus:outline-none 2xl:min-w-px",
|
|
257
|
+
)}
|
|
258
|
+
>
|
|
259
|
+
<DialogBody className="relative px-0 py-2">
|
|
260
|
+
<div>
|
|
261
|
+
<div className="border-b border-b-gray-100 px-6 pb-2">
|
|
262
|
+
<input
|
|
263
|
+
type="text"
|
|
264
|
+
placeholder={placeholder}
|
|
265
|
+
value={searchValue}
|
|
266
|
+
onChange={(e) => handleSearchChange(e.target.value)}
|
|
267
|
+
className="w-full bg-transparent p-2 px-0 text-sm font-medium text-gray-600 placeholder-gray-400 outline-none focus:outline-none"
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{loading ? (
|
|
272
|
+
<SearchSkeleton />
|
|
273
|
+
) : (
|
|
274
|
+
<div className="p-4 flex flex-col gap-2">
|
|
275
|
+
{showRecentlyViewed && (
|
|
276
|
+
<p className="text-sm font-normal text-gray-600 px-2">
|
|
277
|
+
{recentlyViewedLabel}
|
|
278
|
+
</p>
|
|
279
|
+
)}
|
|
280
|
+
|
|
281
|
+
{showFilters && (
|
|
282
|
+
<SearchFilters
|
|
283
|
+
filters={filterOptions}
|
|
284
|
+
selected={selectedFilter}
|
|
285
|
+
onChange={(filter) => onFilterChange?.(filter)}
|
|
286
|
+
/>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
<div className="overflow-y-auto max-h-[320px]" tabIndex={0}>
|
|
290
|
+
{items.map((item, index) => (
|
|
291
|
+
<div
|
|
292
|
+
key={item.id}
|
|
293
|
+
ref={(el) => {
|
|
294
|
+
if (el) itemRefs.current[index] = el;
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
<SearchItemComponent
|
|
298
|
+
item={item}
|
|
299
|
+
isActive={activeIndex === index}
|
|
300
|
+
onClick={() => handleItemSelect(item)}
|
|
301
|
+
searchTerm={searchValue}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
|
|
306
|
+
{!loading && items.length === 0 && (
|
|
307
|
+
<div className="text-sm text-gray-900 font-medium">
|
|
308
|
+
{noResultsText}
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
</DialogBody>
|
|
316
|
+
</Dialog>
|
|
317
|
+
);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export default SearchModal;
|