diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ea5e341..96c3430 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -56,5 +56,17 @@ services: count: 1 capabilities: [gpu] + frontend: + build: + context: ./frontend + container_name: crosspost_frontend + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "5173:5173" + depends_on: + - backend + volumes: model_cache: \ No newline at end of file diff --git a/example.env b/example.env index 5e544ef..ac126d8 100644 --- a/example.env +++ b/example.env @@ -11,7 +11,7 @@ POSTGRES_DIR= # JWT JWT_SECRET_KEY= -JWT_ACCESS_TOKEN_EXPIRES=1200 +JWT_ACCESS_TOKEN_EXPIRES=28800 # Models HF_HOME=/models/huggingface diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e8ea84e --- /dev/null +++ b/frontend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7b1726..a017f87 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,30 @@ -import { Routes, Route } from "react-router-dom"; +import { useEffect } from "react"; +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; +import AppLayout from "./components/AppLayout"; +import DatasetsPage from "./pages/Datasets"; +import DatasetStatusPage from "./pages/DatasetStatus"; +import LoginPage from "./pages/Login"; import UploadPage from "./pages/Upload"; import StatPage from "./pages/Stats"; +import { getDocumentTitle } from "./utils/documentTitle"; function App() { + const location = useLocation(); + + useEffect(() => { + document.title = getDocumentTitle(location.pathname); + }, [location.pathname]); + return ( - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + ); } diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..968f792 --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -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; +}; + +const styles = StatsStyling; + +const getUserLabel = (user: Record | 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 | 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(`${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 ( +
+
+
+
+ + CrossPost Analysis Engine + + + {isSignedIn ? `Signed in: ${getUserLabel(currentUser)}` : "Not signed in"} + +
+ +
+ {isSignedIn && } + + +
+
+
+ + +
+ ); +}; + +export default AppLayout; diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx index cdefe2b..3f19ecc 100644 --- a/frontend/src/components/Card.tsx +++ b/frontend/src/components/Card.tsx @@ -1,4 +1,7 @@ import type { CSSProperties } from "react"; +import StatsStyling from "../styles/stats_styling"; + +const styles = StatsStyling; const Card = (props: { label: string; @@ -8,45 +11,17 @@ const Card = (props: { style?: CSSProperties }) => { return ( -
-
-
+
+
+
{props.label}
{props.rightSlot ?
{props.rightSlot}
: null}
-
{props.value}
- {props.sublabel ?
{props.sublabel}
: null} +
{props.value}
+ {props.sublabel ?
{props.sublabel}
: null}
); } -export default Card; \ No newline at end of file +export default Card; diff --git a/frontend/src/components/EmotionalStats.tsx b/frontend/src/components/EmotionalStats.tsx index 4da8259..ecc588f 100644 --- a/frontend/src/components/EmotionalStats.tsx +++ b/frontend/src/components/EmotionalStats.tsx @@ -66,11 +66,11 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {

Average Emotion by Topic

Read confidence together with sample size. Topics with fewer than {lowSampleThreshold} events are usually noisy and less reliable.

-
- Topics: {strongestPerTopic.length} - Median Sample: {medianSampleSize} events - Low Sample (<{lowSampleThreshold}): {lowSampleTopics} - Stable Sample ({stableSampleThreshold}+): {stableSampleTopics} +
+ Topics: {strongestPerTopic.length} + Median Sample: {medianSampleSize} events + Low Sample (<{lowSampleThreshold}): {lowSampleTopics} + Stable Sample ({stableSampleThreshold}+): {stableSampleTopics}

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) => (

{topic.topic}

-
+
Top Emotion
-
+
{formatEmotion(topic.emotion)}
-
+
Confidence - {topic.value.toFixed(3)} + {topic.value.toFixed(3)}
-
+
Sample Size - {topic.count} events + {topic.count} events
))} diff --git a/frontend/src/components/UserModal.tsx b/frontend/src/components/UserModal.tsx index b62eb9e..54ee5fc 100644 --- a/frontend/src/components/UserModal.tsx +++ b/frontend/src/components/UserModal.tsx @@ -13,26 +13,11 @@ type Props = { export default function UserModal({ open, onClose, userData, username }: Props) { return ( - -
+ +
-
- +
+
{username} diff --git a/frontend/src/components/UserStats.tsx b/frontend/src/components/UserStats.tsx index b33fcd4..bb060cc 100644 --- a/frontend/src/components/UserStats.tsx +++ b/frontend/src/components/UserStats.tsx @@ -1,3 +1,4 @@ +import { useEffect, useMemo, useRef, useState } from "react"; import ForceGraph3D from "react-force-graph-3d"; import { @@ -6,12 +7,19 @@ import { } from '../types/ApiTypes'; import StatsStyling from "../styles/stats_styling"; +import Card from "./Card"; const styles = StatsStyling; +type GraphLink = { + source: string; + target: string; + value: number; +}; + function ApiToGraphData(apiData: InteractionGraph) { const nodes = Object.keys(apiData).map(username => ({ id: username })); - const links = []; + const links: GraphLink[] = []; for (const [source, targets] of Object.entries(apiData)) { for (const [target, count] of Object.entries(targets)) { @@ -35,27 +43,106 @@ function ApiToGraphData(apiData: InteractionGraph) { const UserStats = (props: { data: UserAnalysisResponse }) => { - const graphData = ApiToGraphData(props.data.interaction_graph); + const graphData = useMemo(() => ApiToGraphData(props.data.interaction_graph), [props.data.interaction_graph]); + const graphContainerRef = useRef(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((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 (
-

User Interaction Graph

-

- This graph visualizes interactions between users based on comments and replies. - Nodes represent users, and edges represent interactions (e.g., comments or replies) between them. -

-
- + + + + + + ${strongestLink.target}` : "—"} + sublabel={strongestLink ? `${strongestLink.value.toLocaleString()} interactions` : "No graph edges after filtering"} + style={{ gridColumn: "span 6" }} + /> + + +
+

User Interaction Graph

+

+ Nodes represent users and links represent conversation interactions. +

+
+ Math.sqrt(link.value)} + linkDirectionalParticles={1} + linkDirectionalParticleSpeed={0.004} + linkWidth={(link) => Math.sqrt(Number(link.value))} nodeLabel={(node) => `${node.id}`} - /> + /> +
+
); } -export default UserStats; \ No newline at end of file +export default UserStats; diff --git a/frontend/src/index.css b/frontend/src/index.css index 08a3ac9..53764e5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,68 +1,65 @@ :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - + --bg-default: #f6f8fa; + --text-default: #24292f; + --border-default: #d0d7de; + --focus-ring: rgba(9, 105, 218, 0.22); font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; +html, +body, +#root { + width: 100%; + height: 100%; } body { margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; + background: var(--bg-default); + color: var(--text-default); + font-family: "IBM Plex Sans", "Noto Sans", "Liberation Sans", "Segoe UI", sans-serif; } -h1 { - font-size: 3.2em; - line-height: 1.1; +* { + box-sizing: border-box; } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; +button, +input, +select, +textarea { + font: inherit; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; +input:focus, +button:focus-visible, +select:focus, +textarea:focus { + border-color: #0969da; + box-shadow: 0 0 0 3px var(--focus-ring); + outline: none; +} + +@keyframes stats-spin { + from { + transform: rotate(0deg); } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; + + to { + transform: rotate(360deg); + } +} + +@keyframes stats-pulse { + 0%, + 100% { + opacity: 0.5; + } + + 50% { + opacity: 1; } } diff --git a/frontend/src/pages/DatasetStatus.tsx b/frontend/src/pages/DatasetStatus.tsx new file mode 100644 index 0000000..919e48d --- /dev/null +++ b/frontend/src/pages/DatasetStatus.tsx @@ -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("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( + `${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 ( +
+
+
+

+ {isProcessing ? "Processing dataset..." : isError ? "Dataset processing failed" : "Dataset ready"} +

+ +

+ {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..."} +

+ +
+ {statusMessage || (isProcessing ? "Waiting for updates from the worker queue..." : "No details provided.")} +
+
+
+
+ ); +}; + +export default DatasetStatusPage; diff --git a/frontend/src/pages/Datasets.tsx b/frontend/src/pages/Datasets.tsx new file mode 100644 index 0000000..b054420 --- /dev/null +++ b/frontend/src/pages/Datasets.tsx @@ -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([]); + 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(`${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

Loading datasets...

; + } + + return ( +
+
+
+
+

My Datasets

+

+ View and reopen datasets you previously uploaded. +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {!error && datasets.length === 0 && ( +
+ No datasets yet. Upload one to get started. +
+ )} + + {!error && datasets.length > 0 && ( +
+
    + {datasets.map((dataset) => { + const isComplete = dataset.status === "complete"; + const targetPath = isComplete + ? `/dataset/${dataset.id}/stats` + : `/dataset/${dataset.id}/status`; + + return ( +
  • +
    +
    + {dataset.name || `Dataset #${dataset.id}`} +
    +
    + ID #{dataset.id} • Status: {dataset.status || "unknown"} +
    + {dataset.status_message && ( +
    + {dataset.status_message} +
    + )} +
    + + +
  • + ); + })} +
+
+ )} +
+
+ ); +}; + +export default DatasetsPage; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..5aaaa54 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -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) => { + 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 ( +
+
+
+

+ {isRegisterMode ? "Create your account" : "Welcome back"} +

+

+ {isRegisterMode + ? "Register to start uploading and exploring your dataset insights." + : "Sign in to continue to your analytics workspace."} +

+
+ +
+ setUsername(event.target.value)} + required + /> + + {isRegisterMode && ( + setEmail(event.target.value)} + required + /> + )} + + setPassword(event.target.value)} + required + /> + + +
+ + {error && ( +

+ {error} +

+ )} + + {info && ( +

+ {info} +

+ )} + +
+ + {isRegisterMode ? "Already have an account?" : "New here?"} + + +
+
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/src/pages/Stats.tsx b/frontend/src/pages/Stats.tsx index b8a68b5..683584f 100644 --- a/frontend/src/pages/Stats.tsx +++ b/frontend/src/pages/Stats.tsx @@ -1,9 +1,10 @@ import { useEffect, useState, useRef } from "react"; import axios from "axios"; +import { useParams } from "react-router-dom"; import StatsStyling from "../styles/stats_styling"; import SummaryStats from "../components/SummaryStats"; import EmotionalStats from "../components/EmotionalStats"; -import InteractionStats from "../components/UserStats"; +import UserStats from "../components/UserStats"; import { type SummaryResponse, @@ -12,12 +13,14 @@ import { type ContentAnalysisResponse } from '../types/ApiTypes' +const API_BASE_URL = import.meta.env.VITE_BACKEND_URL const styles = StatsStyling; const StatPage = () => { + const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>(); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - const [activeView, setActiveView] = useState<"summary" | "emotional" | "interaction">("summary"); + const [activeView, setActiveView] = useState<"summary" | "emotional" | "user">("summary"); const [userData, setUserData] = useState(null); const [timeData, setTimeData] = useState(null); @@ -29,15 +32,73 @@ const StatPage = () => { const beforeDateRef = useRef(null); const afterDateRef = useRef(null); - const getStats = () => { + const parsedDatasetId = Number(routeDatasetId ?? ""); + const datasetId = Number.isInteger(parsedDatasetId) && parsedDatasetId > 0 ? parsedDatasetId : null; + + const getFilterParams = () => { + const params: Record = {}; + 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 = {}) => { + if (!datasetId) { + setError("Missing dataset id. Open /dataset//stats."); + return; + } + + const authHeaders = getAuthHeaders(); + if (!authHeaders) { + setError("You must be signed in to load stats."); + return; + } + setError(""); setLoading(true); Promise.all([ - axios.get("http://localhost:5000/stats/time"), - axios.get("http://localhost:5000/stats/user"), - axios.get("http://localhost:5000/stats/content"), - axios.get(`http://localhost:5000/stats/summary`), + axios.get(`${API_BASE_URL}/dataset/${datasetId}/time`, { + params, + headers: authHeaders, + }), + axios.get(`${API_BASE_URL}/dataset/${datasetId}/user`, { + params, + headers: authHeaders, + }), + axios.get(`${API_BASE_URL}/dataset/${datasetId}/content`, { + params, + headers: authHeaders, + }), + axios.get(`${API_BASE_URL}/dataset/${datasetId}/summary`, { + params, + headers: authHeaders, + }), ]) .then(([timeRes, userRes, contentRes, summaryRes]) => { setUserData(userRes.data || null); @@ -50,37 +111,52 @@ const StatPage = () => { }; const onSubmitFilters = () => { - const query = searchInputRef.current?.value ?? ""; - - Promise.all([ - axios.post("http://localhost:5000/filter/search", { - query: query - }), - ]) - .then(() => { - getStats(); - }) - .catch(e => { - setError("Failed to load filters: " + e.response); - }) + getStats(getFilterParams()); }; const resetFilters = () => { - axios.get("http://localhost:5000/filter/reset") - .then(() => { - getStats(); - }) - .catch(e => { - setError(e); - }) + if (searchInputRef.current) { + searchInputRef.current.value = ""; + } + if (beforeDateRef.current) { + beforeDateRef.current.value = ""; + } + if (afterDateRef.current) { + afterDateRef.current.value = ""; + } + getStats(); }; useEffect(() => { setError(""); + if (!datasetId) { + setError("Missing dataset id. Open /dataset//stats."); + return; + } getStats(); - }, []) + }, [datasetId]) - if (loading) return

Loading insights…

; + if (loading) { + return ( +
+
+
+
+
+

Loading analytics

+

Fetching summary, timeline, user, and content insights.

+
+
+ +
+
+
+
+
+
+
+ ); + } if (error) return

{error}

; return ( @@ -118,10 +194,11 @@ return (
-
Analytics Dashboard
-
+
Analytics Dashboard
+
Dataset #{datasetId ?? "-"}
+
-
+
@@ -162,8 +239,8 @@ return (
)} - {activeView === "interaction" && userData && ( - + {activeView === "user" && userData && ( + )}
diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx index 2218231..93383dc 100644 --- a/frontend/src/pages/Upload.tsx +++ b/frontend/src/pages/Upload.tsx @@ -1,56 +1,153 @@ -import axios from 'axios' -import './../App.css' -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' +import axios from "axios"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; import StatsStyling from "../styles/stats_styling"; const styles = StatsStyling; +const API_BASE_URL = import.meta.env.VITE_BACKEND_URL const UploadPage = () => { - let postFile: File | undefined; - let topicBucketFile: File | undefined; - const [returnMessage, setReturnMessage] = useState('') - const navigate = useNavigate() + const [datasetName, setDatasetName] = useState(""); + const [postFile, setPostFile] = useState(null); + const [topicBucketFile, setTopicBucketFile] = useState(null); + const [returnMessage, setReturnMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [hasError, setHasError] = useState(false); + const navigate = useNavigate(); const uploadFiles = async () => { - if (!postFile || !topicBucketFile) { - alert('Please upload all files before uploading.') - return + const normalizedDatasetName = datasetName.trim(); + + if (!normalizedDatasetName) { + setHasError(true); + setReturnMessage("Please add a dataset name before continuing."); + return; } - const formData = new FormData() - formData.append('posts', postFile) - formData.append('topics', topicBucketFile) + if (!postFile || !topicBucketFile) { + setHasError(true); + setReturnMessage("Please upload both files before continuing."); + return; + } + + const formData = new FormData(); + formData.append("name", normalizedDatasetName); + formData.append("posts", postFile); + formData.append("topics", topicBucketFile); try { - const response = await axios.post('http://localhost:5000/upload', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }) - console.log('Files uploaded successfully:', response.data) - setReturnMessage(`Upload successful! Posts: ${response.data.posts_count}, Comments: ${response.data.comments_count}`) - navigate('/stats') - } catch (error) { - console.error('Error uploading files:', error) - setReturnMessage('Error uploading files. Error details: ' + error) - } - } - return ( -
-
-

Posts File

- postFile = e.target.files?.[0]}> -
-
-

Topic Buckets File

- topicBucketFile = e.target.files?.[0]}> -
- + setIsSubmitting(true); + setHasError(false); + setReturnMessage(""); -

{returnMessage}

+ 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 ( +
+
+
+
+

Upload Dataset

+

+ Name your dataset, then upload posts and topic map files to generate analytics. +

+
+ +
+ +
+
+

Dataset Name

+

Use a clear label so you can identify this upload later.

+ setDatasetName(event.target.value)} + /> +
+ +
+

Posts File (.jsonl)

+

Upload the raw post records export.

+ setPostFile(event.target.files?.[0] ?? null)} + /> +

+ {postFile ? `Selected: ${postFile.name}` : "No file selected"} +

+
+ +
+

Topics File (.json)

+

Upload your topic bucket mapping file.

+ setTopicBucketFile(event.target.files?.[0] ?? null)} + /> +

+ {topicBucketFile ? `Selected: ${topicBucketFile.name}` : "No file selected"} +

+
+
+ +
+ {returnMessage || "After upload, your dataset is queued for processing and you'll land on stats."} +
+
- ) -} + ); +}; export default UploadPage; diff --git a/frontend/src/styles/stats/appLayout.ts b/frontend/src/styles/stats/appLayout.ts new file mode 100644 index 0000000..b680ea6 --- /dev/null +++ b/frontend/src/styles/stats/appLayout.ts @@ -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, + }, +}; diff --git a/frontend/src/styles/stats/auth.ts b/frontend/src/styles/stats/auth.ts new file mode 100644 index 0000000..abc82b5 --- /dev/null +++ b/frontend/src/styles/stats/auth.ts @@ -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, + }, +}; diff --git a/frontend/src/styles/stats/cards.ts b/frontend/src/styles/stats/cards.ts new file mode 100644 index 0000000..ab388a9 --- /dev/null +++ b/frontend/src/styles/stats/cards.ts @@ -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, + }, +}; diff --git a/frontend/src/styles/stats/datasets.ts b/frontend/src/styles/stats/datasets.ts new file mode 100644 index 0000000..a2e2d28 --- /dev/null +++ b/frontend/src/styles/stats/datasets.ts @@ -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, + }, +}; diff --git a/frontend/src/styles/stats/emotional.ts b/frontend/src/styles/stats/emotional.ts new file mode 100644 index 0000000..6ad68ce --- /dev/null +++ b/frontend/src/styles/stats/emotional.ts @@ -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, + }, +}; diff --git a/frontend/src/styles/stats/feedback.ts b/frontend/src/styles/stats/feedback.ts new file mode 100644 index 0000000..31f9fa6 --- /dev/null +++ b/frontend/src/styles/stats/feedback.ts @@ -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, + }, +}; diff --git a/frontend/src/styles/stats/foundations.ts b/frontend/src/styles/stats/foundations.ts new file mode 100644 index 0000000..7801824 --- /dev/null +++ b/frontend/src/styles/stats/foundations.ts @@ -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", + }, +}; diff --git a/frontend/src/styles/stats/modal.ts b/frontend/src/styles/stats/modal.ts new file mode 100644 index 0000000..596ee00 --- /dev/null +++ b/frontend/src/styles/stats/modal.ts @@ -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)", + }, +}; diff --git a/frontend/src/styles/stats/palette.ts b/frontend/src/styles/stats/palette.ts new file mode 100644 index 0000000..a4943ae --- /dev/null +++ b/frontend/src/styles/stats/palette.ts @@ -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; diff --git a/frontend/src/styles/stats/types.ts b/frontend/src/styles/stats/types.ts new file mode 100644 index 0000000..74d3d2e --- /dev/null +++ b/frontend/src/styles/stats/types.ts @@ -0,0 +1,3 @@ +import type { CSSProperties } from "react"; + +export type StyleMap = Record; diff --git a/frontend/src/styles/stats_styling.tsx b/frontend/src/styles/stats_styling.tsx index e942a7f..9397214 100644 --- a/frontend/src/styles/stats_styling.tsx +++ b/frontend/src/styles/stats_styling.tsx @@ -1,136 +1,22 @@ import type { CSSProperties } from "react"; +import { appLayoutStyles } from "./stats/appLayout"; +import { authStyles } from "./stats/auth"; +import { cardStyles } from "./stats/cards"; +import { datasetStyles } from "./stats/datasets"; +import { emotionalStyles } from "./stats/emotional"; +import { feedbackStyles } from "./stats/feedback"; +import { foundationStyles } from "./stats/foundations"; +import { modalStyles } from "./stats/modal"; const StatsStyling: Record = { - page: { - width: "100%", - minHeight: "100vh", - padding: 24, - background: "#f6f7fb", - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Arial, sans-serif', - color: "#111827", - overflowX: "hidden", - boxSizing: "border-box" - }, - - - container: { - maxWidth: 1400, - margin: "0 auto", - }, - - card: { - background: "white", - borderRadius: 16, - padding: 16, - border: "1px solid rgba(0,0,0,0.06)", - boxShadow: "0 6px 20px rgba(0,0,0,0.06)", - }, - - headerBar: { - display: "flex", - flexWrap: "wrap", - alignItems: "center", - justifyContent: "space-between", - gap: 12, - }, - - controls: { - display: "flex", - gap: 10, - alignItems: "center", - }, - - input: { - width: 320, - maxWidth: "70vw", - padding: "10px 12px", - borderRadius: 12, - border: "1px solid rgba(0,0,0,0.12)", - outline: "none", - fontSize: 14, - background: "#fff", - color: "black" - }, - - buttonPrimary: { - padding: "10px 14px", - borderRadius: 12, - border: "1px solid rgba(0,0,0,0.08)", - background: "#2563eb", - color: "white", - fontWeight: 600, - cursor: "pointer", - boxShadow: "0 6px 16px rgba(37,99,235,0.25)", - }, - - buttonSecondary: { - padding: "10px 14px", - borderRadius: 12, - border: "1px solid rgba(0,0,0,0.12)", - background: "#fff", - color: "#111827", - fontWeight: 600, - cursor: "pointer", - }, - - grid: { - marginTop: 18, - display: "grid", - gridTemplateColumns: "repeat(12, 1fr)", - gap: 16, - }, - - sectionTitle: { - margin: 0, - fontSize: 16, - fontWeight: 700, - }, - - sectionSubtitle: { - margin: "6px 0 14px", - fontSize: 13, - color: "#6b7280", - }, - - chartWrapper: { - width: "100%", - height: 350, - }, - - heatmapWrapper: { - width: "100%", - height: 320, - }, - - topUsersList: { - display: "flex", - flexDirection: "column", - gap: 10, - }, - - topUserItem: { - padding: "10px 12px", - borderRadius: 12, - background: "#f9fafb", - border: "1px solid rgba(0,0,0,0.06)", - }, - - topUserName: { - fontWeight: 700, - fontSize: 14, - color: "black" - }, - - topUserMeta: { - fontSize: 13, - color: "#6b7280", - }, - - scrollArea: { - maxHeight: 450, - overflowY: "auto", - }, + ...foundationStyles, + ...appLayoutStyles, + ...authStyles, + ...datasetStyles, + ...feedbackStyles, + ...cardStyles, + ...emotionalStyles, + ...modalStyles, }; -export default StatsStyling; \ No newline at end of file +export default StatsStyling; diff --git a/frontend/src/types/ApiTypes.ts b/frontend/src/types/ApiTypes.ts index 7153720..5feaddf 100644 --- a/frontend/src/types/ApiTypes.ts +++ b/frontend/src/types/ApiTypes.ts @@ -10,12 +10,6 @@ type FrequencyWord = { count: number; } -type AverageEmotionByTopic = { - topic: string; - n: number; - [emotion: string]: string | number; -} - type Vocab = { author: string; events: number; @@ -58,13 +52,33 @@ type HeatmapCell = { type TimeAnalysisResponse = { events_per_day: EventsPerDay[]; weekday_hour_heatmap: HeatmapCell[]; - burstiness: number; } // Content Analysis +type Emotion = { + emotion_anger: number; + emotion_disgust: number; + emotion_fear: number; + emotion_joy: number; + emotion_sadness: number; +}; + +type NGram = { + count: number; + ngram: string; +} + +type AverageEmotionByTopic = Emotion & { + n: number; + topic: string; +}; + + type ContentAnalysisResponse = { word_frequencies: FrequencyWord[]; average_emotion_by_topic: AverageEmotionByTopic[]; + common_three_phrases: NGram[]; + common_two_phrases: NGram[]; } // Summary diff --git a/frontend/src/utils/documentTitle.ts b/frontend/src/utils/documentTitle.ts new file mode 100644 index 0000000..904a6a8 --- /dev/null +++ b/frontend/src/utils/documentTitle.ts @@ -0,0 +1,19 @@ +const DEFAULT_TITLE = "Ethnograph View"; + +const STATIC_TITLES: Record = { + "/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; +}; diff --git a/server/analysis/interactional.py b/server/analysis/interactional.py index 5c8ac3d..864980d 100644 --- a/server/analysis/interactional.py +++ b/server/analysis/interactional.py @@ -127,8 +127,8 @@ class InteractionAnalysis: def interaction_graph(self, df: pd.DataFrame): interactions = {a: {} for a in df["author"].dropna().unique()} - # reply_to refers to the comment id, this allows us to map comment ids to usernames - id_to_author = df.set_index("id")["author"].to_dict() + # reply_to refers to the comment id, this allows us to map comment/post ids to usernames + id_to_author = df.set_index("post_id")["author"].to_dict() for _, row in df.iterrows(): a = row["author"] diff --git a/server/analysis/stat_gen.py b/server/analysis/stat_gen.py index f9d8344..a9e9289 100644 --- a/server/analysis/stat_gen.py +++ b/server/analysis/stat_gen.py @@ -96,10 +96,7 @@ class StatGen: "common_three_phrases": self.linguistic_analysis.ngrams(filtered_df, n=3), "average_emotion_by_topic": self.emotional_analysis.avg_emotion_by_topic( filtered_df - ), - "reply_time_by_emotion": self.temporal_analysis.avg_reply_time_per_emotion( - filtered_df - ), + ) } def get_user_analysis(self, df: pd.DataFrame, filters: dict | None = None) -> dict: @@ -108,9 +105,7 @@ class StatGen: return { "top_users": self.interaction_analysis.top_users(filtered_df), "users": self.interaction_analysis.per_user_analysis(filtered_df), - "interaction_graph": self.interaction_analysis.interaction_graph( - filtered_df - ), + "interaction_graph": self.interaction_analysis.interaction_graph(filtered_df) } def get_interactional_analysis(self, df: pd.DataFrame, filters: dict | None = None) -> dict: diff --git a/server/app.py b/server/app.py index a92f4a7..1332ad2 100644 --- a/server/app.py +++ b/server/app.py @@ -15,7 +15,6 @@ from flask_jwt_extended import ( ) from server.analysis.stat_gen import StatGen -from server.analysis.enrichment import DatasetEnrichment from server.exceptions import NotAuthorisedException, NonExistentDatasetException from server.db.database import PostgresConnector from server.core.auth import AuthManager @@ -46,6 +45,7 @@ auth_manager = AuthManager(db, bcrypt) dataset_manager = DatasetManager(db) stat_gen = StatGen() + @app.route("/register", methods=["POST"]) def register_user(): data = request.get_json() @@ -105,6 +105,11 @@ def profile(): message="Access granted", user=auth_manager.get_user_by_id(current_user) ), 200 +@app.route("/user/datasets") +@jwt_required() +def get_user_datasets(): + current_user = int(get_jwt_identity()) + return jsonify(dataset_manager.get_user_datasets(current_user)), 200 @app.route("/upload", methods=["POST"]) @jwt_required() @@ -114,6 +119,10 @@ def upload_data(): post_file = request.files["posts"] topic_file = request.files["topics"] + dataset_name = (request.form.get("name") or "").strip() + + if not dataset_name: + return jsonify({"error": "Missing required dataset name"}), 400 if post_file.filename == "" or topic_file.filename == "": return jsonify({"error": "Empty filename"}), 400 @@ -130,19 +139,15 @@ def upload_data(): posts_df = pd.read_json(post_file, lines=True, convert_dates=False) topics = json.load(topic_file) - dataset_id = dataset_manager.save_dataset_info(current_user, f"dataset_{current_user}", topics) + dataset_id = dataset_manager.save_dataset_info(current_user, dataset_name, topics) - process_dataset.delay( - dataset_id, - posts_df.to_dict(orient="records"), - topics - ) + process_dataset.delay(dataset_id, posts_df.to_dict(orient="records"), topics) return jsonify( { "message": "Dataset queued for processing", "dataset_id": dataset_id, - "status": "processing" + "status": "processing", } ), 202 except ValueError as e: @@ -155,7 +160,7 @@ def upload_data(): def get_dataset(dataset_id): try: user_id = int(get_jwt_identity()) - + if not dataset_manager.authorize_user_dataset(dataset_id, user_id): 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): try: user_id = int(get_jwt_identity()) - + if not dataset_manager.authorize_user_dataset(dataset_id, user_id): raise NotAuthorisedException("This user is not authorised to access this dataset") diff --git a/server/core/datasets.py b/server/core/datasets.py index 541db5d..e7ee717 100644 --- a/server/core/datasets.py +++ b/server/core/datasets.py @@ -17,6 +17,10 @@ class DatasetManager: return False return True + + def get_user_datasets(self, user_id: int) -> list[dict]: + query = "SELECT * FROM datasets WHERE user_id = %s" + return self.db.execute(query, (user_id, ), fetch=True) def get_dataset_content(self, dataset_id: int) -> pd.DataFrame: query = "SELECT * FROM events WHERE dataset_id = %s" @@ -48,6 +52,7 @@ class DatasetManager: query = """ INSERT INTO events ( dataset_id, + post_id, type, parent_id, author, @@ -74,13 +79,14 @@ class DatasetManager: %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, - %s + %s, %s ) """ values = [ ( dataset_id, + row["id"], row["type"], row["parent_id"], row["author"], diff --git a/server/db/schema.sql b/server/db/schema.sql index 5379a95..051a396 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -30,6 +30,8 @@ CREATE TABLE events ( /* Required Fields */ id SERIAL PRIMARY KEY, dataset_id INTEGER NOT NULL, + + post_id VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, author VARCHAR(255) NOT NULL,