Rewrite from Go + vanilla JS to Python (FastAPI) + React (TypeScript)
- Backend: Python 3.12 with FastAPI, SQLAlchemy, boto3 - Frontend: React 18 with TypeScript, Vite build tooling - Updated Dockerfile for multi-stage Node + Python build - Updated CI pipeline for Python backend - Removed old Go code (cmd/, internal/, go.mod, go.sum) - Updated README with new tech stack documentation
This commit is contained in:
160
frontend/src/pages/TreePage.tsx
Normal file
160
frontend/src/pages/TreePage.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Graft } from '../types';
|
||||
import { listGrafts, cultivate, getDownloadUrl } from '../api';
|
||||
import './Home.css';
|
||||
import './TreePage.css';
|
||||
|
||||
function TreePage() {
|
||||
const { groveName, treeName } = useParams<{ groveName: string; treeName: string }>();
|
||||
const [grafts, setGrafts] = useState<Graft[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadResult, setUploadResult] = useState<string | null>(null);
|
||||
const [tag, setTag] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (groveName && treeName) {
|
||||
loadGrafts();
|
||||
}
|
||||
}, [groveName, treeName]);
|
||||
|
||||
async function loadGrafts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await listGrafts(groveName!, treeName!);
|
||||
setGrafts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load grafts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const file = fileInputRef.current?.files?.[0];
|
||||
if (!file) {
|
||||
setError('Please select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
const result = await cultivate(groveName!, treeName!, file, tag || undefined);
|
||||
setUploadResult(`Uploaded successfully! Fruit ID: ${result.fruit_id}`);
|
||||
setTag('');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
loadGrafts();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<nav className="breadcrumb">
|
||||
<Link to="/">Groves</Link> / <Link to={`/grove/${groveName}`}>{groveName}</Link> / <span>{treeName}</span>
|
||||
</nav>
|
||||
|
||||
<div className="page-header">
|
||||
<h1>📦 {treeName}</h1>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{uploadResult && <div className="success-message">{uploadResult}</div>}
|
||||
|
||||
<div className="upload-section card">
|
||||
<h3>Upload Artifact</h3>
|
||||
<form onSubmit={handleUpload} className="upload-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="file">File</label>
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tag">Tag (optional)</label>
|
||||
<input
|
||||
id="tag"
|
||||
type="text"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
placeholder="v1.0.0, latest, stable..."
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={uploading}>
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2>Tags / Versions</h2>
|
||||
{grafts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No tags yet. Upload an artifact with a tag to create one!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grafts-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Fruit ID</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grafts.map((graft) => (
|
||||
<tr key={graft.id}>
|
||||
<td><strong>{graft.name}</strong></td>
|
||||
<td className="fruit-id">{graft.fruit_id.substring(0, 12)}...</td>
|
||||
<td>{new Date(graft.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
<a
|
||||
href={getDownloadUrl(groveName!, treeName!, graft.name)}
|
||||
className="btn btn-secondary btn-small"
|
||||
download
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="usage-section card">
|
||||
<h3>Usage</h3>
|
||||
<p>Download artifacts using:</p>
|
||||
<pre>
|
||||
<code>curl -O {window.location.origin}/api/v1/grove/{groveName}/{treeName}/+/latest</code>
|
||||
</pre>
|
||||
<p>Or with a specific tag:</p>
|
||||
<pre>
|
||||
<code>curl -O {window.location.origin}/api/v1/grove/{groveName}/{treeName}/+/v1.0.0</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TreePage;
|
||||
Reference in New Issue
Block a user