Merge pull request 'Fix the frontend API calls and implement logins on frontend' (#7) from feat/update-frontend-api-calls into main

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-03-04 20:20:50 +00:00
33 changed files with 1691 additions and 366 deletions

View File

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

View File

@@ -11,7 +11,7 @@ POSTGRES_DIR=
# JWT
JWT_SECRET_KEY=
JWT_ACCESS_TOKEN_EXPIRES=1200
JWT_ACCESS_TOKEN_EXPIRES=28800
# Models
HF_HOME=/models/huggingface

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,30 @@
import { Routes, Route } from "react-router-dom";
import { useEffect } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import AppLayout from "./components/AppLayout";
import DatasetsPage from "./pages/Datasets";
import DatasetStatusPage from "./pages/DatasetStatus";
import LoginPage from "./pages/Login";
import UploadPage from "./pages/Upload";
import StatPage from "./pages/Stats";
import { getDocumentTitle } from "./utils/documentTitle";
function App() {
const location = useLocation();
useEffect(() => {
document.title = getDocumentTitle(location.pathname);
}, [location.pathname]);
return (
<Routes>
<Route path="/upload" element={<UploadPage />} />
<Route path="/stats" element={<StatPage />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/upload" element={<UploadPage />} />
<Route path="/datasets" element={<DatasetsPage />} />
<Route path="/dataset/:datasetId/status" element={<DatasetStatusPage />} />
<Route path="/dataset/:datasetId/stats" element={<StatPage />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,122 @@
import { useCallback, useEffect, useState } from "react";
import axios from "axios";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import StatsStyling from "../styles/stats_styling";
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
type ProfileResponse = {
user?: Record<string, unknown>;
};
const styles = StatsStyling;
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={styles.appShell}>
<div style={{ ...styles.container, ...styles.appHeaderWrap }}>
<div style={{ ...styles.card, ...styles.headerBar }}>
<div style={styles.appHeaderBrandRow}>
<span style={styles.appTitle}>
CrossPost Analysis Engine
</span>
<span
style={{
...styles.authStatusBadge,
...(isSignedIn ? styles.authStatusSignedIn : styles.authStatusSignedOut),
}}
>
{isSignedIn ? `Signed in: ${getUserLabel(currentUser)}` : "Not signed in"}
</span>
</div>
<div style={styles.controlsWrapped}>
{isSignedIn && <button
type="button"
style={location.pathname === "/datasets" ? styles.buttonPrimary : styles.buttonSecondary}
onClick={() => navigate("/datasets")}
>
My datasets
</button>}
<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,4 +1,7 @@
import type { CSSProperties } from "react";
import StatsStyling from "../styles/stats_styling";
const styles = StatsStyling;
const Card = (props: {
label: string;
@@ -8,43 +11,15 @@ const Card = (props: {
style?: CSSProperties
}) => {
return (
<div style={{
background: "rgba(255,255,255,0.85)",
border: "1px solid rgba(15,23,42,0.08)",
borderRadius: 16,
padding: 14,
boxShadow: "0 12px 30px rgba(15,23,42,0.06)",
minHeight: 88,
...props.style
}}>
<div style={ {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
}}>
<div style={{
fontSize: 12,
fontWeight: 700,
color: "rgba(15, 23, 42, 0.65)",
letterSpacing: "0.02em",
textTransform: "uppercase"
}}>
<div style={{ ...styles.cardBase, ...props.style }}>
<div style={styles.cardTopRow}>
<div style={styles.cardLabel}>
{props.label}
</div>
{props.rightSlot ? <div>{props.rightSlot}</div> : null}
</div>
<div style={{
fontSize: 22,
fontWeight: 850,
marginTop: 6,
letterSpacing: "-0.02em",
}}>{props.value}</div>
{props.sublabel ? <div style={{
marginTop: 6,
fontSize: 12,
color: "rgba(15, 23, 42, 0.55)",
}}>{props.sublabel}</div> : null}
<div style={styles.cardValue}>{props.value}</div>
{props.sublabel ? <div style={styles.cardSubLabel}>{props.sublabel}</div> : null}
</div>
);
}

View File

@@ -66,11 +66,11 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
<h2 style={styles.sectionTitle}>Average Emotion by Topic</h2>
<p style={styles.sectionSubtitle}>Read confidence together with sample size. Topics with fewer than {lowSampleThreshold} events are usually noisy and less reliable.</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, fontSize: 13, color: "#4b5563", marginTop: 6 }}>
<span><strong style={{ color: "#111827" }}>Topics:</strong> {strongestPerTopic.length}</span>
<span><strong style={{ color: "#111827" }}>Median Sample:</strong> {medianSampleSize} events</span>
<span><strong style={{ color: "#111827" }}>Low Sample (&lt;{lowSampleThreshold}):</strong> {lowSampleTopics}</span>
<span><strong style={{ color: "#111827" }}>Stable Sample ({stableSampleThreshold}+):</strong> {stableSampleTopics}</span>
<div style={styles.emotionalSummaryRow}>
<span><strong style={{ color: "#24292f" }}>Topics:</strong> {strongestPerTopic.length}</span>
<span><strong style={{ color: "#24292f" }}>Median Sample:</strong> {medianSampleSize} events</span>
<span><strong style={{ color: "#24292f" }}>Low Sample (&lt;{lowSampleThreshold}):</strong> {lowSampleTopics}</span>
<span><strong style={{ color: "#24292f" }}>Stable Sample ({stableSampleThreshold}+):</strong> {stableSampleTopics}</span>
</div>
<p style={{ ...styles.sectionSubtitle, marginTop: 10, marginBottom: 0 }}>
Confidence reflects how strongly one emotion leads within a topic, not model accuracy. Use larger samples for stronger conclusions.
@@ -81,19 +81,19 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
{strongestPerTopic.map((topic) => (
<div key={topic.topic} style={{ ...styles.card, gridColumn: "span 4" }}>
<h3 style={{ ...styles.sectionTitle, marginBottom: 6 }}>{topic.topic}</h3>
<div style={{ fontSize: 12, fontWeight: 700, color: "#6b7280", letterSpacing: "0.02em", textTransform: "uppercase" }}>
<div style={styles.emotionalTopicLabel}>
Top Emotion
</div>
<div style={{ fontSize: 24, fontWeight: 800, marginTop: 4, lineHeight: 1.2 }}>
<div style={styles.emotionalTopicValue}>
{formatEmotion(topic.emotion)}
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, fontSize: 13, color: "#6b7280" }}>
<div style={styles.emotionalMetricRow}>
<span>Confidence</span>
<span style={{ fontWeight: 700, color: "#111827" }}>{topic.value.toFixed(3)}</span>
<span style={styles.emotionalMetricValue}>{topic.value.toFixed(3)}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 4, fontSize: 13, color: "#6b7280" }}>
<div style={styles.emotionalMetricRowCompact}>
<span>Sample Size</span>
<span style={{ fontWeight: 700, color: "#111827" }}>{topic.count} events</span>
<span style={styles.emotionalMetricValue}>{topic.count} events</span>
</div>
</div>
))}

View File

@@ -13,26 +13,11 @@ type Props = {
export default function UserModal({ open, onClose, userData, username }: Props) {
return (
<Dialog open={open} onClose={onClose} style={{ position: "relative", zIndex: 50 }}>
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
}}
/>
<Dialog open={open} onClose={onClose} style={styles.modalRoot}>
<div style={styles.modalBackdrop} />
<div
style={{
position: "fixed",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
}}
>
<DialogPanel style={{ ...styles.card, width: "min(520px, 95vw)" }}>
<div style={styles.modalContainer}>
<DialogPanel style={{ ...styles.card, ...styles.modalPanel }}>
<div style={styles.headerBar}>
<div>
<DialogTitle style={styles.sectionTitle}>{username}</DialogTitle>

View File

@@ -1,3 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import ForceGraph3D from "react-force-graph-3d";
import {
@@ -6,12 +7,19 @@ import {
} from '../types/ApiTypes';
import StatsStyling from "../styles/stats_styling";
import Card from "./Card";
const styles = StatsStyling;
type GraphLink = {
source: string;
target: string;
value: number;
};
function ApiToGraphData(apiData: InteractionGraph) {
const nodes = Object.keys(apiData).map(username => ({ id: username }));
const links = [];
const links: GraphLink[] = [];
for (const [source, targets] of Object.entries(apiData)) {
for (const [target, count] of Object.entries(targets)) {
@@ -35,24 +43,103 @@ function ApiToGraphData(apiData: InteractionGraph) {
const UserStats = (props: { data: UserAnalysisResponse }) => {
const graphData = ApiToGraphData(props.data.interaction_graph);
const graphData = useMemo(() => ApiToGraphData(props.data.interaction_graph), [props.data.interaction_graph]);
const graphContainerRef = useRef<HTMLDivElement | null>(null);
const [graphSize, setGraphSize] = useState({ width: 720, height: 540 });
useEffect(() => {
const updateGraphSize = () => {
const containerWidth = graphContainerRef.current?.clientWidth ?? 720;
const nextWidth = Math.max(320, Math.floor(containerWidth));
const nextHeight = nextWidth < 700 ? 300 : 540;
setGraphSize({ width: nextWidth, height: nextHeight });
};
updateGraphSize();
window.addEventListener("resize", updateGraphSize);
return () => window.removeEventListener("resize", updateGraphSize);
}, []);
const totalUsers = props.data.users.length;
const connectedUsers = graphData.nodes.length;
const totalInteractions = graphData.links.reduce((sum, link) => sum + link.value, 0);
const avgInteractionsPerConnectedUser = connectedUsers ? totalInteractions / connectedUsers : 0;
const strongestLink = graphData.links.reduce<GraphLink | null>((best, current) => {
if (!best || current.value > best.value) {
return current;
}
return best;
}, null);
const highlyInteractiveUser = [...props.data.users].sort((a, b) => b.comment_share - a.comment_share)[0];
const mostActiveUser = props.data.top_users.find(u => u.author !== "[deleted]");
return (
<div style={styles.page}>
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
<p style={styles.sectionSubtitle}>
This graph visualizes interactions between users based on comments and replies.
Nodes represent users, and edges represent interactions (e.g., comments or replies) between them.
</p>
<div>
<ForceGraph3D
<div style={{ ...styles.container, ...styles.grid }}>
<Card
label="Users"
value={totalUsers.toLocaleString()}
sublabel={`${connectedUsers.toLocaleString()} users in filtered graph`}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Interactions"
value={totalInteractions.toLocaleString()}
sublabel="Filtered links (2+ interactions)"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Average Intensity"
value={avgInteractionsPerConnectedUser.toFixed(1)}
sublabel="Interactions per connected user"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Most Active User"
value={mostActiveUser?.author ?? "—"}
sublabel={mostActiveUser ? `${mostActiveUser.count.toLocaleString()} events` : "No user activity found"}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Strongest Connection"
value={strongestLink ? `${strongestLink.source} -> ${strongestLink.target}` : "—"}
sublabel={strongestLink ? `${strongestLink.value.toLocaleString()} interactions` : "No graph edges after filtering"}
style={{ gridColumn: "span 6" }}
/>
<Card
label="Most Reply-Driven User"
value={highlyInteractiveUser?.author ?? "—"}
sublabel={
highlyInteractiveUser
? `${Math.round(highlyInteractiveUser.comment_share * 100)}% comments`
: "No user distribution available"
}
style={{ gridColumn: "span 6" }}
/>
<div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
<p style={styles.sectionSubtitle}>
Nodes represent users and links represent conversation interactions.
</p>
<div ref={graphContainerRef} style={{ width: "100%", height: graphSize.height }}>
<ForceGraph3D
width={graphSize.width}
height={graphSize.height}
graphData={graphData}
nodeAutoColorBy="id"
linkDirectionalParticles={2}
linkDirectionalParticleSpeed={0.005}
linkWidth={(link) => Math.sqrt(link.value)}
linkDirectionalParticles={1}
linkDirectionalParticleSpeed={0.004}
linkWidth={(link) => Math.sqrt(Number(link.value))}
nodeLabel={(node) => `${node.id}`}
/>
/>
</div>
</div>
</div>
</div>
);

View File

@@ -1,68 +1,65 @@
: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;
--bg-default: #f6f8fa;
--text-default: #24292f;
--border-default: #d0d7de;
--focus-ring: rgba(9, 105, 218, 0.22);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
html,
body,
#root {
width: 100%;
height: 100%;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
background: var(--bg-default);
color: var(--text-default);
font-family: "IBM Plex Sans", "Noto Sans", "Liberation Sans", "Segoe UI", sans-serif;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
* {
box-sizing: border-box;
}
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;
button,
input,
select,
textarea {
font: inherit;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
input:focus,
button:focus-visible,
select:focus,
textarea:focus {
border-color: #0969da;
box-shadow: 0 0 0 3px var(--focus-ring);
outline: none;
}
@keyframes stats-spin {
from {
transform: rotate(0deg);
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
to {
transform: rotate(360deg);
}
}
@keyframes stats-pulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}

View File

@@ -0,0 +1,111 @@
import { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { useNavigate, useParams } from "react-router-dom";
import StatsStyling from "../styles/stats_styling";
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
type DatasetStatusResponse = {
status?: "processing" | "complete" | "error";
status_message?: string | null;
completed_at?: string | null;
};
const styles = StatsStyling;
const DatasetStatusPage = () => {
const navigate = useNavigate();
const { datasetId } = useParams<{ datasetId: string }>();
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<DatasetStatusResponse["status"]>("processing");
const [statusMessage, setStatusMessage] = useState("");
const parsedDatasetId = useMemo(() => Number(datasetId), [datasetId]);
useEffect(() => {
if (!Number.isInteger(parsedDatasetId) || parsedDatasetId <= 0) {
setLoading(false);
setStatus("error");
setStatusMessage("Invalid dataset id.");
return;
}
let pollTimer: number | undefined;
const pollStatus = async () => {
try {
const response = await axios.get<DatasetStatusResponse>(
`${API_BASE_URL}/dataset/${parsedDatasetId}/status`
);
const nextStatus = response.data.status ?? "processing";
setStatus(nextStatus);
setStatusMessage(String(response.data.status_message ?? ""));
setLoading(false);
if (nextStatus === "complete") {
window.setTimeout(() => {
navigate(`/dataset/${parsedDatasetId}/stats`, { replace: true });
}, 800);
}
} catch (error: unknown) {
setLoading(false);
setStatus("error");
if (axios.isAxiosError(error)) {
const message = String(error.response?.data?.error || error.message || "Request failed");
setStatusMessage(message);
} else {
setStatusMessage("Unable to fetch dataset status.");
}
}
};
void pollStatus();
pollTimer = window.setInterval(() => {
if (status !== "complete" && status !== "error") {
void pollStatus();
}
}, 2000);
return () => {
if (pollTimer) {
window.clearInterval(pollTimer);
}
};
}, [navigate, parsedDatasetId, status]);
const isProcessing = loading || status === "processing";
const isError = status === "error";
return (
<div style={styles.page}>
<div style={styles.containerNarrow}>
<div style={{ ...styles.card, marginTop: 28 }}>
<h1 style={styles.sectionHeaderTitle}>
{isProcessing ? "Processing dataset..." : isError ? "Dataset processing failed" : "Dataset ready"}
</h1>
<p style={{ ...styles.sectionSubtitle, marginTop: 10 }}>
{isProcessing &&
"Your dataset is being analyzed. This page will redirect to stats automatically once complete."}
{isError && "There was an issue while processing your dataset. Please review the error details."}
{status === "complete" && "Processing complete. Redirecting to your stats now..."}
</p>
<div
style={{
...styles.card,
...styles.statusMessageCard,
borderColor: isError ? "rgba(185, 28, 28, 0.28)" : "rgba(0,0,0,0.06)",
background: isError ? "#fff5f5" : "#ffffff",
color: isError ? "#991b1b" : "#374151",
}}
>
{statusMessage || (isProcessing ? "Waiting for updates from the worker queue..." : "No details provided.")}
</div>
</div>
</div>
</div>
);
};
export default DatasetStatusPage;

View File

@@ -0,0 +1,138 @@
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.containerWide}>
<div style={{ ...styles.card, ...styles.headerBar }}>
<div>
<h1 style={styles.sectionHeaderTitle}>My Datasets</h1>
<p style={styles.sectionHeaderSubtitle}>
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={styles.listNoBullets}>
{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={styles.datasetListItem}
>
<div style={{ minWidth: 0 }}>
<div style={styles.datasetName}>
{dataset.name || `Dataset #${dataset.id}`}
</div>
<div style={styles.datasetMeta}>
ID #{dataset.id} Status: {dataset.status || "unknown"}
</div>
{dataset.status_message && (
<div style={styles.datasetMetaSecondary}>
{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;

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { useNavigate } from "react-router-dom";
import StatsStyling from "../styles/stats_styling";
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
const styles = StatsStyling;
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.containerAuth}>
<div style={{ ...styles.card, ...styles.authCard }}>
<div style={styles.headingBlock}>
<h1 style={styles.headingXl}>
{isRegisterMode ? "Create your account" : "Welcome back"}
</h1>
<p style={styles.mutedText}>
{isRegisterMode
? "Register to start uploading and exploring your dataset insights."
: "Sign in to continue to your analytics workspace."}
</p>
</div>
<form onSubmit={handleSubmit} style={styles.authForm}>
<input
type="text"
placeholder="Username"
style={{ ...styles.input, ...styles.authControl }}
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
{isRegisterMode && (
<input
type="email"
placeholder="Email"
style={{ ...styles.input, ...styles.authControl }}
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
)}
<input
type="password"
placeholder="Password"
style={{ ...styles.input, ...styles.authControl }}
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<button
type="submit"
style={{ ...styles.buttonPrimary, ...styles.authControl, marginTop: 2 }}
disabled={loading}
>
{loading
? "Please wait..."
: isRegisterMode
? "Create account"
: "Sign in"}
</button>
</form>
{error && (
<p style={styles.authErrorText}>
{error}
</p>
)}
{info && (
<p style={styles.authInfoText}>
{info}
</p>
)}
<div style={styles.authSwitchRow}>
<span style={styles.authSwitchLabel}>
{isRegisterMode ? "Already have an account?" : "New here?"}
</span>
<button
type="button"
style={styles.authSwitchButton}
onClick={() => {
setError("");
setInfo("");
setIsRegisterMode((value) => !value);
}}
>
{isRegisterMode ? "Switch to sign in" : "Create account"}
</button>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -1,9 +1,10 @@
import { useEffect, useState, useRef } from "react";
import axios from "axios";
import { useParams } from "react-router-dom";
import StatsStyling from "../styles/stats_styling";
import SummaryStats from "../components/SummaryStats";
import EmotionalStats from "../components/EmotionalStats";
import InteractionStats from "../components/UserStats";
import UserStats from "../components/UserStats";
import {
type SummaryResponse,
@@ -12,12 +13,14 @@ import {
type ContentAnalysisResponse
} from '../types/ApiTypes'
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
const styles = StatsStyling;
const StatPage = () => {
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [activeView, setActiveView] = useState<"summary" | "emotional" | "interaction">("summary");
const [activeView, setActiveView] = useState<"summary" | "emotional" | "user">("summary");
const [userData, setUserData] = useState<UserAnalysisResponse | null>(null);
const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null);
@@ -29,15 +32,73 @@ const StatPage = () => {
const beforeDateRef = useRef<HTMLInputElement>(null);
const afterDateRef = useRef<HTMLInputElement>(null);
const getStats = () => {
const parsedDatasetId = Number(routeDatasetId ?? "");
const datasetId = Number.isInteger(parsedDatasetId) && parsedDatasetId > 0 ? parsedDatasetId : null;
const getFilterParams = () => {
const params: Record<string, string> = {};
const query = (searchInputRef.current?.value ?? "").trim();
const start = (afterDateRef.current?.value ?? "").trim();
const end = (beforeDateRef.current?.value ?? "").trim();
if (query) {
params.search_query = query;
}
if (start) {
params.start_date = start;
}
if (end) {
params.end_date = end;
}
return params;
};
const getAuthHeaders = () => {
const token = localStorage.getItem("access_token");
if (!token) {
return null;
}
return {
Authorization: `Bearer ${token}`,
};
};
const getStats = (params: Record<string, string> = {}) => {
if (!datasetId) {
setError("Missing dataset id. Open /dataset/<id>/stats.");
return;
}
const authHeaders = getAuthHeaders();
if (!authHeaders) {
setError("You must be signed in to load stats.");
return;
}
setError("");
setLoading(true);
Promise.all([
axios.get<TimeAnalysisResponse>("http://localhost:5000/stats/time"),
axios.get<UserAnalysisResponse>("http://localhost:5000/stats/user"),
axios.get<ContentAnalysisResponse>("http://localhost:5000/stats/content"),
axios.get<SummaryResponse>(`http://localhost:5000/stats/summary`),
axios.get<TimeAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/time`, {
params,
headers: authHeaders,
}),
axios.get<UserAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/user`, {
params,
headers: authHeaders,
}),
axios.get<ContentAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/content`, {
params,
headers: authHeaders,
}),
axios.get<SummaryResponse>(`${API_BASE_URL}/dataset/${datasetId}/summary`, {
params,
headers: authHeaders,
}),
])
.then(([timeRes, userRes, contentRes, summaryRes]) => {
setUserData(userRes.data || null);
@@ -50,37 +111,52 @@ const StatPage = () => {
};
const onSubmitFilters = () => {
const query = searchInputRef.current?.value ?? "";
Promise.all([
axios.post("http://localhost:5000/filter/search", {
query: query
}),
])
.then(() => {
getStats();
})
.catch(e => {
setError("Failed to load filters: " + e.response);
})
getStats(getFilterParams());
};
const resetFilters = () => {
axios.get("http://localhost:5000/filter/reset")
.then(() => {
getStats();
})
.catch(e => {
setError(e);
})
if (searchInputRef.current) {
searchInputRef.current.value = "";
}
if (beforeDateRef.current) {
beforeDateRef.current.value = "";
}
if (afterDateRef.current) {
afterDateRef.current.value = "";
}
getStats();
};
useEffect(() => {
setError("");
if (!datasetId) {
setError("Missing dataset id. Open /dataset/<id>/stats.");
return;
}
getStats();
}, [])
}, [datasetId])
if (loading) return <p style={{...styles.page, minWidth: "100vh", minHeight: "100vh"}}>Loading insights</p>;
if (loading) {
return (
<div style={styles.loadingPage}>
<div style={{ ...styles.loadingCard, transform: "translateY(-100px)" }}>
<div style={styles.loadingHeader}>
<div style={styles.loadingSpinner} />
<div>
<h2 style={styles.loadingTitle}>Loading analytics</h2>
<p style={styles.loadingSubtitle}>Fetching summary, timeline, user, and content insights.</p>
</div>
</div>
<div style={styles.loadingSkeleton}>
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineLong }} />
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineMed }} />
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineShort }} />
</div>
</div>
</div>
);
}
if (error) return <p style={{...styles.page}}>{error}</p>;
return (
@@ -118,10 +194,11 @@ return (
</button>
</div>
<div style={{ fontSize: 13, color: "#6b7280" }}>Analytics Dashboard</div>
</div>
<div style={styles.dashboardMeta}>Analytics Dashboard</div>
<div style={styles.dashboardMeta}>Dataset #{datasetId ?? "-"}</div>
</div>
<div style={{ ...styles.container, display: "flex", gap: 8, marginTop: 12 }}>
<div style={{ ...styles.container, ...styles.tabsRow }}>
<button
onClick={() => setActiveView("summary")}
style={activeView === "summary" ? styles.buttonPrimary : styles.buttonSecondary}
@@ -136,10 +213,10 @@ return (
</button>
<button
onClick={() => setActiveView("interaction")}
style={activeView === "interaction" ? styles.buttonPrimary : styles.buttonSecondary}
onClick={() => setActiveView("user")}
style={activeView === "user" ? styles.buttonPrimary : styles.buttonSecondary}
>
Interaction
Users
</button>
</div>
@@ -162,8 +239,8 @@ return (
</div>
)}
{activeView === "interaction" && userData && (
<InteractionStats data={userData} />
{activeView === "user" && userData && (
<UserStats data={userData} />
)}
</div>

View File

@@ -1,56 +1,153 @@
import axios from 'axios'
import './../App.css'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from "axios";
import { useState } from "react";
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
const UploadPage = () => {
let postFile: File | undefined;
let topicBucketFile: File | undefined;
const [returnMessage, setReturnMessage] = useState('')
const navigate = useNavigate()
const [datasetName, setDatasetName] = useState("");
const [postFile, setPostFile] = useState<File | null>(null);
const [topicBucketFile, setTopicBucketFile] = useState<File | null>(null);
const [returnMessage, setReturnMessage] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasError, setHasError] = useState(false);
const navigate = useNavigate();
const uploadFiles = async () => {
if (!postFile || !topicBucketFile) {
alert('Please upload all files before uploading.')
return
const normalizedDatasetName = datasetName.trim();
if (!normalizedDatasetName) {
setHasError(true);
setReturnMessage("Please add a dataset name before continuing.");
return;
}
const formData = new FormData()
formData.append('posts', postFile)
formData.append('topics', topicBucketFile)
if (!postFile || !topicBucketFile) {
setHasError(true);
setReturnMessage("Please upload both files before continuing.");
return;
}
const formData = new FormData();
formData.append("name", normalizedDatasetName);
formData.append("posts", postFile);
formData.append("topics", topicBucketFile);
try {
const response = await axios.post('http://localhost:5000/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
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>
setIsSubmitting(true);
setHasError(false);
setReturnMessage("");
<p>{returnMessage}</p>
const response = await axios.post(`${API_BASE_URL}/upload`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
const datasetId = Number(response.data.dataset_id);
setReturnMessage(
`Upload queued successfully (dataset #${datasetId}). Redirecting to processing status...`
);
setTimeout(() => {
navigate(`/dataset/${datasetId}/status`);
}, 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.containerWide}>
<div style={{ ...styles.card, ...styles.headerBar }}>
<div>
<h1 style={styles.sectionHeaderTitle}>Upload Dataset</h1>
<p style={styles.sectionHeaderSubtitle}>
Name your dataset, then upload posts and topic map files to generate 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: "#24292f" }}>Dataset Name</h2>
<p style={styles.sectionSubtitle}>Use a clear label so you can identify this upload later.</p>
<input
style={{ ...styles.input, ...styles.inputFullWidth }}
type="text"
placeholder="Example: Cork Discussions - Jan 2026"
value={datasetName}
onChange={(event) => setDatasetName(event.target.value)}
/>
</div>
<div style={{ ...styles.card, gridColumn: "auto" }}>
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Posts File (.jsonl)</h2>
<p style={styles.sectionSubtitle}>Upload the raw post records export.</p>
<input
style={{ ...styles.input, ...styles.inputFullWidth }}
type="file"
accept=".jsonl"
onChange={(event) => setPostFile(event.target.files?.[0] ?? null)}
/>
<p style={styles.subtleBodyText}>
{postFile ? `Selected: ${postFile.name}` : "No file selected"}
</p>
</div>
<div style={{ ...styles.card, gridColumn: "auto" }}>
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Topics File (.json)</h2>
<p style={styles.sectionSubtitle}>Upload your topic bucket mapping file.</p>
<input
style={{ ...styles.input, ...styles.inputFullWidth }}
type="file"
accept=".json"
onChange={(event) => setTopicBucketFile(event.target.files?.[0] ?? null)}
/>
<p style={styles.subtleBodyText}>
{topicBucketFile ? `Selected: ${topicBucketFile.name}` : "No file selected"}
</p>
</div>
</div>
<div
style={{
...styles.card,
marginTop: 14,
...(hasError ? styles.alertCardError : styles.alertCardInfo),
}}
>
{returnMessage || "After upload, your dataset is queued for processing and you'll land on stats."}
</div>
</div>
</div>
)
}
);
};
export default UploadPage;

View File

@@ -0,0 +1,42 @@
import { palette } from "./palette";
import type { StyleMap } from "./types";
export const appLayoutStyles: StyleMap = {
appHeaderWrap: {
padding: "16px 24px 0",
},
appHeaderBrandRow: {
display: "flex",
alignItems: "center",
gap: 10,
flexWrap: "wrap",
},
appTitle: {
margin: 0,
color: palette.textPrimary,
fontSize: 18,
fontWeight: 600,
},
authStatusBadge: {
padding: "3px 8px",
borderRadius: 6,
fontSize: 12,
fontWeight: 600,
fontFamily: '"IBM Plex Sans", "Noto Sans", "Liberation Sans", "Segoe UI", sans-serif',
},
authStatusSignedIn: {
border: `1px solid ${palette.statusPositiveBorder}`,
background: palette.statusPositiveBg,
color: palette.statusPositiveText,
},
authStatusSignedOut: {
border: `1px solid ${palette.statusNegativeBorder}`,
background: palette.statusNegativeBg,
color: palette.statusNegativeText,
},
};

View File

@@ -0,0 +1,92 @@
import { palette } from "./palette";
import type { StyleMap } from "./types";
export const authStyles: StyleMap = {
containerAuth: {
maxWidth: 560,
margin: "0 auto",
padding: "48px 24px",
},
headingXl: {
margin: 0,
color: palette.textPrimary,
fontSize: 28,
fontWeight: 600,
lineHeight: 1.1,
},
headingBlock: {
marginBottom: 22,
textAlign: "center",
},
mutedText: {
margin: "8px 0 0",
color: palette.textSecondary,
fontSize: 14,
},
authCard: {
padding: 28,
},
authForm: {
display: "grid",
gap: 12,
maxWidth: 380,
margin: "0 auto",
},
inputFullWidth: {
width: "100%",
maxWidth: "100%",
boxSizing: "border-box",
},
authControl: {
width: "100%",
maxWidth: "100%",
boxSizing: "border-box",
},
authErrorText: {
color: palette.dangerText,
margin: "12px auto 0",
fontSize: 14,
maxWidth: 380,
textAlign: "center",
},
authInfoText: {
color: palette.successText,
margin: "12px auto 0",
fontSize: 14,
maxWidth: 380,
textAlign: "center",
},
authSwitchRow: {
marginTop: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
flexWrap: "wrap",
},
authSwitchLabel: {
color: palette.textSecondary,
fontSize: 14,
},
authSwitchButton: {
border: "none",
background: "transparent",
color: palette.brandGreenBorder,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
padding: 0,
},
};

View File

@@ -0,0 +1,42 @@
import { palette } from "./palette";
import type { StyleMap } from "./types";
export const cardStyles: StyleMap = {
cardBase: {
background: palette.surface,
border: `1px solid ${palette.borderDefault}`,
borderRadius: 8,
padding: 14,
boxShadow: `0 1px 0 ${palette.shadowSubtle}`,
minHeight: 88,
},
cardTopRow: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
},
cardLabel: {
fontSize: 12,
fontWeight: 600,
color: palette.textSecondary,
letterSpacing: "0.02em",
textTransform: "uppercase",
},
cardValue: {
fontSize: 24,
fontWeight: 700,
marginTop: 6,
letterSpacing: "-0.02em",
color: palette.textPrimary,
},
cardSubLabel: {
marginTop: 6,
fontSize: 12,
color: palette.textSecondary,
},
};

View File

@@ -0,0 +1,55 @@
import { palette } from "./palette";
import type { StyleMap } from "./types";
export const datasetStyles: StyleMap = {
sectionHeaderTitle: {
margin: 0,
color: palette.textPrimary,
fontSize: 28,
fontWeight: 600,
},
sectionHeaderSubtitle: {
margin: "8px 0 0",
color: palette.textSecondary,
fontSize: 14,
},
listNoBullets: {
listStyle: "none",
margin: 0,
padding: 0,
},
datasetListItem: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "14px 16px",
borderBottom: `1px solid ${palette.borderMuted}`,
},
datasetName: {
fontWeight: 600,
color: palette.textPrimary,
},
datasetMeta: {
fontSize: 13,
color: palette.textSecondary,
marginTop: 4,
},
datasetMetaSecondary: {
fontSize: 13,
color: palette.textSecondary,
marginTop: 2,
},
subtleBodyText: {
margin: "10px 0 0",
fontSize: 13,
color: palette.textBody,
},
};

View File

@@ -0,0 +1,51 @@
import { palette } from "./palette";
import type { StyleMap } from "./types";
export const emotionalStyles: StyleMap = {
emotionalSummaryRow: {
display: "flex",
flexWrap: "wrap",
gap: 10,
fontSize: 13,
color: palette.textTertiary,
marginTop: 6,
},
emotionalTopicLabel: {
fontSize: 12,
fontWeight: 600,
color: palette.textSecondary,
letterSpacing: "0.02em",
textTransform: "uppercase",
},
emotionalTopicValue: {
fontSize: 24,
fontWeight: 800,
marginTop: 4,
lineHeight: 1.2,
},
emotionalMetricRow: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 10,
fontSize: 13,
color: palette.textSecondary,
},
emotionalMetricRowCompact: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 4,
fontSize: 13,
color: palette.textSecondary,
},
emotionalMetricValue: {
fontWeight: 600,
color: palette.textPrimary,
},
};

View File

@@ -0,0 +1,106 @@
import { palette } from "./palette";
import type { StyleMap } from "./types";
export const feedbackStyles: StyleMap = {
loadingPage: {
width: "100%",
minHeight: "100vh",
padding: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
loadingCard: {
width: "min(560px, 92vw)",
background: palette.surface,
border: `1px solid ${palette.borderDefault}`,
borderRadius: 8,
boxShadow: `0 1px 0 ${palette.shadowSubtle}`,
padding: 20,
},
loadingHeader: {
display: "flex",
alignItems: "center",
gap: 12,
},
loadingSpinner: {
width: 18,
height: 18,
borderRadius: "50%",
border: `2px solid ${palette.borderDefault}`,
borderTopColor: palette.brandGreen,
animation: "stats-spin 0.9s linear infinite",
flexShrink: 0,
},
loadingTitle: {
margin: 0,
fontSize: 16,
fontWeight: 600,
color: palette.textPrimary,
},
loadingSubtitle: {
margin: "6px 0 0",
fontSize: 13,
color: palette.textSecondary,
},
loadingSkeleton: {
marginTop: 16,
display: "grid",
gap: 8,
},
loadingSkeletonLine: {
height: 9,
borderRadius: 999,
background: palette.canvas,
animation: "stats-pulse 1.25s ease-in-out infinite",
},
loadingSkeletonLineLong: {
width: "100%",
},
loadingSkeletonLineMed: {
width: "78%",
},
loadingSkeletonLineShort: {
width: "62%",
},
alertCardError: {
borderColor: palette.alertErrorBorder,
background: palette.alertErrorBg,
color: palette.alertErrorText,
fontSize: 14,
},
alertCardInfo: {
borderColor: palette.alertInfoBorder,
background: palette.surface,
color: palette.textBody,
fontSize: 14,
},
statusMessageCard: {
marginTop: 12,
boxShadow: "none",
},
dashboardMeta: {
fontSize: 13,
color: palette.textSecondary,
},
tabsRow: {
display: "flex",
gap: 8,
marginTop: 12,
},
};

View File

@@ -0,0 +1,157 @@
import { palette } from "./palette";
import type { StyleMap } from "./types";
export const foundationStyles: StyleMap = {
appShell: {
minHeight: "100vh",
background: palette.canvas,
fontFamily: '"IBM Plex Sans", "Noto Sans", "Liberation Sans", "Segoe UI", sans-serif',
color: palette.textPrimary,
},
page: {
width: "100%",
minHeight: "100vh",
padding: 20,
background: palette.canvas,
fontFamily: '"IBM Plex Sans", "Noto Sans", "Liberation Sans", "Segoe UI", sans-serif',
color: palette.textPrimary,
overflowX: "hidden",
boxSizing: "border-box",
},
container: {
maxWidth: 1240,
margin: "0 auto",
},
containerWide: {
maxWidth: 1100,
margin: "0 auto",
},
containerNarrow: {
maxWidth: 720,
margin: "0 auto",
},
card: {
background: palette.surface,
borderRadius: 8,
padding: 16,
border: `1px solid ${palette.borderDefault}`,
boxShadow: `0 1px 0 ${palette.shadowSubtle}`,
},
headerBar: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
},
controls: {
display: "flex",
gap: 8,
alignItems: "center",
},
controlsWrapped: {
display: "flex",
gap: 8,
alignItems: "center",
flexWrap: "wrap",
},
input: {
width: 280,
maxWidth: "70vw",
padding: "8px 10px",
borderRadius: 6,
border: `1px solid ${palette.borderDefault}`,
outline: "none",
fontSize: 14,
background: palette.surface,
color: palette.textPrimary,
},
buttonPrimary: {
padding: "8px 12px",
borderRadius: 6,
border: `1px solid ${palette.brandGreenBorder}`,
background: palette.brandGreen,
color: palette.surface,
fontWeight: 600,
cursor: "pointer",
boxShadow: "none",
},
buttonSecondary: {
padding: "8px 12px",
borderRadius: 6,
border: `1px solid ${palette.borderDefault}`,
background: palette.canvas,
color: palette.textPrimary,
fontWeight: 600,
cursor: "pointer",
},
grid: {
marginTop: 12,
display: "grid",
gridTemplateColumns: "repeat(12, 1fr)",
gap: 12,
},
sectionTitle: {
margin: 0,
fontSize: 17,
fontWeight: 600,
},
sectionSubtitle: {
margin: "6px 0 14px",
fontSize: 13,
color: palette.textSecondary,
},
chartWrapper: {
width: "100%",
height: 350,
},
heatmapWrapper: {
width: "100%",
height: 320,
},
topUsersList: {
display: "flex",
flexDirection: "column",
gap: 10,
},
topUserItem: {
padding: "10px 12px",
borderRadius: 8,
background: palette.canvas,
border: `1px solid ${palette.borderMuted}`,
},
topUserName: {
fontWeight: 600,
fontSize: 14,
color: palette.textPrimary,
},
topUserMeta: {
fontSize: 13,
color: palette.textSecondary,
},
scrollArea: {
maxHeight: 420,
overflowY: "auto",
},
};

View File

@@ -0,0 +1,28 @@
import { palette } from "./palette";
import type { StyleMap } from "./types";
export const modalStyles: StyleMap = {
modalRoot: {
position: "relative",
zIndex: 50,
},
modalBackdrop: {
position: "fixed",
inset: 0,
background: palette.modalBackdrop,
},
modalContainer: {
position: "fixed",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
},
modalPanel: {
width: "min(520px, 95vw)",
},
};

View File

@@ -0,0 +1,26 @@
export const palette = {
canvas: "#f6f8fa",
surface: "#ffffff",
textPrimary: "#24292f",
textSecondary: "#57606a",
textTertiary: "#4b5563",
textBody: "#374151",
borderDefault: "#d0d7de",
borderMuted: "#d8dee4",
shadowSubtle: "rgba(27, 31, 36, 0.04)",
brandGreen: "#2da44e",
brandGreenBorder: "#1f883d",
statusPositiveBorder: "#b7dfc8",
statusPositiveBg: "#edf9f1",
statusPositiveText: "#1f6f43",
statusNegativeBorder: "#f3c1c1",
statusNegativeBg: "#fff2f2",
statusNegativeText: "#9a2929",
dangerText: "#b91c1c",
successText: "#166534",
alertErrorBorder: "rgba(185, 28, 28, 0.28)",
alertErrorBg: "#fff5f5",
alertErrorText: "#991b1b",
alertInfoBorder: "rgba(0,0,0,0.06)",
modalBackdrop: "rgba(0,0,0,0.45)",
} as const;

View File

@@ -0,0 +1,3 @@
import type { CSSProperties } from "react";
export type StyleMap = Record<string, CSSProperties>;

View File

@@ -1,136 +1,22 @@
import type { CSSProperties } from "react";
import { appLayoutStyles } from "./stats/appLayout";
import { authStyles } from "./stats/auth";
import { cardStyles } from "./stats/cards";
import { datasetStyles } from "./stats/datasets";
import { emotionalStyles } from "./stats/emotional";
import { feedbackStyles } from "./stats/feedback";
import { foundationStyles } from "./stats/foundations";
import { modalStyles } from "./stats/modal";
const StatsStyling: Record<string, CSSProperties> = {
page: {
width: "100%",
minHeight: "100vh",
padding: 24,
background: "#f6f7fb",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Arial, sans-serif',
color: "#111827",
overflowX: "hidden",
boxSizing: "border-box"
},
container: {
maxWidth: 1400,
margin: "0 auto",
},
card: {
background: "white",
borderRadius: 16,
padding: 16,
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 6px 20px rgba(0,0,0,0.06)",
},
headerBar: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
},
controls: {
display: "flex",
gap: 10,
alignItems: "center",
},
input: {
width: 320,
maxWidth: "70vw",
padding: "10px 12px",
borderRadius: 12,
border: "1px solid rgba(0,0,0,0.12)",
outline: "none",
fontSize: 14,
background: "#fff",
color: "black"
},
buttonPrimary: {
padding: "10px 14px",
borderRadius: 12,
border: "1px solid rgba(0,0,0,0.08)",
background: "#2563eb",
color: "white",
fontWeight: 600,
cursor: "pointer",
boxShadow: "0 6px 16px rgba(37,99,235,0.25)",
},
buttonSecondary: {
padding: "10px 14px",
borderRadius: 12,
border: "1px solid rgba(0,0,0,0.12)",
background: "#fff",
color: "#111827",
fontWeight: 600,
cursor: "pointer",
},
grid: {
marginTop: 18,
display: "grid",
gridTemplateColumns: "repeat(12, 1fr)",
gap: 16,
},
sectionTitle: {
margin: 0,
fontSize: 16,
fontWeight: 700,
},
sectionSubtitle: {
margin: "6px 0 14px",
fontSize: 13,
color: "#6b7280",
},
chartWrapper: {
width: "100%",
height: 350,
},
heatmapWrapper: {
width: "100%",
height: 320,
},
topUsersList: {
display: "flex",
flexDirection: "column",
gap: 10,
},
topUserItem: {
padding: "10px 12px",
borderRadius: 12,
background: "#f9fafb",
border: "1px solid rgba(0,0,0,0.06)",
},
topUserName: {
fontWeight: 700,
fontSize: 14,
color: "black"
},
topUserMeta: {
fontSize: 13,
color: "#6b7280",
},
scrollArea: {
maxHeight: 450,
overflowY: "auto",
},
...foundationStyles,
...appLayoutStyles,
...authStyles,
...datasetStyles,
...feedbackStyles,
...cardStyles,
...emotionalStyles,
...modalStyles,
};
export default StatsStyling;

View File

@@ -10,12 +10,6 @@ type FrequencyWord = {
count: number;
}
type AverageEmotionByTopic = {
topic: string;
n: number;
[emotion: string]: string | number;
}
type Vocab = {
author: string;
events: number;
@@ -58,13 +52,33 @@ type HeatmapCell = {
type TimeAnalysisResponse = {
events_per_day: EventsPerDay[];
weekday_hour_heatmap: HeatmapCell[];
burstiness: number;
}
// Content Analysis
type Emotion = {
emotion_anger: number;
emotion_disgust: number;
emotion_fear: number;
emotion_joy: number;
emotion_sadness: number;
};
type NGram = {
count: number;
ngram: string;
}
type AverageEmotionByTopic = Emotion & {
n: number;
topic: string;
};
type ContentAnalysisResponse = {
word_frequencies: FrequencyWord[];
average_emotion_by_topic: AverageEmotionByTopic[];
common_three_phrases: NGram[];
common_two_phrases: NGram[];
}
// Summary

View File

@@ -0,0 +1,19 @@
const DEFAULT_TITLE = "Ethnograph View";
const STATIC_TITLES: Record<string, string> = {
"/login": "Sign In",
"/upload": "Upload Dataset",
"/datasets": "My Datasets",
};
export const getDocumentTitle = (pathname: string) => {
if (pathname.includes("status")) {
return "Processing Dataset";
}
if (pathname.includes("stats")) {
return "Ethnography Analysis"
}
return STATIC_TITLES[pathname] ?? DEFAULT_TITLE;
};

View File

@@ -127,8 +127,8 @@ class InteractionAnalysis:
def interaction_graph(self, df: pd.DataFrame):
interactions = {a: {} for a in df["author"].dropna().unique()}
# reply_to refers to the comment id, this allows us to map comment ids to usernames
id_to_author = df.set_index("id")["author"].to_dict()
# reply_to refers to the comment id, this allows us to map comment/post ids to usernames
id_to_author = df.set_index("post_id")["author"].to_dict()
for _, row in df.iterrows():
a = row["author"]

View File

@@ -96,10 +96,7 @@ class StatGen:
"common_three_phrases": self.linguistic_analysis.ngrams(filtered_df, n=3),
"average_emotion_by_topic": self.emotional_analysis.avg_emotion_by_topic(
filtered_df
),
"reply_time_by_emotion": self.temporal_analysis.avg_reply_time_per_emotion(
filtered_df
),
)
}
def get_user_analysis(self, df: pd.DataFrame, filters: dict | None = None) -> dict:
@@ -108,9 +105,7 @@ class StatGen:
return {
"top_users": self.interaction_analysis.top_users(filtered_df),
"users": self.interaction_analysis.per_user_analysis(filtered_df),
"interaction_graph": self.interaction_analysis.interaction_graph(
filtered_df
),
"interaction_graph": self.interaction_analysis.interaction_graph(filtered_df)
}
def get_interactional_analysis(self, df: pd.DataFrame, filters: dict | None = None) -> dict:

View File

@@ -15,7 +15,6 @@ from flask_jwt_extended import (
)
from server.analysis.stat_gen import StatGen
from server.analysis.enrichment import DatasetEnrichment
from server.exceptions import NotAuthorisedException, NonExistentDatasetException
from server.db.database import PostgresConnector
from server.core.auth import AuthManager
@@ -46,6 +45,7 @@ auth_manager = AuthManager(db, bcrypt)
dataset_manager = DatasetManager(db)
stat_gen = StatGen()
@app.route("/register", methods=["POST"])
def register_user():
data = request.get_json()
@@ -105,6 +105,11 @@ def profile():
message="Access granted", user=auth_manager.get_user_by_id(current_user)
), 200
@app.route("/user/datasets")
@jwt_required()
def get_user_datasets():
current_user = int(get_jwt_identity())
return jsonify(dataset_manager.get_user_datasets(current_user)), 200
@app.route("/upload", methods=["POST"])
@jwt_required()
@@ -114,6 +119,10 @@ def upload_data():
post_file = request.files["posts"]
topic_file = request.files["topics"]
dataset_name = (request.form.get("name") or "").strip()
if not dataset_name:
return jsonify({"error": "Missing required dataset name"}), 400
if post_file.filename == "" or topic_file.filename == "":
return jsonify({"error": "Empty filename"}), 400
@@ -130,19 +139,15 @@ def upload_data():
posts_df = pd.read_json(post_file, lines=True, convert_dates=False)
topics = json.load(topic_file)
dataset_id = dataset_manager.save_dataset_info(current_user, f"dataset_{current_user}", topics)
dataset_id = dataset_manager.save_dataset_info(current_user, dataset_name, topics)
process_dataset.delay(
dataset_id,
posts_df.to_dict(orient="records"),
topics
)
process_dataset.delay(dataset_id, posts_df.to_dict(orient="records"), topics)
return jsonify(
{
"message": "Dataset queued for processing",
"dataset_id": dataset_id,
"status": "processing"
"status": "processing",
}
), 202
except ValueError as e:

View File

@@ -18,6 +18,10 @@ class DatasetManager:
return True
def get_user_datasets(self, user_id: int) -> list[dict]:
query = "SELECT * FROM datasets WHERE user_id = %s"
return self.db.execute(query, (user_id, ), fetch=True)
def get_dataset_content(self, dataset_id: int) -> pd.DataFrame:
query = "SELECT * FROM events WHERE dataset_id = %s"
result = self.db.execute(query, (dataset_id,), fetch=True)
@@ -48,6 +52,7 @@ class DatasetManager:
query = """
INSERT INTO events (
dataset_id,
post_id,
type,
parent_id,
author,
@@ -74,13 +79,14 @@ class DatasetManager:
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s
%s, %s
)
"""
values = [
(
dataset_id,
row["id"],
row["type"],
row["parent_id"],
row["author"],

View File

@@ -30,6 +30,8 @@ CREATE TABLE events (
/* Required Fields */
id SERIAL PRIMARY KEY,
dataset_id INTEGER NOT NULL,
post_id VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
author VARCHAR(255) NOT NULL,