feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hans">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>Buzz 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "buzz-admin-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome lint .",
|
||||
"check": "biome check . && pnpm typecheck && pnpm test",
|
||||
"format": "biome format --write .",
|
||||
"test": "vitest run src --passWithNoTests",
|
||||
"test:e2e": "pnpm build && playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"jsdom": "^27.3.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vitest": "^4.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
webServer: {
|
||||
command: "pnpm exec vite preview --host 127.0.0.1 --port 4174",
|
||||
url: "http://127.0.0.1:4174",
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
use: { baseURL: "http://127.0.0.1:4174" },
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 466 309">
|
||||
<style>
|
||||
.mark { fill: #231e1e; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.mark { fill: #d7d72e; }
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<mask id="bee-mask">
|
||||
<circle cx="91.7" cy="154.5" r="91.7" fill="white"/>
|
||||
<circle cx="374.3" cy="154.5" r="91.7" fill="white"/>
|
||||
<rect x="128" y="0" width="210" height="309" rx="34" fill="white"/>
|
||||
<circle cx="193.3" cy="84.4" r="27" fill="black"/>
|
||||
<circle cx="276" cy="84.4" r="27" fill="black"/>
|
||||
<rect x="166.3" y="157.2" width="136.9" height="38.3" rx="5" fill="black"/>
|
||||
<rect x="166.9" y="235.1" width="136.2" height="37.6" rx="5" fill="black"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect class="mark" width="466" height="309" mask="url(#bee-mask)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
@@ -0,0 +1,917 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ApiFailure, request } from "./api";
|
||||
import { useI18n, type MessageKey } from "./i18n";
|
||||
import type {
|
||||
FeedbackDetail,
|
||||
FeedbackSummary,
|
||||
Report,
|
||||
ReportDetail as ReportDetailData,
|
||||
} from "./types";
|
||||
import { useResource } from "./useResource";
|
||||
|
||||
function usePath() {
|
||||
const [path, setPath] = useState(location.pathname);
|
||||
useEffect(() => {
|
||||
const update = () => setPath(location.pathname);
|
||||
addEventListener("popstate", update);
|
||||
return () => removeEventListener("popstate", update);
|
||||
}, []);
|
||||
const navigate = useCallback((url: string) => {
|
||||
history.pushState(null, "", url);
|
||||
dispatchEvent(new PopStateEvent("popstate"));
|
||||
}, []);
|
||||
return { path, navigate };
|
||||
}
|
||||
|
||||
function Link({
|
||||
href,
|
||||
className,
|
||||
activeWhenNested = false,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
className?: string;
|
||||
activeWhenNested?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { path, navigate } = usePath();
|
||||
const active =
|
||||
path === href || (activeWhenNested && path.startsWith(`${href}/`));
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={className}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={(event) => {
|
||||
if (!event.metaKey && !event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
navigate(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function StateView<T>({
|
||||
resource,
|
||||
children,
|
||||
}: {
|
||||
resource: ReturnType<typeof useResource<T>>;
|
||||
children: (data: T) => ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
if (resource.loading && !resource.data)
|
||||
return <div className="state">{t("loading")}</div>;
|
||||
if (resource.error && !resource.data) {
|
||||
const forbidden =
|
||||
resource.error instanceof ApiFailure && resource.error.status === 403;
|
||||
return (
|
||||
<div className="state error" role="alert">
|
||||
<h2>{forbidden ? t("accessDenied") : t("couldNotLoadData")}</h2>
|
||||
<p>{t("requestFailed")}</p>
|
||||
<button type="button" onClick={resource.refetch}>
|
||||
{t("retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return resource.data ? children(resource.data) : null;
|
||||
}
|
||||
|
||||
function Reports() {
|
||||
const { t, locale } = useI18n();
|
||||
const resource = useResource(
|
||||
() => request<Report[]>("/reports?status=open&limit=100"),
|
||||
"reports",
|
||||
);
|
||||
return (
|
||||
<Page
|
||||
eyebrow={t("moderation")}
|
||||
title={t("openReports")}
|
||||
description={t("reportsDescription")}
|
||||
>
|
||||
<StateView resource={resource}>
|
||||
{(reports) =>
|
||||
reports.length ? (
|
||||
<div className="cards">
|
||||
{reports.map((report) => (
|
||||
<Link
|
||||
key={report.id}
|
||||
href={`/reports/${report.id}`}
|
||||
className="card-link"
|
||||
>
|
||||
<article className="record-card">
|
||||
<span className="record-icon report-icon">
|
||||
<ReportIcon />
|
||||
</span>
|
||||
<div className="record-primary">
|
||||
<span className="tag">
|
||||
{dataLabel(t, report.reportType, reportTypeLabels)}
|
||||
</span>
|
||||
<strong>{report.communityHost}</strong>
|
||||
<code>
|
||||
{dataLabel(t, report.targetKind, targetKindLabels)}:{" "}
|
||||
{short(report.target)}
|
||||
</code>
|
||||
</div>
|
||||
<div className="record-date">
|
||||
<span>{t("submitted")}</span>
|
||||
<time>
|
||||
{date(report.createdAt, locale, t("unknownDate"))}
|
||||
</time>
|
||||
</div>
|
||||
<ArrowIcon />
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty />
|
||||
)
|
||||
}
|
||||
</StateView>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportDetail({ id }: { id: string }) {
|
||||
const { t, locale } = useI18n();
|
||||
const resource = useResource(
|
||||
() => request<ReportDetailData>(`/reports/${id}`),
|
||||
id,
|
||||
);
|
||||
return (
|
||||
<Page
|
||||
eyebrow={t("moderation")}
|
||||
title={t("reportDetail")}
|
||||
description={t("reportDetailDescription")}
|
||||
back="/reports"
|
||||
>
|
||||
<StateView resource={resource}>
|
||||
{(report) => (
|
||||
<article className="detail">
|
||||
<div className="detail-heading">
|
||||
<span className="record-icon report-icon">
|
||||
<ReportIcon />
|
||||
</span>
|
||||
<div>
|
||||
<span className="tag">
|
||||
{dataLabel(t, report.reportType, reportTypeLabels)}
|
||||
</span>
|
||||
<h2>{report.communityHost}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<dt>{t("status")}</dt>
|
||||
<dd>
|
||||
<span className="status">
|
||||
{dataLabel(t, report.status, reportStatusLabels)}
|
||||
</span>
|
||||
</dd>
|
||||
<dt>{t("reporter")}</dt>
|
||||
<dd>
|
||||
<code>{report.reporterPubkey}</code>
|
||||
</dd>
|
||||
<dt>{t("target")}</dt>
|
||||
<dd>
|
||||
<code>{report.target}</code>
|
||||
</dd>
|
||||
{report.targetKind === "event" ? (
|
||||
<>
|
||||
<dt>{t("message")}</dt>
|
||||
<dd>
|
||||
{report.message ? (
|
||||
<div className="reported-message">
|
||||
{report.message.deletedAt ? (
|
||||
<span className="status">{t("deleted")}</span>
|
||||
) : null}
|
||||
<p>{report.message.content}</p>
|
||||
<div className="reported-message-meta">
|
||||
<span>{t("author")}</span>
|
||||
<code>{report.message.authorPubkey}</code>
|
||||
<span>{t("created")}</span>
|
||||
<time>
|
||||
{date(
|
||||
report.message.createdAt,
|
||||
locale,
|
||||
t("unknownDate"),
|
||||
)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="message-unavailable">
|
||||
{t("messageUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>{t("note")}</dt>
|
||||
<dd className="sensitive">{report.note ?? t("noNote")}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
)}
|
||||
</StateView>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackList() {
|
||||
const { t, locale } = useI18n();
|
||||
const resource = useResource(
|
||||
() => request<FeedbackSummary[]>("/feedback"),
|
||||
"feedback",
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const [community, setCommunity] = useState("all");
|
||||
const [timeRange, setTimeRange] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [statuses, setStatuses] = useState(loadFeedbackStatuses);
|
||||
|
||||
const updateStatus = (id: string, event: ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = event.target.checked;
|
||||
setStatuses((current) => {
|
||||
const next = {
|
||||
...current,
|
||||
[id]: checked,
|
||||
};
|
||||
saveFeedbackStatuses(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
eyebrow={t("product")}
|
||||
title={t("feedback")}
|
||||
description={t("feedbackDescription")}
|
||||
>
|
||||
<StateView resource={resource}>
|
||||
{(items) => {
|
||||
if (!items.length) return <Empty />;
|
||||
return (
|
||||
<FeedbackResults
|
||||
items={items}
|
||||
query={query}
|
||||
community={community}
|
||||
timeRange={timeRange}
|
||||
statusFilter={statusFilter}
|
||||
statuses={statuses}
|
||||
>
|
||||
{({ communities, filtered }) => (
|
||||
<>
|
||||
<div className="feedback-filters">
|
||||
<label className="search-field">
|
||||
<span>{t("searchFeedback")}</span>
|
||||
<div>
|
||||
<SearchIcon />
|
||||
<input
|
||||
type="search"
|
||||
placeholder={t("searchFeedback")}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("community")}</span>
|
||||
<select
|
||||
value={community}
|
||||
onChange={(event) => setCommunity(event.target.value)}
|
||||
>
|
||||
<option value="all">{t("allCommunities")}</option>
|
||||
{communities.map((host) => (
|
||||
<option key={host} value={host}>
|
||||
{host}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("received")}</span>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(event) => setTimeRange(event.target.value)}
|
||||
>
|
||||
<option value="all">{t("anyTime")}</option>
|
||||
<option value="day">{t("last24Hours")}</option>
|
||||
<option value="week">{t("last7Days")}</option>
|
||||
<option value="month">{t("last30Days")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("status")}</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) =>
|
||||
setStatusFilter(event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="all">{t("anyStatus")}</option>
|
||||
<option value="pending">{t("needsAction")}</option>
|
||||
<option value="acted-on">{t("actedOn")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="result-count" aria-live="polite">
|
||||
{t("submissionsCount", {
|
||||
filtered: filtered.length,
|
||||
total: items.length,
|
||||
})}
|
||||
</p>
|
||||
{filtered.length ? (
|
||||
<div className="cards">
|
||||
{filtered.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="record-card feedback-card feedback-record"
|
||||
>
|
||||
<Link
|
||||
href={`/feedback/${item.id}`}
|
||||
className="feedback-main-link"
|
||||
>
|
||||
<span className="record-icon feedback-icon">
|
||||
<CategoryIcon category={item.category} />
|
||||
</span>
|
||||
<div className="record-primary">
|
||||
<CategoryTag category={item.category} />
|
||||
<strong>{item.bodySummary}</strong>
|
||||
<span className="record-provenance">
|
||||
{item.communityHost}
|
||||
<code>{short(item.submitterPubkey)}</code>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<label className="feedback-status">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={statuses[item.id] ?? false}
|
||||
onChange={(event) => updateStatus(item.id, event)}
|
||||
/>
|
||||
{t("actedOn")}
|
||||
<span className="visually-hidden">
|
||||
{t("feedback")} · {item.communityHost}
|
||||
</span>
|
||||
</label>
|
||||
<div className="record-date">
|
||||
<span>{t("received")}</span>
|
||||
<time>
|
||||
{date(item.receivedAt, locale, t("unknownDate"))}
|
||||
</time>
|
||||
</div>
|
||||
<Link
|
||||
href={`/feedback/${item.id}`}
|
||||
className="record-open-link"
|
||||
>
|
||||
<span className="visually-hidden">
|
||||
{t("feedback")} · {item.communityHost}
|
||||
</span>
|
||||
<ArrowIcon />
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="state">{t("noMatchingFeedback")}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FeedbackResults>
|
||||
);
|
||||
}}
|
||||
</StateView>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackResults({
|
||||
items,
|
||||
query,
|
||||
community,
|
||||
timeRange,
|
||||
statusFilter,
|
||||
statuses,
|
||||
children,
|
||||
}: {
|
||||
items: FeedbackSummary[];
|
||||
query: string;
|
||||
community: string;
|
||||
timeRange: string;
|
||||
statusFilter: string;
|
||||
statuses: FeedbackStatuses;
|
||||
children: (results: {
|
||||
communities: string[];
|
||||
filtered: FeedbackSummary[];
|
||||
}) => ReactNode;
|
||||
}) {
|
||||
const results = useMemo(() => {
|
||||
const communities = [...new Set(items.map((item) => item.communityHost))]
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const after = timeRangeStart(timeRange);
|
||||
const filtered = items.filter((item) => {
|
||||
if (community !== "all" && item.communityHost !== community) return false;
|
||||
if (statusFilter === "pending" && statuses[item.id]) return false;
|
||||
if (statusFilter === "acted-on" && !statuses[item.id]) return false;
|
||||
if (after !== undefined) {
|
||||
const receivedAt = new Date(item.receivedAt).valueOf();
|
||||
if (Number.isNaN(receivedAt) || receivedAt < after) return false;
|
||||
}
|
||||
if (!normalizedQuery) return true;
|
||||
return [
|
||||
item.bodySummary,
|
||||
item.communityHost,
|
||||
item.category ?? "uncategorized",
|
||||
item.submitterPubkey,
|
||||
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
|
||||
});
|
||||
return { communities, filtered };
|
||||
}, [items, query, community, timeRange, statusFilter, statuses]);
|
||||
return children(results);
|
||||
}
|
||||
|
||||
function FeedbackDetailView({ id }: { id: string }) {
|
||||
const { t, locale } = useI18n();
|
||||
const resource = useResource(
|
||||
() => request<FeedbackDetail>(`/feedback/${id}`),
|
||||
id,
|
||||
);
|
||||
return (
|
||||
<Page
|
||||
eyebrow={t("product")}
|
||||
title={t("feedbackDetail")}
|
||||
description={t("feedbackDetailDescription")}
|
||||
back="/feedback"
|
||||
backLabel={t("backToFeedback")}
|
||||
>
|
||||
<StateView resource={resource}>
|
||||
{(feedback) => {
|
||||
const attachments = feedbackAttachments(
|
||||
feedback.id,
|
||||
feedback.tags,
|
||||
feedback.communityHost,
|
||||
);
|
||||
const body = stripAttachmentMarkdown(feedback.body, attachments);
|
||||
return (
|
||||
<article className="detail">
|
||||
<div className="detail-heading">
|
||||
<span className="record-icon feedback-icon">
|
||||
<CategoryIcon category={feedback.category} />
|
||||
</span>
|
||||
<div>
|
||||
<CategoryTag category={feedback.category} />
|
||||
<h2>{feedback.communityHost}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<dl>
|
||||
<dt>{t("feedback")}</dt>
|
||||
<dd className="sensitive feedback-body">{body}</dd>
|
||||
{attachments.length ? (
|
||||
<>
|
||||
<dt>{t("attachments")}</dt>
|
||||
<dd className="attachments">
|
||||
{attachments.map((attachment) => (
|
||||
<Attachment
|
||||
key={`${attachment.hash}-${attachment.url}`}
|
||||
attachment={attachment}
|
||||
/>
|
||||
))}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>{t("submittedBy")}</dt>
|
||||
<dd>
|
||||
<code>{feedback.submitterPubkey}</code>
|
||||
</dd>
|
||||
<dt>{t("event")}</dt>
|
||||
<dd>
|
||||
<code>{feedback.eventId}</code>
|
||||
</dd>
|
||||
<dt>{t("created")}</dt>
|
||||
<dd>
|
||||
{date(feedback.eventCreatedAt, locale, t("unknownDate"))}
|
||||
</dd>
|
||||
<dt>{t("received")}</dt>
|
||||
<dd>{date(feedback.receivedAt, locale, t("unknownDate"))}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
);
|
||||
}}
|
||||
</StateView>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
type FeedbackStatuses = Record<string, boolean>;
|
||||
|
||||
interface FeedbackAttachment {
|
||||
url: string;
|
||||
sourceUrl: string;
|
||||
mimeType: string;
|
||||
hash: string;
|
||||
size?: number;
|
||||
dimensions?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
const FEEDBACK_STATUS_KEY = "buzz-admin-feedback-status";
|
||||
|
||||
function loadFeedbackStatuses(): FeedbackStatuses {
|
||||
try {
|
||||
const stored = localStorage.getItem(FEEDBACK_STATUS_KEY);
|
||||
return stored ? (JSON.parse(stored) as FeedbackStatuses) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveFeedbackStatuses(statuses: FeedbackStatuses) {
|
||||
try {
|
||||
localStorage.setItem(FEEDBACK_STATUS_KEY, JSON.stringify(statuses));
|
||||
} catch {
|
||||
// The controls remain useful for the current session if storage is blocked.
|
||||
}
|
||||
}
|
||||
|
||||
function timeRangeStart(range: string) {
|
||||
const durations: Record<string, number> = {
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
month: 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
const duration = durations[range];
|
||||
return duration ? Date.now() - duration : undefined;
|
||||
}
|
||||
|
||||
function feedbackAttachments(
|
||||
feedbackId: string,
|
||||
tags: string[][],
|
||||
communityHost: string,
|
||||
): FeedbackAttachment[] {
|
||||
return tags.flatMap((tag) => {
|
||||
if (tag[0] !== "imeta") return [];
|
||||
const values = new Map<string, string>();
|
||||
for (const entry of tag.slice(1)) {
|
||||
const separator = entry.indexOf(" ");
|
||||
if (separator > 0) {
|
||||
values.set(entry.slice(0, separator), entry.slice(separator + 1));
|
||||
}
|
||||
}
|
||||
const url = values.get("url");
|
||||
const mimeType = values.get("m");
|
||||
const hash = values.get("x");
|
||||
const safeUrl = url && safeAttachmentUrl(url, communityHost);
|
||||
if (!safeUrl || !mimeType || !hash) return [];
|
||||
const parsedSize = Number(values.get("size"));
|
||||
return [
|
||||
{
|
||||
url: `/api/admin/v1/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
|
||||
sourceUrl: safeUrl,
|
||||
mimeType,
|
||||
hash,
|
||||
size:
|
||||
Number.isFinite(parsedSize) && parsedSize > 0
|
||||
? parsedSize
|
||||
: undefined,
|
||||
dimensions: values.get("dim"),
|
||||
filename: values.get("filename"),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function safeAttachmentUrl(value: string, communityHost: string) {
|
||||
try {
|
||||
const url = new URL(value, `${location.protocol}//${communityHost}`);
|
||||
return ["http:", "https:"].includes(url.protocol) &&
|
||||
url.host.toLocaleLowerCase() === communityHost.toLocaleLowerCase() &&
|
||||
url.pathname.startsWith("/media/")
|
||||
? url.href
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function stripAttachmentMarkdown(
|
||||
body: string,
|
||||
attachments?: FeedbackAttachment[],
|
||||
) {
|
||||
const knownUrls = attachments
|
||||
? new Set(attachments.map((attachment) => attachment.sourceUrl))
|
||||
: undefined;
|
||||
return body
|
||||
.replace(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (match, url) => {
|
||||
const isMedia = knownUrls ? knownUrls.has(url) : url.includes("/media/");
|
||||
return isMedia ? "" : match;
|
||||
})
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
|
||||
const url = attachment.url;
|
||||
const name =
|
||||
attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
|
||||
const metadata = [
|
||||
attachment.mimeType,
|
||||
attachment.dimensions,
|
||||
attachment.size ? formatBytes(attachment.size) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
if (attachment.mimeType.startsWith("image/")) {
|
||||
return (
|
||||
<figure className="image-attachment">
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<img src={url} alt={name} loading="lazy" />
|
||||
</a>
|
||||
<figcaption>
|
||||
<span>{name}</span>
|
||||
<small>{metadata}</small>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className="file-attachment"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download={name}
|
||||
>
|
||||
<FileIcon />
|
||||
<span>
|
||||
<strong>{name}</strong>
|
||||
<small>{metadata}</small>
|
||||
</span>
|
||||
<ArrowIcon />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function Page({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
back,
|
||||
backLabel,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
back?: string;
|
||||
backLabel?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<section>
|
||||
<header className="page-title">
|
||||
{back ? (
|
||||
<Link href={back} className="back-link">
|
||||
<ArrowIcon /> {backLabel ?? t("backToReports")}
|
||||
</Link>
|
||||
) : null}
|
||||
<p>{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
<span>{description}</span>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
const { t } = useI18n();
|
||||
return <div className="state">{t("noRecords")}</div>;
|
||||
}
|
||||
|
||||
function short(value: string) {
|
||||
return value.length > 20 ? `${value.slice(0, 10)}…${value.slice(-8)}` : value;
|
||||
}
|
||||
|
||||
function date(value: string, locale: string, unknownDate: string) {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.valueOf())
|
||||
? unknownDate
|
||||
: new Intl.DateTimeFormat(locale === "zh-Hans" ? "zh-CN" : "en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
function BuzzMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 466 309" aria-hidden="true">
|
||||
<path d="M91.7 62.8a91.7 91.7 0 0 0 0 183.4H128V62.8H91.7Zm282.6 0H338v183.4h36.3a91.7 91.7 0 1 0 0-183.4Z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M162 0h142a34 34 0 0 1 34 34v241a34 34 0 0 1-34 34H162a34 34 0 0 1-34-34V34a34 34 0 0 1 34-34Zm31.3 57.4a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm82.7 0a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm-109.7 99.8h136.9v38.3H166.3v-38.3Zm.6 77.9h136.2v37.6H166.9v-37.6Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3 4.5 6v5.2c0 4.7 3.2 8.8 7.5 9.8 4.3-1 7.5-5.1 7.5-9.8V6L12 3Z" />
|
||||
<path d="M12 7.5v5M12 16.5h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 5.5h14v10H9l-4 3v-13Z" />
|
||||
<path d="M8.5 9h7M8.5 12h4.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTag({ category }: { category?: string }) {
|
||||
const { t } = useI18n();
|
||||
const labels: Record<string, string> = {
|
||||
bug: t("categoryBug"),
|
||||
praise: t("categoryPraise"),
|
||||
"needs-work": t("categoryNeedsWork"),
|
||||
};
|
||||
return (
|
||||
<span className="tag">
|
||||
<CategoryIcon category={category} />
|
||||
{category ? (labels[category] ?? category) : t("uncategorized")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const reportTypeLabels: Record<string, MessageKey> = {
|
||||
spam: "reportSpam",
|
||||
illegal: "reportIllegal",
|
||||
nudity: "reportNudity",
|
||||
malware: "reportMalware",
|
||||
impersonation: "reportImpersonation",
|
||||
profanity: "reportProfanity",
|
||||
other: "reportOther",
|
||||
};
|
||||
|
||||
const reportStatusLabels: Record<string, MessageKey> = {
|
||||
open: "statusOpen",
|
||||
resolved: "statusResolved",
|
||||
dismissed: "statusDismissed",
|
||||
escalated: "statusEscalated",
|
||||
};
|
||||
|
||||
const targetKindLabels: Record<string, MessageKey> = {
|
||||
event: "targetEvent",
|
||||
pubkey: "targetPubkey",
|
||||
blob: "targetBlob",
|
||||
};
|
||||
|
||||
function dataLabel(
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
value: string,
|
||||
labels: Record<string, MessageKey>,
|
||||
) {
|
||||
const key = labels[value];
|
||||
return key ? t(key) : value;
|
||||
}
|
||||
|
||||
function CategoryIcon({ category }: { category?: string }) {
|
||||
if (category === "bug") return <BugIcon />;
|
||||
if (category === "praise") return <ThumbsUpIcon />;
|
||||
if (category === "needs-work") return <WrenchIcon />;
|
||||
return <FeedbackIcon />;
|
||||
}
|
||||
|
||||
function BugIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
||||
<path d="M14.12 3.88 16 2M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ThumbsUpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
||||
<path d="M7 10v12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WrenchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 3h8l4 4v14H6V3Z" />
|
||||
<path d="M14 3v5h4M9 13h6M9 17h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowIcon() {
|
||||
return (
|
||||
<svg className="arrow-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { path } = usePath();
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const report = path.match(/^\/reports\/([^/]+)$/);
|
||||
const feedback = path.match(/^\/feedback\/([^/]+)$/);
|
||||
const content = report ? (
|
||||
<ReportDetail id={report[1]} />
|
||||
) : feedback ? (
|
||||
<FeedbackDetailView id={feedback[1]} />
|
||||
) : path === "/feedback" ? (
|
||||
<FeedbackList />
|
||||
) : (
|
||||
<Reports />
|
||||
);
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<Link href="/reports" className="brand">
|
||||
<span className="brand-mark">
|
||||
<BuzzMark />
|
||||
</span>
|
||||
<span>{t("buzzAdmin")}</span>
|
||||
</Link>
|
||||
<nav>
|
||||
<Link href="/reports" className="nav-link" activeWhenNested>
|
||||
<ReportIcon /> {t("reports")}
|
||||
</Link>
|
||||
<Link href="/feedback" className="nav-link" activeWhenNested>
|
||||
<FeedbackIcon /> {t("feedback")}
|
||||
</Link>
|
||||
</nav>
|
||||
<fieldset className="locale-switcher">
|
||||
<legend className="visually-hidden">{t("localeSwitch")}</legend>
|
||||
<button
|
||||
type="button"
|
||||
className={locale === "zh-Hans" ? "active" : undefined}
|
||||
aria-pressed={locale === "zh-Hans"}
|
||||
onClick={() => setLocale("zh-Hans")}
|
||||
>
|
||||
{t("localeChinese")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={locale === "en" ? "active" : undefined}
|
||||
aria-pressed={locale === "en"}
|
||||
onClick={() => setLocale("en")}
|
||||
>
|
||||
{t("localeEnglish")}
|
||||
</button>
|
||||
</fieldset>
|
||||
</header>
|
||||
<main>{content}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const PREFIX = "/api/admin/v1";
|
||||
|
||||
export class ApiFailure extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${PREFIX}${path}`, {
|
||||
credentials: "same-origin",
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const envelope = await response.json().catch(() => null);
|
||||
throw new ApiFailure(
|
||||
response.status,
|
||||
envelope?.error?.message ?? `Request failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { messages, type Locale, type MessageKey } from "./messages";
|
||||
|
||||
const STORAGE_KEY = "buzz-admin-locale";
|
||||
const DEFAULT_LOCALE: Locale = "zh-Hans";
|
||||
|
||||
interface I18nValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: MessageKey, values?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nValue | null>(null);
|
||||
|
||||
function readLocale(): Locale {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "en" || stored === "zh-Hans") return stored;
|
||||
} catch {
|
||||
// Storage may be disabled; the default remains usable.
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function interpolate(
|
||||
template: string,
|
||||
values?: Record<string, string | number>,
|
||||
): string {
|
||||
if (!values) return template;
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) =>
|
||||
String(values[key] ?? `{{${key}}}`),
|
||||
);
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(readLocale);
|
||||
const setLocale = useCallback((next: Locale) => {
|
||||
setLocaleState(next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Keep the current session usable when storage is blocked.
|
||||
}
|
||||
}, []);
|
||||
const t = useCallback(
|
||||
(key: MessageKey, values?: Record<string, string | number>) =>
|
||||
interpolate(messages[locale][key] ?? messages.en[key], values),
|
||||
[locale],
|
||||
);
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
document.title = t("appTitle");
|
||||
}, [locale, t]);
|
||||
const value = useMemo(
|
||||
() => ({ locale, setLocale, t }),
|
||||
[locale, setLocale, t],
|
||||
);
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nValue {
|
||||
const value = useContext(I18nContext);
|
||||
if (!value) throw new Error("useI18n must be used inside I18nProvider");
|
||||
return value;
|
||||
}
|
||||
|
||||
export { DEFAULT_LOCALE, STORAGE_KEY };
|
||||
export type { Locale, MessageKey } from "./messages";
|
||||
@@ -0,0 +1,158 @@
|
||||
export const en = {
|
||||
appTitle: "Buzz admin",
|
||||
localeName: "English",
|
||||
localeSwitch: "Language",
|
||||
localeEnglish: "English",
|
||||
localeChinese: "简体中文",
|
||||
loading: "Loading…",
|
||||
accessDenied: "Access denied",
|
||||
couldNotLoadData: "Could not load data",
|
||||
retry: "Retry",
|
||||
requestFailed: "The request failed. Please try again.",
|
||||
moderation: "Moderation",
|
||||
openReports: "Open reports",
|
||||
reportsDescription: "Review reports across every Buzz community.",
|
||||
submitted: "Submitted",
|
||||
reportDetail: "Report detail",
|
||||
reportDetailDescription: "The full report as submitted to the relay.",
|
||||
status: "Status",
|
||||
reporter: "Reporter",
|
||||
target: "Target",
|
||||
message: "Message",
|
||||
deleted: "Deleted",
|
||||
author: "Author",
|
||||
created: "Created",
|
||||
messageUnavailable:
|
||||
"Message content is unavailable. It may have expired or been removed from event storage.",
|
||||
note: "Note",
|
||||
noNote: "No note provided.",
|
||||
product: "Product",
|
||||
feedback: "Feedback",
|
||||
feedbackDescription: "Recent product feedback from across Buzz.",
|
||||
searchFeedback: "Search feedback",
|
||||
community: "Community",
|
||||
allCommunities: "All communities",
|
||||
received: "Received",
|
||||
anyTime: "Any time",
|
||||
last24Hours: "Last 24 hours",
|
||||
last7Days: "Last 7 days",
|
||||
last30Days: "Last 30 days",
|
||||
anyStatus: "Any status",
|
||||
needsAction: "Needs action",
|
||||
actedOn: "Acted on",
|
||||
submissionsCount: "{{filtered}} of {{total}} submissions",
|
||||
noMatchingFeedback: "No matching feedback.",
|
||||
feedbackDetail: "Feedback detail",
|
||||
feedbackDetailDescription: "The complete feedback submission and its source.",
|
||||
backToFeedback: "Back to feedback",
|
||||
backToReports: "Back to reports",
|
||||
attachments: "Attachments",
|
||||
submittedBy: "Submitted by",
|
||||
event: "Event",
|
||||
noRecords: "No records.",
|
||||
unknownDate: "Unknown date",
|
||||
uncategorized: "Uncategorized",
|
||||
categoryBug: "Bug",
|
||||
categoryPraise: "Praise",
|
||||
categoryNeedsWork: "Needs work",
|
||||
buzzAdmin: "Buzz Admin",
|
||||
reports: "Reports",
|
||||
reportSpam: "Spam",
|
||||
reportIllegal: "Illegal content",
|
||||
reportNudity: "Nudity",
|
||||
reportMalware: "Malware",
|
||||
reportImpersonation: "Impersonation",
|
||||
reportProfanity: "Profanity",
|
||||
reportOther: "Other",
|
||||
statusOpen: "Open",
|
||||
statusResolved: "Resolved",
|
||||
statusDismissed: "Dismissed",
|
||||
statusEscalated: "Escalated",
|
||||
targetEvent: "Event",
|
||||
targetPubkey: "Public key",
|
||||
targetBlob: "File",
|
||||
languageUpdated: "Language updated",
|
||||
} as const;
|
||||
|
||||
export const zhHans: Record<keyof typeof en, string> = {
|
||||
appTitle: "Buzz 管理后台",
|
||||
localeName: "简体中文",
|
||||
localeSwitch: "语言",
|
||||
localeEnglish: "English",
|
||||
localeChinese: "简体中文",
|
||||
loading: "加载中…",
|
||||
accessDenied: "无权访问",
|
||||
couldNotLoadData: "无法加载数据",
|
||||
retry: "重试",
|
||||
requestFailed: "请求失败,请稍后重试。",
|
||||
moderation: "内容审核",
|
||||
openReports: "待处理举报",
|
||||
reportsDescription: "查看所有 Buzz 社区的举报。",
|
||||
submitted: "提交时间",
|
||||
reportDetail: "举报详情",
|
||||
reportDetailDescription: "查看提交到 relay 的完整举报内容。",
|
||||
status: "状态",
|
||||
reporter: "举报人",
|
||||
target: "目标",
|
||||
message: "消息",
|
||||
deleted: "已删除",
|
||||
author: "作者",
|
||||
created: "创建时间",
|
||||
messageUnavailable: "消息内容不可用,可能已过期或已从事件存储中移除。",
|
||||
note: "备注",
|
||||
noNote: "未提供备注。",
|
||||
product: "产品",
|
||||
feedback: "反馈",
|
||||
feedbackDescription: "查看来自 Buzz 各社区的近期产品反馈。",
|
||||
searchFeedback: "搜索反馈",
|
||||
community: "社区",
|
||||
allCommunities: "所有社区",
|
||||
received: "收到时间",
|
||||
anyTime: "不限时间",
|
||||
last24Hours: "过去 24 小时",
|
||||
last7Days: "过去 7 天",
|
||||
last30Days: "过去 30 天",
|
||||
anyStatus: "不限状态",
|
||||
needsAction: "待处理",
|
||||
actedOn: "已处理",
|
||||
submissionsCount: "共 {{total}} 条,当前显示 {{filtered}} 条",
|
||||
noMatchingFeedback: "没有匹配的反馈。",
|
||||
feedbackDetail: "反馈详情",
|
||||
feedbackDetailDescription: "查看完整反馈内容及其来源。",
|
||||
backToFeedback: "返回反馈列表",
|
||||
backToReports: "返回举报列表",
|
||||
attachments: "附件",
|
||||
submittedBy: "提交人",
|
||||
event: "事件",
|
||||
noRecords: "暂无记录。",
|
||||
unknownDate: "未知日期",
|
||||
uncategorized: "未分类",
|
||||
categoryBug: "问题",
|
||||
categoryPraise: "表扬",
|
||||
categoryNeedsWork: "需要改进",
|
||||
buzzAdmin: "Buzz 管理后台",
|
||||
reports: "举报",
|
||||
reportSpam: "垃圾信息",
|
||||
reportIllegal: "违规内容",
|
||||
reportNudity: "裸露内容",
|
||||
reportMalware: "恶意软件",
|
||||
reportImpersonation: "冒充他人",
|
||||
reportProfanity: "粗俗内容",
|
||||
reportOther: "其他",
|
||||
statusOpen: "待处理",
|
||||
statusResolved: "已解决",
|
||||
statusDismissed: "已驳回",
|
||||
statusEscalated: "已升级",
|
||||
targetEvent: "事件",
|
||||
targetPubkey: "公钥",
|
||||
targetBlob: "文件",
|
||||
languageUpdated: "语言已更新",
|
||||
};
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
export type Locale = "en" | "zh-Hans";
|
||||
|
||||
export const messages: Record<Locale, Record<MessageKey, string>> = {
|
||||
en,
|
||||
"zh-Hans": zhHans,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("root element missing");
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,759 @@
|
||||
:root {
|
||||
font-family:
|
||||
"Helvetica Neue", Helvetica, Arial, ui-sans-serif, system-ui, sans-serif;
|
||||
color: #231e1e;
|
||||
background: #d7e7f6;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
color: white;
|
||||
background: #231e1e;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.75rem 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #d7d72e 0%, #dfe379 24%, #d7e7f6 78%);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
width: min(1120px, calc(100% - 3rem));
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.brand b {
|
||||
font-weight: 400;
|
||||
color: rgb(35 30 30 / 60%);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.7rem;
|
||||
color: #d7d72e;
|
||||
background: #231e1e;
|
||||
}
|
||||
|
||||
.brand-mark svg {
|
||||
width: 1.7rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid rgb(35 30 30 / 10%);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 42%);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 999px;
|
||||
color: rgb(35 30 30 / 60%);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: -0.035em;
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.nav-link svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #231e1e;
|
||||
}
|
||||
|
||||
.nav-link[aria-current="page"] {
|
||||
color: #231e1e;
|
||||
background: white;
|
||||
box-shadow: 0 0.2rem 1rem rgb(35 30 30 / 8%);
|
||||
}
|
||||
|
||||
.locale-switcher {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid rgb(35 30 30 / 10%);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 42%);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.locale-switcher button {
|
||||
padding: 0.45rem 0.7rem;
|
||||
color: rgb(35 30 30 / 60%);
|
||||
background: transparent;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.locale-switcher button.active {
|
||||
color: #231e1e;
|
||||
background: white;
|
||||
box-shadow: 0 0.2rem 1rem rgb(35 30 30 / 8%);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1120px, calc(100% - 3rem));
|
||||
margin: 0 auto;
|
||||
padding: 4.5rem 0 7rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
max-width: 52rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.page-title > p {
|
||||
margin: 0 0 1rem;
|
||||
color: rgb(35 30 30 / 55%);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(3rem, 7vw, 5.5rem);
|
||||
font-weight: 400;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
|
||||
.page-title > span {
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
color: rgb(35 30 30 / 62%);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 2.25rem;
|
||||
color: rgb(35 30 30 / 62%);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.feedback-filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 1fr) repeat(3, auto);
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgb(35 30 30 / 10%);
|
||||
border-radius: 1.25rem;
|
||||
background: rgb(255 255 255 / 45%);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.feedback-filters label {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.feedback-filters label > span {
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.feedback-filters input,
|
||||
.feedback-filters select {
|
||||
min-height: 2.75rem;
|
||||
border: 1px solid rgb(35 30 30 / 12%);
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
color: #231e1e;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.feedback-filters input:focus,
|
||||
.feedback-filters select:focus {
|
||||
border-color: #231e1e;
|
||||
box-shadow: 0 0 0 3px rgb(35 30 30 / 10%);
|
||||
}
|
||||
|
||||
.feedback-filters select {
|
||||
padding: 0 2.25rem 0 1rem;
|
||||
}
|
||||
|
||||
.search-field > div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
width: 100%;
|
||||
padding: 0 1rem 0 2.6rem;
|
||||
}
|
||||
|
||||
.search-field svg {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transform: translateY(-50%);
|
||||
fill: none;
|
||||
stroke: rgb(35 30 30 / 45%);
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
margin: 0 0 0.75rem;
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: block;
|
||||
border-radius: 1.6rem;
|
||||
}
|
||||
|
||||
.record-card,
|
||||
.detail,
|
||||
.state {
|
||||
background: white;
|
||||
border-radius: 1.6rem;
|
||||
box-shadow: 0 1rem 4rem rgb(35 30 30 / 6%);
|
||||
}
|
||||
|
||||
.record-card {
|
||||
min-height: 7.2rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
padding: 1.2rem 1.5rem;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.feedback-record {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
}
|
||||
|
||||
.feedback-main-link {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.record-open-link:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2rem;
|
||||
}
|
||||
|
||||
.feedback-status {
|
||||
min-width: 5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: rgb(35 30 30 / 62%);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feedback-status input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
accent-color: #231e1e;
|
||||
}
|
||||
|
||||
.record-open-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 999px;
|
||||
color: rgb(35 30 30 / 30%);
|
||||
}
|
||||
|
||||
.card-link:hover .record-card {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 1.4rem 4.5rem rgb(35 30 30 / 12%);
|
||||
}
|
||||
|
||||
.record-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
color: #231e1e;
|
||||
}
|
||||
|
||||
.report-icon {
|
||||
background: #d7d72e;
|
||||
}
|
||||
|
||||
.feedback-icon {
|
||||
background: #d7e7f6;
|
||||
}
|
||||
|
||||
.record-icon svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.record-primary {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tag,
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
background: #f2f2ed;
|
||||
padding: 0.25rem 0.55rem;
|
||||
color: rgb(35 30 30 / 65%);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tag {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tag svg {
|
||||
width: 0.78rem;
|
||||
height: 0.78rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.record-primary strong {
|
||||
max-width: 42rem;
|
||||
overflow: hidden;
|
||||
color: #231e1e;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.04em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feedback-card .record-primary strong {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.record-provenance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: rgb(35 30 30 / 55%);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
code {
|
||||
color: rgb(35 30 30 / 50%);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-provenance code {
|
||||
padding-left: 0.65rem;
|
||||
border-left: 1px solid rgb(35 30 30 / 14%);
|
||||
}
|
||||
|
||||
.record-date {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.3rem;
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.record-date span {
|
||||
color: rgb(35 30 30 / 32%);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.back-link .arrow-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.record-card > .arrow-icon {
|
||||
color: rgb(35 30 30 / 30%);
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid rgb(35 30 30 / 9%);
|
||||
}
|
||||
|
||||
.detail-heading h2 {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 9rem minmax(0, 1fr);
|
||||
gap: 1.4rem;
|
||||
margin: 2rem 0 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reported-message {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 1rem;
|
||||
border-left: 3px solid #d7d72e;
|
||||
border-radius: 0 0.8rem 0.8rem 0;
|
||||
background: #f6f6f1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.reported-message p,
|
||||
.message-unavailable {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.reported-message-meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.4rem 0.75rem;
|
||||
width: 100%;
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.message-unavailable {
|
||||
color: rgb(35 30 30 / 60%);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
border-left: 3px solid #d7d72e;
|
||||
border-radius: 0 0.8rem 0.8rem 0;
|
||||
background: #f6f6f1;
|
||||
padding: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.feedback-body {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.image-attachment {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(35 30 30 / 10%);
|
||||
border-radius: 1rem;
|
||||
background: #f6f6f1;
|
||||
}
|
||||
|
||||
.image-attachment a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-attachment img {
|
||||
width: 100%;
|
||||
max-height: 32rem;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: rgb(35 30 30 / 4%);
|
||||
}
|
||||
|
||||
.image-attachment figcaption,
|
||||
.file-attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.image-attachment figcaption {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.image-attachment figcaption span,
|
||||
.file-attachment strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feedback-main-link:hover strong {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2rem;
|
||||
}
|
||||
|
||||
.image-attachment small,
|
||||
.file-attachment small {
|
||||
color: rgb(35 30 30 / 48%);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.file-attachment {
|
||||
align-self: start;
|
||||
border: 1px solid rgb(35 30 30 / 10%);
|
||||
border-radius: 1rem;
|
||||
background: #f6f6f1;
|
||||
}
|
||||
|
||||
.file-attachment:hover strong {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15rem;
|
||||
}
|
||||
|
||||
.file-attachment > svg:first-child {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.file-attachment > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.state {
|
||||
min-height: 14rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
padding: 2rem;
|
||||
color: rgb(35 30 30 / 56%);
|
||||
text-align: center;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.state h2 {
|
||||
margin: 0;
|
||||
color: #231e1e;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.state p {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.state.error {
|
||||
color: #9f2424;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-header {
|
||||
width: min(100% - 2rem, 1120px);
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100% - 2rem, 1120px);
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(3rem, 16vw, 4.5rem);
|
||||
}
|
||||
|
||||
.record-card {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.feedback-filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.feedback-record {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.feedback-main-link {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.feedback-status {
|
||||
grid-column: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feedback-record .record-date {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.record-open-link {
|
||||
grid-column: 2;
|
||||
grid-row: 2 / span 2;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
grid-column: 2;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.record-card > .arrow-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
dl {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
dd + dt {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface Report {
|
||||
id: string;
|
||||
communityId: string;
|
||||
communityHost: string;
|
||||
reporterPubkey: string;
|
||||
targetKind: "event" | "pubkey" | "blob";
|
||||
target: string;
|
||||
channelId?: string;
|
||||
reportType: string;
|
||||
note?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ReportedMessage {
|
||||
authorPubkey: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ReportDetail extends Report {
|
||||
message: ReportedMessage | null;
|
||||
}
|
||||
|
||||
export interface FeedbackSummary {
|
||||
id: string;
|
||||
communityId: string;
|
||||
communityHost: string;
|
||||
submitterPubkey: string;
|
||||
category?: string;
|
||||
bodySummary: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
export interface FeedbackDetail {
|
||||
id: string;
|
||||
communityId: string;
|
||||
communityHost: string;
|
||||
eventId: string;
|
||||
submitterPubkey: string;
|
||||
category?: string;
|
||||
body: string;
|
||||
tags: string[][];
|
||||
eventCreatedAt: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface Resource<T> {
|
||||
data?: T;
|
||||
error?: Error;
|
||||
loading: boolean;
|
||||
stale: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useResource<T>(
|
||||
load: () => Promise<T>,
|
||||
key: string,
|
||||
): Resource<T> {
|
||||
const [data, setData] = useState<T>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const loadRef = useRef(load);
|
||||
const activeRequest = useRef("");
|
||||
loadRef.current = load;
|
||||
const refetch = useCallback(() => setRevision((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = `${key}\0${revision}`;
|
||||
activeRequest.current = requestId;
|
||||
const isCurrent = () => activeRequest.current === requestId;
|
||||
const loadCurrent = async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const value = await loadRef.current();
|
||||
if (isCurrent()) setData(value);
|
||||
} catch (reason) {
|
||||
if (isCurrent()) {
|
||||
setError(
|
||||
reason instanceof Error ? reason : new Error("Request failed"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
};
|
||||
void loadCurrent();
|
||||
return () => {
|
||||
activeRequest.current = "";
|
||||
};
|
||||
}, [key, revision]);
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
stale: loading && data !== undefined,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,334 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("buzz-admin-locale", "en");
|
||||
});
|
||||
await page.route("**/api/admin/v1/**", async (route) => {
|
||||
await route.fulfill({ contentType: "application/json", body: "[]" });
|
||||
});
|
||||
});
|
||||
|
||||
for (const [path, heading] of [
|
||||
["/reports", "Open reports"],
|
||||
["/feedback", "Feedback"],
|
||||
]) {
|
||||
test(`${path} supports a deep link and empty state`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
await expect(page.getByRole("heading", { name: heading })).toBeVisible();
|
||||
await expect(page.getByText("No records.")).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("forbidden reads have an explicit state", async ({ page }) => {
|
||||
await page.route("**/api/admin/v1/reports?**", (route) =>
|
||||
route.fulfill({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
error: { code: "forbidden", message: "request is not authorized" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.goto("/reports");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Access denied" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("report rows render the relay response contract", async ({ page }) => {
|
||||
await page.route("**/api/admin/v1/reports?**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "0e6caad8-1e18-4cd7-84fa-7264103f0a08",
|
||||
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||
communityHost: "design.buzz.xyz",
|
||||
reporterPubkey: "21".repeat(32),
|
||||
targetKind: "event",
|
||||
target: "12".repeat(32),
|
||||
reportType: "spam",
|
||||
status: "open",
|
||||
createdAt: "2026-07-17T17:30:00Z",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
await page.goto("/reports");
|
||||
await expect(page.getByText("design.buzz.xyz")).toBeVisible();
|
||||
await expect(page.getByText("Spam")).toBeVisible();
|
||||
await expect(page.getByText("Unknown date")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("defaults to Simplified Chinese and can switch to English", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => localStorage.clear());
|
||||
await page.goto("/reports");
|
||||
await expect(page.getByRole("heading", { name: "待处理举报" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "English" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Open reports" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
});
|
||||
|
||||
test("event report detail renders the reported message content", async ({
|
||||
page,
|
||||
}) => {
|
||||
const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a08";
|
||||
await page.route(`**/api/admin/v1/reports/${id}`, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||
communityHost: "design.buzz.xyz",
|
||||
reporterPubkey: "21".repeat(32),
|
||||
targetKind: "event",
|
||||
target: "12".repeat(32),
|
||||
reportType: "spam",
|
||||
status: "open",
|
||||
createdAt: "2026-07-17T17:30:00Z",
|
||||
message: {
|
||||
authorPubkey: "31".repeat(32),
|
||||
content:
|
||||
"This is the complete reported message.\nIt preserves lines.",
|
||||
createdAt: "2026-07-17T17:25:00Z",
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/reports/${id}`);
|
||||
await expect(
|
||||
page.getByText("This is the complete reported message.", { exact: false }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("31".repeat(32))).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Message content is unavailable", { exact: false }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("event report detail explains when message content is unavailable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a09";
|
||||
await page.route(`**/api/admin/v1/reports/${id}`, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||
communityHost: "design.buzz.xyz",
|
||||
reporterPubkey: "21".repeat(32),
|
||||
targetKind: "event",
|
||||
target: "12".repeat(32),
|
||||
reportType: "spam",
|
||||
status: "open",
|
||||
createdAt: "2026-07-17T17:30:00Z",
|
||||
message: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/reports/${id}`);
|
||||
await expect(
|
||||
page.getByText("Message content is unavailable", { exact: false }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("feedback cards open the complete submission", async ({ page }) => {
|
||||
const id = "feed0000-0000-4000-8000-000000000001";
|
||||
const fullBody = `${"Long feedback ".repeat(30)}end of feedback`;
|
||||
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||
communityHost: "design.buzz.xyz",
|
||||
eventId: "31".repeat(32),
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "needs-work",
|
||||
body: fullBody,
|
||||
tags: [],
|
||||
eventCreatedAt: "2026-07-17T17:25:00Z",
|
||||
receivedAt: "2026-07-17T17:30:00Z",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/admin/v1/feedback", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id,
|
||||
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
|
||||
communityHost: "design.buzz.xyz",
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "needs-work",
|
||||
bodySummary: `${fullBody.slice(0, 240)}…`,
|
||||
receivedAt: "2026-07-17T17:30:00Z",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/feedback");
|
||||
const card = page.locator(".feedback-record");
|
||||
await expect(card.locator(".record-provenance")).toContainText(
|
||||
"design.buzz.xyz",
|
||||
);
|
||||
await card.locator(".feedback-main-link").click();
|
||||
await expect(page).toHaveURL(`/feedback/${id}`);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Feedback detail" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("end of feedback", { exact: false }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("feedback can be searched and filtered by community and time", async ({
|
||||
page,
|
||||
}) => {
|
||||
const recent = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const old = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await page.route("**/api/admin/v1/feedback", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "recent",
|
||||
communityId: "one",
|
||||
communityHost: "design.buzz.xyz",
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "bug",
|
||||
bodySummary: "Composer freezes after sleep",
|
||||
receivedAt: recent,
|
||||
},
|
||||
{
|
||||
id: "old",
|
||||
communityId: "two",
|
||||
communityHost: "engineering.buzz.xyz",
|
||||
submitterPubkey: "22".repeat(32),
|
||||
category: "praise",
|
||||
bodySummary: "Calls are much more reliable",
|
||||
receivedAt: old,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/feedback");
|
||||
await expect(page.getByText("2 of 2 submissions")).toBeVisible();
|
||||
await page.getByRole("searchbox", { name: "Search feedback" }).fill("calls");
|
||||
await expect(page.getByText("Calls are much more reliable")).toBeVisible();
|
||||
await expect(page.getByText("Composer freezes after sleep")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("searchbox", { name: "Search feedback" }).fill("");
|
||||
await page.getByLabel("Community").selectOption("design.buzz.xyz");
|
||||
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
|
||||
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
|
||||
|
||||
await page.getByLabel("Community").selectOption("all");
|
||||
await page.getByLabel("Received").selectOption("day");
|
||||
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
|
||||
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("feedback status is stored locally by feedback id", async ({ page }) => {
|
||||
await page.route("**/api/admin/v1/feedback", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "feedback-one",
|
||||
communityId: "one",
|
||||
communityHost: "design.buzz.xyz",
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "bug",
|
||||
bodySummary: "Composer freezes after sleep",
|
||||
receivedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/feedback");
|
||||
await page.getByRole("checkbox", { name: "Acted on" }).check();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("checkbox", { name: "Acted on" })).toBeChecked();
|
||||
await page.getByLabel("Status").selectOption("acted-on");
|
||||
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
|
||||
await page.getByLabel("Status").selectOption("pending");
|
||||
await expect(page.getByText("No matching feedback.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("feedback attachments render from imeta without raw markdown", async ({
|
||||
page,
|
||||
}) => {
|
||||
const id = "feedback-with-attachments";
|
||||
const imageUrl = `https://design.buzz.xyz/media/${"a".repeat(64)}.png`;
|
||||
const fileUrl = `https://design.buzz.xyz/media/${"b".repeat(64)}.txt`;
|
||||
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
communityId: "one",
|
||||
communityHost: "design.buzz.xyz",
|
||||
eventId: "31".repeat(32),
|
||||
submitterPubkey: "21".repeat(32),
|
||||
category: "bug",
|
||||
body: `Composer froze.\n\n[diagnostics.txt](${fileUrl})`,
|
||||
tags: [
|
||||
[
|
||||
"imeta",
|
||||
`url ${imageUrl}`,
|
||||
"m image/png",
|
||||
`x ${"a".repeat(64)}`,
|
||||
"size 48213",
|
||||
"dim 1280x720",
|
||||
"filename screenshot.png",
|
||||
],
|
||||
[
|
||||
"imeta",
|
||||
`url ${fileUrl}`,
|
||||
"m text/plain",
|
||||
`x ${"b".repeat(64)}`,
|
||||
"size 391",
|
||||
"filename diagnostics.txt",
|
||||
],
|
||||
],
|
||||
eventCreatedAt: "2026-07-17T17:25:00Z",
|
||||
receivedAt: "2026-07-17T17:30:00Z",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto(`/feedback/${id}`);
|
||||
await expect(
|
||||
page.getByText("Composer froze.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("![image]", { exact: false })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("img", { name: "screenshot.png" }),
|
||||
).toHaveAttribute(
|
||||
"src",
|
||||
`/api/admin/v1/feedback/${id}/attachments/${"a".repeat(64)}`,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("link", { name: /diagnostics.txt/ }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
`/api/admin/v1/feedback/${id}/attachments/${"b".repeat(64)}`,
|
||||
);
|
||||
const fileHeight = await page
|
||||
.locator(".file-attachment")
|
||||
.evaluate((element) => element.getBoundingClientRect().height);
|
||||
expect(fileHeight).toBeLessThan(100);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "playwright.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 4174 },
|
||||
build: { sourcemap: false },
|
||||
});
|
||||
Reference in New Issue
Block a user