Fix the frontend API calls and implement logins on frontend #7

Merged
dylan merged 24 commits from feat/update-frontend-api-calls into main 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 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:

View File

@@ -11,7 +11,7 @@ POSTGRES_DIR=
# JWT # JWT
JWT_SECRET_KEY= JWT_SECRET_KEY=
JWT_ACCESS_TOKEN_EXPIRES=1200 JWT_ACCESS_TOKEN_EXPIRES=28800
# Models # Models
HF_HOME=/models/huggingface 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 UploadPage from "./pages/Upload";
import StatPage from "./pages/Stats"; import StatPage from "./pages/Stats";
import { getDocumentTitle } from "./utils/documentTitle";
function App() { function App() {
const location = useLocation();
useEffect(() => {
document.title = getDocumentTitle(location.pathname);
}, [location.pathname]);
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="/datasets" element={<DatasetsPage />} />
<Route path="/dataset/:datasetId/status" element={<DatasetStatusPage />} />
<Route path="/dataset/:datasetId/stats" element={<StatPage />} />
</Route>
</Routes> </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 type { CSSProperties } from "react";
import StatsStyling from "../styles/stats_styling";
const styles = StatsStyling;
const Card = (props: { const Card = (props: {
label: string; label: string;
@@ -8,43 +11,15 @@ const Card = (props: {
style?: CSSProperties style?: CSSProperties
}) => { }) => {
return ( return (
<div style={{ <div style={{ ...styles.cardBase, ...props.style }}>
background: "rgba(255,255,255,0.85)", <div style={styles.cardTopRow}>
border: "1px solid rgba(15,23,42,0.08)", <div style={styles.cardLabel}>
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"
}}>
{props.label} {props.label}
</div> </div>
{props.rightSlot ? <div>{props.rightSlot}</div> : null} {props.rightSlot ? <div>{props.rightSlot}</div> : null}
</div> </div>
<div style={{ <div style={styles.cardValue}>{props.value}</div>
fontSize: 22, {props.sublabel ? <div style={styles.cardSubLabel}>{props.sublabel}</div> : null}
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> </div>
); );
} }

View File

@@ -66,11 +66,11 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}> <div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
<h2 style={styles.sectionTitle}>Average Emotion by Topic</h2> <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> <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 }}> <div style={styles.emotionalSummaryRow}>
<span><strong style={{ color: "#111827" }}>Topics:</strong> {strongestPerTopic.length}</span> <span><strong style={{ color: "#24292f" }}>Topics:</strong> {strongestPerTopic.length}</span>
<span><strong style={{ color: "#111827" }}>Median Sample:</strong> {medianSampleSize} events</span> <span><strong style={{ color: "#24292f" }}>Median Sample:</strong> {medianSampleSize} events</span>
<span><strong style={{ color: "#111827" }}>Low Sample (&lt;{lowSampleThreshold}):</strong> {lowSampleTopics}</span> <span><strong style={{ color: "#24292f" }}>Low Sample (&lt;{lowSampleThreshold}):</strong> {lowSampleTopics}</span>
<span><strong style={{ color: "#111827" }}>Stable Sample ({stableSampleThreshold}+):</strong> {stableSampleTopics}</span> <span><strong style={{ color: "#24292f" }}>Stable Sample ({stableSampleThreshold}+):</strong> {stableSampleTopics}</span>
</div> </div>
<p style={{ ...styles.sectionSubtitle, marginTop: 10, marginBottom: 0 }}> <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. 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) => ( {strongestPerTopic.map((topic) => (
<div key={topic.topic} style={{ ...styles.card, gridColumn: "span 4" }}> <div key={topic.topic} style={{ ...styles.card, gridColumn: "span 4" }}>
<h3 style={{ ...styles.sectionTitle, marginBottom: 6 }}>{topic.topic}</h3> <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 Top Emotion
</div> </div>
<div style={{ fontSize: 24, fontWeight: 800, marginTop: 4, lineHeight: 1.2 }}> <div style={styles.emotionalTopicValue}>
{formatEmotion(topic.emotion)} {formatEmotion(topic.emotion)}
</div> </div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, fontSize: 13, color: "#6b7280" }}> <div style={styles.emotionalMetricRow}>
<span>Confidence</span> <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>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 4, fontSize: 13, color: "#6b7280" }}> <div style={styles.emotionalMetricRowCompact}>
<span>Sample Size</span> <span>Sample Size</span>
<span style={{ fontWeight: 700, color: "#111827" }}>{topic.count} events</span> <span style={styles.emotionalMetricValue}>{topic.count} events</span>
</div> </div>
</div> </div>
))} ))}

