feat: add "My Datasets" page
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import AppLayout from "./components/AppLayout";
|
import AppLayout from "./components/AppLayout";
|
||||||
|
import DatasetsPage from "./pages/Datasets";
|
||||||
import DatasetStatusPage from "./pages/DatasetStatus";
|
import DatasetStatusPage from "./pages/DatasetStatus";
|
||||||
import LoginPage from "./pages/Login";
|
import LoginPage from "./pages/Login";
|
||||||
import UploadPage from "./pages/Upload";
|
import UploadPage from "./pages/Upload";
|
||||||
@@ -20,6 +21,7 @@ function App() {
|
|||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/upload" element={<UploadPage />} />
|
<Route path="/upload" element={<UploadPage />} />
|
||||||
|
<Route path="/datasets" element={<DatasetsPage />} />
|
||||||
<Route path="/dataset/:datasetId/status" element={<DatasetStatusPage />} />
|
<Route path="/dataset/:datasetId/status" element={<DatasetStatusPage />} />
|
||||||
<Route path="/dataset/:datasetId/stats" element={<StatPage />} />
|
<Route path="/dataset/:datasetId/stats" element={<StatPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ const AppLayout = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSignedIn, setIsSignedIn] = useState(false);
|
const [isSignedIn, setIsSignedIn] = useState(false);
|
||||||
const [currentUser, setCurrentUser] = useState<Record<string, unknown> | null>(null);
|
const [currentUser, setCurrentUser] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [lastDatasetId, setLastDatasetId] = useState<string | null>(
|
||||||
|
localStorage.getItem("last_dataset_id")
|
||||||
|
);
|
||||||
|
|
||||||
const syncAuthState = useCallback(async () => {
|
const syncAuthState = useCallback(async () => {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
@@ -63,6 +66,17 @@ const AppLayout = () => {
|
|||||||
void syncAuthState();
|
void syncAuthState();
|
||||||
}, [location.pathname, syncAuthState]);
|
}, [location.pathname, syncAuthState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const datasetMatch = location.pathname.match(/^\/dataset\/(\d+)\/(status|stats)$/);
|
||||||
|
if (!datasetMatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasetId = datasetMatch[1];
|
||||||
|
localStorage.setItem("last_dataset_id", datasetId);
|
||||||
|
setLastDatasetId(datasetId);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
const onAuthButtonClick = () => {
|
const onAuthButtonClick = () => {
|
||||||
if (isSignedIn) {
|
if (isSignedIn) {
|
||||||
localStorage.removeItem("access_token");
|
localStorage.removeItem("access_token");
|
||||||
@@ -107,6 +121,31 @@ const AppLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ ...styles.controls, flexWrap: "wrap" }}>
|
<div style={{ ...styles.controls, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={location.pathname === "/upload" ? styles.buttonPrimary : styles.buttonSecondary}
|
||||||
|
onClick={() => navigate("/upload")}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={location.pathname === "/datasets" ? styles.buttonPrimary : styles.buttonSecondary}
|
||||||
|
onClick={() => navigate("/datasets")}
|
||||||
|
>
|
||||||
|
My datasets
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={location.pathname.endsWith("/stats") ? styles.buttonPrimary : styles.buttonSecondary}
|
||||||
|
onClick={() => lastDatasetId && navigate(`/dataset/${lastDatasetId}/stats`)}
|
||||||
|
disabled={!lastDatasetId}
|
||||||
|
>
|
||||||
|
{lastDatasetId ? "Last stats" : "Last stats (none)"}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={isSignedIn ? styles.buttonSecondary : styles.buttonPrimary}
|
style={isSignedIn ? styles.buttonSecondary : styles.buttonPrimary}
|
||||||
|
|||||||
145
frontend/src/pages/Datasets.tsx
Normal file
145
frontend/src/pages/Datasets.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import StatsStyling from "../styles/stats_styling";
|
||||||
|
|
||||||
|
const styles = StatsStyling;
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
|
type DatasetItem = {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
status?: "processing" | "complete" | "error" | string;
|
||||||
|
status_message?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DatasetsPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [datasets, setDatasets] = useState<DatasetItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false);
|
||||||
|
setError("You must be signed in to view datasets.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
axios
|
||||||
|
.get<DatasetItem[]>(`${API_BASE_URL}/user/datasets`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const sorted = [...(response.data || [])].sort((a, b) => b.id - a.id);
|
||||||
|
setDatasets(sorted);
|
||||||
|
})
|
||||||
|
.catch((requestError: unknown) => {
|
||||||
|
if (axios.isAxiosError(requestError)) {
|
||||||
|
setError(String(requestError.response?.data?.error || requestError.message));
|
||||||
|
} else {
|
||||||
|
setError("Failed to load datasets.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p style={{ ...styles.page, minHeight: "100vh" }}>Loading datasets...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.page}>
|
||||||
|
<div style={{ ...styles.container, maxWidth: 1100 }}>
|
||||||
|
<div style={{ ...styles.card, ...styles.headerBar }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, color: "#111827", fontSize: 28 }}>My Datasets</h1>
|
||||||
|
<p style={{ margin: "8px 0 0", color: "#6b7280", fontSize: 14 }}>
|
||||||
|
View and reopen datasets you previously uploaded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" style={styles.buttonPrimary} onClick={() => navigate("/upload")}>
|
||||||
|
Upload New Dataset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.card,
|
||||||
|
marginTop: 14,
|
||||||
|
borderColor: "rgba(185, 28, 28, 0.28)",
|
||||||
|
background: "#fff5f5",
|
||||||
|
color: "#991b1b",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && datasets.length === 0 && (
|
||||||
|
<div style={{ ...styles.card, marginTop: 14, color: "#374151" }}>
|
||||||
|
No datasets yet. Upload one to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && datasets.length > 0 && (
|
||||||
|
<div style={{ ...styles.card, marginTop: 14, padding: 0, overflow: "hidden" }}>
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
|
{datasets.map((dataset) => {
|
||||||
|
const isComplete = dataset.status === "complete";
|
||||||
|
const targetPath = isComplete
|
||||||
|
? `/dataset/${dataset.id}/stats`
|
||||||
|
: `/dataset/${dataset.id}/status`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={dataset.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
padding: "14px 16px",
|
||||||
|
borderBottom: "1px solid rgba(0,0,0,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 700, color: "#111827" }}>
|
||||||
|
{dataset.name || `Dataset #${dataset.id}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280", marginTop: 4 }}>
|
||||||
|
ID #{dataset.id} • Status: {dataset.status || "unknown"}
|
||||||
|
</div>
|
||||||
|
{dataset.status_message && (
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>
|
||||||
|
{dataset.status_message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={isComplete ? styles.buttonPrimary : styles.buttonSecondary}
|
||||||
|
onClick={() => navigate(targetPath)}
|
||||||
|
>
|
||||||
|
{isComplete ? "Open stats" : "View status"}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DatasetsPage;
|
||||||
@@ -3,7 +3,7 @@ const DEFAULT_TITLE = "Ethnograph View";
|
|||||||
const STATIC_TITLES: Record<string, string> = {
|
const STATIC_TITLES: Record<string, string> = {
|
||||||
"/login": "Sign In",
|
"/login": "Sign In",
|
||||||
"/upload": "Upload Dataset",
|
"/upload": "Upload Dataset",
|
||||||
"/stats": "Stats",
|
"/datasets": "My Datasets",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDocumentTitle = (pathname: string) => {
|
export const getDocumentTitle = (pathname: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user