feat: bun app foundation, sample data, shared credits/audio, mockup stubs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-23 18:19:36 +02:00
1 parent d52ac63afe
commit 10ce858b31
42 files changed
+839

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import { motifs, sagas, songs } from "./sample";
import type { Motif, Saga, Song } from "./types";
export function songsWithMotif(motifId: string): Song[] {
return songs.filter((s) => s.parts.some((p) => p.motifIds.includes(motifId)));
}
export function motifsInSong(song: Song): Motif[] {
const ids = [...new Set(song.parts.flatMap((p) => p.motifIds))];
return ids.flatMap((id) => motifs.find((m) => m.id === id) ?? []);
}
export function gapYears(a: Song, b: Song): number {
return b.storyTime.yearsAfterTroy - a.storyTime.yearsAfterTroy;
}
export function sagaOf(song: Song): Saga {
const saga = sagas.find((s) => s.id === song.sagaId);
if (!saga) throw new Error(`Unknown saga ${song.sagaId}`);
return saga;
}
+139
View File
@@ -0,0 +1,139 @@
import type { Motif, Saga, Song } from "./types";
// MOCKUP DATA: lyrics are placeholders, never real lyrics. Anything unverified is marked TODO(verify).
const BUTLER = "https://www.gutenberg.org/ebooks/1727"; // Samuel Butler translation, public domain
const yt = (q: string) => `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}`;
export const sagas: Saga[] = [
{ id: "troy", name: "The Troy Saga", order: 1 },
{ id: "cyclops", name: "The Cyclops Saga", order: 2 },
{ id: "ocean", name: "The Ocean Saga", order: 3 }, // TODO(verify) saga name and order
];
export const motifs: Motif[] = [
{ id: "warrior", name: "Warrior of the Mind", kind: "motif", color: "#d9a441", description: "Odysseus's cunning, strategy-first fighting theme." }, // TODO(verify)
{ id: "open-arms", name: "Open Arms", kind: "motif", color: "#e8836b", description: "Welcoming, hospitable melody tied to home and guests." }, // TODO(verify)
{ id: "just-a-man", name: "Just a Man", kind: "motif", color: "#7aa6c9", description: "A humble, human refrain about being only mortal." }, // TODO(verify)
{ id: "remember", name: "Remember Them", kind: "motif", color: "#8fbf8a", description: "A memorial refrain for the fallen crew." }, // TODO(verify)
{ id: "odysseus", name: "Odysseus", kind: "character", color: "#f2e3b3", description: "Theme of the king of Ithaca." },
{ id: "athena", name: "Athena", kind: "character", color: "#b69bd6", description: "Theme of the goddess of wisdom." },
{ id: "polyphemus", name: "Polyphemus", kind: "character", color: "#c0504d", description: "Theme of the Cyclops." },
];
const lyric = (who: string, n: number) => [
`[placeholder lyric — ${who}, verse ${n}, line 1]`,
`[placeholder lyric — ${who}, verse ${n}, line 2]`,
];
export const songs: Song[] = [
{
id: "the-horse-and-the-infant", title: "The Horse and the Infant", sagaId: "troy", track: 1,
storyTime: { yearsAfterTroy: 0, label: "The fall of Troy" }, // TODO(verify)
odyssey: { book: 8, lines: "492-520", url: BUTLER }, // TODO(verify) Demodocus sings of the wooden horse
parts: [
{ name: "Verse 1", singers: ["Odysseus"], lines: lyric("Odysseus", 1), motifIds: ["odysseus", "warrior"] }, // TODO(verify) singers
{ name: "Verse 2", singers: ["Athena", "Odysseus"], lines: lyric("Athena", 2), motifIds: ["athena", "warrior"], startSec: 45 }, // TODO(verify)
],
facts: ["Opens the story at the end of the Trojan War."], // TODO(verify)
videos: [{ title: "The Horse and the Infant — search", url: yt("EPIC The Musical The Horse and the Infant") }],
},
{
id: "just-a-man", title: "Just a Man", sagaId: "troy", track: 2,
storyTime: { yearsAfterTroy: 0, label: "Aboard the ships, just after Troy" }, // TODO(verify)
odyssey: null, // TODO(verify)
parts: [
{ name: "Verse 1", singers: ["Odysseus"], lines: lyric("Odysseus", 1), motifIds: ["odysseus", "just-a-man"] }, // TODO(verify)
{ name: "Chorus", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["just-a-man"], startSec: 60 },
],
facts: ["Shows Odysseus as a husband and father before the voyage."], // TODO(verify)
videos: [{ title: "Just a Man — search", url: yt("EPIC The Musical Just a Man") }],
},
{
id: "full-speed-ahead", title: "Full Speed Ahead", sagaId: "troy", track: 3,
storyTime: { yearsAfterTroy: 0, label: "Sailing from Troy" }, // TODO(verify)
odyssey: { book: 9, lines: "39-61", url: BUTLER }, // TODO(verify) raid on the Cicones
parts: [
{ name: "Verse 1", singers: ["Eurylochus", "Odysseus"], lines: lyric("crew", 1), motifIds: ["odysseus"] }, // TODO(verify)
{ name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["warrior"] },
],
facts: ["The crew's high spirits are about to run into trouble."], // TODO(verify)
videos: [{ title: "Full Speed Ahead — search", url: yt("EPIC The Musical Full Speed Ahead") }],
},
{
id: "open-arms", title: "Open Arms", sagaId: "troy", track: 4,
storyTime: { yearsAfterTroy: 0, label: "Shortly after leaving Troy" }, // TODO(verify)
odyssey: null, // TODO(verify)
parts: [
{ name: "Verse 1", singers: ["Odysseus"], lines: lyric("Odysseus", 1), motifIds: ["open-arms"] }, // TODO(verify)
{ name: "Verse 2", singers: ["Odysseus", "Crew"], lines: lyric("crew", 2), motifIds: ["open-arms", "odysseus"] },
],
facts: ["Introduces a welcoming melody that returns later."], // TODO(verify)
videos: [{ title: "Open Arms — search", url: yt("EPIC The Musical Open Arms") }],
},
{
id: "warrior-of-the-mind", title: "Warrior of the Mind", sagaId: "troy", track: 5,
storyTime: { yearsAfterTroy: 1, label: "About a year after Troy" }, // TODO(verify)
odyssey: { book: 13, lines: "287-310", url: BUTLER }, // TODO(verify) Athena and Odysseus
parts: [
{ name: "Verse 1", singers: ["Athena"], lines: lyric("Athena", 1), motifIds: ["athena", "warrior"] }, // TODO(verify)
{ name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["odysseus", "warrior"], startSec: 70 },
],
facts: ["Athena teaches Odysseus to fight with wit."], // TODO(verify)
videos: [{ title: "Warrior of the Mind — search", url: yt("EPIC The Musical Warrior of the Mind") }],
},
{
id: "polyphemus", title: "Polyphemus", sagaId: "cyclops", track: 6,
storyTime: { yearsAfterTroy: 1, label: "The island of the Cyclopes" }, // TODO(verify)
odyssey: { book: 9, lines: "105-460", url: BUTLER }, // TODO(verify)
parts: [
{ name: "Verse 1", singers: ["Polyphemus"], lines: lyric("Polyphemus", 1), motifIds: ["polyphemus"] },
{ name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["odysseus", "warrior"], startSec: 50 },
],
facts: ["Odysseus meets Polyphemus in his cave."], // TODO(verify)
videos: [{ title: "Polyphemus — search", url: yt("EPIC The Musical Polyphemus") }],
},
{
id: "survive", title: "Survive", sagaId: "cyclops", track: 7,
storyTime: { yearsAfterTroy: 1, label: "Inside the cave" }, // TODO(verify)
odyssey: { book: 9, lines: "287-412", url: BUTLER }, // TODO(verify)
parts: [
{ name: "Verse 1", singers: ["Polyphemus", "Crew"], lines: lyric("Polyphemus", 1), motifIds: ["polyphemus"] }, // TODO(verify)
{ name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["odysseus", "just-a-man"] },
],
facts: ["The crew is trapped and Odysseus must choose a plan."], // TODO(verify)
videos: [{ title: "Survive — search", url: yt("EPIC The Musical Survive") }],
},
{
id: "remember-them", title: "Remember Them", sagaId: "cyclops", track: 8,
storyTime: { yearsAfterTroy: 1, label: "Escaping the cave" }, // TODO(verify)
odyssey: { book: 9, lines: "431-460", url: BUTLER }, // TODO(verify)
parts: [
{ name: "Verse 1", singers: ["Telemachus"], lines: lyric("Telemachus", 1), motifIds: ["remember"] }, // TODO(verify)
{ name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["remember", "open-arms"], startSec: 40 },
],
facts: ["A memorial theme introduced here returns later."], // TODO(verify)
videos: [{ title: "Remember Them — search", url: yt("EPIC The Musical Remember Them") }],
},
{
id: "my-goodbye", title: "My Goodbye", sagaId: "cyclops", track: 9,
storyTime: { yearsAfterTroy: 1, label: "Leaving the island" }, // TODO(verify)
odyssey: { book: 9, lines: "473-542", url: BUTLER }, // TODO(verify)
parts: [
{ name: "Verse 1", singers: ["Odysseus", "Polyphemus"], lines: lyric("Odysseus", 1), motifIds: ["polyphemus", "odysseus"] }, // TODO(verify)
],
facts: ["The Cyclops saga ends with Odysseus revealing his name."], // TODO(verify)
videos: [{ title: "My Goodbye — search", url: yt("EPIC The Musical My Goodbye") }],
},
{
id: "storm", title: "Storm", sagaId: "ocean", track: 10, // TODO(verify) title, track and saga
storyTime: { yearsAfterTroy: 3, label: "Years later, at sea" }, // TODO(verify)
odyssey: { book: 10, lines: "1-76", url: BUTLER }, // TODO(verify)
parts: [
{ name: "Verse 1", singers: ["Poseidon"], lines: lyric("Poseidon", 1), motifIds: ["polyphemus"] }, // TODO(verify) callback
{ name: "Verse 2", singers: ["Odysseus"], lines: lyric("Odysseus", 2), motifIds: ["remember", "warrior"], startSec: 30 }, // TODO(verify) callback
],
facts: ["Callback song: motifs from the Troy and Cyclops sagas return."], // TODO(verify)
videos: [{ title: "Storm — search", url: yt("EPIC The Musical Storm") }],
},
];
+12
View File
@@ -0,0 +1,12 @@
export type MotifKind = "motif" | "character";
export interface Motif { id: string; name: string; kind: MotifKind; color: string; description: string }
export interface Saga { id: string; name: string; order: number }
export interface OdysseyRef { book: number; lines: string; url: string }
export interface StoryTime { yearsAfterTroy: number; label: string }
export interface Part { name: string; singers: string[]; lines: string[]; motifIds: string[]; startSec?: number }
export interface Video { title: string; url: string }
export interface Song {
id: string; title: string; sagaId: string; track: number;
storyTime: StoryTime; odyssey: OdysseyRef | null; // null = no Homer counterpart
parts: Part[]; facts: string[]; videos: Video[]; audio?: string;
}