@riligar/elysia-sqlite 1.1.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/LICENSE +201 -0
- package/README.md +121 -0
- package/package.json +61 -0
- package/src/core/session.js +90 -0
- package/src/index.js +553 -0
- package/src/ui/bun.lock +612 -0
- package/src/ui/index.html +18 -0
- package/src/ui/package.json +29 -0
- package/src/ui/postcss.config.cjs +14 -0
- package/src/ui/src/App.jsx +2103 -0
- package/src/ui/src/components/DataGrid.jsx +122 -0
- package/src/ui/src/components/EditableCell.jsx +166 -0
- package/src/ui/src/components/ExportButton.jsx +95 -0
- package/src/ui/src/components/FKPreview.jsx +106 -0
- package/src/ui/src/components/Filter.jsx +302 -0
- package/src/ui/src/components/HoldButton.jsx +230 -0
- package/src/ui/src/components/Login.jsx +148 -0
- package/src/ui/src/components/Onboarding.jsx +127 -0
- package/src/ui/src/components/Pagination.jsx +35 -0
- package/src/ui/src/components/SecuritySettings.jsx +273 -0
- package/src/ui/src/components/TableSelector.jsx +75 -0
- package/src/ui/src/hooks/useFilter.js +120 -0
- package/src/ui/src/index.css +123 -0
- package/src/ui/src/main.jsx +115 -0
- package/src/ui/vite.config.js +19 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
TextInput,
|
|
4
|
+
Paper,
|
|
5
|
+
Group,
|
|
6
|
+
Badge,
|
|
7
|
+
Stack,
|
|
8
|
+
Text,
|
|
9
|
+
Chip,
|
|
10
|
+
ActionIcon,
|
|
11
|
+
Button,
|
|
12
|
+
} from "@mantine/core";
|
|
13
|
+
import { useClickOutside } from "@mantine/hooks";
|
|
14
|
+
import { IconSearch, IconX, IconRefresh } from "@tabler/icons-react";
|
|
15
|
+
|
|
16
|
+
export function Filter({
|
|
17
|
+
data: { query, activeFilters, logicalOperators },
|
|
18
|
+
setData: { setQuery, setActiveFilters, setLogicalOperators },
|
|
19
|
+
filterOptions = {},
|
|
20
|
+
}) {
|
|
21
|
+
const [showMenu, setShowMenu] = useState(false);
|
|
22
|
+
const [menuStep, setMenuStep] = useState(null);
|
|
23
|
+
const [tempKey, setTempKey] = useState(null);
|
|
24
|
+
const [tempOperator, setTempOperator] = useState(null);
|
|
25
|
+
|
|
26
|
+
const ref = useClickOutside(() => {
|
|
27
|
+
setShowMenu(false);
|
|
28
|
+
setMenuStep(null);
|
|
29
|
+
setTempKey(null);
|
|
30
|
+
setTempOperator(null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const operators = [
|
|
34
|
+
"=",
|
|
35
|
+
"!=",
|
|
36
|
+
">",
|
|
37
|
+
"<",
|
|
38
|
+
">=",
|
|
39
|
+
"<=",
|
|
40
|
+
"contains",
|
|
41
|
+
"not_contains",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const getMenuOptions = () => {
|
|
45
|
+
if (menuStep === "key") {
|
|
46
|
+
return Object.keys(filterOptions);
|
|
47
|
+
}
|
|
48
|
+
if (menuStep === "operator") {
|
|
49
|
+
return operators;
|
|
50
|
+
}
|
|
51
|
+
if (menuStep === "value" && tempKey) {
|
|
52
|
+
if (
|
|
53
|
+
Array.isArray(filterOptions[tempKey]) &&
|
|
54
|
+
filterOptions[tempKey].length > 0
|
|
55
|
+
) {
|
|
56
|
+
return filterOptions[tempKey];
|
|
57
|
+
}
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return [];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const selectMenuOption = (option) => {
|
|
64
|
+
if (menuStep === "key") {
|
|
65
|
+
setTempKey(option);
|
|
66
|
+
setMenuStep("operator");
|
|
67
|
+
} else if (menuStep === "operator") {
|
|
68
|
+
setTempOperator(option);
|
|
69
|
+
setMenuStep("value");
|
|
70
|
+
} else if (menuStep === "value" && tempKey && tempOperator) {
|
|
71
|
+
const filterValue = `${tempOperator} ${option}`;
|
|
72
|
+
setActiveFilters((prev) => {
|
|
73
|
+
const current = prev[tempKey];
|
|
74
|
+
const newValue = current ? `${current},${filterValue}` : filterValue;
|
|
75
|
+
return {
|
|
76
|
+
...prev,
|
|
77
|
+
[tempKey]: newValue,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
closeMenu();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const closeMenu = () => {
|
|
85
|
+
setShowMenu(false);
|
|
86
|
+
setMenuStep(null);
|
|
87
|
+
setTempKey(null);
|
|
88
|
+
setTempOperator(null);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const removeFilter = (key) => {
|
|
92
|
+
setActiveFilters((prev) => {
|
|
93
|
+
const updated = { ...prev };
|
|
94
|
+
delete updated[key];
|
|
95
|
+
return updated;
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const clearAllFilters = () => {
|
|
100
|
+
setActiveFilters({});
|
|
101
|
+
setQuery("");
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const hasActiveFilters = Object.keys(activeFilters).length > 0;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Stack gap="xs">
|
|
108
|
+
{/* Unified Search/Filter Input */}
|
|
109
|
+
<div style={{ position: "relative" }} ref={ref}>
|
|
110
|
+
<TextInput
|
|
111
|
+
placeholder="Search or filter..."
|
|
112
|
+
leftSection={<IconSearch size={14} />}
|
|
113
|
+
rightSection={
|
|
114
|
+
query.trim() || hasActiveFilters ? (
|
|
115
|
+
<ActionIcon
|
|
116
|
+
size="xs"
|
|
117
|
+
color="gray"
|
|
118
|
+
radius="sm"
|
|
119
|
+
variant="subtle"
|
|
120
|
+
onClick={clearAllFilters}
|
|
121
|
+
>
|
|
122
|
+
<IconX size={12} />
|
|
123
|
+
</ActionIcon>
|
|
124
|
+
) : null
|
|
125
|
+
}
|
|
126
|
+
size="sm"
|
|
127
|
+
radius="md"
|
|
128
|
+
value={query}
|
|
129
|
+
onChange={(e) => setQuery(e.currentTarget.value)}
|
|
130
|
+
onFocus={() => {
|
|
131
|
+
if (!showMenu) {
|
|
132
|
+
setMenuStep("key");
|
|
133
|
+
setShowMenu(true);
|
|
134
|
+
}
|
|
135
|
+
}}
|
|
136
|
+
styles={{
|
|
137
|
+
input: {
|
|
138
|
+
border: "1px solid #E5E5E5",
|
|
139
|
+
"&:focus": {
|
|
140
|
+
borderColor: "#9CA3AF",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
{/* Filter Picker Dropdown */}
|
|
147
|
+
{showMenu && menuStep && (
|
|
148
|
+
<Paper
|
|
149
|
+
shadow="sm"
|
|
150
|
+
p="sm"
|
|
151
|
+
radius="md"
|
|
152
|
+
withBorder
|
|
153
|
+
style={{
|
|
154
|
+
position: "absolute",
|
|
155
|
+
top: "100%",
|
|
156
|
+
left: 0,
|
|
157
|
+
right: 0,
|
|
158
|
+
zIndex: 1000,
|
|
159
|
+
marginTop: 4,
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<Stack gap={8}>
|
|
163
|
+
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
|
164
|
+
{menuStep === "key" && "Filter by column"}
|
|
165
|
+
{menuStep === "operator" && `Operator for ${tempKey}`}
|
|
166
|
+
{menuStep === "value" && `Value for ${tempKey} ${tempOperator}`}
|
|
167
|
+
</Text>
|
|
168
|
+
<Group gap={6} wrap="wrap">
|
|
169
|
+
{getMenuOptions().map((option, idx) => (
|
|
170
|
+
<Chip
|
|
171
|
+
key={idx}
|
|
172
|
+
onClick={() => selectMenuOption(option)}
|
|
173
|
+
variant="light"
|
|
174
|
+
checked={false}
|
|
175
|
+
size="xs"
|
|
176
|
+
radius="md"
|
|
177
|
+
styles={{
|
|
178
|
+
label: {
|
|
179
|
+
cursor: "pointer",
|
|
180
|
+
},
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{option}
|
|
184
|
+
</Chip>
|
|
185
|
+
))}
|
|
186
|
+
{menuStep === "value" && (
|
|
187
|
+
<TextInput
|
|
188
|
+
placeholder="Type value..."
|
|
189
|
+
size="xs"
|
|
190
|
+
radius="md"
|
|
191
|
+
autoFocus
|
|
192
|
+
style={{ flex: 1, minWidth: 120 }}
|
|
193
|
+
onKeyDown={(e) => {
|
|
194
|
+
if (e.key === "Enter") {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
e.stopPropagation();
|
|
197
|
+
selectMenuOption(e.currentTarget.value);
|
|
198
|
+
}
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
)}
|
|
202
|
+
</Group>
|
|
203
|
+
</Stack>
|
|
204
|
+
</Paper>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{/* Active Filters Display */}
|
|
209
|
+
{hasActiveFilters && (
|
|
210
|
+
<Group gap={6} wrap="wrap" align="center">
|
|
211
|
+
{Object.entries(activeFilters).map(
|
|
212
|
+
([key, valueStr], filterIdx, filterArray) => {
|
|
213
|
+
const values = valueStr.split(",").map((v) => v.trim());
|
|
214
|
+
const isLastFilter = filterIdx === filterArray.length - 1;
|
|
215
|
+
return (
|
|
216
|
+
<Group key={key} gap={4} align="center">
|
|
217
|
+
<Group gap={2}>
|
|
218
|
+
{values.map((value, idx) => (
|
|
219
|
+
<Badge
|
|
220
|
+
key={`${key}-${idx}`}
|
|
221
|
+
size="md"
|
|
222
|
+
radius="md"
|
|
223
|
+
variant="outline"
|
|
224
|
+
color="gray"
|
|
225
|
+
pr={4}
|
|
226
|
+
rightSection={
|
|
227
|
+
<IconX
|
|
228
|
+
size={12}
|
|
229
|
+
style={{ cursor: "pointer" }}
|
|
230
|
+
onClick={() => {
|
|
231
|
+
const newValues = values.filter(
|
|
232
|
+
(_, i) => i !== idx
|
|
233
|
+
);
|
|
234
|
+
if (newValues.length === 0) {
|
|
235
|
+
removeFilter(key);
|
|
236
|
+
} else {
|
|
237
|
+
setActiveFilters((prev) => ({
|
|
238
|
+
...prev,
|
|
239
|
+
[key]: newValues.join(","),
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
}
|
|
245
|
+
styles={{
|
|
246
|
+
root: {
|
|
247
|
+
textTransform: "none",
|
|
248
|
+
fontWeight: 500,
|
|
249
|
+
},
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
<Text span c="dimmed" size="xs">
|
|
253
|
+
{key}
|
|
254
|
+
</Text>{" "}
|
|
255
|
+
{value}
|
|
256
|
+
</Badge>
|
|
257
|
+
))}
|
|
258
|
+
</Group>
|
|
259
|
+
{!isLastFilter && (
|
|
260
|
+
<Button
|
|
261
|
+
size="xs"
|
|
262
|
+
variant="subtle"
|
|
263
|
+
color="gray"
|
|
264
|
+
onClick={() => {
|
|
265
|
+
setLogicalOperators((prev) => ({
|
|
266
|
+
...prev,
|
|
267
|
+
[key]: prev[key] === "OR" ? "AND" : "OR",
|
|
268
|
+
}));
|
|
269
|
+
}}
|
|
270
|
+
title="Toggle AND/OR"
|
|
271
|
+
fw={700}
|
|
272
|
+
px={4}
|
|
273
|
+
h="auto"
|
|
274
|
+
py={2}
|
|
275
|
+
c="dimmed"
|
|
276
|
+
styles={{
|
|
277
|
+
label: {
|
|
278
|
+
fontSize: "10px",
|
|
279
|
+
},
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
{logicalOperators[key] || "AND"}
|
|
283
|
+
</Button>
|
|
284
|
+
)}
|
|
285
|
+
</Group>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
)}
|
|
289
|
+
<ActionIcon
|
|
290
|
+
size="sm"
|
|
291
|
+
variant="subtle"
|
|
292
|
+
color="red"
|
|
293
|
+
onClick={clearAllFilters}
|
|
294
|
+
title="Clear all"
|
|
295
|
+
>
|
|
296
|
+
<IconX size={14} />
|
|
297
|
+
</ActionIcon>
|
|
298
|
+
</Group>
|
|
299
|
+
)}
|
|
300
|
+
</Stack>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ActionIcon,
|
|
4
|
+
RingProgress,
|
|
5
|
+
Tooltip,
|
|
6
|
+
Box,
|
|
7
|
+
Button,
|
|
8
|
+
Loader,
|
|
9
|
+
} from "@mantine/core";
|
|
10
|
+
import { IconTrash, IconCopy, IconCheck } from "@tabler/icons-react";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HoldButton - Botão que requer segurar por um tempo para confirmar ação
|
|
14
|
+
* Ideal para ações destrutivas como delete
|
|
15
|
+
*/
|
|
16
|
+
export function HoldButton({
|
|
17
|
+
onConfirm,
|
|
18
|
+
holdDuration = 3000,
|
|
19
|
+
icon: Icon = IconTrash,
|
|
20
|
+
color = "red",
|
|
21
|
+
size = "md",
|
|
22
|
+
tooltip = "Segure para confirmar",
|
|
23
|
+
confirmingTooltip = "Segurando...",
|
|
24
|
+
...props
|
|
25
|
+
}) {
|
|
26
|
+
const [progress, setProgress] = useState(0);
|
|
27
|
+
const [isHolding, setIsHolding] = useState(false);
|
|
28
|
+
const intervalRef = useRef(null);
|
|
29
|
+
const startTimeRef = useRef(null);
|
|
30
|
+
|
|
31
|
+
const startHold = useCallback(() => {
|
|
32
|
+
if (props.disabled || props.loading) return;
|
|
33
|
+
|
|
34
|
+
setIsHolding(true);
|
|
35
|
+
startTimeRef.current = Date.now();
|
|
36
|
+
|
|
37
|
+
intervalRef.current = setInterval(() => {
|
|
38
|
+
const elapsed = Date.now() - startTimeRef.current;
|
|
39
|
+
const newProgress = Math.min((elapsed / holdDuration) * 100, 100);
|
|
40
|
+
setProgress(newProgress);
|
|
41
|
+
|
|
42
|
+
if (newProgress >= 100) {
|
|
43
|
+
clearInterval(intervalRef.current);
|
|
44
|
+
setIsHolding(false);
|
|
45
|
+
setProgress(0);
|
|
46
|
+
onConfirm?.();
|
|
47
|
+
}
|
|
48
|
+
}, 50);
|
|
49
|
+
}, [holdDuration, onConfirm, props.disabled, props.loading]);
|
|
50
|
+
|
|
51
|
+
const cancelHold = useCallback(() => {
|
|
52
|
+
if (intervalRef.current) {
|
|
53
|
+
clearInterval(intervalRef.current);
|
|
54
|
+
intervalRef.current = null;
|
|
55
|
+
}
|
|
56
|
+
setIsHolding(false);
|
|
57
|
+
setProgress(0);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
return () => {
|
|
62
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
63
|
+
};
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Tooltip label={isHolding ? confirmingTooltip : tooltip} withArrow>
|
|
68
|
+
<Box
|
|
69
|
+
style={{
|
|
70
|
+
position: "relative",
|
|
71
|
+
display: "inline-flex",
|
|
72
|
+
verticalAlign: "middle",
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
{isHolding && (
|
|
76
|
+
<RingProgress
|
|
77
|
+
size={size === "sm" ? 28 : size === "md" ? 34 : 42}
|
|
78
|
+
thickness={2}
|
|
79
|
+
sections={[{ value: progress, color }]}
|
|
80
|
+
style={{
|
|
81
|
+
position: "absolute",
|
|
82
|
+
top: "50%",
|
|
83
|
+
left: "50%",
|
|
84
|
+
transform: "translate(-50%, -50%)",
|
|
85
|
+
pointerEvents: "none",
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
<ActionIcon
|
|
90
|
+
variant="subtle"
|
|
91
|
+
color={color}
|
|
92
|
+
size={size}
|
|
93
|
+
onMouseDown={startHold}
|
|
94
|
+
onMouseUp={cancelHold}
|
|
95
|
+
onMouseLeave={cancelHold}
|
|
96
|
+
onTouchStart={startHold}
|
|
97
|
+
onTouchEnd={cancelHold}
|
|
98
|
+
style={{
|
|
99
|
+
opacity: isHolding ? 0.7 : 1,
|
|
100
|
+
transition: "opacity 0.2s",
|
|
101
|
+
...props.style,
|
|
102
|
+
}}
|
|
103
|
+
{...props}
|
|
104
|
+
>
|
|
105
|
+
{props.loading ? (
|
|
106
|
+
<Loader size={12} color={color} />
|
|
107
|
+
) : (
|
|
108
|
+
<Icon size={size === "sm" ? 14 : size === "md" ? 18 : 22} />
|
|
109
|
+
)}
|
|
110
|
+
</ActionIcon>
|
|
111
|
+
</Box>
|
|
112
|
+
</Tooltip>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* TextHoldButton - Versão em texto do botão de segurar
|
|
118
|
+
* Usado para compatibilidade com type="button" ou quando children é passado
|
|
119
|
+
*/
|
|
120
|
+
export function TextHoldButton({
|
|
121
|
+
onDelete,
|
|
122
|
+
timerSeconds = 3,
|
|
123
|
+
children,
|
|
124
|
+
loading,
|
|
125
|
+
disabled,
|
|
126
|
+
variant = "light",
|
|
127
|
+
color = "red",
|
|
128
|
+
leftSection = <IconTrash size={14} />,
|
|
129
|
+
...buttonProps
|
|
130
|
+
}) {
|
|
131
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
132
|
+
const [deleteCountdown, setDeleteCountdown] = useState(timerSeconds);
|
|
133
|
+
const deleteTimerRef = useRef(null);
|
|
134
|
+
const deleteIntervalRef = useRef(null);
|
|
135
|
+
|
|
136
|
+
const startDeleteTimer = () => {
|
|
137
|
+
if (loading || disabled) return;
|
|
138
|
+
setIsDeleting(true);
|
|
139
|
+
setDeleteCountdown(timerSeconds);
|
|
140
|
+
|
|
141
|
+
deleteTimerRef.current = setTimeout(() => {
|
|
142
|
+
handleDelete();
|
|
143
|
+
}, timerSeconds * 1000);
|
|
144
|
+
|
|
145
|
+
deleteIntervalRef.current = setInterval(() => {
|
|
146
|
+
setDeleteCountdown((prev) => {
|
|
147
|
+
const newCount = prev - 1;
|
|
148
|
+
if (newCount <= 0) {
|
|
149
|
+
clearInterval(deleteIntervalRef.current);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
return newCount;
|
|
153
|
+
});
|
|
154
|
+
}, 1000);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const stopDeleteTimer = () => {
|
|
158
|
+
setIsDeleting(false);
|
|
159
|
+
setDeleteCountdown(timerSeconds);
|
|
160
|
+
if (deleteTimerRef.current) clearTimeout(deleteTimerRef.current);
|
|
161
|
+
if (deleteIntervalRef.current) clearInterval(deleteIntervalRef.current);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleDelete = async () => {
|
|
165
|
+
stopDeleteTimer();
|
|
166
|
+
if (onDelete) await onDelete();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
return () => stopDeleteTimer();
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Tooltip
|
|
175
|
+
label="Mantenha pressionado até a contagem terminar para confirmar a exclusão"
|
|
176
|
+
withArrow
|
|
177
|
+
multiline
|
|
178
|
+
w={220}
|
|
179
|
+
>
|
|
180
|
+
<Button
|
|
181
|
+
variant={variant}
|
|
182
|
+
color={isDeleting ? "orange" : color}
|
|
183
|
+
leftSection={leftSection}
|
|
184
|
+
onMouseDown={startDeleteTimer}
|
|
185
|
+
onMouseUp={stopDeleteTimer}
|
|
186
|
+
onMouseLeave={stopDeleteTimer}
|
|
187
|
+
onTouchStart={startDeleteTimer}
|
|
188
|
+
onTouchEnd={stopDeleteTimer}
|
|
189
|
+
disabled={loading || disabled}
|
|
190
|
+
style={{
|
|
191
|
+
transition: "all 0.2s ease",
|
|
192
|
+
transform: isDeleting ? "scale(0.98)" : "scale(1)",
|
|
193
|
+
...buttonProps.style,
|
|
194
|
+
}}
|
|
195
|
+
{...buttonProps}
|
|
196
|
+
>
|
|
197
|
+
{isDeleting
|
|
198
|
+
? `Excluindo em ${deleteCountdown}s...`
|
|
199
|
+
: children || "Segure para Excluir"}
|
|
200
|
+
</Button>
|
|
201
|
+
</Tooltip>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Wrapper para manter compatibilidade com Components.Buttons.Delete
|
|
207
|
+
*/
|
|
208
|
+
export function ButtonDelete({
|
|
209
|
+
onDelete,
|
|
210
|
+
timerSeconds = 3,
|
|
211
|
+
type = "button",
|
|
212
|
+
...props
|
|
213
|
+
}) {
|
|
214
|
+
if (type === "icon") {
|
|
215
|
+
return (
|
|
216
|
+
<HoldButton
|
|
217
|
+
onConfirm={onDelete}
|
|
218
|
+
holdDuration={timerSeconds * 1000}
|
|
219
|
+
{...props}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return (
|
|
224
|
+
<TextHoldButton
|
|
225
|
+
onDelete={onDelete}
|
|
226
|
+
timerSeconds={timerSeconds}
|
|
227
|
+
{...props}
|
|
228
|
+
/>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Paper,
|
|
4
|
+
Title,
|
|
5
|
+
Text,
|
|
6
|
+
TextInput,
|
|
7
|
+
PasswordInput,
|
|
8
|
+
Button,
|
|
9
|
+
Container,
|
|
10
|
+
Group,
|
|
11
|
+
Stack,
|
|
12
|
+
ThemeIcon,
|
|
13
|
+
Box,
|
|
14
|
+
Transition,
|
|
15
|
+
} from "@mantine/core";
|
|
16
|
+
import { IconDatabase, IconLock, IconDeviceMobile } from "@tabler/icons-react";
|
|
17
|
+
import { notifications } from "@mantine/notifications";
|
|
18
|
+
|
|
19
|
+
export function Login({ onLogin }) {
|
|
20
|
+
const [username, setUsername] = useState("");
|
|
21
|
+
const [password, setPassword] = useState("");
|
|
22
|
+
const [totpCode, setTotpCode] = useState("");
|
|
23
|
+
const [showTotp, setShowTotp] = useState(false);
|
|
24
|
+
const [loading, setLoading] = useState(false);
|
|
25
|
+
|
|
26
|
+
const handleSubmit = async (e) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
setLoading(true);
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch("/admin/auth/login", {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ username, password, totpCode }),
|
|
34
|
+
});
|
|
35
|
+
const data = await response.json();
|
|
36
|
+
|
|
37
|
+
if (data.success) {
|
|
38
|
+
onLogin();
|
|
39
|
+
} else {
|
|
40
|
+
if (data.code === "2FA_REQUIRED") {
|
|
41
|
+
setShowTotp(true);
|
|
42
|
+
notifications.show({
|
|
43
|
+
title: "Authentication Required",
|
|
44
|
+
message: "Please enter your 2FA code",
|
|
45
|
+
color: "blue",
|
|
46
|
+
icon: <IconDeviceMobile size={16} />
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
notifications.show({
|
|
50
|
+
title: "Login Failed",
|
|
51
|
+
message: data.error || "Invalid credentials",
|
|
52
|
+
color: "red",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
notifications.show({
|
|
58
|
+
title: "Error",
|
|
59
|
+
message: "Failed to connect to server",
|
|
60
|
+
color: "red",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
setLoading(false);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Box
|
|
68
|
+
style={{
|
|
69
|
+
height: "100vh",
|
|
70
|
+
display: "flex",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
justifyContent: "center",
|
|
73
|
+
backgroundColor: "#FBFAF8",
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<Container size={420}>
|
|
77
|
+
<Stack align="center" mb="xl">
|
|
78
|
+
<ThemeIcon size={60} radius="md" color="dark">
|
|
79
|
+
<IconDatabase size={34} />
|
|
80
|
+
</ThemeIcon>
|
|
81
|
+
<Title order={1} fw={700}>
|
|
82
|
+
SQLite Admin Login
|
|
83
|
+
</Title>
|
|
84
|
+
</Stack>
|
|
85
|
+
|
|
86
|
+
<Paper withBorder shadow="md" p={30} radius="md">
|
|
87
|
+
<form onSubmit={handleSubmit}>
|
|
88
|
+
<Stack>
|
|
89
|
+
<TextInput
|
|
90
|
+
label="Username"
|
|
91
|
+
placeholder="Your username"
|
|
92
|
+
required
|
|
93
|
+
value={username}
|
|
94
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
95
|
+
disabled={showTotp}
|
|
96
|
+
/>
|
|
97
|
+
<PasswordInput
|
|
98
|
+
label="Password"
|
|
99
|
+
placeholder="Your password"
|
|
100
|
+
required
|
|
101
|
+
value={password}
|
|
102
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
103
|
+
disabled={showTotp}
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
{showTotp && (
|
|
107
|
+
<TextInput
|
|
108
|
+
label="Authenticator Code (2FA)"
|
|
109
|
+
placeholder="000 000"
|
|
110
|
+
required
|
|
111
|
+
autoFocus
|
|
112
|
+
value={totpCode}
|
|
113
|
+
onChange={(e) => setTotpCode(e.target.value)}
|
|
114
|
+
leftSection={<IconDeviceMobile size={16} />}
|
|
115
|
+
maxLength={6}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
<Group mt="lg">
|
|
120
|
+
<Button
|
|
121
|
+
type="submit"
|
|
122
|
+
color="dark"
|
|
123
|
+
fullWidth
|
|
124
|
+
loading={loading}
|
|
125
|
+
leftSection={<IconLock size={18} />}
|
|
126
|
+
>
|
|
127
|
+
{showTotp ? "Verify & Login" : "Login"}
|
|
128
|
+
</Button>
|
|
129
|
+
</Group>
|
|
130
|
+
|
|
131
|
+
{showTotp && (
|
|
132
|
+
<Text
|
|
133
|
+
size="xs"
|
|
134
|
+
c="dimmed"
|
|
135
|
+
ta="center"
|
|
136
|
+
style={{ cursor: 'pointer' }}
|
|
137
|
+
onClick={() => { setShowTotp(false); setTotpCode(""); }}
|
|
138
|
+
>
|
|
139
|
+
Cancel
|
|
140
|
+
</Text>
|
|
141
|
+
)}
|
|
142
|
+
</Stack>
|
|
143
|
+
</form>
|
|
144
|
+
</Paper>
|
|
145
|
+
</Container>
|
|
146
|
+
</Box>
|
|
147
|
+
);
|
|
148
|
+
}
|