sqlalchemy-studio 0.1.8__tar.gz → 0.1.9__tar.gz

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.
Files changed (25) hide show
  1. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/PKG-INFO +2 -1
  2. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/pyproject.toml +9 -1
  3. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio/Studio.py +158 -62
  4. sqlalchemy_studio-0.1.9/sqlalchemy_studio/backend.py +106 -0
  5. sqlalchemy_studio-0.1.9/sqlalchemy_studio/static/assets/index-D9EiKPre.css +2 -0
  6. sqlalchemy_studio-0.1.8/sqlalchemy_studio/static/assets/index-0qQM-QLq.js → sqlalchemy_studio-0.1.9/sqlalchemy_studio/static/assets/index-OOWfHZW5.js +1 -1
  7. sqlalchemy_studio-0.1.8/sqlalchemy_studio/static/assets/routes-BJJUUmei.js → sqlalchemy_studio-0.1.9/sqlalchemy_studio/static/assets/routes-BhTC3gEq.js +1 -1
  8. sqlalchemy_studio-0.1.9/sqlalchemy_studio/static/assets/tables._tableName-C-AKBKP7.js +1 -0
  9. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio/static/index.html +2 -2
  10. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio.egg-info/PKG-INFO +2 -1
  11. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio.egg-info/SOURCES.txt +4 -4
  12. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio.egg-info/requires.txt +1 -0
  13. sqlalchemy_studio-0.1.8/sqlalchemy_studio/backend.py +0 -68
  14. sqlalchemy_studio-0.1.8/sqlalchemy_studio/static/assets/index-B8xFyRBc.css +0 -2
  15. sqlalchemy_studio-0.1.8/sqlalchemy_studio/static/assets/tables._tableName-C7FBzk6i.js +0 -1
  16. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/README.md +0 -0
  17. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/setup.cfg +0 -0
  18. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio/__init__.py +0 -0
  19. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio/static/favicon.ico +0 -0
  20. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio/static/logo192.png +0 -0
  21. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio/static/logo512.png +0 -0
  22. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio/static/manifest.json +0 -0
  23. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio/static/robots.txt +0 -0
  24. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio.egg-info/dependency_links.txt +0 -0
  25. {sqlalchemy_studio-0.1.8 → sqlalchemy_studio-0.1.9}/sqlalchemy_studio.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemy-studio
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: FastAPI studio for inspecting SQLAlchemy databases
5
5
  Author-email: Xursand Qarlibayev <coderxuz2009@gmail.com>
6
6
  License: MIT
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
18
18
  Requires-Dist: build>=1.5.0
19
19
  Requires-Dist: fastapi>=0.136.3
20
20
  Requires-Dist: sqlalchemy>=2.0.50
21
+ Requires-Dist: taskipy>=1.14.1
21
22
  Requires-Dist: twine>=6.2.0
22
23
  Requires-Dist: uvicorn>=0.48.0
23
24
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sqlalchemy-studio"
7
- version = "0.1.8"
7
+ version = "0.1.9"
8
8
  description = "FastAPI studio for inspecting SQLAlchemy databases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -12,6 +12,7 @@ dependencies = [
12
12
  "build>=1.5.0",
13
13
  "fastapi>=0.136.3",
14
14
  "sqlalchemy>=2.0.50",
15
+ "taskipy>=1.14.1",
15
16
  "twine>=6.2.0",
16
17
  "uvicorn>=0.48.0",
17
18
  ]
@@ -35,3 +36,10 @@ include = ["studio*", "sqlalchemy_studio*"]
35
36
 
36
37
  [tool.setuptools.package-data]
37
38
  sqlalchemy_studio = ["static/**/*"]
39
+
40
+
41
+ [tool.taskipy.tasks]
42
+ build = "build"
43
+ upload = "-m twine upload dist/*"
44
+ dev = "uvicorn backend.main:app --reload"
45
+ bot = "python -m bot.main"
@@ -1,6 +1,7 @@
1
- from sqlalchemy import and_, create_engine, func, inspect, Engine, or_, select, Table
1
+ from sqlalchemy import and_, create_engine, delete, func, inspect, Engine, or_, select, Table
2
2
  from sqlalchemy.engine.interfaces import ReflectedColumn
3
3
  from sqlalchemy.orm import DeclarativeBase
4
+ from sqlalchemy.sql.elements import ColumnElement
4
5
  from fastapi import FastAPI, HTTPException, status, Request
