feat(frontend): implement corpus explorer
This allows you to view the posts & comments associated with a specific aggregate.
This commit is contained in:
@@ -22,12 +22,10 @@ const DatasetEditPage = () => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const [datasetName, setDatasetName] = useState("");
|
||||
useEffect(() => {
|
||||
if (!Number.isInteger(parsedDatasetId) || parsedDatasetId <= 0) {
|
||||
setHasError(true);
|
||||
setStatusMessage("Invalid dataset id.");
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -35,7 +33,6 @@ const DatasetEditPage = () => {
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (!token) {
|
||||
setHasError(true);
|
||||
setStatusMessage("You must be signed in to edit datasets.");
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -49,7 +46,6 @@ const DatasetEditPage = () => {
|
||||
setDatasetName(response.data.name || "");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setHasError(true);
|
||||
if (axios.isAxiosError(error)) {
|
||||
setStatusMessage(
|
||||
String(error.response?.data?.error || error.message),
|
||||
@@ -68,21 +64,18 @@ const DatasetEditPage = () => {
|
||||
|
||||
const trimmedName = datasetName.trim();
|
||||
if (!trimmedName) {
|
||||
setHasError(true);
|
||||
setStatusMessage("Please enter a valid dataset name.");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (!token) {
|
||||
setHasError(true);
|
||||
setStatusMessage("You must be signed in to save changes.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setHasError(false);
|
||||
setStatusMessage("");
|
||||
|
||||
await axios.patch(
|
||||
@@ -93,7 +86,6 @@ const DatasetEditPage = () => {
|
||||
|
||||
navigate("/datasets", { replace: true });
|
||||
} catch (error: unknown) {
|
||||
setHasError(true);
|
||||
if (axios.isAxiosError(error)) {
|
||||
setStatusMessage(
|
||||
String(
|
||||
@@ -111,7 +103,6 @@ const DatasetEditPage = () => {
|
||||
const deleteDataset = async () => {
|
||||
const deleteToken = localStorage.getItem("access_token");
|
||||
if (!deleteToken) {
|
||||
setHasError(true);
|
||||
setStatusMessage("You must be signed in to delete datasets.");
|
||||
setIsDeleteModalOpen(false);
|
||||
return;
|
||||
@@ -119,7 +110,6 @@ const DatasetEditPage = () => {
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
setHasError(false);
|
||||
setStatusMessage("");
|
||||
|
||||
await axios.delete(`${API_BASE_URL}/dataset/${parsedDatasetId}`, {
|
||||
@@ -129,7 +119,6 @@ const DatasetEditPage = () => {
|
||||
setIsDeleteModalOpen(false);
|
||||
navigate("/datasets", { replace: true });
|
||||
} catch (error: unknown) {
|
||||
setHasError(true);
|
||||
if (axios.isAxiosError(error)) {
|
||||
setStatusMessage(
|
||||
String(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useParams } from "react-router-dom";
|
||||
import StatsStyling from "../styles/stats_styling";
|
||||
@@ -8,6 +8,7 @@ import UserStats from "../components/UserStats";
|
||||
import LinguisticStats from "../components/LinguisticStats";
|
||||
import InteractionalStats from "../components/InteractionalStats";
|
||||
import CulturalStats from "../components/CulturalStats";
|
||||
import CorpusExplorer from "../components/CorpusExplorer";
|
||||
|
||||
import {
|
||||
type SummaryResponse,
|
||||
@@ -19,10 +20,15 @@ import {
|
||||
type InteractionAnalysisResponse,
|
||||
type CulturalAnalysisResponse,
|
||||
} from "../types/ApiTypes";
|
||||
import {
|
||||
buildExplorerContext,
|
||||
type CorpusExplorerSpec,
|
||||
type DatasetRecord,
|
||||
} from "../utils/corpusExplorer";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
const styles = StatsStyling;
|
||||
const DELETED_USERS = ["[deleted]"];
|
||||
const DELETED_USERS = ["[deleted]", "automoderator"];
|
||||
|
||||
const isDeletedUser = (value: string | null | undefined) =>
|
||||
DELETED_USERS.includes((value ?? "").trim().toLowerCase());
|
||||
@@ -40,6 +46,97 @@ type UserStatsMeta = {
|
||||
mostCommentHeavyUser: { author: string; commentShare: number } | null;
|
||||
};
|
||||
|
||||
type ExplorerState = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
emptyMessage: string;
|
||||
records: DatasetRecord[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
const EMPTY_EXPLORER_STATE: ExplorerState = {
|
||||
open: false,
|
||||
title: "Corpus Explorer",
|
||||
description: "",
|
||||
emptyMessage: "No records found.",
|
||||
records: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
};
|
||||
|
||||
const normalizeRecordPayload = (payload: unknown): DatasetRecord[] => {
|
||||
if (typeof payload === "string") {
|
||||
try {
|
||||
return normalizeRecordPayload(JSON.parse(payload));
|
||||
} catch {
|
||||
throw new Error("Corpus endpoint returned a non-JSON string payload.");
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"error" in payload &&
|
||||
typeof (payload as { error?: unknown }).error === "string"
|
||||
) {
|
||||
throw new Error((payload as { error: string }).error);
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as DatasetRecord[];
|
||||
}
|
||||
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"data" in payload &&
|
||||
Array.isArray((payload as { data?: unknown }).data)
|
||||
) {
|
||||
return (payload as { data: DatasetRecord[] }).data;
|
||||
}
|
||||
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"records" in payload &&
|
||||
Array.isArray((payload as { records?: unknown }).records)
|
||||
) {
|
||||
return (payload as { records: DatasetRecord[] }).records;
|
||||
}
|
||||
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"rows" in payload &&
|
||||
Array.isArray((payload as { rows?: unknown }).rows)
|
||||
) {
|
||||
return (payload as { rows: DatasetRecord[] }).rows;
|
||||
}
|
||||
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"result" in payload &&
|
||||
Array.isArray((payload as { result?: unknown }).result)
|
||||
) {
|
||||
return (payload as { result: DatasetRecord[] }).result;
|
||||
}
|
||||
|
||||
if (payload && typeof payload === "object") {
|
||||
const values = Object.values(payload);
|
||||
if (values.length === 1 && Array.isArray(values[0])) {
|
||||
return values[0] as DatasetRecord[];
|
||||
}
|
||||
if (values.every((value) => value && typeof value === "object")) {
|
||||
return values as DatasetRecord[];
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Corpus endpoint returned an unexpected payload.");
|
||||
};
|
||||
|
||||
const StatPage = () => {
|
||||
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
|
||||
const [error, setError] = useState("");
|
||||
@@ -61,6 +158,12 @@ const StatPage = () => {
|
||||
totalUsers: 0,
|
||||
mostCommentHeavyUser: null,
|
||||
});
|
||||
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>({});
|
||||
const [allRecords, setAllRecords] = useState<DatasetRecord[] | null>(null);
|
||||
const [allRecordsKey, setAllRecordsKey] = useState("");
|
||||
const [explorerState, setExplorerState] = useState<ExplorerState>(
|
||||
EMPTY_EXPLORER_STATE,
|
||||
);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const beforeDateRef = useRef<HTMLInputElement>(null);
|
||||
@@ -104,6 +207,82 @@ const StatPage = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const getFilterKey = (params: Record<string, string>) =>
|
||||
JSON.stringify(Object.entries(params).sort(([a], [b]) => a.localeCompare(b)));
|
||||
|
||||
const ensureFilteredRecords = async () => {
|
||||
if (!datasetId) {
|
||||
throw new Error("Missing dataset id.");
|
||||
}
|
||||
|
||||
const authHeaders = getAuthHeaders();
|
||||
if (!authHeaders) {
|
||||
throw new Error("You must be signed in to load corpus records.");
|
||||
}
|
||||
|
||||
const filterKey = getFilterKey(appliedFilters);
|
||||
if (allRecords && allRecordsKey === filterKey) {
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
const response = await axios.get<unknown>(
|
||||
`${API_BASE_URL}/dataset/${datasetId}/all`,
|
||||
{
|
||||
params: appliedFilters,
|
||||
headers: authHeaders,
|
||||
},
|
||||
);
|
||||
|
||||
const normalizedRecords = normalizeRecordPayload(response.data);
|
||||
|
||||
setAllRecords(normalizedRecords);
|
||||
setAllRecordsKey(filterKey);
|
||||
return normalizedRecords;
|
||||
};
|
||||
|
||||
const openExplorer = async (spec: CorpusExplorerSpec) => {
|
||||
setExplorerState({
|
||||
open: true,
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
emptyMessage: spec.emptyMessage ?? "No matching records found.",
|
||||
records: [],
|
||||
loading: true,
|
||||
error: "",
|
||||
});
|
||||
|
||||
try {
|
||||
const records = await ensureFilteredRecords();
|
||||
const context = buildExplorerContext(records);
|
||||
const matched = records.filter((record) => spec.matcher(record, context));
|
||||
matched.sort((a, b) => {
|
||||
const aValue = String(a.dt ?? a.date ?? a.timestamp ?? "");
|
||||
const bValue = String(b.dt ?? b.date ?? b.timestamp ?? "");
|
||||
return bValue.localeCompare(aValue);
|
||||
});
|
||||
|
||||
setExplorerState({
|
||||
open: true,
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
emptyMessage: spec.emptyMessage ?? "No matching records found.",
|
||||
records: matched,
|
||||
loading: false,
|
||||
error: "",
|
||||
});
|
||||
} catch (e) {
|
||||
setExplorerState({
|
||||
open: true,
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
emptyMessage: spec.emptyMessage ?? "No matching records found.",
|
||||
records: [],
|
||||
loading: false,
|
||||
error: `Failed to load corpus records: ${String(e)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStats = (params: Record<string, string> = {}) => {
|
||||
if (!datasetId) {
|
||||
setError("Missing dataset id. Open /dataset/<id>/stats.");
|
||||
@@ -118,22 +297,20 @@ const StatPage = () => {
|
||||
|
||||
setError("");
|
||||
setLoading(true);
|
||||
setAppliedFilters(params);
|
||||
setAllRecords(null);
|
||||
setAllRecordsKey("");
|
||||
setExplorerState((current) => ({ ...current, open: false }));
|
||||
|
||||
Promise.all([
|
||||
axios.get<TimeAnalysisResponse>(
|
||||
`${API_BASE_URL}/dataset/${datasetId}/temporal`,
|
||||
{
|
||||
params,
|
||||
headers: authHeaders,
|
||||
},
|
||||
),
|
||||
axios.get<UserEndpointResponse>(
|
||||
`${API_BASE_URL}/dataset/${datasetId}/user`,
|
||||
{
|
||||
params,
|
||||
headers: authHeaders,
|
||||
},
|
||||
),
|
||||
axios.get<TimeAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/temporal`, {
|
||||
params,
|
||||
headers: authHeaders,
|
||||
}),
|
||||
axios.get<UserEndpointResponse>(`${API_BASE_URL}/dataset/${datasetId}/user`, {
|
||||
params,
|
||||
headers: authHeaders,
|
||||
}),
|
||||
axios.get<LinguisticAnalysisResponse>(
|
||||
`${API_BASE_URL}/dataset/${datasetId}/linguistic`,
|
||||
{
|
||||
@@ -141,13 +318,10 @@ const StatPage = () => {
|
||||
headers: authHeaders,
|
||||
},
|
||||
),
|
||||
axios.get<EmotionalAnalysisResponse>(
|
||||
`${API_BASE_URL}/dataset/${datasetId}/emotional`,
|
||||
{
|
||||
params,
|
||||
headers: authHeaders,
|
||||
},
|
||||
),
|
||||
axios.get<EmotionalAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/emotional`, {
|
||||
params,
|
||||
headers: authHeaders,
|
||||
}),
|
||||
axios.get<InteractionAnalysisResponse>(
|
||||
`${API_BASE_URL}/dataset/${datasetId}/interactional`,
|
||||
{
|
||||
@@ -155,20 +329,14 @@ const StatPage = () => {
|
||||
headers: authHeaders,
|
||||
},
|
||||
),
|
||||
axios.get<SummaryResponse>(
|
||||
`${API_BASE_URL}/dataset/${datasetId}/summary`,
|
||||
{
|
||||
params,
|
||||
headers: authHeaders,
|
||||
},
|
||||
),
|
||||
axios.get<CulturalAnalysisResponse>(
|
||||
`${API_BASE_URL}/dataset/${datasetId}/cultural`,
|
||||
{
|
||||
params,
|
||||
headers: authHeaders,
|
||||
},
|
||||
),
|
||||
axios.get<SummaryResponse>(`${API_BASE_URL}/dataset/${datasetId}/summary`, {
|
||||
params,
|
||||
headers: authHeaders,
|
||||
}),
|
||||
axios.get<CulturalAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/cultural`, {
|
||||
params,
|
||||
headers: authHeaders,
|
||||
}),
|
||||
])
|
||||
.then(
|
||||
([
|
||||
@@ -182,8 +350,7 @@ const StatPage = () => {
|
||||
]) => {
|
||||
const usersList = userRes.data.users ?? [];
|
||||
const topUsersList = userRes.data.top_users ?? [];
|
||||
const interactionGraphRaw =
|
||||
interactionRes.data?.interaction_graph ?? {};
|
||||
const interactionGraphRaw = interactionRes.data?.interaction_graph ?? {};
|
||||
const topPairsRaw = interactionRes.data?.top_interaction_pairs ?? [];
|
||||
|
||||
const filteredUsers: typeof usersList = [];
|
||||
@@ -194,18 +361,14 @@ const StatPage = () => {
|
||||
|
||||
const filteredTopUsers: typeof topUsersList = [];
|
||||
for (const user of topUsersList) {
|
||||
if (isDeletedUser(user.author)) continue;
|
||||
filteredTopUsers.push(user);
|
||||
if (isDeletedUser(user.author)) continue;
|
||||
filteredTopUsers.push(user);
|
||||
}
|
||||
|
||||
let mostCommentHeavyUser: UserStatsMeta["mostCommentHeavyUser"] =
|
||||
null;
|
||||
let mostCommentHeavyUser: UserStatsMeta["mostCommentHeavyUser"] = null;
|
||||
for (const user of filteredUsers) {
|
||||
const currentShare = user.comment_share ?? 0;
|
||||
if (
|
||||
!mostCommentHeavyUser ||
|
||||
currentShare > mostCommentHeavyUser.commentShare
|
||||
) {
|
||||
if (!mostCommentHeavyUser || currentShare > mostCommentHeavyUser.commentShare) {
|
||||
mostCommentHeavyUser = {
|
||||
author: user.author,
|
||||
commentShare: currentShare,
|
||||
@@ -221,8 +384,7 @@ const StatPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredInteractionGraph: Record<string, Record<string, number>> =
|
||||
{};
|
||||
const filteredInteractionGraph: Record<string, Record<string, number>> = {};
|
||||
for (const [source, targets] of Object.entries(interactionGraphRaw)) {
|
||||
if (isDeletedUser(source)) {
|
||||
continue;
|
||||
@@ -279,7 +441,7 @@ const StatPage = () => {
|
||||
setSummary(filteredSummary || null);
|
||||
},
|
||||
)
|
||||
.catch((e) => setError("Failed to load statistics: " + String(e)))
|
||||
.catch((e) => setError(`Failed to load statistics: ${String(e)}`))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
@@ -302,6 +464,9 @@ const StatPage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setError("");
|
||||
setAllRecords(null);
|
||||
setAllRecordsKey("");
|
||||
setExplorerState(EMPTY_EXPLORER_STATE);
|
||||
if (!datasetId) {
|
||||
setError("Missing dataset id. Open /dataset/<id>/stats.");
|
||||
return;
|
||||
@@ -398,9 +563,7 @@ const StatPage = () => {
|
||||
<button
|
||||
onClick={() => setActiveView("summary")}
|
||||
style={
|
||||
activeView === "summary"
|
||||
? styles.buttonPrimary
|
||||
: styles.buttonSecondary
|
||||
activeView === "summary" ? styles.buttonPrimary : styles.buttonSecondary
|
||||
}
|
||||
>
|
||||
Summary
|
||||
@@ -418,11 +581,7 @@ const StatPage = () => {
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView("user")}
|
||||
style={
|
||||
activeView === "user"
|
||||
? styles.buttonPrimary
|
||||
: styles.buttonSecondary
|
||||
}
|
||||
style={activeView === "user" ? styles.buttonPrimary : styles.buttonSecondary}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
@@ -449,9 +608,7 @@ const StatPage = () => {
|
||||
<button
|
||||
onClick={() => setActiveView("cultural")}
|
||||
style={
|
||||
activeView === "cultural"
|
||||
? styles.buttonPrimary
|
||||
: styles.buttonSecondary
|
||||
activeView === "cultural" ? styles.buttonPrimary : styles.buttonSecondary
|
||||
}
|
||||
>
|
||||
Cultural
|
||||
@@ -464,11 +621,12 @@ const StatPage = () => {
|
||||
timeData={timeData}
|
||||
linguisticData={linguisticData}
|
||||
summary={summary}
|
||||
onExplore={openExplorer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "emotional" && emotionalData && (
|
||||
<EmotionalStats emotionalData={emotionalData} />
|
||||
<EmotionalStats emotionalData={emotionalData} onExplore={openExplorer} />
|
||||
)}
|
||||
|
||||
{activeView === "emotional" && !emotionalData && (
|
||||
@@ -483,6 +641,7 @@ const StatPage = () => {
|
||||
interactionGraph={interactionData.interaction_graph}
|
||||
totalUsers={userStatsMeta.totalUsers}
|
||||
mostCommentHeavyUser={userStatsMeta.mostCommentHeavyUser}
|
||||
onExplore={openExplorer}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -493,7 +652,7 @@ const StatPage = () => {
|
||||
)}
|
||||
|
||||
{activeView === "linguistic" && linguisticData && (
|
||||
<LinguisticStats data={linguisticData} />
|
||||
<LinguisticStats data={linguisticData} onExplore={openExplorer} />
|
||||
)}
|
||||
|
||||
{activeView === "linguistic" && !linguisticData && (
|
||||
@@ -503,7 +662,7 @@ const StatPage = () => {
|
||||
)}
|
||||
|
||||
{activeView === "interactional" && interactionData && (
|
||||
<InteractionalStats data={interactionData} />
|
||||
<InteractionalStats data={interactionData} onExplore={openExplorer} />
|
||||
)}
|
||||
|
||||
{activeView === "interactional" && !interactionData && (
|
||||
@@ -513,7 +672,7 @@ const StatPage = () => {
|
||||
)}
|
||||
|
||||
{activeView === "cultural" && culturalData && (
|
||||
<CulturalStats data={culturalData} />
|
||||
<CulturalStats data={culturalData} onExplore={openExplorer} />
|
||||
)}
|
||||
|
||||
{activeView === "cultural" && !culturalData && (
|
||||
@@ -521,6 +680,17 @@ const StatPage = () => {
|
||||
No cultural data available.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CorpusExplorer
|
||||
open={explorerState.open}
|
||||
onClose={() => setExplorerState((current) => ({ ...current, open: false }))}
|
||||
title={explorerState.title}
|
||||
description={explorerState.description}
|
||||
records={explorerState.records}
|
||||
loading={explorerState.loading}
|
||||
error={explorerState.error}
|
||||
emptyMessage={explorerState.emptyMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user