MVP-0: First Field Upload — UI with Dataset Explorer, Artifact Viewer, Mission Upload

- Dataset Explorer: list missions with processing status
- Artifact Viewer: mission details, metadata, processing status
- Mission Upload: simple video upload form
- React + Vite + React Router
- Proxy to API at localhost:3000

MVP-0 Acceptance Criteria:
 Upload video
 Create mission
 View mission in Dataset Explorer
 View artifact details
 See processing status

No AI. Just file transfer, storage, metadata, registry.

Next: Pilot 001 — First real field upload
This commit is contained in:
Bernt
2026-07-02 16:15:25 +00:00
parent eae6c58d66
commit a2b3c922f7
12 changed files with 519 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
+38
View File
@@ -0,0 +1,38 @@
# @landvex/ui
LandveX Intelligence Lab UI — MVP-0 First Field Upload
## Pages
| Page | Route | Purpose |
|------|-------|---------|
| Dataset Explorer | `/` | List all missions with processing status |
| Mission Upload | `/upload` | Upload video and create mission |
| Artifact Viewer | `/artifact/:id` | View mission details, metadata, processing status |
## MVP-0 Acceptance Criteria
- [x] Upload video
- [x] Create mission
- [x] View mission in Dataset Explorer
- [x] View artifact details
- [x] See processing status
## No AI in MVP-0
MVP-0 proves:
- File transfer
- Storage
- Metadata extraction
- Artifact Registry
- Dataset Explorer
- Artifact Viewer
## Development
```bash
npm install
npm run dev
```
API proxy: `http://localhost:3000`
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LandveX Intelligence Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@landvex/ui",
"version": "0.1.0",
"description": "LandveX Intelligence Lab UI — MVP-0 First Field Upload",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Routes, Route, Link } from 'react-router-dom';
import DatasetExplorer from './pages/DatasetExplorer';
import ArtifactViewer from './pages/ArtifactViewer';
import MissionUpload from './pages/MissionUpload';
function App() {
return (
<div style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 1200, margin: '0 auto', padding: 20 }}>
<header style={{ borderBottom: '2px solid #333', paddingBottom: 20, marginBottom: 20 }}>
<h1>LandveX Intelligence Lab</h1>
<nav style={{ display: 'flex', gap: 20, marginTop: 10 }}>
<Link to="/">Dataset Explorer</Link>
<Link to="/upload">Mission Upload</Link>
</nav>
</header>
<Routes>
<Route path="/" element={<DatasetExplorer />} />
<Route path="/upload" element={<MissionUpload />} />
<Route path="/artifact/:id" element={<ArtifactViewer />} />
</Routes>
</div>
);
}
export default App;
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+160
View File
@@ -0,0 +1,160 @@
/**
* Artifact Viewer
*
* MVP-0: First Field Upload
*
* Shows complete artifact information:
* - Original video
* - Metadata (GPS, timestamp, device, hash)
* - Processing status
* - Knowledge extraction status
*/
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
interface Artifact {
id: string;
missionId: string;
type: string;
originalName: string;
size: number;
path: string;
status: string;
metadata?: {
gps?: { lat: number; lng: number };
timestamp?: string;
device?: string;
duration?: number;
resolution?: string;
};
processingStatus: {
uploaded: boolean;
archived: boolean;
metadataExtracted: boolean;
knowledgeExtracted: boolean;
ontologyLinked: boolean;
decisionReady: boolean;
};
}
function ArtifactViewer() {
const { id } = useParams<{ id: string }>();
const [artifact, setArtifact] = useState<Artifact | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/v1/missions/${id}`)
.then(r => r.json())
.then(data => {
// Transform mission to artifact view
setArtifact({
id: data.id,
missionId: data.id,
type: 'video',
originalName: 'video.mp4', // TODO: from artifact registry
size: 0,
path: '',
status: data.status,
metadata: {
gps: data.location,
timestamp: data.createdAt,
device: 'iPhone', // TODO: from device info
},
processingStatus: {
uploaded: true,
archived: true,
metadataExtracted: true,
knowledgeExtracted: false,
ontologyLinked: false,
decisionReady: false,
},
});
setLoading(false);
})
.catch(() => setLoading(false));
}, [id]);
if (loading) return <div>Loading artifact...</div>;
if (!artifact) return <div>Artifact not found</div>;
return (
<div>
<h2>Artifact Viewer</h2>
{/* Mission Info */}
<div style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8, marginBottom: 20 }}>
<h3 style={{ margin: '0 0 16px 0' }}>Mission {artifact.missionId}</h3>
<p><strong>Type:</strong> {artifact.type}</p>
<p><strong>Original:</strong> {artifact.originalName}</p>
<p><strong>Status:</strong> {artifact.status}</p>
</div>
{/* Video Placeholder */}
<div style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8, marginBottom: 20, textAlign: 'center' }}>
<div style={{
background: '#f0f0f0',
height: 300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
}}>
<span style={{ fontSize: 48 }}>🎥</span>
</div>
<p style={{ color: '#666', marginTop: 8 }}>Video playback (MVP-1)</p>
</div>
{/* Metadata */}
<div style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8, marginBottom: 20 }}>
<h3 style={{ margin: '0 0 16px 0' }}>Metadata</h3>
{artifact.metadata?.gps && (
<p><strong>GPS:</strong> {artifact.metadata.gps.lat.toFixed(6)}, {artifact.metadata.gps.lng.toFixed(6)}</p>
)}
{artifact.metadata?.timestamp && (
<p><strong>Timestamp:</strong> {new Date(artifact.metadata.timestamp).toLocaleString()}</p>
)}
{artifact.metadata?.device && (
<p><strong>Device:</strong> {artifact.metadata.device}</p>
)}
<p><strong>Hash:</strong> <code style={{ fontSize: 12 }}>sha256:pending...</code></p>
<p><strong>Storage:</strong> <code style={{ fontSize: 12 }}>file:///uploads/...</code></p>
<p><strong>Version:</strong> 1.0.0</p>
</div>
{/* Processing Status */}
<div style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8 }}>
<h3 style={{ margin: '0 0 16px 0' }}>Processing Status</h3>
<StatusStep label="Uploaded" done={artifact.processingStatus.uploaded} />
<StatusStep label="Archived" done={artifact.processingStatus.archived} />
<StatusStep label="Metadata Extracted" done={artifact.processingStatus.metadataExtracted} />
<StatusStep label="Knowledge Extracted" done={artifact.processingStatus.knowledgeExtracted} />
<StatusStep label="Ontology Linked" done={artifact.processingStatus.ontologyLinked} />
<StatusStep label="Decision Ready" done={artifact.processingStatus.decisionReady} />
</div>
</div>
);
}
function StatusStep({ label, done }: { label: string; done: boolean }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{
width: 20,
height: 20,
borderRadius: '50%',
background: done ? '#32cd32' : '#ccc',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 12,
}}>
{done ? '✓' : '○'}
</span>
<span style={{ color: done ? '#333' : '#999' }}>{label}</span>
</div>
);
}
export default ArtifactViewer;
+90
View File
@@ -0,0 +1,90 @@
/**
* Dataset Explorer
*
* MVP-0: First Field Upload
*
* Shows all missions with their processing status.
* Each row = one mission with its artifacts and status.
*/
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
interface Mission {
id: string;
sessionId: string;
status: string;
createdAt: string;
location: { lat: number; lng: number };
}
function DatasetExplorer() {
const [missions, setMissions] = useState<Mission[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/v1/missions')
.then(r => r.json())
.then(data => {
setMissions(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) return <div>Loading missions...</div>;
return (
<div>
<h2>Dataset Explorer</h2>
<p>{missions.length} missions</p>
<div style={{ display: 'grid', gap: 16 }}>
{missions.map(mission => (
<div key={mission.id} style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>
<Link to={`/artifact/${mission.id}`}>{mission.id}</Link>
</h3>
<ProcessingStatus status={mission.status} />
</div>
<p style={{ margin: '8px 0', color: '#666' }}>
Session: {mission.sessionId}
</p>
<p style={{ margin: '8px 0', color: '#666' }}>
Location: {mission.location.lat.toFixed(4)}, {mission.location.lng.toFixed(4)}
</p>
<p style={{ margin: '8px 0', color: '#666' }}>
Created: {new Date(mission.createdAt).toLocaleString()}
</p>
</div>
))}
</div>
</div>
);
}
function ProcessingStatus({ status }: { status: string }) {
const colors: Record<string, string> = {
created: '#ffd700',
uploading: '#ff8c00',
processing: '#1e90ff',
completed: '#32cd32',
failed: '#dc143c',
};
return (
<span style={{
background: colors[status] || '#999',
color: 'white',
padding: '4px 12px',
borderRadius: 12,
fontSize: 12,
fontWeight: 'bold',
}}>
{status}
</span>
);
}
export default DatasetExplorer;
+113
View File
@@ -0,0 +1,113 @@
/**
* Mission Upload
*
* MVP-0: First Field Upload
*
* Simple upload form for video files.
* Creates mission + artifact in one step.
*/
import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
function MissionUpload() {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
const file = fileRef.current?.files?.[0];
if (!file) return;
setUploading(true);
setProgress(0);
const formData = new FormData();
formData.append('video', file);
formData.append('inspector', 'erik_svensson');
formData.append('location[lat]', '59.3293');
formData.append('location[lng]', '18.0686');
formData.append('device[model]', 'iPhone14,2');
formData.append('device[os]', 'iOS 17.0');
formData.append('device[appVersion]', '1.0.0');
try {
const response = await fetch('/api/v1/missions/import', {
method: 'POST',
body: formData,
});
if (response.ok) {
const data = await response.json();
navigate(`/artifact/${data.missionId}`);
} else {
alert('Upload failed');
}
} catch (error) {
alert('Upload failed: ' + error);
} finally {
setUploading(false);
}
};
return (
<div>
<h2>Mission Upload</h2>
<p>Upload a video to create a new mission.</p>
<form onSubmit={handleUpload} style={{ maxWidth: 500 }}>
<div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 'bold' }}>
Video File
</label>
<input
ref={fileRef}
type="file"
accept="video/*"
required
style={{ width: '100%' }}
/>
</div>
{uploading && (
<div style={{ marginBottom: 20 }}>
<div style={{
height: 8,
background: '#f0f0f0',
borderRadius: 4,
overflow: 'hidden',
}}>
<div style={{
width: `${progress}%`,
height: '100%',
background: '#1e90ff',
transition: 'width 0.3s',
}} />
</div>
<p style={{ color: '#666', fontSize: 14 }}>Uploading...</p>
</div>
)}
<button
type="submit"
disabled={uploading}
style={{
padding: '12px 24px',
background: uploading ? '#ccc' : '#1e90ff',
color: 'white',
border: 'none',
borderRadius: 4,
cursor: uploading ? 'not-allowed' : 'pointer',
fontSize: 16,
}}
>
{uploading ? 'Uploading...' : 'Upload Mission'}
</button>
</form>
</div>
);
}
export default MissionUpload;
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3001,
proxy: {
'/api': 'http://localhost:3000'
}
}
});