fix(podcast): the left rail showed the workforce, not the episodes
The PODCAST tier had no branch in the left column, so it fell through to the default — the org/company/team roster. Opening the podcast page showed a list of agents, which is the one thing on that screen that has nothing to do with it. `PodcastList` now fills the rail with one card per episode (date, title, duration, size), the same shape `MissionsList` and `RepoList` give their tiers: objects in the rail, the selected one in the canvas. It selects the newest on first load so the canvas is never blank, and refreshes on the render sweep's own two-minute cadence so a new episode appears without a reload. `PodcastPanel` loses the duplicated list and becomes what a canvas should be: how to subscribe, and the selected episode with a player. The empty state points at the Continuous Research mission that produces one rather than just saying there is nothing. Verified on the served page: PODCAST renders between AGENT and REPOS. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fe45f72f09
commit
ba98c29481
@@ -230,7 +230,9 @@ pub async fn list_episodes(
|
|||||||
"SELECT e.id, e.episode_date, e.title, e.bytes, e.duration_secs,
|
"SELECT e.id, e.episode_date, e.title, e.bytes, e.duration_secs,
|
||||||
e.rendered_by, e.created_at, e.mission_id, m.title AS mission_title
|
e.rendered_by, e.created_at, e.mission_id, m.title AS mission_title
|
||||||
FROM podcast_episodes e
|
FROM podcast_episodes e
|
||||||
JOIN missions m ON m.id = e.mission_id
|
-- LEFT: an episode outlives its mission (migration 0080). An inner
|
||||||
|
-- join would hide exactly the back-catalogue that change protects.
|
||||||
|
LEFT JOIN missions m ON m.id = e.mission_id
|
||||||
WHERE e.workspace_id = $1 AND e.bytes > 0
|
WHERE e.workspace_id = $1 AND e.bytes > 0
|
||||||
ORDER BY e.created_at DESC
|
ORDER BY e.created_at DESC
|
||||||
LIMIT 50",
|
LIMIT 50",
|
||||||
@@ -246,8 +248,10 @@ pub async fn list_episodes(
|
|||||||
let created: time::OffsetDateTime = r.get("created_at");
|
let created: time::OffsetDateTime = r.get("created_at");
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": r.get::<uuid::Uuid, _>("id"),
|
"id": r.get::<uuid::Uuid, _>("id"),
|
||||||
"missionId": r.get::<uuid::Uuid, _>("mission_id"),
|
"missionId": r.get::<Option<uuid::Uuid>, _>("mission_id"),
|
||||||
"missionTitle": r.get::<String, _>("mission_title"),
|
"missionTitle": r
|
||||||
|
.get::<Option<String>, _>("mission_title")
|
||||||
|
.unwrap_or_else(|| "(mission deleted)".to_string()),
|
||||||
"title": r.get::<String, _>("title"),
|
"title": r.get::<String, _>("title"),
|
||||||
"date": r.get::<String, _>("episode_date"),
|
"date": r.get::<String, _>("episode_date"),
|
||||||
"bytes": r.get::<i64, _>("bytes"),
|
"bytes": r.get::<i64, _>("bytes"),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { LevelUpInbox } from "./LevelUpInbox";
|
|||||||
import { LevelUpDrawer } from "./LevelUpDrawer";
|
import { LevelUpDrawer } from "./LevelUpDrawer";
|
||||||
import { proposeForAgent } from "@/lib/api/level-up";
|
import { proposeForAgent } from "@/lib/api/level-up";
|
||||||
import { MissionCanvas } from "./MissionCanvas";
|
import { MissionCanvas } from "./MissionCanvas";
|
||||||
|
import { PodcastList } from "@/components/dashboard/PodcastList";
|
||||||
import { PodcastPanel } from "@/components/dashboard/PodcastPanel";
|
import { PodcastPanel } from "@/components/dashboard/PodcastPanel";
|
||||||
import { RepoList } from "./RepoList";
|
import { RepoList } from "./RepoList";
|
||||||
import { RepoCanvas } from "./RepoCanvas";
|
import { RepoCanvas } from "./RepoCanvas";
|
||||||
@@ -263,6 +264,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
|
|
||||||
const fallbackOrg = orgs[0] ?? EMPTY_ORG;
|
const fallbackOrg = orgs[0] ?? EMPTY_ORG;
|
||||||
const [tier, setTier] = useState<Tier>("claw");
|
const [tier, setTier] = useState<Tier>("claw");
|
||||||
|
const [podcastSel, setPodcastSel] = useState<string | null>(null);
|
||||||
// Collapse the ~252px context sidebar (ResearchList / LoopsList /
|
// Collapse the ~252px context sidebar (ResearchList / LoopsList /
|
||||||
// etc.) into a thin 32px rail so the canvas gets the extra width.
|
// etc.) into a thin 32px rail so the canvas gets the extra width.
|
||||||
// Persisted in localStorage via useSyncExternalStore — SSR returns
|
// Persisted in localStorage via useSyncExternalStore — SSR returns
|
||||||
@@ -1007,6 +1009,8 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
<LevelUpInbox />
|
<LevelUpInbox />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : isPodcast ? (
|
||||||
|
<PodcastList selectedId={podcastSel} onSelect={setPodcastSel} />
|
||||||
) : isRepos ? (
|
) : isRepos ? (
|
||||||
<RepoList
|
<RepoList
|
||||||
selectedId={repoSel}
|
selectedId={repoSel}
|
||||||
@@ -1132,7 +1136,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : isPodcast ? (
|
) : isPodcast ? (
|
||||||
<PodcastPanel />
|
<PodcastPanel selectedId={podcastSel} />
|
||||||
) : isRepos ? (
|
) : isRepos ? (
|
||||||
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
|
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
|
||||||
) : isWorld ? (
|
) : isWorld ? (
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { listEpisodes, type Episode } from "@/lib/api/podcast";
|
||||||
|
|
||||||
|
const mono = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
|
||||||
|
|
||||||
|
/// The left rail on the PODCAST tier: one card per episode.
|
||||||
|
///
|
||||||
|
/// Mirrors `MissionsList` and `RepoList` — the tier's objects live in the rail
|
||||||
|
/// and the canvas shows the selected one. Before this the rail fell through to
|
||||||
|
/// the workforce roster, so the podcast tier showed a list of agents.
|
||||||
|
export function PodcastList({
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [eps, setEps] = useState<Episode[]>([]);
|
||||||
|
const [unrenderable, setUnrenderable] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await listEpisodes();
|
||||||
|
setEps(list.episodes);
|
||||||
|
setUnrenderable(list.unrenderable);
|
||||||
|
setErr(null);
|
||||||
|
// Select the newest on first load so the canvas is never blank.
|
||||||
|
if (!selectedId && list.episodes.length > 0) onSelect(list.episodes[0].id);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : "could not load episodes");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedId, onSelect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// Matches the render sweep's cadence, so a new episode appears on its own.
|
||||||
|
const t = setInterval(() => void load(), 120_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", minHeight: 0, flex: 1 }}>
|
||||||
|
<div style={{ padding: "12px 14px 8px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 10.5, letterSpacing: ".14em", color: "#5a5a62" }}>
|
||||||
|
EPISODES
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: "1 1 0", minHeight: 0, overflowY: "auto", padding: "10px 10px 14px" }}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ ...note, color: "#8a8a92" }}>loading…</div>
|
||||||
|
) : err ? (
|
||||||
|
<div style={{ ...note, color: "#ff8a7a" }}>{err}</div>
|
||||||
|
) : eps.length === 0 ? (
|
||||||
|
<div style={{ padding: "18px 10px", textAlign: "center" }}>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10.5, letterSpacing: ".14em", color: "#5a5a62", marginBottom: 8 }}>
|
||||||
|
NOTHING YET
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: "#cfcfd5", lineHeight: 1.55, marginBottom: 6 }}>
|
||||||
|
No episodes recorded.
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.6 }}>
|
||||||
|
Start a <span style={{ color: "#e0b0ff" }}>Continuous Research</span> mission.
|
||||||
|
The episode is voiced a few minutes after it finishes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
eps.map((e) => {
|
||||||
|
const on = e.id === selectedId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
onClick={() => onSelect(e.id)}
|
||||||
|
style={{
|
||||||
|
...card,
|
||||||
|
borderColor: on ? "#5a4a6a" : "#24242a",
|
||||||
|
background: on ? "#1a1620" : "#141418",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".1em", color: "#8a7a9a", marginBottom: 5 }}>
|
||||||
|
{e.date}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: "#e8e8ea", lineHeight: 1.4, marginBottom: 6 }}>
|
||||||
|
{e.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#6a6a72" }}>
|
||||||
|
{fmtDur(e.durationSecs)} · {(e.bytes / 1_048_576).toFixed(1)} MB
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unrenderable > 0 && (
|
||||||
|
<div style={{ ...note, color: "#c9a227", marginTop: 10 }}>
|
||||||
|
{unrenderable} mission{unrenderable === 1 ? "" : "s"} produced no audio —
|
||||||
|
the script was cleaned up before the renderer reached it.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDur(s: number): string {
|
||||||
|
return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card: React.CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
border: "1px solid #24242a",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
marginBottom: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background var(--duration-fast, .12s), border-color var(--duration-fast, .12s)",
|
||||||
|
};
|
||||||
|
const note: React.CSSProperties = {
|
||||||
|
fontSize: 11.5,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
padding: "8px 10px",
|
||||||
|
};
|
||||||
@@ -3,48 +3,37 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { listEpisodes, subscription, episodeSrc, type Episode } from "@/lib/api/podcast";
|
import { listEpisodes, subscription, episodeSrc, type Episode } from "@/lib/api/podcast";
|
||||||
|
|
||||||
/// The podcast tier.
|
/// The PODCAST canvas: how to subscribe, and the selected episode.
|
||||||
///
|
///
|
||||||
/// Two jobs, in this order: hand over the feed URL so a podcast app can take
|
/// The episode LIST lives in the left rail (`PodcastList`), the same shape as
|
||||||
/// over, and list what has been produced. The player here is for checking an
|
/// missions and repos. The player here is for checking an episode at a desk —
|
||||||
/// episode at a desk — the feed is how it is actually listened to, because a
|
/// the feed is how it is actually listened to, because a browser tab does not
|
||||||
/// browser tab does not download overnight, play offline or survive a lock
|
/// download overnight, play offline, or survive a lock screen.
|
||||||
/// screen.
|
export function PodcastPanel({ selectedId }: { selectedId: string | null }) {
|
||||||
export function PodcastPanel() {
|
|
||||||
const [eps, setEps] = useState<Episode[]>([]);
|
const [eps, setEps] = useState<Episode[]>([]);
|
||||||
const [unrenderable, setUnrenderable] = useState(0);
|
|
||||||
const [feed, setFeed] = useState<string | null>(null);
|
const [feed, setFeed] = useState<string | null>(null);
|
||||||
const [reachable, setReachable] = useState(true);
|
const [reachable, setReachable] = useState(true);
|
||||||
const [playing, setPlaying] = useState<{ id: string; url: string } | null>(null);
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [list, sub] = await Promise.all([listEpisodes(), subscription()]);
|
const [list, sub] = await Promise.all([listEpisodes(), subscription()]);
|
||||||
setEps(list.episodes);
|
setEps(list.episodes);
|
||||||
setUnrenderable(list.unrenderable);
|
|
||||||
setFeed(sub.feedUrl);
|
setFeed(sub.feedUrl);
|
||||||
setReachable(sub.reachable);
|
setReachable(sub.reachable);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : "could not load episodes");
|
setErr(e instanceof Error ? e.message : "could not load");
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
// The render sweep runs every two minutes; refresh on that cadence so a new
|
|
||||||
// episode appears without the operator reloading the page.
|
|
||||||
const t = setInterval(() => void load(), 120_000);
|
const t = setInterval(() => void load(), 120_000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const play = (id: string) => setPlaying({ id, url: episodeSrc(id) });
|
|
||||||
|
|
||||||
const copyFeed = async () => {
|
const copyFeed = async () => {
|
||||||
if (!feed) return;
|
if (!feed) return;
|
||||||
await navigator.clipboard.writeText(feed);
|
await navigator.clipboard.writeText(feed);
|
||||||
@@ -52,41 +41,38 @@ export function PodcastPanel() {
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ep = eps.find((e) => e.id === selectedId) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "24px 28px", overflowY: "auto", height: "100%" }}>
|
<div style={{ padding: "26px 30px", overflowY: "auto", height: "100%" }}>
|
||||||
<h2 style={{ margin: 0, fontSize: 15, letterSpacing: ".08em", color: "#e8e8ea" }}>
|
<h2 style={{ margin: 0, fontSize: 15, letterSpacing: ".08em", color: "#e8e8ea" }}>
|
||||||
RESEARCH PODCAST
|
RESEARCH PODCAST
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: "6px 0 20px", fontSize: 12.5, color: "#8a8a92", maxWidth: 620 }}>
|
<p style={{ margin: "6px 0 22px", fontSize: 12.5, color: "#8a8a92", maxWidth: 640 }}>
|
||||||
Papers read against your projects, voiced each morning. Subscribe once and
|
Papers read against your projects, voiced each morning. Subscribe once and
|
||||||
episodes download overnight.
|
episodes arrive on their own.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Subscribe — the point of the screen. */}
|
|
||||||
<div style={panel}>
|
<div style={panel}>
|
||||||
<div style={{ fontSize: 11, letterSpacing: ".08em", color: "#8a8a92", marginBottom: 8 }}>
|
<div style={{ fontSize: 11, letterSpacing: ".08em", color: "#8a8a92", marginBottom: 9 }}>
|
||||||
SUBSCRIBE
|
SUBSCRIBE
|
||||||
</div>
|
</div>
|
||||||
{feed ? (
|
{feed ? (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
<code style={urlBox}>{feed.replace(/token=[^&]+/, "token=…")}</code>
|
<code style={urlBox}>{feed.replace(/token=[^&]+/, "token=…")}</code>
|
||||||
<button onClick={copyFeed} style={btn}>
|
<button onClick={copyFeed} style={btn}>{copied ? "copied" : "copy"}</button>
|
||||||
{copied ? "copied" : "copy"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{!reachable && (
|
{!reachable && (
|
||||||
<p style={{ margin: "10px 0 0", fontSize: 11.5, color: "#c9a227", lineHeight: 1.5 }}>
|
<p style={{ ...small, color: "#c9a227" }}>
|
||||||
This address is local to the server, so a phone cannot reach it.
|
This address is local to the server, so a phone cannot reach it. Set
|
||||||
Set CLAWMATES_PUBLIC_URL to a hostname your devices can resolve
|
CLAWMATES_PUBLIC_URL to a hostname your devices can resolve.
|
||||||
before subscribing.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p style={{ margin: "10px 0 0", fontSize: 11.5, color: "#7a7a82", lineHeight: 1.5 }}>
|
<p style={small}>
|
||||||
Paste into Apple Podcasts (File → Follow a Show by URL), Overcast or
|
Apple Podcasts: File → Follow a Show by URL. Also Overcast, Pocket
|
||||||
Pocket Casts. The link carries your session token, so treat it like a
|
Casts. The link carries your session token, so treat it like a
|
||||||
password — anyone with it can read this feed, and revoking your session
|
password — revoking your session revokes the feed.
|
||||||
revokes it.
|
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -96,77 +82,42 @@ export function PodcastPanel() {
|
|||||||
|
|
||||||
{err && <div style={{ ...panel, color: "#e0796f" }}>{err}</div>}
|
{err && <div style={{ ...panel, color: "#e0796f" }}>{err}</div>}
|
||||||
|
|
||||||
{unrenderable > 0 && (
|
{ep ? (
|
||||||
<div style={{ ...panel, borderColor: "#4a3a2a" }}>
|
<div style={panel}>
|
||||||
<span style={{ fontSize: 12, color: "#c9a227" }}>
|
<div style={{ fontSize: 11, letterSpacing: ".08em", color: "#8a8a92", marginBottom: 9 }}>
|
||||||
{unrenderable} mission{unrenderable === 1 ? "" : "s"} produced no audio —
|
NOW SELECTED
|
||||||
the script was cleaned up before the renderer reached it. The text is
|
</div>
|
||||||
still on that mission's vault branch.
|
<div style={{ fontSize: 15, color: "#e8e8ea", marginBottom: 6 }}>{ep.title}</div>
|
||||||
</span>
|
<div style={{ fontSize: 11.5, color: "#7a7a82", marginBottom: 14 }}>
|
||||||
</div>
|
{ep.date} · {Math.floor(ep.durationSecs / 60)}m{" "}
|
||||||
)}
|
{String(ep.durationSecs % 60).padStart(2, "0")}s ·{" "}
|
||||||
|
{(ep.bytes / 1_048_576).toFixed(1)} MB · {ep.renderedBy}
|
||||||
<div style={{ fontSize: 11, letterSpacing: ".08em", color: "#8a8a92", margin: "22px 0 10px" }}>
|
</div>
|
||||||
EPISODES
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
</div>
|
<audio src={episodeSrc(ep.id)} controls style={{ width: "100%" }} />
|
||||||
|
<p style={small}>From mission “{ep.missionTitle}”.</p>
|
||||||
{loading ? (
|
|
||||||
<div style={{ fontSize: 12, color: "#8a8a92" }}>loading…</div>
|
|
||||||
) : eps.length === 0 ? (
|
|
||||||
<div style={{ fontSize: 12.5, color: "#8a8a92", lineHeight: 1.6 }}>
|
|
||||||
No episodes yet. Launch a <strong style={{ color: "#c8c8cc" }}>Continuous
|
|
||||||
Research</strong> mission from Missions — it harvests new papers, reads
|
|
||||||
them against the projects in your mission description, and the episode is
|
|
||||||
rendered a few minutes after the mission finishes.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
eps.map((e) => (
|
<div style={{ ...panel, color: "#8a8a92", fontSize: 12.5, lineHeight: 1.6 }}>
|
||||||
<div key={e.id} style={row}>
|
Pick an episode on the left, or start a{" "}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<strong style={{ color: "#e0b0ff" }}>Continuous Research</strong> mission
|
||||||
<div style={{ fontSize: 13, color: "#e8e8ea", marginBottom: 3 }}>{e.title}</div>
|
from Missions. It harvests new papers, reads them against the projects in
|
||||||
<div style={{ fontSize: 11, color: "#7a7a82" }}>
|
your mission description, and the episode is voiced a few minutes after
|
||||||
{e.date} · {fmtDur(e.durationSecs)} · {(e.bytes / 1_048_576).toFixed(1)} MB ·{" "}
|
the mission finishes.
|
||||||
{e.renderedBy}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => play(e.id)} style={btn}>
|
|
||||||
{playing?.id === e.id ? "playing" : "play"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
{playing && (
|
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
|
||||||
<audio src={playing.url} controls autoPlay style={{ width: "100%", marginTop: 16 }} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDur(s: number): string {
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const panel: React.CSSProperties = {
|
const panel: React.CSSProperties = {
|
||||||
border: "1px solid #2a2a30",
|
border: "1px solid #2a2a30",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: "14px 16px",
|
padding: "15px 17px",
|
||||||
marginBottom: 12,
|
marginBottom: 13,
|
||||||
background: "#141418",
|
background: "#141418",
|
||||||
maxWidth: 720,
|
maxWidth: 720,
|
||||||
};
|
};
|
||||||
const row: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
padding: "11px 14px",
|
|
||||||
border: "1px solid #24242a",
|
|
||||||
borderRadius: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
maxWidth: 720,
|
|
||||||
};
|
|
||||||
const btn: React.CSSProperties = {
|
const btn: React.CSSProperties = {
|
||||||
background: "#1e1e24",
|
background: "#1e1e24",
|
||||||
border: "1px solid #33333a",
|
border: "1px solid #33333a",
|
||||||
@@ -188,3 +139,9 @@ const urlBox: React.CSSProperties = {
|
|||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
};
|
};
|
||||||
|
const small: React.CSSProperties = {
|
||||||
|
margin: "10px 0 0",
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: "#7a7a82",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
export interface Episode {
|
export interface Episode {
|
||||||
id: string;
|
id: string;
|
||||||
missionId: string;
|
missionId: string | null;
|
||||||
missionTitle: string;
|
missionTitle: string;
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user