Compare commits
25 Commits
c1a0324a03
...
fcdac6f3bb
| Author | SHA1 | Date | |
|---|---|---|---|
| fcdac6f3bb | |||
| 5fc1f1532f | |||
| 24277e0104 | |||
| 4e99b77492 | |||
| b6815c490a | |||
| 29c90ddfff | |||
| 3fe08b9c67 | |||
| f9bc9cf9c9 | |||
| 249528bb5c | |||
| bd0e1a9050 | |||
| e2ac4495fd | |||
| f3b48525e2 | |||
| 55319461e5 | |||
| 531ddb0467 | |||
| d11c5acb77 | |||
| f63f4e5f10 | |||
| 23c58e20ae | |||
| 207c4b67da | |||
| 772205d3df | |||
| b6de100a17 | |||
| 5310568631 | |||
| 4b33f17b4b | |||
| 64783e764d | |||
| 8ac5207a11 | |||
| 090a57f4dd |
@@ -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:
|
||||||
@@ -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
13
frontend/Dockerfile
Normal 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"]
|
||||||
@@ -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 path="/upload" element={<UploadPage />} />
|
<Route element={<AppLayout />}>
|
||||||
<Route path="/stats" element={<StatPage />} />
|
<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>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
122
frontend/src/components/AppLayout.tsx
Normal file
122
frontend/src/components/AppLayout.tsx
Normal 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;
|
||||||
@@ -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,45 +11,17 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Card;
|
export default Card;
|
||||||
|
|||||||
@@ -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 (<{lowSampleThreshold}):</strong> {lowSampleTopics}</span>
|
<span><strong style={{ color: "#24292f" }}>Low Sample (<{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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,27 +43,106 @@ 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}>
|
||||||
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
|
<div style={{ ...styles.container, ...styles.grid }}>
|
||||||
<p style={styles.sectionSubtitle}>
|
<Card
|
||||||
This graph visualizes interactions between users based on comments and replies.
|
label="Users"
|
||||||
Nodes represent users, and edges represent interactions (e.g., comments or replies) between them.
|
value={totalUsers.toLocaleString()}
|
||||||
</p>
|
sublabel={`${connectedUsers.toLocaleString()} users in filtered graph`}
|
||||||
<div>
|
style={{ gridColumn: "span 3" }}
|
||||||
<ForceGraph3D
|
/>
|
||||||
|
<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}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UserStats;
|
export default UserStats;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
111
frontend/src/pages/DatasetStatus.tsx
Normal file
111
frontend/src/pages/DatasetStatus.tsx
Normal 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;
|
||||||
138
frontend/src/pages/Datasets.tsx
Normal file
138
frontend/src/pages/Datasets.tsx
Normal 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;
|
||||||
164
frontend/src/pages/Login.tsx
Normal file
164
frontend/src/pages/Login.tsx
Normal 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;
|
||||||
@@ -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 = "";
|
||||||
getStats();
|
}
|
||||||
})
|
if (beforeDateRef.current) {
|
||||||
.catch(e => {
|
beforeDateRef.current.value = "";
|
||||||
setError(e);
|
}
|
||||||
})
|
if (afterDateRef.current) {
|
||||||
|
afterDateRef.current.value = "";
|
||||||
|
}
|
||||||
|
getStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
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>
|
<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
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<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>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default UploadPage;
|
export default UploadPage;
|
||||||
|
|||||||
42
frontend/src/styles/stats/appLayout.ts
Normal file
42
frontend/src/styles/stats/appLayout.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
92
frontend/src/styles/stats/auth.ts
Normal file
92
frontend/src/styles/stats/auth.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
42
frontend/src/styles/stats/cards.ts
Normal file
42
frontend/src/styles/stats/cards.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
55
frontend/src/styles/stats/datasets.ts
Normal file
55
frontend/src/styles/stats/datasets.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
51
frontend/src/styles/stats/emotional.ts
Normal file
51
frontend/src/styles/stats/emotional.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
106
frontend/src/styles/stats/feedback.ts
Normal file
106
frontend/src/styles/stats/feedback.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
157
frontend/src/styles/stats/foundations.ts
Normal file
157
frontend/src/styles/stats/foundations.ts
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
28
frontend/src/styles/stats/modal.ts
Normal file
28
frontend/src/styles/stats/modal.ts
Normal 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)",
|
||||||
|
},
|
||||||
|
};
|
||||||
26
frontend/src/styles/stats/palette.ts
Normal file
26
frontend/src/styles/stats/palette.ts
Normal 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;
|
||||||
3
frontend/src/styles/stats/types.ts
Normal file
3
frontend/src/styles/stats/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export type StyleMap = Record<string, CSSProperties>;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
19
frontend/src/utils/documentTitle.ts
Normal file
19
frontend/src/utils/documentTitle.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -155,7 +160,7 @@ def upload_data():
|
|||||||
def get_dataset(dataset_id):
|
def get_dataset(dataset_id):
|
||||||
try:
|
try:
|
||||||
user_id = int(get_jwt_identity())
|
user_id = int(get_jwt_identity())
|
||||||
|
|
||||||
if not dataset_manager.authorize_user_dataset(dataset_id, user_id):
|
if not dataset_manager.authorize_user_dataset(dataset_id, user_id):
|
||||||
raise NotAuthorisedException("This user is not authorised to access this dataset")
|
raise NotAuthorisedException("This user is not authorised to access this dataset")
|
||||||
|
|
||||||
@@ -176,7 +181,7 @@ def get_dataset(dataset_id):
|
|||||||
def get_dataset_status(dataset_id):
|
def get_dataset_status(dataset_id):
|
||||||
try:
|
try:
|
||||||
user_id = int(get_jwt_identity())
|
user_id = int(get_jwt_identity())
|
||||||
|
|
||||||
if not dataset_manager.authorize_user_dataset(dataset_id, user_id):
|
if not dataset_manager.authorize_user_dataset(dataset_id, user_id):
|
||||||
raise NotAuthorisedException("This user is not authorised to access this dataset")
|
raise NotAuthorisedException("This user is not authorised to access this dataset")
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class DatasetManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
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"
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user