@lensjs/core 1.0.1
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/README.md +1 -0
- package/copy-front-build.cjs +16 -0
- package/package.json +40 -0
- package/src/abstracts/adapter.ts +41 -0
- package/src/abstracts/store.ts +36 -0
- package/src/context/container.ts +67 -0
- package/src/context/context.ts +9 -0
- package/src/core/api_controller.ts +116 -0
- package/src/core/lens.ts +147 -0
- package/src/core/watcher.ts +6 -0
- package/src/index.ts +11 -0
- package/src/stores/better_sqlite.ts +176 -0
- package/src/stores/index.ts +1 -0
- package/src/types/index.ts +103 -0
- package/src/ui/README.md +69 -0
- package/src/ui/bun.lock +750 -0
- package/src/ui/eslint.config.js +27 -0
- package/src/ui/index.html +13 -0
- package/src/ui/package-lock.json +2953 -0
- package/src/ui/package.json +40 -0
- package/src/ui/public/favicon.ico +0 -0
- package/src/ui/src/App.tsx +40 -0
- package/src/ui/src/components/DetailPanel.tsx +70 -0
- package/src/ui/src/components/JsonViewer.tsx +232 -0
- package/src/ui/src/components/LoadMore.tsx +25 -0
- package/src/ui/src/components/MethodBadge.tsx +19 -0
- package/src/ui/src/components/Modal.tsx +48 -0
- package/src/ui/src/components/StatusCode.tsx +20 -0
- package/src/ui/src/components/Table.tsx +127 -0
- package/src/ui/src/components/layout/DeleteButton.tsx +60 -0
- package/src/ui/src/components/layout/Footer.tsx +12 -0
- package/src/ui/src/components/layout/Header.tsx +40 -0
- package/src/ui/src/components/layout/Layout.tsx +49 -0
- package/src/ui/src/components/layout/LoadingScreen.tsx +14 -0
- package/src/ui/src/components/layout/Sidebar.tsx +67 -0
- package/src/ui/src/components/queryFormatters/MongoViewer.tsx +92 -0
- package/src/ui/src/components/queryFormatters/QueryViewer.tsx +18 -0
- package/src/ui/src/components/queryFormatters/SqlViewer.tsx +105 -0
- package/src/ui/src/components/table/NoData.tsx +26 -0
- package/src/ui/src/components/tabs/TabbedDataViewer.tsx +77 -0
- package/src/ui/src/containers/queries/QueriesContainer.tsx +21 -0
- package/src/ui/src/containers/queries/QueryDetailsContainer.tsx +15 -0
- package/src/ui/src/containers/requests/RequestDetailsContainer.tsx +16 -0
- package/src/ui/src/containers/requests/RequestsContainer.tsx +22 -0
- package/src/ui/src/hooks/useLensApi.ts +92 -0
- package/src/ui/src/hooks/useLoadMore.ts +48 -0
- package/src/ui/src/hooks/useQueries.ts +58 -0
- package/src/ui/src/hooks/useRequests.ts +79 -0
- package/src/ui/src/hooks/useTanstackApi.ts +126 -0
- package/src/ui/src/index.css +78 -0
- package/src/ui/src/interfaces/index.ts +10 -0
- package/src/ui/src/main.tsx +33 -0
- package/src/ui/src/router/Router.ts +11 -0
- package/src/ui/src/router/routes/Loading.tsx +5 -0
- package/src/ui/src/router/routes/index.tsx +85 -0
- package/src/ui/src/types/index.ts +95 -0
- package/src/ui/src/utils/api.ts +7 -0
- package/src/ui/src/utils/context.ts +24 -0
- package/src/ui/src/utils/date.ts +36 -0
- package/src/ui/src/views/queries/QueryDetails.tsx +58 -0
- package/src/ui/src/views/queries/QueryTable.tsx +21 -0
- package/src/ui/src/views/queries/columns.tsx +83 -0
- package/src/ui/src/views/requests/BasicRequestDetails.tsx +82 -0
- package/src/ui/src/views/requests/RequestDetails.tsx +70 -0
- package/src/ui/src/views/requests/RequetsTable.tsx +19 -0
- package/src/ui/src/views/requests/columns.tsx +62 -0
- package/src/ui/src/vite-env.d.ts +1 -0
- package/src/ui/tsconfig.app.json +27 -0
- package/src/ui/tsconfig.json +7 -0
- package/src/ui/tsconfig.node.json +25 -0
- package/src/ui/vite.config.ts +9 -0
- package/src/utils/event_emitter.ts +13 -0
- package/src/utils/index.ts +176 -0
- package/src/watchers/index.ts +2 -0
- package/src/watchers/query_watcher.ts +15 -0
- package/src/watchers/request_watcher.ts +27 -0
- package/tests/core/lens.test.ts +89 -0
- package/tests/stores/better_sqlite.test.ts +168 -0
- package/tests/utils/index.test.ts +182 -0
- package/tests/watchers/query_watcher.test.ts +35 -0
- package/tests/watchers/request_watcher.test.ts +59 -0
- package/tsconfig.json +3 -0
- package/tsup.config.ts +15 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ui",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "cross-env VITE_API_URL=http://localhost:3000 vite",
|
|
8
|
+
"build": "cross-env VITE_API_URL='' tsc -b && vite build",
|
|
9
|
+
"lint": "eslint .",
|
|
10
|
+
"preview": "vite preview"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@tailwindcss/vite": "^4.1.11",
|
|
14
|
+
"@tanstack/react-query": "^5.85.0",
|
|
15
|
+
"dayjs": "^1.11.13",
|
|
16
|
+
"js-beautify": "^1.15.4",
|
|
17
|
+
"lucide-react": "^0.541.0",
|
|
18
|
+
"react": "^19.1.1",
|
|
19
|
+
"react-dom": "^19.1.1",
|
|
20
|
+
"react-router-dom": "^7.8.0",
|
|
21
|
+
"tailwindcss": "^4.1.11"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@eslint/js": "^9.32.0",
|
|
25
|
+
"@tanstack/eslint-plugin-query": "^5.83.1",
|
|
26
|
+
"@types/js-beautify": "^1.14.3",
|
|
27
|
+
"@types/react": "^19.1.9",
|
|
28
|
+
"@types/react-dom": "^19.1.7",
|
|
29
|
+
"@vitejs/plugin-react-swc": "^3.11.0",
|
|
30
|
+
"cross-env": "^10.0.0",
|
|
31
|
+
"eslint": "^9.32.0",
|
|
32
|
+
"eslint-plugin-react-hooks": "^5.2.0",
|
|
33
|
+
"eslint-plugin-react-refresh": "^0.4.20",
|
|
34
|
+
"globals": "^16.3.0",
|
|
35
|
+
"sass-embedded": "^1.90.0",
|
|
36
|
+
"typescript": "~5.8.3",
|
|
37
|
+
"typescript-eslint": "^8.39.0",
|
|
38
|
+
"vite": "^7.1.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Suspense, useEffect, useState } from "react";
|
|
2
|
+
import Router from "./router/Router";
|
|
3
|
+
import type { LensConfig } from "./types";
|
|
4
|
+
import { prepareApiUrl } from "./utils/api";
|
|
5
|
+
import ConfigContext from "./utils/context";
|
|
6
|
+
import LoadingScreen from "./components/layout/LoadingScreen";
|
|
7
|
+
import { GlobalLoader } from "./router/routes/Loading";
|
|
8
|
+
|
|
9
|
+
const App = () => {
|
|
10
|
+
const [config, setConfig] = useState<LensConfig>({} as LensConfig);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
fetch(prepareApiUrl("/lens-config"))
|
|
15
|
+
.then((res) => res.json())
|
|
16
|
+
.then((cfg: unknown) => {
|
|
17
|
+
setConfig(cfg as LensConfig);
|
|
18
|
+
})
|
|
19
|
+
.catch((err) => {
|
|
20
|
+
console.error("Failed to load config:", err);
|
|
21
|
+
})
|
|
22
|
+
.finally(() => {
|
|
23
|
+
setLoading(false);
|
|
24
|
+
});
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
if (loading) {
|
|
28
|
+
return <LoadingScreen />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<ConfigContext.Provider value={{ config: config }}>
|
|
33
|
+
<Suspense fallback={<GlobalLoader />}>
|
|
34
|
+
<Router config={config} />
|
|
35
|
+
</Suspense>
|
|
36
|
+
</ConfigContext.Provider>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default App;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export type DetailItem = {
|
|
4
|
+
label: string;
|
|
5
|
+
value: string | React.ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
interface DetailPanelProps {
|
|
10
|
+
title: string;
|
|
11
|
+
items: DetailItem[];
|
|
12
|
+
emptyMessage?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DetailPanel: React.FC<DetailPanelProps> = ({
|
|
16
|
+
title,
|
|
17
|
+
items,
|
|
18
|
+
emptyMessage = "No data available",
|
|
19
|
+
}) => {
|
|
20
|
+
if (!items || items.length === 0 || items.every((item) => !item.value)) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-lg shadow-sm p-6">
|
|
23
|
+
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
24
|
+
{title}
|
|
25
|
+
</h2>{" "}
|
|
26
|
+
<p className="text-center text-gray-500 dark:text-gray-400">
|
|
27
|
+
{emptyMessage}
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="bg-white dark:bg-neutral-800 border border-gray-200 dark:border-neutral-700 rounded-lg shadow-sm">
|
|
35
|
+
<div className="px-6 py-4 border-b border-gray-200 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-900">
|
|
36
|
+
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
37
|
+
{title}
|
|
38
|
+
</h2>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div className="px-6 py-4">
|
|
42
|
+
<div className="space-y-4">
|
|
43
|
+
{items.map((item, index) => (
|
|
44
|
+
<div
|
|
45
|
+
key={index}
|
|
46
|
+
className="flex flex-col sm:flex-row sm:items-start gap-2"
|
|
47
|
+
>
|
|
48
|
+
<div className="w-full sm:w-32 flex-shrink-0">
|
|
49
|
+
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
50
|
+
{item.label}
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="flex-1">
|
|
54
|
+
{typeof item.value === "string" ? (
|
|
55
|
+
<span className={`text-sm ${item.className || ""}`}>
|
|
56
|
+
{item.value}
|
|
57
|
+
</span>
|
|
58
|
+
) : (
|
|
59
|
+
item.value
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default DetailPanel;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type React from "react";
|
|
4
|
+
import type { JSX } from "react";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
|
|
7
|
+
interface JsonViewerProps {
|
|
8
|
+
data: Record<string, any>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function generateRandomKey(prefix = "key") {
|
|
12
|
+
return prefix + "-" + Math.random().toString(36).substr(2, 9);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const JsonViewer: React.FC<JsonViewerProps> = ({ data }) => {
|
|
16
|
+
const [copied, setCopied] = useState(false);
|
|
17
|
+
|
|
18
|
+
const copyToClipboard = async () => {
|
|
19
|
+
try {
|
|
20
|
+
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
|
21
|
+
setCopied(true);
|
|
22
|
+
setTimeout(() => setCopied(false), 2000);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error("Failed to copy: ", err);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const CopyIcon = () => (
|
|
29
|
+
<svg
|
|
30
|
+
width="16"
|
|
31
|
+
height="16"
|
|
32
|
+
viewBox="0 0 24 24"
|
|
33
|
+
fill="none"
|
|
34
|
+
stroke="currentColor"
|
|
35
|
+
strokeWidth="2"
|
|
36
|
+
strokeLinecap="round"
|
|
37
|
+
strokeLinejoin="round"
|
|
38
|
+
>
|
|
39
|
+
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
|
40
|
+
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
|
41
|
+
</svg>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const CheckIcon = () => (
|
|
45
|
+
<svg
|
|
46
|
+
width="16"
|
|
47
|
+
height="16"
|
|
48
|
+
viewBox="0 0 24 24"
|
|
49
|
+
fill="none"
|
|
50
|
+
stroke="currentColor"
|
|
51
|
+
strokeWidth="2"
|
|
52
|
+
strokeLinecap="round"
|
|
53
|
+
strokeLinejoin="round"
|
|
54
|
+
>
|
|
55
|
+
<path d="M20 6 9 17l-5-5" />
|
|
56
|
+
</svg>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const formatJson = (obj: any, indent = 0): JSX.Element[] => {
|
|
60
|
+
const elements: JSX.Element[] = [];
|
|
61
|
+
const indentStr = " ".repeat(indent);
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(obj)) {
|
|
64
|
+
elements.push(
|
|
65
|
+
<span
|
|
66
|
+
key={generateRandomKey("open-bracket")}
|
|
67
|
+
className="text-neutral-600 dark:text-neutral-400"
|
|
68
|
+
>
|
|
69
|
+
[
|
|
70
|
+
</span>,
|
|
71
|
+
);
|
|
72
|
+
obj.forEach((item, index) => {
|
|
73
|
+
elements.push(<br key={generateRandomKey("br")} />);
|
|
74
|
+
elements.push(
|
|
75
|
+
<span key={generateRandomKey("indent")} className="text-transparent">
|
|
76
|
+
{indentStr}{" "}
|
|
77
|
+
</span>,
|
|
78
|
+
);
|
|
79
|
+
elements.push(...formatJson(item, indent + 1));
|
|
80
|
+
if (index < obj.length - 1) {
|
|
81
|
+
elements.push(
|
|
82
|
+
<span
|
|
83
|
+
key={generateRandomKey("comma")}
|
|
84
|
+
className="text-neutral-600 dark:text-neutral-400"
|
|
85
|
+
>
|
|
86
|
+
,
|
|
87
|
+
</span>,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
elements.push(<br key={generateRandomKey("br-close")} />);
|
|
92
|
+
elements.push(
|
|
93
|
+
<span
|
|
94
|
+
key={generateRandomKey("indent-close")}
|
|
95
|
+
className="text-transparent"
|
|
96
|
+
>
|
|
97
|
+
{indentStr}
|
|
98
|
+
</span>,
|
|
99
|
+
);
|
|
100
|
+
elements.push(
|
|
101
|
+
<span
|
|
102
|
+
key={generateRandomKey("close-bracket")}
|
|
103
|
+
className="text-neutral-600 dark:text-neutral-400"
|
|
104
|
+
>
|
|
105
|
+
]
|
|
106
|
+
</span>,
|
|
107
|
+
);
|
|
108
|
+
} else if (typeof obj === "object" && obj !== null) {
|
|
109
|
+
elements.push(
|
|
110
|
+
<span
|
|
111
|
+
key={generateRandomKey("open-brace")}
|
|
112
|
+
className="text-neutral-600 dark:text-neutral-400"
|
|
113
|
+
>
|
|
114
|
+
{"{"}
|
|
115
|
+
</span>,
|
|
116
|
+
);
|
|
117
|
+
const entries = Object.entries(obj);
|
|
118
|
+
entries.forEach(([key, value], index) => {
|
|
119
|
+
elements.push(<br key={generateRandomKey("br")} />);
|
|
120
|
+
elements.push(
|
|
121
|
+
<span key={generateRandomKey("indent")} className="text-transparent">
|
|
122
|
+
{indentStr}{" "}
|
|
123
|
+
</span>,
|
|
124
|
+
);
|
|
125
|
+
elements.push(
|
|
126
|
+
<span
|
|
127
|
+
key={generateRandomKey("key")}
|
|
128
|
+
className="text-blue-600 dark:text-blue-400"
|
|
129
|
+
>
|
|
130
|
+
"{key}"
|
|
131
|
+
</span>,
|
|
132
|
+
);
|
|
133
|
+
elements.push(
|
|
134
|
+
<span
|
|
135
|
+
key={generateRandomKey("colon")}
|
|
136
|
+
className="text-neutral-600 dark:text-neutral-400"
|
|
137
|
+
>
|
|
138
|
+
:{" "}
|
|
139
|
+
</span>,
|
|
140
|
+
);
|
|
141
|
+
if (typeof value === "string") {
|
|
142
|
+
elements.push(
|
|
143
|
+
<span
|
|
144
|
+
key={generateRandomKey("value-string")}
|
|
145
|
+
className="text-green-600 dark:text-green-400"
|
|
146
|
+
>
|
|
147
|
+
"{value}"
|
|
148
|
+
</span>,
|
|
149
|
+
);
|
|
150
|
+
} else if (typeof value === "number") {
|
|
151
|
+
elements.push(
|
|
152
|
+
<span
|
|
153
|
+
key={generateRandomKey("value-number")}
|
|
154
|
+
className="text-orange-600 dark:text-orange-400"
|
|
155
|
+
>
|
|
156
|
+
{value}
|
|
157
|
+
</span>,
|
|
158
|
+
);
|
|
159
|
+
} else if (typeof value === "boolean") {
|
|
160
|
+
elements.push(
|
|
161
|
+
<span
|
|
162
|
+
key={generateRandomKey("value-boolean")}
|
|
163
|
+
className="text-purple-600 dark:text-purple-400"
|
|
164
|
+
>
|
|
165
|
+
{value.toString()}
|
|
166
|
+
</span>,
|
|
167
|
+
);
|
|
168
|
+
} else if (value === null) {
|
|
169
|
+
elements.push(
|
|
170
|
+
<span
|
|
171
|
+
key={generateRandomKey("value-null")}
|
|
172
|
+
className="text-neutral-500 dark:text-neutral-400"
|
|
173
|
+
>
|
|
174
|
+
null
|
|
175
|
+
</span>,
|
|
176
|
+
);
|
|
177
|
+
} else {
|
|
178
|
+
elements.push(...formatJson(value, indent + 1));
|
|
179
|
+
}
|
|
180
|
+
if (index < entries.length - 1) {
|
|
181
|
+
elements.push(
|
|
182
|
+
<span
|
|
183
|
+
key={generateRandomKey("comma")}
|
|
184
|
+
className="text-neutral-600 dark:text-neutral-400"
|
|
185
|
+
>
|
|
186
|
+
,
|
|
187
|
+
</span>,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
elements.push(<br key={generateRandomKey("br-close")} />);
|
|
192
|
+
elements.push(
|
|
193
|
+
<span
|
|
194
|
+
key={generateRandomKey("indent-close")}
|
|
195
|
+
className="text-transparent"
|
|
196
|
+
>
|
|
197
|
+
{indentStr}
|
|
198
|
+
</span>,
|
|
199
|
+
);
|
|
200
|
+
elements.push(
|
|
201
|
+
<span
|
|
202
|
+
key={generateRandomKey("close-brace")}
|
|
203
|
+
className="text-neutral-600 dark:text-neutral-400"
|
|
204
|
+
>
|
|
205
|
+
{"}"}
|
|
206
|
+
</span>,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return elements;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div className="bg-neutral-50 dark:bg-slate-900 rounded-lg p-4 font-mono text-sm overflow-x-auto relative">
|
|
214
|
+
<button
|
|
215
|
+
onClick={copyToClipboard}
|
|
216
|
+
className={`absolute top-3 right-3 p-2 rounded-md transition-colors ${
|
|
217
|
+
copied
|
|
218
|
+
? "bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400"
|
|
219
|
+
: "bg-white text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 dark:bg-slate-800 dark:text-neutral-400 dark:hover:text-neutral-300 dark:hover:bg-slate-700"
|
|
220
|
+
}`}
|
|
221
|
+
title={copied ? "Copied!" : "Copy to clipboard"}
|
|
222
|
+
>
|
|
223
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
224
|
+
</button>
|
|
225
|
+
<pre className="whitespace-pre-wrap pr-12 text-neutral-800 dark:text-neutral-200">
|
|
226
|
+
{formatJson(data)}
|
|
227
|
+
</pre>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export default JsonViewer;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { twMerge } from "tailwind-merge";
|
|
2
|
+
import type { HasMoreType } from "../types";
|
|
3
|
+
|
|
4
|
+
export function LoadMoreButton({
|
|
5
|
+
paginatedPage,
|
|
6
|
+
}: {
|
|
7
|
+
paginatedPage: HasMoreType<any>;
|
|
8
|
+
}) {
|
|
9
|
+
if (!paginatedPage.hasMore) return null;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="flex justify-center p-4">
|
|
13
|
+
<button
|
|
14
|
+
onClick={paginatedPage.loadMore}
|
|
15
|
+
disabled={paginatedPage.loading}
|
|
16
|
+
className={twMerge(
|
|
17
|
+
"px-4 py-2 rounded-md bg-gray-200 text-gray-800 dark:bg-neutral-800 dark:text-white text-sm hover:bg-gray-300 dark:hover:bg-neutral-700 transition-colors",
|
|
18
|
+
paginatedPage.loading && "opacity-50 cursor-not-allowed"
|
|
19
|
+
)}
|
|
20
|
+
>
|
|
21
|
+
{paginatedPage.loading ? "Loading..." : "Load More"}
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export default function MethodBadge({ method }: { method: string }) {
|
|
2
|
+
const colors: Record<string, string> = {
|
|
3
|
+
GET: "bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-white",
|
|
4
|
+
POST: "bg-blue-100 text-blue-800 dark:bg-blue-600 dark:text-white",
|
|
5
|
+
PUT: "bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-white",
|
|
6
|
+
DELETE: "bg-red-100 text-red-800 dark:bg-red-600 dark:text-white",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<span
|
|
11
|
+
className={`rounded px-2 py-1 text-sm font-semibold ${
|
|
12
|
+
colors[method.toUpperCase()] || "bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-white"
|
|
13
|
+
}`}
|
|
14
|
+
>
|
|
15
|
+
{method}
|
|
16
|
+
</span>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
|
|
4
|
+
interface ModalProps {
|
|
5
|
+
visible: boolean;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const Modal = ({ visible, onClose, children }: ModalProps) => {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
|
|
14
|
+
addEventListener(
|
|
15
|
+
"keydown",
|
|
16
|
+
(e) => {
|
|
17
|
+
if (e.key === "Escape") {
|
|
18
|
+
onClose();
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{ signal: controller.signal },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return () => {
|
|
25
|
+
controller.abort();
|
|
26
|
+
};
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
if (!visible) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return createPortal(
|
|
34
|
+
<div className="fixed inset-0 z-[99] flex items-center justify-center">
|
|
35
|
+
<div
|
|
36
|
+
className="absolute inset-0 z-[99] bg-black/40 backdrop-blur-[3px]"
|
|
37
|
+
aria-hidden={!open}
|
|
38
|
+
onClick={onClose}
|
|
39
|
+
role="button"
|
|
40
|
+
tabIndex={-1}
|
|
41
|
+
></div>
|
|
42
|
+
<div className="z-[100]">{children}</div>
|
|
43
|
+
</div>,
|
|
44
|
+
document.body,
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export default Modal;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const getColorClass = (status: number) => {
|
|
2
|
+
if (status >= 200 && status < 300) return "bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100";
|
|
3
|
+
if (status >= 300 && status < 400) return "bg-blue-100 text-blue-800 dark:bg-blue-700 dark:text-blue-100";
|
|
4
|
+
if (status >= 400 && status < 500) return "bg-yellow-100 text-yellow-800 dark:bg-yellow-700 dark:text-yellow-100";
|
|
5
|
+
if (status >= 500) return "bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100";
|
|
6
|
+
|
|
7
|
+
return "bg-gray-100 text-gray-800 dark:bg-neutral-700 dark:text-neutral-200";
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const StatusCode = ({ status }: { status: number }) => {
|
|
11
|
+
return (
|
|
12
|
+
<span
|
|
13
|
+
className={`rounded-lg px-2 py-1 text-sm font-semibold ${getColorClass(status)}`}
|
|
14
|
+
>
|
|
15
|
+
{status}
|
|
16
|
+
</span>
|
|
17
|
+
);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default StatusCode;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
type Position = "start" | "end";
|
|
5
|
+
|
|
6
|
+
export function getNestedValue<T>(obj: T, path: string): unknown {
|
|
7
|
+
if (!path.trim()) return obj;
|
|
8
|
+
|
|
9
|
+
return path.split(".").reduce<unknown>((acc, key) => {
|
|
10
|
+
if (typeof acc === "object" && acc !== null && key in acc) {
|
|
11
|
+
return (acc as Record<string, unknown>)[key];
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}, obj);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type BaseColumn<T> = {
|
|
18
|
+
name: string;
|
|
19
|
+
position?: Position;
|
|
20
|
+
class?: string;
|
|
21
|
+
prefix?: (row: T) => ReactNode;
|
|
22
|
+
suffix?: (row: T) => ReactNode;
|
|
23
|
+
headPrefix?: () => ReactNode;
|
|
24
|
+
icon?: (row: T) => ReactNode;
|
|
25
|
+
hidden?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type TableColumn<T> =
|
|
29
|
+
| (BaseColumn<T> & {
|
|
30
|
+
key: string;
|
|
31
|
+
render?: undefined;
|
|
32
|
+
value?: undefined;
|
|
33
|
+
})
|
|
34
|
+
| (BaseColumn<T> & {
|
|
35
|
+
render: (row: T) => ReactNode;
|
|
36
|
+
key?: undefined;
|
|
37
|
+
value?: undefined;
|
|
38
|
+
})
|
|
39
|
+
| (BaseColumn<T> & {
|
|
40
|
+
key?: undefined;
|
|
41
|
+
render?: undefined;
|
|
42
|
+
value: (row: T) => string | number;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
interface TableProps<T> {
|
|
46
|
+
columns: TableColumn<T>[];
|
|
47
|
+
data: T[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Table<T>({ columns: columnsProp, data }: TableProps<T>) {
|
|
51
|
+
const columns = columnsProp.filter((column) => !column.hidden);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="relative overflow-x-auto">
|
|
55
|
+
<table className="w-full text-start">
|
|
56
|
+
<thead>
|
|
57
|
+
<tr>
|
|
58
|
+
{columns.map((column, i) => (
|
|
59
|
+
<th
|
|
60
|
+
key={i}
|
|
61
|
+
scope="col"
|
|
62
|
+
className={twMerge(
|
|
63
|
+
"min-w-32 bg-gray-50 dark:bg-neutral-900 p-5 text-sm font-semibold text-gray-900 dark:text-neutral-200 first:rounded-s-lg last:rounded-e-lg",
|
|
64
|
+
column.position === "end" ? "text-end" : ""
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<div
|
|
68
|
+
className={twMerge(
|
|
69
|
+
"flex items-center gap-2",
|
|
70
|
+
column.position === "end" ? "justify-end" : "justify-start"
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{column.headPrefix && column.headPrefix()}
|
|
74
|
+
{column.name}
|
|
75
|
+
</div>
|
|
76
|
+
</th>
|
|
77
|
+
))}
|
|
78
|
+
</tr>
|
|
79
|
+
</thead>
|
|
80
|
+
<tbody>
|
|
81
|
+
{data.map((row, rowIndex) => (
|
|
82
|
+
<tr
|
|
83
|
+
key={rowIndex}
|
|
84
|
+
className="max-h-none overflow-hidden rounded-lg text-sm font-medium text-gray-800 dark:text-neutral-400 even:[&_td]:bg-gray-25 dark:even:[&_td]:bg-neutral-950 transition-colors"
|
|
85
|
+
>
|
|
86
|
+
{columns.map((column, colIndex) => (
|
|
87
|
+
<td
|
|
88
|
+
key={colIndex}
|
|
89
|
+
className={twMerge(
|
|
90
|
+
"p-5 first:rounded-s-lg last:rounded-e-lg bg-white dark:bg-transparent",
|
|
91
|
+
column.position === "end" ? "text-end" : "text-start"
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
className={twMerge(
|
|
96
|
+
"flex items-center gap-1",
|
|
97
|
+
colIndex < columns.length - 1 &&
|
|
98
|
+
column.position !== "end" &&
|
|
99
|
+
"pe-8",
|
|
100
|
+
column.class,
|
|
101
|
+
column.position === "end"
|
|
102
|
+
? "justify-end"
|
|
103
|
+
: "justify-start"
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
{column.icon && column.icon(row)}
|
|
107
|
+
{column.prefix && column.prefix(row)}
|
|
108
|
+
{column.render
|
|
109
|
+
? column.render(row)
|
|
110
|
+
: column.key
|
|
111
|
+
? String(getNestedValue(row, column.key) ?? "-")
|
|
112
|
+
: column.value
|
|
113
|
+
? column.value(row)
|
|
114
|
+
: "-"}
|
|
115
|
+
{column.suffix && column.suffix(row)}
|
|
116
|
+
</div>
|
|
117
|
+
</td>
|
|
118
|
+
))}
|
|
119
|
+
</tr>
|
|
120
|
+
))}
|
|
121
|
+
</tbody>
|
|
122
|
+
</table>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default Table;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Trash2 } from "lucide-react";
|
|
2
|
+
import { useConfig } from "../../utils/context";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import Modal from "../Modal";
|
|
5
|
+
import { prepareApiUrl } from "../../utils/api";
|
|
6
|
+
|
|
7
|
+
export default function DeleteButton() {
|
|
8
|
+
const config = useConfig();
|
|
9
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
10
|
+
const [loading, setLoading] = useState(false);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<Modal visible={isOpen} onClose={() => setIsOpen(false)}>
|
|
15
|
+
<div className="flex flex-col items-center justify-center gap-4 sm:p-12 p-6 bg-neutral-900 text-white rounded-2xl shadow-2xl border-2 border-neutral-800">
|
|
16
|
+
<h1 className="text-2xl font-bold">Are you sure?</h1>
|
|
17
|
+
<p className="text-gray-500">
|
|
18
|
+
This will delete all entries. This action cannot be undone.
|
|
19
|
+
</p>
|
|
20
|
+
<div className="flex items-center justify-center gap-4">
|
|
21
|
+
<button className="" onClick={() => setIsOpen(false)}>
|
|
22
|
+
Cancel
|
|
23
|
+
</button>
|
|
24
|
+
<button
|
|
25
|
+
className="bg-red-500 hover:bg-red-600 text-white font-bold py-1.5 px-4 rounded-2xl"
|
|
26
|
+
disabled={loading}
|
|
27
|
+
onClick={async () => {
|
|
28
|
+
try {
|
|
29
|
+
setLoading(true);
|
|
30
|
+
await fetch(prepareApiUrl(config.api.truncate), {
|
|
31
|
+
method: "DELETE",
|
|
32
|
+
});
|
|
33
|
+
window.location.reload();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error("Failed to delete:", error);
|
|
36
|
+
} finally {
|
|
37
|
+
setLoading(false);
|
|
38
|
+
}
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
Confirm
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</Modal>
|
|
46
|
+
<button
|
|
47
|
+
onClick={() => setIsOpen(true)}
|
|
48
|
+
className="relative p-2.5 rounded-[10px] border border-neutral-800 hover:bg-gray-800 bg-neutral-900 transition-all duration-200 shadow-sm hover:shadow-md group"
|
|
49
|
+
aria-label="Delete entries"
|
|
50
|
+
>
|
|
51
|
+
<div className="relative w-5 h-5">
|
|
52
|
+
<Trash2
|
|
53
|
+
size={20}
|
|
54
|
+
className="text-red-500 group-hover:text-red-400 duration-200 transform transition-transform"
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
</button>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|