5
6
  from fastapi.middleware.cors import CORSMiddleware
6
7
  from fastapi.staticfiles import StaticFiles
@@ -15,7 +16,12 @@ from sqlalchemy_studio.backend import create_tables_router
15
16
  from typing import Self, TypedDict, Any, cast, TYPE_CHECKING
16
17
 
17
18
  if TYPE_CHECKING:
18
- from sqlalchemy_studio.backend import FilterRequest
19
+ from sqlalchemy_studio.backend import (
20
+ ColumnValueMatch,
21
+ DeleteRowsRequest,
22
+ FilterCondition,
23
+ FilterRequest,
24
+ )
19
25
 
20
26
  engine = create_engine("sqlite:///test.db")
21
27
 
@@ -44,6 +50,10 @@ class GetRowsResponse(TypedDict):
44
50
  total_count: int
45
51
 
46
52
 
53
+ class DeleteRowsResponse(TypedDict):
54
+ deleted_count: int
55
+
56
+
47
57
  class Studio:
48
58
  def __init__(self: Self, engine: Engine, base: DeclarativeBase) -> None:
49
59
  self.engine = engine
@@ -174,84 +184,170 @@ class Studio:
174
184
 
175
185
  return response_data
176
186
 
177
- def get_rows_advanced(
178
- self: Self, table_name: str, payload: "FilterRequest"
179
- ) -> GetRowsResponse:
180
- """
181
- Advanced filtering using logical operator trees matching custom UI filters.
182
- """
187
+ def _get_table_or_404(self: Self, table_name: str) -> Table:
183
188
  if not self.inspector.has_table(table_name):
184
189
  raise HTTPException(
185
- status_code=404, detail=f"Table '{table_name}' not found."
190
+ status_code=status.HTTP_404_NOT_FOUND,
191
+ detail=f"Table '{table_name}' not found.",
186
192
  )
187
193
 
