thread-master/apps/web/src/pages/TodayPage.tsx

832 lines
31 KiB
TypeScript
Raw Normal View History

2026-07-13 03:18:08 +00:00
import { useCallback, useEffect, useMemo, useState } from "react";
2026-07-10 05:10:31 +00:00
import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type {
2026-07-13 03:18:08 +00:00
MentionItem,
2026-07-23 05:56:42 +00:00
OutcomeSummary,
2026-07-10 05:10:31 +00:00
OutboxBundle,
OwnPost,
2026-08-03 05:52:02 +00:00
RadarToday,
2026-07-10 05:10:31 +00:00
ScoutPost,
ThreadsAccount,
TrendItem,
2026-07-23 05:56:42 +00:00
WeeklyCheckup,
2026-07-10 05:10:31 +00:00
} from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
2026-07-13 03:18:08 +00:00
import { useFormatApiError } from "../lib/apiErrors";
import { dismissRadarOnboarding, isRadarOnboardingDismissed } from "../lib/radarOnboarding";
2026-07-10 05:10:31 +00:00
import { loadScoutToday } from "../lib/scoutToday";
function isPendingScout(p: ScoutPost): boolean {
return p.outreach_status === "new" || p.outreach_status === "drafted";
}
function startOfLocalDayNano(): number {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime() * 1_000_000;
}
type AccountPulse = {
account: ThreadsAccount;
posts: number;
views: number;
likes: number;
replies: number;
topInsight?: string;
};
2026-07-13 03:18:08 +00:00
/**
*
* live repos tab
*/
2026-07-10 05:10:31 +00:00
export function TodayPage() {
const repos = useRepos();
const { tick, refresh } = useData();
const { t, locale } = useI18n();
2026-07-13 03:18:08 +00:00
const formatApiError = useFormatApiError();
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
2026-07-23 05:56:42 +00:00
const [trendMessage, setTrendMessage] = useState("");
2026-07-13 03:18:08 +00:00
const [refreshingTrends, setRefreshingTrends] = useState(false);
const [syncingPosts, setSyncingPosts] = useState(false);
2026-07-10 05:10:31 +00:00
const [scoutPosts, setScoutPosts] = useState<ScoutPost[]>([]);
const [outbox, setOutbox] = useState<OutboxBundle[]>([]);
const [trends, setTrends] = useState<TrendItem[]>([]);
const [ownPosts, setOwnPosts] = useState<OwnPost[]>([]);
2026-07-13 03:18:08 +00:00
const [mentions, setMentions] = useState<MentionItem[]>([]);
2026-07-10 05:10:31 +00:00
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [scoutDone, setScoutDone] = useState(0);
const [scoutGoal, setScoutGoal] = useState(8);
2026-07-23 05:56:42 +00:00
const [outcomeSummary, setOutcomeSummary] = useState<OutcomeSummary | null>(null);
const [checkup, setCheckup] = useState<WeeklyCheckup | null>(null);
2026-08-03 05:52:02 +00:00
const [radarToday, setRadarToday] = useState<RadarToday | null>(null);
const [onboardingDismissed, setOnboardingDismissed] = useState(() => isRadarOnboardingDismissed());
2026-07-10 05:10:31 +00:00
const dateLocale = locale === "en" ? "en-US" : "zh-TW";
2026-07-15 15:23:59 +00:00
const todayLabel = new Date().toLocaleDateString(dateLocale, {
month: "numeric",
day: "numeric",
weekday: "short",
});
2026-07-10 05:10:31 +00:00
2026-07-13 03:18:08 +00:00
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const scoutLocal = loadScoutToday();
setScoutGoal(scoutLocal.goalValue);
2026-08-03 05:52:02 +00:00
const [posts, box, trendList, acc, owns, mentionList, outcomes, latestCheckup, radar] =
2026-07-23 05:56:42 +00:00
await Promise.all([
repos.scout.listPosts(),
repos.outbox.list(),
repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
repos.accounts.list(),
repos.ownPosts.list().catch(() => [] as OwnPost[]),
repos.mentions.list().catch(() => [] as MentionItem[]),
repos.growth.getOutcomeSummary("week").catch(() => null),
repos.growth.getLatestCheckup().catch(() => null),
2026-08-03 05:52:02 +00:00
repos.radar.getToday().catch(() => null),
2026-07-23 05:56:42 +00:00
]);
setOutcomeSummary(outcomes);
setCheckup(latestCheckup);
2026-08-03 05:52:02 +00:00
setRadarToday(radar);
2026-07-13 03:18:08 +00:00
const usable = acc.filter((a) => a.is_usable);
setAccounts(usable.length ? usable : acc);
2026-07-10 05:10:31 +00:00
setScoutPosts(posts);
setOutbox(box);
2026-07-13 03:18:08 +00:00
setTrends(
[...trendList]
.sort((a, b) => (b.heat || 0) - (a.heat || 0))
.slice(0, 12),
);
2026-07-20 06:33:14 +00:00
setOwnPosts(owns);
setMentions(mentionList);
2026-07-13 03:18:08 +00:00
2026-07-15 15:23:59 +00:00
// 舊資料沒有發佈時間時不可拿歷史 published 總數冒充今日完成量。
const dayStart = startOfLocalDayNano();
const publishedN = posts.filter(
(p) =>
p.outreach_status === "published" &&
p.published_at != null &&
p.published_at >= dayStart,
).length;
2026-07-13 03:18:08 +00:00
setScoutDone(Math.max(scoutLocal.done, publishedN));
2026-07-20 06:33:14 +00:00
// 舊 API 若不支援全帳列表,分帳 fallback 在背景補齊,不阻塞首屏。
2026-07-13 03:18:08 +00:00
if (!owns.length && usable.length) {
2026-07-20 06:33:14 +00:00
void Promise.all(
usable.slice(0, 6).map((a) => repos.ownPosts.list(a.id).catch(() => [] as OwnPost[])),
).then((chunks) => setOwnPosts(chunks.flat()));
2026-07-13 03:18:08 +00:00
}
2026-07-20 06:33:14 +00:00
if (!mentionList.length && usable[0]?.id) {
void repos.mentions.list(usable[0].id).then(setMentions).catch(() => undefined);
2026-07-13 03:18:08 +00:00
}
} catch (e) {
setError(formatApiError(e, "today.loadFail"));
} finally {
setLoading(false);
}
}, [repos, formatApiError]);
useEffect(() => {
void load();
}, [load, tick]);
2026-07-10 05:10:31 +00:00
useEffect(() => {
const onStore = () => refresh();
window.addEventListener("harbor:store", onStore);
return () => window.removeEventListener("harbor:store", onStore);
}, [refresh]);
2026-07-15 15:23:59 +00:00
const dayStart = startOfLocalDayNano();
2026-07-10 05:10:31 +00:00
const pendingScout = useMemo(
() =>
scoutPosts
.filter(isPendingScout)
.slice()
.sort((a, b) => (b.score || 0) - (a.score || 0)),
[scoutPosts],
);
2026-07-13 03:18:08 +00:00
const pendingMentions = useMemo(
() => mentions.filter((m) => m.status === "pending"),
[mentions],
);
2026-07-10 05:10:31 +00:00
const failedOutbox = useMemo(
() => outbox.filter((o) => o.status === "partial_failed"),
[outbox],
);
const runningOutbox = useMemo(
() => outbox.filter((o) => o.status === "scheduling" || o.status === "active"),
[outbox],
);
const sentToday = useMemo(() => {
return outbox.filter((o) => {
if (o.status !== "completed") return false;
return o.updated_at >= dayStart;
}).length;
}, [outbox, dayStart]);
const goalPct = Math.min(100, Math.round((scoutDone / Math.max(1, scoutGoal)) * 100));
2026-07-13 03:18:08 +00:00
const topicCards = useMemo(() => trends.slice(0, 6), [trends]);
2026-07-10 05:10:31 +00:00
const accountPulses = useMemo((): AccountPulse[] => {
const byAcc = new Map<string, OwnPost[]>();
for (const p of ownPosts) {
const list = byAcc.get(p.account_id) || [];
list.push(p);
byAcc.set(p.account_id, list);
}
const rows: AccountPulse[] = [];
for (const acc of accounts) {
const posts = byAcc.get(acc.id) || [];
if (!posts.length && accounts.length > 3) continue;
const views = posts.reduce((s, p) => s + (p.view_count || 0), 0);
const likes = posts.reduce((s, p) => s + (p.like_count || 0), 0);
const replies = posts.reduce((s, p) => s + (p.reply_count || 0), 0);
const top = posts.slice().sort((a, b) => (b.view_count || 0) - (a.view_count || 0))[0];
rows.push({
account: acc,
posts: posts.length,
views,
likes,
replies,
topInsight: top?.insight || top?.formula_summary,
});
}
if (!rows.length) {
for (const [accId, posts] of byAcc) {
const acc =
accounts.find((a) => a.id === accId) ||
({
id: accId,
username: accId,
display_name: accId,
connection: "connected",
is_usable: true,
2026-07-30 01:25:34 +00:00
avatar_color: "var(--hb-muted)",
2026-07-10 05:10:31 +00:00
} as ThreadsAccount);
rows.push({
account: acc,
posts: posts.length,
views: posts.reduce((s, p) => s + (p.view_count || 0), 0),
likes: posts.reduce((s, p) => s + (p.like_count || 0), 0),
replies: posts.reduce((s, p) => s + (p.reply_count || 0), 0),
topInsight: posts[0]?.insight,
});
}
}
return rows
.sort((a, b) => b.views - a.views || b.likes - a.likes)
.slice(0, 4);
}, [accounts, ownPosts]);
2026-07-13 03:18:08 +00:00
const pendingPreview = pendingScout.slice(0, 5);
2026-07-23 05:56:42 +00:00
const hasOutcome = Boolean(
outcomeSummary &&
(outcomeSummary.reach ||
outcomeSummary.conversations ||
outcomeSummary.follows_possible ||
outcomeSummary.follows_confirmed ||
outcomeSummary.conversions),
);
// 三步引導只從 radarToday.empty_reason 推斷,不額外打 API
// 有結果或曾巡過(含暫停/失敗/沒命中)代表訂閱已建立。
const onboardingProfileDone = Boolean(
radarToday && radarToday.empty_reason !== "no_profile",
);
const onboardingWatchDone = Boolean(
radarToday &&
(radarToday.stats.total > 0 ||
["not_swept_yet", "sweep_failed", "no_hit", "all_watches_paused"].includes(
radarToday.empty_reason || "",
)),
);
const onboardingOpportunityDone = Boolean(radarToday && radarToday.stats.total > 0);
const onboardingAllDone =
onboardingProfileDone && onboardingWatchDone && onboardingOpportunityDone;
const showOnboarding = Boolean(radarToday) && !onboardingAllDone && !onboardingDismissed;
useEffect(() => {
if (onboardingAllDone && !onboardingDismissed) {
dismissRadarOnboarding();
setOnboardingDismissed(true);
}
}, [onboardingAllDone, onboardingDismissed]);
2026-07-13 03:18:08 +00:00
async function onRefreshTrends() {
setRefreshingTrends(true);
setError("");
2026-07-23 05:56:42 +00:00
setTrendMessage("");
2026-07-13 03:18:08 +00:00
try {
const list = await repos.inspiration.refreshTrends("all");
2026-07-23 05:56:42 +00:00
if (!list.length) {
throw new Error(t("today.trendsFail"));
}
2026-07-13 03:18:08 +00:00
setTrends(
[...list].sort((a, b) => (b.heat || 0) - (a.heat || 0)).slice(0, 12),
);
2026-07-23 05:56:42 +00:00
setTrendMessage(t("today.trendsUpdated", { n: list.length }));
2026-07-13 03:18:08 +00:00
refresh();
} catch (e) {
setError(formatApiError(e, "today.trendsFail"));
} finally {
setRefreshingTrends(false);
}
}
async function onSyncOwnPosts() {
const acc = accounts[0];
if (!acc) {
setError(t("today.needAccount"));
return;
}
setSyncingPosts(true);
setError("");
try {
const list = await repos.ownPosts.sync(acc.id);
setOwnPosts((prev) => {
const others = prev.filter((p) => p.account_id !== acc.id);
return [...list, ...others];
});
refresh();
} catch (e) {
setError(formatApiError(e, "today.syncPostsFail"));
} finally {
setSyncingPosts(false);
}
}
function topicHref(label: string) {
const q = new URLSearchParams({ tab: "inspire", topic: label });
return `/app/studio?${q.toString()}`;
}
if (loading && !scoutPosts.length && !trends.length && !outbox.length) {
return (
<>
<PageHeader title={t("nav.today")} description={todayLabel} />
<p className="text-muted">{t("common.loading")}</p>
</>
);
}
2026-07-10 05:10:31 +00:00
return (
<>
<PageHeader title={t("nav.today")} description={todayLabel} />
2026-07-13 03:18:08 +00:00
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
{showOnboarding ? (
<Card title={t("today.onboarding.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
<ul className="hb-today-list">
{[
{
key: "profile",
done: onboardingProfileDone,
2026-08-13 02:22:24 +00:00
to: "/app/policy",
label: t("today.onboarding.step.profile"),
hint: t("today.onboarding.step.profileHint"),
},
{
key: "watch",
done: onboardingWatchDone,
to: "/app/radar/watches",
label: t("today.onboarding.step.watch"),
hint: t("today.onboarding.step.watchHint"),
},
{
key: "opportunity",
done: onboardingOpportunityDone,
to: "/app/radar/today",
label: t("today.onboarding.step.opportunity"),
hint: t("today.onboarding.step.opportunityHint"),
},
].map((step) => (
<li key={step.key}>
<Link to={step.to} className="hb-today-list__item">
<span className="hb-today-list__meta">
<Badge tone={step.done ? "success" : "neutral"}>
{step.done ? t("today.onboarding.step.done") : t("today.onboarding.step.go")}
</Badge>{" "}
{step.label}
</span>
<span className="hb-today-list__text">{step.hint}</span>
</Link>
</li>
))}
</ul>
<Button
type="button"
variant="ghost"
onClick={() => {
dismissRadarOnboarding();
setOnboardingDismissed(true);
}}
>
{t("today.onboarding.dismiss")}
</Button>
</Card>
) : null}
2026-08-03 05:52:02 +00:00
<Card title={t("today.radar.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
{radarToday && radarToday.stats.total > 0 ? (
<>
<div className="hb-today-metrics" role="group" aria-label={t("today.radar.title")}>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.radar.total")}</span>
<span className="hb-today-metric__value">{radarToday.stats.total}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.radar.high")}</span>
<span className="hb-today-metric__value">{radarToday.stats.high}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.radar.mid")}</span>
<span className="hb-today-metric__value">{radarToday.stats.mid}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.radar.low")}</span>
<span className="hb-today-metric__value">{radarToday.stats.low}</span>
</div>
</div>
<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">
{t("today.radar.open")}
</Link>
</>
) : (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{radarToday?.empty_hint || t("today.radar.empty")}{" "}
<Link
to={
radarToday?.empty_reason === "no_profile"
2026-08-13 02:22:24 +00:00
? "/app/policy"
: "/app/radar/watches"
}
>
2026-08-03 05:52:02 +00:00
{radarToday?.empty_reason === "no_profile"
? t("today.radar.goProfile")
: t("today.radar.goWatches")}
</Link>
</p>
)}
</Card>
2026-07-23 05:56:42 +00:00
<Card title={t("today.outcome.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
<div className="hb-today-metrics" role="group" aria-label={t("today.outcome.title")}>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.outcome.reach")}</span>
<span className="hb-today-metric__value">{outcomeSummary?.reach ?? 0}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.outcome.conversations")}</span>
<span className="hb-today-metric__value">{outcomeSummary?.conversations ?? 0}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.outcome.follows")}</span>
<span className="hb-today-metric__value">
{outcomeSummary?.follows_possible ?? 0}
{outcomeSummary?.follows_confirmed ? (
<span className="hb-today-metric__den">
{t("today.outcome.followsConfirmedHint", { n: outcomeSummary.follows_confirmed })}
</span>
) : null}
</span>
<span className="hb-today-metric__hint">{t("today.outcome.followsHint")}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.outcome.conversions")}</span>
<span className="hb-today-metric__value">{outcomeSummary?.conversions ?? 0}</span>
{outcomeSummary?.conversion_amount ? (
<span className="hb-today-metric__hint">
${Math.round(outcomeSummary.conversion_amount)}
</span>
) : null}
</div>
</div>
{!hasOutcome ? (
2026-07-30 01:25:34 +00:00
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
2026-07-23 05:56:42 +00:00
{t("today.outcome.emptyHint")}{" "}
<Link to="/app/scout">{t("today.goScout")}</Link>
</p>
) : null}
{checkup ? (
<div style={{ marginTop: "0.75rem" }}>
<p className="text-muted" style={{ margin: 0 }}>
{t("today.checkup.prefix")}
{checkup.summary}
</p>
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1.2rem" }}>
{checkup.actions.slice(0, 3).map((a) => (
<li key={a.title}>
<Link
to={
a.deeplink === "scout"
? "/app/scout"
: a.deeplink === "studio_compose"
? "/app/studio?tab=compose"
: a.deeplink === "studio_inspire"
? "/app/studio?tab=inspire"
: a.deeplink === "crew_persona"
? "/app/crew"
: a.deeplink === "outbox"
? "/app/outbox"
: "/app/today"
}
>
{a.title}
</Link>
<span className="text-muted"> {a.reason}</span>
</li>
))}
</ul>
</div>
) : (
2026-07-30 01:25:34 +00:00
<p className="text-muted" style={{ margin: "0.75rem 0 0", fontSize: "var(--hb-text-sm)" }}>
2026-07-23 05:56:42 +00:00
{t("today.checkup.empty")}
</p>
)}
</Card>
2026-07-10 05:10:31 +00:00
<div className="hb-today-actions">
<Link to="/app/scout">
<Button type="button" variant={pendingScout.length ? "primary" : "ghost"}>
{pendingScout.length
? t("today.pendingRepliesN", { n: pendingScout.length })
: t("today.pendingReplies")}
</Button>
</Link>
2026-07-13 03:18:08 +00:00
<Link to="/app/studio?tab=compose">
2026-07-10 05:10:31 +00:00
<Button type="button" variant="ghost">
{t("today.newThread")}
</Button>
</Link>
2026-07-13 03:18:08 +00:00
<Button type="button" variant="ghost" onClick={() => void load()} disabled={loading}>
{t("today.reload")}
</Button>
2026-07-10 05:10:31 +00:00
</div>
{/* 數值列 */}
<div className="hb-today-metrics" role="group" aria-label={t("today.metricsAria")}>
<Link to="/app/scout" className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.metric.pending")}</span>
<span className="hb-today-metric__value">{pendingScout.length}</span>
<span className="hb-today-metric__hint">{t("today.metric.pendingHint")}</span>
</Link>
2026-07-13 03:18:08 +00:00
<div className="hb-today-metric" title={t("today.metric.doneGoalHint")}>
2026-07-10 05:10:31 +00:00
<span className="hb-today-metric__label">{t("today.metric.doneGoal")}</span>
<span className="hb-today-metric__value">
{scoutDone}
<span className="hb-today-metric__den">/{scoutGoal}</span>
</span>
<div className="hb-progress hb-progress--sm" aria-hidden>
<div className="hb-progress__bar" style={{ width: `${goalPct}%` }} />
</div>
</div>
<Link to="/app/outbox" className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.metric.sentToday")}</span>
<span className="hb-today-metric__value">{sentToday}</span>
<span className="hb-today-metric__hint">
{runningOutbox.length
? t("today.metric.running", { n: runningOutbox.length })
: t("today.metric.sentDone")}
</span>
</Link>
<Link
to="/app/outbox"
className={`hb-today-metric${failedOutbox.length ? " is-alert" : ""}`}
>
<span className="hb-today-metric__label">{t("today.metric.failed")}</span>
<span className="hb-today-metric__value">{failedOutbox.length}</span>
<span className="hb-today-metric__hint">
{failedOutbox.length ? t("today.metric.needAction") : t("today.metric.ok")}
</span>
</Link>
2026-07-13 03:18:08 +00:00
<Link to="/app/studio?tab=mentions" className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.metric.mentions")}</span>
<span className="hb-today-metric__value">{pendingMentions.length}</span>
<span className="hb-today-metric__hint">{t("today.metric.mentionsHint")}</span>
</Link>
2026-07-10 05:10:31 +00:00
</div>
2026-07-13 03:18:08 +00:00
{/* 待回覆 · 海巡 */}
2026-07-10 05:10:31 +00:00
<Card title={t("today.pending.title", { n: pendingScout.length })}>
{pendingPreview.length === 0 ? (
<EmptyState
title={t("today.pending.empty")}
action={
<Link to="/app/scout">
<Button type="button">{t("today.goScout")}</Button>
</Link>
}
/>
) : (
<div className="hb-stack">
<ul className="hb-today-list">
{pendingPreview.map((p) => (
<li key={p.id}>
<Link to="/app/scout" className="hb-today-list__item">
<span className="hb-today-list__meta">
@{p.author}
{p.search_tag ? ` · ${p.search_tag}` : ""}
{typeof p.score === "number" ? ` · ${Math.round(p.score)}` : ""}
2026-07-13 03:18:08 +00:00
{p.outreach_status === "drafted" ? (
<>
{" · "}
<Badge tone="brand">{t("today.badge.drafted")}</Badge>
</>
) : null}
2026-07-10 05:10:31 +00:00
</span>
<span className="hb-today-list__text">
{p.text.slice(0, 96)}
{p.text.length > 96 ? "…" : ""}
</span>
2026-07-13 03:18:08 +00:00
{p.opportunity ? (
<span className="hb-today-list__meta">{p.opportunity}</span>
) : null}
2026-07-10 05:10:31 +00:00
</Link>
</li>
))}
</ul>
{pendingScout.length > pendingPreview.length ? (
2026-07-30 01:25:34 +00:00
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
2026-07-10 05:10:31 +00:00
{t("today.pending.more", { n: pendingScout.length - pendingPreview.length })}{" "}
<Link to="/app/scout">{t("today.pending.handle")}</Link>
</p>
) : (
<Link to="/app/scout">
<Button type="button" variant="ghost">
{t("today.pending.start")}
</Button>
</Link>
)}
</div>
)}
</Card>
{/* 找話題 */}
<Card title={t("today.topics.title")}>
2026-07-13 03:18:08 +00:00
<div className="hb-today-actions" style={{ margin: "0 0 0.5rem" }}>
<Button
type="button"
variant="ghost"
disabled={refreshingTrends}
onClick={() => void onRefreshTrends()}
>
{refreshingTrends ? t("common.loading") : t("today.refreshTopics")}
</Button>
2026-07-23 05:56:42 +00:00
{trendMessage ? (
<span className="text-muted" role="status">
{trendMessage}
</span>
) : null}
2026-07-13 03:18:08 +00:00
</div>
2026-07-10 05:10:31 +00:00
{topicCards.length === 0 ? (
<EmptyState
title={t("today.topics.empty")}
action={
2026-07-13 03:18:08 +00:00
<div className="hb-today-actions" style={{ margin: 0 }}>
<Button
type="button"
onClick={() => void onRefreshTrends()}
disabled={refreshingTrends}
>
{refreshingTrends ? t("common.loading") : t("today.refreshTopics")}
</Button>
<Link to="/app/studio?tab=inspire">
<Button type="button" variant="ghost">
{t("today.goStudio")}
</Button>
</Link>
</div>
2026-07-10 05:10:31 +00:00
}
/>
) : (
<div className="hb-stack">
<ul className="hb-today-list">
{topicCards.map((item) => (
<li key={item.id}>
2026-07-13 03:18:08 +00:00
<Link to={topicHref(item.label)} className="hb-today-list__item">
2026-07-10 05:10:31 +00:00
<span className="hb-today-list__meta">
{item.label}
2026-07-13 03:18:08 +00:00
{typeof item.heat === "number" && item.heat > 0 ? (
2026-07-10 05:10:31 +00:00
<Badge tone="brand">{t("today.heat", { n: item.heat })}</Badge>
) : null}
2026-07-13 03:18:08 +00:00
{item.source_label ? (
<span className="text-muted"> · {item.source_label}</span>
) : null}
2026-07-10 05:10:31 +00:00
</span>
<span className="hb-today-list__text">
2026-07-13 03:18:08 +00:00
{item.summary?.slice(0, 100) ||
item.samples?.[0] ||
t("today.topicAngle")}
2026-07-10 05:10:31 +00:00
{(item.summary?.length || 0) > 100 ? "…" : ""}
</span>
</Link>
</li>
))}
</ul>
<div className="hb-today-actions" style={{ margin: 0 }}>
2026-07-13 03:18:08 +00:00
<Link to="/app/studio?tab=inspire">
2026-07-10 05:10:31 +00:00
<Button type="button" variant="ghost">
{t("today.moreInspire")}
</Button>
</Link>
2026-07-13 03:18:08 +00:00
{topicCards[0] ? (
<Link to={topicHref(topicCards[0].label)}>
<Button type="button" variant="ghost">
{t("today.useTopic")}
</Button>
</Link>
) : null}
2026-07-10 05:10:31 +00:00
</div>
</div>
)}
</Card>
{/* 發送摘要 */}
<Card title={t("today.outbox.title")}>
{failedOutbox.length === 0 && runningOutbox.length === 0 && sentToday === 0 ? (
<p className="text-muted" style={{ margin: 0 }}>
{t("today.outbox.empty")}{" "}
2026-07-13 03:18:08 +00:00
<Link to="/app/studio?tab=compose">{t("today.newThread")}</Link>
2026-07-10 05:10:31 +00:00
{t("today.outbox.emptyMid")}{" "}
<Link to="/app/outbox">{t("nav.outbox")}</Link>
{t("today.outbox.emptyEnd")}
</p>
) : (
<div className="hb-stack" style={{ gap: "0.5rem" }}>
2026-07-30 01:25:34 +00:00
<p style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
2026-07-10 05:10:31 +00:00
{t("today.outbox.summary", {
sent: sentToday,
running: runningOutbox.length,
failed: failedOutbox.length,
})}
</p>
{failedOutbox.slice(0, 2).map((o) => (
2026-07-30 01:25:34 +00:00
<p key={o.id} style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
2026-07-10 05:10:31 +00:00
<Badge tone="danger">{t("today.badge.failed")}</Badge>{" "}
2026-07-13 03:18:08 +00:00
<Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
2026-07-10 05:10:31 +00:00
</p>
))}
{runningOutbox.slice(0, 2).map((o) => (
2026-07-30 01:25:34 +00:00
<p key={o.id} style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
2026-07-10 05:10:31 +00:00
<Badge tone="brand">
2026-07-13 03:18:08 +00:00
{o.status === "scheduling"
? t("today.badge.scheduling")
: t("today.badge.sending")}
2026-07-10 05:10:31 +00:00
</Badge>{" "}
2026-07-13 03:18:08 +00:00
<Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
2026-07-10 05:10:31 +00:00
</p>
))}
<Link to="/app/outbox">
<Button type="button" variant="ghost">
{t("today.openOutbox")}
</Button>
</Link>
</div>
)}
</Card>
2026-07-13 03:18:08 +00:00
{/* 帳號成效 */}
2026-07-10 05:10:31 +00:00
<Card title={t("today.accounts.title")}>
{accountPulses.length === 0 ? (
<EmptyState
title={t("today.accounts.empty")}
action={
2026-07-13 03:18:08 +00:00
<div className="hb-today-actions" style={{ margin: 0 }}>
{accounts.length > 0 ? (
<Button
type="button"
onClick={() => void onSyncOwnPosts()}
disabled={syncingPosts}
>
{syncingPosts ? t("common.loading") : t("today.syncPosts")}
</Button>
) : (
<Link to="/app/crew">
<Button type="button" variant="ghost">
{t("nav.crew")}
</Button>
</Link>
)}
</div>
2026-07-10 05:10:31 +00:00
}
/>
) : (
<div className="hb-stack">
<ul className="hb-today-account-list">
{accountPulses.map((row) => (
<li key={row.account.id} className="hb-today-account">
<div className="hb-today-account__head">
<strong>@{row.account.username}</strong>
2026-07-30 01:25:34 +00:00
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
2026-07-10 05:10:31 +00:00
{t("today.postsCount", { n: row.posts })}
</span>
</div>
<div className="hb-today-account__stats">
<span>
2026-07-13 03:18:08 +00:00
{t("today.views")}{" "}
<strong>{row.views.toLocaleString(dateLocale)}</strong>
2026-07-10 05:10:31 +00:00
</span>
<span>
{t("today.likes")} <strong>{row.likes}</strong>
</span>
<span>
{t("today.repliesShort")} <strong>{row.replies}</strong>
</span>
</div>
{row.topInsight ? (
<p className="hb-today-account__insight">{row.topInsight}</p>
) : null}
</li>
))}
</ul>
<div className="hb-today-actions" style={{ margin: 0 }}>
<Link to="/app/insights">
<Button type="button">{t("today.fullInsights")}</Button>
</Link>
<Link to="/app/studio?tab=posts">
<Button type="button" variant="ghost">
{t("today.viewPosts")}
</Button>
</Link>
2026-07-13 03:18:08 +00:00
<Button
type="button"
variant="ghost"
onClick={() => void onSyncOwnPosts()}
disabled={syncingPosts || !accounts.length}
>
{syncingPosts ? t("common.loading") : t("today.syncPosts")}
</Button>
2026-07-10 05:10:31 +00:00
<Link to="/app/crew">
<Button type="button" variant="ghost">
{t("today.manageAccounts")}
</Button>
</Link>
</div>
</div>
)}
</Card>
</>
);
}