-
-
Posts File
- postFile = e.target.files?.[0]}>
-
-
-
Topic Buckets File
- topicBucketFile = e.target.files?.[0]}>
-
-
Upload
+ 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.
+
+
+
+ {isSubmitting ? "Uploading..." : "Upload and Analyze"}
+
+
+
+
+
+
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,