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:
1 parent
d52ac63afe
commit
10ce858b31
42 files changed
+839
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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") }],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user