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.
- package/.claude/agents/een-jobs-agent.md +676 -0
- package/CHANGELOG.md +7 -8
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1172 -28
- package/dist/index.js +796 -333
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +22 -1
- package/docs/ai-reference/AI-AUTH.md +1 -1
- package/docs/ai-reference/AI-AUTOMATIONS.md +1 -1
- package/docs/ai-reference/AI-DEVICES.md +1 -1
- package/docs/ai-reference/AI-EVENTS.md +1 -1
- package/docs/ai-reference/AI-GROUPING.md +1 -1
- package/docs/ai-reference/AI-JOBS.md +1084 -0
- package/docs/ai-reference/AI-MEDIA.md +1 -1
- package/docs/ai-reference/AI-SETUP.md +1 -1
- package/docs/ai-reference/AI-USERS.md +1 -1
- package/examples/vue-jobs/.env.example +11 -0
- package/examples/vue-jobs/README.md +245 -0
- package/examples/vue-jobs/e2e/app.spec.ts +79 -0
- package/examples/vue-jobs/e2e/auth.spec.ts +382 -0
- package/examples/vue-jobs/e2e/delete-features.spec.ts +564 -0
- package/examples/vue-jobs/e2e/timelapse.spec.ts +361 -0
- package/examples/vue-jobs/index.html +13 -0
- package/examples/vue-jobs/package-lock.json +1722 -0
- package/examples/vue-jobs/package.json +28 -0
- package/examples/vue-jobs/playwright.config.ts +47 -0
- package/examples/vue-jobs/src/App.vue +154 -0
- package/examples/vue-jobs/src/main.ts +25 -0
- package/examples/vue-jobs/src/router/index.ts +82 -0
- package/examples/vue-jobs/src/views/Callback.vue +76 -0
- package/examples/vue-jobs/src/views/CreateExport.vue +284 -0
- package/examples/vue-jobs/src/views/Files.vue +424 -0
- package/examples/vue-jobs/src/views/Home.vue +195 -0
- package/examples/vue-jobs/src/views/JobDetail.vue +392 -0
- package/examples/vue-jobs/src/views/Jobs.vue +297 -0
- package/examples/vue-jobs/src/views/Login.vue +33 -0
- package/examples/vue-jobs/src/views/Logout.vue +59 -0
- package/examples/vue-jobs/src/vite-env.d.ts +1 -0
- package/examples/vue-jobs/tsconfig.json +25 -0
- package/examples/vue-jobs/vite.config.ts +12 -0
- 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>
|