188
- table = Table(
194
+ return Table(
189
195
  table_name,
190
196
  self.base.metadata,
191
197
  autoload_with=self.engine,
192
198
  extend_existing=True,
193
199
  )
194
200
 
201
+ @staticmethod
202
+ def _get_column_or_400(table: Table, column_name: str) -> Any:
203
+ if column_name not in table.c:
204
+ raise HTTPException(
205
+ status_code=status.HTTP_400_BAD_REQUEST,
206
+ detail=f"Column '{column_name}' does not exist on table '{table.name}'.",
207
+ )
208
+
209
+ return table.c[column_name]
210
+
211
+ def _build_filter_expression(
212
+ self: Self, table: Table, condition: "FilterCondition"
213
+ ) -> ColumnElement[bool]:
214
+ column = self._get_column_or_400(table, condition.column)
215
+ value = condition.value
216
+
217
+ if condition.operator == "equals":
218
+ expression = column == value
219
+ elif condition.operator == "not_equals":
220
+ expression = column != value
221
+ elif condition.operator == "contains":
222
+ expression = column.ilike(f"%{value}%")
223
+ elif condition.operator == "starts_with":
224
+ expression = column.ilike(f"{value}%")
225
+ elif condition.operator == "ends_with":
226
+ expression = column.ilike(f"%{value}")
227
+ elif condition.operator == "greater_than":
228
+ expression = column > value
229
+ elif condition.operator == "less_than":
230
+ expression = column < value
231
+ elif condition.operator == "greater_than_or_equals":
232
+ expression = column >= value
233
+ elif condition.operator == "less_than_or_equals":
234
+ expression = column <= value
235
+ else:
236
+ raise HTTPException(
237
+ status_code=status.HTTP_400_BAD_REQUEST,
238
+ detail=f"Unsupported filter operator '{condition.operator}'.",
239
+ )
240
+
241
+ return cast(ColumnElement[bool], expression)
242
+
243
+ def _build_filter_clause(
244
+ self: Self, table: Table, filters: list["FilterCondition"]
245
+ ) -> ColumnElement[bool] | None:
246
+ filter_clause: ColumnElement[bool] | None = None
247
+
248
+ for condition in filters:
249
+ expression = self._build_filter_expression(table, condition)
250
+
251
+ if filter_clause is None:
252
+ filter_clause = expression
253
+ elif condition.link == "or":
254
+ filter_clause = cast(ColumnElement[bool], or_(filter_clause, expression))
255
+ else:
256
+ filter_clause = cast(ColumnElement[bool], and_(filter_clause, expression))
257
+
258
+ return filter_clause
259
+
260
+ def _build_exact_match_clause(
261
+ self: Self, table: Table, matches: list["ColumnValueMatch"]
262
+ ) -> ColumnElement[bool] | None:
263
+ expressions = [
264
+ self._get_column_or_400(table, match.column) == match.value
265
+ for match in matches
266
+ ]
267
+
268
+ if not expressions:
269
+ return None
270
+
271
+ return cast(ColumnElement[bool], and_(*expressions))
272
+
273
+ def _build_match_groups_clause(
274
+ self: Self, table: Table, match_groups: list[list["ColumnValueMatch"]]
275
+ ) -> ColumnElement[bool] | None:
276
+ group_clauses = [
277
+ group_clause
278
+ for group in match_groups
279
+ if (group_clause := self._build_exact_match_clause(table, group)) is not None
280
+ ]
281
+
282
+ if not group_clauses:
283
+ return None
284
+
285
+ return cast(ColumnElement[bool], or_(*group_clauses))
286
+
287
+ @staticmethod
288
+ def _combine_with_and(
289
+ clauses: list[ColumnElement[bool]],
290
+ ) -> ColumnElement[bool] | None:
291
+ if not clauses:
292
+ return None
293
+
294
+ return cast(ColumnElement[bool], and_(*clauses))
295
+
296
+ def get_rows_advanced(
297
+ self: Self, table_name: str, payload: "FilterRequest"
298
+ ) -> GetRowsResponse:
299
+ """
300
+ Advanced filtering using logical operator trees matching custom UI filters.
301
+ """
302
+ table = self._get_table_or_404(table_name)
303
+
195
304
  rows_query = select(table)
196
305
  count_query = select(func.count()).select_from(table)
197
306
 
198
- # Build dynamic expressions clauses
199
- if payload.filters:
200
- conditions = []
201
-
202
- for cond in payload.filters:
203
- if cond.column not in table.c:
204
- continue # Guard against non-existent columns safely
205
-
206
- col = table.c[cond.column]
207
- val = cond.value
208
-
209
- # Map string operators to SQLAlchemy expressions
210
- if cond.operator == "equals":
211
- expr = col == val
212
- elif cond.operator == "not_equals":
213
- expr = col != val
214
- elif cond.operator == "contains":
215
- expr = col.ilike(f"%{val}%")
216
- elif cond.operator == "starts_with":
217
- expr = col.ilike(f"{val}%")
218
- elif cond.operator == "ends_with":
219
- expr = col.ilike(f"%{val}")
220
- elif cond.operator == "greater_than":
221
- expr = col > val
222
- elif cond.operator == "less_than":
223
- expr = col < val
224
- elif cond.operator == "greater_than_or_equals":
225
- expr = col >= val
226
- elif cond.operator == "less_than_or_equals":
227
- expr = col <= val
228
- else:
229
- continue
230
-
231
- # Combine them based on the link field ('and' / 'or')
232
- if not conditions:
233
- conditions.append(expr)
234
- else:
235
- if cond.link == "or":
236
- # Merge last expression with current one via OR
237
- prev_expr = conditions.pop()
238
- conditions.append(or_(prev_expr, expr))
239
- else:
240
- # Default to AND chaining
241
- prev_expr = conditions.pop()
242
- conditions.append(and_(prev_expr, expr))
243
-
244
- if conditions:
245
- rows_query = rows_query.where(*conditions)
246
- count_query = count_query.where(*conditions)
247
-
248
- # Apply Pagination
307
+ filter_clause = self._build_filter_clause(table, payload.filters)
308
+ if filter_clause is not None:
309
+ rows_query = rows_query.where(filter_clause)
310
+ count_query = count_query.where(filter_clause)
311
+
249
312
  rows_query = rows_query.limit(payload.limit).offset(payload.offset)
250
313
 
251
- # Execute
252
314
  with self.engine.connect() as connection:
253
315
  total = int(connection.execute(count_query).scalar_one())
254
316
  result = connection.execute(rows_query)
255
317
  rows = [dict(row) for row in result.mappings()]
256
318
 
257
319
  return {"rows": rows, "total_count": total}
320
+
321
+ def delete_rows(
322
+ self: Self, table_name: str, payload: "DeleteRowsRequest"
323
+ ) -> DeleteRowsResponse:
324
+ table = self._get_table_or_404(table_name)
325
+
326
+ clauses: list[ColumnElement[bool]] = []
327
+
328
+ match_clause = self._build_exact_match_clause(table, payload.matches)
329
+ if match_clause is not None:
330
+ clauses.append(match_clause)
331
+
332
+ match_groups_clause = self._build_match_groups_clause(table, payload.match_groups)
333
+ if match_groups_clause is not None:
334
+ clauses.append(match_groups_clause)
335
+
336
+ filter_clause = self._build_filter_clause(table, payload.filters)
337
+ if filter_clause is not None:
338
+ clauses.append(filter_clause)
339
+
340
+ where_clause = self._combine_with_and(clauses)
341
+ if where_clause is None:
342
+ raise HTTPException(
343
+ status_code=status.HTTP_400_BAD_REQUEST,
344
+ detail="At least one match or filter is required to delete rows.",
345
+ )
346
+
347
+ delete_query = delete(table).where(where_clause)
348
+
349
+ with self.engine.begin() as connection:
350
+ result = connection.execute(delete_query)
351
+
352
+ deleted_count = result.rowcount if result.rowcount is not None else 0
353
+ return {"deleted_count": max(deleted_count, 0)}
@@ -0,0 +1,106 @@
1
+ from typing import TYPE_CHECKING, Any, Literal
2
+
3
+ if TYPE_CHECKING:
4
+ from sqlalchemy_studio.Studio import Studio
5
+
6
+ from fastapi import APIRouter, HTTPException, status
7
+ from pydantic import BaseModel, Field
8
+
9
+ OperatorType = Literal[
10
+ "equals",
11
+ "not_equals",
12
+ "contains",
13
+ "starts_with",
14
+ "ends_with",
15
+ "greater_than",
16
+ "less_than",
17
+ "greater_than_or_equals",
18
+ "less_than_or_equals",
19
+ ]
20
+ LinkType = Literal["and", "or"]
21
+
22
+
23
+ class FilterCondition(BaseModel):
24
+ column: str
25
+ operator: OperatorType
26
+ value: Any
27
+ link: LinkType = "and"
28
+
29
+
30
+ class FilterRequest(BaseModel):
31
+ limit: int = Field(default=10, ge=1)
32
+ offset: int = Field(default=0, ge=0)
33
+ filters: list[FilterCondition] = Field(default_factory=list)
34
+ # optional projection of columns to return
35
+ columns: list[str] = Field(default_factory=list)
36
+
37
+
38
+ class SortDirection(str):
39
+ pass
40
+
41
+
42
+ class SortSpec(BaseModel):
43
+ column: str
44
+ direction: str = Field(default="asc")
45
+
46
+
47
+ class ColumnValueMatch(BaseModel):
48
+ column: str
49
+ value: Any
50
+
51
+
52
+ class DeleteRowsRequest(BaseModel):
53
+ # matches for a single-row delete (AND across entries), kept for backward compat
54
+ matches: list[ColumnValueMatch] = Field(default_factory=list)
55
+ # groups of matches to delete multiple specific rows. Each group is ANDed,
56
+ # groups are ORed together.
57
+ match_groups: list[list[ColumnValueMatch]] = Field(default_factory=list)
58
+ filters: list[FilterCondition] = Field(default_factory=list)
59
+
60
+
61
+ class DeleteRowsResponse(BaseModel):
62
+ deleted_count: int
63
+
64
+
65
+ def create_tables_router(studio: "Studio") -> APIRouter:
66
+ router = APIRouter()
67
+
68
+ @router.get("/api/tables")
69
+ def get_tables() -> Any:
70
+ return studio.show_tables()
71
+
72
+ @router.get("/api/tables/{name}")
73
+ def get_table(name: str) -> Any:
74
+ return studio.show_table(name)
75
+
76
+ @router.post("/api/{table_name}/query")
77
+ async def query_table_rows(table_name: str, payload: FilterRequest) -> Any:
78
+ """
79
+ Exposes advanced database table operations to handle compound UI filters.
80
+ """
81
+ try:
82
+ return studio.get_rows_advanced(table_name, payload)
83
+ except HTTPException as e:
84
+ raise e
85
+ except Exception as e:
86
+ raise HTTPException(
87
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
88
+ detail=f"Query builder failed: {str(e)}",
89
+ )
90
+
91
+ @router.post("/api/{table_name}/delete", response_model=DeleteRowsResponse)
92
+ async def delete_table_rows(
93
+ table_name: str, payload: DeleteRowsRequest
94
+ ) -> DeleteRowsResponse:
95
+ try:
96
+ result = studio.delete_rows(table_name, payload)
97
+ return DeleteRowsResponse(**result)
98
+ except HTTPException as e:
99
+ raise e
100
+ except Exception as e:
101
+ raise HTTPException(
102
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
103
+ detail=f"Delete rows failed: {str(e)}",
104
+ )
105
+
106
+ return router
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--radius-md:.375rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.order-last{order:9999}.col-span-2{grid-column:span 2/span 2}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.table{display:table}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.max-h-\[45vh\]{max-height:45vh}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-screen{min-height:100vh}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-20{width:calc(var(--spacing) * 20)}.w-full{width:100%}.w-max{width:max-content}.max-w-12{max-width:calc(var(--spacing) * 12)}.max-w-48{max-width:calc(var(--spacing) * 48)}.max-w-72{max-width:calc(var(--spacing) * 72)}.max-w-full{max-width:100%}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-12{min-width:calc(var(--spacing) * 12)}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.animate-spin{animation:var(--animate-spin)}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[2\.25rem_minmax\(0\,1fr\)\]{grid-template-columns:2.25rem minmax(0,1fr)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.rounded{border-radius:.25rem}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-neutral-600{border-color:var(--color-neutral-600)}.border-neutral-800{border-color:var(--color-neutral-800)}.border-red-900\/60{border-color:#82181a99}@supports (color:color-mix(in lab, red, red)){.border-red-900\/60{border-color:color-mix(in oklab, var(--color-red-900) 60%, transparent)}}.bg-neutral-700{background-color:var(--color-neutral-700)}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-neutral-900\/40{background-color:#17171766}@supports (color:color-mix(in lab, red, red)){.bg-neutral-900\/40{background-color:color-mix(in oklab, var(--color-neutral-900) 40%, transparent)}}.bg-neutral-950{background-color:var(--color-neutral-950)}.bg-red-700{background-color:var(--color-red-700)}.bg-red-950\/40{background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.bg-red-950\/40{background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-8{padding:calc(var(--spacing) * 8)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-6{padding-block:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-neutral-100{color:var(--color-neutral-100)}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-white{color:var(--color-white)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.placeholder\:text-neutral-500::placeholder{color:var(--color-neutral-500)}@media (hover:hover){.hover\:border-neutral-700:hover{border-color:var(--color-neutral-700)}.hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.hover\:bg-neutral-800:hover{background-color:var(--color-neutral-800)}.hover\:bg-neutral-900:hover{background-color:var(--color-neutral-900)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:text-neutral-100:hover{color:var(--color-neutral-100)}}.focus\:border-neutral-600:focus{border-color:var(--color-neutral-600)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-35:disabled{opacity:.35}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:order-none{order:0}.sm\:ml-0{margin-left:calc(var(--spacing) * 0)}.sm\:w-auto{width:auto}.sm\:min-w-32{min-width:calc(var(--spacing) * 32)}.sm\:flex-none{flex:none}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}}@media (width>=48rem){.md\:col-span-1{grid-column:span 1/span 1}.md\:grid-cols-\[2\.25rem_minmax\(5rem\,6rem\)_minmax\(9rem\,14rem\)_minmax\(10rem\,16rem\)_minmax\(10rem\,1fr\)\]{grid-template-columns:2.25rem minmax(5rem,6rem) minmax(9rem,14rem) minmax(10rem,16rem) minmax(10rem,1fr)}}@media (width>=64rem){.lg\:mb-4{margin-bottom:calc(var(--spacing) * 4)}.lg\:block{display:block}.lg\:hidden{display:none}.lg\:h-screen{height:100vh}.lg\:max-h-none{max-height:none}.lg\:w-72{width:calc(var(--spacing) * 72)}.lg\:flex-row{flex-direction:row}.lg\:overflow-auto{overflow:auto}.lg\:overflow-hidden{overflow:hidden}.lg\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.lg\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.lg\:p-6{padding:calc(var(--spacing) * 6)}}}*{box-sizing:border-box}html,body,#app{min-height:100%}body{margin:0}.app-root{min-height:100vh}.app-root ::-webkit-scrollbar{width:8px;height:8px}.app-root ::-webkit-scrollbar-thumb{background:#7373732e;border-radius:8px}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}