Compare commits

..

6 Commits

8 changed files with 491 additions and 100 deletions

View File

@@ -56,5 +56,17 @@ services:
count: 1 count: 1
capabilities: [gpu] capabilities: [gpu]
frontend:
build:
context: ./frontend
container_name: crosspost_frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
depends_on:
- backend
volumes: volumes:
model_cache: model_cache:

13
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# Copy rest of the app
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]

View File

@@ -1,12 +1,18 @@
import { Routes, Route } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import AppLayout from "./components/AppLayout";
import LoginPage from "./pages/Login";
import UploadPage from "./pages/Upload"; import UploadPage from "./pages/Upload";
import StatPage from "./pages/Stats"; import StatPage from "./pages/Stats";
function App() { function App() {
return ( return (
<Routes> <Routes>
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/upload" element={<UploadPage />} /> <Route path="/upload" element={<UploadPage />} />
<Route path="/stats" element={<StatPage />} /> <Route path="/stats" element={<StatPage />} />
</Route>
</Routes> </Routes>
); );
} }

View File

@@ -0,0 +1,125 @@
import { useCallback, useEffect, useState } from "react";
import axios from "axios";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import StatsStyling from "../styles/stats_styling";
type ProfileResponse = {
user?: Record<string, unknown>;
};
const styles = StatsStyling;
const API_BASE_URL = "http://localhost:5000";
const getUserLabel = (user: Record<string, unknown> | null) => {
if (!user) {
return "Signed in";
}
const username = user.username;
if (typeof username === "string" && username.length > 0) {
return username;
}
const email = user.email;
if (typeof email === "string" && email.length > 0) {
return email;
}
return "Signed in";
};
const AppLayout = () => {
const location = useLocation();
const navigate = useNavigate();
const [isSignedIn, setIsSignedIn] = useState(false);
const [currentUser, setCurrentUser] = useState<Record<string, unknown> | null>(null);
const syncAuthState = useCallback(async () => {
const token = localStorage.getItem("access_token");
if (!token) {
setIsSignedIn(false);
setCurrentUser(null);
delete axios.defaults.headers.common.Authorization;
return;
}
axios.defaults.headers.common.Authorization = `Bearer ${token}`;
try {
const response = await axios.get<ProfileResponse>(`${API_BASE_URL}/profile`);
setIsSignedIn(true);
setCurrentUser(response.data.user ?? null);
} catch {
localStorage.removeItem("access_token");
delete axios.defaults.headers.common.Authorization;
setIsSignedIn(false);
setCurrentUser(null);
}
}, []);
useEffect(() => {
void syncAuthState();
}, [location.pathname, syncAuthState]);
const onAuthButtonClick = () => {
if (isSignedIn) {
localStorage.removeItem("access_token");
delete axios.defaults.headers.common.Authorization;
setIsSignedIn(false);
setCurrentUser(null);
navigate("/login", { replace: true });
return;
}
navigate("/login");
};
return (
<div
style={{
minHeight: "100vh",
background: "#f6f7fb",
fontFamily: styles.page.fontFamily,
color: "#111827",
}}
>
<div style={{ ...styles.container, padding: "16px 24px 0" }}>
<div style={{ ...styles.card, ...styles.headerBar }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span style={{ margin: 0, color: "#111827", fontSize: 18, fontWeight: 700 }}>
Ethnograph View
</span>
<span
style={{
padding: "4px 10px",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
fontFamily: styles.page.fontFamily,
background: isSignedIn ? "#dcfce7" : "#fee2e2",
color: isSignedIn ? "#166534" : "#991b1b",
}}
>
{isSignedIn ? `Signed in: ${getUserLabel(currentUser)}` : "Not signed in"}
</span>
</div>
<div style={{ ...styles.controls, flexWrap: "wrap" }}>
<button
type="button"
style={isSignedIn ? styles.buttonSecondary : styles.buttonPrimary}
onClick={onAuthButtonClick}
>
{isSignedIn ? "Sign out" : "Sign in"}
</button>
</div>
</div>
</div>
<Outlet />
</div>
);
};
export default AppLayout;

View File

@@ -1,68 +1,17 @@
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
a { html,
font-weight: 500; body,
color: #646cff; #root {
text-decoration: inherit; width: 100%;
} height: 100%;
a:hover {
color: #535bf2;
} }
body { body {
margin: 0; margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
} }

View File

