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-10 05:10:31 +00:00
|
|
|
|
OutboxBundle,
|
|
|
|
|
|
OwnPost,
|
|
|
|
|
|
ScoutPost,
|
|
|
|
|
|
ThreadsAccount,
|
|
|
|
|
|
TrendItem,
|
|
|
|
|
|
} from "../domain/types";
|
|
|
|
|
|
import { useI18n } from "../i18n/I18nContext";
|
2026-07-13 03:18:08 +00:00
|
|
|
|
import { useFormatApiError } from "../lib/apiErrors";
|
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("");
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
const [posts, box, trendList, acc] = await Promise.all([
|
2026-07-10 05:10:31 +00:00
|
|
|
|
repos.scout.listPosts(),
|
|
|
|
|
|
repos.outbox.list(),
|
2026-07-13 03:18:08 +00:00
|
|
|
|
repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
|
2026-07-10 05:10:31 +00:00
|
|
|
|
repos.accounts.list(),
|
|
|
|
|
|
]);
|
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-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));
|
|
|
|
|
|
|
|
|
|
|
|
// 自己貼文:全帳或分帳合併
|
|
|
|
|
|
let owns: OwnPost[] = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
owns = await repos.ownPosts.list();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
owns = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!owns.length && usable.length) {
|
|
|
|
|
|
const chunks = await Promise.all(
|
|
|
|
|
|
usable.slice(0, 6).map((a) =>
|
|
|
|
|
|
repos.ownPosts.list(a.id).catch(() => [] as OwnPost[]),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
owns = chunks.flat();
|
|
|
|
|
|
}
|
2026-07-10 05:10:31 +00:00
|
|
|
|
setOwnPosts(owns);
|
2026-07-13 03:18:08 +00:00
|
|
|
|
|
|
|
|
|
|
// 待回提及
|
|
|
|
|
|
let mentionList: MentionItem[] = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (usable[0]?.id) {
|
|
|
|
|
|
mentionList = await repos.mentions.list(usable[0].id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
mentionList = await repos.mentions.list();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
mentionList = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
setMentions(mentionList);
|
|
|
|
|
|
} 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,
|
|
|
|
|
|
avatar_color: "#888",
|
|
|
|
|
|
} 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);
|
|
|
|
|
|
|
|
|
|
|
|
async function onRefreshTrends() {
|
|
|
|
|
|
setRefreshingTrends(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
try {
|
|
|
|
|
|
const list = await repos.inspiration.refreshTrends("all");
|
|
|
|
|
|
setTrends(
|
|
|
|
|
|
[...list].sort((a, b) => (b.heat || 0) - (a.heat || 0)).slice(0, 12),
|
|
|
|
|
|
);
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
2026-07-10 05:10:31 +00:00
|
|
|
|
<div className="hb-today-actions">
|
2026-07-13 03:18:08 +00:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void onRefreshTrends()}
|
|
|
|
|
|
disabled={refreshingTrends}
|
|
|
|
|
|
>
|
|
|
|
|
|
{refreshingTrends ? t("common.loading") : t("today.findTopic")}
|
|
|
|
|
|
</Button>
|
2026-07-10 05:10:31 +00:00
|
|
|
|
<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 ? (
|
|
|
|
|
|
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
</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" }}>
|
|
|
|
|
|
<p style={{ margin: 0, fontSize: "0.9rem" }}>
|
|
|
|
|
|
{t("today.outbox.summary", {
|
|
|
|
|
|
sent: sentToday,
|
|
|
|
|
|
running: runningOutbox.length,
|
|
|
|
|
|
failed: failedOutbox.length,
|
|
|
|
|
|
})}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{failedOutbox.slice(0, 2).map((o) => (
|
|
|
|
|
|
<p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}>
|
|
|
|
|
|
<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) => (
|
|
|
|
|
|
<p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<span className="text-muted" style={{ fontSize: "0.78rem" }}>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|