een-api-toolkit 0.3.47 → 0.3.49

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 (42) hide show
  1. package/.claude/agents/een-jobs-agent.md +676 -0
  2. package/CHANGELOG.md +7 -8
  3. package/dist/index.cjs +3 -3
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.ts +1172 -28
  6. package/dist/index.js +796 -333
  7. package/dist/index.js.map +1 -1
  8. package/docs/AI-CONTEXT.md +22 -1
  9. package/docs/ai-reference/AI-AUTH.md +1 -1
  10. package/docs/ai-reference/AI-AUTOMATIONS.md +1 -1
  11. package/docs/ai-reference/AI-DEVICES.md +1 -1
  12. package/docs/ai-reference/AI-EVENTS.md +1 -1
  13. package/docs/ai-reference/AI-GROUPING.md +1 -1
  14. package/docs/ai-reference/AI-JOBS.md +1084 -0
  15. package/docs/ai-reference/AI-MEDIA.md +1 -1
  16. package/docs/ai-reference/AI-SETUP.md +1 -1
  17. package/docs/ai-reference/AI-USERS.md +1 -1
  18. package/examples/vue-jobs/.env.example +11 -0
  19. package/examples/vue-jobs/README.md +245 -0
  20. package/examples/vue-jobs/e2e/app.spec.ts +79 -0
  21. package/examples/vue-jobs/e2e/auth.spec.ts +382 -0
  22. package/examples/vue-jobs/e2e/delete-features.spec.ts +564 -0
  23. package/examples/vue-jobs/e2e/timelapse.spec.ts +361 -0
  24. package/examples/vue-jobs/index.html +13 -0
  25. package/examples/vue-jobs/package-lock.json +1722 -0
  26. package/examples/vue-jobs/package.json +28 -0
  27. package/examples/vue-jobs/playwright.config.ts +47 -0
  28. package/examples/vue-jobs/src/App.vue +154 -0
  29. package/examples/vue-jobs/src/main.ts +25 -0
  30. package/examples/vue-jobs/src/router/index.ts +82 -0
  31. package/examples/vue-jobs/src/views/Callback.vue +76 -0
  32. package/examples/vue-jobs/src/views/CreateExport.vue +284 -0
  33. package/examples/vue-jobs/src/views/Files.vue +424 -0
  34. package/examples/vue-jobs/src/views/Home.vue +195 -0
  35. package/examples/vue-jobs/src/views/JobDetail.vue +392 -0
  36. package/examples/vue-jobs/src/views/Jobs.vue +297 -0
  37. package/examples/vue-jobs/src/views/Login.vue +33 -0
  38. package/examples/vue-jobs/src/views/Logout.vue +59 -0
  39. package/examples/vue-jobs/src/vite-env.d.ts +1 -0
  40. package/examples/vue-jobs/tsconfig.json +25 -0
  41. package/examples/vue-jobs/vite.config.ts +12 -0
  42. package/package.json +1 -1
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "een-api-toolkit-example-jobs",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "stop": "lsof -ti :3333 2>/dev/null | xargs -r kill -9 || echo 'Port 3333 is free'",
8
+ "dev": "npm run stop && vite",
9
+ "build": "vue-tsc && vite build",
10
+ "preview": "vite preview",
11
+ "test:e2e": "playwright test",
12
+ "test:e2e:ui": "playwright test --ui"
13
+ },
14
+ "dependencies": {
15
+ "een-api-toolkit": "file:../..",
16
+ "pinia": "^3.0.4",
17
+ "vue": "^3.4.0",
18
+ "vue-router": "^4.2.0"
19
+ },
20
+ "devDependencies": {
21
+ "@playwright/test": "1.58.0",
22
+ "@vitejs/plugin-vue": "^6.0.0",
23
+ "dotenv": "^17.2.3",
24
+ "typescript": "~5.8.0",
25
+ "vite": "^7.3.0",
26
+ "vue-tsc": "^3.2.1"
27
+ }
28
+ }
@@ -0,0 +1,47 @@
1
+ import { defineConfig, devices } from '@playwright/test'
2
+ import dotenv from 'dotenv'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+
8
+ // Load .env files: parent first, then local with override to replace any conflicts
9
+ // In CI, env vars are passed directly via workflow
10
+ dotenv.config({ path: path.resolve(__dirname, '../../.env') })
11
+ dotenv.config({ path: path.resolve(__dirname, '.env'), override: true })
12
+
13
+ const redirectUri = process.env.VITE_REDIRECT_URI || 'http://127.0.0.1:3333'
14
+ if (!redirectUri.startsWith('http://127.0.0.1:') && !redirectUri.startsWith('http://localhost:')) {
15
+ throw new Error('VITE_REDIRECT_URI must use localhost or 127.0.0.1 for security')
16
+ }
17
+ export const baseURL = redirectUri
18
+
19
+ export default defineConfig({
20
+ testDir: './e2e',
21
+ testMatch: '**/*.spec.ts',
22
+ fullyParallel: false, // Run tests sequentially for predictable order
23
+ forbidOnly: !!process.env.CI,
24
+ retries: 0, // No retries - fail fast
25
+ maxFailures: 1, // Stop on first failure
26
+ workers: 1,
27
+ reporter: [['html', { open: 'never' }]],
28
+ timeout: 30000,
29
+ use: {
30
+ baseURL,
31
+ trace: 'on-first-retry',
32
+ video: 'retain-on-failure'
33
+ },
34
+ outputDir: './e2e-results/',
35
+ projects: [
36
+ {
37
+ name: 'chromium',
38
+ use: { ...devices['Desktop Chrome'] }
39
+ }
40
+ ],
41
+ webServer: {
42
+ command: 'npm run dev',
43
+ url: baseURL,
44
+ reuseExistingServer: !process.env.CI,
45
+ timeout: 30000
46
+ }
47
+ })
@@ -0,0 +1,154 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, computed } from 'vue'
3
+ import { useAuthStore } from 'een-api-toolkit'
4
+
5
+ const authStore = useAuthStore()
6
+ const isAuthenticated = computed(() => authStore.isAuthenticated)
7
+
8
+ // Initialize auth store from storage on app mount
9
+ // This restores the session if a valid token exists in localStorage/sessionStorage
10
+ onMounted(() => {
11
+ authStore.initialize()
12
+ })
13
+ </script>
14
+
15
+ <template>
16
+ <div class="app">
17
+ <header>
18
+ <h1 data-testid="app-title">EEN Jobs Example</h1>
19
+ <nav>
20
+ <router-link data-testid="nav-home" to="/">Home</router-link>
21
+ <router-link data-testid="nav-jobs" v-if="isAuthenticated" to="/jobs">Jobs</router-link>
22
+ <router-link data-testid="nav-files" v-if="isAuthenticated" to="/files">Files</router-link>
23
+ <router-link data-testid="nav-create-export" v-if="isAuthenticated" to="/create-export">Create Export</router-link>
24
+ <router-link data-testid="nav-login" v-if="!isAuthenticated" to="/login">Login</router-link>
25
+ <router-link data-testid="nav-logout" v-if="isAuthenticated" to="/logout">Logout</router-link>
26
+ </nav>
27
+ </header>
28
+ <main>
29
+ <router-view />
30
+ </main>
31
+ </div>
32
+ </template>
33
+
34
+ <style>
35
+ * {
36
+ box-sizing: border-box;
37
+ margin: 0;
38
+ padding: 0;
39
+ }
40
+
41
+ body {
42
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
43
+ Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
44
+ line-height: 1.6;
45
+ color: #333;
46
+ }
47
+
48
+ .app {
49
+ max-width: 1200px;
50
+ margin: 0 auto;
51
+ padding: 20px;
52
+ }
53
+
54
+ header {
55
+ display: flex;
56
+ justify-content: space-between;
57
+ align-items: center;
58
+ margin-bottom: 30px;
59
+ padding-bottom: 20px;
60
+ border-bottom: 1px solid #eee;
61
+ }
62
+
63
+ header h1 {
64
+ font-size: 1.5rem;
65
+ }
66
+
67
+ nav {
68
+ display: flex;
69
+ gap: 20px;
70
+ }
71
+
72
+ nav a {
73
+ color: #42b883;
74
+ text-decoration: none;
75
+ }
76
+
77
+ nav a:hover {
78
+ text-decoration: underline;
79
+ }
80
+
81
+ nav a.router-link-active {
82
+ font-weight: bold;
83
+ }
84
+
85
+ button {
86
+ background: #42b883;
87
+ color: white;
88
+ border: none;
89
+ padding: 10px 20px;
90
+ border-radius: 4px;
91
+ cursor: pointer;
92
+ font-size: 1rem;
93
+ }
94
+
95
+ button:hover {
96
+ background: #3aa876;
97
+ }
98
+
99
+ button:disabled {
100
+ background: #ccc;
101
+ cursor: not-allowed;
102
+ }
103
+
104
+ .error {
105
+ color: #e74c3c;
106
+ padding: 10px;
107
+ background: #fdf2f2;
108
+ border-radius: 4px;
109
+ margin: 10px 0;
110
+ }
111
+
112
+ .loading {
113
+ color: #666;
114
+ font-style: italic;
115
+ }
116
+
117
+ .badge {
118
+ display: inline-block;
119
+ padding: 2px 8px;
120
+ border-radius: 4px;
121
+ font-size: 0.85em;
122
+ font-weight: 500;
123
+ }
124
+
125
+ .badge-pending {
126
+ background: #fff3cd;
127
+ color: #856404;
128
+ }
129
+
130
+ .badge-started {
131
+ background: #cce5ff;
132
+ color: #004085;
133
+ }
134
+
135
+ .badge-success {
136
+ background: #d4edda;
137
+ color: #155724;
138
+ }
139
+
140
+ .badge-failure {
141
+ background: #f8d7da;
142
+ color: #721c24;
143
+ }
144
+
145
+ .badge-available {
146
+ background: #d4edda;
147
+ color: #155724;
148
+ }
149
+
150
+ .badge-expired {
151
+ background: #e2e3e5;
152
+ color: #383d41;
153
+ }
154
+ </style>
@@ -0,0 +1,25 @@
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import { initEenToolkit } from 'een-api-toolkit'
4
+ import App from './App.vue'
5
+ import router from './router'
6
+
7
+ const app = createApp(App)
8
+
9
+ // Install Pinia (required before initEenToolkit)
10
+ app.use(createPinia())
11
+
12
+ // Initialize EEN API Toolkit with localStorage for persistent sessions
13
+ // Note: Using 'localStorage' storage means tokens persist across page refreshes
14
+ initEenToolkit({
15
+ proxyUrl: import.meta.env.VITE_PROXY_URL,
16
+ clientId: import.meta.env.VITE_EEN_CLIENT_ID,
17
+ redirectUri: import.meta.env.VITE_REDIRECT_URI,
18
+ storageStrategy: 'localStorage',
19
+ debug: import.meta.env.VITE_DEBUG === 'true'
20
+ })
21
+
22
+ // Install router
23
+ app.use(router)
24
+
25
+ app.mount('#app')
@@ -0,0 +1,82 @@
1
+ import { createRouter, createWebHistory } from 'vue-router'
2
+ import { useAuthStore } from 'een-api-toolkit'
3
+ import Home from '../views/Home.vue'
4
+ import Login from '../views/Login.vue'
5
+ import Callback from '../views/Callback.vue'
6
+ import Jobs from '../views/Jobs.vue'
7
+ import JobDetail from '../views/JobDetail.vue'
8
+ import Files from '../views/Files.vue'
9
+ import CreateExport from '../views/CreateExport.vue'
10
+ import Logout from '../views/Logout.vue'
11
+
12
+ const router = createRouter({
13
+ history: createWebHistory(),
14
+ routes: [
15
+ {
16
+ path: '/',
17
+ name: 'home',
18
+ component: Home,
19
+ // Handle OAuth callback on root path (EEN IDP redirects to http://127.0.0.1:3333)
20
+ beforeEnter: (to, _from, next) => {
21
+ // If URL has code and state params, it's an OAuth callback
22
+ if (to.query.code && to.query.state) {
23
+ next({ name: 'callback', query: to.query })
24
+ } else {
25
+ next()
26
+ }
27
+ }
28
+ },
29
+ {
30
+ path: '/login',
31
+ name: 'login',
32
+ component: Login
33
+ },
34
+ {
35
+ path: '/callback',
36
+ name: 'callback',
37
+ component: Callback
38
+ },
39
+ {
40
+ path: '/jobs',
41
+ name: 'jobs',
42
+ component: Jobs,
43
+ meta: { requiresAuth: true }
44
+ },
45
+ {
46
+ path: '/jobs/:id',
47
+ name: 'job-detail',
48
+ component: JobDetail,
49
+ meta: { requiresAuth: true }
50
+ },
51
+ {
52
+ path: '/files',
53
+ name: 'files',
54
+ component: Files,
55
+ meta: { requiresAuth: true }
56
+ },
57
+ {
58
+ path: '/create-export',
59
+ name: 'create-export',
60
+ component: CreateExport,
61
+ meta: { requiresAuth: true }
62
+ },
63
+ {
64
+ path: '/logout',
65
+ name: 'logout',
66
+ component: Logout
67
+ }
68
+ ]
69
+ })
70
+
71
+ // Navigation guard for protected routes
72
+ router.beforeEach((to, _from, next) => {
73
+ const authStore = useAuthStore()
74
+
75
+ if (to.meta.requiresAuth && !authStore.isAuthenticated) {
76
+ next({ name: 'login' })
77
+ } else {
78
+ next()
79
+ }
80
+ })
81
+
82
+ export default router
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { handleAuthCallback } from 'een-api-toolkit'
5
+
6
+ const router = useRouter()
7
+ const error = ref<string | null>(null)
8
+ const processing = ref(true)
9
+
10
+ onMounted(async () => {
11
+ const url = new URL(window.location.href)
12
+ const code = url.searchParams.get('code')
13
+ const state = url.searchParams.get('state')
14
+ const errorParam = url.searchParams.get('error')
15
+
16
+ if (errorParam) {
17
+ error.value = `OAuth error: ${errorParam}`
18
+ processing.value = false
19
+ return
20
+ }
21
+
22
+ if (!code || !state) {
23
+ error.value = 'Missing authorization code or state parameter'
24
+ processing.value = false
25
+ return
26
+ }
27
+
28
+ const result = await handleAuthCallback(code, state)
29
+
30
+ if (result.error) {
31
+ error.value = result.error.message
32
+ processing.value = false
33
+ return
34
+ }
35
+
36
+ // Success - redirect to home
37
+ router.push('/')
38
+ })
39
+ </script>
40
+
41
+ <template>
42
+ <div class="callback">
43
+ <div v-if="processing" class="loading">
44
+ <h2>Authenticating...</h2>
45
+ <p>Please wait while we complete the login process.</p>
46
+ </div>
47
+
48
+ <div v-else-if="error" class="error-state">
49
+ <h2>Authentication Failed</h2>
50
+ <p class="error">{{ error }}</p>
51
+ <router-link to="/login">
52
+ <button>Try Again</button>
53
+ </router-link>
54
+ </div>
55
+ </div>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .callback {
60
+ text-align: center;
61
+ max-width: 400px;
62
+ margin: 0 auto;
63
+ }
64
+
65
+ h2 {
66
+ margin-bottom: 20px;
67
+ }
68
+
69
+ .loading p {
70
+ color: #666;
71
+ }
72
+
73
+ .error-state .error {
74
+ margin-bottom: 20px;
75
+ }
76
+ </style>
@@ -0,0 +1,284 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { getCameras, createExportJob, formatTimestamp, type Camera, type EenError, type ExportType } from 'een-api-toolkit'
5
+
6
+ const router = useRouter()
7
+
8
+ // Form state
9
+ const selectedCamera = ref<string>('')
10
+ const exportType = ref<ExportType>('video')
11
+ const exportName = ref('')
12
+ const duration = ref(15) // minutes
13
+ const playbackMultiplier = ref(10) // default multiplier for timeLapse/bundle
14
+
15
+ // Check if playbackMultiplier is needed
16
+ const needsPlaybackMultiplier = computed(() => {
17
+ return exportType.value === 'timeLapse' || exportType.value === 'bundle'
18
+ })
19
+
20
+ // Data state
21
+ const cameras = ref<Camera[]>([])
22
+ const loading = ref(false)
23
+ const submitting = ref(false)
24
+ const error = ref<EenError | null>(null)
25
+ const success = ref<string | null>(null)
26
+
27
+ const exportTypes: { value: ExportType; label: string }[] = [
28
+ { value: 'video', label: 'Video' },
29
+ { value: 'timeLapse', label: 'Time Lapse' },
30
+ { value: 'bundle', label: 'Bundle (Video + Images)' }
31
+ ]
32
+
33
+ const durationOptions = [
34
+ { value: 5, label: '5 minutes' },
35
+ { value: 15, label: '15 minutes' },
36
+ { value: 30, label: '30 minutes' },
37
+ { value: 60, label: '1 hour' }
38
+ ]
39
+
40
+ const playbackMultiplierOptions = [
41
+ { value: 2, label: '2x (30 min → 15 min)' },
42
+ { value: 5, label: '5x (30 min → 6 min)' },
43
+ { value: 10, label: '10x (30 min → 3 min)' },
44
+ { value: 20, label: '20x (30 min → 1.5 min)' },
45
+ { value: 48, label: '48x (30 min → 37 sec)' }
46
+ ]
47
+
48
+ const canSubmit = computed(() => {
49
+ if (!selectedCamera.value || !exportType.value || submitting.value) {
50
+ return false
51
+ }
52
+ // Validate playbackMultiplier for timeLapse/bundle
53
+ if (needsPlaybackMultiplier.value) {
54
+ return playbackMultiplier.value >= 1 && playbackMultiplier.value <= 48
55
+ }
56
+ return true
57
+ })
58
+
59
+ async function fetchCameras() {
60
+ loading.value = true
61
+ error.value = null
62
+
63
+ const result = await getCameras({
64
+ pageSize: 100,
65
+ status__in: ['online', 'streaming']
66
+ })
67
+
68
+ if (result.error) {
69
+ error.value = result.error
70
+ } else {
71
+ cameras.value = result.data.results
72
+ }
73
+
74
+ loading.value = false
75
+ }
76
+
77
+ async function handleSubmit() {
78
+ if (!canSubmit.value) return
79
+
80
+ submitting.value = true
81
+ error.value = null
82
+ success.value = null
83
+
84
+ const endTime = new Date()
85
+ const startTime = new Date(endTime.getTime() - duration.value * 60 * 1000)
86
+
87
+ const exportParams: Parameters<typeof createExportJob>[0] = {
88
+ name: exportName.value || `Export - ${new Date().toLocaleString()}`,
89
+ type: exportType.value,
90
+ cameraId: selectedCamera.value,
91
+ startTimestamp: formatTimestamp(startTime.toISOString()),
92
+ endTimestamp: formatTimestamp(endTime.toISOString())
93
+ }
94
+
95
+ // Add playbackMultiplier for timeLapse and bundle exports
96
+ if (needsPlaybackMultiplier.value) {
97
+ exportParams.playbackMultiplier = playbackMultiplier.value
98
+ }
99
+
100
+ const result = await createExportJob(exportParams)
101
+
102
+ if (result.error) {
103
+ error.value = result.error
104
+ } else {
105
+ success.value = `Export job created successfully! Job ID: ${result.data.id}`
106
+ // Redirect to job detail page after a moment
107
+ setTimeout(() => {
108
+ router.push(`/jobs/${result.data.id}`)
109
+ }, 2000)
110
+ }
111
+
112
+ submitting.value = false
113
+ }
114
+
115
+ onMounted(() => {
116
+ fetchCameras()
117
+ })
118
+ </script>
119
+
120
+ <template>
121
+ <div class="create-export">
122
+ <h2>Create Export</h2>
123
+ <p class="description">
124
+ Create a video export from a camera. The export will be processed in the background
125
+ and you can track its progress on the Jobs page.
126
+ </p>
127
+
128
+ <div v-if="loading" class="loading">Loading cameras...</div>
129
+
130
+ <div v-else-if="cameras.length === 0 && !error" class="no-cameras">
131
+ <p>No online cameras available. Exports require at least one online camera.</p>
132
+ </div>
133
+
134
+ <form v-else @submit.prevent="handleSubmit" class="export-form">
135
+ <div class="form-group">
136
+ <label for="camera">Camera</label>
137
+ <select id="camera" v-model="selectedCamera" required>
138
+ <option value="">Select a camera...</option>
139
+ <option v-for="camera in cameras" :key="camera.id" :value="camera.id">
140
+ {{ camera.name }}
141
+ </option>
142
+ </select>
143
+ </div>
144
+
145
+ <div class="form-group">
146
+ <label for="exportType">Export Type</label>
147
+ <select id="exportType" v-model="exportType">
148
+ <option v-for="type in exportTypes" :key="type.value" :value="type.value">
149
+ {{ type.label }}
150
+ </option>
151
+ </select>
152
+ </div>
153
+
154
+ <div class="form-group">
155
+ <label for="duration">Duration (from now)</label>
156
+ <select id="duration" v-model="duration">
157
+ <option v-for="opt in durationOptions" :key="opt.value" :value="opt.value">
158
+ {{ opt.label }}
159
+ </option>
160
+ </select>
161
+ </div>
162
+
163
+ <div v-if="needsPlaybackMultiplier" class="form-group">
164
+ <label for="playbackMultiplier">Playback Speed</label>
165
+ <select id="playbackMultiplier" v-model="playbackMultiplier">
166
+ <option v-for="opt in playbackMultiplierOptions" :key="opt.value" :value="opt.value">
167
+ {{ opt.label }}
168
+ </option>
169
+ </select>
170
+ <p class="field-hint">Higher values create shorter, faster time-lapse videos</p>
171
+ </div>
172
+
173
+ <div class="form-group">
174
+ <label for="name">Export Name (optional)</label>
175
+ <input
176
+ id="name"
177
+ type="text"
178
+ v-model="exportName"
179
+ placeholder="Enter a name for this export"
180
+ />
181
+ </div>
182
+
183
+ <div v-if="error" class="error">
184
+ Error: {{ error.message }}
185
+ </div>
186
+
187
+ <div v-if="success" class="success">
188
+ {{ success }}
189
+ </div>
190
+
191
+ <div class="form-actions">
192
+ <button type="submit" :disabled="!canSubmit">
193
+ {{ submitting ? 'Creating...' : 'Create Export' }}
194
+ </button>
195
+ <router-link to="/jobs">
196
+ <button type="button" class="btn-secondary">Cancel</button>
197
+ </router-link>
198
+ </div>
199
+ </form>
200
+ </div>
201
+ </template>
202
+
203
+ <style scoped>
204
+ .create-export {
205
+ width: 80vw;
206
+ min-width: 500px;
207
+ margin: 0 auto;
208
+ }
209
+
210
+ h2 {
211
+ margin-bottom: 10px;
212
+ }
213
+
214
+ .description {
215
+ color: #666;
216
+ margin-bottom: 30px;
217
+ }
218
+
219
+ .no-cameras {
220
+ padding: 20px;
221
+ background: #fff3cd;
222
+ border-radius: 8px;
223
+ color: #856404;
224
+ }
225
+
226
+ .export-form {
227
+ background: #f5f5f5;
228
+ padding: 20px;
229
+ border-radius: 8px;
230
+ }
231
+
232
+ .form-group {
233
+ margin-bottom: 20px;
234
+ }
235
+
236
+ .form-group label {
237
+ display: block;
238
+ margin-bottom: 5px;
239
+ font-weight: 500;
240
+ }
241
+
242
+ .form-group select,
243
+ .form-group input {
244
+ width: 100%;
245
+ padding: 10px;
246
+ border: 1px solid #ddd;
247
+ border-radius: 4px;
248
+ font-size: 1rem;
249
+ }
250
+
251
+ .form-group select:focus,
252
+ .form-group input:focus {
253
+ outline: none;
254
+ border-color: #42b883;
255
+ }
256
+
257
+ .field-hint {
258
+ margin-top: 5px;
259
+ font-size: 0.85rem;
260
+ color: #888;
261
+ }
262
+
263
+ .form-actions {
264
+ display: flex;
265
+ gap: 10px;
266
+ margin-top: 20px;
267
+ }
268
+
269
+ .btn-secondary {
270
+ background: #6c757d;
271
+ }
272
+
273
+ .btn-secondary:hover {
274
+ background: #5a6268;
275
+ }
276
+
277
+ .success {
278
+ padding: 10px;
279
+ background: #d4edda;
280
+ border-radius: 4px;
281
+ color: #155724;
282
+ margin-bottom: 20px;
283
+ }
284
+ </style>