diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 968f792..ad49a61 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -3,7 +3,7 @@ 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 +const API_BASE_URL = import.meta.env.VITE_BACKEND_URL; type ProfileResponse = { user?: Record; @@ -33,7 +33,10 @@ const AppLayout = () => { const location = useLocation(); const navigate = useNavigate(); const [isSignedIn, setIsSignedIn] = useState(false); - const [currentUser, setCurrentUser] = useState | null>(null); + const [currentUser, setCurrentUser] = useState | null>(null); const syncAuthState = useCallback(async () => { const token = localStorage.getItem("access_token"); @@ -48,7 +51,9 @@ const AppLayout = () => { axios.defaults.headers.common.Authorization = `Bearer ${token}`; try { - const response = await axios.get(`${API_BASE_URL}/profile`); + const response = await axios.get( + `${API_BASE_URL}/profile`, + ); setIsSignedIn(true); setCurrentUser(response.data.user ?? null); } catch { @@ -81,27 +86,35 @@ const AppLayout = () => {
- - CrossPost Analysis Engine - + CrossPost Analysis Engine - {isSignedIn ? `Signed in: ${getUserLabel(currentUser)}` : "Not signed in"} + {isSignedIn + ? `Signed in: ${getUserLabel(currentUser)}` + : "Not signed in"}
- {isSignedIn && } + {isSignedIn && ( + + )} -
diff --git a/frontend/src/components/CulturalStats.tsx b/frontend/src/components/CulturalStats.tsx index c46e0c3..e62b956 100644 --- a/frontend/src/components/CulturalStats.tsx +++ b/frontend/src/components/CulturalStats.tsx @@ -14,15 +14,17 @@ const CulturalStats = ({ data }: CulturalStatsProps) => { const inGroupWords = identity?.in_group_usage ?? 0; const outGroupWords = identity?.out_group_usage ?? 0; const totalGroupWords = inGroupWords + outGroupWords; - const inGroupWordRate = typeof identity?.in_group_ratio === "number" - ? identity.in_group_ratio * 100 - : null; - const outGroupWordRate = typeof identity?.out_group_ratio === "number" - ? identity.out_group_ratio * 100 - : null; + const inGroupWordRate = + typeof identity?.in_group_ratio === "number" + ? identity.in_group_ratio * 100 + : null; + const outGroupWordRate = + typeof identity?.out_group_ratio === "number" + ? identity.out_group_ratio * 100 + : null; const rawEntities = data.avg_emotion_per_entity?.entity_emotion_avg ?? {}; const entities = Object.entries(rawEntities) - .sort((a, b) => (b[1].post_count - a[1].post_count)) + .sort((a, b) => b[1].post_count - a[1].post_count) .slice(0, 20); const topEmotion = (emotionAvg: Record | undefined) => { @@ -42,7 +44,10 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {

Community Framing Overview

-

Simple view of how often people use "us" words vs "them" words, and the tone around that language.

+

+ Simple view of how often people use "us" words vs "them" words, and + the tone around that language. +

{ /> @@ -98,52 +107,87 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {

Mood in "Us" Posts

-

Most likely emotion when in-group wording is stronger.

-
{topEmotion(identity?.in_group_emotion_avg)}
+

+ Most likely emotion when in-group wording is stronger. +

+
+ {topEmotion(identity?.in_group_emotion_avg)} +

Mood in "Them" Posts

-

Most likely emotion when out-group wording is stronger.

-
{topEmotion(identity?.out_group_emotion_avg)}
+

+ Most likely emotion when out-group wording is stronger. +

+
+ {topEmotion(identity?.out_group_emotion_avg)} +

Entity Mood Snapshot

-

Most mentioned entities and the mood that appears most with each.

+

+ Most mentioned entities and the mood that appears most with each. +

{!entities.length ? ( -
No entity-level cultural data available.
+
+ No entity-level cultural data available. +
) : ( -
+
{entities.map(([entity, aggregate]) => (
{entity}
- {aggregate.post_count.toLocaleString()} posts • Likely mood: {topEmotion(aggregate.emotion_avg)} + {aggregate.post_count.toLocaleString()} posts • Likely mood:{" "} + {topEmotion(aggregate.emotion_avg)}
))} diff --git a/frontend/src/components/EmotionalStats.tsx b/frontend/src/components/EmotionalStats.tsx index a0a66f3..9797ba9 100644 --- a/frontend/src/components/EmotionalStats.tsx +++ b/frontend/src/components/EmotionalStats.tsx @@ -1,16 +1,17 @@ -import type { ContentAnalysisResponse } from "../types/ApiTypes" +import type { ContentAnalysisResponse } from "../types/ApiTypes"; import StatsStyling from "../styles/stats_styling"; const styles = StatsStyling; type EmotionalStatsProps = { contentData: ContentAnalysisResponse; -} +}; -const EmotionalStats = ({contentData}: EmotionalStatsProps) => { +const EmotionalStats = ({ contentData }: EmotionalStatsProps) => { const rows = contentData.average_emotion_by_topic ?? []; const overallEmotionAverage = contentData.overall_emotion_average ?? []; - const dominantEmotionDistribution = contentData.dominant_emotion_distribution ?? []; + const dominantEmotionDistribution = + contentData.dominant_emotion_distribution ?? []; const emotionBySource = contentData.emotion_by_source ?? []; const lowSampleThreshold = 20; const stableSampleThreshold = 50; @@ -34,7 +35,7 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => { topic: String(row.topic), count: Number(row.n ?? 0), emotion: maxKey.replace("emotion_", "") || "unknown", - value: maxValue > Number.NEGATIVE_INFINITY ? maxValue : 0 + value: maxValue > Number.NEGATIVE_INFINITY ? maxValue : 0, }; }); @@ -48,8 +49,12 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => { .filter((count) => Number.isFinite(count) && count > 0) .sort((a, b) => a - b); - const lowSampleTopics = strongestPerTopic.filter((topic) => topic.count < lowSampleThreshold).length; - const stableSampleTopics = strongestPerTopic.filter((topic) => topic.count >= stableSampleThreshold).length; + const lowSampleTopics = strongestPerTopic.filter( + (topic) => topic.count < lowSampleThreshold, + ).length; + const stableSampleTopics = strongestPerTopic.filter( + (topic) => topic.count >= stableSampleThreshold, + ).length; const medianSampleSize = sampleSizes.length ? sampleSizes[Math.floor(sampleSizes.length / 2)] @@ -68,15 +73,37 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {

Topic Mood Overview

-

Use the strength score together with post count. Topics with fewer than {lowSampleThreshold} events are often noisy.

+

+ Use the strength score together with post count. Topics with fewer + than {lowSampleThreshold} events are often noisy. +

- Topics: {strongestPerTopic.length} - Median Posts: {medianSampleSize} - Small Topics (<{lowSampleThreshold}): {lowSampleTopics} - Stable Topics ({stableSampleThreshold}+): {stableSampleTopics} + + Topics:{" "} + {strongestPerTopic.length} + + + Median Posts:{" "} + {medianSampleSize} + + + + Small Topics (<{lowSampleThreshold}): + {" "} + {lowSampleTopics} + + + + Stable Topics ({stableSampleThreshold}+): + {" "} + {stableSampleTopics} +
-

- Strength means how far the top emotion is ahead in that topic. It does not mean model accuracy. +

+ Strength means how far the top emotion is ahead in that topic. It does + not mean model accuracy.

@@ -85,14 +112,24 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {

Mood Averages

Average score for each emotion.

{!overallEmotionAverage.length ? ( -
No overall emotion averages available.
+
+ No overall emotion averages available. +
) : ( -
+
{[...overallEmotionAverage] .sort((a, b) => b.score - a.score) .map((row) => (
-
{formatEmotion(row.emotion)}
+
+ {formatEmotion(row.emotion)} +
{row.score.toFixed(3)}
))} @@ -102,17 +139,32 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {

Mood Split

-

How often each emotion is dominant.

+

+ How often each emotion is dominant. +

{!dominantEmotionDistribution.length ? ( -
No dominant-emotion split available.
+
+ No dominant-emotion split available. +
) : ( -
+
{[...dominantEmotionDistribution] .sort((a, b) => b.ratio - a.ratio) .map((row) => (
-
{formatEmotion(row.emotion)}
-
{(row.ratio * 100).toFixed(1)}% • {row.count.toLocaleString()} events
+
+ {formatEmotion(row.emotion)} +
+
+ {(row.ratio * 100).toFixed(1)}% •{" "} + {row.count.toLocaleString()} events +
))}
@@ -123,16 +175,26 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {

Mood by Source

Leading emotion in each source.

{!emotionBySource.length ? ( -
No source emotion profile available.
+
+ No source emotion profile available. +
) : ( -
+
{[...emotionBySource] .sort((a, b) => b.event_count - a.event_count) .map((row) => (
{row.source}
- {formatEmotion(row.dominant_emotion)} • {row.dominant_score.toFixed(3)} • {row.event_count.toLocaleString()} events + {formatEmotion(row.dominant_emotion)} •{" "} + {row.dominant_score.toFixed(3)} •{" "} + {row.event_count.toLocaleString()} events
))} @@ -142,20 +204,27 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {

Topic Snapshots

-

Per-topic mood with strength and post count.

+

+ Per-topic mood with strength and post count. +

{strongestPerTopic.map((topic) => ( -
-

{topic.topic}

-
- Likely Mood -
+
+

+ {topic.topic} +

+
Likely Mood
{formatEmotion(topic.emotion)}
Strength - {topic.value.toFixed(3)} + + {topic.value.toFixed(3)} +
Posts in Topic @@ -168,6 +237,6 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
); -} +}; export default EmotionalStats; diff --git a/frontend/src/components/InteractionalStats.tsx b/frontend/src/components/InteractionalStats.tsx index 11ab3a2..6216340 100644 --- a/frontend/src/components/InteractionalStats.tsx +++ b/frontend/src/components/InteractionalStats.tsx @@ -24,25 +24,32 @@ type InteractionalStatsProps = { const InteractionalStats = ({ data }: InteractionalStatsProps) => { const graph = data.interaction_graph ?? {}; const userCount = Object.keys(graph).length; - const edges = Object.values(graph).flatMap((targets) => Object.values(targets)); + const edges = Object.values(graph).flatMap((targets) => + Object.values(targets), + ); const edgeCount = edges.length; const interactionVolume = edges.reduce((sum, value) => sum + value, 0); const concentration = data.conversation_concentration; - const topTenCommentShare = typeof concentration?.top_10pct_comment_share === "number" - ? concentration?.top_10pct_comment_share - : null; - const topTenAuthorCount = typeof concentration?.top_10pct_author_count === "number" - ? concentration.top_10pct_author_count - : null; - const totalCommentingAuthors = typeof concentration?.total_commenting_authors === "number" - ? concentration.total_commenting_authors - : null; - const singleCommentAuthorRatio = typeof concentration?.single_comment_author_ratio === "number" - ? concentration.single_comment_author_ratio - : null; - const singleCommentAuthors = typeof concentration?.single_comment_authors === "number" - ? concentration.single_comment_authors - : null; + const topTenCommentShare = + typeof concentration?.top_10pct_comment_share === "number" + ? concentration?.top_10pct_comment_share + : null; + const topTenAuthorCount = + typeof concentration?.top_10pct_author_count === "number" + ? concentration.top_10pct_author_count + : null; + const totalCommentingAuthors = + typeof concentration?.total_commenting_authors === "number" + ? concentration.total_commenting_authors + : null; + const singleCommentAuthorRatio = + typeof concentration?.single_comment_author_ratio === "number" + ? concentration.single_comment_author_ratio + : null; + const singleCommentAuthors = + typeof concentration?.single_comment_authors === "number" + ? concentration.single_comment_authors + : null; const topPairs = (data.top_interaction_pairs ?? []) .filter((item): item is [[string, string], number] => { @@ -53,26 +60,28 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => { const pair = item[0]; const count = item[1]; - return Array.isArray(pair) - && pair.length === 2 - && typeof pair[0] === "string" - && typeof pair[1] === "string" - && typeof count === "number"; + return ( + Array.isArray(pair) && + pair.length === 2 && + typeof pair[0] === "string" && + typeof pair[1] === "string" && + typeof count === "number" + ); }) .slice(0, 20); - const topPairChartData = topPairs.slice(0, 8).map(([[source, target], value], index) => ({ - pair: `${source} -> ${target}`, - replies: value, - rank: index + 1, - })); + const topPairChartData = topPairs + .slice(0, 8) + .map(([[source, target], value], index) => ({ + pair: `${source} -> ${target}`, + replies: value, + rank: index + 1, + })); - const topTenSharePercent = topTenCommentShare === null - ? null - : topTenCommentShare * 100; - const nonTopTenSharePercent = topTenSharePercent === null - ? null - : Math.max(0, 100 - topTenSharePercent); + const topTenSharePercent = + topTenCommentShare === null ? null : topTenCommentShare * 100; + const nonTopTenSharePercent = + topTenSharePercent === null ? null : Math.max(0, 100 - topTenSharePercent); let concentrationPieData: { name: string; value: number }[] = []; if (topTenSharePercent !== null && nonTopTenSharePercent !== null) { @@ -89,12 +98,18 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {

Conversation Overview

-

Who talks to who, and how concentrated the replies are.

+

+ Who talks to who, and how concentrated the replies are. +

@@ -118,31 +133,51 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => { />

Conversation Visuals

-

Main reply links and concentration split.

+

+ Main reply links and concentration split. +

-

Top Interaction Pairs

+

+ Top Interaction Pairs +

- + { width={36} /> - +
-

Top 10% vs Other Comment Share

+

+ Top 10% vs Other Comment Share +

@@ -172,7 +213,10 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => { paddingAngle={2} > {concentrationPieData.map((entry, index) => ( - + ))} @@ -186,15 +230,32 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {

Frequent Reply Paths

-

Most common user-to-user reply paths.

+

+ Most common user-to-user reply paths. +

{!topPairs.length ? ( -
No interaction pair data available.
+
+ No interaction pair data available. +
) : ( -
+
{topPairs.map(([[source, target], value], index) => ( -
${target}-${index}`} style={styles.topUserItem}> -
{source} -> {target}
-
{value.toLocaleString()} replies
+
${target}-${index}`} + style={styles.topUserItem} + > +
+ {source} -> {target} +
+
+ {value.toLocaleString()} replies +
))}
diff --git a/frontend/src/components/LinguisticStats.tsx b/frontend/src/components/LinguisticStats.tsx index 34fdafd..794d118 100644 --- a/frontend/src/components/LinguisticStats.tsx +++ b/frontend/src/components/LinguisticStats.tsx @@ -23,7 +23,9 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {

Language Overview

-

Quick read on how broad and repetitive the wording is.

+

+ Quick read on how broad and repetitive the wording is. +

{ /> @@ -48,11 +52,19 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {

Top Words

Most used single words.

-
+
{topWords.map((item) => (
{item.word}
-
{item.count.toLocaleString()} uses
+
+ {item.count.toLocaleString()} uses +
))}
@@ -61,11 +73,19 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {

Top Bigrams

Most used 2-word phrases.

-
+
{topBigrams.map((item) => (
{item.ngram}
-
{item.count.toLocaleString()} uses
+
+ {item.count.toLocaleString()} uses +
))}
@@ -74,11 +94,19 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {

Top Trigrams

Most used 3-word phrases.

-
+
{topTrigrams.map((item) => (
{item.ngram}
-
{item.count.toLocaleString()} uses
+
+ {item.count.toLocaleString()} uses +
))}
diff --git a/frontend/src/components/SummaryStats.tsx b/frontend/src/components/SummaryStats.tsx index 98f54ad..2c8d53e 100644 --- a/frontend/src/components/SummaryStats.tsx +++ b/frontend/src/components/SummaryStats.tsx @@ -6,32 +6,32 @@ import { YAxis, Tooltip, CartesianGrid, - ResponsiveContainer + ResponsiveContainer, } from "recharts"; import ActivityHeatmap from "../stats/ActivityHeatmap"; -import { ReactWordcloud } from '@cp949/react-wordcloud'; +import { ReactWordcloud } from "@cp949/react-wordcloud"; import StatsStyling from "../styles/stats_styling"; import Card from "../components/Card"; import UserModal from "../components/UserModal"; -import { - type SummaryResponse, - type FrequencyWord, - type UserAnalysisResponse, +import { + type SummaryResponse, + type FrequencyWord, + type UserAnalysisResponse, type TimeAnalysisResponse, type ContentAnalysisResponse, - type User -} from '../types/ApiTypes' + type User, +} from "../types/ApiTypes"; const styles = StatsStyling; type SummaryStatsProps = { - userData: UserAnalysisResponse | null; - timeData: TimeAnalysisResponse | null; - contentData: ContentAnalysisResponse | null; - summary: SummaryResponse | null; -} + userData: UserAnalysisResponse | null; + timeData: TimeAnalysisResponse | null; + contentData: ContentAnalysisResponse | null; + summary: SummaryResponse | null; +}; function formatDateRange(startUnix: number, endUnix: number) { const start = new Date(startUnix * 1000); @@ -48,168 +48,180 @@ function formatDateRange(startUnix: number, endUnix: number) { } function convertFrequencyData(data: FrequencyWord[]) { - return data.map((d: FrequencyWord) => ({ - text: d.word, - value: d.count, - })) + return data.map((d: FrequencyWord) => ({ + text: d.word, + value: d.count, + })); } -const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsProps) => { - const [selectedUser, setSelectedUser] = useState(null); - const selectedUserData: User | null = userData?.users.find((u) => u.author === selectedUser) ?? null; +const SummaryStats = ({ + userData, + timeData, + contentData, + summary, +}: SummaryStatsProps) => { + const [selectedUser, setSelectedUser] = useState(null); + const selectedUserData: User | null = + userData?.users.find((u) => u.author === selectedUser) ?? null; - return ( + return (
+ {/* main grid*/} +
+ + + - {/* main grid*/} -
- - - + - + - - - 3 ? "…" : "") - : "—" - } - style={{ - gridColumn: "span 4" - }} - /> + 3 ? "…" : "") + : "—" + } + style={{ + gridColumn: "span 4", + }} + /> {/* events per day */}
-

Activity Over Time

-

How much posting happened each day.

+

Activity Over Time

+

+ How much posting happened each day. +

-
+
- + - + -
+
{/* Word Cloud */}
-

Common Words

-

Frequently used words across the dataset.

+

Common Words

+

+ Frequently used words across the dataset. +

-
+
-
+
{/* Top Users */} -
-

Most Active Users

-

Who posted the most events.

+

Most Active Users

+

Who posted the most events.

-
+
{userData?.top_users.slice(0, 100).map((item) => ( -
setSelectedUser(item.author)} - > + >
{item.author}
- {item.source} • {item.count} events -
+ {item.source} • {item.count} events
+
))} -
+
{/* Heatmap */}
-

Weekly Activity Pattern

-

When activity tends to happen by weekday and hour.

+

Weekly Activity Pattern

+

+ When activity tends to happen by weekday and hour. +

-
+
-
-
+
+
- setSelectedUser(null)} username={selectedUser ?? ""} userData={selectedUserData} - /> + />
- ); -} + ); +}; export default SummaryStats; diff --git a/frontend/src/components/UserModal.tsx b/frontend/src/components/UserModal.tsx index 682b730..6321804 100644 --- a/frontend/src/components/UserModal.tsx +++ b/frontend/src/components/UserModal.tsx @@ -11,9 +11,15 @@ type Props = { username: string; }; -export default function UserModal({ open, onClose, userData, username }: Props) { - const dominantEmotionEntry = Object.entries(userData?.avg_emotions ?? {}) - .sort((a, b) => b[1] - a[1])[0]; +export default function UserModal({ + open, + onClose, + userData, + username, +}: Props) { + const dominantEmotionEntry = Object.entries( + userData?.avg_emotions ?? {}, + ).sort((a, b) => b[1] - a[1])[0]; return ( @@ -36,7 +42,9 @@ export default function UserModal({ open, onClose, userData, username }: Props)

No data for this user.

) : (
-
{userData.author}
+
+ {userData.author} +
Posts
{userData.post}
@@ -65,7 +73,8 @@ export default function UserModal({ open, onClose, userData, username }: Props)
Vocab Richness
- {userData.vocab.vocab_richness} (avg {userData.vocab.avg_words_per_event} words/event) + {userData.vocab.vocab_richness} (avg{" "} + {userData.vocab.avg_words_per_event} words/event)
) : null} @@ -74,7 +83,8 @@ export default function UserModal({ open, onClose, userData, username }: Props)
Dominant Avg Emotion
- {dominantEmotionEntry[0].replace("emotion_", "")} ({dominantEmotionEntry[1].toFixed(3)}) + {dominantEmotionEntry[0].replace("emotion_", "")} ( + {dominantEmotionEntry[1].toFixed(3)})
) : null} diff --git a/frontend/src/components/UserStats.tsx b/frontend/src/components/UserStats.tsx index b467998..d93063d 100644 --- a/frontend/src/components/UserStats.tsx +++ b/frontend/src/components/UserStats.tsx @@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react"; import ForceGraph3D from "react-force-graph-3d"; import { - type UserAnalysisResponse, - type InteractionGraph -} from '../types/ApiTypes'; + type UserAnalysisResponse, + type InteractionGraph, +} from "../types/ApiTypes"; import StatsStyling from "../styles/stats_styling"; import Card from "./Card"; @@ -12,38 +12,43 @@ import Card from "./Card"; const styles = StatsStyling; type GraphLink = { - source: string; - target: string; - value: number; + source: string; + target: string; + value: number; }; function ApiToGraphData(apiData: InteractionGraph) { - const nodes = Object.keys(apiData).map(username => ({ id: username })); - const links: GraphLink[] = []; - - for (const [source, targets] of Object.entries(apiData)) { - for (const [target, count] of Object.entries(targets)) { - links.push({ source, target, value: count }); - } + const nodes = Object.keys(apiData).map((username) => ({ id: username })); + const links: GraphLink[] = []; + + for (const [source, targets] of Object.entries(apiData)) { + for (const [target, count] of Object.entries(targets)) { + links.push({ source, target, value: count }); } - - // drop low-value and deleted interactions to reduce clutter - const filteredLinks = links.filter(link => - link.value >= 2 && - link.source !== "[deleted]" && - link.target !== "[deleted]" - ); + } - // also filter out nodes that are no longer connected after link filtering - const connectedNodeIds = new Set(filteredLinks.flatMap(link => [link.source, link.target])); - const filteredNodes = nodes.filter(node => connectedNodeIds.has(node.id)); + // drop low-value and deleted interactions to reduce clutter + const filteredLinks = links.filter( + (link) => + link.value >= 2 && + link.source !== "[deleted]" && + link.target !== "[deleted]", + ); - return { nodes: filteredNodes, links: filteredLinks}; + // also filter out nodes that are no longer connected after link filtering + const connectedNodeIds = new Set( + filteredLinks.flatMap((link) => [link.source, link.target]), + ); + const filteredNodes = nodes.filter((node) => connectedNodeIds.has(node.id)); + + return { nodes: filteredNodes, links: filteredLinks }; } - const UserStats = (props: { data: UserAnalysisResponse }) => { - const graphData = useMemo(() => ApiToGraphData(props.data.interaction_graph), [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 }); @@ -63,86 +68,113 @@ const UserStats = (props: { data: UserAnalysisResponse }) => { 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 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 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 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]"); + const mostActiveUser = props.data.top_users.find( + (u) => u.author !== "[deleted]", + ); return (
-
- - - - +
+ + + + - ${strongestLink.target}` : "—"} - sublabel={strongestLink ? `${strongestLink.value.toLocaleString()} replies` : "No graph links after filtering"} - style={{ gridColumn: "span 6" }} - /> - + ${strongestLink.target}` + : "—" + } + sublabel={ + strongestLink + ? `${strongestLink.value.toLocaleString()} replies` + : "No graph links after filtering" + } + style={{ gridColumn: "span 6" }} + /> + -
-

User Interaction Graph

-

- Each node is a user, and each link shows replies between them. -

-
- Math.sqrt(Number(link.value))} - nodeLabel={(node) => `${node.id}`} - /> -
+
+

User Interaction Graph

+

+ Each node is a user, and each link shows replies between them. +

+
+ Math.sqrt(Number(link.value))} + nodeLabel={(node) => `${node.id}`} + />
+
); -} +}; export default UserStats; diff --git a/frontend/src/pages/AutoScrape.tsx b/frontend/src/pages/AutoScrape.tsx index cbea251..9ebfd50 100644 --- a/frontend/src/pages/AutoScrape.tsx +++ b/frontend/src/pages/AutoScrape.tsx @@ -58,8 +58,8 @@ const AutoScrapePage = () => { if (axios.isAxiosError(requestError)) { setReturnMessage( `Failed to load available sources: ${String( - requestError.response?.data?.error || requestError.message - )}` + requestError.response?.data?.error || requestError.message, + )}`, ); } else { setReturnMessage("Failed to load available sources."); @@ -70,15 +70,19 @@ const AutoScrapePage = () => { }); }, []); - const updateSourceConfig = (index: number, field: keyof SourceConfig, value: string) => { + const updateSourceConfig = ( + index: number, + field: keyof SourceConfig, + value: string, + ) => { setSourceConfigs((previous) => previous.map((config, configIndex) => configIndex === index ? field === "sourceName" ? { ...config, sourceName: value, search: "", category: "" } : { ...config, [field]: value } - : config - ) + : config, + ), ); }; @@ -93,7 +97,9 @@ const AutoScrapePage = () => { }; const removeSourceConfig = (index: number) => { - setSourceConfigs((previous) => previous.filter((_, configIndex) => configIndex !== index)); + setSourceConfigs((previous) => + previous.filter((_, configIndex) => configIndex !== index), + ); }; const autoScrape = async () => { @@ -123,7 +129,9 @@ const AutoScrapePage = () => { return { name: source.sourceName, limit: Number(source.limit || 100), - search: supportsSearch(sourceOption) ? source.search.trim() || undefined : undefined, + search: supportsSearch(sourceOption) + ? source.search.trim() || undefined + : undefined, category: supportsCategories(sourceOption) ? source.category.trim() || undefined : undefined, @@ -131,12 +139,15 @@ const AutoScrapePage = () => { }); const invalidSource = normalizedSources.find( - (source) => !source.name || !Number.isFinite(source.limit) || source.limit <= 0 + (source) => + !source.name || !Number.isFinite(source.limit) || source.limit <= 0, ); if (invalidSource) { setHasError(true); - setReturnMessage("Every source needs a name and a limit greater than zero."); + setReturnMessage( + "Every source needs a name and a limit greater than zero.", + ); return; } @@ -155,13 +166,13 @@ const AutoScrapePage = () => { headers: { Authorization: `Bearer ${token}`, }, - } + }, ); const datasetId = Number(response.data.dataset_id); setReturnMessage( - `Auto scrape queued successfully (dataset #${datasetId}). Redirecting to processing status...` + `Auto scrape queued successfully (dataset #${datasetId}). Redirecting to processing status...`, ); setTimeout(() => { @@ -171,7 +182,9 @@ const AutoScrapePage = () => { setHasError(true); if (axios.isAxiosError(requestError)) { const message = String( - requestError.response?.data?.error || requestError.message || "Auto scrape failed." + requestError.response?.data?.error || + requestError.message || + "Auto scrape failed.", ); setReturnMessage(`Auto scrape failed: ${message}`); } else { @@ -189,15 +202,26 @@ const AutoScrapePage = () => {

Auto Scrape Dataset

- Select sources and scrape settings, then queue processing automatically. + Select sources and scrape settings, then queue processing + automatically.

-

- Warning: Scraping more than 250 posts from any single site can take hours due to rate limits. +

+ Warning: Scraping more than 250 posts from any single site can + take hours due to rate limits.

+ )} +
); })} -
diff --git a/frontend/src/pages/DatasetEdit.tsx b/frontend/src/pages/DatasetEdit.tsx index fb92603..798ec35 100644 --- a/frontend/src/pages/DatasetEdit.tsx +++ b/frontend/src/pages/DatasetEdit.tsx @@ -51,7 +51,9 @@ const DatasetEditPage = () => { .catch((error: unknown) => { setHasError(true); if (axios.isAxiosError(error)) { - setStatusMessage(String(error.response?.data?.error || error.message)); + setStatusMessage( + String(error.response?.data?.error || error.message), + ); } else { setStatusMessage("Could not get dataset info."); } @@ -61,7 +63,6 @@ const DatasetEditPage = () => { }); }, [parsedDatasetId]); - const saveDatasetName = async (event: FormEvent) => { event.preventDefault(); @@ -87,14 +88,18 @@ const DatasetEditPage = () => { await axios.patch( `${API_BASE_URL}/dataset/${parsedDatasetId}`, { name: trimmedName }, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${token}` } }, ); navigate("/datasets", { replace: true }); } catch (error: unknown) { setHasError(true); if (axios.isAxiosError(error)) { - setStatusMessage(String(error.response?.data?.error || error.message || "Save failed.")); + setStatusMessage( + String( + error.response?.data?.error || error.message || "Save failed.", + ), + ); } else { setStatusMessage("Save failed due to an unexpected error."); } @@ -117,17 +122,20 @@ const DatasetEditPage = () => { setHasError(false); setStatusMessage(""); - await axios.delete( - `${API_BASE_URL}/dataset/${parsedDatasetId}`, - { headers: { Authorization: `Bearer ${deleteToken}` } } - ); + await axios.delete(`${API_BASE_URL}/dataset/${parsedDatasetId}`, { + headers: { Authorization: `Bearer ${deleteToken}` }, + }); setIsDeleteModalOpen(false); navigate("/datasets", { replace: true }); } catch (error: unknown) { setHasError(true); if (axios.isAxiosError(error)) { - setStatusMessage(String(error.response?.data?.error || error.message || "Delete failed.")); + setStatusMessage( + String( + error.response?.data?.error || error.message || "Delete failed.", + ), + ); } else { setStatusMessage("Delete failed due to an unexpected error."); } @@ -142,7 +150,9 @@ const DatasetEditPage = () => {

Edit Dataset

-

Update the dataset name shown in your datasets list.

+

+ Update the dataset name shown in your datasets list. +

@@ -173,8 +183,8 @@ const DatasetEditPage = () => { style={styles.buttonDanger} onClick={() => setIsDeleteModalOpen(true)} disabled={isSaving || isDeleting} - > - Delete Dataset + > + Delete Dataset - {loading - ? "Loading dataset details..." - : statusMessage} + {loading ? "Loading dataset details..." : statusMessage}
diff --git a/frontend/src/pages/DatasetStatus.tsx b/frontend/src/pages/DatasetStatus.tsx index 331a470..74d2933 100644 --- a/frontend/src/pages/DatasetStatus.tsx +++ b/frontend/src/pages/DatasetStatus.tsx @@ -3,7 +3,7 @@ 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 +const API_BASE_URL = import.meta.env.VITE_BACKEND_URL; type DatasetStatusResponse = { status?: "fetching" | "processing" | "complete" | "error"; @@ -17,7 +17,8 @@ const DatasetStatusPage = () => { const navigate = useNavigate(); const { datasetId } = useParams<{ datasetId: string }>(); const [loading, setLoading] = useState(true); - const [status, setStatus] = useState("processing"); + const [status, setStatus] = + useState("processing"); const [statusMessage, setStatusMessage] = useState(""); const parsedDatasetId = useMemo(() => Number(datasetId), [datasetId]); @@ -34,7 +35,7 @@ const DatasetStatusPage = () => { const pollStatus = async () => { try { const response = await axios.get( - `${API_BASE_URL}/dataset/${parsedDatasetId}/status` + `${API_BASE_URL}/dataset/${parsedDatasetId}/status`, ); const nextStatus = response.data.status ?? "processing"; @@ -51,7 +52,9 @@ const DatasetStatusPage = () => { setLoading(false); setStatus("error"); if (axios.isAxiosError(error)) { - const message = String(error.response?.data?.error || error.message || "Request failed"); + const message = String( + error.response?.data?.error || error.message || "Request failed", + ); setStatusMessage(message); } else { setStatusMessage("Unable to fetch dataset status."); @@ -73,7 +76,8 @@ const DatasetStatusPage = () => { }; }, [navigate, parsedDatasetId, status]); - const isProcessing = loading || status === "fetching" || status === "processing"; + const isProcessing = + loading || status === "fetching" || status === "processing"; const isError = status === "error"; return ( @@ -81,26 +85,37 @@ const DatasetStatusPage = () => {

- {isProcessing ? "Processing dataset..." : isError ? "Dataset processing failed" : "Dataset ready"} + {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..."} + {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.")} + {statusMessage || + (isProcessing + ? "Waiting for updates from the worker queue..." + : "No details provided.")}
diff --git a/frontend/src/pages/Datasets.tsx b/frontend/src/pages/Datasets.tsx index d06d32a..90ad7d4 100644 --- a/frontend/src/pages/Datasets.tsx +++ b/frontend/src/pages/Datasets.tsx @@ -39,7 +39,9 @@ const DatasetsPage = () => { }) .catch((requestError: unknown) => { if (axios.isAxiosError(requestError)) { - setError(String(requestError.response?.data?.error || requestError.message)); + setError( + String(requestError.response?.data?.error || requestError.message), + ); } else { setError("Failed to load datasets."); } @@ -61,13 +63,28 @@ const DatasetsPage = () => {
-
-
-
+
+
+
- ) + ); } return ( @@ -81,7 +98,11 @@ const DatasetsPage = () => {

- - } + )} - - - {error && ( -

- {error} -

- )} - - {info && ( -

- {info} -

- )} - -
- - {isRegisterMode ? "Already have an account?" : "New here?"} - - -
+
+
+

+ {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?"} + + +
+
); }; diff --git a/frontend/src/pages/Stats.tsx b/frontend/src/pages/Stats.tsx index 408cfa2..874003e 100644 --- a/frontend/src/pages/Stats.tsx +++ b/frontend/src/pages/Stats.tsx @@ -9,47 +9,59 @@ import LinguisticStats from "../components/LinguisticStats"; import InteractionalStats from "../components/InteractionalStats"; import CulturalStats from "../components/CulturalStats"; -import { - type SummaryResponse, - type UserAnalysisResponse, +import { + type SummaryResponse, + type UserAnalysisResponse, type TimeAnalysisResponse, type ContentAnalysisResponse, type UserEndpointResponse, type LinguisticAnalysisResponse, type EmotionalAnalysisResponse, type InteractionAnalysisResponse, - type CulturalAnalysisResponse -} from '../types/ApiTypes' + type CulturalAnalysisResponse, +} from "../types/ApiTypes"; -const API_BASE_URL = import.meta.env.VITE_BACKEND_URL +const API_BASE_URL = import.meta.env.VITE_BACKEND_URL; const styles = StatsStyling; const DELETED_USERS = ["[deleted]"]; -const isDeletedUser = (value: string | null | undefined) => ( - DELETED_USERS.includes((value ?? "").trim().toLowerCase()) -); +const isDeletedUser = (value: string | null | undefined) => + DELETED_USERS.includes((value ?? "").trim().toLowerCase()); const StatPage = () => { const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>(); - const [error, setError] = useState(''); + const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const [activeView, setActiveView] = useState<"summary" | "emotional" | "user" | "linguistic" | "interactional" | "cultural">("summary"); + const [activeView, setActiveView] = useState< + | "summary" + | "emotional" + | "user" + | "linguistic" + | "interactional" + | "cultural" + >("summary"); const [userData, setUserData] = useState(null); const [timeData, setTimeData] = useState(null); - const [contentData, setContentData] = useState(null); - const [linguisticData, setLinguisticData] = useState(null); - const [interactionData, setInteractionData] = useState(null); - const [culturalData, setCulturalData] = useState(null); + const [contentData, setContentData] = + useState(null); + const [linguisticData, setLinguisticData] = + useState(null); + const [interactionData, setInteractionData] = + useState(null); + const [culturalData, setCulturalData] = + useState(null); const [summary, setSummary] = useState(null); - const searchInputRef = useRef(null); const beforeDateRef = useRef(null); const afterDateRef = useRef(null); const parsedDatasetId = Number(routeDatasetId ?? ""); - const datasetId = Number.isInteger(parsedDatasetId) && parsedDatasetId > 0 ? parsedDatasetId : null; + const datasetId = + Number.isInteger(parsedDatasetId) && parsedDatasetId > 0 + ? parsedDatasetId + : null; const getFilterParams = () => { const params: Record = {}; @@ -99,112 +111,147 @@ const StatPage = () => { setLoading(true); Promise.all([ - axios.get(`${API_BASE_URL}/dataset/${datasetId}/temporal`, { - params, - headers: authHeaders, - }), - axios.get(`${API_BASE_URL}/dataset/${datasetId}/user`, { - params, - headers: authHeaders, - }), - axios.get(`${API_BASE_URL}/dataset/${datasetId}/linguistic`, { - params, - headers: authHeaders, - }), - axios.get(`${API_BASE_URL}/dataset/${datasetId}/emotional`, { - params, - headers: authHeaders, - }), - axios.get(`${API_BASE_URL}/dataset/${datasetId}/interactional`, { - params, - headers: authHeaders, - }), - axios.get(`${API_BASE_URL}/dataset/${datasetId}/summary`, { - params, - headers: authHeaders, - }), - axios.get(`${API_BASE_URL}/dataset/${datasetId}/cultural`, { - params, - headers: authHeaders, - }), - ]) - .then(([timeRes, userRes, linguisticRes, emotionalRes, interactionRes, summaryRes, culturalRes]) => { - const usersList = userRes.data.users ?? []; - const topUsersList = userRes.data.top_users ?? []; - const interactionGraphRaw = interactionRes.data?.interaction_graph ?? {}; - const topPairsRaw = interactionRes.data?.top_interaction_pairs ?? []; + axios.get( + `${API_BASE_URL}/dataset/${datasetId}/temporal`, + { + params, + headers: authHeaders, + }, + ), + axios.get( + `${API_BASE_URL}/dataset/${datasetId}/user`, + { + params, + headers: authHeaders, + }, + ), + axios.get( + `${API_BASE_URL}/dataset/${datasetId}/linguistic`, + { + params, + headers: authHeaders, + }, + ), + axios.get( + `${API_BASE_URL}/dataset/${datasetId}/emotional`, + { + params, + headers: authHeaders, + }, + ), + axios.get( + `${API_BASE_URL}/dataset/${datasetId}/interactional`, + { + params, + headers: authHeaders, + }, + ), + axios.get( + `${API_BASE_URL}/dataset/${datasetId}/summary`, + { + params, + headers: authHeaders, + }, + ), + axios.get( + `${API_BASE_URL}/dataset/${datasetId}/cultural`, + { + params, + headers: authHeaders, + }, + ), + ]) + .then( + ([ + timeRes, + userRes, + linguisticRes, + emotionalRes, + interactionRes, + summaryRes, + culturalRes, + ]) => { + const usersList = userRes.data.users ?? []; + const topUsersList = userRes.data.top_users ?? []; + const interactionGraphRaw = + interactionRes.data?.interaction_graph ?? {}; + const topPairsRaw = interactionRes.data?.top_interaction_pairs ?? []; - const filteredUsers: typeof usersList = []; - for (const user of usersList) { - if (isDeletedUser(user.author)) continue; - filteredUsers.push(user); - } - - const filteredTopUsers: typeof topUsersList = []; - for (const user of topUsersList) { - if (isDeletedUser(user.author)) continue; - filteredTopUsers.push(user); - } - - const filteredInteractionGraph: Record> = {}; - for (const [source, targets] of Object.entries(interactionGraphRaw)) { - if (isDeletedUser(source)) { - continue; + const filteredUsers: typeof usersList = []; + for (const user of usersList) { + if (isDeletedUser(user.author)) continue; + filteredUsers.push(user); } - const nextTargets: Record = {}; - for (const [target, count] of Object.entries(targets)) { - if (isDeletedUser(target)) { + const filteredTopUsers: typeof topUsersList = []; + for (const user of topUsersList) { + if (isDeletedUser(user.author)) continue; + filteredTopUsers.push(user); + } + + const filteredInteractionGraph: Record< + string, + Record + > = {}; + for (const [source, targets] of Object.entries(interactionGraphRaw)) { + if (isDeletedUser(source)) { continue; } - nextTargets[target] = count; + + const nextTargets: Record = {}; + for (const [target, count] of Object.entries(targets)) { + if (isDeletedUser(target)) { + continue; + } + nextTargets[target] = count; + } + + filteredInteractionGraph[source] = nextTargets; } - filteredInteractionGraph[source] = nextTargets; - } - - const filteredTopInteractionPairs: typeof topPairsRaw = []; - for (const pairEntry of topPairsRaw) { - const pair = pairEntry[0]; - const source = pair[0]; - const target = pair[1]; - if (isDeletedUser(source) || isDeletedUser(target)) { - continue; + const filteredTopInteractionPairs: typeof topPairsRaw = []; + for (const pairEntry of topPairsRaw) { + const pair = pairEntry[0]; + const source = pair[0]; + const target = pair[1]; + if (isDeletedUser(source) || isDeletedUser(target)) { + continue; + } + filteredTopInteractionPairs.push(pairEntry); } - filteredTopInteractionPairs.push(pairEntry); - } - const combinedUserData: UserAnalysisResponse = { - ...userRes.data, - users: filteredUsers, - top_users: filteredTopUsers, - interaction_graph: filteredInteractionGraph, - }; + const combinedUserData: UserAnalysisResponse = { + ...userRes.data, + users: filteredUsers, + top_users: filteredTopUsers, + interaction_graph: filteredInteractionGraph, + }; - const combinedContentData: ContentAnalysisResponse = { - ...linguisticRes.data, - ...emotionalRes.data, - }; + const combinedContentData: ContentAnalysisResponse = { + ...linguisticRes.data, + ...emotionalRes.data, + }; - const filteredInteractionData: InteractionAnalysisResponse = { - ...interactionRes.data, - interaction_graph: filteredInteractionGraph, - top_interaction_pairs: filteredTopInteractionPairs, - }; + const filteredInteractionData: InteractionAnalysisResponse = { + ...interactionRes.data, + interaction_graph: filteredInteractionGraph, + top_interaction_pairs: filteredTopInteractionPairs, + }; - const filteredSummary: SummaryResponse = { - ...summaryRes.data, - unique_users: filteredUsers.length, - }; + const filteredSummary: SummaryResponse = { + ...summaryRes.data, + unique_users: filteredUsers.length, + }; - setUserData(combinedUserData); - setTimeData(timeRes.data || null); - setContentData(combinedContentData); - setLinguisticData(linguisticRes.data || null); - setInteractionData(filteredInteractionData || null); - setCulturalData(culturalRes.data || null); - setSummary(filteredSummary || null); - }) + setUserData(combinedUserData); + setTimeData(timeRes.data || null); + setContentData(combinedContentData); + setLinguisticData(linguisticRes.data || null); + setInteractionData(filteredInteractionData || null); + setCulturalData(culturalRes.data || null); + setSummary(filteredSummary || null); + }, + ) .catch((e) => setError("Failed to load statistics: " + String(e))) .finally(() => setLoading(false)); }; @@ -233,7 +280,7 @@ const StatPage = () => { return; } getStats(); - }, [datasetId]) + }, [datasetId]); if (loading) { return ( @@ -243,155 +290,199 @@ const StatPage = () => {

Loading analytics

-

Fetching summary, timeline, user, and content insights.

+

+ Fetching summary, timeline, user, and content insights. +

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

{error}

; + if (error) return

{error}

; -return ( -
-
-
- + return ( +
+
+
+ - + - + /> - + - -
- -
Analytics Dashboard
-
Dataset #{datasetId ?? "-"}
+
-
- - +
Analytics Dashboard
+
Dataset #{datasetId ?? "-"}
+
- - - - + + + + + + + +
+ + {activeView === "summary" && ( + + )} + + {activeView === "emotional" && contentData && ( + + )} + + {activeView === "emotional" && !contentData && ( +
+ No emotional data available. +
+ )} + + {activeView === "user" && userData && } + + {activeView === "linguistic" && linguisticData && ( + + )} + + {activeView === "linguistic" && !linguisticData && ( +
+ No linguistic data available. +
+ )} + + {activeView === "interactional" && interactionData && ( + + )} + + {activeView === "interactional" && !interactionData && ( +
+ No interactional data available. +
+ )} + + {activeView === "cultural" && culturalData && ( + + )} + + {activeView === "cultural" && !culturalData && ( +
+ No cultural data available. +
+ )}
- - {activeView === "summary" && ( - - )} - - {activeView === "emotional" && contentData && ( - - )} - - {activeView === "emotional" && !contentData && ( -
- No emotional data available. -
- )} - - {activeView === "user" && userData && ( - - )} - - {activeView === "linguistic" && linguisticData && ( - - )} - - {activeView === "linguistic" && !linguisticData && ( -
- No linguistic data available. -
- )} - - {activeView === "interactional" && interactionData && ( - - )} - - {activeView === "interactional" && !interactionData && ( -
- No interactional data available. -
- )} - - {activeView === "cultural" && culturalData && ( - - )} - - {activeView === "cultural" && !culturalData && ( -
- No cultural data available. -
- )} - -
-); -} + ); +}; export default StatPage; diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx index 0799f9b..41a8c9d 100644 --- a/frontend/src/pages/Upload.tsx +++ b/frontend/src/pages/Upload.tsx @@ -4,7 +4,7 @@ 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 API_BASE_URL = import.meta.env.VITE_BACKEND_URL; const UploadPage = () => { const [datasetName, setDatasetName] = useState(""); @@ -40,16 +40,20 @@ const UploadPage = () => { setHasError(false); setReturnMessage(""); - const response = await axios.post(`${API_BASE_URL}/datasets/upload`, formData, { - headers: { - "Content-Type": "multipart/form-data", + const response = await axios.post( + `${API_BASE_URL}/datasets/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...` + `Upload queued successfully (dataset #${datasetId}). Redirecting to processing status...`, ); setTimeout(() => { @@ -58,7 +62,9 @@ const UploadPage = () => { } catch (error: unknown) { setHasError(true); if (axios.isAxiosError(error)) { - const message = String(error.response?.data?.error || error.message || "Upload failed."); + const message = String( + error.response?.data?.error || error.message || "Upload failed.", + ); setReturnMessage(`Upload failed: ${message}`); } else { setReturnMessage("Upload failed due to an unexpected error."); @@ -75,12 +81,16 @@ const UploadPage = () => {

Upload Dataset

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

@@ -143,7 +169,8 @@ const UploadPage = () => { ...(hasError ? styles.alertCardError : styles.alertCardInfo), }} > - {returnMessage || "After upload, your dataset is queued for processing and you'll land on stats."} + {returnMessage || + "After upload, your dataset is queued for processing and you'll land on stats."}
diff --git a/frontend/src/stats/ActivityHeatmap.tsx b/frontend/src/stats/ActivityHeatmap.tsx index ddc1674..73398e9 100644 --- a/frontend/src/stats/ActivityHeatmap.tsx +++ b/frontend/src/stats/ActivityHeatmap.tsx @@ -25,8 +25,7 @@ const DAYS = [ "Sunday", ]; -const hourLabel = (h: number) => - `${h.toString().padStart(2, "0")}:00`; +const hourLabel = (h: number) => `${h.toString().padStart(2, "0")}:00`; const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => { return dataset.map((dayData, index) => ({ @@ -40,32 +39,29 @@ const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => { })); }; - const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => { - const convertedData = convertWeeklyData(data); + const convertedData = convertWeeklyData(data); - const maxValue = Math.max( - ...convertedData.flatMap(day => - day.data.map(point => point.y) - ) + const maxValue = Math.max( + ...convertedData.flatMap((day) => day.data.map((point) => point.y)), ); - return ( - - ) -} + return ( + + ); +}; -export default ActivityHeatmap; \ No newline at end of file +export default ActivityHeatmap; diff --git a/frontend/src/types/ApiTypes.ts b/frontend/src/types/ApiTypes.ts index 7a0b521..eb7ed4a 100644 --- a/frontend/src/types/ApiTypes.ts +++ b/frontend/src/types/ApiTypes.ts @@ -17,7 +17,7 @@ type Emotion = { emotion_sadness: number; }; -// User +// User type TopUser = { author: string; source: string; @@ -57,7 +57,7 @@ type UserAnalysisResponse = { interaction_graph: InteractionGraph; }; -// Time +// Time type EventsPerDay = { date: Date; count: number; @@ -125,7 +125,7 @@ type EmotionalAnalysisResponse = { emotion_by_source?: EmotionBySource[]; }; -// Interactional +// Interactional type ConversationConcentration = { total_commenting_authors: number; top_10pct_author_count: number; @@ -180,7 +180,7 @@ type CulturalAnalysisResponse = { avg_emotion_per_entity?: AverageEmotionPerEntity; }; -// Summary +// Summary type SummaryResponse = { total_events: number; total_posts: number; @@ -195,7 +195,7 @@ type SummaryResponse = { sources: string[]; }; -// Filter +// Filter type FilterResponse = { rows: number; data: any; diff --git a/frontend/src/utils/documentTitle.ts b/frontend/src/utils/documentTitle.ts index 5c7d00d..7452481 100644 --- a/frontend/src/utils/documentTitle.ts +++ b/frontend/src/utils/documentTitle.ts @@ -13,7 +13,7 @@ export const getDocumentTitle = (pathname: string) => { } if (pathname.includes("stats")) { - return "Ethnography Analysis" + return "Ethnography Analysis"; } return STATIC_TITLES[pathname] ?? DEFAULT_TITLE;