@@ -0,0 +1,211 @@
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 = "http://localhost:5000";
const controlStyle = {
width: "100%",
maxWidth: "100%",
boxSizing: "border-box" as const,
};
const LoginPage = () => {
const navigate = useNavigate();
const [isRegisterMode, setIsRegisterMode] = useState(false);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [info, setInfo] = useState("");
useEffect(() => {
const token = localStorage.getItem("access_token");
if (!token) {
return;
}
axios.defaults.headers.common.Authorization = `Bearer ${token}`;
axios
.get(`${API_BASE_URL}/profile`)
.then(() => {
navigate("/upload", { replace: true });
})
.catch(() => {
localStorage.removeItem("access_token");
delete axios.defaults.headers.common.Authorization;
});
}, [navigate]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError("");
setInfo("");
setLoading(true);
try {
if (isRegisterMode) {
await axios.post(`${API_BASE_URL}/register`, { username, email, password });
setInfo("Account created. You can now sign in.");
setIsRegisterMode(false);
} else {
const response = await axios.post<{ access_token: string }>(
`${API_BASE_URL}/login`,
{ username, password }
);
const token = response.data.access_token;
localStorage.setItem("access_token", token);
axios.defaults.headers.common.Authorization = `Bearer ${token}`;
navigate("/upload");
}
} catch (requestError: unknown) {
if (axios.isAxiosError(requestError)) {
setError(
String(requestError.response?.data?.error || requestError.message || "Request failed")
);
} else {
setError("Unexpected error occurred.");
}
} finally {
setLoading(false);
}
};
return (
<div style={{ ...styles.container, maxWidth: 560, padding: "48px 24px" }}>
<div
style={{
...styles.card,
padding: 28,
background:
"linear-gradient(180deg, rgba(255,255,255,1) 0%, rgba(248,250,255,1) 100%)",
}}
>
<div style={{ marginBottom: 22, textAlign: "center" }}>
<h1 style={{ margin: 0, color: "#111827", fontSize: 30, lineHeight: 1.1 }}>
{isRegisterMode ? "Create your account" : "Welcome back"}
</h1>
<p style={{ margin: "8px 0 0", color: "#6b7280", fontSize: 14 }}>
{isRegisterMode
? "Register to start uploading and exploring your dataset insights."
: "Sign in to continue to your analytics workspace."}
</p>
</div>
<form
onSubmit={handleSubmit}
style={{ display: "grid", gap: 12, maxWidth: 380, margin: "0 auto" }}
>
<input
type="text"
placeholder="Username"
style={{ ...styles.input, ...controlStyle }}
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
{isRegisterMode && (
<input
type="email"
placeholder="Email"
style={{ ...styles.input, ...controlStyle }}
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
)}
<input
type="password"
placeholder="Password"
style={{ ...styles.input, ...controlStyle }}
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<button
type="submit"
style={{ ...styles.buttonPrimary, ...controlStyle, marginTop: 2 }}
disabled={loading}
>
{loading
? "Please wait..."
: isRegisterMode
? "Create account"
: "Sign in"}
</button>
</form>
{error && (
<p
style={{
color: "#b91c1c",
margin: "12px auto 0",
fontSize: 14,
maxWidth: 380,
textAlign: "center",
}}
>
{error}
</p>
)}
{info && (
<p
style={{
color: "#166534",
margin: "12px auto 0",
fontSize: 14,
maxWidth: 380,
textAlign: "center",
}}
>
{info}
</p>
)}
<div
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
flexWrap: "wrap",
}}
>
<span style={{ color: "#6b7280", fontSize: 14 }}>
{isRegisterMode ? "Already have an account?" : "New here?"}
</span>
<button
type="button"
style={{
border: "none",
background: "transparent",
color: "#2563eb",
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
padding: 0,
}}
onClick={() => {
setError("");
setInfo("");
setIsRegisterMode((value) => !value);
}}
>
{isRegisterMode ? "Switch to sign in" : "Create account"}
</button>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -1,56 +1,132 @@
import axios from 'axios' import axios from "axios";
import './../App.css' import { useState } from "react";
import { useState } from 'react' import { useNavigate } from "react-router-dom";
import { useNavigate } from 'react-router-dom'
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
const styles = StatsStyling; const styles = StatsStyling;
const API_BASE_URL = "http://localhost:5000";
const UploadPage = () => { const UploadPage = () => {
let postFile: File | undefined; const [postFile, setPostFile] = useState<File | null>(null);
let topicBucketFile: File | undefined; const [topicBucketFile, setTopicBucketFile] = useState<File | null>(null);
const [returnMessage, setReturnMessage] = useState('') const [returnMessage, setReturnMessage] = useState("");
const navigate = useNavigate() const [isSubmitting, setIsSubmitting] = useState(false);
const [hasError, setHasError] = useState(false);
const navigate = useNavigate();
const uploadFiles = async () => { const uploadFiles = async () => {
if (!postFile || !topicBucketFile) { if (!postFile || !topicBucketFile) {
alert('Please upload all files before uploading.') setHasError(true);
return setReturnMessage("Please upload both files before continuing.");
return;
} }
const formData = new FormData() const formData = new FormData();
formData.append('posts', postFile) formData.append("posts", postFile);
formData.append('topics', topicBucketFile) formData.append("topics", topicBucketFile);
try { try {
const response = await axios.post('http://localhost:5000/upload', formData, { setIsSubmitting(true);
headers: { setHasError(false);
'Content-Type': 'multipart/form-data', setReturnMessage("");
},
})
console.log('Files uploaded successfully:', response.data)
setReturnMessage(`Upload successful! Posts: ${response.data.posts_count}, Comments: ${response.data.comments_count}`)
navigate('/stats')
} catch (error) {
console.error('Error uploading files:', error)
setReturnMessage('Error uploading files. Error details: ' + error)
}
}
return (
<div style={{...styles.container, ...styles.grid, margin: "0"}}>
<div style={{ ...styles.card }}>
<h2 style={{color: "black" }}>Posts File</h2>
<input style={{color: "black" }} type="file" onChange={(e) => postFile = e.target.files?.[0]}></input>
</div>
<div style={{ ...styles.card }}>
<h2 style={{color: "black" }}>Topic Buckets File</h2>
<input style={{color: "black" }} type="file" onChange={(e) => topicBucketFile = e.target.files?.[0]}></input>
</div>
<button onClick={uploadFiles}>Upload</button>
<p>{returnMessage}</p> const response = await axios.post(`${API_BASE_URL}/upload`, formData, {
</div> headers: {
) "Content-Type": "multipart/form-data",
},
});
setReturnMessage(
`Upload queued successfully (dataset #${response.data.dataset_id}). Redirecting to insights...`
);
setTimeout(() => {
navigate("/stats");
}, 400);
} catch (error: unknown) {
setHasError(true);
if (axios.isAxiosError(error)) {
const message = String(error.response?.data?.error || error.message || "Upload failed.");
setReturnMessage(`Upload failed: ${message}`);
} else {
setReturnMessage("Upload failed due to an unexpected error.");
} }
} finally {
setIsSubmitting(false);
}
};
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 }}>Upload Dataset</h1>
<p style={{ margin: "8px 0 0", color: "#6b7280", fontSize: 14 }}>
Add your posts and topic map files to generate fresh analytics.
</p>
</div>
<button
type="button"
style={{ ...styles.buttonPrimary, opacity: isSubmitting ? 0.75 : 1 }}
onClick={uploadFiles}
disabled={isSubmitting}
>
{isSubmitting ? "Uploading..." : "Upload and Analyze"}
</button>
</div>
<div
style={{
...styles.grid,
marginTop: 14,
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
}}
>
<div style={{ ...styles.card, gridColumn: "auto" }}>
<h2 style={{ ...styles.sectionTitle, color: "#111827" }}>Posts File (.jsonl)</h2>
<p style={styles.sectionSubtitle}>Upload the raw post records export.</p>
<input
style={{ ...styles.input, width: "80%", maxWidth: "100%" }}
type="file"
accept=".jsonl"
onChange={(event) => setPostFile(event.target.files?.[0] ?? null)}
/>
<p style={{ margin: "10px 0 0", fontSize: 13, color: "#374151" }}>
{postFile ? `Selected: ${postFile.name}` : "No file selected"}
</p>
</div>
<div style={{ ...styles.card, gridColumn: "auto" }}>
<h2 style={{ ...styles.sectionTitle, color: "#111827" }}>Topics File (.json)</h2>
<p style={styles.sectionSubtitle}>Upload your topic bucket mapping file.</p>
<input
style={{ ...styles.input, width: "80%", maxWidth: "100%" }}
type="file"
accept=".json"
onChange={(event) => setTopicBucketFile(event.target.files?.[0] ?? null)}
/>
<p style={{ margin: "10px 0 0", fontSize: 13, color: "#374151" }}>
{topicBucketFile ? `Selected: ${topicBucketFile.name}` : "No file selected"}
</p>
</div>
</div>
<div
style={{
...styles.card,
marginTop: 14,
borderColor: hasError ? "rgba(185, 28, 28, 0.28)" : "rgba(0,0,0,0.06)",
background: hasError ? "#fff5f5" : "#ffffff",
color: hasError ? "#991b1b" : "#374151",
fontSize: 14,
}}
>
{returnMessage || "After upload, your dataset is queued for processing and you'll land on stats."}
</div>
</div>
</div>
);
};
export default UploadPage; export default UploadPage;

View File

@@ -15,7 +15,6 @@ from flask_jwt_extended import (
) )
from server.analysis.stat_gen import StatGen from server.analysis.stat_gen import StatGen
from server.analysis.enrichment import DatasetEnrichment
from server.exceptions import NotAuthorisedException, NonExistentDatasetException from server.exceptions import NotAuthorisedException, NonExistentDatasetException
from server.db.database import PostgresConnector from server.db.database import PostgresConnector
from server.core.auth import AuthManager from server.core.auth import AuthManager