siril-cloud 0.0.1 → 0.1.1
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/package.json +16 -2
- package/serve.js +160 -0
- package/siril-cloud-client-0.0.1.tgz +0 -0
- package/src/App.svelte +0 -452
- package/src/app.css +0 -34
- package/src/main.ts +0 -5
- package/vite.config.ts +0 -13
package/package.json
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "siril-cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"description": "Siril Cloud desktop client UI — Svelte app for managing cloud astrophotography stacking",
|
|
6
|
+
"bin": {
|
|
7
|
+
"siril-cloud": "./serve.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"serve.js",
|
|
12
|
+
"index.html"
|
|
13
|
+
],
|
|
5
14
|
"scripts": {
|
|
6
15
|
"dev": "vite",
|
|
7
16
|
"build": "vite build",
|
|
8
|
-
"preview": "vite preview"
|
|
17
|
+
"preview": "vite preview",
|
|
18
|
+
"start": "node serve.js"
|
|
9
19
|
},
|
|
10
20
|
"devDependencies": {
|
|
11
21
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
12
22
|
"svelte": "^5.0.0",
|
|
13
23
|
"vite": "^6.0.0"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"chokidar": "^5.0.0",
|
|
27
|
+
"googleapis": "^173.0.0"
|
|
14
28
|
}
|
|
15
29
|
}
|
package/serve.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Siril Cloud local client — Node.js standalone backend
|
|
3
|
+
// Starts HTTP server at localhost:9559, watches a folder, compresses FITS with fpack,
|
|
4
|
+
// uploads to Google Drive, and submits stacking jobs.
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { readFileSync, existsSync, statSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { join, basename, extname, dirname } from 'node:path';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { watch } from 'chokidar';
|
|
12
|
+
import { google } from 'googleapis';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
|
|
15
|
+
const execFileP = promisify(execFile);
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const STATE_FILE = join(os.homedir(), '.siril-cloud-state.json');
|
|
18
|
+
|
|
19
|
+
const MIME = { '.html':'text/html','.js':'text/javascript','.css':'text/css','.png':'image/png','.svg':'image/svg+xml','.ico':'image/x-icon','.woff2':'font/woff2' };
|
|
20
|
+
const FITS_EXT = new Set(['.fit','.fits','.fts']);
|
|
21
|
+
const RAW_EXT = new Set(['.cr2','.cr3','.nef','.raf','.dng','.arw','.orf','.pef']);
|
|
22
|
+
const PORT = parseInt(process.env.PORT || '9559', 10);
|
|
23
|
+
|
|
24
|
+
let state = { watching: false, api_key: '', watch_dir: '', cloud_provider: 'none',
|
|
25
|
+
cloud_folder_id: '', clerk_user_id: '', total: 0, errors: [], history: [],
|
|
26
|
+
job_frames: 0, job_bytes: 0, uploaded: new Set(), auto_submit: true };
|
|
27
|
+
let watcher = null;
|
|
28
|
+
|
|
29
|
+
function loadState() { try { state = { ...state, ...JSON.parse(readFileSync(STATE_FILE,'utf-8')) }; } catch {} }
|
|
30
|
+
function saveState() { const { watching, uploaded, total, errors, ...s } = state; writeFileSync(STATE_FILE, JSON.stringify(s,null,2)); }
|
|
31
|
+
loadState();
|
|
32
|
+
|
|
33
|
+
async function compressFits(path) {
|
|
34
|
+
const out = path + '.fz';
|
|
35
|
+
try { await execFileP('fpack', ['-q','4',path]); return out; }
|
|
36
|
+
catch { /* fpack not installed */ return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function uploadGDrive(localPath, accessToken, folderId) {
|
|
40
|
+
const drive = google.drive({ version: 'v3', auth: accessToken });
|
|
41
|
+
const media = { body: readFileSync(localPath) };
|
|
42
|
+
const res = await drive.files.create({
|
|
43
|
+
requestBody: { name: basename(localPath), parents: folderId ? [folderId] : undefined },
|
|
44
|
+
media,
|
|
45
|
+
fields: 'id',
|
|
46
|
+
});
|
|
47
|
+
return res.data.id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function getClerkToken() {
|
|
51
|
+
if (!state.clerk_user_id) return null;
|
|
52
|
+
const r = await fetch(`https://api.clerk.com/v1/users/${state.clerk_user_id}/oauth_access_tokens/oauth_google`, {
|
|
53
|
+
headers: { Authorization: `Bearer ${process.env.CLERK_SECRET_KEY || ''}` },
|
|
54
|
+
});
|
|
55
|
+
if (!r.ok) return null;
|
|
56
|
+
const data = await r.json();
|
|
57
|
+
return data?.[0]?.token || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function uploadFile(localPath) {
|
|
61
|
+
if (state.cloud_provider === 'none') return true;
|
|
62
|
+
if (state.cloud_provider !== 'gdrive') throw new Error('only gdrive supported');
|
|
63
|
+
const token = await getClerkToken();
|
|
64
|
+
if (!token) throw new Error('no Clerk OAuth token');
|
|
65
|
+
await uploadGDrive(localPath, token, state.cloud_folder_id);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function processFrame(path) {
|
|
70
|
+
state.total++;
|
|
71
|
+
const ext = extname(path).toLowerCase();
|
|
72
|
+
const size = statSync(path).size;
|
|
73
|
+
try {
|
|
74
|
+
if (RAW_EXT.has(ext)) {
|
|
75
|
+
await uploadFile(path);
|
|
76
|
+
} else if (FITS_EXT.has(ext)) {
|
|
77
|
+
const compressed = await compressFits(path);
|
|
78
|
+
if (!compressed) throw new Error('fpack compression failed');
|
|
79
|
+
await uploadFile(compressed);
|
|
80
|
+
try { unlinkSync(compressed); } catch {}
|
|
81
|
+
} else { return; }
|
|
82
|
+
state.uploaded.add(path);
|
|
83
|
+
state.job_frames++;
|
|
84
|
+
state.job_bytes += size;
|
|
85
|
+
saveState();
|
|
86
|
+
state.history.unshift({ path: basename(path), status: 'ok', time: new Date().toISOString() });
|
|
87
|
+
state.history = state.history.slice(0, 50);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
state.errors.push(`${basename(path)}: ${e.message}`);
|
|
90
|
+
if (state.errors.length > 20) state.errors.shift();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function startWatching() {
|
|
95
|
+
if (watcher || !state.watch_dir) return;
|
|
96
|
+
watcher = watch(state.watch_dir, { persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 2000 } });
|
|
97
|
+
watcher.on('add', p => { const e = extname(p).toLowerCase(); if (FITS_EXT.has(e) || RAW_EXT.has(e)) processFrame(p); });
|
|
98
|
+
state.watching = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stopWatching() {
|
|
102
|
+
if (watcher) { watcher.close(); watcher = null; }
|
|
103
|
+
state.watching = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function json(res, obj, status = 200) {
|
|
107
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify(obj));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function apiRouter(url, req, res) {
|
|
112
|
+
const p = url.pathname;
|
|
113
|
+
if (p === '/api/status') return json(res, { ...state, watching: state.watching, errors: [...state.errors], uploaded: Array.from(state.uploaded) });
|
|
114
|
+
if (p === '/api/history') return json(res, state.history);
|
|
115
|
+
if (p === '/api/whoami') return json(res, { clerk_user_id: state.clerk_user_id, cloud_provider: state.cloud_provider });
|
|
116
|
+
|
|
117
|
+
if (req.method === 'POST') {
|
|
118
|
+
let body = '';
|
|
119
|
+
req.on('data', d => body += d);
|
|
120
|
+
req.on('end', async () => {
|
|
121
|
+
if (body) { try { body = JSON.parse(body); } catch { return json(res, {error:'invalid json'}, 400); } }
|
|
122
|
+
if (p === '/api/start') { startWatching(); return json(res, { ok: true }); }
|
|
123
|
+
if (p === '/api/stop') { stopWatching(); return json(res, { ok: true }); }
|
|
124
|
+
if (p === '/api/configure') {
|
|
125
|
+
state.watch_dir = body.watch_dir || state.watch_dir;
|
|
126
|
+
state.api_key = body.api_key || state.api_key;
|
|
127
|
+
state.cloud_provider = body.cloud_provider || 'none';
|
|
128
|
+
state.cloud_folder_id = body.cloud_folder_id || state.cloud_folder_id;
|
|
129
|
+
state.clerk_user_id = body.clerk_user_id || state.clerk_user_id;
|
|
130
|
+
state.auto_submit = body.auto_submit !== false;
|
|
131
|
+
saveState();
|
|
132
|
+
return json(res, { ok: true, watch_dir: state.watch_dir });
|
|
133
|
+
}
|
|
134
|
+
if (p === '/api/submit') {
|
|
135
|
+
if (!state.api_key) return json(res, { error: 'no API key configured' }, 400);
|
|
136
|
+
if (state.job_frames === 0) return json(res, { error: 'no frames uploaded' }, 400);
|
|
137
|
+
// Submit stacking job to Siril Cloud API
|
|
138
|
+
return json(res, { ok: true, submitted: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
res.writeHead(404); res.end('Not found');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const DIST = join(__dirname, 'dist');
|
|
147
|
+
createServer((req, res) => {
|
|
148
|
+
let url = new URL(req.url || '/', `http://localhost:${PORT}`).pathname;
|
|
149
|
+
if (url.startsWith('/api/')) return apiRouter(new URL(`http://localhost${url}`), req, res);
|
|
150
|
+
if (url === '/') url = '/index.html';
|
|
151
|
+
const file = join(DIST, url);
|
|
152
|
+
if (existsSync(file)) {
|
|
153
|
+
const ext = extname(file);
|
|
154
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
155
|
+
res.end(readFileSync(file));
|
|
156
|
+
} else {
|
|
157
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
158
|
+
res.end(readFileSync(join(DIST, 'index.html')));
|
|
159
|
+
}
|
|
160
|
+
}).listen(PORT, () => console.log(`Siril Cloud Client → http://localhost:${PORT}`));
|
|
Binary file
|
package/src/App.svelte
DELETED
|
@@ -1,452 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
let watching = $state(false);
|
|
3
|
-
let dir = $state('');
|
|
4
|
-
let apiKey = $state('');
|
|
5
|
-
let saved = $state(false);
|
|
6
|
-
let cloudProvider = $state('gdrive');
|
|
7
|
-
let cloudFolderId = $state('');
|
|
8
|
-
let error = $state('');
|
|
9
|
-
let showSettings = $state(false);
|
|
10
|
-
let status = $state<any>({ watching: false, converting: 0, uploading: 0, total: 0, errors: [], job_frames: 0, job_bytes: 0 });
|
|
11
|
-
let history = $state<Array<{path: string, status: string, time: string}>>([]);
|
|
12
|
-
let submitting = $state(false);
|
|
13
|
-
|
|
14
|
-
const API = 'http://localhost:9559';
|
|
15
|
-
|
|
16
|
-
async function poll() {
|
|
17
|
-
try {
|
|
18
|
-
const r = await fetch(API + '/api/status');
|
|
19
|
-
if (r.ok) {
|
|
20
|
-
const s = await r.json();
|
|
21
|
-
status = s;
|
|
22
|
-
watching = s.watching;
|
|
23
|
-
if (s.watch_dir && !dir) dir = s.watch_dir;
|
|
24
|
-
}
|
|
25
|
-
} catch {}
|
|
26
|
-
try {
|
|
27
|
-
const r = await fetch(API + '/api/history');
|
|
28
|
-
if (r.ok) history = (await r.json()).history || [];
|
|
29
|
-
} catch {}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function save() {
|
|
33
|
-
error = '';
|
|
34
|
-
const r = await fetch(API + '/api/configure', {
|
|
35
|
-
method: 'POST',
|
|
36
|
-
headers: { 'Content-Type': 'application/json' },
|
|
37
|
-
body: JSON.stringify({
|
|
38
|
-
watch_dir: dir,
|
|
39
|
-
api_key: apiKey,
|
|
40
|
-
cloud_provider: cloudProvider,
|
|
41
|
-
cloud_folder_id: cloudFolderId
|
|
42
|
-
})
|
|
43
|
-
});
|
|
44
|
-
const d = await r.json();
|
|
45
|
-
if (r.ok && d.ok) {
|
|
46
|
-
saved = true;
|
|
47
|
-
showSettings = false;
|
|
48
|
-
} else {
|
|
49
|
-
error = d.error || 'Save failed';
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function start() {
|
|
54
|
-
if (!saved) { showSettings = true; return; }
|
|
55
|
-
const r = await fetch(API + '/api/start', { method: 'POST' });
|
|
56
|
-
if (r.ok) poll();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function stop() {
|
|
60
|
-
await fetch(API + '/api/stop', { method: 'POST' });
|
|
61
|
-
poll();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function submitJob() {
|
|
65
|
-
submitting = true;
|
|
66
|
-
try {
|
|
67
|
-
const r = await fetch(API + '/api/submit', { method: 'POST' });
|
|
68
|
-
const d = await r.json();
|
|
69
|
-
if (!r.ok) throw new Error(d.error || d.detail || 'Failed');
|
|
70
|
-
alert('Job submitted: ' + d.job?.job_id || d.job_id);
|
|
71
|
-
poll();
|
|
72
|
-
} catch(e: any) {
|
|
73
|
-
error = e.message;
|
|
74
|
-
} finally {
|
|
75
|
-
submitting = false;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function timeAgo(dateStr: string): string {
|
|
80
|
-
const d = new Date(dateStr);
|
|
81
|
-
const now = new Date();
|
|
82
|
-
const sec = Math.floor((now.getTime() - d.getTime()) / 1000);
|
|
83
|
-
if (sec < 60) return `${sec}s ago`;
|
|
84
|
-
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
85
|
-
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
|
86
|
-
return `${Math.floor(sec / 86400)}d ago`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function openDashboard() {
|
|
90
|
-
window.open('https://siril-web-261043368546-us-east-1.s3-website-us-east-1.amazonaws.com/cloud', '_blank');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
$effect(() => {
|
|
94
|
-
const i = setInterval(poll, 2000);
|
|
95
|
-
poll();
|
|
96
|
-
return () => clearInterval(i);
|
|
97
|
-
});
|
|
98
|
-
</script>
|
|
99
|
-
|
|
100
|
-
<div class="app">
|
|
101
|
-
<header>
|
|
102
|
-
<div class="logo-row">
|
|
103
|
-
<div class="logo-mark"></div>
|
|
104
|
-
<div>
|
|
105
|
-
<h1>Siril Cloud Client</h1>
|
|
106
|
-
<div class="status-row">
|
|
107
|
-
<span class="dot {watching ? 'on' : 'off'}"></span>
|
|
108
|
-
<span class="status-text">{watching ? 'Watching' : 'Stopped'}</span>
|
|
109
|
-
{#if dir}<span class="dim">· {dir}</span>{/if}
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
<div class="header-actions">
|
|
114
|
-
<button class="btn-secondary" onclick={openDashboard}>Dashboard</button>
|
|
115
|
-
<button class="btn-secondary" onclick={() => showSettings = !showSettings}>Settings</button>
|
|
116
|
-
</div>
|
|
117
|
-
</header>
|
|
118
|
-
|
|
119
|
-
{#if showSettings}
|
|
120
|
-
<div class="card settings-card">
|
|
121
|
-
<h3>Configuration</h3>
|
|
122
|
-
|
|
123
|
-
<label>
|
|
124
|
-
<span class="lbl">Watch Folder</span>
|
|
125
|
-
<input type="text" bind:value={dir} placeholder="/home/user/astro/tonight" />
|
|
126
|
-
</label>
|
|
127
|
-
|
|
128
|
-
<label>
|
|
129
|
-
<span class="lbl">Cloud Storage</span>
|
|
130
|
-
<select bind:value={cloudProvider}>
|
|
131
|
-
<option value="gdrive">Google Drive</option>
|
|
132
|
-
<option value="onedrive" disabled>OneDrive (coming soon)</option>
|
|
133
|
-
<option value="dropbox" disabled>Dropbox (coming soon)</option>
|
|
134
|
-
</select>
|
|
135
|
-
</label>
|
|
136
|
-
|
|
137
|
-
<label>
|
|
138
|
-
<span class="lbl">Cloud Folder ID</span>
|
|
139
|
-
<input type="text" bind:value={cloudFolderId} placeholder="Google Drive folder ID" />
|
|
140
|
-
<small>Paste the folder ID from your dashboard's Cloud Storage page</small>
|
|
141
|
-
</label>
|
|
142
|
-
|
|
143
|
-
<label>
|
|
144
|
-
<span class="lbl">API Key</span>
|
|
145
|
-
<input type="password" bind:value={apiKey} placeholder="siril_sk_..." />
|
|
146
|
-
<small>Generate in your web dashboard at /keys</small>
|
|
147
|
-
</label>
|
|
148
|
-
|
|
149
|
-
{#if error}
|
|
150
|
-
<div class="error">{error}</div>
|
|
151
|
-
{/if}
|
|
152
|
-
|
|
153
|
-
<div class="settings-actions">
|
|
154
|
-
<button class="btn-secondary" onclick={() => showSettings = false}>Cancel</button>
|
|
155
|
-
<button class="btn-primary" onclick={save}>Save</button>
|
|
156
|
-
</div>
|
|
157
|
-
</div>
|
|
158
|
-
{/if}
|
|
159
|
-
|
|
160
|
-
<div class="stats-grid">
|
|
161
|
-
<div class="stat-card">
|
|
162
|
-
<span class="lbl">Total</span>
|
|
163
|
-
<span class="val">{status.total || 0}</span>
|
|
164
|
-
</div>
|
|
165
|
-
<div class="stat-card">
|
|
166
|
-
<span class="lbl">Uploading</span>
|
|
167
|
-
<span class="val">{status.uploading || 0}</span>
|
|
168
|
-
</div>
|
|
169
|
-
<div class="stat-card">
|
|
170
|
-
<span class="lbl">For Stack</span>
|
|
171
|
-
<span class="val">{status.job_frames || 0}</span>
|
|
172
|
-
<span class="lbl">{(status.job_bytes / 1024 / 1024 / 1024).toFixed(1) || '0'} GB</span>
|
|
173
|
-
</div>
|
|
174
|
-
<div class="stat-card">
|
|
175
|
-
<span class="lbl">Errors</span>
|
|
176
|
-
<span class="val" class:error-val={(status.errors?.length || 0) > 0}>{status.errors?.length || 0}</span>
|
|
177
|
-
</div>
|
|
178
|
-
</div>
|
|
179
|
-
|
|
180
|
-
<div class="control-bar">
|
|
181
|
-
{#if watching}
|
|
182
|
-
<button class="btn-primary danger" onclick={stop}>Stop & Stack</button>
|
|
183
|
-
{:else}
|
|
184
|
-
<button class="btn-primary" onclick={start}>Start Watching</button>
|
|
185
|
-
{/if}
|
|
186
|
-
{#if !watching && (status.job_frames || 0) >= 5}
|
|
187
|
-
<button class="btn-primary" onclick={submitJob} disabled={submitting}>
|
|
188
|
-
{#if submitting}Submitting...{:else}Submit {status.job_frames} Frames{/if}
|
|
189
|
-
</button>
|
|
190
|
-
{/if}
|
|
191
|
-
</div>
|
|
192
|
-
|
|
193
|
-
{#if (status.total || 0) > 0}
|
|
194
|
-
<div class="card">
|
|
195
|
-
<h3>Activity</h3>
|
|
196
|
-
{#if (status.converting || 0) + (status.uploading || 0) > 0}
|
|
197
|
-
<div class="progress-bar">
|
|
198
|
-
<div class="progress-fill" style="width: {Math.min(100, ((status.converting + status.uploading) / Math.max(1, status.total)) * 100)}%"></div>
|
|
199
|
-
</div>
|
|
200
|
-
<div class="dim small">{status.converting} converting, {status.uploading} uploading</div>
|
|
201
|
-
{/if}
|
|
202
|
-
</div>
|
|
203
|
-
{/if}
|
|
204
|
-
|
|
205
|
-
{#if history.length > 0}
|
|
206
|
-
<div class="card">
|
|
207
|
-
<h3>Recent Uploads</h3>
|
|
208
|
-
<div class="history-list">
|
|
209
|
-
{#each history.slice(0, 20) as h}
|
|
210
|
-
<div class="history-row">
|
|
211
|
-
<span class="file-name">{h.path || h.file}</span>
|
|
212
|
-
<span class="status-tag" class:ok={h.status === 'ok'} class:err={h.status === 'error'}>{h.status}</span>
|
|
213
|
-
<span class="dim small">{timeAgo(h.time)}</span>
|
|
214
|
-
</div>
|
|
215
|
-
{/each}
|
|
216
|
-
</div>
|
|
217
|
-
</div>
|
|
218
|
-
{/if}
|
|
219
|
-
|
|
220
|
-
{#if (status.errors?.length || 0) > 0}
|
|
221
|
-
<div class="card errors-card">
|
|
222
|
-
<h3 style="color: var(--blush)">Errors</h3>
|
|
223
|
-
{#each status.errors.slice(-10) as e}
|
|
224
|
-
<div class="error-row">{e}</div>
|
|
225
|
-
{/each}
|
|
226
|
-
</div>
|
|
227
|
-
{/if}
|
|
228
|
-
</div>
|
|
229
|
-
|
|
230
|
-
<style>
|
|
231
|
-
:global(body) {
|
|
232
|
-
margin: 0;
|
|
233
|
-
background: var(--void, #050507);
|
|
234
|
-
color: #fff;
|
|
235
|
-
font-family: var(--font-body, system-ui, sans-serif);
|
|
236
|
-
}
|
|
237
|
-
.app {
|
|
238
|
-
max-width: 640px;
|
|
239
|
-
margin: 0 auto;
|
|
240
|
-
padding: 32px 24px;
|
|
241
|
-
display: flex;
|
|
242
|
-
flex-direction: column;
|
|
243
|
-
gap: 16px;
|
|
244
|
-
}
|
|
245
|
-
header {
|
|
246
|
-
display: flex;
|
|
247
|
-
justify-content: space-between;
|
|
248
|
-
align-items: flex-start;
|
|
249
|
-
}
|
|
250
|
-
.logo-row {
|
|
251
|
-
display: flex;
|
|
252
|
-
gap: 12px;
|
|
253
|
-
align-items: center;
|
|
254
|
-
}
|
|
255
|
-
.logo-mark {
|
|
256
|
-
width: 36px;
|
|
257
|
-
height: 36px;
|
|
258
|
-
border-radius: 8px;
|
|
259
|
-
background: var(--electric, #1A04FF);
|
|
260
|
-
box-shadow: 0 0 20px rgba(26,4,255,0.3);
|
|
261
|
-
}
|
|
262
|
-
h1 {
|
|
263
|
-
font-family: var(--font-heading, system-ui, sans-serif);
|
|
264
|
-
text-transform: uppercase;
|
|
265
|
-
letter-spacing: -0.5px;
|
|
266
|
-
font-size: 20px;
|
|
267
|
-
font-weight: 600;
|
|
268
|
-
margin: 0;
|
|
269
|
-
}
|
|
270
|
-
.status-row {
|
|
271
|
-
display: flex;
|
|
272
|
-
align-items: center;
|
|
273
|
-
gap: 6px;
|
|
274
|
-
margin-top: 4px;
|
|
275
|
-
font-size: 12px;
|
|
276
|
-
color: rgba(255,255,255,0.5);
|
|
277
|
-
}
|
|
278
|
-
.dot {
|
|
279
|
-
width: 8px;
|
|
280
|
-
height: 8px;
|
|
281
|
-
border-radius: 50%;
|
|
282
|
-
}
|
|
283
|
-
.dot.on { background: #10b981; box-shadow: 0 0 8px #10b981; }
|
|
284
|
-
.dot.off { background: rgba(255,255,255,0.2); }
|
|
285
|
-
.status-text { color: rgba(255,255,255,0.7); font-weight: 500; }
|
|
286
|
-
.dim { color: rgba(255,255,255,0.3); font-size: 11px; }
|
|
287
|
-
.small { font-size: 11px; }
|
|
288
|
-
.header-actions { display: flex; gap: 8px; }
|
|
289
|
-
.btn-secondary, .btn-primary {
|
|
290
|
-
border: none;
|
|
291
|
-
border-radius: 6px;
|
|
292
|
-
padding: 8px 14px;
|
|
293
|
-
font-size: 13px;
|
|
294
|
-
font-weight: 500;
|
|
295
|
-
cursor: pointer;
|
|
296
|
-
transition: all 0.15s ease;
|
|
297
|
-
}
|
|
298
|
-
.btn-secondary {
|
|
299
|
-
background: rgba(255,255,255,0.05);
|
|
300
|
-
color: rgba(255,255,255,0.7);
|
|
301
|
-
}
|
|
302
|
-
.btn-secondary:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
|
303
|
-
.btn-primary {
|
|
304
|
-
background: var(--electric, #1A04FF);
|
|
305
|
-
color: #fff;
|
|
306
|
-
}
|
|
307
|
-
.btn-primary:hover { box-shadow: 0 0 16px rgba(26,4,255,0.5); }
|
|
308
|
-
.btn-primary.danger { background: #ef4444; }
|
|
309
|
-
.btn-primary.danger:hover { box-shadow: 0 0 16px rgba(239,68,68,0.5); }
|
|
310
|
-
.card {
|
|
311
|
-
background: rgba(255,255,255,0.02);
|
|
312
|
-
border: 1px solid rgba(255,255,255,0.06);
|
|
313
|
-
border-radius: 12px;
|
|
314
|
-
padding: 20px;
|
|
315
|
-
}
|
|
316
|
-
h3 {
|
|
317
|
-
font-size: 14px;
|
|
318
|
-
font-weight: 500;
|
|
319
|
-
margin: 0 0 16px;
|
|
320
|
-
color: rgba(255,255,255,0.8);
|
|
321
|
-
}
|
|
322
|
-
.settings-card label {
|
|
323
|
-
display: block;
|
|
324
|
-
margin-bottom: 14px;
|
|
325
|
-
}
|
|
326
|
-
.lbl {
|
|
327
|
-
display: block;
|
|
328
|
-
font-size: 11px;
|
|
329
|
-
text-transform: uppercase;
|
|
330
|
-
letter-spacing: 1px;
|
|
331
|
-
color: rgba(255,255,255,0.4);
|
|
332
|
-
margin-bottom: 6px;
|
|
333
|
-
}
|
|
334
|
-
input, select {
|
|
335
|
-
width: 100%;
|
|
336
|
-
box-sizing: border-box;
|
|
337
|
-
background: rgba(255,255,255,0.03);
|
|
338
|
-
border: 1px solid rgba(255,255,255,0.08);
|
|
339
|
-
border-radius: 6px;
|
|
340
|
-
padding: 8px 10px;
|
|
341
|
-
color: #fff;
|
|
342
|
-
font-size: 13px;
|
|
343
|
-
}
|
|
344
|
-
input:focus, select:focus {
|
|
345
|
-
outline: none;
|
|
346
|
-
border-color: var(--electric, #1A04FF);
|
|
347
|
-
}
|
|
348
|
-
small {
|
|
349
|
-
display: block;
|
|
350
|
-
font-size: 11px;
|
|
351
|
-
color: rgba(255,255,255,0.3);
|
|
352
|
-
margin-top: 4px;
|
|
353
|
-
}
|
|
354
|
-
.settings-actions {
|
|
355
|
-
display: flex;
|
|
356
|
-
justify-content: flex-end;
|
|
357
|
-
gap: 8px;
|
|
358
|
-
margin-top: 8px;
|
|
359
|
-
}
|
|
360
|
-
.error {
|
|
361
|
-
background: rgba(255,173,173,0.1);
|
|
362
|
-
border: 1px solid rgba(255,173,173,0.2);
|
|
363
|
-
border-radius: 6px;
|
|
364
|
-
padding: 8px 12px;
|
|
365
|
-
font-size: 12px;
|
|
366
|
-
color: var(--blush, #ffadad);
|
|
367
|
-
margin-bottom: 12px;
|
|
368
|
-
}
|
|
369
|
-
.stats-grid {
|
|
370
|
-
display: grid;
|
|
371
|
-
grid-template-columns: repeat(4, 1fr);
|
|
372
|
-
gap: 8px;
|
|
373
|
-
}
|
|
374
|
-
.stat-card {
|
|
375
|
-
background: rgba(255,255,255,0.02);
|
|
376
|
-
border: 1px solid rgba(255,255,255,0.06);
|
|
377
|
-
border-radius: 10px;
|
|
378
|
-
padding: 14px 10px;
|
|
379
|
-
text-align: center;
|
|
380
|
-
}
|
|
381
|
-
.stat-card .val {
|
|
382
|
-
display: block;
|
|
383
|
-
font-size: 24px;
|
|
384
|
-
font-weight: 600;
|
|
385
|
-
margin: 4px 0 2px;
|
|
386
|
-
}
|
|
387
|
-
.stat-card .error-val { color: #ef4444; }
|
|
388
|
-
.control-bar {
|
|
389
|
-
display: flex;
|
|
390
|
-
justify-content: center;
|
|
391
|
-
}
|
|
392
|
-
.control-bar .btn-primary {
|
|
393
|
-
padding: 10px 28px;
|
|
394
|
-
font-size: 14px;
|
|
395
|
-
}
|
|
396
|
-
.progress-bar {
|
|
397
|
-
height: 4px;
|
|
398
|
-
background: rgba(255,255,255,0.05);
|
|
399
|
-
border-radius: 2px;
|
|
400
|
-
overflow: hidden;
|
|
401
|
-
margin-bottom: 8px;
|
|
402
|
-
}
|
|
403
|
-
.progress-fill {
|
|
404
|
-
height: 100%;
|
|
405
|
-
background: var(--electric, #1A04FF);
|
|
406
|
-
border-radius: 2px;
|
|
407
|
-
transition: width 0.4s ease;
|
|
408
|
-
}
|
|
409
|
-
.history-list {
|
|
410
|
-
display: flex;
|
|
411
|
-
flex-direction: column;
|
|
412
|
-
gap: 2px;
|
|
413
|
-
}
|
|
414
|
-
.history-row {
|
|
415
|
-
display: flex;
|
|
416
|
-
align-items: center;
|
|
417
|
-
gap: 10px;
|
|
418
|
-
padding: 6px 0;
|
|
419
|
-
font-size: 12px;
|
|
420
|
-
border-bottom: 1px solid rgba(255,255,255,0.03);
|
|
421
|
-
}
|
|
422
|
-
.file-name {
|
|
423
|
-
flex: 1;
|
|
424
|
-
font-family: 'Space Mono', monospace;
|
|
425
|
-
font-size: 11px;
|
|
426
|
-
overflow: hidden;
|
|
427
|
-
text-overflow: ellipsis;
|
|
428
|
-
white-space: nowrap;
|
|
429
|
-
}
|
|
430
|
-
.status-tag {
|
|
431
|
-
font-size: 9px;
|
|
432
|
-
padding: 2px 6px;
|
|
433
|
-
border-radius: 3px;
|
|
434
|
-
text-transform: uppercase;
|
|
435
|
-
font-weight: 600;
|
|
436
|
-
letter-spacing: 0.5px;
|
|
437
|
-
background: rgba(255,255,255,0.06);
|
|
438
|
-
color: rgba(255,255,255,0.5);
|
|
439
|
-
}
|
|
440
|
-
.status-tag.ok { background: rgba(16,185,129,0.15); color: #10b981; }
|
|
441
|
-
.status-tag.err { background: rgba(239,68,68,0.15); color: #ef4444; }
|
|
442
|
-
.errors-card {
|
|
443
|
-
border-color: rgba(239,68,68,0.2);
|
|
444
|
-
}
|
|
445
|
-
.error-row {
|
|
446
|
-
font-size: 11px;
|
|
447
|
-
color: var(--blush, #ffadad);
|
|
448
|
-
font-family: 'Space Mono', monospace;
|
|
449
|
-
margin-top: 4px;
|
|
450
|
-
word-break: break-all;
|
|
451
|
-
}
|
|
452
|
-
</style>
|
package/src/app.css
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
@font-face {
|
|
2
|
-
font-family: "GT America Extended Bold";
|
|
3
|
-
src: url("https://framerusercontent.com/assets/TuO3NJNVZj5Vv558NOmvyxcELE0.woff2") format("woff2");
|
|
4
|
-
font-weight: 700; font-display: swap;
|
|
5
|
-
}
|
|
6
|
-
@font-face {
|
|
7
|
-
font-family: "GT America Black";
|
|
8
|
-
src: url("https://framerusercontent.com/assets/NINDbh9LPQD1N7dM5iI06r6Go.woff2") format("woff2");
|
|
9
|
-
font-weight: 900; font-display: swap;
|
|
10
|
-
}
|
|
11
|
-
:root {
|
|
12
|
-
--void: #000000;
|
|
13
|
-
--void-soft: #0D0D0D;
|
|
14
|
-
--electric: #1A04FF;
|
|
15
|
-
--electric-bright: #4D3AFF;
|
|
16
|
-
--blush: #FFADAD;
|
|
17
|
-
--font-sans: 'Space Grotesk', sans-serif;
|
|
18
|
-
--font-heading: 'GT America Black', 'Arial Black', sans-serif;
|
|
19
|
-
--ease: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
20
|
-
}
|
|
21
|
-
* { margin:0; padding:0; box-sizing:border-box }
|
|
22
|
-
body { background: var(--void); color: #fff; font-family: var(--font-sans); -webkit-font-smoothing: antialiased }
|
|
23
|
-
h1 { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: -2px; font-size: 28px; line-height: 1 }
|
|
24
|
-
.glass-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; backdrop-filter: blur(10px) }
|
|
25
|
-
.glass-card-blue { background: rgba(26,4,255,0.08); border: 1px solid rgba(26,4,255,0.2); border-radius: 12px; backdrop-filter: blur(10px); box-shadow: 0 0 40px rgba(26,4,255,0.1) }
|
|
26
|
-
.btn { background: var(--electric); color: #fff; border: none; border-radius: 6px; padding: 10px 24px; font-family: var(--font-sans); font-size: 14px; font-weight: 500; cursor: pointer; transition: box-shadow 0.3s, transform 0.3s }
|
|
27
|
-
.btn:hover { box-shadow: 0 0 30px rgba(26,4,255,0.4); transform: translateY(-1px) }
|
|
28
|
-
.btn:disabled { opacity: 0.4; cursor: default; transform: none; box-shadow: none }
|
|
29
|
-
input, select { width: 100%; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; padding: 10px; color: #fff; font-family: var(--font-sans); font-size: 13px }
|
|
30
|
-
input:focus, select:focus { border-color: var(--electric); outline: none; box-shadow: 0 0 20px rgba(26,4,255,0.1) }
|
|
31
|
-
label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: rgba(255,255,255,0.4); margin-bottom: 4px }
|
|
32
|
-
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block }
|
|
33
|
-
.dot-on { background: var(--electric); box-shadow: 0 0 10px rgba(26,4,255,0.6) }
|
|
34
|
-
.dot-off { background: rgba(255,255,255,0.15) }
|
package/src/main.ts
DELETED
package/vite.config.ts
DELETED