Skip to content

Commit 7edc577

Browse files
Merge pull request #71 from Juliusolsson05/fix/ui-polish
fix: map layout, YouTube live detection, and UI cleanup
2 parents 0c50af6 + 6574519 commit 7edc577

9 files changed

Lines changed: 149 additions & 57 deletions

File tree

.env.local.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ OPENAI_API_KEY="sk-xxx"
4040
XAI_API_KEY="xai-xxx"
4141
XAI_MODEL="grok-4-1-fast-reasoning"
4242

43+
# ─── YouTube Data API v3 — live stream detection for Perspectives ─
44+
# Get a free key at https://console.cloud.google.com/apis/credentials
45+
# Enable "YouTube Data API v3" under APIs & Services > Library
46+
YOUTUBE_API_KEY="AIza..."
47+
4348
# ─── PostHog analytics ─────────────────────────────────────────
4449
NEXT_PUBLIC_ANALYTICS_ENABLED="false"
4550
NEXT_PUBLIC_POSTHOG_KEY="phc_xxx"

src/app/api/v1/perspectives/live-status/route.ts

Lines changed: 97 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import { NextRequest } from 'next/server';
22

33
import { err, ok } from '@/server/lib/api-utils';
44

5+
import { PERSPECTIVE_CHANNELS } from '@/data/perspective-channels';
6+
57
const CACHE_TTL = 600;
68
const IS_LIVE_RE = /"isLive"\s*:\s*true/;
7-
const VIDEO_ID_RE = /"videoId"\s*:\s*"([^"]+)"/;
9+
const PAGE_VIDEO_ID_RE = /"videoId"\s*:\s*"([^"]+)"/;
810
const CANONICAL_VIDEO_RE = /<link rel="canonical" href="https:\/\/www\.youtube\.com\/watch\?v=([^"&]+)"/;
11+
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY ?? '';
12+
const VIDEO_ID_RE = /<yt:videoId>([^<]+)<\/yt:videoId>/g;
913

1014
type CacheEntry = {
1115
isLive: boolean;
@@ -15,36 +19,111 @@ type CacheEntry = {
1519

1620
const cache = new Map<string, CacheEntry>();
1721

22+
function resolveChannelId(handle: string): string | null {
23+
const ch = PERSPECTIVE_CHANNELS.find(c => c.handle.toLowerCase() === handle.toLowerCase());
24+
return ch?.channelId ?? null;
25+
}
26+
1827
function extractVideoId(html: string): string | null {
1928
return html.match(CANONICAL_VIDEO_RE)?.[1]
20-
?? html.match(VIDEO_ID_RE)?.[1]
29+
?? html.match(PAGE_VIDEO_ID_RE)?.[1]
2130
?? null;
2231
}
2332

33+
async function fetchRecentVideoIds(channelId: string, limit = 5): Promise<string[]> {
34+
const res = await fetch(
35+
`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`,
36+
{ signal: AbortSignal.timeout(8000) },
37+
);
38+
const xml = await res.text();
39+
const ids: string[] = [];
40+
let match: RegExpExecArray | null;
41+
while ((match = VIDEO_ID_RE.exec(xml)) !== null && ids.length < limit) {
42+
ids.push(match[1]);
43+
}
44+
VIDEO_ID_RE.lastIndex = 0;
45+
return ids;
46+
}
47+
48+
async function findLiveVideo(videoIds: string[]): Promise<{ videoId: string } | null> {
49+
if (!YOUTUBE_API_KEY || videoIds.length === 0) return null;
50+
51+
const ids = videoIds.join(',');
52+
const url = `https://www.googleapis.com/youtube/v3/videos?id=${ids}&part=snippet,liveStreamingDetails&fields=items(id,snippet/liveBroadcastContent)&key=${YOUTUBE_API_KEY}`;
53+
54+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
55+
if (!res.ok) return null;
56+
57+
const data = (await res.json()) as {
58+
items?: { id: string; snippet?: { liveBroadcastContent?: string } }[];
59+
};
60+
61+
for (const item of data.items ?? []) {
62+
if (item.snippet?.liveBroadcastContent === 'live') {
63+
return { videoId: item.id };
64+
}
65+
}
66+
67+
return null;
68+
}
69+
70+
async function checkLiveStatusViaPage(handle: string): Promise<{ isLive: boolean; videoId: string | null }> {
71+
const res = await fetch(`https://www.youtube.com/${handle}/live`, {
72+
headers: {
73+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
74+
'Accept-Language': 'en-US,en;q=0.9',
75+
Cookie: 'CONSENT=YES+1',
76+
},
77+
signal: AbortSignal.timeout(8000),
78+
});
79+
const html = await res.text();
80+
const isLive = IS_LIVE_RE.test(html);
81+
82+
return {
83+
isLive,
84+
videoId: isLive ? extractVideoId(html) : null,
85+
};
86+
}
87+
2488
async function checkLiveStatus(handle: string): Promise<{ isLive: boolean; videoId: string | null }> {
2589
const cached = cache.get(handle);
2690
if (cached && Date.now() - cached.checkedAt < CACHE_TTL * 1000) {
2791
return { isLive: cached.isLive, videoId: cached.videoId };
2892
}
2993

94+
const offline = { isLive: false, videoId: null };
95+
3096
try {
31-
const res = await fetch(`https://www.youtube.com/${handle}/live`, {
32-
headers: {
33-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
34-
'Accept-Language': 'en-US,en;q=0.9',
35-
'Cookie': 'CONSENT=YES+1',
36-
},
37-
signal: AbortSignal.timeout(8000),
38-
});
39-
const html = await res.text();
40-
const isLive = IS_LIVE_RE.test(html);
41-
const videoId = isLive ? extractVideoId(html) : null;
42-
43-
cache.set(handle, { isLive, videoId, checkedAt: Date.now() });
44-
return { isLive, videoId };
97+
if (!YOUTUBE_API_KEY) {
98+
const fallback = await checkLiveStatusViaPage(handle);
99+
cache.set(handle, { ...fallback, checkedAt: Date.now() });
100+
return fallback;
101+
}
102+
103+
const channelId = resolveChannelId(handle);
104+
if (!channelId) {
105+
const fallback = await checkLiveStatusViaPage(handle);
106+
cache.set(handle, { ...fallback, checkedAt: Date.now() });
107+
return fallback;
108+
}
109+
110+
const videoIds = await fetchRecentVideoIds(channelId);
111+
if (videoIds.length === 0) {
112+
const fallback = await checkLiveStatusViaPage(handle);
113+
cache.set(handle, { ...fallback, checkedAt: Date.now() });
114+
return fallback;
115+
}
116+
117+
const live = await findLiveVideo(videoIds);
118+
const result = live
119+
? { isLive: true, videoId: live.videoId }
120+
: await checkLiveStatusViaPage(handle);
121+
122+
cache.set(handle, { ...result, checkedAt: Date.now() });
123+
return result;
45124
} catch {
46-
cache.set(handle, { isLive: false, videoId: null, checkedAt: Date.now() });
47-
return { isLive: false, videoId: null };
125+
cache.set(handle, { ...offline, checkedAt: Date.now() });
126+
return offline;
48127
}
49128
}
50129

@@ -56,8 +135,6 @@ export async function GET(req: NextRequest) {
56135

57136
const { isLive, videoId } = await checkLiveStatus(handle);
58137

59-
// Always allow embedding if the channel is live — worst case YouTube shows
60-
// its own error inside the iframe for the ~1 in 30 non-embeddable channels
61138
return ok(
62139
{ handle, isLive, playableInEmbed: isLive, videoId, ttl: CACHE_TTL },
63140
{

src/features/actors/components/ActorDossier.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export function ActorDossier({ actor, tab, onTabChange, currentDay, compact = fa
171171
</CollapsibleTrigger>
172172
<CollapsibleContent>
173173
<div className="border-t border-[var(--bd)] bg-[var(--bg-app)]">
174-
<ActorLeadershipGraph actor={actor} inline pageScroll />
174+
<ActorLeadershipGraph actor={actor} pageScroll />
175175
</div>
176176
</CollapsibleContent>
177177
</div>

src/features/brief/components/BriefContent.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,6 @@ export function BriefContent() {
166166
</div>
167167
</BriefSection>
168168

169-
<div className="mt-10 pt-4 border-t border-[var(--bd)] text-center">
170-
<span className="label text-[length:var(--text-tiny)] text-[var(--t4)]">
171-
UNCLASSIFIED // PHAROS ANALYTICAL // OPERATION EPIC FURY // {currentDay}
172-
</span>
173-
</div>
174169
</div>
175170
);
176171

src/features/leadership/components/ActorLeadershipGraph.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import '@xyflow/react/dist/style.css';
3333

3434
type Props = {
3535
actor: Actor;
36-
inline?: boolean;
3736
pageScroll?: boolean;
3837
};
3938

@@ -355,16 +354,17 @@ function LeadershipDetailPanel({ selectedNode, headerCollapsed, onHeaderCollapse
355354
);
356355
}
357356

358-
function LeadershipBoardWithActor({ actor, inline = false }: { actor: Props['actor']; inline?: boolean }) {
357+
function LeadershipBoardWithActor({ actor }: { actor: Props['actor'] }) {
359358
const { data: tree, isLoading } = useActorLeadership(undefined, actor.id);
360359
const [selectedNode, setSelectedNode] = useState<GraphNodeData | null>(null);
361360
const [headerCollapsed, setHeaderCollapsed] = useState(true);
362361
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<Node<GraphNodeData>, Edge> | null>(null);
363362
const isMobile = useIsMobile(1024);
364363
const graph = useMemo(() => (tree ? buildGraph(tree) : { nodes: [], edges: [], height: 980 }), [tree]);
365-
const boardHeight = inline
366-
? Math.max(Math.min(graph.height, isMobile ? 680 : 760), isMobile ? 520 : 620)
367-
: Math.max(graph.height, isMobile ? 780 : 980);
364+
const boardHeight = Math.max(
365+
Math.min(graph.height, isMobile ? 680 : 760),
366+
isMobile ? 520 : 620,
367+
);
368368

369369
const onNodeClick: NodeMouseHandler<Node<GraphNodeData>> = (_, node) => {
370370
setHeaderCollapsed(true);
@@ -451,11 +451,11 @@ function LeadershipBoardWithActor({ actor, inline = false }: { actor: Props['act
451451
);
452452
}
453453

454-
export function ActorLeadershipGraph({ actor, inline = false, pageScroll = false }: Props) {
454+
export function ActorLeadershipGraph({ actor, pageScroll = false }: Props) {
455455
return (
456456
<ReactFlowProvider>
457457
<div className={pageScroll ? '' : 'h-full'}>
458-
<LeadershipBoardWithActor actor={actor} inline={inline} />
458+
<LeadershipBoardWithActor actor={actor} />
459459
</div>
460460
</ReactFlowProvider>
461461
);

src/features/map/components/DatasetDrilldown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function DatasetDrilldown({ dataset, facets, state, onToggleType, onToggl
2323
style={{
2424
background: 'rgba(28,33,39,0.97)',
2525
border: '1px solid var(--bd)',
26-
width: 240,
26+
width: 'min(240px, 100%)',
2727
maxHeight: 'calc(100vh - 120px)',
2828
}}
2929
>

src/features/map/components/MapFilterPanel.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function MapFilterPanel(props: Props) {
6464
const drill = drillDataset ? facets.perDataset[drillDataset] : null;
6565

6666
return (
67-
<div className="flex flex-col items-end gap-1">
67+
<div className="flex flex-col items-end gap-1 min-w-0" style={{ maxWidth: '100%' }}>
6868

6969
{/* Collapsed: single icon button */}
7070
{!expanded && (
@@ -92,7 +92,8 @@ export function MapFilterPanel(props: Props) {
9292
background: 'rgba(28,33,39,0.95)',
9393
border: '1px solid var(--bd)',
9494
padding: '4px 6px',
95-
maxWidth: 'min(100vw - 24px, 520px)',
95+
maxWidth: 'min(100%, 520px)',
96+
minWidth: 0,
9697
}}
9798
>
9899
{ALL_DATASETS.map(d => (

src/features/map/components/MapTimeline.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,15 @@ export function MapTimeline({ rawData, dataExtent, viewExtent, onViewExtent, tim
105105
const toPct = (ms: number) => ((ms - vMin) / span) * 100;
106106

107107
return (
108-
<div className="absolute bottom-0 left-0 right-0 z-10 select-none"
108+
<div className="absolute bottom-0 left-0 right-0 z-10 select-none min-w-0"
109109
style={{
110110
background: 'rgba(28,33,39,0.92)',
111111
borderTop: '1px solid var(--bd)',
112112
padding: isMobile ? '6px 10px calc(12px + var(--safe-bottom))' : '4px 16px 6px',
113113
touchAction: 'none',
114114
}}>
115-
<div className="flex items-center justify-between mb-0.5">
116-
<div className="flex items-center gap-0.5 overflow-x-auto touch-scroll hide-scrollbar pr-2">
115+
<div className="flex items-center gap-2 mb-0.5 min-w-0">
116+
<div className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto touch-scroll hide-scrollbar pr-2">
117117
{ZOOM_LEVELS.map(z => (
118118
<Button key={z.label} variant="ghost" size="xs" onClick={() => handleZoom(z.ms)}
119119
className={`mono rounded-sm px-1.5 py-0 text-[length:var(--text-tiny)] font-bold tracking-wider ${isMobile ? 'h-5' : 'h-4'}`}
@@ -126,8 +126,8 @@ export function MapTimeline({ rawData, dataExtent, viewExtent, onViewExtent, tim
126126
))}
127127
</div>
128128
{isActive && (
129-
<div className="flex items-center gap-2">
130-
<span className="mono text-[length:var(--text-caption)] text-[var(--t2)]">{fmt(rng[0])}{fmt(rng[1])}</span>
129+
<div className="flex min-w-0 items-center gap-2">
130+
<span className="mono min-w-0 truncate text-[length:var(--text-caption)] text-[var(--t2)]">{fmt(rng[0])}{fmt(rng[1])}</span>
131131
<Button variant="ghost" size="xs" onClick={() => onTimeRange(null)}
132132
className="mono rounded-sm px-1 py-0 h-4 text-[length:var(--text-tiny)]"
133133
style={{ color: 'var(--danger)', background: 'var(--danger-dim)', border: '1px solid var(--danger)' }}

src/features/map/components/desktop/MapLayout.tsx

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ export function DesktopMapLayout({ ctx, embedded = false }: Props) {
6666
)}
6767

6868
{/* ── Map canvas ── */}
69-
<ResizablePanel id="canvas" defaultSize="75%" minSize="40%" className="relative overflow-hidden">
70-
<div className="relative overflow-hidden w-full h-full">
69+
<ResizablePanel id="canvas" defaultSize="75%" minSize="40%" className="relative overflow-hidden min-w-0">
70+
<div className="relative overflow-hidden w-full h-full min-w-0">
7171
<DeckGL
7272
viewState={{
7373
...viewState,
@@ -109,35 +109,49 @@ export function DesktopMapLayout({ ctx, embedded = false }: Props) {
109109
<div style={{
110110
position: 'absolute',
111111
bottom: showTimeline ? 118 : 74,
112+
left: 12,
112113
right: selectedItem ? 332 : 12,
113114
zIndex: 10,
115+
display: 'flex',
116+
justifyContent: 'flex-end',
117+
minWidth: 0,
118+
pointerEvents: 'none',
114119
transition: 'right 0.22s cubic-bezier(0.4,0,0.2,1)',
115120
}}>
116-
<MapVisibilityMenu visibility={overlayVisibility} onToggle={toggleOverlay} />
121+
<div style={{ pointerEvents: 'auto' }}>
122+
<MapVisibilityMenu visibility={overlayVisibility} onToggle={toggleOverlay} />
123+
</div>
117124
</div>
118125

119126
{/* Filter panel */}
120127
{overlayVisibility.filters && (
121128
<div style={{
122129
position: 'absolute',
123130
top: 12,
131+
left: 12,
124132
right: selectedItem ? 332 : 12,
125133
zIndex: 10,
134+
display: 'flex',
135+
justifyContent: 'flex-end',
136+
minWidth: 0,
137+
pointerEvents: 'none',
126138
transition: 'right 0.22s cubic-bezier(0.4,0,0.2,1)',
127139
}}>
128-
<MapFilterPanel
129-
defaultExpanded
130-
state={f.state}
131-
facets={f.facets}
132-
isFiltered={f.isFiltered}
133-
onToggleDataset={f.toggleDataset}
134-
onToggleType={f.toggleType}
135-
onToggleActor={f.toggleActor}
136-
onTogglePriority={f.togglePriority}
137-
onToggleStatus={f.toggleStatus}
138-
onToggleHeat={f.toggleHeat}
139-
onReset={f.resetFilters}
140-
/>
140+
<div style={{ minWidth: 0, maxWidth: '100%', pointerEvents: 'auto' }}>
141+
<MapFilterPanel
142+
defaultExpanded
143+
state={f.state}
144+
facets={f.facets}
145+
isFiltered={f.isFiltered}
146+
onToggleDataset={f.toggleDataset}
147+
onToggleType={f.toggleType}
148+
onToggleActor={f.toggleActor}
149+
onTogglePriority={f.togglePriority}
150+
onToggleStatus={f.toggleStatus}
151+
onToggleHeat={f.toggleHeat}
152+
onReset={f.resetFilters}
153+
/>
154+
</div>
141155
</div>
142156
)}
143157

0 commit comments

Comments
 (0)