axiodb 22.2.2 → 22.4.2

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 (74) hide show
  1. package/electron/electron-builder.json +58 -0
  2. package/electron/main/main.cts +585 -0
  3. package/electron/main/preload.cts +51 -0
  4. package/electron/package-lock.json +8123 -0
  5. package/electron/package.json +52 -0
  6. package/electron/public/AXioDB.png +0 -0
  7. package/electron/resources/after-install.sh +45 -0
  8. package/electron/resources/after-remove.sh +29 -0
  9. package/electron/resources/icon.png +0 -0
  10. package/electron/resources/icons/128x128.png +0 -0
  11. package/electron/resources/icons/16x16.png +0 -0
  12. package/electron/resources/icons/24x24.png +0 -0
  13. package/electron/resources/icons/256x256.png +0 -0
  14. package/electron/resources/icons/32x32.png +0 -0
  15. package/electron/resources/icons/48x48.png +0 -0
  16. package/electron/resources/icons/512x512.png +0 -0
  17. package/electron/resources/icons/64x64.png +0 -0
  18. package/electron/src/App.jsx +128 -0
  19. package/electron/src/api/authApi.js +83 -0
  20. package/electron/src/api/client.js +102 -0
  21. package/electron/src/assets/AXioDB.png +0 -0
  22. package/electron/src/components/auth/CreateRoleModal.jsx +189 -0
  23. package/electron/src/components/auth/CreateUserModal.jsx +131 -0
  24. package/electron/src/components/auth/ForcePasswordChangeModal.jsx +132 -0
  25. package/electron/src/components/auth/ProtectedRoute.jsx +42 -0
  26. package/electron/src/components/auth/ResetPasswordModal.jsx +90 -0
  27. package/electron/src/components/auth/UserAvatarMenu.jsx +103 -0
  28. package/electron/src/components/collection/CreateCollectionModal.jsx +116 -0
  29. package/electron/src/components/collection/DeleteCollectionModal.jsx +85 -0
  30. package/electron/src/components/collection/SchemaViewModal.jsx +369 -0
  31. package/electron/src/components/dashboard/CollectionsChart.jsx +94 -0
  32. package/electron/src/components/dashboard/DatabaseTreeView.jsx +130 -0
  33. package/electron/src/components/dashboard/InMemoryCacheCard.jsx +60 -0
  34. package/electron/src/components/dashboard/StorageDonut.jsx +76 -0
  35. package/electron/src/components/dashboard/StorageUsageCard.jsx +59 -0
  36. package/electron/src/components/dashboard/TotalCollectionsCard.jsx +47 -0
  37. package/electron/src/components/dashboard/TotalDatabasesCard.jsx +43 -0
  38. package/electron/src/components/dashboard/TotalDocumentsCard.jsx +44 -0
  39. package/electron/src/components/database/CreateDatabaseModal.jsx +109 -0
  40. package/electron/src/components/database/DeleteDatabaseModal.jsx +97 -0
  41. package/electron/src/components/query/CodeEditor.jsx +343 -0
  42. package/electron/src/components/query/ObjectEditor.jsx +115 -0
  43. package/electron/src/components/query/ObjectView.jsx +44 -0
  44. package/electron/src/components/query/QueryEditor.jsx +32 -0
  45. package/electron/src/components/query/queryLanguage.js +755 -0
  46. package/electron/src/components/ui/Button.jsx +58 -0
  47. package/electron/src/components/ui/Card.jsx +29 -0
  48. package/electron/src/components/ui/ErrorBoundary.jsx +108 -0
  49. package/electron/src/components/ui/Feedback.jsx +66 -0
  50. package/electron/src/components/ui/Field.jsx +65 -0
  51. package/electron/src/components/ui/MetricCard.jsx +104 -0
  52. package/electron/src/components/ui/Modal.jsx +119 -0
  53. package/electron/src/components/ui/Page.jsx +40 -0
  54. package/electron/src/config/key.js +11 -0
  55. package/electron/src/index.css +181 -0
  56. package/electron/src/index.html +13 -0
  57. package/electron/src/layout/Sidebar.jsx +478 -0
  58. package/electron/src/layout/StatusBar.jsx +70 -0
  59. package/electron/src/layout/Titlebar.jsx +128 -0
  60. package/electron/src/main.jsx +42 -0
  61. package/electron/src/pages/ConnectionHub.jsx +464 -0
  62. package/electron/src/pages/Dashboard.jsx +128 -0
  63. package/electron/src/pages/Documents.jsx +823 -0
  64. package/electron/src/pages/Import.jsx +527 -0
  65. package/electron/src/pages/UserManagement.jsx +294 -0
  66. package/electron/src/pages/Welcome.jsx +157 -0
  67. package/electron/src/store/authStore.js +30 -0
  68. package/electron/src/store/connectionStore.js +103 -0
  69. package/electron/src/store/dbStore.js +165 -0
  70. package/electron/src/store/store.js +8 -0
  71. package/electron/src/utils/format.js +39 -0
  72. package/electron/vite.config.js +28 -0
  73. package/lib/Services/Indexation.operation.js +1 -1
  74. package/package.json +1 -1
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The one button. Variants exist so "primary action" is a decision made once here, not
3
+ * re-picked per page - the old code had blue, indigo and green all playing that role.
4
+ *
5
+ * @param {'primary'|'secondary'|'ghost'|'danger'|'dangerGhost'} [variant]
6
+ * @param {'sm'|'md'|'lg'} [size]
7
+ * @param {boolean} [loading] - shows a spinner and blocks interaction
8
+ * @param {React.ReactNode} [icon] - leading icon, hidden from assistive tech
9
+ */
10
+ const VARIANTS = {
11
+ primary:
12
+ 'bg-brand-600 text-white shadow-sm hover:bg-brand-700 active:bg-brand-800 disabled:bg-ink-300',
13
+ secondary:
14
+ 'bg-white text-ink-700 border border-ink-300 shadow-sm hover:bg-ink-50 hover:border-ink-400 active:bg-ink-100 disabled:text-ink-400',
15
+ ghost:
16
+ 'text-ink-600 hover:bg-ink-100 hover:text-ink-900 active:bg-ink-200 disabled:text-ink-400',
17
+ danger:
18
+ 'bg-danger-600 text-white shadow-sm hover:bg-danger-700 active:bg-danger-700 disabled:bg-ink-300',
19
+ dangerGhost:
20
+ 'text-danger-600 border border-danger-200 hover:bg-danger-50 hover:border-danger-500 active:bg-danger-100 disabled:text-ink-400'
21
+ }
22
+
23
+ const SIZES = {
24
+ sm: 'h-8 px-3 text-xs gap-1.5 rounded-lg',
25
+ md: 'h-10 px-4 text-sm gap-2 rounded-lg',
26
+ lg: 'h-11 px-5 text-sm gap-2 rounded-xl'
27
+ }
28
+
29
+ const Button = ({
30
+ variant = 'primary',
31
+ size = 'md',
32
+ loading = false,
33
+ icon = null,
34
+ className = '',
35
+ children,
36
+ disabled,
37
+ ...rest
38
+ }) => (
39
+ <button
40
+ disabled={disabled || loading}
41
+ className={`inline-flex items-center justify-center font-medium transition-all duration-150 disabled:cursor-not-allowed disabled:shadow-none active:scale-[0.98] ${VARIANTS[variant]} ${SIZES[size]} ${className}`}
42
+ {...rest}
43
+ >
44
+ {loading
45
+ ? (
46
+ <svg className='h-4 w-4 shrink-0 animate-spin' viewBox='0 0 24 24' fill='none' aria-hidden='true'>
47
+ <circle className='opacity-25' cx='12' cy='12' r='10' stroke='currentColor' strokeWidth='4' />
48
+ <path className='opacity-75' fill='currentColor' d='M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z' />
49
+ </svg>
50
+ )
51
+ : icon
52
+ ? <span className='shrink-0' aria-hidden='true'>{icon}</span>
53
+ : null}
54
+ {children}
55
+ </button>
56
+ )
57
+
58
+ export default Button
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Surface primitive. `interactive` adds the lift-on-hover treatment - only use it when the
3
+ * whole card is genuinely clickable, otherwise it promises an affordance that isn't there.
4
+ */
5
+ const Card = ({ interactive = false, className = '', children, ...rest }) => (
6
+ <div
7
+ className={`rounded-card border border-ink-200 bg-white shadow-card ${
8
+ interactive
9
+ ? 'cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:border-ink-300 hover:shadow-float'
10
+ : ''
11
+ } ${className}`}
12
+ {...rest}
13
+ >
14
+ {children}
15
+ </div>
16
+ )
17
+
18
+ /** Optional header strip: title on the left, actions on the right. */
19
+ export const CardHeader = ({ title, subtitle, action, className = '' }) => (
20
+ <div className={`flex items-start justify-between gap-4 border-b border-ink-200 px-5 py-4 ${className}`}>
21
+ <div className='min-w-0'>
22
+ <h3 className='truncate text-sm font-semibold text-ink-900'>{title}</h3>
23
+ {subtitle && <p className='mt-0.5 truncate text-xs text-ink-500'>{subtitle}</p>}
24
+ </div>
25
+ {action}
26
+ </div>
27
+ )
28
+
29
+ export default Card
@@ -0,0 +1,108 @@
1
+ import React from "react";
2
+
3
+ class ErrorBoundary extends React.Component {
4
+ constructor(props) {
5
+ super(props);
6
+ this.state = { hasError: false, error: null, errorInfo: null };
7
+ }
8
+
9
+ static getDerivedStateFromError(error) {
10
+ return { hasError: true, error };
11
+ }
12
+
13
+ componentDidCatch(error, errorInfo) {
14
+ console.error("ErrorBoundary caught an unhandled error:", error, errorInfo);
15
+ this.setState({ errorInfo });
16
+ }
17
+
18
+ handleReset = () => {
19
+ this.setState({ hasError: false, error: null, errorInfo: null });
20
+ if (typeof this.props.onReset === "function") {
21
+ this.props.onReset();
22
+ } else {
23
+ window.location.reload();
24
+ }
25
+ };
26
+
27
+ handleDisconnect = () => {
28
+ try {
29
+ localStorage.removeItem("axiodb_session");
30
+ if (window.electronAPI?.clearCookies) {
31
+ window.electronAPI.clearCookies();
32
+ }
33
+ } catch {
34
+ // ignore
35
+ }
36
+ window.location.hash = "#/";
37
+ window.location.reload();
38
+ };
39
+
40
+ render() {
41
+ if (this.state.hasError) {
42
+ const errorMsg = this.state.error?.message || "An unexpected rendering error occurred.";
43
+ const errorStack = this.state.error?.stack || this.state.errorInfo?.componentStack || "";
44
+
45
+ return (
46
+ <div className="min-h-screen w-full bg-slate-50 flex flex-col items-center justify-center p-6 text-slate-800 font-sans select-none">
47
+ <div className="max-w-lg w-full bg-white border border-slate-200 rounded-2xl p-6 shadow-md text-center">
48
+ {/* Warning Icon */}
49
+ <div className="w-14 h-14 rounded-2xl bg-amber-50 text-amber-600 border border-amber-200 flex items-center justify-center mx-auto mb-4 shadow-xs">
50
+ <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
51
+ <path
52
+ strokeLinecap="round"
53
+ strokeLinejoin="round"
54
+ strokeWidth={2}
55
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
56
+ />
57
+ </svg>
58
+ </div>
59
+
60
+ <h2 className="text-lg font-bold text-slate-900 mb-1">Application Error</h2>
61
+ <p className="text-xs text-slate-500 mb-4 leading-relaxed">
62
+ AxioDB Control encountered a problem while rendering this view. Your databases and server data remain safe.
63
+ </p>
64
+
65
+ {/* Error message card */}
66
+ <div className="bg-red-50 border border-red-200 rounded-lg p-3 text-left mb-4">
67
+ <p className="text-xs font-mono text-red-800 font-semibold break-words">{errorMsg}</p>
68
+ </div>
69
+
70
+ {/* Action buttons */}
71
+ <div className="flex items-center justify-center gap-2.5 mb-4">
72
+ <button
73
+ type="button"
74
+ onClick={this.handleReset}
75
+ className="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 active:scale-[0.98] text-white text-xs font-bold transition-all shadow-xs"
76
+ >
77
+ Reload View
78
+ </button>
79
+ <button
80
+ type="button"
81
+ onClick={this.handleDisconnect}
82
+ className="px-4 py-2 rounded-lg bg-slate-100 hover:bg-slate-200 border border-slate-200 text-slate-700 text-xs font-semibold transition-all"
83
+ >
84
+ Return to Connection Hub
85
+ </button>
86
+ </div>
87
+
88
+ {/* Collapsible Details */}
89
+ {errorStack && (
90
+ <details className="text-left group">
91
+ <summary className="text-[11px] font-mono text-slate-400 hover:text-slate-600 cursor-pointer select-none">
92
+ Show stack trace
93
+ </summary>
94
+ <pre className="mt-2 p-2.5 rounded bg-slate-900 text-slate-200 text-[10px] font-mono overflow-auto max-h-40 leading-tight">
95
+ {errorStack}
96
+ </pre>
97
+ </details>
98
+ )}
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ return this.props.children;
105
+ }
106
+ }
107
+
108
+ export default ErrorBoundary;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Small shared pieces of feedback: status badges, skeletons, empty states and alerts.
3
+ * Kept together because they are each a handful of lines and are almost always imported
4
+ * alongside one another.
5
+ */
6
+
7
+ const BADGE_TONES = {
8
+ neutral: 'bg-ink-100 text-ink-700 ring-ink-200',
9
+ brand: 'bg-brand-50 text-brand-700 ring-brand-200',
10
+ danger: 'bg-danger-50 text-danger-700 ring-danger-200',
11
+ warn: 'bg-warn-50 text-warn-700 ring-warn-200',
12
+ info: 'bg-info-50 text-info-700 ring-info-200'
13
+ }
14
+
15
+ /** @param {'neutral'|'brand'|'danger'|'warn'|'info'} [tone] */
16
+ export const Badge = ({ tone = 'neutral', dot = false, className = '', children }) => (
17
+ <span
18
+ className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${BADGE_TONES[tone]} ${className}`}
19
+ >
20
+ {dot && <span className='h-1.5 w-1.5 rounded-full bg-current' aria-hidden='true' />}
21
+ {children}
22
+ </span>
23
+ )
24
+
25
+ /**
26
+ * Loading placeholder. Give it the dimensions of the content it stands in for - a skeleton
27
+ * that doesn't match causes a layout jump the moment real data lands.
28
+ */
29
+ export const Skeleton = ({ className = '' }) => (
30
+ <div className={`skeleton rounded-md ${className}`} aria-hidden='true' />
31
+ )
32
+
33
+ /**
34
+ * Shown when a list has no rows. Always offers the action that would create the first one -
35
+ * an empty state without a next step is a dead end.
36
+ */
37
+ export const EmptyState = ({ icon, title, description, action, className = '' }) => (
38
+ <div className={`flex flex-col items-center justify-center px-6 py-16 text-center ${className}`}>
39
+ {icon && (
40
+ <div className='mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-ink-100 text-ink-400'>
41
+ {icon}
42
+ </div>
43
+ )}
44
+ <h3 className='text-base font-semibold text-ink-900'>{title}</h3>
45
+ {description && <p className='mt-1.5 max-w-sm text-sm text-ink-500'>{description}</p>}
46
+ {action && <div className='mt-6'>{action}</div>}
47
+ </div>
48
+ )
49
+
50
+ const ALERT_TONES = {
51
+ danger: 'border-danger-200 bg-danger-50 text-danger-700',
52
+ warn: 'border-warn-200 bg-warn-50 text-warn-700',
53
+ info: 'border-info-200 bg-info-50 text-info-700',
54
+ brand: 'border-brand-200 bg-brand-50 text-brand-700'
55
+ }
56
+
57
+ /** @param {'danger'|'warn'|'info'|'brand'} [tone] */
58
+ export const Alert = ({ tone = 'info', title, className = '', children }) => (
59
+ <div
60
+ role={tone === 'danger' ? 'alert' : 'status'}
61
+ className={`animate-fadeIn rounded-lg border px-4 py-3 text-sm ${ALERT_TONES[tone]} ${className}`}
62
+ >
63
+ {title && <p className='font-semibold'>{title}</p>}
64
+ {children && <div className={title ? 'mt-1' : ''}>{children}</div>}
65
+ </div>
66
+ )
@@ -0,0 +1,65 @@
1
+ import { useId } from 'react'
2
+
3
+ /**
4
+ * Labelled input. Bundles label, hint and error so a form field can't ship without a label
5
+ * wired to its control, and so error styling is identical everywhere.
6
+ */
7
+ export const Input = ({ label, hint, error, mono = false, className = '', ...rest }) => {
8
+ const id = useId()
9
+
10
+ return (
11
+ <div className={className}>
12
+ {label && (
13
+ <label htmlFor={id} className='mb-1.5 block text-sm font-medium text-ink-700'>
14
+ {label}
15
+ </label>
16
+ )}
17
+ <input
18
+ id={id}
19
+ aria-invalid={Boolean(error)}
20
+ aria-describedby={error || hint ? `${id}-desc` : undefined}
21
+ className={`w-full rounded-lg border bg-white px-3 py-2 text-sm text-ink-900 shadow-sm transition-all placeholder:text-ink-400 focus:outline-none focus:ring-2 ${
22
+ error
23
+ ? 'border-danger-500 focus:border-danger-500 focus:ring-danger-500/30'
24
+ : 'border-ink-300 focus:border-brand-500 focus:ring-brand-500/25'
25
+ } ${mono ? 'font-mono' : ''}`}
26
+ {...rest}
27
+ />
28
+ {(error || hint) && (
29
+ <p id={`${id}-desc`} className={`mt-1.5 text-xs ${error ? 'text-danger-600' : 'text-ink-500'}`}>
30
+ {error || hint}
31
+ </p>
32
+ )}
33
+ </div>
34
+ )
35
+ }
36
+
37
+ /** Same contract as Input, for multi-line values. */
38
+ export const Textarea = ({ label, hint, error, mono = true, className = '', ...rest }) => {
39
+ const id = useId()
40
+
41
+ return (
42
+ <div className={className}>
43
+ {label && (
44
+ <label htmlFor={id} className='mb-1.5 block text-sm font-medium text-ink-700'>
45
+ {label}
46
+ </label>
47
+ )}
48
+ <textarea
49
+ id={id}
50
+ aria-invalid={Boolean(error)}
51
+ className={`w-full rounded-lg border bg-white px-3 py-2 text-sm text-ink-900 shadow-sm transition-all placeholder:text-ink-400 focus:outline-none focus:ring-2 ${
52
+ error
53
+ ? 'border-danger-500 focus:border-danger-500 focus:ring-danger-500/30'
54
+ : 'border-ink-300 focus:border-brand-500 focus:ring-brand-500/25'
55
+ } ${mono ? 'font-mono' : ''}`}
56
+ {...rest}
57
+ />
58
+ {(error || hint) && (
59
+ <p className={`mt-1.5 text-xs ${error ? 'text-danger-600' : 'text-ink-500'}`}>
60
+ {error || hint}
61
+ </p>
62
+ )}
63
+ </div>
64
+ )
65
+ }
@@ -0,0 +1,104 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import Card from './Card'
3
+ import { Skeleton } from './Feedback'
4
+
5
+ /**
6
+ * Counts from the previous value to the next one instead of snapping. Uses rAF and eases
7
+ * out, so the number settles rather than stopping dead.
8
+ *
9
+ * Respects prefers-reduced-motion by jumping straight to the target - an animated number is
10
+ * decoration, and for anyone who has asked for less motion it is also hard to read.
11
+ */
12
+ function useCountUp (target, duration = 700) {
13
+ const [display, setDisplay] = useState(target)
14
+ const fromRef = useRef(target)
15
+
16
+ useEffect(() => {
17
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
18
+ const from = fromRef.current
19
+
20
+ if (reduced || from === target) {
21
+ fromRef.current = target
22
+ setDisplay(target)
23
+ return
24
+ }
25
+
26
+ let frame
27
+ const start = performance.now()
28
+
29
+ const tick = (now) => {
30
+ const progress = Math.min((now - start) / duration, 1)
31
+ const eased = 1 - Math.pow(1 - progress, 3)
32
+ setDisplay(from + (target - from) * eased)
33
+ if (progress < 1) frame = requestAnimationFrame(tick)
34
+ else fromRef.current = target
35
+ }
36
+
37
+ frame = requestAnimationFrame(tick)
38
+ return () => cancelAnimationFrame(frame)
39
+ }, [target, duration])
40
+
41
+ return display
42
+ }
43
+
44
+ const ACCENTS = {
45
+ brand: 'bg-brand-50 text-brand-600',
46
+ indigo: 'bg-indigo-50 text-indigo-600',
47
+ amber: 'bg-amber-50 text-amber-600',
48
+ cyan: 'bg-cyan-50 text-cyan-600',
49
+ pink: 'bg-pink-50 text-pink-600'
50
+ }
51
+
52
+ /**
53
+ * A single headline number.
54
+ *
55
+ * @param {string} label
56
+ * @param {number} value
57
+ * @param {string} [unit] - rendered smaller, after the number
58
+ * @param {React.ReactNode} [icon]
59
+ * @param {'brand'|'indigo'|'amber'|'cyan'|'pink'} [accent]
60
+ * @param {boolean} [loading]
61
+ * @param {number} [decimals]
62
+ * @param {React.ReactNode} [footer] - sparkline, progress bar, or a secondary figure
63
+ */
64
+ const MetricCard = ({
65
+ label,
66
+ value = 0,
67
+ unit,
68
+ icon,
69
+ accent = 'brand',
70
+ loading = false,
71
+ decimals = 0,
72
+ footer = null
73
+ }) => {
74
+ const animated = useCountUp(loading ? 0 : value)
75
+
76
+ return (
77
+ <Card className='p-5'>
78
+ <div className='flex items-start justify-between gap-3'>
79
+ <div className='min-w-0'>
80
+ <p className='text-xs font-medium uppercase tracking-wide text-ink-500'>{label}</p>
81
+ {loading
82
+ ? <Skeleton className='mt-2 h-8 w-24' />
83
+ : (
84
+ <p className='mt-1.5 flex items-baseline gap-1 text-2xl font-bold tabular-nums text-ink-900'>
85
+ {animated.toLocaleString(undefined, {
86
+ minimumFractionDigits: decimals,
87
+ maximumFractionDigits: decimals
88
+ })}
89
+ {unit && <span className='text-sm font-medium text-ink-500'>{unit}</span>}
90
+ </p>
91
+ )}
92
+ </div>
93
+ {icon && (
94
+ <span className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl ${ACCENTS[accent]}`}>
95
+ {icon}
96
+ </span>
97
+ )}
98
+ </div>
99
+ {footer && <div className='mt-4'>{loading ? <Skeleton className='h-2 w-full' /> : footer}</div>}
100
+ </Card>
101
+ )
102
+ }
103
+
104
+ export default MetricCard
@@ -0,0 +1,119 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ const SIZES = {
4
+ sm: 'max-w-md',
5
+ md: 'max-w-lg',
6
+ lg: 'max-w-2xl',
7
+ xl: 'max-w-4xl'
8
+ }
9
+
10
+ const Modal = ({
11
+ isOpen,
12
+ onClose,
13
+ title,
14
+ subtitle,
15
+ icon = null,
16
+ size = 'md',
17
+ tone = 'default',
18
+ footer = null,
19
+ children
20
+ }) => {
21
+ const panelRef = useRef(null)
22
+ const returnFocusRef = useRef(null)
23
+
24
+ const onCloseRef = useRef(onClose)
25
+ useEffect(() => {
26
+ onCloseRef.current = onClose
27
+ }, [onClose])
28
+
29
+ useEffect(() => {
30
+ if (!isOpen) return
31
+
32
+ returnFocusRef.current = document.activeElement
33
+ const { overflow } = document.body.style
34
+ document.body.style.overflow = 'hidden'
35
+
36
+ const onKeyDown = (event) => {
37
+ if (event.key === 'Escape') onCloseRef.current?.()
38
+ }
39
+ document.addEventListener('keydown', onKeyDown)
40
+
41
+ const focusTimer = requestAnimationFrame(() => {
42
+ const firstFocusable = panelRef.current?.querySelector('input:not([disabled]), textarea:not([disabled]), button:not([disabled]), select:not([disabled]')
43
+ if (firstFocusable instanceof HTMLElement) firstFocusable.focus()
44
+ else panelRef.current?.focus()
45
+ })
46
+
47
+ return () => {
48
+ document.removeEventListener('keydown', onKeyDown)
49
+ cancelAnimationFrame(focusTimer)
50
+ document.body.style.overflow = overflow
51
+ if (returnFocusRef.current instanceof HTMLElement) returnFocusRef.current.focus()
52
+ }
53
+ }, [isOpen])
54
+
55
+ if (!isOpen) return null
56
+
57
+ return (
58
+ <div
59
+ className='fixed inset-0 z-50 flex items-center justify-center bg-slate-900/25 p-4 backdrop-blur-xs select-none'
60
+ onMouseDown={(event) => {
61
+ if (event.target === event.currentTarget) onCloseRef.current?.()
62
+ }}
63
+ >
64
+ <div
65
+ ref={panelRef}
66
+ role='dialog'
67
+ aria-modal='true'
68
+ aria-label={title}
69
+ tabIndex={-1}
70
+ className={`animate-popIn flex max-h-[92vh] w-full ${SIZES[size]} flex-col overflow-hidden rounded-xl bg-white shadow-xl border border-slate-200 outline-none`}
71
+ >
72
+ <div
73
+ className={`flex items-center justify-between gap-4 px-5 py-3.5 border-b ${
74
+ tone === 'danger'
75
+ ? 'bg-red-50/80 border-red-200 text-red-900'
76
+ : 'bg-slate-50/90 border-slate-200 text-slate-900'
77
+ }`}
78
+ >
79
+ <div className='flex min-w-0 items-center gap-3'>
80
+ {icon && (
81
+ <span
82
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
83
+ tone === 'danger'
84
+ ? 'bg-red-100 text-red-600'
85
+ : 'bg-emerald-50 text-emerald-600 border border-emerald-200'
86
+ }`}
87
+ >
88
+ {icon}
89
+ </span>
90
+ )}
91
+ <div className='min-w-0'>
92
+ <h3 className='truncate text-sm font-bold text-slate-800'>{title}</h3>
93
+ {subtitle && <p className='truncate font-mono text-xs text-slate-500'>{subtitle}</p>}
94
+ </div>
95
+ </div>
96
+ <button
97
+ onClick={onClose}
98
+ aria-label='Close dialog'
99
+ className='shrink-0 rounded-lg p-1 text-slate-400 transition-colors hover:bg-slate-200/60 hover:text-slate-700'
100
+ >
101
+ <svg className='h-4 w-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
102
+ <path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12' />
103
+ </svg>
104
+ </button>
105
+ </div>
106
+
107
+ <div className='flex-1 overflow-y-auto p-5 text-slate-700'>{children}</div>
108
+
109
+ {footer && (
110
+ <div className='flex items-center justify-end gap-2.5 border-t border-slate-200 bg-slate-50/80 px-5 py-3'>
111
+ {footer}
112
+ </div>
113
+ )}
114
+ </div>
115
+ </div>
116
+ )
117
+ }
118
+
119
+ export default Modal
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Standard page container.
3
+ *
4
+ * Fluid up to 1920px rather than the old 1280px cap, so a 1080p or 1440p screen is actually
5
+ * used instead of showing ~320px of empty gutter on each side. The cap only engages on
6
+ * ultrawide displays, where an unbounded row of cards would stretch past the point of being
7
+ * scannable.
8
+ *
9
+ * Prose is the exception - see `prose` below. Full-width body text is hard to read because
10
+ * the eye loses its place on the return sweep, so text-heavy pages constrain the measure
11
+ * even though their page frame is fluid.
12
+ */
13
+ const Page = ({ className = '', children }) => (
14
+ <div className={`mx-auto w-full max-w-[120rem] px-4 py-8 sm:px-6 lg:px-8 ${className}`}>
15
+ {children}
16
+ </div>
17
+ )
18
+
19
+ /**
20
+ * Page title block. Optional actions sit on the right and wrap underneath on small screens.
21
+ */
22
+ export const PageHeader = ({ title, description, action, children, className = '' }) => (
23
+ <header className={`mb-8 ${className}`}>
24
+ {children}
25
+ <div className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
26
+ <div className='min-w-0'>
27
+ <h1 className='text-2xl font-bold tracking-tight text-ink-900 sm:text-3xl'>{title}</h1>
28
+ {description && <p className='mt-1 text-sm text-ink-500'>{description}</p>}
29
+ </div>
30
+ {action}
31
+ </div>
32
+ </header>
33
+ )
34
+
35
+ /** Constrains a column of body text to a readable measure (~75 characters). */
36
+ export const Prose = ({ className = '', children }) => (
37
+ <div className={`max-w-[70ch] ${className}`}>{children}</div>
38
+ )
39
+
40
+ export default Page
@@ -0,0 +1,11 @@
1
+ import { useConnectionStore } from "../store/connectionStore";
2
+
3
+ export const isDevelopmentMode = import.meta.env.DEV;
4
+
5
+ // When set to empty string, all relative API paths `/api/...` are resolved
6
+ // against the active connection Base URL in axios request interceptors.
7
+ export const BASE_API_URL = "";
8
+
9
+ export const getActiveBaseUrl = () => {
10
+ return useConnectionStore.getState().getBaseUrl();
11
+ };