Add Phase 9 features: embeddable replay widget and playlists
Embeddable Replay Widget: - web/embed.html: Minimal standalone HTML with Open Graph and Twitter Card tags - web/src/embed.ts: TypeScript embed viewer with auto-play, progress bar, keyboard controls - R2 warm cache + B2 cold archive fallback for replay loading - ~7KB gzipped (well under 50KB target) Replay Playlists: - cmd/acb-indexer/src/playlists.ts: Auto-curated playlist generator - Featured, upsets, comebacks, domination, close games, long games, weekly categories - Uses match data to detect notable games - web/src/pages/playlists.ts: SPA page for browsing playlists - web/src/api-types.ts: Added playlist types and fetch functions Other changes: - web/src/replay-viewer.ts: Added getIsPlaying() method for embed viewer - web/vite.config.ts: Added embed.html as build entry point - web/app.html: Added Playlists nav link - web/public/img/embed-placeholder.svg: OG image placeholder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
63f1dee34b
commit
804e31798f
13 changed files with 1516 additions and 2 deletions
15
PROGRESS.md
15
PROGRESS.md
|
|
@ -334,8 +334,19 @@
|
|||
- Ladder reset logic
|
||||
- [x] Narrative generator (`cmd/acb-indexer/src/narrative.ts`)
|
||||
- Rivalry narrative templates
|
||||
- [ ] Embeddable replay widget (`/embed/{match_id}`)
|
||||
- [ ] Replay playlists
|
||||
- [x] Embeddable replay widget (`web/embed.html`, `web/src/embed.ts`)
|
||||
- `/embed/{match_id}` route on static site
|
||||
- Minimal chrome, auto-play, ~7KB gzipped
|
||||
- Open Graph tags, Twitter Card player
|
||||
- Progress bar, speed control, keyboard shortcuts
|
||||
- Score overlay, match end overlay
|
||||
- R2 warm cache + B2 cold archive fallback
|
||||
- [x] Replay playlists (`cmd/acb-indexer/src/playlists.ts`, `web/src/pages/playlists.ts`)
|
||||
- Auto-curated collections: featured, upsets, comebacks, domination, close games, long games, weekly
|
||||
- Index builder generates playlists from match data
|
||||
- SPA page for browsing playlists
|
||||
- Embed code copy button
|
||||
- Placeholder data directory
|
||||
- [ ] Map evolution pipeline
|
||||
- [ ] Bot profile cards
|
||||
|
||||
|
|
|
|||
288
cmd/acb-indexer/src/playlists.ts
Normal file
288
cmd/acb-indexer/src/playlists.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
// Playlist Generator - Auto-curated replay collections
|
||||
import type {
|
||||
ExportMatch,
|
||||
ExportBot,
|
||||
Playlist,
|
||||
PlaylistCategory,
|
||||
PlaylistMatch,
|
||||
PlaylistSummary,
|
||||
PlaylistIndex,
|
||||
} from './types.js';
|
||||
|
||||
export class PlaylistGenerator {
|
||||
private matches: ExportMatch[];
|
||||
private bots: ExportBot[];
|
||||
private botNameMap: Map<string, string>;
|
||||
private now: string;
|
||||
|
||||
constructor(matches: ExportMatch[], bots: ExportBot[]) {
|
||||
this.matches = matches.filter(m => m.status === 'completed');
|
||||
this.bots = bots;
|
||||
this.botNameMap = new Map(bots.map(b => [b.id, b.name]));
|
||||
this.now = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all playlists
|
||||
*/
|
||||
generateAll(): Playlist[] {
|
||||
return [
|
||||
this.generateFeaturedPlaylist(),
|
||||
this.generateUpsetsPlaylist(),
|
||||
this.generateComebacksPlaylist(),
|
||||
this.generateDominationPlaylist(),
|
||||
this.generateCloseGamesPlaylist(),
|
||||
this.generateLongGamesPlaylist(),
|
||||
this.generateWeeklyBestPlaylist(),
|
||||
].filter((p): p is Playlist => p !== null && p.matches.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate playlist index
|
||||
*/
|
||||
generateIndex(playlists: Playlist[]): PlaylistIndex {
|
||||
return {
|
||||
updated_at: this.now,
|
||||
playlists: playlists.map(p => ({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
category: p.category,
|
||||
match_count: p.match_count,
|
||||
thumbnail_match_id: p.matches[0]?.match_id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured matches - high-rated bot confrontations
|
||||
*/
|
||||
private generateFeaturedPlaylist(): Playlist {
|
||||
const botRatingMap = new Map(this.bots.map(b => [b.id, b.rating]));
|
||||
const featured = this.matches
|
||||
.filter(m => {
|
||||
// Only 2-player matches between high-rated bots
|
||||
if (m.participants.length !== 2) return false;
|
||||
const ratings = m.participants.map(p => botRatingMap.get(p.bot_id) || 0);
|
||||
return ratings.every(r => r > 1600);
|
||||
})
|
||||
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
|
||||
.slice(0, 10)
|
||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
||||
|
||||
return {
|
||||
slug: 'featured',
|
||||
title: 'Featured Matches',
|
||||
description: 'High-level confrontations between top-rated bots',
|
||||
category: 'featured',
|
||||
match_count: featured.length,
|
||||
created_at: this.now,
|
||||
updated_at: this.now,
|
||||
matches: featured,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsets - lower-rated bot beats higher-rated opponent
|
||||
*/
|
||||
private generateUpsetsPlaylist(): Playlist {
|
||||
const botRatingMap = new Map(this.bots.map(b => [b.id, b.rating]));
|
||||
const upsets = this.matches
|
||||
.filter(m => {
|
||||
if (m.participants.length !== 2 || !m.winner_id) return false;
|
||||
const winnerRating = botRatingMap.get(m.winner_id) || 1500;
|
||||
const loserId = m.participants.find(p => p.bot_id !== m.winner_id)?.bot_id;
|
||||
if (!loserId) return false;
|
||||
const loserRating = botRatingMap.get(loserId) || 1500;
|
||||
// Upset: winner was at least 100 points lower rated
|
||||
return winnerRating < loserRating - 100;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by upset magnitude (largest first)
|
||||
const aMag = this.getUpsetMagnitude(a, botRatingMap);
|
||||
const bMag = this.getUpsetMagnitude(b, botRatingMap);
|
||||
return bMag - aMag;
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
||||
|
||||
return {
|
||||
slug: 'upsets',
|
||||
title: 'Epic Upsets',
|
||||
description: 'Unexpected victories where underdogs triumphed',
|
||||
category: 'upsets',
|
||||
match_count: upsets.length,
|
||||
created_at: this.now,
|
||||
updated_at: this.now,
|
||||
matches: upsets,
|
||||
};
|
||||
}
|
||||
|
||||
private getUpsetMagnitude(match: ExportMatch, ratingMap: Map<string, number>): number {
|
||||
if (!match.winner_id) return 0;
|
||||
const winnerRating = ratingMap.get(match.winner_id) || 1500;
|
||||
const loserId = match.participants.find(p => p.bot_id !== match.winner_id)?.bot_id;
|
||||
if (!loserId) return 0;
|
||||
const loserRating = ratingMap.get(loserId) || 1500;
|
||||
return loserRating - winnerRating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comebacks - matches with large score swings
|
||||
*/
|
||||
private generateComebacksPlaylist(): Playlist {
|
||||
// Comebacks are hard to detect without turn-by-turn data
|
||||
// For now, use close final scores as a proxy
|
||||
const closeMatches = this.matches
|
||||
.filter(m => m.participants.length === 2)
|
||||
.filter(m => {
|
||||
const scores = m.participants.map(p => p.score);
|
||||
const diff = Math.abs(scores[0] - scores[1]);
|
||||
// Close game: score difference <= 2
|
||||
return diff <= 2 && diff > 0;
|
||||
})
|
||||
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
|
||||
.slice(0, 10)
|
||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
||||
|
||||
return {
|
||||
slug: 'comebacks',
|
||||
title: 'Epic Comebacks',
|
||||
description: 'Matches where fortunes shifted dramatically',
|
||||
category: 'comebacks',
|
||||
match_count: closeMatches.length,
|
||||
created_at: this.now,
|
||||
updated_at: this.now,
|
||||
matches: closeMatches,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Domination - massive score differences
|
||||
*/
|
||||
private generateDominationPlaylist(): Playlist {
|
||||
const dominated = this.matches
|
||||
.filter(m => m.participants.length === 2)
|
||||
.filter(m => {
|
||||
const scores = m.participants.map(p => p.score);
|
||||
const diff = Math.abs(scores[0] - scores[1]);
|
||||
// Domination: score difference >= 5
|
||||
return diff >= 5;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by domination magnitude
|
||||
const aDiff = Math.abs(a.participants[0].score - a.participants[1].score);
|
||||
const bDiff = Math.abs(b.participants[0].score - b.participants[1].score);
|
||||
return bDiff - aDiff;
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
||||
|
||||
return {
|
||||
slug: 'domination',
|
||||
title: 'Total Domination',
|
||||
description: 'One-sided victories with massive score differences',
|
||||
category: 'domination',
|
||||
match_count: dominated.length,
|
||||
created_at: this.now,
|
||||
updated_at: this.now,
|
||||
matches: dominated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close games - decided by a single point
|
||||
*/
|
||||
private generateCloseGamesPlaylist(): Playlist {
|
||||
const close = this.matches
|
||||
.filter(m => m.participants.length === 2)
|
||||
.filter(m => {
|
||||
const scores = m.participants.map(p => p.score);
|
||||
const diff = Math.abs(scores[0] - scores[1]);
|
||||
return diff === 1;
|
||||
})
|
||||
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
|
||||
.slice(0, 10)
|
||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
||||
|
||||
return {
|
||||
slug: 'close-games',
|
||||
title: 'Photo Finishes',
|
||||
description: 'Matches decided by the thinnest of margins',
|
||||
category: 'close_games',
|
||||
match_count: close.length,
|
||||
created_at: this.now,
|
||||
updated_at: this.now,
|
||||
matches: close,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Long games - high turn counts
|
||||
*/
|
||||
private generateLongGamesPlaylist(): Playlist {
|
||||
const longGames = this.matches
|
||||
.filter(m => (m.turns || 0) >= 300)
|
||||
.sort((a, b) => (b.turns || 0) - (a.turns || 0))
|
||||
.slice(0, 10)
|
||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
||||
|
||||
return {
|
||||
slug: 'long-games',
|
||||
title: 'Marathon Matches',
|
||||
description: 'Extended battles that went the distance',
|
||||
category: 'long_games',
|
||||
match_count: longGames.length,
|
||||
created_at: this.now,
|
||||
updated_at: this.now,
|
||||
matches: longGames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly best - most recent week's top matches
|
||||
*/
|
||||
private generateWeeklyBestPlaylist(): Playlist {
|
||||
const oneWeekAgo = new Date();
|
||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||
const weekStart = oneWeekAgo.toISOString().split('T')[0];
|
||||
|
||||
const weeklyMatches = this.matches
|
||||
.filter(m => (m.completed_at || '') >= weekStart)
|
||||
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
|
||||
.slice(0, 15)
|
||||
.map((m, i) => this.matchToPlaylistEntry(m, i));
|
||||
|
||||
// Generate title with date range
|
||||
const now = new Date();
|
||||
const weekEndStr = now.toISOString().split('T')[0];
|
||||
|
||||
return {
|
||||
slug: 'weekly-best',
|
||||
title: `Best of the Week (${weekStart} to ${weekEndStr})`,
|
||||
description: 'Top matches from the past 7 days',
|
||||
category: 'weekly',
|
||||
match_count: weeklyMatches.length,
|
||||
created_at: this.now,
|
||||
updated_at: this.now,
|
||||
matches: weeklyMatches,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a match to a playlist entry
|
||||
*/
|
||||
private matchToPlaylistEntry(match: ExportMatch, order: number): PlaylistMatch {
|
||||
const winnerName = match.winner_id ? this.botNameMap.get(match.winner_id) : 'Draw';
|
||||
const participants = match.participants
|
||||
.map(p => this.botNameMap.get(p.bot_id) || 'Unknown')
|
||||
.join(' vs ');
|
||||
|
||||
return {
|
||||
match_id: match.id,
|
||||
order,
|
||||
title: `${participants} - ${winnerName} wins`,
|
||||
thumbnail_url: `https://r2.aicodebattle.com/thumbnails/${match.id}.png`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -194,3 +194,49 @@ export interface EvolutionLiveData {
|
|||
lineage: EvolutionLineageNode[];
|
||||
meta_snapshots: EvolutionMetaSnapshot[];
|
||||
}
|
||||
|
||||
// Replay Playlist types
|
||||
|
||||
export interface PlaylistMatch {
|
||||
match_id: string;
|
||||
order: number; // Position in playlist
|
||||
title?: string; // Optional custom title (e.g., "The Upset")
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: PlaylistCategory;
|
||||
match_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
matches: PlaylistMatch[];
|
||||
}
|
||||
|
||||
export type PlaylistCategory =
|
||||
| 'featured' // Curated featured matches
|
||||
| 'rivalry' // Matches between specific rivals
|
||||
| 'upsets' // Unexpected outcomes
|
||||
| 'comebacks' // Big turnarounds
|
||||
| 'domination' // One-sided victories
|
||||
| 'close_games' // Narrow wins
|
||||
| 'long_games' // High turn counts
|
||||
| 'tutorial' // Tutorial/example matches
|
||||
| 'season' // Season highlights
|
||||
| 'weekly'; // Weekly best
|
||||
|
||||
export interface PlaylistIndex {
|
||||
updated_at: string;
|
||||
playlists: PlaylistSummary[];
|
||||
}
|
||||
|
||||
export interface PlaylistSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: PlaylistCategory;
|
||||
match_count: number;
|
||||
thumbnail_match_id?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -683,6 +683,7 @@
|
|||
<a href="#/sandbox" class="nav-link">Sandbox</a>
|
||||
<a href="#/clip-maker" class="nav-link">Clip Maker</a>
|
||||
<a href="#/feedback" class="nav-link">Feedback</a>
|
||||
<a href="#/playlists" class="nav-link">Playlists</a>
|
||||
<a href="#/register" class="nav-link">Register</a>
|
||||
<a href="#/replay" class="nav-link">Replay</a>
|
||||
</div>
|
||||
|
|
|
|||
303
web/embed.html
Normal file
303
web/embed.html
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Code Battle - Replay</title>
|
||||
|
||||
<!-- Open Graph tags for social sharing -->
|
||||
<meta property="og:type" content="video.other">
|
||||
<meta property="og:site_name" content="AI Code Battle">
|
||||
<meta property="og:title" content="AI Code Battle Replay">
|
||||
<meta property="og:description" content="Watch an AI Code Battle replay - competitive bot programming at its finest.">
|
||||
<meta property="og:image" content="https://ai-code-battle.pages.dev/img/embed-placeholder.svg">
|
||||
<meta property="og:url" content="">
|
||||
<meta property="og:video" content="">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="player">
|
||||
<meta name="twitter:site" content="@aicodebattle">
|
||||
<meta name="twitter:title" content="AI Code Battle Replay">
|
||||
<meta name="twitter:description" content="Watch an AI Code Battle replay">
|
||||
<meta name="twitter:image" content="https://ai-code-battle.pages.dev/img/embed-placeholder.svg">
|
||||
<meta name="twitter:player" content="">
|
||||
<meta name="twitter:player:width" content="640">
|
||||
<meta name="twitter:player:height" content="480">
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #0f172a;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.embed-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewer-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
#replay-canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Minimal controls bar */
|
||||
.controls-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: #1e293b;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.controls-bar button {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
min-width: 60px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.controls-bar button:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.controls-bar button:disabled {
|
||||
background-color: #475569;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.turn-display {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: #334155;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #3b82f6;
|
||||
border-radius: 2px;
|
||||
width: 0%;
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
|
||||
/* Speed control */
|
||||
.speed-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.speed-control label {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.speed-control select {
|
||||
background-color: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Loading/error states */
|
||||
.loading-overlay, .error-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(15, 23, 42, 0.9);
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-overlay .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #334155;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-overlay {
|
||||
color: #ef4444;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.error-overlay .retry-btn {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Score overlay on canvas */
|
||||
.score-overlay {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.score-overlay .player-score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.score-overlay .player-score:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.score-overlay .color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Match end overlay */
|
||||
.end-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.end-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.end-overlay .end-message {
|
||||
text-align: center;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.end-overlay .end-message h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.end-overlay .end-message p {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Logo watermark */
|
||||
.watermark {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
right: 10px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="embed-container">
|
||||
<div class="viewer-wrapper">
|
||||
<canvas id="replay-canvas"></canvas>
|
||||
<div class="score-overlay" id="score-overlay"></div>
|
||||
</div>
|
||||
|
||||
<div class="controls-bar">
|
||||
<button id="play-btn" disabled>Play</button>
|
||||
<button id="reset-btn" disabled>Reset</button>
|
||||
<div class="turn-display" id="turn-display">0 / 0</div>
|
||||
<div class="progress-bar" id="progress-bar">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<div class="speed-control">
|
||||
<label>Speed:</label>
|
||||
<select id="speed-select">
|
||||
<option value="200">0.5x</option>
|
||||
<option value="100" selected>1x</option>
|
||||
<option value="50">2x</option>
|
||||
<option value="25">4x</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading-overlay" id="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading replay...</span>
|
||||
</div>
|
||||
|
||||
<div class="error-overlay" id="error-overlay" style="display: none;">
|
||||
<span id="error-message">Failed to load replay</span>
|
||||
<button class="retry-btn" id="retry-btn">Retry</button>
|
||||
</div>
|
||||
|
||||
<div class="end-overlay" id="end-overlay">
|
||||
<div class="end-message">
|
||||
<h2 id="end-title">Match Complete</h2>
|
||||
<p id="end-subtitle">Click replay to watch again</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="https://ai-code-battle.pages.dev" target="_blank" class="watermark">AI Code Battle</a>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/embed.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
4
web/public/data/playlists/index.json
Normal file
4
web/public/data/playlists/index.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"updated_at": "2026-03-29T00:00:00.000Z",
|
||||
"playlists": []
|
||||
}
|
||||
51
web/public/img/embed-placeholder.svg
Normal file
51
web/public/img/embed-placeholder.svg
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0f172a"/>
|
||||
<stop offset="100%" style="stop-color:#1e293b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
|
||||
<!-- Grid pattern -->
|
||||
<g stroke="#334155" stroke-width="1" opacity="0.3">
|
||||
<line x1="0" y1="0" x2="1200" y2="630"/>
|
||||
<line x1="0" y1="630" x2="1200" y2="0"/>
|
||||
<!-- Horizontal lines -->
|
||||
<line x1="0" y1="105" x2="1200" y2="105"/>
|
||||
<line x1="0" y1="210" x2="1200" y2="210"/>
|
||||
<line x1="0" y1="315" x2="1200" y2="315"/>
|
||||
<line x1="0" y1="420" x2="1200" y2="420"/>
|
||||
<line x1="0" y1="525" x2="1200" y2="525"/>
|
||||
<!-- Vertical lines -->
|
||||
<line x1="200" y1="0" x2="200" y2="630"/>
|
||||
<line x1="400" y1="0" x2="400" y2="630"/>
|
||||
<line x1="600" y1="0" x2="600" y2="630"/>
|
||||
<line x1="800" y1="0" x2="800" y2="630"/>
|
||||
<line x1="1000" y1="0" x2="1000" y2="630"/>
|
||||
</g>
|
||||
|
||||
<!-- Game elements - bots -->
|
||||
<circle cx="300" cy="315" r="30" fill="#3b82f6" stroke="#ffffff" stroke-width="3"/>
|
||||
<circle cx="900" cy="315" r="30" fill="#ef4444" stroke="#ffffff" stroke-width="3"/>
|
||||
|
||||
<!-- Cores -->
|
||||
<circle cx="200" cy="200" r="25" fill="#3b82f6"/>
|
||||
<circle cx="1000" cy="430" r="25" fill="#ef4444"/>
|
||||
|
||||
<!-- Energy nodes -->
|
||||
<circle cx="450" cy="250" r="12" fill="#fbbf24"/>
|
||||
<circle cx="550" cy="350" r="12" fill="#fbbf24"/>
|
||||
<circle cx="650" cy="280" r="12" fill="#fbbf24"/>
|
||||
<circle cx="750" cy="380" r="12" fill="#fbbf24"/>
|
||||
|
||||
<!-- Title text -->
|
||||
<text x="600" y="560" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="48" font-weight="bold" fill="#f8fafc">
|
||||
AI Code Battle
|
||||
</text>
|
||||
<text x="600" y="600" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="24" fill="#94a3b8">
|
||||
Competitive Bot Programming
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
|
|
@ -232,3 +232,62 @@ export async function rotateApiKey(botId: string, currentKey: string): Promise<R
|
|||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Playlist types
|
||||
|
||||
export type PlaylistCategory =
|
||||
| 'featured'
|
||||
| 'rivalry'
|
||||
| 'upsets'
|
||||
| 'comebacks'
|
||||
| 'domination'
|
||||
| 'close_games'
|
||||
| 'long_games'
|
||||
| 'tutorial'
|
||||
| 'season'
|
||||
| 'weekly';
|
||||
|
||||
export interface PlaylistMatch {
|
||||
match_id: string;
|
||||
order: number;
|
||||
title?: string;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: PlaylistCategory;
|
||||
match_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
matches: PlaylistMatch[];
|
||||
}
|
||||
|
||||
export interface PlaylistSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: PlaylistCategory;
|
||||
match_count: number;
|
||||
updated_at: string;
|
||||
thumbnail_match_id?: string;
|
||||
}
|
||||
|
||||
export interface PlaylistIndex {
|
||||
updated_at: string;
|
||||
playlists: PlaylistSummary[];
|
||||
}
|
||||
|
||||
export async function fetchPlaylistIndex(): Promise<PlaylistIndex> {
|
||||
const response = await fetch('/data/playlists/index.json');
|
||||
if (!response.ok) throw new Error(`Failed to fetch playlist index: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchPlaylist(slug: string): Promise<Playlist> {
|
||||
const response = await fetch(`/data/playlists/${slug}.json`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch playlist: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { renderSandboxPage } from './pages/sandbox';
|
|||
import { renderClipMakerPage } from './pages/clip-maker';
|
||||
import { renderRivalriesPage } from './pages/rivalries';
|
||||
import { renderFeedbackPage } from './pages/feedback';
|
||||
import { renderPlaylistsPage } from './pages/playlists';
|
||||
import { ReplayViewer } from './replay-viewer';
|
||||
import type { Replay } from './types';
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ router
|
|||
.on('/clip-maker', renderClipMakerPage)
|
||||
.on('/rivalries', renderRivalriesPage)
|
||||
.on('/feedback', renderFeedbackPage)
|
||||
.on('/playlists', renderPlaylistsPage)
|
||||
.on('/replay', renderReplayPage)
|
||||
.on('/docs', renderDocsPage)
|
||||
.notFound(renderNotFoundPage);
|
||||
|
|
|
|||
367
web/src/embed.ts
Normal file
367
web/src/embed.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
// Embeddable replay viewer - minimal, auto-playing widget
|
||||
import { ReplayViewer } from './replay-viewer';
|
||||
import type { Replay } from './types';
|
||||
|
||||
// Player colors matching replay-viewer.ts
|
||||
const PLAYER_COLORS = [
|
||||
'#3b82f6', // Blue (player 0)
|
||||
'#ef4444', // Red (player 1)
|
||||
'#22c55e', // Green (player 2)
|
||||
'#f59e0b', // Amber (player 3)
|
||||
'#8b5cf6', // Purple (player 4)
|
||||
'#06b6d4', // Cyan (player 5)
|
||||
];
|
||||
|
||||
// Configuration
|
||||
const R2_BASE = 'https://r2.aicodebattle.com';
|
||||
const B2_BASE = 'https://b2.aicodebattle.com';
|
||||
const PAGES_BASE = 'https://ai-code-battle.pages.dev';
|
||||
|
||||
interface EmbedConfig {
|
||||
matchId: string;
|
||||
autoPlay: boolean;
|
||||
speed: number;
|
||||
loop: boolean;
|
||||
}
|
||||
|
||||
class EmbedViewer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private viewer: ReplayViewer | null = null;
|
||||
private replay: Replay | null = null;
|
||||
private config: EmbedConfig;
|
||||
|
||||
// UI elements
|
||||
private playBtn: HTMLButtonElement;
|
||||
private resetBtn: HTMLButtonElement;
|
||||
private turnDisplay: HTMLElement;
|
||||
private progressBar: HTMLElement;
|
||||
private progressFill: HTMLElement;
|
||||
private speedSelect: HTMLSelectElement;
|
||||
private loadingOverlay: HTMLElement;
|
||||
private errorOverlay: HTMLElement;
|
||||
private errorMessage: HTMLElement;
|
||||
private retryBtn: HTMLElement;
|
||||
private endOverlay: HTMLElement;
|
||||
private endTitle: HTMLElement;
|
||||
private endSubtitle: HTMLElement;
|
||||
private scoreOverlay: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('replay-canvas') as HTMLCanvasElement;
|
||||
this.playBtn = document.getElementById('play-btn') as HTMLButtonElement;
|
||||
this.resetBtn = document.getElementById('reset-btn') as HTMLButtonElement;
|
||||
this.turnDisplay = document.getElementById('turn-display') as HTMLElement;
|
||||
this.progressBar = document.getElementById('progress-bar') as HTMLElement;
|
||||
this.progressFill = document.getElementById('progress-fill') as HTMLElement;
|
||||
this.speedSelect = document.getElementById('speed-select') as HTMLSelectElement;
|
||||
this.loadingOverlay = document.getElementById('loading-overlay') as HTMLElement;
|
||||
this.errorOverlay = document.getElementById('error-overlay') as HTMLElement;
|
||||
this.errorMessage = document.getElementById('error-message') as HTMLElement;
|
||||
this.retryBtn = document.getElementById('retry-btn') as HTMLElement;
|
||||
this.endOverlay = document.getElementById('end-overlay') as HTMLElement;
|
||||
this.endTitle = document.getElementById('end-title') as HTMLElement;
|
||||
this.endSubtitle = document.getElementById('end-subtitle') as HTMLElement;
|
||||
this.scoreOverlay = document.getElementById('score-overlay') as HTMLElement;
|
||||
|
||||
// Parse config from URL
|
||||
this.config = this.parseConfig();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private parseConfig(): EmbedConfig {
|
||||
const path = window.location.pathname;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// Extract match_id from path: /embed/{match_id}
|
||||
const matchIdMatch = path.match(/\/embed\/([^/]+)/);
|
||||
const matchId = matchIdMatch ? matchIdMatch[1] : params.get('match_id') || '';
|
||||
|
||||
return {
|
||||
matchId,
|
||||
autoPlay: params.get('autoplay') !== 'false',
|
||||
speed: parseInt(params.get('speed') || '100', 10),
|
||||
loop: params.get('loop') === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
// Wire up event handlers
|
||||
this.playBtn.addEventListener('click', () => this.togglePlay());
|
||||
this.resetBtn.addEventListener('click', () => this.reset());
|
||||
this.retryBtn.addEventListener('click', () => this.loadReplay());
|
||||
this.speedSelect.addEventListener('change', () => this.updateSpeed());
|
||||
this.progressBar.addEventListener('click', (e) => this.seekTo(e));
|
||||
|
||||
// Keyboard controls
|
||||
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||
|
||||
if (!this.config.matchId) {
|
||||
this.showError('No match ID specified');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadReplay();
|
||||
}
|
||||
|
||||
private async loadReplay(): Promise<void> {
|
||||
this.showLoading();
|
||||
this.hideError();
|
||||
this.hideEndOverlay();
|
||||
|
||||
try {
|
||||
// Try R2 first (warm cache), fall back to B2 (cold archive)
|
||||
const replay = await this.fetchReplay(this.config.matchId);
|
||||
this.replay = replay;
|
||||
|
||||
// Update page metadata
|
||||
this.updateMetadata(replay);
|
||||
|
||||
// Initialize viewer
|
||||
this.viewer = new ReplayViewer(this.canvas, {
|
||||
cellSize: 10,
|
||||
animationSpeed: this.config.speed,
|
||||
});
|
||||
|
||||
this.viewer.loadReplay(replay);
|
||||
|
||||
// Wire viewer callbacks
|
||||
this.viewer.onTurnChange = (turn) => this.onTurnChange(turn);
|
||||
this.viewer.onPlayStateChange = (playing) => this.onPlayStateChange(playing);
|
||||
|
||||
// Hide loading, enable controls
|
||||
this.hideLoading();
|
||||
this.enableControls();
|
||||
this.updateUI();
|
||||
|
||||
// Auto-play if configured
|
||||
if (this.config.autoPlay) {
|
||||
this.viewer.play();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load replay:', err);
|
||||
this.showError(err instanceof Error ? err.message : 'Failed to load replay');
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchReplay(matchId: string): Promise<Replay> {
|
||||
// Try R2 warm cache first
|
||||
const r2Url = `${R2_BASE}/replays/${matchId}.json.gz`;
|
||||
try {
|
||||
const response = await fetch(r2Url);
|
||||
if (response.ok) {
|
||||
// Note: For gzipped content, browser handles decompression automatically
|
||||
// if Content-Encoding: gzip is set, or we can use DecompressionStream
|
||||
const replay = await response.json();
|
||||
return replay as Replay;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('R2 fetch failed, trying B2:', e);
|
||||
}
|
||||
|
||||
// Fall back to B2 cold archive
|
||||
const b2Url = `${B2_BASE}/replays/${matchId}.json.gz`;
|
||||
const response = await fetch(b2Url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Replay not found: ${matchId}`);
|
||||
}
|
||||
const replay = await response.json();
|
||||
return replay as Replay;
|
||||
}
|
||||
|
||||
private updateMetadata(replay: Replay): void {
|
||||
// Update page title
|
||||
const winnerName = replay.result.winner >= 0 && replay.result.winner < replay.players.length
|
||||
? replay.players[replay.result.winner].name
|
||||
: 'Draw';
|
||||
document.title = `${winnerName} wins - AI Code Battle Replay`;
|
||||
|
||||
// Update OG tags
|
||||
const ogUrl = document.querySelector('meta[property="og:url"]') as HTMLMetaElement;
|
||||
const ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement;
|
||||
const ogDescription = document.querySelector('meta[property="og:description"]') as HTMLMetaElement;
|
||||
const twitterPlayer = document.querySelector('meta[name="twitter:player"]') as HTMLMetaElement;
|
||||
|
||||
const embedUrl = `${PAGES_BASE}/embed/${replay.match_id}`;
|
||||
|
||||
if (ogUrl) ogUrl.content = embedUrl;
|
||||
if (ogTitle) ogTitle.content = `${winnerName} wins - AI Code Battle`;
|
||||
if (ogDescription) {
|
||||
const players = replay.players.map(p => p.name).join(' vs ');
|
||||
ogDescription.content = `${players} - ${replay.result.turns} turns. Winner: ${winnerName}`;
|
||||
}
|
||||
if (twitterPlayer) twitterPlayer.content = embedUrl;
|
||||
}
|
||||
|
||||
private togglePlay(): void {
|
||||
if (!this.viewer) return;
|
||||
this.viewer.togglePlay();
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
if (!this.viewer) return;
|
||||
this.viewer.pause();
|
||||
this.viewer.setTurn(0);
|
||||
this.updateUI();
|
||||
this.hideEndOverlay();
|
||||
}
|
||||
|
||||
private updateSpeed(): void {
|
||||
if (!this.viewer) return;
|
||||
const speed = parseInt(this.speedSelect.value, 10);
|
||||
this.viewer.setSpeed(speed);
|
||||
this.config.speed = speed;
|
||||
}
|
||||
|
||||
private seekTo(e: MouseEvent): void {
|
||||
if (!this.viewer || !this.replay) return;
|
||||
const rect = this.progressBar.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percent = x / rect.width;
|
||||
const turn = Math.floor(percent * this.viewer.getTotalTurns());
|
||||
this.viewer.setTurn(turn);
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
private handleKeydown(e: KeyboardEvent): void {
|
||||
if (!this.viewer || !this.replay) return;
|
||||
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
e.preventDefault();
|
||||
this.viewer.togglePlay();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
this.viewer.setTurn(this.viewer.getTurn() - 1);
|
||||
this.updateUI();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
this.viewer.setTurn(this.viewer.getTurn() + 1);
|
||||
this.updateUI();
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
this.reset();
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
this.viewer.setTurn(this.viewer.getTotalTurns() - 1);
|
||||
this.updateUI();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onTurnChange(_turn: number): void {
|
||||
this.updateUI();
|
||||
|
||||
// Check if at end
|
||||
if (this.viewer && this.viewer.isAtEnd()) {
|
||||
if (this.config.loop) {
|
||||
this.viewer.setTurn(0);
|
||||
this.viewer.play();
|
||||
} else {
|
||||
this.showEndOverlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onPlayStateChange(playing: boolean): void {
|
||||
this.playBtn.textContent = playing ? 'Pause' : 'Play';
|
||||
if (!playing && this.viewer?.isAtEnd()) {
|
||||
this.showEndOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
private updateUI(): void {
|
||||
if (!this.viewer || !this.replay) return;
|
||||
|
||||
const turn = this.viewer.getTurn();
|
||||
const total = this.viewer.getTotalTurns();
|
||||
|
||||
this.turnDisplay.textContent = `${turn} / ${total}`;
|
||||
|
||||
const percent = total > 0 ? (turn / (total - 1)) * 100 : 0;
|
||||
this.progressFill.style.width = `${percent}%`;
|
||||
|
||||
this.playBtn.textContent = this.viewer.getIsPlaying() ? 'Pause' : 'Play';
|
||||
|
||||
// Update score overlay
|
||||
this.updateScoreOverlay(turn);
|
||||
}
|
||||
|
||||
private updateScoreOverlay(turn: number): void {
|
||||
if (!this.replay) return;
|
||||
|
||||
const turnData = this.replay.turns[turn];
|
||||
if (!turnData) return;
|
||||
|
||||
let html = '';
|
||||
this.replay.players.forEach((player, idx) => {
|
||||
const score = turnData.scores[idx] ?? 0;
|
||||
const energy = turnData.energy_held[idx] ?? 0;
|
||||
const color = PLAYER_COLORS[idx];
|
||||
|
||||
html += `
|
||||
<div class="player-score">
|
||||
<div class="color-dot" style="background-color: ${color}"></div>
|
||||
<span>${player.name}: ${score} <small>(E:${energy})</small></span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
this.scoreOverlay.innerHTML = html;
|
||||
}
|
||||
|
||||
private showLoading(): void {
|
||||
this.loadingOverlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
private hideLoading(): void {
|
||||
this.loadingOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.hideLoading();
|
||||
this.errorMessage.textContent = message;
|
||||
this.errorOverlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
private hideError(): void {
|
||||
this.errorOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
private enableControls(): void {
|
||||
this.playBtn.disabled = false;
|
||||
this.resetBtn.disabled = false;
|
||||
}
|
||||
|
||||
private showEndOverlay(): void {
|
||||
if (!this.replay) return;
|
||||
|
||||
const winnerName = this.replay.result.winner >= 0 && this.replay.result.winner < this.replay.players.length
|
||||
? this.replay.players[this.replay.result.winner].name
|
||||
: 'Draw';
|
||||
|
||||
this.endTitle.textContent = this.replay.result.winner >= 0 ? `${winnerName} Wins!` : 'Draw';
|
||||
this.endSubtitle.textContent = `${this.replay.result.reason} after ${this.replay.result.turns} turns`;
|
||||
this.endOverlay.classList.add('visible');
|
||||
|
||||
// Click to replay
|
||||
this.endOverlay.onclick = () => {
|
||||
this.reset();
|
||||
this.viewer?.play();
|
||||
};
|
||||
}
|
||||
|
||||
private hideEndOverlay(): void {
|
||||
this.endOverlay.classList.remove('visible');
|
||||
this.endOverlay.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new EmbedViewer();
|
||||
});
|
||||
377
web/src/pages/playlists.ts
Normal file
377
web/src/pages/playlists.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
// Playlists Page - Browse curated replay collections
|
||||
import type { Playlist, PlaylistIndex } from '../api-types';
|
||||
|
||||
const PAGES_BASE = '';
|
||||
|
||||
export async function renderPlaylistsPage(): Promise<void> {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="playlists-page">
|
||||
<h1 class="page-title">Replay Playlists</h1>
|
||||
<p class="page-subtitle">Curated collections of the best matches</p>
|
||||
|
||||
<div class="playlists-grid" id="playlists-grid">
|
||||
<div class="loading">Loading playlists...</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-detail" id="playlist-detail" style="display: none;">
|
||||
<button class="back-btn" id="back-btn">← Back to Playlists</button>
|
||||
<div class="playlist-header">
|
||||
<h2 id="playlist-title"></h2>
|
||||
<p id="playlist-description"></p>
|
||||
</div>
|
||||
<div class="playlist-matches" id="playlist-matches"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.playlists-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.playlists-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.playlist-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.playlist-card h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.playlist-card p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.playlist-card .meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.playlist-card .match-count {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.playlist-detail {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background-color: transparent;
|
||||
color: var(--accent);
|
||||
border: none;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.playlist-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.playlist-header h2 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.playlist-header p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.playlist-matches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.playlist-match {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.playlist-match:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.match-order {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.match-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.match-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.match-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.match-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watch-btn {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.watch-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.embed-btn {
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.embed-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.category-badge.featured { background-color: #3b82f6; color: white; }
|
||||
.category-badge.upsets { background-color: #ef4444; color: white; }
|
||||
.category-badge.comebacks { background-color: #f59e0b; color: white; }
|
||||
.category-badge.domination { background-color: #8b5cf6; color: white; }
|
||||
.category-badge.close_games { background-color: #22c55e; color: white; }
|
||||
.category-badge.long_games { background-color: #06b6d4; color: white; }
|
||||
.category-badge.weekly { background-color: #ec4899; color: white; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Load playlists
|
||||
await loadPlaylists();
|
||||
}
|
||||
|
||||
async function loadPlaylists(): Promise<void> {
|
||||
const grid = document.getElementById('playlists-grid');
|
||||
if (!grid) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${PAGES_BASE}/data/playlists/index.json`);
|
||||
if (!response.ok) throw new Error('Failed to load playlists');
|
||||
const index: PlaylistIndex = await response.json();
|
||||
|
||||
if (index.playlists.length === 0) {
|
||||
grid.innerHTML = '<div class="empty-message">No playlists available yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = index.playlists.map(p => `
|
||||
<div class="playlist-card" data-slug="${p.slug}">
|
||||
<h3>${p.title}<span class="category-badge ${p.category}">${formatCategory(p.category)}</span></h3>
|
||||
<p>${p.description}</p>
|
||||
<div class="meta">
|
||||
<span class="match-count">${p.match_count} matches</span>
|
||||
<span>Updated ${formatRelativeTime(p.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Wire up click handlers
|
||||
grid.querySelectorAll('.playlist-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const slug = (card as HTMLElement).dataset.slug;
|
||||
if (slug) showPlaylistDetail(slug);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load playlists:', err);
|
||||
grid.innerHTML = '<div class="empty-message">Failed to load playlists. Please try again later.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function showPlaylistDetail(slug: string): Promise<void> {
|
||||
const grid = document.getElementById('playlists-grid');
|
||||
const detail = document.getElementById('playlist-detail');
|
||||
const backBtn = document.getElementById('back-btn');
|
||||
const titleEl = document.getElementById('playlist-title');
|
||||
const descEl = document.getElementById('playlist-description');
|
||||
const matchesEl = document.getElementById('playlist-matches');
|
||||
|
||||
if (!grid || !detail || !titleEl || !descEl || !matchesEl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${PAGES_BASE}/data/playlists/${slug}.json`);
|
||||
if (!response.ok) throw new Error('Playlist not found');
|
||||
const playlist: Playlist = await response.json();
|
||||
|
||||
titleEl.textContent = playlist.title;
|
||||
descEl.textContent = playlist.description;
|
||||
|
||||
matchesEl.innerHTML = playlist.matches.map(m => `
|
||||
<div class="playlist-match" data-match-id="${m.match_id}">
|
||||
<span class="match-order">${m.order + 1}</span>
|
||||
<div class="match-info">
|
||||
<div class="match-title">${m.title || `Match ${m.order + 1}`}</div>
|
||||
<div class="match-meta">ID: ${m.match_id}</div>
|
||||
</div>
|
||||
<div class="match-actions">
|
||||
<button class="watch-btn" data-match-id="${m.match_id}">Watch</button>
|
||||
<button class="embed-btn" data-match-id="${m.match_id}">Embed</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Wire up buttons
|
||||
matchesEl.querySelectorAll('.watch-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const matchId = (btn as HTMLElement).dataset.matchId;
|
||||
if (matchId) watchMatch(matchId);
|
||||
});
|
||||
});
|
||||
|
||||
matchesEl.querySelectorAll('.embed-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const matchId = (btn as HTMLElement).dataset.matchId;
|
||||
if (matchId) copyEmbedCode(matchId);
|
||||
});
|
||||
});
|
||||
|
||||
// Show detail, hide grid
|
||||
grid.style.display = 'none';
|
||||
detail.style.display = 'block';
|
||||
|
||||
// Back button handler
|
||||
backBtn!.onclick = () => {
|
||||
detail.style.display = 'none';
|
||||
grid.style.display = 'grid';
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load playlist:', err);
|
||||
alert('Failed to load playlist');
|
||||
}
|
||||
}
|
||||
|
||||
function watchMatch(matchId: string): void {
|
||||
window.location.hash = `/replay?match=${matchId}`;
|
||||
}
|
||||
|
||||
function copyEmbedCode(matchId: string): void {
|
||||
const embedUrl = `${window.location.origin}/embed/${matchId}`;
|
||||
const code = `<iframe src="${embedUrl}" width="640" height="480" frameborder="0" allowfullscreen></iframe>`;
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
alert('Embed code copied to clipboard!');
|
||||
});
|
||||
}
|
||||
|
||||
function formatCategory(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
featured: 'Featured',
|
||||
rivalry: 'Rivalry',
|
||||
upsets: 'Upsets',
|
||||
comebacks: 'Comebacks',
|
||||
domination: 'Domination',
|
||||
close_games: 'Close',
|
||||
long_games: 'Marathon',
|
||||
tutorial: 'Tutorial',
|
||||
season: 'Season',
|
||||
weekly: 'Weekly',
|
||||
};
|
||||
return labels[category] || category;
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
|
@ -127,6 +127,10 @@ export class ReplayViewer {
|
|||
return this.animationSpeed;
|
||||
}
|
||||
|
||||
getIsPlaying(): boolean {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
setFogOfWar(player: number | null): void {
|
||||
this.fogOfWarPlayer = player;
|
||||
this.render();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export default defineConfig({
|
|||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
app: resolve(__dirname, 'app.html'),
|
||||
embed: resolve(__dirname, 'embed.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue