@fifthbell/brokaw 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/carousels.d.ts +1 -0
- package/dist/carousels.js +65 -0
- package/dist/components/live-program/LiveProgram.d.ts +8 -0
- package/dist/components/live-program/LiveProgram.js +526 -0
- package/dist/components/live-program/assets.d.ts +14 -0
- package/dist/components/live-program/assets.js +14 -0
- package/dist/components/live-program/components/Marquee.d.ts +16 -0
- package/dist/components/live-program/components/Marquee.js +88 -0
- package/dist/components/live-program/components/MarqueeCurtain.d.ts +5 -0
- package/dist/components/live-program/components/MarqueeCurtain.js +30 -0
- package/dist/components/live-program/components/WorldClocks.d.ts +19 -0
- package/dist/components/live-program/components/WorldClocks.js +101 -0
- package/dist/components/live-program/components/slides/ArticleSlide.d.ts +14 -0
- package/dist/components/live-program/components/slides/ArticleSlide.js +22 -0
- package/dist/components/live-program/components/slides/CallsignSlide.d.ts +6 -0
- package/dist/components/live-program/components/slides/CallsignSlide.js +49 -0
- package/dist/components/live-program/components/slides/slideStyles.d.ts +1 -0
- package/dist/components/live-program/components/slides/slideStyles.js +64 -0
- package/dist/components/live-program/events.d.ts +34 -0
- package/dist/components/live-program/events.js +167 -0
- package/dist/components/live-program/hooks/useSSE.d.ts +11 -0
- package/dist/components/live-program/hooks/useSSE.js +67 -0
- package/dist/components/live-program/i18n.d.ts +4 -0
- package/dist/components/live-program/i18n.js +290 -0
- package/dist/components/live-program/segments/ArticlesSegment.d.ts +6 -0
- package/dist/components/live-program/segments/ArticlesSegment.js +160 -0
- package/dist/components/live-program/segments/EarthquakeSegment.d.ts +16 -0
- package/dist/components/live-program/segments/EarthquakeSegment.js +130 -0
- package/dist/components/live-program/segments/MarketsSegment.d.ts +12 -0
- package/dist/components/live-program/segments/MarketsSegment.js +87 -0
- package/dist/components/live-program/segments/WeatherSegment.d.ts +15 -0
- package/dist/components/live-program/segments/WeatherSegment.js +184 -0
- package/dist/components/live-program/segments/index.d.ts +6 -0
- package/dist/components/live-program/segments/index.js +6 -0
- package/dist/components/live-program/segments/types.d.ts +23 -0
- package/dist/components/live-program/segments/types.js +1 -0
- package/dist/components/live-program/segments/usePlaylistEngine.d.ts +9 -0
- package/dist/components/live-program/segments/usePlaylistEngine.js +108 -0
- package/dist/components/live-program/utils/broadcastTime.d.ts +12 -0
- package/dist/components/live-program/utils/broadcastTime.js +33 -0
- package/dist/homepage-distributor.d.ts +55 -0
- package/dist/homepage-distributor.js +68 -0
- package/dist/instagram-image-template.d.ts +8 -0
- package/dist/instagram-image-template.js +200 -0
- package/dist/outlet-config.d.ts +23 -0
- package/dist/outlet-config.js +23 -0
- package/dist/renderer.browser.d.ts +2 -0
- package/dist/renderer.browser.js +128 -0
- package/dist/renderer.core.d.ts +9 -0
- package/dist/renderer.core.js +353 -0
- package/dist/renderer.d.ts +3 -0
- package/dist/renderer.js +3 -0
- package/dist/renderer.node.d.ts +2 -0
- package/dist/renderer.node.js +71 -0
- package/dist/types/canonical-article.d.ts +247 -0
- package/dist/types/canonical-article.js +235 -0
- package/dist/utils/sofascore.d.ts +3 -0
- package/dist/utils/sofascore.js +31 -0
- package/package.json +78 -0
- package/src/partial-deps.json +52 -0
- package/src/styles/compiled.css +2 -0
- package/src/templates/layouts/404.hbs +5 -0
- package/src/templates/layouts/article-page.hbs +5 -0
- package/src/templates/layouts/category-page.hbs +5 -0
- package/src/templates/layouts/homepage.hbs +5 -0
- package/src/templates/layouts/link-in-bio.hbs +228 -0
- package/src/templates/layouts/live-story.hbs +5 -0
- package/src/templates/layouts/search-page.hbs +5 -0
- package/src/templates/partials/blocks/audio.hbs +12 -0
- package/src/templates/partials/blocks/data-table.hbs +23 -0
- package/src/templates/partials/blocks/divider.hbs +1 -0
- package/src/templates/partials/blocks/heading.hbs +9 -0
- package/src/templates/partials/blocks/image.hbs +6 -0
- package/src/templates/partials/blocks/info-box.hbs +8 -0
- package/src/templates/partials/blocks/instagram.hbs +28 -0
- package/src/templates/partials/blocks/key-points.hbs +8 -0
- package/src/templates/partials/blocks/list.hbs +13 -0
- package/src/templates/partials/blocks/live-update.hbs +24 -0
- package/src/templates/partials/blocks/pull-quote.hbs +6 -0
- package/src/templates/partials/blocks/rich-text.hbs +1 -0
- package/src/templates/partials/blocks/tiktok.hbs +15 -0
- package/src/templates/partials/blocks/x.hbs +74 -0
- package/src/templates/partials/blocks/youtube.hbs +12 -0
- package/src/templates/partials/components/article-main.hbs +159 -0
- package/src/templates/partials/components/breaking-news/live-updates-column.hbs +29 -0
- package/src/templates/partials/components/breaking-news.hbs +56 -0
- package/src/templates/partials/components/category/header.hbs +5 -0
- package/src/templates/partials/components/category/main-grid.hbs +55 -0
- package/src/templates/partials/components/category/main.hbs +7 -0
- package/src/templates/partials/components/category/more-grid.hbs +26 -0
- package/src/templates/partials/components/editorial-hero.hbs +73 -0
- package/src/templates/partials/components/headline.hbs +15 -0
- package/src/templates/partials/components/hero-editorial.hbs +1 -0
- package/src/templates/partials/components/hero.hbs +1 -0
- package/src/templates/partials/components/home/landing.hbs +111 -0
- package/src/templates/partials/components/home/main.hbs +63 -0
- package/src/templates/partials/components/home/more-stories.hbs +23 -0
- package/src/templates/partials/components/home/must-read.hbs +77 -0
- package/src/templates/partials/components/live-story/main.hbs +229 -0
- package/src/templates/partials/components/not-found/main.hbs +28 -0
- package/src/templates/partials/components/search/main.hbs +420 -0
- package/src/templates/partials/components/snack.hbs +92 -0
- package/src/templates/partials/components/spotlight-hero.hbs +59 -0
- package/src/templates/partials/components/trending.hbs +14 -0
- package/src/templates/partials/components/ui/accordion.hbs +30 -0
- package/src/templates/partials/components/ui/breadcrumb.hbs +16 -0
- package/src/templates/partials/components/ui/icon-button.hbs +19 -0
- package/src/templates/partials/components/ui/loading-spinner.hbs +27 -0
- package/src/templates/partials/components/ui/pagination.hbs +56 -0
- package/src/templates/partials/components/ui/scroll-area.hbs +12 -0
- package/src/templates/partials/components/ui/status-badge.hbs +21 -0
- package/src/templates/partials/footers/footer-full.hbs +79 -0
- package/src/templates/partials/footers/footer-minimal.hbs +5 -0
- package/src/templates/partials/headers/header-main.hbs +397 -0
- package/src/templates/partials/headers/header-minimal.hbs +16 -0
- package/src/templates/partials/nav/nav-categories.hbs +5 -0
- package/src/templates/partials/shell/doc-end.hbs +282 -0
- package/src/templates/partials/shell/doc-start-404.hbs +28 -0
- package/src/templates/partials/shell/doc-start-standard.hbs +68 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
export interface Segment {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
itemCount: number;
|
|
6
|
+
durationMsPerItem: number;
|
|
7
|
+
render(itemIndex: number, progress: number): React.ReactNode;
|
|
8
|
+
onEnter?(): void;
|
|
9
|
+
onExit?(): void;
|
|
10
|
+
prefetch?(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface PlaylistState {
|
|
13
|
+
currentSegmentIndex: number;
|
|
14
|
+
currentItemIndex: number;
|
|
15
|
+
progress: number;
|
|
16
|
+
isPaused: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface PlaylistConfig {
|
|
19
|
+
segments: Segment[];
|
|
20
|
+
defaultDurationMs?: number;
|
|
21
|
+
updateIntervalMs?: number;
|
|
22
|
+
onPlaylistLoop?: () => void;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PlaylistConfig, PlaylistState, Segment } from './types.js';
|
|
2
|
+
export declare function usePlaylistEngine(config: PlaylistConfig): {
|
|
3
|
+
state: PlaylistState;
|
|
4
|
+
currentSegment: Segment | null;
|
|
5
|
+
pause: () => void;
|
|
6
|
+
resume: () => void;
|
|
7
|
+
reset: () => void;
|
|
8
|
+
advanceToNext: () => void;
|
|
9
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
const DEFAULT_DURATION_MS = 10000;
|
|
3
|
+
const DEFAULT_UPDATE_INTERVAL_MS = 100;
|
|
4
|
+
export function usePlaylistEngine(config) {
|
|
5
|
+
const { segments, defaultDurationMs = DEFAULT_DURATION_MS, updateIntervalMs = DEFAULT_UPDATE_INTERVAL_MS, onPlaylistLoop } = config;
|
|
6
|
+
const [state, setState] = useState({
|
|
7
|
+
currentSegmentIndex: 0,
|
|
8
|
+
currentItemIndex: 0,
|
|
9
|
+
progress: 0,
|
|
10
|
+
isPaused: false
|
|
11
|
+
});
|
|
12
|
+
const prevSegmentIndexRef = useRef(0);
|
|
13
|
+
const getCurrentSegment = useCallback(() => {
|
|
14
|
+
if (segments.length === 0)
|
|
15
|
+
return null;
|
|
16
|
+
return segments[state.currentSegmentIndex] || null;
|
|
17
|
+
}, [segments, state.currentSegmentIndex]);
|
|
18
|
+
const pause = useCallback(() => {
|
|
19
|
+
setState((prev) => ({ ...prev, isPaused: true }));
|
|
20
|
+
}, []);
|
|
21
|
+
const resume = useCallback(() => {
|
|
22
|
+
setState((prev) => ({ ...prev, isPaused: false }));
|
|
23
|
+
}, []);
|
|
24
|
+
const reset = useCallback(() => {
|
|
25
|
+
setState({
|
|
26
|
+
currentSegmentIndex: 0,
|
|
27
|
+
currentItemIndex: 0,
|
|
28
|
+
progress: 0,
|
|
29
|
+
isPaused: false
|
|
30
|
+
});
|
|
31
|
+
}, []);
|
|
32
|
+
const calculateNextState = useCallback((prevState) => {
|
|
33
|
+
const currentSegment = segments[prevState.currentSegmentIndex];
|
|
34
|
+
if (!currentSegment) {
|
|
35
|
+
return prevState;
|
|
36
|
+
}
|
|
37
|
+
const nextItemIndex = prevState.currentItemIndex + 1;
|
|
38
|
+
if (nextItemIndex >= currentSegment.itemCount) {
|
|
39
|
+
const nextSegmentIndex = (prevState.currentSegmentIndex + 1) % segments.length;
|
|
40
|
+
return {
|
|
41
|
+
...prevState,
|
|
42
|
+
currentSegmentIndex: nextSegmentIndex,
|
|
43
|
+
currentItemIndex: 0,
|
|
44
|
+
progress: 0
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...prevState,
|
|
49
|
+
currentItemIndex: nextItemIndex,
|
|
50
|
+
progress: 0
|
|
51
|
+
};
|
|
52
|
+
}, [segments]);
|
|
53
|
+
const advanceToNext = useCallback(() => {
|
|
54
|
+
setState((prevState) => calculateNextState(prevState));
|
|
55
|
+
}, [calculateNextState]);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const currentSegmentIndex = state.currentSegmentIndex;
|
|
58
|
+
const previousSegmentIndex = prevSegmentIndexRef.current;
|
|
59
|
+
if (currentSegmentIndex !== previousSegmentIndex) {
|
|
60
|
+
const previousSegment = segments[previousSegmentIndex];
|
|
61
|
+
previousSegment?.onExit?.();
|
|
62
|
+
const currentSegment = segments[currentSegmentIndex];
|
|
63
|
+
currentSegment?.onEnter?.();
|
|
64
|
+
const nextSegmentIndex = (currentSegmentIndex + 1) % segments.length;
|
|
65
|
+
const nextSegment = segments[nextSegmentIndex];
|
|
66
|
+
nextSegment?.prefetch?.().catch((error) => {
|
|
67
|
+
console.error(`[Playlist] Failed to prefetch ${nextSegment.label}:`, error);
|
|
68
|
+
});
|
|
69
|
+
if (currentSegmentIndex === 0 &&
|
|
70
|
+
previousSegmentIndex === segments.length - 1 &&
|
|
71
|
+
segments.length > 0) {
|
|
72
|
+
onPlaylistLoop?.();
|
|
73
|
+
}
|
|
74
|
+
prevSegmentIndexRef.current = currentSegmentIndex;
|
|
75
|
+
}
|
|
76
|
+
}, [state.currentSegmentIndex, segments, onPlaylistLoop]);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (state.isPaused || segments.length === 0) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const currentSegment = segments[state.currentSegmentIndex];
|
|
82
|
+
if (!currentSegment) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const duration = currentSegment.durationMsPerItem || defaultDurationMs;
|
|
86
|
+
const progressIncrement = (updateIntervalMs / duration) * 100;
|
|
87
|
+
const timer = setInterval(() => {
|
|
88
|
+
setState((prevState) => {
|
|
89
|
+
if (prevState.progress >= 100) {
|
|
90
|
+
return calculateNextState(prevState);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
...prevState,
|
|
94
|
+
progress: prevState.progress + progressIncrement
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}, updateIntervalMs);
|
|
98
|
+
return () => clearInterval(timer);
|
|
99
|
+
}, [state.isPaused, state.currentSegmentIndex, segments, defaultDurationMs, updateIntervalMs, calculateNextState]);
|
|
100
|
+
return {
|
|
101
|
+
state,
|
|
102
|
+
currentSegment: getCurrentSegment(),
|
|
103
|
+
pause,
|
|
104
|
+
resume,
|
|
105
|
+
reset,
|
|
106
|
+
advanceToNext
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface GlobalTimeOverride {
|
|
2
|
+
startTime: string;
|
|
3
|
+
startedAt: string;
|
|
4
|
+
}
|
|
5
|
+
interface ClockParts {
|
|
6
|
+
hours: number;
|
|
7
|
+
minutes: number;
|
|
8
|
+
seconds: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function getOverrideClockParts(override: GlobalTimeOverride, now?: Date): ClockParts | null;
|
|
11
|
+
export declare function formatClockParts(parts: ClockParts): string;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function parseStartTime(startTime) {
|
|
2
|
+
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(startTime.trim());
|
|
3
|
+
if (!match) {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
return {
|
|
7
|
+
hours: Number(match[1]),
|
|
8
|
+
minutes: Number(match[2]),
|
|
9
|
+
seconds: 0,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function getOverrideClockParts(override, now = new Date()) {
|
|
13
|
+
const parsed = parseStartTime(override.startTime);
|
|
14
|
+
if (!parsed) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const startedAtMillis = new Date(override.startedAt).getTime();
|
|
18
|
+
if (!Number.isFinite(startedAtMillis)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const startSeconds = parsed.hours * 3600 + parsed.minutes * 60;
|
|
22
|
+
const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - startedAtMillis) / 1000));
|
|
23
|
+
const totalSeconds = (startSeconds + elapsedSeconds) % 86400;
|
|
24
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
25
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
26
|
+
const seconds = totalSeconds % 60;
|
|
27
|
+
return { hours, minutes, seconds };
|
|
28
|
+
}
|
|
29
|
+
export function formatClockParts(parts) {
|
|
30
|
+
const hours = String(parts.hours).padStart(2, '0');
|
|
31
|
+
const minutes = String(parts.minutes).padStart(2, '0');
|
|
32
|
+
return `${hours}:${minutes}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SelfReference } from './types/canonical-article.js';
|
|
2
|
+
/**
|
|
3
|
+
* Represents the pre-distributed article slots for the homepage.
|
|
4
|
+
*
|
|
5
|
+
* Featured slots (Landing[0,6,7] and MustRead[16,17,18]) are filled by
|
|
6
|
+
* `featured:true` articles published within the last 24 hours, ordered by
|
|
7
|
+
* date descending. If no such articles exist, all slots fall back to the
|
|
8
|
+
* general queue.
|
|
9
|
+
*
|
|
10
|
+
* The general queue contains:
|
|
11
|
+
* - Overflow featured articles (beyond the first 6, within 24h)
|
|
12
|
+
* - All remaining non-featured / older articles
|
|
13
|
+
* Sorted by date descending.
|
|
14
|
+
*
|
|
15
|
+
* Breaking News no longer reserves queue items; all queue ordering feeds
|
|
16
|
+
* Landing / Must Read / More Stories directly.
|
|
17
|
+
*/
|
|
18
|
+
export interface HomepageSlots {
|
|
19
|
+
landing: {
|
|
20
|
+
/** Featured slot 1 — large hero article (Landing headline 1) */
|
|
21
|
+
headline1: SelfReference | undefined;
|
|
22
|
+
/** Sub-snacks below headline 1 (4 items from queue) */
|
|
23
|
+
sub1: SelfReference[];
|
|
24
|
+
/** Dark-card feature (Landing card 5) */
|
|
25
|
+
card5: SelfReference | undefined;
|
|
26
|
+
/** Featured slot 2 — second prominent card (Landing headline 6) */
|
|
27
|
+
headline6: SelfReference | undefined;
|
|
28
|
+
/** Top Stories sidebar — first item is featured slot 3, rest from queue (6 items total) */
|
|
29
|
+
topStories: SelfReference[];
|
|
30
|
+
};
|
|
31
|
+
mustRead: {
|
|
32
|
+
/** Must Read wide leader article (not a featured slot) */
|
|
33
|
+
lead: SelfReference | undefined;
|
|
34
|
+
/** Featured slot 4 — first two-column card (Must Read headline 16) */
|
|
35
|
+
headline16: SelfReference | undefined;
|
|
36
|
+
/** Featured slot 5 — second two-column card (Must Read headline 17) */
|
|
37
|
+
headline17: SelfReference | undefined;
|
|
38
|
+
/** Must Read sidebar — first item is featured slot 6, rest from queue (7 items total) */
|
|
39
|
+
sidebar: SelfReference[];
|
|
40
|
+
};
|
|
41
|
+
/** More Stories grid (12 items from queue) */
|
|
42
|
+
moreStories: SelfReference[];
|
|
43
|
+
/** Deprecated compatibility field after candy-bar removal. Always empty. */
|
|
44
|
+
breakingNewsSnacks: SelfReference[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Distribute homepage articles into named slots.
|
|
48
|
+
*
|
|
49
|
+
* @param articles Full list of article references from the document.
|
|
50
|
+
* @param now The reference time (default: current time). Exposed
|
|
51
|
+
* as a parameter so tests can pass a fixed instant.
|
|
52
|
+
* @param showBreakingNews Deprecated after candy-bar removal. Kept for
|
|
53
|
+
* compatibility and has no effect.
|
|
54
|
+
*/
|
|
55
|
+
export declare function distributeHomepageArticles(articles: SelfReference[], now?: Date, showBreakingNews?: boolean): HomepageSlots;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Return the best available date timestamp for an article, as epoch ms. */
|
|
2
|
+
function articleDateMs(article) {
|
|
3
|
+
const ts = article.publishedAt ?? article.updatedAt;
|
|
4
|
+
return ts ? new Date(ts).getTime() : 0;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Distribute homepage articles into named slots.
|
|
8
|
+
*
|
|
9
|
+
* @param articles Full list of article references from the document.
|
|
10
|
+
* @param now The reference time (default: current time). Exposed
|
|
11
|
+
* as a parameter so tests can pass a fixed instant.
|
|
12
|
+
* @param showBreakingNews Deprecated after candy-bar removal. Kept for
|
|
13
|
+
* compatibility and has no effect.
|
|
14
|
+
*/
|
|
15
|
+
export function distributeHomepageArticles(articles, now = new Date(), showBreakingNews = false) {
|
|
16
|
+
const FEATURED_SLOT_COUNT = 6;
|
|
17
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
const cutoffMs = now.getTime() - ONE_DAY_MS;
|
|
19
|
+
// Articles that qualify for the 6 featured slots:
|
|
20
|
+
// featured:true AND published/updated within the last 24 hours,
|
|
21
|
+
// ordered newest-first.
|
|
22
|
+
const recentFeatured = articles
|
|
23
|
+
.filter((a) => a.featured === true && articleDateMs(a) >= cutoffMs)
|
|
24
|
+
.sort((a, b) => articleDateMs(b) - articleDateMs(a));
|
|
25
|
+
const featuredSlots = recentFeatured.slice(0, FEATURED_SLOT_COUNT);
|
|
26
|
+
const overflow = recentFeatured.slice(FEATURED_SLOT_COUNT);
|
|
27
|
+
const usedIds = new Set(featuredSlots.map((a) => a.id));
|
|
28
|
+
// General queue: overflow featured articles (beyond the 6 slots) followed by
|
|
29
|
+
// all other articles, sorted newest-first.
|
|
30
|
+
const others = articles.filter((a) => !usedIds.has(a.id));
|
|
31
|
+
const queue = [...overflow, ...others].sort((a, b) => articleDateMs(b) - articleDateMs(a));
|
|
32
|
+
let qi = 0;
|
|
33
|
+
const nextFromQueue = () => queue[qi++];
|
|
34
|
+
const nextNFromQueue = (n) => {
|
|
35
|
+
const result = queue.slice(qi, qi + n);
|
|
36
|
+
qi += result.length;
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
// A featured slot falls back to the queue if not enough featured articles
|
|
40
|
+
// are available.
|
|
41
|
+
const getFeatured = (index) => featuredSlots[index] ?? nextFromQueue();
|
|
42
|
+
// Breaking News no longer reserves queue items.
|
|
43
|
+
void showBreakingNews;
|
|
44
|
+
const breakingNewsSnacks = [];
|
|
45
|
+
// ── Landing ──────────────────────────────────────────────────────────────
|
|
46
|
+
const headline1 = getFeatured(0);
|
|
47
|
+
const sub1 = nextNFromQueue(4);
|
|
48
|
+
const card5 = nextFromQueue();
|
|
49
|
+
const headline6 = getFeatured(1);
|
|
50
|
+
const topStoriesHead = getFeatured(2);
|
|
51
|
+
const topStoriesTail = nextNFromQueue(5);
|
|
52
|
+
const topStories = [topStoriesHead, ...topStoriesTail].filter((a) => a !== undefined);
|
|
53
|
+
// ── Must Read ─────────────────────────────────────────────────────────────
|
|
54
|
+
const lead = nextFromQueue();
|
|
55
|
+
const headline16 = getFeatured(3);
|
|
56
|
+
const headline17 = getFeatured(4);
|
|
57
|
+
const sidebarHead = getFeatured(5);
|
|
58
|
+
const sidebarTail = nextNFromQueue(6);
|
|
59
|
+
const sidebar = [sidebarHead, ...sidebarTail].filter((a) => a !== undefined);
|
|
60
|
+
// ── More Stories ──────────────────────────────────────────────────────────
|
|
61
|
+
const moreStories = nextNFromQueue(12);
|
|
62
|
+
return {
|
|
63
|
+
landing: { headline1, sub1, card5, headline6, topStories },
|
|
64
|
+
mustRead: { lead, headline16, headline17, sidebar },
|
|
65
|
+
moreStories,
|
|
66
|
+
breakingNewsSnacks
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import qrcode from 'qrcode-generator';
|
|
2
|
+
function escapeHtml(input) {
|
|
3
|
+
return input.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"').replace(/'/g, ''');
|
|
4
|
+
}
|
|
5
|
+
export function buildInstagramImageHtml(params) {
|
|
6
|
+
const safeImageUrl = escapeHtml(params.imageUrl);
|
|
7
|
+
const safeTitle = escapeHtml(params.title);
|
|
8
|
+
const categoryName = params.category
|
|
9
|
+
? escapeHtml(params.category.toUpperCase())
|
|
10
|
+
: params.slug
|
|
11
|
+
? escapeHtml(params.slug.replace(/-/g, ' ').toUpperCase())
|
|
12
|
+
: 'LATEST STORY';
|
|
13
|
+
// Create inline QR code SVG if a post URL is provided
|
|
14
|
+
let qrCodeHtml = '';
|
|
15
|
+
if (params.url) {
|
|
16
|
+
const qr = qrcode(0, 'M');
|
|
17
|
+
qr.addData(params.url);
|
|
18
|
+
qr.make();
|
|
19
|
+
const qrSvg = qr.createSvgTag(5, 0);
|
|
20
|
+
qrCodeHtml = `<div class="qr-container"><div class="qr-code" aria-label="QR Code">${qrSvg}</div></div>`;
|
|
21
|
+
}
|
|
22
|
+
// SVG for the actual Brokaw logo (red box with white bell)
|
|
23
|
+
const logoSvg = `<div style="color: white; width: 140px; height: 119px; display: flex; align-items: center; justify-content: end;">
|
|
24
|
+
<svg width="61" height="61" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
25
|
+
<path d="M10.268 21a2 2 0 0 0 3.464 0"></path>
|
|
26
|
+
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path>
|
|
27
|
+
<path d="M4 2C2.8 3.7 2 5.7 2 8"></path>
|
|
28
|
+
<path d="M22 8a10 10 0 0 0-2-6"></path>
|
|
29
|
+
</svg>
|
|
30
|
+
</div>`;
|
|
31
|
+
return `
|
|
32
|
+
<!DOCTYPE html>
|
|
33
|
+
<html lang="en">
|
|
34
|
+
<head>
|
|
35
|
+
<meta charset="UTF-8" />
|
|
36
|
+
<meta name="viewport" content="width=1080, initial-scale=1.0" />
|
|
37
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
38
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
39
|
+
<link href="https://fonts.googleapis.com/css2?family=Encode+Sans+Condensed:wght@100;200;300;400;500;600;700;800;900&family=Encode+Sans:wght@100..900" rel="stylesheet">
|
|
40
|
+
<style>
|
|
41
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
42
|
+
body {
|
|
43
|
+
width: 1080px;
|
|
44
|
+
height: 1350px;
|
|
45
|
+
overflow: hidden;
|
|
46
|
+
background-color: #000;
|
|
47
|
+
position: relative;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.hero-image {
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: 0;
|
|
53
|
+
height: 100%;
|
|
54
|
+
background-color: #000;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.hero-image img {
|
|
58
|
+
width: 100%;
|
|
59
|
+
height: 100%;
|
|
60
|
+
object-fit: cover;
|
|
61
|
+
opacity: 0.9;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
.card-container {
|
|
66
|
+
position: absolute;
|
|
67
|
+
left: 0;
|
|
68
|
+
right: 0;
|
|
69
|
+
bottom: 0;
|
|
70
|
+
display: flex;
|
|
71
|
+
flex-direction: column;
|
|
72
|
+
z-index: 10;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.card-container header {
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
background-color: rgba(0, 0, 0, 0.70);
|
|
79
|
+
backdrop-filter: blur(8px);
|
|
80
|
+
-webkit-backdrop-filter: blur(8px);
|
|
81
|
+
position: relative;
|
|
82
|
+
z-index: 1;
|
|
83
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.card-container header .category-label {
|
|
87
|
+
margin-left: 32px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.logo-container {
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: flex-start;
|
|
94
|
+
padding: 0 32px;
|
|
95
|
+
background-color: #b21100;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.brand-logo {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.qr-container {
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
justify-content: center;
|
|
107
|
+
padding: 0 16px;
|
|
108
|
+
background-color: #ffffff;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.qr-code {
|
|
112
|
+
width: 119px;
|
|
113
|
+
height: 119px;
|
|
114
|
+
padding: 10px;
|
|
115
|
+
display: flex;
|
|
116
|
+
justify-content: center;
|
|
117
|
+
align-items: center;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.qr-code svg {
|
|
121
|
+
width: 100%;
|
|
122
|
+
height: 100%;
|
|
123
|
+
display: block;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.card-container main {
|
|
127
|
+
display: flex;
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
background-color: rgba(0, 0, 0, 0.35);
|
|
130
|
+
backdrop-filter: blur(16px);
|
|
131
|
+
-webkit-backdrop-filter: blur(16px);
|
|
132
|
+
padding: 110px;
|
|
133
|
+
padding-top: 36px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.accent-bar {
|
|
137
|
+
width: 64px;
|
|
138
|
+
height: 6px;
|
|
139
|
+
background-color: #b21100;
|
|
140
|
+
margin-bottom: 24px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.category-label {
|
|
144
|
+
font-family: 'Encode Sans Condensed', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
145
|
+
font-size: 80px;
|
|
146
|
+
line-height: 119px;
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
color: #ffffff;
|
|
149
|
+
text-transform: uppercase;
|
|
150
|
+
letter-spacing: 0.02em;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.title-text {
|
|
154
|
+
font-family: 'Encode Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
155
|
+
font-size: 68px;
|
|
156
|
+
line-height: 1.1;
|
|
157
|
+
font-weight: 700;
|
|
158
|
+
color: #ffffff;
|
|
159
|
+
text-wrap: balance;
|
|
160
|
+
display: -webkit-box;
|
|
161
|
+
-webkit-line-clamp: 5;
|
|
162
|
+
-webkit-box-orient: vertical;
|
|
163
|
+
overflow: hidden;
|
|
164
|
+
}
|
|
165
|
+
</style>
|
|
166
|
+
</head>
|
|
167
|
+
<body>
|
|
168
|
+
<div class="hero-image">
|
|
169
|
+
<img src="${safeImageUrl}" alt="Featured Image" />
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div class="card-container">
|
|
173
|
+
<header>
|
|
174
|
+
<div class="logo-container">
|
|
175
|
+
<div class="brand-logo">${logoSvg}</div>
|
|
176
|
+
</div>
|
|
177
|
+
${qrCodeHtml}
|
|
178
|
+
<div class="category-label">${categoryName}</div>
|
|
179
|
+
</header>
|
|
180
|
+
<main>
|
|
181
|
+
<div class="accent-bar"></div>
|
|
182
|
+
<h1 class="title-text">${safeTitle}</h1>
|
|
183
|
+
</main>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<script>
|
|
187
|
+
window.onload = () => {
|
|
188
|
+
if (document.fonts) {
|
|
189
|
+
document.fonts.ready.then(() => {
|
|
190
|
+
setTimeout(() => { document.body.setAttribute('data-ready', '1'); }, 300);
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
setTimeout(() => { document.body.setAttribute('data-ready', '1'); }, 1000);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
</script>
|
|
197
|
+
</body>
|
|
198
|
+
</html>
|
|
199
|
+
`.trim();
|
|
200
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const outletConfig: {
|
|
2
|
+
readonly siteName: "fifthbell";
|
|
3
|
+
readonly defaultLanguage: "en";
|
|
4
|
+
readonly supportedLanguages: readonly ["en", "es", "it"];
|
|
5
|
+
readonly prefixDefaultLocale: false;
|
|
6
|
+
readonly defaultAuthor: {
|
|
7
|
+
readonly name: "Fifthbell Newsroom";
|
|
8
|
+
readonly slug: "fifthbell-newsroom";
|
|
9
|
+
};
|
|
10
|
+
readonly defaultCategory: {
|
|
11
|
+
readonly name: "Top Stories";
|
|
12
|
+
readonly slug: "top-stories";
|
|
13
|
+
};
|
|
14
|
+
readonly linkInBioRoute: "/instagram";
|
|
15
|
+
readonly searchTitle: "Search";
|
|
16
|
+
readonly searchDescriptions: {
|
|
17
|
+
readonly en: "Search stories from Fifthbell.";
|
|
18
|
+
readonly es: "Busca noticias de Fifthbell.";
|
|
19
|
+
readonly it: "Cerca notizie Fifthbell.";
|
|
20
|
+
};
|
|
21
|
+
readonly socialLanguages: readonly ["en"];
|
|
22
|
+
readonly socialUserAgent: "Cronkite/1.0";
|
|
23
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const outletConfig = {
|
|
2
|
+
siteName: 'fifthbell',
|
|
3
|
+
defaultLanguage: 'en',
|
|
4
|
+
supportedLanguages: ['en', 'es', 'it'],
|
|
5
|
+
prefixDefaultLocale: false,
|
|
6
|
+
defaultAuthor: {
|
|
7
|
+
name: 'Fifthbell Newsroom',
|
|
8
|
+
slug: 'fifthbell-newsroom'
|
|
9
|
+
},
|
|
10
|
+
defaultCategory: {
|
|
11
|
+
name: 'Top Stories',
|
|
12
|
+
slug: 'top-stories'
|
|
13
|
+
},
|
|
14
|
+
linkInBioRoute: '/instagram',
|
|
15
|
+
searchTitle: 'Search',
|
|
16
|
+
searchDescriptions: {
|
|
17
|
+
en: 'Search stories from Fifthbell.',
|
|
18
|
+
es: 'Busca noticias de Fifthbell.',
|
|
19
|
+
it: 'Cerca notizie Fifthbell.'
|
|
20
|
+
},
|
|
21
|
+
socialLanguages: ['en'],
|
|
22
|
+
socialUserAgent: 'Cronkite/1.0'
|
|
23
|
+
};
|