hazo_auth 0.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 +21 -0
- package/README.md +48 -0
- package/components.json +22 -0
- package/hazo_auth_config.example.ini +414 -0
- package/hazo_notify_config.example.ini +159 -0
- package/instrumentation.ts +32 -0
- package/migrations/001_add_token_type_to_refresh_tokens.sql +14 -0
- package/migrations/002_add_name_to_hazo_users.sql +7 -0
- package/next.config.mjs +55 -0
- package/package.json +114 -0
- package/postcss.config.mjs +8 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/apply_migration.ts +118 -0
- package/src/app/api/auth/change_password/route.ts +109 -0
- package/src/app/api/auth/forgot_password/route.ts +107 -0
- package/src/app/api/auth/library_photos/route.ts +70 -0
- package/src/app/api/auth/login/route.ts +155 -0
- package/src/app/api/auth/logout/route.ts +62 -0
- package/src/app/api/auth/me/route.ts +47 -0
- package/src/app/api/auth/profile_picture/[filename]/route.ts +67 -0
- package/src/app/api/auth/register/route.ts +106 -0
- package/src/app/api/auth/remove_profile_picture/route.ts +86 -0
- package/src/app/api/auth/resend_verification/route.ts +107 -0
- package/src/app/api/auth/reset_password/route.ts +107 -0
- package/src/app/api/auth/update_user/route.ts +126 -0
- package/src/app/api/auth/upload_profile_picture/route.ts +268 -0
- package/src/app/api/auth/validate_reset_token/route.ts +80 -0
- package/src/app/api/auth/verify_email/route.ts +85 -0
- package/src/app/api/migrations/apply/route.ts +91 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/fonts/GeistMonoVF.woff +0 -0
- package/src/app/fonts/GeistVF.woff +0 -0
- package/src/app/forgot_password/forgot_password_page_client.tsx +60 -0
- package/src/app/forgot_password/page.tsx +24 -0
- package/src/app/globals.css +89 -0
- package/src/app/hazo_connect/api/sqlite/data/route.ts +197 -0
- package/src/app/hazo_connect/api/sqlite/schema/route.ts +35 -0
- package/src/app/hazo_connect/api/sqlite/tables/route.ts +26 -0
- package/src/app/hazo_connect/sqlite_admin/page.tsx +51 -0
- package/src/app/hazo_connect/sqlite_admin/sqlite-admin-client.tsx +947 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/login/login_page_client.tsx +71 -0
- package/src/app/login/page.tsx +26 -0
- package/src/app/my_settings/my_settings_page_client.tsx +120 -0
- package/src/app/my_settings/page.tsx +40 -0
- package/src/app/page.tsx +170 -0
- package/src/app/register/page.tsx +26 -0
- package/src/app/register/register_page_client.tsx +72 -0
- package/src/app/reset_password/page.tsx +29 -0
- package/src/app/reset_password/reset_password_page_client.tsx +81 -0
- package/src/app/verify_email/page.tsx +24 -0
- package/src/app/verify_email/verify_email_page_client.tsx +60 -0
- package/src/components/layouts/email_verification/config/email_verification_field_config.ts +86 -0
- package/src/components/layouts/email_verification/hooks/use_email_verification.ts +291 -0
- package/src/components/layouts/email_verification/index.tsx +297 -0
- package/src/components/layouts/forgot_password/config/forgot_password_field_config.ts +58 -0
- package/src/components/layouts/forgot_password/hooks/use_forgot_password_form.ts +179 -0
- package/src/components/layouts/forgot_password/index.tsx +168 -0
- package/src/components/layouts/login/config/login_field_config.ts +67 -0
- package/src/components/layouts/login/hooks/use_login_form.ts +281 -0
- package/src/components/layouts/login/index.tsx +224 -0
- package/src/components/layouts/my_settings/components/editable_field.tsx +177 -0
- package/src/components/layouts/my_settings/components/password_change_dialog.tsx +301 -0
- package/src/components/layouts/my_settings/components/profile_picture_dialog.tsx +385 -0
- package/src/components/layouts/my_settings/components/profile_picture_display.tsx +66 -0
- package/src/components/layouts/my_settings/components/profile_picture_gravatar_tab.tsx +143 -0
- package/src/components/layouts/my_settings/components/profile_picture_library_tab.tsx +282 -0
- package/src/components/layouts/my_settings/components/profile_picture_upload_tab.tsx +341 -0
- package/src/components/layouts/my_settings/config/my_settings_field_config.ts +61 -0
- package/src/components/layouts/my_settings/hooks/use_my_settings.ts +458 -0
- package/src/components/layouts/my_settings/index.tsx +351 -0
- package/src/components/layouts/register/config/register_field_config.ts +101 -0
- package/src/components/layouts/register/hooks/use_register_form.ts +272 -0
- package/src/components/layouts/register/index.tsx +208 -0
- package/src/components/layouts/reset_password/config/reset_password_field_config.ts +86 -0
- package/src/components/layouts/reset_password/hooks/use_reset_password_form.ts +276 -0
- package/src/components/layouts/reset_password/index.tsx +294 -0
- package/src/components/layouts/shared/components/already_logged_in_guard.tsx +95 -0
- package/src/components/layouts/shared/components/field_error_message.tsx +29 -0
- package/src/components/layouts/shared/components/form_action_buttons.tsx +64 -0
- package/src/components/layouts/shared/components/form_field_wrapper.tsx +44 -0
- package/src/components/layouts/shared/components/form_header.tsx +36 -0
- package/src/components/layouts/shared/components/logout_button.tsx +76 -0
- package/src/components/layouts/shared/components/password_field.tsx +72 -0
- package/src/components/layouts/shared/components/sidebar_layout_wrapper.tsx +264 -0
- package/src/components/layouts/shared/components/two_column_auth_layout.tsx +44 -0
- package/src/components/layouts/shared/components/unauthorized_guard.tsx +78 -0
- package/src/components/layouts/shared/components/visual_panel.tsx +41 -0
- package/src/components/layouts/shared/config/layout_customization.ts +95 -0
- package/src/components/layouts/shared/data/layout_data_client.ts +19 -0
- package/src/components/layouts/shared/hooks/use_auth_status.ts +103 -0
- package/src/components/layouts/shared/utils/ip_address.ts +37 -0
- package/src/components/layouts/shared/utils/validation.ts +66 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/hazo_ui_tooltip.tsx +67 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +139 -0
- package/src/components/ui/sidebar.tsx +773 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +31 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/tooltip.tsx +32 -0
- package/src/components/ui/vertical-tabs.tsx +59 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/lib/already_logged_in_config.server.ts +46 -0
- package/src/lib/app_logger.ts +24 -0
- package/src/lib/auth/auth_utils.server.ts +196 -0
- package/src/lib/auth/server_auth.ts +88 -0
- package/src/lib/config/config_loader.server.ts +149 -0
- package/src/lib/email_verification_config.server.ts +32 -0
- package/src/lib/file_types_config.server.ts +25 -0
- package/src/lib/forgot_password_config.server.ts +32 -0
- package/src/lib/hazo_connect_instance.server.ts +77 -0
- package/src/lib/hazo_connect_setup.server.ts +181 -0
- package/src/lib/hazo_connect_setup.ts +54 -0
- package/src/lib/login_config.server.ts +46 -0
- package/src/lib/messages_config.server.ts +45 -0
- package/src/lib/migrations/apply_migration.ts +105 -0
- package/src/lib/my_settings_config.server.ts +135 -0
- package/src/lib/password_requirements_config.server.ts +39 -0
- package/src/lib/profile_picture_config.server.ts +56 -0
- package/src/lib/register_config.server.ts +57 -0
- package/src/lib/reset_password_config.server.ts +75 -0
- package/src/lib/services/email_service.ts +581 -0
- package/src/lib/services/email_verification_service.ts +264 -0
- package/src/lib/services/login_service.ts +118 -0
- package/src/lib/services/password_change_service.ts +154 -0
- package/src/lib/services/password_reset_service.ts +405 -0
- package/src/lib/services/profile_picture_remove_service.ts +120 -0
- package/src/lib/services/profile_picture_service.ts +215 -0
- package/src/lib/services/profile_picture_source_mapper.ts +62 -0
- package/src/lib/services/registration_service.ts +163 -0
- package/src/lib/services/token_service.ts +240 -0
- package/src/lib/services/user_update_service.ts +128 -0
- package/src/lib/ui_sizes_config.server.ts +37 -0
- package/src/lib/user_fields_config.server.ts +31 -0
- package/src/lib/utils/api_route_helpers.ts +60 -0
- package/src/lib/utils.ts +11 -0
- package/src/middleware.ts +91 -0
- package/src/server/config/config_loader.ts +496 -0
- package/src/server/index.ts +38 -0
- package/src/server/logging/logger_service.ts +56 -0
- package/src/server/routes/root_router.ts +16 -0
- package/src/server/server.ts +28 -0
- package/src/server/types/app_types.ts +74 -0
- package/src/server/types/express.d.ts +15 -0
- package/src/stories/email_verification_layout.stories.tsx +137 -0
- package/src/stories/forgot_password_layout.stories.tsx +85 -0
- package/src/stories/login_layout.stories.tsx +85 -0
- package/src/stories/project_overview.stories.tsx +33 -0
- package/src/stories/register_layout.stories.tsx +107 -0
- package/tailwind.config.ts +77 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
// file_description: SQLite admin UI client component for browsing and editing database tables
|
|
2
|
+
"use client"
|
|
3
|
+
|
|
4
|
+
import { useEffect, useMemo, useState } from "react"
|
|
5
|
+
import {
|
|
6
|
+
Filter,
|
|
7
|
+
Loader2,
|
|
8
|
+
Pencil,
|
|
9
|
+
Plus,
|
|
10
|
+
RefreshCw,
|
|
11
|
+
Save,
|
|
12
|
+
Trash2,
|
|
13
|
+
X
|
|
14
|
+
} from "lucide-react"
|
|
15
|
+
import { toast } from "sonner"
|
|
16
|
+
import type {
|
|
17
|
+
SqliteFilterOperator,
|
|
18
|
+
TableColumn,
|
|
19
|
+
TableSchema,
|
|
20
|
+
TableSummary
|
|
21
|
+
} from "hazo_connect/ui"
|
|
22
|
+
|
|
23
|
+
type FilterState = {
|
|
24
|
+
column?: string
|
|
25
|
+
operator: SqliteFilterOperator
|
|
26
|
+
value: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type SqlValue = Record<string, unknown>
|
|
30
|
+
type DataResponse = {
|
|
31
|
+
data: SqlValue[]
|
|
32
|
+
total: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_LIMIT = 20
|
|
36
|
+
const filterOperators: { label: string; value: SqliteFilterOperator }[] = [
|
|
37
|
+
{ label: "Equals", value: "eq" },
|
|
38
|
+
{ label: "Not equal", value: "neq" },
|
|
39
|
+
{ label: "Greater than", value: "gt" },
|
|
40
|
+
{ label: "Greater or equal", value: "gte" },
|
|
41
|
+
{ label: "Less than", value: "lt" },
|
|
42
|
+
{ label: "Less or equal", value: "lte" },
|
|
43
|
+
{ label: "Contains", value: "like" },
|
|
44
|
+
{ label: "Contains (case-insensitive)", value: "ilike" },
|
|
45
|
+
{ label: "Is / Is Not", value: "is" }
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
export default function SqliteAdminClient({
|
|
49
|
+
initialTables
|
|
50
|
+
}: {
|
|
51
|
+
initialTables: TableSummary[]
|
|
52
|
+
}) {
|
|
53
|
+
const [tables, setTables] = useState<TableSummary[]>(initialTables)
|
|
54
|
+
const [selectedTable, setSelectedTable] = useState<TableSummary | null>(
|
|
55
|
+
initialTables[0] ?? null
|
|
56
|
+
)
|
|
57
|
+
const [schema, setSchema] = useState<TableSchema | null>(null)
|
|
58
|
+
const [rows, setRows] = useState<SqlValue[]>([])
|
|
59
|
+
const [totalRows, setTotalRows] = useState(0)
|
|
60
|
+
const [limit, setLimit] = useState(DEFAULT_LIMIT)
|
|
61
|
+
const [offset, setOffset] = useState(0)
|
|
62
|
+
const [orderBy, setOrderBy] = useState<string | undefined>()
|
|
63
|
+
const [orderDirection, setOrderDirection] = useState<"asc" | "desc">("asc")
|
|
64
|
+
const [filterState, setFilterState] = useState<FilterState>({
|
|
65
|
+
operator: "eq",
|
|
66
|
+
value: ""
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const [isLoadingSchema, setIsLoadingSchema] = useState(false)
|
|
70
|
+
const [isLoadingData, setIsLoadingData] = useState(false)
|
|
71
|
+
const [isRefreshingTables, setIsRefreshingTables] = useState(false)
|
|
72
|
+
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
|
73
|
+
const [editingRow, setEditingRow] = useState<SqlValue | null>(null)
|
|
74
|
+
|
|
75
|
+
const columns = useMemo<TableColumn[]>(() => schema?.columns ?? [], [schema])
|
|
76
|
+
const filterColumns = useMemo(
|
|
77
|
+
() => columns.filter(column => identifierIsFilterable(column.name)),
|
|
78
|
+
[columns]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!selectedTable) {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
void loadSchemaAndData(selectedTable.name)
|
|
86
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
+
}, [selectedTable?.name])
|
|
88
|
+
|
|
89
|
+
async function loadSchemaAndData(tableName: string) {
|
|
90
|
+
setIsLoadingSchema(true)
|
|
91
|
+
try {
|
|
92
|
+
const schemaResponse = await fetch(`/hazo_connect/api/sqlite/schema?table=${encodeURIComponent(tableName)}`)
|
|
93
|
+
if (!schemaResponse.ok) {
|
|
94
|
+
throw new Error(await schemaResponse.text())
|
|
95
|
+
}
|
|
96
|
+
const schemaJson = await schemaResponse.json()
|
|
97
|
+
setSchema(schemaJson.data as TableSchema)
|
|
98
|
+
const preferredFilterColumn =
|
|
99
|
+
filterState.column && schemaJson.data?.columns?.some((col: TableColumn) => col.name === filterState.column)
|
|
100
|
+
? filterState.column
|
|
101
|
+
: schemaJson.data?.columns?.[0]?.name
|
|
102
|
+
setFilterState(current => ({
|
|
103
|
+
...current,
|
|
104
|
+
column: preferredFilterColumn
|
|
105
|
+
}))
|
|
106
|
+
setLimit(DEFAULT_LIMIT)
|
|
107
|
+
setOffset(0)
|
|
108
|
+
setOrderBy(undefined)
|
|
109
|
+
setOrderDirection("asc")
|
|
110
|
+
await loadData(tableName, {
|
|
111
|
+
limit: DEFAULT_LIMIT,
|
|
112
|
+
offset: 0,
|
|
113
|
+
orderBy: undefined,
|
|
114
|
+
orderDirection: "asc",
|
|
115
|
+
filterOverride: {
|
|
116
|
+
...filterState,
|
|
117
|
+
column: preferredFilterColumn
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
} catch (error) {
|
|
121
|
+
toast.error(
|
|
122
|
+
error instanceof Error
|
|
123
|
+
? error.message
|
|
124
|
+
: `Failed to load schema for table '${tableName}'`
|
|
125
|
+
)
|
|
126
|
+
} finally {
|
|
127
|
+
setIsLoadingSchema(false)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function loadData(
|
|
132
|
+
tableName: string,
|
|
133
|
+
overrides?: {
|
|
134
|
+
limit?: number
|
|
135
|
+
offset?: number
|
|
136
|
+
orderBy?: string
|
|
137
|
+
orderDirection?: "asc" | "desc"
|
|
138
|
+
filterOverride?: FilterState
|
|
139
|
+
}
|
|
140
|
+
) {
|
|
141
|
+
setIsLoadingData(true)
|
|
142
|
+
const nextLimit = overrides?.limit ?? limit
|
|
143
|
+
const nextOffset = overrides?.offset ?? offset
|
|
144
|
+
const nextOrderBy = overrides?.orderBy ?? orderBy
|
|
145
|
+
const nextOrderDirection = overrides?.orderDirection ?? orderDirection
|
|
146
|
+
const nextFilter = overrides?.filterOverride ?? filterState
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const params = new URLSearchParams({
|
|
150
|
+
table: tableName,
|
|
151
|
+
limit: String(nextLimit),
|
|
152
|
+
offset: String(nextOffset)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
if (nextOrderBy) {
|
|
156
|
+
params.set("orderBy", nextOrderBy)
|
|
157
|
+
params.set("orderDirection", nextOrderDirection)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (nextFilter.column && nextFilter.value.trim().length) {
|
|
161
|
+
const key =
|
|
162
|
+
nextFilter.operator === "eq"
|
|
163
|
+
? `filter[${nextFilter.column}]`
|
|
164
|
+
: `filter[${nextFilter.column}][${nextFilter.operator}]`
|
|
165
|
+
params.set(key, nextFilter.value)
|
|
166
|
+
} else if (nextFilter.operator === "is" && nextFilter.column) {
|
|
167
|
+
const key = `filter[${nextFilter.column}][is]`
|
|
168
|
+
params.set(key, nextFilter.value || "null")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const response = await fetch(`/hazo_connect/api/sqlite/data?${params.toString()}`)
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
throw new Error(await response.text())
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const json = (await response.json()) as DataResponse
|
|
177
|
+
setRows(json.data ?? [])
|
|
178
|
+
setTotalRows(json.total ?? 0)
|
|
179
|
+
setLimit(nextLimit)
|
|
180
|
+
setOffset(nextOffset)
|
|
181
|
+
setOrderBy(nextOrderBy)
|
|
182
|
+
setOrderDirection(nextOrderDirection)
|
|
183
|
+
setFilterState(nextFilter)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
toast.error(
|
|
186
|
+
error instanceof Error
|
|
187
|
+
? error.message
|
|
188
|
+
: `Failed to load data for table '${tableName}'`
|
|
189
|
+
)
|
|
190
|
+
} finally {
|
|
191
|
+
setIsLoadingData(false)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function refreshTables() {
|
|
196
|
+
setIsRefreshingTables(true)
|
|
197
|
+
try {
|
|
198
|
+
const response = await fetch("/hazo_connect/api/sqlite/tables")
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
throw new Error(await response.text())
|
|
201
|
+
}
|
|
202
|
+
const json = await response.json()
|
|
203
|
+
setTables(json.data ?? [])
|
|
204
|
+
toast.success("Tables refreshed")
|
|
205
|
+
} catch (error) {
|
|
206
|
+
toast.error(
|
|
207
|
+
error instanceof Error ? error.message : "Failed to refresh tables"
|
|
208
|
+
)
|
|
209
|
+
} finally {
|
|
210
|
+
setIsRefreshingTables(false)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function handleSelectTable(table: TableSummary) {
|
|
215
|
+
setSelectedTable(table)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function handleChangePage(nextOffset: number) {
|
|
219
|
+
if (!selectedTable) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
void loadData(selectedTable.name, { offset: Math.max(0, nextOffset) })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function handleChangeLimit(nextLimit: number) {
|
|
226
|
+
if (!selectedTable) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
void loadData(selectedTable.name, { limit: nextLimit, offset: 0 })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function handleChangeOrder(column?: string) {
|
|
233
|
+
if (!selectedTable) {
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
const nextDirection =
|
|
237
|
+
orderBy === column
|
|
238
|
+
? orderDirection === "asc"
|
|
239
|
+
? "desc"
|
|
240
|
+
: "asc"
|
|
241
|
+
: "asc"
|
|
242
|
+
void loadData(selectedTable.name, {
|
|
243
|
+
orderBy: column,
|
|
244
|
+
orderDirection: nextDirection
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function handleApplyFilter() {
|
|
249
|
+
if (!selectedTable) {
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
void loadData(selectedTable.name, { offset: 0 })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function handleInsertRow(data: Record<string, unknown>) {
|
|
256
|
+
if (!selectedTable) {
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const response = await fetch("/hazo_connect/api/sqlite/data", {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: {
|
|
264
|
+
"Content-Type": "application/json"
|
|
265
|
+
},
|
|
266
|
+
body: JSON.stringify({ table: selectedTable.name, data })
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
if (!response.ok) {
|
|
270
|
+
throw new Error(await response.text())
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
toast.success("Row inserted")
|
|
274
|
+
setIsCreateOpen(false)
|
|
275
|
+
await Promise.all([
|
|
276
|
+
refreshTables(),
|
|
277
|
+
loadData(selectedTable.name, { offset: 0 })
|
|
278
|
+
])
|
|
279
|
+
} catch (error) {
|
|
280
|
+
toast.error(error instanceof Error ? error.message : "Insert failed")
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function handleUpdateRow(
|
|
285
|
+
row: SqlValue,
|
|
286
|
+
data: Record<string, unknown>
|
|
287
|
+
) {
|
|
288
|
+
if (!selectedTable || !schema) {
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const criteria = buildCriteriaFromRow(row, schema.columns)
|
|
293
|
+
try {
|
|
294
|
+
const response = await fetch("/hazo_connect/api/sqlite/data", {
|
|
295
|
+
method: "PATCH",
|
|
296
|
+
headers: {
|
|
297
|
+
"Content-Type": "application/json"
|
|
298
|
+
},
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
table: selectedTable.name,
|
|
301
|
+
data,
|
|
302
|
+
criteria
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
throw new Error(await response.text())
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
toast.success("Row updated")
|
|
311
|
+
setEditingRow(null)
|
|
312
|
+
await loadData(selectedTable.name)
|
|
313
|
+
} catch (error) {
|
|
314
|
+
toast.error(error instanceof Error ? error.message : "Update failed")
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function handleDeleteRow(row: SqlValue) {
|
|
319
|
+
if (!selectedTable || !schema) {
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const criteria = buildCriteriaFromRow(row, schema.columns)
|
|
324
|
+
if (!Object.keys(criteria).length) {
|
|
325
|
+
toast.error("Unable to determine primary key for row deletion")
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!window.confirm("Delete this row? This action cannot be undone.")) {
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const response = await fetch("/hazo_connect/api/sqlite/data", {
|
|
335
|
+
method: "DELETE",
|
|
336
|
+
headers: {
|
|
337
|
+
"Content-Type": "application/json"
|
|
338
|
+
},
|
|
339
|
+
body: JSON.stringify({
|
|
340
|
+
table: selectedTable.name,
|
|
341
|
+
criteria
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
if (!response.ok) {
|
|
346
|
+
throw new Error(await response.text())
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
toast.success("Row deleted")
|
|
350
|
+
await loadData(selectedTable.name, { offset: 0 })
|
|
351
|
+
await refreshTables()
|
|
352
|
+
} catch (error) {
|
|
353
|
+
toast.error(error instanceof Error ? error.message : "Delete failed")
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const totalPages = Math.max(1, Math.ceil(totalRows / limit))
|
|
358
|
+
const currentPage = Math.min(totalPages, Math.floor(offset / limit) + 1)
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<div className="flex min-h-[calc(100vh-4rem)] flex-col gap-4 p-6 lg:flex-row">
|
|
362
|
+
<aside className="w-full max-w-xs rounded-lg border border-slate-200 bg-white shadow-sm lg:sticky lg:top-6 lg:h-[calc(100vh-6rem)]">
|
|
363
|
+
<header className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
|
364
|
+
<h2 className="text-sm font-medium text-slate-700">Tables</h2>
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
onClick={refreshTables}
|
|
368
|
+
disabled={isRefreshingTables}
|
|
369
|
+
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
370
|
+
>
|
|
371
|
+
{isRefreshingTables ? (
|
|
372
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
373
|
+
) : (
|
|
374
|
+
<RefreshCw className="h-3 w-3" />
|
|
375
|
+
)}
|
|
376
|
+
Refresh
|
|
377
|
+
</button>
|
|
378
|
+
</header>
|
|
379
|
+
<nav className="max-h-[calc(100vh-9rem)] overflow-auto px-2 py-3 text-sm">
|
|
380
|
+
{tables.length === 0 ? (
|
|
381
|
+
<p className="rounded-md bg-slate-50 p-3 text-slate-500">
|
|
382
|
+
No tables detected.
|
|
383
|
+
</p>
|
|
384
|
+
) : (
|
|
385
|
+
<ul className="space-y-1">
|
|
386
|
+
{tables.map(table => {
|
|
387
|
+
const isActive = selectedTable?.name === table.name
|
|
388
|
+
return (
|
|
389
|
+
<li key={table.name}>
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
onClick={() => handleSelectTable(table)}
|
|
393
|
+
className={[
|
|
394
|
+
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition",
|
|
395
|
+
isActive
|
|
396
|
+
? "bg-slate-900 text-white shadow-sm"
|
|
397
|
+
: "bg-white text-slate-700 hover:bg-slate-100"
|
|
398
|
+
].join(" ")}
|
|
399
|
+
>
|
|
400
|
+
<span className="truncate font-medium">{table.name}</span>
|
|
401
|
+
{typeof table.row_count === "number" && (
|
|
402
|
+
<span
|
|
403
|
+
className={[
|
|
404
|
+
"ml-2 inline-flex min-w-[2rem] items-center justify-center rounded-full px-2 text-xs",
|
|
405
|
+
isActive
|
|
406
|
+
? "bg-slate-800 text-slate-100"
|
|
407
|
+
: "bg-slate-100 text-slate-600"
|
|
408
|
+
].join(" ")}
|
|
409
|
+
>
|
|
410
|
+
{table.row_count}
|
|
411
|
+
</span>
|
|
412
|
+
)}
|
|
413
|
+
</button>
|
|
414
|
+
</li>
|
|
415
|
+
)
|
|
416
|
+
})}
|
|
417
|
+
</ul>
|
|
418
|
+
)}
|
|
419
|
+
</nav>
|
|
420
|
+
</aside>
|
|
421
|
+
|
|
422
|
+
<section className="flex-1 space-y-6">
|
|
423
|
+
<header className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
|
424
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
425
|
+
<div>
|
|
426
|
+
<h1 className="text-xl font-semibold text-slate-900">
|
|
427
|
+
SQLite Admin
|
|
428
|
+
</h1>
|
|
429
|
+
<p className="text-sm text-slate-500">
|
|
430
|
+
Browse tables, inspect schema, and edit data safely.
|
|
431
|
+
</p>
|
|
432
|
+
</div>
|
|
433
|
+
<div className="flex items-center gap-2">
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
onClick={() => selectedTable && loadSchemaAndData(selectedTable.name)}
|
|
437
|
+
disabled={isLoadingSchema || isLoadingData}
|
|
438
|
+
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
|
439
|
+
>
|
|
440
|
+
{isLoadingSchema || isLoadingData ? (
|
|
441
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
442
|
+
) : (
|
|
443
|
+
<RefreshCw className="h-4 w-4" />
|
|
444
|
+
)}
|
|
445
|
+
Refresh
|
|
446
|
+
</button>
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
onClick={() => setIsCreateOpen(true)}
|
|
450
|
+
disabled={!selectedTable || !schema}
|
|
451
|
+
className="inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-400"
|
|
452
|
+
>
|
|
453
|
+
<Plus className="h-4 w-4" />
|
|
454
|
+
Add Row
|
|
455
|
+
</button>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
<div className="flex flex-wrap items-center gap-3 rounded-md bg-slate-50 p-3 text-sm text-slate-600">
|
|
460
|
+
<Filter className="h-4 w-4 text-slate-500" />
|
|
461
|
+
<select
|
|
462
|
+
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm"
|
|
463
|
+
value={filterState.column ?? ""}
|
|
464
|
+
onChange={event =>
|
|
465
|
+
setFilterState(current => ({
|
|
466
|
+
...current,
|
|
467
|
+
column: event.target.value || undefined
|
|
468
|
+
}))
|
|
469
|
+
}
|
|
470
|
+
>
|
|
471
|
+
{filterColumns.length === 0 ? (
|
|
472
|
+
<option value="">No filterable columns</option>
|
|
473
|
+
) : (
|
|
474
|
+
filterColumns.map(column => (
|
|
475
|
+
<option key={column.name} value={column.name}>
|
|
476
|
+
{column.name}
|
|
477
|
+
</option>
|
|
478
|
+
))
|
|
479
|
+
)}
|
|
480
|
+
</select>
|
|
481
|
+
<select
|
|
482
|
+
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm"
|
|
483
|
+
value={filterState.operator}
|
|
484
|
+
onChange={event =>
|
|
485
|
+
setFilterState(current => ({
|
|
486
|
+
...current,
|
|
487
|
+
operator: event.target.value as SqliteFilterOperator
|
|
488
|
+
}))
|
|
489
|
+
}
|
|
490
|
+
>
|
|
491
|
+
{filterOperators.map(operator => (
|
|
492
|
+
<option key={operator.value} value={operator.value}>
|
|
493
|
+
{operator.label}
|
|
494
|
+
</option>
|
|
495
|
+
))}
|
|
496
|
+
</select>
|
|
497
|
+
{filterState.operator === "is" ? (
|
|
498
|
+
<select
|
|
499
|
+
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm"
|
|
500
|
+
value={filterState.value}
|
|
501
|
+
onChange={event =>
|
|
502
|
+
setFilterState(current => ({
|
|
503
|
+
...current,
|
|
504
|
+
value: event.target.value
|
|
505
|
+
}))
|
|
506
|
+
}
|
|
507
|
+
>
|
|
508
|
+
<option value="null">NULL</option>
|
|
509
|
+
<option value="not.null">NOT NULL</option>
|
|
510
|
+
</select>
|
|
511
|
+
) : (
|
|
512
|
+
<input
|
|
513
|
+
type="text"
|
|
514
|
+
className="flex-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-sm focus:border-slate-400 focus:outline-none"
|
|
515
|
+
placeholder="Filter value…"
|
|
516
|
+
value={filterState.value}
|
|
517
|
+
onChange={event =>
|
|
518
|
+
setFilterState(current => ({
|
|
519
|
+
...current,
|
|
520
|
+
value: event.target.value
|
|
521
|
+
}))
|
|
522
|
+
}
|
|
523
|
+
/>
|
|
524
|
+
)}
|
|
525
|
+
<button
|
|
526
|
+
type="button"
|
|
527
|
+
onClick={handleApplyFilter}
|
|
528
|
+
disabled={!selectedTable || !schema}
|
|
529
|
+
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
|
530
|
+
>
|
|
531
|
+
Apply
|
|
532
|
+
</button>
|
|
533
|
+
</div>
|
|
534
|
+
</header>
|
|
535
|
+
|
|
536
|
+
{selectedTable ? (
|
|
537
|
+
<>
|
|
538
|
+
<section className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
|
539
|
+
<header className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
|
540
|
+
<div>
|
|
541
|
+
<h2 className="text-base font-semibold text-slate-800">
|
|
542
|
+
{selectedTable.name}
|
|
543
|
+
</h2>
|
|
544
|
+
<p className="text-xs text-slate-500">
|
|
545
|
+
{schema?.columns.length ?? 0} columns
|
|
546
|
+
</p>
|
|
547
|
+
</div>
|
|
548
|
+
<div className="flex items-center gap-2">
|
|
549
|
+
<label className="flex items-center gap-1 text-xs text-slate-500">
|
|
550
|
+
Rows per page
|
|
551
|
+
<select
|
|
552
|
+
value={limit}
|
|
553
|
+
onChange={event =>
|
|
554
|
+
handleChangeLimit(Number.parseInt(event.target.value, 10))
|
|
555
|
+
}
|
|
556
|
+
className="rounded-md border border-slate-200 bg-white px-2 py-1 text-xs shadow-sm"
|
|
557
|
+
>
|
|
558
|
+
{[10, 20, 50, 100].map(option => (
|
|
559
|
+
<option key={option} value={option}>
|
|
560
|
+
{option}
|
|
561
|
+
</option>
|
|
562
|
+
))}
|
|
563
|
+
</select>
|
|
564
|
+
</label>
|
|
565
|
+
<div className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs text-slate-600 shadow-sm">
|
|
566
|
+
<button
|
|
567
|
+
type="button"
|
|
568
|
+
onClick={() => handleChangeOrder(orderBy)}
|
|
569
|
+
className="inline-flex items-center gap-1"
|
|
570
|
+
disabled={!schema}
|
|
571
|
+
>
|
|
572
|
+
Order:{" "}
|
|
573
|
+
{orderBy ? `${orderBy} • ${orderDirection}` : "not set"}
|
|
574
|
+
</button>
|
|
575
|
+
{schema && schema.columns.length > 0 && (
|
|
576
|
+
<select
|
|
577
|
+
value={orderBy ?? ""}
|
|
578
|
+
onChange={event =>
|
|
579
|
+
handleChangeOrder(event.target.value || undefined)
|
|
580
|
+
}
|
|
581
|
+
className="rounded border border-slate-200 bg-white px-1 py-0.5 text-xs"
|
|
582
|
+
>
|
|
583
|
+
<option value="">None</option>
|
|
584
|
+
{schema.columns.map(column => (
|
|
585
|
+
<option key={column.name} value={column.name}>
|
|
586
|
+
{column.name}
|
|
587
|
+
</option>
|
|
588
|
+
))}
|
|
589
|
+
</select>
|
|
590
|
+
)}
|
|
591
|
+
</div>
|
|
592
|
+
</div>
|
|
593
|
+
</header>
|
|
594
|
+
|
|
595
|
+
<div className="overflow-auto">
|
|
596
|
+
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
|
597
|
+
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
598
|
+
<tr>
|
|
599
|
+
{columns.map(column => (
|
|
600
|
+
<th key={column.name} className="px-3 py-2 text-left">
|
|
601
|
+
{column.name}
|
|
602
|
+
</th>
|
|
603
|
+
))}
|
|
604
|
+
<th className="px-3 py-2 text-right text-slate-400">Actions</th>
|
|
605
|
+
</tr>
|
|
606
|
+
</thead>
|
|
607
|
+
<tbody className="divide-y divide-slate-100 bg-white text-sm text-slate-700">
|
|
608
|
+
{isLoadingData ? (
|
|
609
|
+
<tr>
|
|
610
|
+
<td colSpan={columns.length + 1} className="px-3 py-8 text-center">
|
|
611
|
+
<div className="inline-flex items-center gap-2 text-slate-500">
|
|
612
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
613
|
+
Loading data…
|
|
614
|
+
</div>
|
|
615
|
+
</td>
|
|
616
|
+
</tr>
|
|
617
|
+
) : rows.length === 0 ? (
|
|
618
|
+
<tr>
|
|
619
|
+
<td colSpan={columns.length + 1} className="px-3 py-8 text-center text-slate-400">
|
|
620
|
+
No rows to display.
|
|
621
|
+
</td>
|
|
622
|
+
</tr>
|
|
623
|
+
) : (
|
|
624
|
+
rows.map((row, index) => (
|
|
625
|
+
<tr key={index} className="hover:bg-slate-50/60">
|
|
626
|
+
{columns.map(column => (
|
|
627
|
+
<td key={column.name} className="max-w-[200px] truncate px-3 py-2">
|
|
628
|
+
{formatCellValue(row[column.name])}
|
|
629
|
+
</td>
|
|
630
|
+
))}
|
|
631
|
+
<td className="px-3 py-2 text-right">
|
|
632
|
+
<div className="inline-flex items-center gap-1">
|
|
633
|
+
<button
|
|
634
|
+
type="button"
|
|
635
|
+
onClick={() => setEditingRow(row)}
|
|
636
|
+
className="inline-flex items-center gap-1 rounded-md border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100"
|
|
637
|
+
>
|
|
638
|
+
<Pencil className="h-3 w-3" />
|
|
639
|
+
Edit
|
|
640
|
+
</button>
|
|
641
|
+
<button
|
|
642
|
+
type="button"
|
|
643
|
+
onClick={() => handleDeleteRow(row)}
|
|
644
|
+
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-2 py-1 text-xs font-medium text-red-600 transition hover:bg-red-50"
|
|
645
|
+
>
|
|
646
|
+
<Trash2 className="h-3 w-3" />
|
|
647
|
+
Delete
|
|
648
|
+
</button>
|
|
649
|
+
</div>
|
|
650
|
+
</td>
|
|
651
|
+
</tr>
|
|
652
|
+
))
|
|
653
|
+
)}
|
|
654
|
+
</tbody>
|
|
655
|
+
</table>
|
|
656
|
+
</div>
|
|
657
|
+
|
|
658
|
+
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
|
|
659
|
+
<span>
|
|
660
|
+
Page {currentPage} of {totalPages} • {totalRows} rows
|
|
661
|
+
</span>
|
|
662
|
+
<div className="flex items-center gap-2">
|
|
663
|
+
<button
|
|
664
|
+
type="button"
|
|
665
|
+
onClick={() => handleChangePage(Math.max(0, offset - limit))}
|
|
666
|
+
disabled={offset === 0 || isLoadingData}
|
|
667
|
+
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
|
668
|
+
>
|
|
669
|
+
Prev
|
|
670
|
+
</button>
|
|
671
|
+
<button
|
|
672
|
+
type="button"
|
|
673
|
+
onClick={() => handleChangePage(offset + limit)}
|
|
674
|
+
disabled={offset + limit >= totalRows || isLoadingData}
|
|
675
|
+
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
|
|
676
|
+
>
|
|
677
|
+
Next
|
|
678
|
+
</button>
|
|
679
|
+
</div>
|
|
680
|
+
</footer>
|
|
681
|
+
</section>
|
|
682
|
+
|
|
683
|
+
<section className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
|
684
|
+
<header className="border-b border-slate-100 px-4 py-3">
|
|
685
|
+
<h3 className="text-sm font-semibold text-slate-700">Schema</h3>
|
|
686
|
+
</header>
|
|
687
|
+
<div className="overflow-auto">
|
|
688
|
+
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
|
689
|
+
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
690
|
+
<tr>
|
|
691
|
+
<th className="px-3 py-2 text-left">Column</th>
|
|
692
|
+
<th className="px-3 py-2 text-left">Type</th>
|
|
693
|
+
<th className="px-3 py-2 text-left">Not null</th>
|
|
694
|
+
<th className="px-3 py-2 text-left">Default</th>
|
|
695
|
+
<th className="px-3 py-2 text-left">Primary key</th>
|
|
696
|
+
</tr>
|
|
697
|
+
</thead>
|
|
698
|
+
<tbody className="divide-y divide-slate-100 bg-white text-sm text-slate-700">
|
|
699
|
+
{columns.map(column => (
|
|
700
|
+
<tr key={column.name}>
|
|
701
|
+
<td className="px-3 py-2 font-medium">{column.name}</td>
|
|
702
|
+
<td className="px-3 py-2">{column.type || "TEXT"}</td>
|
|
703
|
+
<td className="px-3 py-2">
|
|
704
|
+
{column.notnull ? "Yes" : "No"}
|
|
705
|
+
</td>
|
|
706
|
+
<td className="px-3 py-2">
|
|
707
|
+
{column.default_value === null
|
|
708
|
+
? "NULL"
|
|
709
|
+
: String(column.default_value)}
|
|
710
|
+
</td>
|
|
711
|
+
<td className="px-3 py-2">
|
|
712
|
+
{column.primary_key_position
|
|
713
|
+
? `Yes (#${column.primary_key_position})`
|
|
714
|
+
: "No"}
|
|
715
|
+
</td>
|
|
716
|
+
</tr>
|
|
717
|
+
))}
|
|
718
|
+
</tbody>
|
|
719
|
+
</table>
|
|
720
|
+
</div>
|
|
721
|
+
</section>
|
|
722
|
+
</>
|
|
723
|
+
) : (
|
|
724
|
+
<p className="rounded-lg border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">
|
|
725
|
+
Select a table to begin.
|
|
726
|
+
</p>
|
|
727
|
+
)}
|
|
728
|
+
</section>
|
|
729
|
+
|
|
730
|
+
{schema && selectedTable && (
|
|
731
|
+
<RowModal
|
|
732
|
+
title={`Insert into ${selectedTable.name}`}
|
|
733
|
+
open={isCreateOpen}
|
|
734
|
+
columns={schema.columns}
|
|
735
|
+
onClose={() => setIsCreateOpen(false)}
|
|
736
|
+
onSubmit={handleInsertRow}
|
|
737
|
+
/>
|
|
738
|
+
)}
|
|
739
|
+
|
|
740
|
+
{schema && selectedTable && editingRow && (
|
|
741
|
+
<RowModal
|
|
742
|
+
title={`Edit row in ${selectedTable.name}`}
|
|
743
|
+
open={Boolean(editingRow)}
|
|
744
|
+
columns={schema.columns}
|
|
745
|
+
initialValues={editingRow}
|
|
746
|
+
onClose={() => setEditingRow(null)}
|
|
747
|
+
onSubmit={data => handleUpdateRow(editingRow, data)}
|
|
748
|
+
primaryKeys={schema.columns.filter(column => column.primary_key_position > 0)}
|
|
749
|
+
/>
|
|
750
|
+
)}
|
|
751
|
+
</div>
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function RowModal({
|
|
756
|
+
title,
|
|
757
|
+
open,
|
|
758
|
+
columns,
|
|
759
|
+
primaryKeys,
|
|
760
|
+
initialValues,
|
|
761
|
+
onClose,
|
|
762
|
+
onSubmit
|
|
763
|
+
}: {
|
|
764
|
+
title: string
|
|
765
|
+
open: boolean
|
|
766
|
+
columns: TableColumn[]
|
|
767
|
+
primaryKeys?: TableColumn[]
|
|
768
|
+
initialValues?: SqlValue
|
|
769
|
+
onClose: () => void
|
|
770
|
+
onSubmit: (data: Record<string, unknown>) => void
|
|
771
|
+
}) {
|
|
772
|
+
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
|
773
|
+
|
|
774
|
+
useEffect(() => {
|
|
775
|
+
if (open) {
|
|
776
|
+
const nextValues: Record<string, string> = {}
|
|
777
|
+
columns.forEach(column => {
|
|
778
|
+
const rawValue = initialValues?.[column.name]
|
|
779
|
+
nextValues[column.name] =
|
|
780
|
+
rawValue === null || rawValue === undefined ? "" : String(rawValue)
|
|
781
|
+
})
|
|
782
|
+
setFormValues(nextValues)
|
|
783
|
+
}
|
|
784
|
+
}, [open, columns, initialValues])
|
|
785
|
+
|
|
786
|
+
if (!open) {
|
|
787
|
+
return null
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function handleChange(column: string, value: string) {
|
|
791
|
+
setFormValues(current => ({
|
|
792
|
+
...current,
|
|
793
|
+
[column]: value
|
|
794
|
+
}))
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
798
|
+
event.preventDefault()
|
|
799
|
+
const payload: Record<string, unknown> = {}
|
|
800
|
+
|
|
801
|
+
for (const column of columns) {
|
|
802
|
+
const value = formValues[column.name]
|
|
803
|
+
payload[column.name] = coerceValue(column, value)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
onSubmit(payload)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return (
|
|
810
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 backdrop-blur">
|
|
811
|
+
<div className="relative w-full max-w-2xl rounded-lg border border-slate-200 bg-white p-6 shadow-xl">
|
|
812
|
+
<header className="mb-4 flex items-center justify-between">
|
|
813
|
+
<div>
|
|
814
|
+
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
|
815
|
+
{primaryKeys && primaryKeys.length === 0 && (
|
|
816
|
+
<p className="mt-1 text-xs text-amber-600">
|
|
817
|
+
Warning: table has no primary key defined. Updates/deletes rely on full row
|
|
818
|
+
matching.
|
|
819
|
+
</p>
|
|
820
|
+
)}
|
|
821
|
+
</div>
|
|
822
|
+
<button
|
|
823
|
+
type="button"
|
|
824
|
+
onClick={onClose}
|
|
825
|
+
className="rounded-full border border-slate-200 p-1 text-slate-500 transition hover:bg-slate-100"
|
|
826
|
+
>
|
|
827
|
+
<X className="h-4 w-4" />
|
|
828
|
+
</button>
|
|
829
|
+
</header>
|
|
830
|
+
|
|
831
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
832
|
+
<div className="grid max-h-[50vh] grid-cols-1 gap-4 overflow-auto pr-2 sm:grid-cols-2">
|
|
833
|
+
{columns.map(column => (
|
|
834
|
+
<label key={column.name} className="flex flex-col gap-1 text-sm">
|
|
835
|
+
<span className="font-medium text-slate-700">
|
|
836
|
+
{column.name}
|
|
837
|
+
{column.notnull && (
|
|
838
|
+
<sup className="ml-1 text-amber-600" title="Required">
|
|
839
|
+
*
|
|
840
|
+
</sup>
|
|
841
|
+
)}
|
|
842
|
+
</span>
|
|
843
|
+
<input
|
|
844
|
+
type="text"
|
|
845
|
+
value={formValues[column.name] ?? ""}
|
|
846
|
+
onChange={event => handleChange(column.name, event.target.value)}
|
|
847
|
+
className="rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-slate-400 focus:outline-none"
|
|
848
|
+
placeholder={
|
|
849
|
+
column.default_value === null
|
|
850
|
+
? column.type || "TEXT"
|
|
851
|
+
: String(column.default_value)
|
|
852
|
+
}
|
|
853
|
+
/>
|
|
854
|
+
<span className="text-xs text-slate-400">
|
|
855
|
+
{column.type || "TEXT"}
|
|
856
|
+
{column.primary_key_position ? " • Primary key" : ""}
|
|
857
|
+
</span>
|
|
858
|
+
</label>
|
|
859
|
+
))}
|
|
860
|
+
</div>
|
|
861
|
+
|
|
862
|
+
<footer className="flex items-center justify-end gap-2">
|
|
863
|
+
<button
|
|
864
|
+
type="button"
|
|
865
|
+
onClick={onClose}
|
|
866
|
+
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100"
|
|
867
|
+
>
|
|
868
|
+
Cancel
|
|
869
|
+
</button>
|
|
870
|
+
<button
|
|
871
|
+
type="submit"
|
|
872
|
+
className="inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-slate-800"
|
|
873
|
+
>
|
|
874
|
+
<Save className="h-4 w-4" />
|
|
875
|
+
Save
|
|
876
|
+
</button>
|
|
877
|
+
</footer>
|
|
878
|
+
</form>
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function buildCriteriaFromRow(row: SqlValue, columns: TableColumn[]) {
|
|
885
|
+
const criteria: Record<string, unknown> = {}
|
|
886
|
+
const primaryKeys = columns.filter(column => column.primary_key_position > 0)
|
|
887
|
+
|
|
888
|
+
if (primaryKeys.length) {
|
|
889
|
+
for (const primary of primaryKeys) {
|
|
890
|
+
criteria[primary.name] = row[primary.name]
|
|
891
|
+
}
|
|
892
|
+
return criteria
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
for (const column of columns) {
|
|
896
|
+
criteria[column.name] = row[column.name]
|
|
897
|
+
}
|
|
898
|
+
return criteria
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function coerceValue(column: TableColumn, rawValue: string): unknown {
|
|
902
|
+
if (rawValue === "") {
|
|
903
|
+
return column.notnull ? "" : null
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const normalisedType = (column.type ?? "").toLowerCase()
|
|
907
|
+
|
|
908
|
+
if (normalisedType.includes("int")) {
|
|
909
|
+
const parsed = Number.parseInt(rawValue, 10)
|
|
910
|
+
return Number.isNaN(parsed) ? rawValue : parsed
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (
|
|
914
|
+
normalisedType.includes("real") ||
|
|
915
|
+
normalisedType.includes("double") ||
|
|
916
|
+
normalisedType.includes("float") ||
|
|
917
|
+
normalisedType.includes("numeric")
|
|
918
|
+
) {
|
|
919
|
+
const parsed = Number.parseFloat(rawValue)
|
|
920
|
+
return Number.isNaN(parsed) ? rawValue : parsed
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (normalisedType.includes("bool")) {
|
|
924
|
+
return rawValue === "true" || rawValue === "1" ? 1 : 0
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
return rawValue
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function formatCellValue(value: unknown): string {
|
|
931
|
+
if (value === null || value === undefined) {
|
|
932
|
+
return "NULL"
|
|
933
|
+
}
|
|
934
|
+
if (typeof value === "object") {
|
|
935
|
+
try {
|
|
936
|
+
return JSON.stringify(value)
|
|
937
|
+
} catch {
|
|
938
|
+
return String(value)
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return String(value)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function identifierIsFilterable(name: string): boolean {
|
|
945
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
|
|
946
|
+
}
|
|
947
|
+
|