View File

@@ -13,26 +13,11 @@ type Props = {
export default function UserModal({ open, onClose, userData, username }: Props) { export default function UserModal({ open, onClose, userData, username }: Props) {
return ( return (
<Dialog open={open} onClose={onClose} style={{ position: "relative", zIndex: 50 }}> <Dialog open={open} onClose={onClose} style={styles.modalRoot}>
<div <div style={styles.modalBackdrop} />
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
}}
/>
<div <div style={styles.modalContainer}>
style={{ <DialogPanel style={{ ...styles.card, ...styles.modalPanel }}>
position: "fixed",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
}}
>
<DialogPanel style={{ ...styles.card, width: "min(520px, 95vw)" }}>
<div style={styles.headerBar}> <div style={styles.headerBar}>
<div> <div>
<DialogTitle style={styles.sectionTitle}>{username}</DialogTitle> <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 ForceGraph3D from "react-force-graph-3d";
import { import {
@@ -6,12 +7,19 @@ import {
} from '../types/ApiTypes'; } from '../types/ApiTypes';
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
import Card from "./Card";
const styles = StatsStyling; const styles = StatsStyling;
type GraphLink = {
source: string;
target: string;
value: number;
};
function ApiToGraphData(apiData: InteractionGraph) { function ApiToGraphData(apiData: InteractionGraph) {
const nodes = Object.keys(apiData).map(username => ({ id: username })); const nodes = Object.keys(apiData).map(username => ({ id: username }));
const links = []; const links: GraphLink[] = [];
for (const [source, targets] of Object.entries(apiData)) { for (const [source, targets] of Object.entries(apiData)) {
for (const [target, count] of Object.entries(targets)) { for (const [target, count] of Object.entries(targets)) {
@@ -35,26 +43,105 @@ function ApiToGraphData(apiData: InteractionGraph) {
const UserStats = (props: { data: UserAnalysisResponse }) => { 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 ( return (
<div style={styles.page}> <div style={styles.page}>
<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> <h2 style={styles.sectionTitle}>User Interaction Graph</h2>
<p style={styles.sectionSubtitle}> <p style={styles.sectionSubtitle}>
This graph visualizes interactions between users based on comments and replies. Nodes represent users and links represent conversation interactions.
Nodes represent users, and edges represent interactions (e.g., comments or replies) between them.
</p> </p>
<div> <div ref={graphContainerRef} style={{ width: "100%", height: graphSize.height }}>
<ForceGraph3D <ForceGraph3D
width={graphSize.width}
height={graphSize.height}
graphData={graphData} graphData={graphData}
nodeAutoColorBy="id" nodeAutoColorBy="id"
linkDirectionalParticles={2} linkDirectionalParticles={1}
linkDirectionalParticleSpeed={0.005} linkDirectionalParticleSpeed={0.004}
linkWidth={(link) => Math.sqrt(link.value)} linkWidth={(link) => Math.sqrt(Number(link.value))}
nodeLabel={(node) => `${node.id}`} nodeLabel={(node) => `${node.id}`}
/> />
</div> </div>
</div> </div>
</div>
</div>
); );
} }

View File

@@ -1,68 +1,65 @@
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; --bg-default: #f6f8fa;
line-height: 1.5; --text-default: #24292f;
font-weight: 400; --border-default: #d0d7de;
--focus-ring: rgba(9, 105, 218, 0.22);
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; background: var(--bg-default);
place-items: center; color: var(--text-default);
min-width: 320px; font-family: "IBM Plex Sans", "Noto Sans", "Liberation Sans", "Segoe UI", sans-serif;
min-height: 100vh;
} }
h1 { * {
font-size: 3.2em; box-sizing: border-box;
line-height: 1.1;
} }
button { button,
border-radius: 8px; input,
border: 1px solid transparent; select,
padding: 0.6em 1.2em; textarea {
font-size: 1em; font: inherit;
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) { input:focus,
:root { button:focus-visible,
color: #213547; select:focus,
background-color: #ffffff; 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; to {
} transform: rotate(360deg);
button { }
background-color: #f9f9f9; }
@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 { useEffect, useState, useRef } from "react";
import axios from "axios"; import axios from "axios";
import { useParams } from "react-router-dom";
import StatsStyling from "../styles/stats_styling"; import StatsStyling from "../styles/stats_styling";
import SummaryStats from "../components/SummaryStats"; import SummaryStats from "../components/SummaryStats";
import EmotionalStats from "../components/EmotionalStats"; import EmotionalStats from "../components/EmotionalStats";
import InteractionStats from "../components/UserStats"; import UserStats from "../components/UserStats";
import { import {
type SummaryResponse, type SummaryResponse,
@@ -12,12 +13,14 @@ import {
type ContentAnalysisResponse type ContentAnalysisResponse
} from '../types/ApiTypes' } from '../types/ApiTypes'
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
const styles = StatsStyling; const styles = StatsStyling;
const StatPage = () => { const StatPage = () => {
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); 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 [userData, setUserData] = useState<UserAnalysisResponse | null>(null);
const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null); const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null);
@@ -29,15 +32,73 @@ const StatPage = () => {
const beforeDateRef = useRef<HTMLInputElement>(null); const beforeDateRef = useRef<HTMLInputElement>(null);
const afterDateRef = 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(""); setError("");
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
axios.get<TimeAnalysisResponse>("http://localhost:5000/stats/time"), axios.get<TimeAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/time`, {
axios.get<UserAnalysisResponse>("http://localhost:5000/stats/user"), params,
axios.get<ContentAnalysisResponse>("http://localhost:5000/stats/content"), headers: authHeaders,
axios.get<SummaryResponse>(`http://localhost:5000/stats/summary`), }),
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]) => { .then(([timeRes, userRes, contentRes, summaryRes]) => {
setUserData(userRes.data || null); setUserData(userRes.data || null);
@@ -50,37 +111,52 @@ const StatPage = () => {
}; };
const onSubmitFilters = () => { const onSubmitFilters = () => {
const query = searchInputRef.current?.value ?? ""; getStats(getFilterParams());
Promise.all([
axios.post("http://localhost:5000/filter/search", {
query: query
}),
])
.then(() => {
getStats();
})
.catch(e => {
setError("Failed to load filters: " + e.response);
})
}; };
const resetFilters = () => { const resetFilters = () => {
axios.get("http://localhost:5000/filter/reset") if (searchInputRef.current) {
.then(() => { searchInputRef.current.value = "";
}
if (beforeDateRef.current) {
beforeDateRef.current.value = "";
}
if (afterDateRef.current) {
afterDateRef.current.value = "";
}
getStats(); getStats();
})
.catch(e => {
setError(e);
})
}; };
useEffect(() => { useEffect(() => {
setError(""); setError("");
if (!datasetId) {
setError("Missing dataset id. Open /dataset/<id>/stats.");
return;
}
getStats(); 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>; if (error) return <p style={{...styles.page}}>{error}</p>;
return ( return (
@@ -118,10 +194,11 @@ return (
</button> </button>
</div> </div>
<div style={{ fontSize: 13, color: "#6b7280" }}>Analytics Dashboard</div> <div style={styles.dashboardMeta}>Analytics Dashboard</div>
<div style={styles.dashboardMeta}>Dataset #{datasetId ?? "-"}</div>
</div> </div>
<div style={{ ...styles.container, display: "flex", gap: 8, marginTop: 12 }}> <div style={{ ...styles.container, ...styles.tabsRow }}>
<button <button
onClick={() => setActiveView("summary")} onClick={() => setActiveView("summary")}
style={activeView === "summary" ? styles.buttonPrimary : styles.buttonSecondary} style={activeView === "summary" ? styles.buttonPrimary : styles.buttonSecondary}
@@ -136,10 +213,10 @@ return (
</button> </button>
<button <button
onClick={() => setActiveView("interaction")} onClick={() => setActiveView("user")}
style={activeView === "interaction" ? styles.buttonPrimary : styles.buttonSecondary} style={activeView === "user" ? styles.buttonPrimary : styles.buttonSecondary}
> >
Interaction Users
</button> </button>
</div> </div>
@@ -162,8 +239,8 @@ return (
</div> </div>
)} )}
{activeView === "interaction" && userData && ( {activeView === "user" && userData && (
<InteractionStats data={userData} /> <UserStats data={userData} />
)} )}
</div> </div>

View File

@@ -1,56 +1,153 @@
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 = import.meta.env.VITE_BACKEND_URL
const UploadPage = () => { const UploadPage = () => {
let postFile: File | undefined; const [datasetName, setDatasetName] = useState("");
let topicBucketFile: File | undefined; const [postFile, setPostFile] = useState<File | null>(null);
const [returnMessage, setReturnMessage] = useState('') const [topicBucketFile, setTopicBucketFile] = useState<File | null>(null);
const navigate = useNavigate() const [returnMessage, setReturnMessage] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasError, setHasError] = useState(false);
const navigate = useNavigate();
const uploadFiles = async () => { const uploadFiles = async () => {
if (!postFile || !topicBucketFile) { const normalizedDatasetName = datasetName.trim();
alert('Please upload all files before uploading.')
return if (!normalizedDatasetName) {
setHasError(true);
setReturnMessage("Please add a dataset name before continuing.");
return;
} }
const formData = new FormData() if (!postFile || !topicBucketFile) {
formData.append('posts', postFile) setHasError(true);
formData.append('topics', topicBucketFile) 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 { 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, {
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> </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; 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 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> = { const StatsStyling: Record<string, CSSProperties> = {
page: { ...foundationStyles,
width: "100%", ...appLayoutStyles,
minHeight: "100vh", ...authStyles,
padding: 24, ...datasetStyles,
background: "#f6f7fb", ...feedbackStyles,
fontFamily: ...cardStyles,
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Arial, sans-serif', ...emotionalStyles,
color: "#111827", ...modalStyles,
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",
},
}; };
export default StatsStyling; export default StatsStyling;

View File

@@ -10,12 +10,6 @@ type FrequencyWord = {
count: number; count: number;
} }
type AverageEmotionByTopic = {
topic: string;
n: number;
[emotion: string]: string | number;
}
type Vocab = { type Vocab = {
author: string; author: string;
events: number; events: number;
@@ -58,13 +52,33 @@ type HeatmapCell = {
type TimeAnalysisResponse = { type TimeAnalysisResponse = {
events_per_day: EventsPerDay[]; events_per_day: EventsPerDay[];
weekday_hour_heatmap: HeatmapCell[]; weekday_hour_heatmap: HeatmapCell[];
burstiness: number;
} }
// Content Analysis // 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 = { type ContentAnalysisResponse = {
word_frequencies: FrequencyWord[]; word_frequencies: FrequencyWord[];
average_emotion_by_topic: AverageEmotionByTopic[]; average_emotion_by_topic: AverageEmotionByTopic[];
common_three_phrases: NGram[];
common_two_phrases: NGram[];
} }
// Summary // 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): def interaction_graph(self, df: pd.DataFrame):
interactions = {a: {} for a in df["author"].dropna().unique()} 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 # reply_to refers to the comment id, this allows us to map comment/post ids to usernames
id_to_author = df.set_index("id")["author"].to_dict() id_to_author = df.set_index("post_id")["author"].to_dict()
for _, row in df.iterrows(): for _, row in df.iterrows():
a = row["author"] a = row["author"]

View File

@@ -96,10 +96,7 @@ class StatGen:
"common_three_phrases": self.linguistic_analysis.ngrams(filtered_df, n=3), "common_three_phrases": self.linguistic_analysis.ngrams(filtered_df, n=3),
"average_emotion_by_topic": self.emotional_analysis.avg_emotion_by_topic( "average_emotion_by_topic": self.emotional_analysis.avg_emotion_by_topic(
filtered_df 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: def get_user_analysis(self, df: pd.DataFrame, filters: dict | None = None) -> dict:
@@ -108,9 +105,7 @@ class StatGen:
return { return {
"top_users": self.interaction_analysis.top_users(filtered_df), "top_users": self.interaction_analysis.top_users(filtered_df),
"users": self.interaction_analysis.per_user_analysis(filtered_df), "users": self.interaction_analysis.per_user_analysis(filtered_df),
"interaction_graph": self.interaction_analysis.interaction_graph( "interaction_graph": self.interaction_analysis.interaction_graph(filtered_df)
filtered_df
),
} }
def get_interactional_analysis(self, df: pd.DataFrame, filters: dict | None = None) -> dict: 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.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
@@ -46,6 +45,7 @@ auth_manager = AuthManager(db, bcrypt)
dataset_manager = DatasetManager(db) dataset_manager = DatasetManager(db)
stat_gen = StatGen() stat_gen = StatGen()
@app.route("/register", methods=["POST"]) @app.route("/register", methods=["POST"])
def register_user(): def register_user():
data = request.get_json() data = request.get_json()
@@ -105,6 +105,11 @@ def profile():
message="Access granted", user=auth_manager.get_user_by_id(current_user) message="Access granted", user=auth_manager.get_user_by_id(current_user)
), 200 ), 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"]) @app.route("/upload", methods=["POST"])
@jwt_required() @jwt_required()
@@ -114,6 +119,10 @@ def upload_data():
post_file = request.files["posts"] post_file = request.files["posts"]
topic_file = request.files["topics"] 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 == "": if post_file.filename == "" or topic_file.filename == "":
return jsonify({"error": "Empty filename"}), 400 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) posts_df = pd.read_json(post_file, lines=True, convert_dates=False)
topics = json.load(topic_file) 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( process_dataset.delay(dataset_id, posts_df.to_dict(orient="records"), topics)
dataset_id,
posts_df.to_dict(orient="records"),
topics
)
return jsonify( return jsonify(
{ {
"message": "Dataset queued for processing", "message": "Dataset queued for processing",
"dataset_id": dataset_id, "dataset_id": dataset_id,
"status": "processing" "status": "processing",
} }
), 202 ), 202
except ValueError as e: except ValueError as e:

View File

@@ -18,6 +18,10 @@ class DatasetManager:
return True 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: def get_dataset_content(self, dataset_id: int) -> pd.DataFrame:
query = "SELECT * FROM events WHERE dataset_id = %s" query = "SELECT * FROM events WHERE dataset_id = %s"
result = self.db.execute(query, (dataset_id,), fetch=True) result = self.db.execute(query, (dataset_id,), fetch=True)
@@ -48,6 +52,7 @@ class DatasetManager:
query = """ query = """
INSERT INTO events ( INSERT INTO events (
dataset_id, dataset_id,
post_id,
type, type,
parent_id, parent_id,
author, 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, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s %s, %s
) )
""" """
values = [ values = [
( (
dataset_id, dataset_id,
row["id"],
row["type"], row["type"],
row["parent_id"], row["parent_id"],
row["author"], row["author"],

View File

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