rename(mana-games): rebrand to Arcade

Rename games/mana-games/ to games/arcade/, update all package names
(@mana-games/* → @arcade/*), appIds, display names, docker-compose
service, root scripts, and documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 18:31:37 +02:00
parent 2874e202ea
commit 9e82e40e16
105 changed files with 86 additions and 80 deletions

View file

@ -0,0 +1,10 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../../../../packages/shared-ui/src";
@source "../../../../../../packages/shared-auth-ui/src";
@source "../../../../../../packages/shared-branding/src";
@source "../../../../../../packages/shared-theme-ui/src";
@source "../../../../../../packages/shared-theme-ui/src/components";
@source "../../../../../../packages/shared-theme-ui/src/pages";

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,12 @@
import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser';
import type { HandleClientError } from '@sveltejs/kit';
initErrorTracking({
serviceName: 'arcade-web',
dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__,
environment: import.meta.env.MODE,
});
export const handleError: HandleClientError = ({ error }) => {
handleSvelteError(error);
};

View file

@ -0,0 +1,28 @@
import type { Handle } from '@sveltejs/kit';
import { injectUmamiAnalytics } from '@manacore/shared-utils/analytics-server';
import { setSecurityHeaders } from '@manacore/shared-utils/security-headers';
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
window.__PUBLIC_BACKEND_URL__ = ${JSON.stringify(PUBLIC_BACKEND_URL_CLIENT)};
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
</script>`;
return injectUmamiAnalytics(html.replace('<head>', `<head>${envScript}`));
},
});
setSecurityHeaders(response, {
connectSrc: [PUBLIC_MANA_CORE_AUTH_URL_CLIENT, PUBLIC_BACKEND_URL_CLIENT],
});
return response;
};

View file

@ -0,0 +1,54 @@
<script lang="ts">
import type { Game } from '$lib/data/games';
let { game, href }: { game: Game; href: string } = $props();
const difficultyColors: Record<string, string> = {
Einfach: 'bg-green-500/20 text-green-400',
Mittel: 'bg-yellow-500/20 text-yellow-400',
Schwer: 'bg-red-500/20 text-red-400',
};
</script>
<a
{href}
class="group block rounded-xl border border-border bg-card p-0 overflow-hidden transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5"
>
{#if game.thumbnail}
<div class="aspect-video w-full overflow-hidden bg-muted">
<img
src={game.thumbnail}
alt={game.title}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
{:else}
<div class="aspect-video w-full bg-muted flex items-center justify-center">
<span class="text-4xl opacity-40">🎮</span>
</div>
{/if}
<div class="p-4">
<div class="flex items-start justify-between gap-2 mb-2">
<h3 class="font-semibold text-foreground group-hover:text-primary transition-colors">
{game.title}
</h3>
<span class="shrink-0 text-xs px-2 py-0.5 rounded-full {difficultyColors[game.difficulty]}">
{game.difficulty}
</span>
</div>
<p class="text-sm text-muted-foreground line-clamp-2 mb-3">
{game.description}
</p>
<div class="flex flex-wrap gap-1">
{#each game.tags.slice(0, 3) as tag}
<span class="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{tag}
</span>
{/each}
</div>
</div>
</a>

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div class="app-loading-skeleton" role="status" aria-label="App wird geladen...">
<div class="header-skeleton">
<SkeletonBox width="120px" height="32px" borderRadius="8px" />
<div class="header-nav">
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
</div>
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
</div>
<div class="content-skeleton">
<div class="games-placeholder">
{#each Array(6) as _}
<SkeletonBox width="100%" height="200px" borderRadius="12px" />
{/each}
</div>
</div>
</div>
<style>
.app-loading-skeleton {
min-height: 100vh;
background: hsl(var(--background));
}
.header-skeleton {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid hsl(var(--border));
}
.header-nav {
display: flex;
gap: 0.5rem;
}
.content-skeleton {
max-width: 80rem;
margin: 0 auto;
padding: 2rem;
}
.games-placeholder {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
@media (max-width: 768px) {
.header-nav {
display: none;
}
.header-skeleton {
padding: 1rem;
}
.content-skeleton {
padding: 1rem;
}
.games-placeholder {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1 @@
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';

View file

@ -0,0 +1,325 @@
export interface Game {
id: string;
title: string;
description: string;
slug: string;
htmlFile: string;
thumbnail?: string;
tags: string[];
difficulty: 'Einfach' | 'Mittel' | 'Schwer';
complexity: 'Minimal' | 'Einfach' | 'Mittel' | 'Komplex';
controls: string;
codeStats?: {
total: number;
code: number;
comments: number;
};
community?: boolean;
author?: string;
submittedAt?: string;
}
export const games: Game[] = [
{
id: '1',
title: 'Snake',
description:
'Der Klassiker! Steuere die Schlange und sammle Nahrung, aber vermeide die roten Felder!',
slug: 'snake',
htmlFile: '/games/snake_game.html',
thumbnail: '/screenshots/snake.jpg',
tags: ['Arcade', 'Klassiker', 'Retro'],
difficulty: 'Einfach',
complexity: 'Komplex',
controls: 'Pfeiltasten oder WASD zum Steuern',
codeStats: { total: 604, code: 338, comments: 192 },
},
{
id: '2',
title: 'Space Defender',
description:
'Verteidige dein Raumschiff gegen Wellen von Aliens. Die Schwierigkeit steigt mit der Zeit!',
slug: 'space-defender',
htmlFile: '/games/space_defender_game.html',
thumbnail: '/screenshots/space-defenders.jpg',
tags: ['Shooter', 'Arcade', 'Action'],
difficulty: 'Mittel',
complexity: 'Mittel',
controls: 'A/D oder Pfeiltasten zum Bewegen, Leertaste zum Schießen',
codeStats: { total: 436, code: 348, comments: 32 },
},
{
id: '3',
title: 'Gravity Painter',
description:
'Ein kreatives Physik-Puzzle! Setze Gravitationspunkte und lenke Partikel zu den Zielen.',
slug: 'gravity-painter',
htmlFile: '/games/gravity_painter.html',
thumbnail: '/screenshots/gravity-painter.jpg',
tags: ['Puzzle', 'Physik', 'Kreativ'],
difficulty: 'Schwer',
complexity: 'Mittel',
controls: 'Klicke für Gravitationspunkte, Leertaste für Partikel',
codeStats: { total: 426, code: 348, comments: 21 },
},
{
id: '4',
title: 'Bounce & Catch Tutorial',
description:
'Ein einfaches Lernspiel, das die Grundlagen der Spieleentwicklung zeigt. Perfekt für Anfänger!',
slug: 'bounce-catch-tutorial',
htmlFile: '/games/bounce_catch_tutorial.html',
thumbnail: '/screenshots/bounce-catch.jpg',
tags: ['Tutorial', 'Lernspiel', 'Arcade'],
difficulty: 'Einfach',
complexity: 'Einfach',
controls: 'Mausbewegung zum Steuern des Paddles',
codeStats: { total: 437, code: 289, comments: 87 },
},
{
id: '5',
title: 'Neon Maze Runner',
description:
'Navigiere durch prozedural generierte Labyrinthe! Sammle Diamanten, nutze Power-ups und finde den Ausgang.',
slug: 'neon-maze-runner',
htmlFile: '/games/neon_maze_runner.html',
thumbnail: '/screenshots/neon-maze-runner.jpg',
tags: ['Puzzle', 'Labyrinth', 'Arcade'],
difficulty: 'Mittel',
complexity: 'Komplex',
controls: 'WASD oder Pfeiltasten zum Bewegen',
codeStats: { total: 832, code: 644, comments: 69 },
},
{
id: '6',
title: 'Rhythm Defender',
description:
'Verteidige dich im Takt der Musik! Drücke die richtigen Tasten im perfekten Timing für maximale Combos.',
slug: 'rhythm-defender',
htmlFile: '/games/rhythm_defender.html',
thumbnail: '/screenshots/rhythm-defender.jpg',
tags: ['Rhythmus', 'Musik', 'Arcade'],
difficulty: 'Mittel',
complexity: 'Komplex',
controls: 'A, S, D, F Tasten im Rhythmus drücken',
codeStats: { total: 741, code: 584, comments: 56 },
},
{
id: '7',
title: 'Click Race',
description: 'Das schnellste Spiel! Klicke 30 mal so schnell du kannst. Wie schnell bist du?',
slug: 'click-race',
htmlFile: '/games/click_race.html',
thumbnail: '/screenshots/click-race.jpg',
tags: ['Geschwindigkeit', 'Minimal', 'Arcade'],
difficulty: 'Einfach',
complexity: 'Minimal',
controls: 'Klicke auf das rote Quadrat',
codeStats: { total: 111, code: 88, comments: 23 },
},
{
id: '8',
title: 'Color Memory',
description:
'Merke dir die Farbreihenfolge! Ein klassisches Gedächtnisspiel das immer schwerer wird.',
slug: 'color-memory',
htmlFile: '/games/color_memory.html',
thumbnail: '/screenshots/color-memory.jpg',
tags: ['Gedächtnis', 'Minimal', 'Puzzle'],
difficulty: 'Einfach',
complexity: 'Minimal',
controls: 'Klicke die Farben in der richtigen Reihenfolge',
codeStats: { total: 86, code: 86, comments: 0 },
},
{
id: '9',
title: 'Reaction Test',
description:
'Wie schnell sind deine Reflexe? Klicke so schnell wie möglich wenn der Bildschirm grün wird!',
slug: 'reaction-test',
htmlFile: '/games/reaction_test.html',
thumbnail: '/screenshots/reaction-test.jpg',
tags: ['Reaktion', 'Minimal', 'Test'],
difficulty: 'Einfach',
complexity: 'Minimal',
controls: 'Klicke wenn der Bildschirm grün wird',
codeStats: { total: 78, code: 78, comments: 0 },
},
{
id: '10',
title: 'Asteroid Dash',
description:
'Fliege durch gefährliche Asteroidenfelder! Sammle Energie-Kristalle, nutze Power-ups und weiche den rotierenden Asteroiden aus.',
slug: 'asteroid-dash',
htmlFile: '/games/asteroid_dash.html',
thumbnail: '/screenshots/asteroid-dash.jpg',
tags: ['Action', 'Arcade', 'Weltraum'],
difficulty: 'Mittel',
complexity: 'Mittel',
controls: 'WASD oder Pfeiltasten zum Fliegen, Leertaste für Boost',
codeStats: { total: 485, code: 428, comments: 57 },
},
{
id: '11',
title: 'Fish Catcher',
description:
'Fange Fische mit deinem Boot! Verschiedene Fischarten bringen unterschiedliche Punkte.',
slug: 'fish-catcher',
htmlFile: '/games/fish_catcher.html',
thumbnail: '/screenshots/fish-catcher.jpg',
tags: ['Arcade', 'Familie', 'Entspannend'],
difficulty: 'Einfach',
complexity: 'Einfach',
controls: 'A/D oder Pfeiltasten zum Bewegen',
codeStats: { total: 362, code: 321, comments: 41 },
},
{
id: '12',
title: 'Balloon Pop',
description:
'Platze bunte Ballons bevor sie entkommen! Verschiedene Ballonarten, Power-ups und Combo-System.',
slug: 'balloon-pop',
htmlFile: '/games/balloon_pop.html',
thumbnail: '/screenshots/balloon-pop.jpg',
tags: ['Geschicklichkeit', 'Familie', 'Bunt'],
difficulty: 'Einfach',
complexity: 'Einfach',
controls: 'Maus zum Klicken auf Ballons',
codeStats: { total: 398, code: 351, comments: 47 },
},
{
id: '13',
title: 'Word Scramble',
description:
'Entschlüssele durcheinandergewürfelte Wörter! Mit 5 Kategorien, Combo-System und steigender Schwierigkeit.',
slug: 'word-scramble',
htmlFile: '/games/word_scramble.html',
tags: ['Puzzle', 'Wortspiel', 'Bildung'],
difficulty: 'Mittel',
complexity: 'Mittel',
controls: 'Tastatur zum Eingeben, Maus zum Klicken auf Buchstaben',
codeStats: { total: 850, code: 720, comments: 130 },
},
{
id: '14',
title: 'Memory Card Match',
description:
'Das klassische Memory-Spiel! Finde alle Kartenpaare mit Emojis. Drei Schwierigkeitsstufen.',
slug: 'memory-card-match',
htmlFile: '/games/memory_card_match.html',
tags: ['Gedächtnis', 'Kartenspiel', 'Familie'],
difficulty: 'Einfach',
complexity: 'Einfach',
controls: 'Maus zum Aufdecken der Karten',
codeStats: { total: 415, code: 350, comments: 0 },
},
{
id: '15',
title: 'Turbo Racer',
description:
'Drift durch die Kurven und stelle Bestzeiten auf! Mit realistischer Drift-Physik und Nitro-Boost.',
slug: 'turbo-racer',
htmlFile: '/games/turbo_racer.html',
tags: ['Rennen', 'Action', 'Arcade'],
difficulty: 'Mittel',
complexity: 'Mittel',
controls: 'WASD oder Pfeiltasten zum Fahren, Leertaste für Boost',
codeStats: { total: 680, code: 620, comments: 60 },
},
{
id: '16',
title: 'Card Stack Rush',
description:
'Sortiere Karten blitzschnell auf die richtigen Stapel! Mit wechselnden Regeln und Combo-System.',
slug: 'card-stack-rush',
htmlFile: '/games/card_stack_rush.html',
tags: ['Kartenspiel', 'Geschwindigkeit', 'Arcade'],
difficulty: 'Mittel',
complexity: 'Einfach',
controls: 'Drag & Drop oder Klicken zum Platzieren',
codeStats: { total: 520, code: 480, comments: 0 },
},
{
id: '17',
title: 'Flappy Mana',
description:
'Fliege durch Röhren und sammle Punkte! Ein Flappy Bird Klon mit Partikeleffekten.',
slug: 'flappy-mana',
htmlFile: '/games/flappy_mana.html',
tags: ['Arcade', 'Geschicklichkeit', 'Endless'],
difficulty: 'Mittel',
complexity: 'Einfach',
controls: 'Klick oder Leertaste zum Fliegen',
codeStats: { total: 450, code: 430, comments: 20 },
},
{
id: '18',
title: 'Mana Runner',
description:
'Laufe und springe durch magische Welten! Sammle Mana-Kristalle und weiche Hindernissen aus.',
slug: 'mana-runner',
htmlFile: '/games/mana_runner.html',
tags: ['Jump n Run', 'Arcade', 'Endless'],
difficulty: 'Mittel',
complexity: 'Mittel',
controls: 'Leertaste zum Springen, Doppelsprung nach 10 Kristallen',
codeStats: { total: 600, code: 580, comments: 20 },
},
{
id: '19',
title: 'Mana Defense',
description:
'Verteidige deinen Mana-Kristall! Baue Türme, plane deine Strategie und überlebe 20 Wellen.',
slug: 'mana-defense',
htmlFile: '/games/mana_defense.html',
tags: ['Tower Defense', 'Strategie', 'Aufbau'],
difficulty: 'Schwer',
complexity: 'Komplex',
controls: 'Maus zum Platzieren, 1-3 für Turmauswahl, S zum Verkaufen',
codeStats: { total: 900, code: 850, comments: 50 },
},
{
id: '20',
title: 'Mana Factory',
description:
'Baue die größte Mana-Produktionsanlage! Ein Idle-Game mit Upgrades und Prestige-System.',
slug: 'mana-factory',
htmlFile: '/games/mana_factory.html',
tags: ['Idle', 'Incremental', 'Aufbau'],
difficulty: 'Einfach',
complexity: 'Mittel',
controls: 'Maus zum Klicken und Kaufen',
codeStats: { total: 800, code: 750, comments: 50 },
},
{
id: '21',
title: 'Puzzle Blocks',
description:
'Klassisches Tetris-Gameplay! Stapele fallende Blöcke, vervollständige Reihen und erreiche den höchsten Score.',
slug: 'puzzle-blocks',
htmlFile: '/games/puzzle_blocks.html',
tags: ['Puzzle', 'Klassiker', 'Arcade'],
difficulty: 'Mittel',
complexity: 'Einfach',
controls: '← → zum Bewegen, ↑ zum Drehen, ↓ schneller fallen, Space für Harddrop',
codeStats: { total: 450, code: 420, comments: 30 },
},
];
export function getGameBySlug(slug: string): Game | undefined {
return games.find((g) => g.slug === slug);
}
export function getGamesByTag(tag: string): Game[] {
return games.filter((g) => g.tags.includes(tag));
}
export function getAllTags(): string[] {
const tagSet = new Set<string>();
for (const game of games) {
for (const tag of game.tags) {
tagSet.add(tag);
}
}
return [...tagSet].sort();
}

View file

@ -0,0 +1 @@
// No guest seed data needed — games are static HTML files, stats build up from play

View file

@ -0,0 +1,55 @@
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
// ─── Types ──────────────────────────────────────────────────
export interface LocalGameStats extends BaseRecord {
gameId: string;
highScore: number;
lastScore: number;
gamesPlayed: number;
totalPlayTime: number;
lastPlayed: string;
}
export interface LocalGeneratedGame extends BaseRecord {
title: string;
description: string;
htmlCode: string;
prompt: string;
model: string;
iterationCount: number;
}
export interface LocalFavorite extends BaseRecord {
gameId: string;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const gamesStore = createLocalStore({
appId: 'arcade',
collections: [
{
name: 'gameStats',
indexes: ['gameId', 'highScore'],
},
{
name: 'generatedGames',
indexes: ['title'],
},
{
name: 'favorites',
indexes: ['gameId'],
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const gameStatsCollection = gamesStore.collection<LocalGameStats>('gameStats');
export const generatedGameCollection = gamesStore.collection<LocalGeneratedGame>('generatedGames');
export const favoriteCollection = gamesStore.collection<LocalFavorite>('favorites');

View file

@ -0,0 +1,24 @@
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
gameStatsCollection,
generatedGameCollection,
favoriteCollection,
type LocalGameStats,
type LocalGeneratedGame,
type LocalFavorite,
} from './local-store';
export function useAllGameStats() {
return useLiveQueryWithDefault(async () => gameStatsCollection.getAll(), [] as LocalGameStats[]);
}
export function useAllGeneratedGames() {
return useLiveQueryWithDefault(async () => {
const games = await generatedGameCollection.getAll();
return games.reverse();
}, [] as LocalGeneratedGame[]);
}
export function useAllFavorites() {
return useLiveQueryWithDefault(async () => favoriteCollection.getAll(), [] as LocalFavorite[]);
}

View file

@ -0,0 +1,38 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
const defaultLocale = 'de';
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
function getInitialLocale(): SupportedLocale {
if (browser) {
const stored = localStorage.getItem('arcade_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('arcade_locale', newLocale);
}
}
export { waitLocale };

View file

@ -0,0 +1,66 @@
{
"app": {
"name": "Arcade",
"loading": "Wird geladen..."
},
"nav": {
"home": "Spiele",
"create": "Erstellen",
"community": "Community",
"stats": "Statistiken",
"settings": "Einstellungen"
},
"home": {
"title": "Browser-Spiele",
"subtitle": "22+ Spiele direkt im Browser spielen",
"search": "Spiel suchen...",
"noResults": "Keine Spiele gefunden",
"allGames": "Alle Spiele",
"favorites": "Favoriten",
"recentlyPlayed": "Zuletzt gespielt"
},
"game": {
"play": "Spielen",
"difficulty": "Schwierigkeit",
"controls": "Steuerung",
"tags": "Tags",
"stats": "Statistiken",
"highScore": "Highscore",
"gamesPlayed": "Spiele gespielt",
"totalPlayTime": "Gesamtspielzeit",
"lastPlayed": "Zuletzt gespielt",
"back": "Zurück",
"fullscreen": "Vollbild",
"editor": "Code ansehen"
},
"create": {
"title": "Spiel erstellen",
"subtitle": "Beschreibe dein Spiel und lass es von KI generieren",
"prompt": "Was für ein Spiel soll erstellt werden?",
"promptPlaceholder": "Ein Neon-Snake-Spiel mit Partikeleffekten...",
"generate": "Generieren",
"generating": "Generiere Spiel...",
"model": "KI-Modell",
"iterate": "Verbessern",
"save": "Speichern",
"preview": "Vorschau"
},
"stats": {
"title": "Deine Statistiken",
"totalGames": "Gespielte Spiele",
"totalTime": "Gesamtspielzeit",
"favoriteGame": "Lieblingsspiel",
"noStats": "Noch keine Statistiken. Spiele ein paar Spiele!"
},
"difficulty": {
"Einfach": "Einfach",
"Mittel": "Mittel",
"Schwer": "Schwer"
},
"time": {
"justNow": "Gerade eben",
"minutesAgo": "Vor {minutes} Minuten",
"hoursAgo": "Vor {hours} Stunden",
"daysAgo": "Vor {days} Tagen"
}
}

View file

@ -0,0 +1,66 @@
{
"app": {
"name": "Arcade",
"loading": "Loading..."
},
"nav": {
"home": "Games",
"create": "Create",
"community": "Community",
"stats": "Statistics",
"settings": "Settings"
},
"home": {
"title": "Browser Games",
"subtitle": "22+ games to play right in your browser",
"search": "Search games...",
"noResults": "No games found",
"allGames": "All Games",
"favorites": "Favorites",
"recentlyPlayed": "Recently Played"
},
"game": {
"play": "Play",
"difficulty": "Difficulty",
"controls": "Controls",
"tags": "Tags",
"stats": "Statistics",
"highScore": "High Score",
"gamesPlayed": "Games Played",
"totalPlayTime": "Total Play Time",
"lastPlayed": "Last Played",
"back": "Back",
"fullscreen": "Fullscreen",
"editor": "View Code"
},
"create": {
"title": "Create Game",
"subtitle": "Describe your game and let AI generate it",
"prompt": "What kind of game should be created?",
"promptPlaceholder": "A neon snake game with particle effects...",
"generate": "Generate",
"generating": "Generating game...",
"model": "AI Model",
"iterate": "Improve",
"save": "Save",
"preview": "Preview"
},
"stats": {
"title": "Your Statistics",
"totalGames": "Games Played",
"totalTime": "Total Play Time",
"favoriteGame": "Favorite Game",
"noStats": "No statistics yet. Play some games!"
},
"difficulty": {
"Einfach": "Easy",
"Mittel": "Medium",
"Schwer": "Hard"
},
"time": {
"justNow": "Just now",
"minutesAgo": "{minutes} minutes ago",
"hoursAgo": "{hours} hours ago",
"daysAgo": "{days} days ago"
}
}

View file

@ -0,0 +1,8 @@
import { createFeedbackService } from '@manacore/feedback';
import { authStore } from '$lib/stores/auth.svelte';
export const feedbackService = createFeedbackService({
apiUrl: import.meta.env.DEV ? 'http://localhost:3001' : 'https://auth.mana.how',
appId: 'arcade',
getAuthToken: async () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,114 @@
import { gameStatsCollection, type LocalGameStats } from '$lib/data/local-store';
export interface GameMessage {
type: 'GAME_EVENT' | 'GAME_LOADED' | 'GAME_ENDED';
gameId: string;
event?: string;
data?: Record<string, unknown>;
}
export function initGameCommunication(gameSlug: string) {
let gameStartTime: number | null = null;
async function getOrCreateStats(gameId: string): Promise<LocalGameStats | null> {
const all = await gameStatsCollection.getAll();
return all.find((s) => s.gameId === gameId) || null;
}
async function updateGameStats(gameId: string, update: Partial<LocalGameStats>) {
const existing = await getOrCreateStats(gameId);
if (existing) {
await gameStatsCollection.update(existing.id, {
...update,
lastPlayed: new Date().toISOString(),
});
} else {
await gameStatsCollection.insert({
gameId,
highScore: 0,
lastScore: 0,
gamesPlayed: 0,
totalPlayTime: 0,
lastPlayed: new Date().toISOString(),
...update,
});
}
}
function handleMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return;
const message = event.data as GameMessage;
if (!message.type || message.gameId !== gameSlug) return;
switch (message.type) {
case 'GAME_LOADED':
gameStartTime = Date.now();
getOrCreateStats(gameSlug).then((stats) => {
updateGameStats(gameSlug, {
gamesPlayed: (stats?.gamesPlayed || 0) + 1,
});
});
break;
case 'GAME_EVENT':
handleGameEvent(gameSlug, message.event!, message.data);
break;
case 'GAME_ENDED':
if (gameStartTime) {
const playTime = Math.floor((Date.now() - gameStartTime) / 1000);
getOrCreateStats(gameSlug).then((stats) => {
updateGameStats(gameSlug, {
totalPlayTime: (stats?.totalPlayTime || 0) + playTime,
});
});
gameStartTime = null;
}
break;
}
}
function handleBeforeUnload() {
if (gameStartTime) {
const playTime = Math.floor((Date.now() - gameStartTime) / 1000);
getOrCreateStats(gameSlug).then((stats) => {
updateGameStats(gameSlug, {
totalPlayTime: (stats?.totalPlayTime || 0) + playTime,
});
});
}
}
window.addEventListener('message', handleMessage);
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('message', handleMessage);
window.removeEventListener('beforeunload', handleBeforeUnload);
};
async function handleGameEvent(
gameId: string,
event: string,
data: Record<string, unknown> | undefined
) {
if (!data) return;
switch (event) {
case 'SCORE_UPDATE':
case 'GAME_OVER': {
const score = data.score as number;
if (score) {
const stats = await getOrCreateStats(gameId);
await updateGameStats(gameId, {
lastScore: score,
highScore: Math.max(score, stats?.highScore || 0),
});
}
break;
}
}
}
}

View file

@ -0,0 +1,41 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
const onboardingSteps: AppOnboardingStep[] = [
{
id: 'features',
type: 'info',
question: 'Willkommen bei Arcade!',
description: 'Das erwartet dich:',
emoji: '🎮',
gradient: { from: 'green-500', to: 'green-700' },
bullets: [
'22+ Browser-Spiele direkt spielbar',
'KI-Spielgenerator: Erstelle eigene Games',
'Statistiken: Highscores & Spielzeit',
'Community: Reiche eigene Spiele ein',
],
},
{
id: 'welcome',
type: 'info',
question: "Los geht's!",
description: 'Tipps:',
emoji: '🕹️',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Cmd/Ctrl+K für Schnellsuche',
'Spiele laufen komplett im Browser',
'Stats werden lokal gespeichert',
'Anmelden synchronisiert deine Daten',
],
},
];
export const gamesOnboarding = createAppOnboardingStore({
appId: 'arcade',
steps: onboardingSteps,
userSettings,
onComplete: async () => {},
onSkip: async () => {},
});

View file

@ -0,0 +1,5 @@
import { createManaAuthStore } from '@manacore/shared-auth-stores';
export const authStore = createManaAuthStore({
devBackendPort: 3011,
});

View file

@ -0,0 +1,5 @@
import { createSimpleNavigationStores } from '@manacore/shared-stores';
export const { isNavCollapsed } = createSimpleNavigationStores({
storageKey: 'arcade',
});

View file

@ -0,0 +1,6 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'arcade',
defaultVariant: 'lume',
});

View file

@ -0,0 +1,18 @@
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
if (injectedUrl) return injectedUrl;
}
return import.meta.env.DEV ? 'http://localhost:3001' : '';
}
export const userSettings = createUserSettingsStore({
appId: 'arcade',
authUrl: getAuthUrl,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,298 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { PillNavigation, CommandBar, SyncIndicator } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
CommandBarItem,
QuickAction,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { games, getGameBySlug } from '$lib/data/games';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { gamesOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { gamesStore } from '$lib/data/local-store';
import {
tagLocalStore,
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
const allTags = useAllSharedTags();
let showGuestWelcome = $state(false);
function initGuestWelcome() {
if (!authStore.isAuthenticated && shouldShowGuestWelcome('arcade')) {
showGuestWelcome = true;
}
}
const appItems = getPillAppItems('arcade');
let { children } = $props();
let commandBarOpen = $state(false);
const commandBarQuickActions: QuickAction[] = [
{ id: 'home', label: 'Alle Spiele', icon: 'gamepad-2', href: '/', shortcut: '1' },
{ id: 'create', label: 'Spiel erstellen', icon: 'sparkles', href: '/create', shortcut: '2' },
{ id: 'community', label: 'Community', icon: 'users', href: '/community', shortcut: '3' },
{ id: 'stats', label: 'Statistiken', icon: 'bar-chart-3', href: '/stats', shortcut: '4' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
const queryLower = query.toLowerCase();
return games
.filter(
(g) =>
g.title.toLowerCase().includes(queryLower) ||
g.tags.some((t) => t.toLowerCase().includes(queryLower))
)
.slice(0, 10)
.map((g) => ({
id: `game-${g.slug}`,
title: g.title,
subtitle: g.tags.join(', '),
}));
}
function handleCommandBarSelect(item: CommandBarItem) {
const slug = item.id.replace('game-', '');
goto(`/play/${slug}`);
}
let isCollapsed = $state(false);
let isDark = $derived(theme.isDark);
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
)
);
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
let themeVariantItems = $derived<PillDropdownItem[]>([
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant]?.label || variant,
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
onClick: () => theme.setVariant(variant),
active: (theme.variant || 'lume') === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
let currentThemeVariantLabel = $derived(
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
);
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Spiele', icon: 'gamepad-2' },
{ href: '/create', label: 'Erstellen', icon: 'sparkles' },
{ href: '/community', label: 'Community', icon: 'users' },
{ href: '/stats', label: 'Statistiken', icon: 'bar-chart-3' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
];
const navItems = $derived(
filterHiddenNavItems('arcade', baseNavItems, userSettings.nav?.hiddenNavItems || {})
);
function handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
commandBarOpen = true;
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('arcade-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
async function handleAuthReady() {
await Promise.all([gamesStore.initialize(), tagLocalStore.initialize()]);
if (authStore.isAuthenticated) {
const getToken = () => authStore.getValidToken();
gamesStore.startSync(getToken);
tagMutations.startSync(getToken);
}
const savedCollapsed = localStorage.getItem('arcade-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
initGuestWelcome();
if (authStore.isAuthenticated) {
await userSettings.load();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Arcade"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#00ff88"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
themesHref="/themes"
helpHref="/help"
allAppsHref="/apps"
/>
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Spiel suchen..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
</div>
{#if gamesOnboarding.shouldShow}
<MiniOnboardingModal store={gamesOnboarding} appName="Arcade" appEmoji="🎮" />
{/if}
<GuestWelcomeModal
appId="arcade"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
<SyncIndicator />
</AuthGate>
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
position: relative;
z-index: 0;
padding-bottom: 100px;
}
.content-wrapper {
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 1rem;
position: relative;
z-index: 0;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
</style>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { games, getAllTags } from '$lib/data/games';
import GameCard from '$lib/components/GameCard.svelte';
let searchQuery = $state('');
let selectedTag = $state<string | null>(null);
const allTags = getAllTags();
let filteredGames = $derived(() => {
let result = games;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(g) =>
g.title.toLowerCase().includes(q) ||
g.description.toLowerCase().includes(q) ||
g.tags.some((t) => t.toLowerCase().includes(q))
);
}
if (selectedTag) {
result = result.filter((g) => g.tags.includes(selectedTag!));
}
return result;
});
</script>
<svelte:head>
<title>{$_('app.name')} - {$_('home.title')}</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('home.title')}</h1>
<p class="text-muted-foreground mt-1">{$_('home.subtitle')}</p>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<input
type="text"
bind:value={searchQuery}
placeholder={$_('home.search')}
class="flex-1 rounded-lg border border-border bg-background px-4 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="flex flex-wrap gap-2">
<button
class="text-xs px-3 py-1.5 rounded-full transition-colors {selectedTag === null
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => (selectedTag = null)}
>
{$_('home.allGames')}
</button>
{#each allTags as tag}
<button
class="text-xs px-3 py-1.5 rounded-full transition-colors {selectedTag === tag
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => (selectedTag = selectedTag === tag ? null : tag)}
>
{tag}
</button>
{/each}
</div>
{#if filteredGames().length === 0}
<div class="text-center py-12">
<p class="text-muted-foreground">{$_('home.noResults')}</p>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{#each filteredGames() as game (game.id)}
<GameCard {game} href="/play/{game.slug}" />
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
</script>
<svelte:head>
<title>{$_('nav.community')} - Arcade</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('nav.community')}</h1>
<p class="text-muted-foreground mt-1">
Von der Community erstellte Spiele. Reiche dein eigenes Spiel ein!
</p>
</div>
<div class="text-center py-12 rounded-xl border border-dashed border-border">
<p class="text-4xl mb-4">🎮</p>
<p class="text-muted-foreground">Noch keine Community-Spiele vorhanden.</p>
<a
href="/create"
class="inline-block mt-4 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Erstelle das erste Spiel!
</a>
</div>
</div>

View file

@ -0,0 +1,211 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { generatedGameCollection } from '$lib/data/local-store';
const BACKEND_URL = import.meta.env.DEV
? 'http://localhost:3011'
: import.meta.env.PUBLIC_MANA_GAMES_BACKEND_URL || '';
const models = [
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash', provider: 'Google', speed: 'Schnell' },
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', provider: 'Google', speed: 'Schnell' },
{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', provider: 'Google', speed: 'Langsam' },
{ id: 'claude-3.5-haiku', label: 'Claude 3.5 Haiku', provider: 'Anthropic', speed: 'Schnell' },
{ id: 'claude-3.5-sonnet', label: 'Claude Sonnet', provider: 'Anthropic', speed: 'Mittel' },
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'Azure', speed: 'Schnell' },
{ id: 'gpt-4o', label: 'GPT-4o', provider: 'Azure', speed: 'Mittel' },
];
let prompt = $state('');
let selectedModel = $state('gemini-2.0-flash');
let isGenerating = $state(false);
let generatedHtml = $state('');
let error = $state('');
let iterationCount = $state(0);
let originalPrompt = $state('');
async function generateGame() {
if (!prompt.trim() || isGenerating) return;
isGenerating = true;
error = '';
try {
const body: Record<string, unknown> = {
description: prompt,
model: selectedModel,
mode: iterationCount > 0 ? 'iterate' : 'create',
};
if (iterationCount > 0 && generatedHtml) {
body.originalPrompt = originalPrompt;
body.currentCode = generatedHtml;
body.iterationCount = iterationCount;
}
const response = await fetch(`${BACKEND_URL}/api/games/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Fehler: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.html) {
generatedHtml = data.html;
if (iterationCount === 0) {
originalPrompt = prompt;
}
iterationCount++;
} else {
error = data.error || 'Unbekannter Fehler bei der Generierung.';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Verbindungsfehler zum Backend.';
} finally {
isGenerating = false;
}
}
async function saveGame() {
if (!generatedHtml || !prompt) return;
await generatedGameCollection.insert({
title: originalPrompt || prompt,
description: prompt,
htmlCode: generatedHtml,
prompt: originalPrompt || prompt,
model: selectedModel,
iterationCount,
});
// Reset
prompt = '';
generatedHtml = '';
iterationCount = 0;
originalPrompt = '';
}
function resetGame() {
generatedHtml = '';
iterationCount = 0;
originalPrompt = '';
prompt = '';
error = '';
}
</script>
<svelte:head>
<title>{$_('create.title')} - Arcade</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('create.title')}</h1>
<p class="text-muted-foreground mt-1">{$_('create.subtitle')}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Input Panel -->
<div class="space-y-4">
<div>
<label for="prompt" class="block text-sm font-medium text-foreground mb-2">
{$_('create.prompt')}
</label>
<textarea
id="prompt"
bind:value={prompt}
placeholder={$_('create.promptPlaceholder')}
rows="4"
class="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
></textarea>
</div>
<div>
<label for="model" class="block text-sm font-medium text-foreground mb-2">
{$_('create.model')}
</label>
<select
id="model"
bind:value={selectedModel}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
>
{#each models as model}
<option value={model.id}>
{model.label} ({model.provider} - {model.speed})
</option>
{/each}
</select>
</div>
{#if error}
<div class="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
{error}
</div>
{/if}
<div class="flex gap-2">
<button
onclick={generateGame}
disabled={!prompt.trim() || isGenerating}
class="flex-1 px-4 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isGenerating}
<span class="inline-flex items-center gap-2">
<span class="animate-spin"></span>
{$_('create.generating')}
</span>
{:else if iterationCount > 0}
{$_('create.iterate')}
{:else}
{$_('create.generate')}
{/if}
</button>
{#if generatedHtml}
<button
onclick={saveGame}
class="px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
{$_('create.save')}
</button>
<button
onclick={resetGame}
class="px-4 py-2.5 rounded-lg border border-border bg-card text-muted-foreground hover:bg-muted transition-colors"
>
Neu
</button>
{/if}
</div>
{#if iterationCount > 0}
<p class="text-xs text-muted-foreground">
Iteration {iterationCount} &middot; Beschreibe Änderungen im Prompt-Feld
</p>
{/if}
</div>
<!-- Preview Panel -->
<div class="rounded-xl border border-border bg-black overflow-hidden">
{#if generatedHtml}
<iframe
srcdoc={generatedHtml}
title="Generiertes Spiel"
class="w-full aspect-[16/10] border-0"
sandbox="allow-scripts"
></iframe>
{:else}
<div class="w-full aspect-[16/10] flex items-center justify-center">
<div class="text-center">
<p class="text-4xl mb-3 opacity-40">🎮</p>
<p class="text-muted-foreground text-sm">{$_('create.preview')}</p>
</div>
</div>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/feedback';
import { feedbackService } from '$lib/services/feedback';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<svelte:head>
<title>Feedback - Arcade</title>
</svelte:head>
<FeedbackPage {feedbackService} appName="Arcade" currentUserId={authStore.user?.id} />

View file

@ -0,0 +1,47 @@
<svelte:head>
<title>Hilfe - Arcade</title>
</svelte:head>
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-2xl font-bold text-foreground">Hilfe</h1>
<section class="space-y-4">
<div class="rounded-xl border border-border bg-card p-4">
<h2 class="font-semibold text-foreground mb-2">Wie spiele ich?</h2>
<p class="text-sm text-muted-foreground">
Wähle ein Spiel auf der Startseite aus und klicke darauf. Das Spiel läuft direkt im Browser.
Die Steuerung wird auf der Spielseite angezeigt.
</p>
</div>
<div class="rounded-xl border border-border bg-card p-4">
<h2 class="font-semibold text-foreground mb-2">KI-Spielgenerator</h2>
<p class="text-sm text-muted-foreground">
Unter "Erstellen" kannst du eigene Spiele beschreiben und von verschiedenen KI-Modellen
generieren lassen. Generierte Spiele werden lokal in deinem Browser gespeichert.
</p>
</div>
<div class="rounded-xl border border-border bg-card p-4">
<h2 class="font-semibold text-foreground mb-2">Statistiken</h2>
<p class="text-sm text-muted-foreground">
Deine Highscores, Spielzeiten und Fortschritte werden automatisch gespeichert. Melde dich
an, um sie geräteübergreifend zu synchronisieren.
</p>
</div>
<div class="rounded-xl border border-border bg-card p-4">
<h2 class="font-semibold text-foreground mb-2">Tastaturkürzel</h2>
<div class="grid grid-cols-2 gap-2 mt-2">
{#each [['Cmd/Ctrl+K', 'Schnellsuche'], ['Esc', 'Suche schließen']] as [key, desc]}
<div class="flex items-center gap-2">
<kbd class="px-2 py-0.5 rounded bg-muted text-xs font-mono text-muted-foreground"
>{key}</kbd
>
<span class="text-sm text-foreground">{desc}</span>
</div>
{/each}
</div>
</div>
</section>
</div>

View file

@ -0,0 +1,9 @@
<svelte:head>
<title>Mana - Arcade</title>
</svelte:head>
<div class="max-w-2xl mx-auto text-center py-12">
<p class="text-4xl mb-4">💎</p>
<h1 class="text-2xl font-bold text-foreground">Mana</h1>
<p class="text-muted-foreground mt-2">Demnächst verfügbar.</p>
</div>

View file

@ -0,0 +1,84 @@
<script lang="ts">
import { useAllGeneratedGames } from '$lib/data/queries';
import { generatedGameCollection } from '$lib/data/local-store';
const generatedGames = useAllGeneratedGames();
let selectedGameId = $state<string | null>(null);
let selectedGame = $derived(generatedGames.value.find((g) => g.id === selectedGameId));
async function deleteGame(id: string) {
await generatedGameCollection.remove(id);
if (selectedGameId === id) {
selectedGameId = null;
}
}
</script>
<svelte:head>
<title>Generierte Spiele - Arcade</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Generierte Spiele</h1>
<p class="text-muted-foreground mt-1">Deine mit KI erstellten Spiele</p>
</div>
{#if generatedGames.value.length === 0}
<div class="text-center py-12 rounded-xl border border-dashed border-border">
<p class="text-4xl mb-4"></p>
<p class="text-muted-foreground">Noch keine generierten Spiele.</p>
<a
href="/create"
class="inline-block mt-4 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Erstelle dein erstes Spiel!
</a>
</div>
{:else}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="space-y-2 lg:col-span-1">
{#each generatedGames.value as game (game.id)}
<button
onclick={() => (selectedGameId = game.id)}
class="w-full text-left rounded-lg border p-3 transition-colors {selectedGameId ===
game.id
? 'border-primary bg-primary/5'
: 'border-border bg-card hover:bg-muted/50'}"
>
<p class="font-medium text-foreground text-sm truncate">{game.title}</p>
<p class="text-xs text-muted-foreground mt-1">
{game.model} &middot; {game.iterationCount} Iterationen
</p>
</button>
{/each}
</div>
<div class="lg:col-span-2 rounded-xl border border-border bg-black overflow-hidden">
{#if selectedGame}
<div class="flex items-center justify-between px-3 py-2 bg-card border-b border-border">
<span class="text-sm text-foreground truncate">{selectedGame.title}</span>
<button
onclick={() => deleteGame(selectedGame!.id)}
class="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Löschen
</button>
</div>
<iframe
srcdoc={selectedGame.htmlCode}
title={selectedGame.title}
class="w-full aspect-[16/10] border-0"
sandbox="allow-scripts"
></iframe>
{:else}
<div class="w-full aspect-[16/10] flex items-center justify-center">
<p class="text-muted-foreground text-sm">Wähle ein Spiel aus der Liste</p>
</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { getGameBySlug } from '$lib/data/games';
import { initGameCommunication } from '$lib/services/game-communication';
import { gameStatsCollection, type LocalGameStats } from '$lib/data/local-store';
const slug = $derived($page.params.slug);
const game = $derived(getGameBySlug(slug));
let stats = $state<LocalGameStats | null>(null);
let isFullscreen = $state(false);
let iframeEl: HTMLIFrameElement;
let cleanup: (() => void) | undefined;
onMount(async () => {
if (!slug) return;
cleanup = initGameCommunication(slug);
const all = await gameStatsCollection.getAll();
stats = all.find((s) => s.gameId === slug) || null;
});
onDestroy(() => {
cleanup?.();
});
function toggleFullscreen() {
if (!iframeEl) return;
if (!document.fullscreenElement) {
iframeEl.requestFullscreen();
isFullscreen = true;
} else {
document.exitFullscreen();
isFullscreen = false;
}
}
function formatPlayTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
</script>
<svelte:head>
<title>{game?.title || 'Spiel'} - Arcade</title>
</svelte:head>
{#if game}
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/" class="text-muted-foreground hover:text-foreground transition-colors">
&larr; {$_('game.back')}
</a>
<h1 class="text-xl font-bold text-foreground">{game.title}</h1>
</div>
<div class="flex gap-2">
<button
onclick={toggleFullscreen}
class="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
>
{$_('game.fullscreen')}
</button>
</div>
</div>
<div class="rounded-xl overflow-hidden border border-border bg-black">
<iframe
bind:this={iframeEl}
src={game.htmlFile}
title={game.title}
class="w-full aspect-[16/10] border-0"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="rounded-xl border border-border bg-card p-4 space-y-3">
<h2 class="font-semibold text-foreground">{game.title}</h2>
<p class="text-sm text-muted-foreground">{game.description}</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">{$_('game.difficulty')}</span>
<span class="text-foreground">{game.difficulty}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">{$_('game.controls')}</span>
<span class="text-foreground text-right max-w-[60%]">{game.controls}</span>
</div>
</div>
<div class="flex flex-wrap gap-1 pt-2">
{#each game.tags as tag}
<span class="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{tag}
</span>
{/each}
</div>
</div>
{#if stats}
<div class="rounded-xl border border-border bg-card p-4 space-y-3">
<h2 class="font-semibold text-foreground">{$_('game.stats')}</h2>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">{$_('game.highScore')}</span>
<span class="text-foreground font-mono">{stats.highScore.toLocaleString()}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">{$_('game.gamesPlayed')}</span>
<span class="text-foreground">{stats.gamesPlayed}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">{$_('game.totalPlayTime')}</span>
<span class="text-foreground">{formatPlayTime(stats.totalPlayTime)}</span>
</div>
</div>
</div>
{/if}
</div>
</div>
{:else}
<div class="text-center py-12">
<p class="text-muted-foreground">Spiel nicht gefunden.</p>
<a href="/" class="text-primary hover:underline mt-2 inline-block">Zurück zur Übersicht</a>
</div>
{/if}

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { ProfilePage } from '@manacore/shared-profile-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
</script>
<svelte:head>
<title>Profil - Arcade</title>
</svelte:head>
{#if authStore.isAuthenticated}
<ProfilePage {authStore} {goto} />
{:else}
<div class="max-w-2xl mx-auto text-center py-12">
<p class="text-muted-foreground">Bitte melde dich an.</p>
<a href="/login" class="text-primary hover:underline mt-2 inline-block">Anmelden</a>
</div>
{/if}

View file

@ -0,0 +1,165 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { locale } from 'svelte-i18n';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { setLocale, supportedLocales } from '$lib/i18n';
import { goto } from '$app/navigation';
import { gameStatsCollection } from '$lib/data/local-store';
async function clearStats() {
const all = await gameStatsCollection.getAll();
for (const stat of all) {
await gameStatsCollection.remove(stat.id);
}
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
</script>
<svelte:head>
<title>{$_('nav.settings')} - Arcade</title>
</svelte:head>
<div class="settings-page">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">{$_('nav.settings')}</h1>
<p class="text-muted-foreground text-sm mt-1">Passe Arcade an deine Bedürfnisse an</p>
</header>
<!-- Theme -->
<section class="settings-section">
<h2 class="text-lg font-bold text-foreground mb-4">Darstellung</h2>
<div class="setting-row">
<div>
<div class="setting-label">Farbmodus</div>
<div class="setting-desc">Hell, Dunkel oder System</div>
</div>
<div class="flex gap-1">
{#each ['light', 'dark', 'system'] as mode}
<button
class="px-3 py-1.5 text-sm rounded-lg transition-colors {theme.mode === mode
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => theme.setMode(mode as 'light' | 'dark' | 'system')}
>
{mode === 'light' ? 'Hell' : mode === 'dark' ? 'Dunkel' : 'System'}
</button>
{/each}
</div>
</div>
</section>
<!-- Language -->
<section class="settings-section">
<h2 class="text-lg font-bold text-foreground mb-4">Sprache</h2>
<div class="setting-row">
<div>
<div class="setting-label">App-Sprache</div>
<div class="setting-desc">Sprache der Benutzeroberfläche</div>
</div>
<select
value={$locale}
onchange={(e) => setLocale((e.target as HTMLSelectElement).value as any)}
class="h-9 px-3 rounded-lg bg-background border border-border text-foreground text-sm"
>
{#each supportedLocales as loc}
<option value={loc}>{loc === 'de' ? 'Deutsch' : 'English'}</option>
{/each}
</select>
</div>
</section>
<!-- Account -->
<section class="settings-section">
<h2 class="text-lg font-bold text-foreground mb-4">Konto</h2>
{#if authStore.isAuthenticated}
<div class="setting-row">
<div>
<div class="setting-label">Eingeloggt als</div>
<div class="setting-desc">{authStore.user?.email}</div>
</div>
<button
class="px-4 py-2 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors text-sm"
onclick={handleLogout}
>
Abmelden
</button>
</div>
{:else}
<div class="setting-row">
<div>
<div class="setting-label">Gast-Modus</div>
<div class="setting-desc">Melde dich an, um Stats zu synchronisieren</div>
</div>
<a
href="/login"
class="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm"
>
Anmelden
</a>
</div>
{/if}
</section>
<!-- Data -->
<section class="settings-section">
<h2 class="text-lg font-bold text-foreground mb-4">Daten</h2>
<div class="setting-row">
<div>
<div class="setting-label">Spielstatistiken löschen</div>
<div class="setting-desc">Alle Highscores und Spielzeiten zurücksetzen</div>
</div>
<button
class="px-4 py-2 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors text-sm"
onclick={clearStats}
>
Löschen
</button>
</div>
</section>
</div>
<style>
.settings-page {
max-width: 600px;
margin: 0 auto;
}
.settings-section {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid hsl(var(--border));
}
.settings-section:last-child {
border-bottom: none;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
}
.setting-label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.setting-desc {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin-top: 2px;
}
</style>

View file

@ -0,0 +1,95 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { useAllGameStats } from '$lib/data/queries';
import { games } from '$lib/data/games';
const allStats = useAllGameStats();
let totalGamesPlayed = $derived(allStats.value.reduce((sum, s) => sum + s.gamesPlayed, 0));
let totalPlayTime = $derived(allStats.value.reduce((sum, s) => sum + s.totalPlayTime, 0));
let favoriteGame = $derived(() => {
if (allStats.value.length === 0) return null;
const top = allStats.value.reduce((fav, s) => (s.gamesPlayed > fav.gamesPlayed ? s : fav));
return games.find((g) => g.slug === top.gameId || g.id === top.gameId);
});
let sortedStats = $derived([...allStats.value].sort((a, b) => b.highScore - a.highScore));
function formatPlayTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function getGameTitle(gameId: string): string {
return games.find((g) => g.slug === gameId || g.id === gameId)?.title || gameId;
}
</script>
<svelte:head>
<title>{$_('stats.title')} - Arcade</title>
</svelte:head>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-foreground">{$_('stats.title')}</h1>
{#if allStats.value.length === 0}
<div class="text-center py-12">
<p class="text-4xl mb-4">📊</p>
<p class="text-muted-foreground">{$_('stats.noStats')}</p>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="rounded-xl border border-border bg-card p-4 text-center">
<p class="text-3xl font-bold text-primary">{totalGamesPlayed}</p>
<p class="text-sm text-muted-foreground mt-1">{$_('stats.totalGames')}</p>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<p class="text-3xl font-bold text-primary">{formatPlayTime(totalPlayTime)}</p>
<p class="text-sm text-muted-foreground mt-1">{$_('stats.totalTime')}</p>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<p class="text-3xl font-bold text-primary">{favoriteGame()?.title || '-'}</p>
<p class="text-sm text-muted-foreground mt-1">{$_('stats.favoriteGame')}</p>
</div>
</div>
<div class="rounded-xl border border-border bg-card overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border text-left">
<th class="px-4 py-3 text-muted-foreground font-medium">Spiel</th>
<th class="px-4 py-3 text-muted-foreground font-medium text-right"
>{$_('game.highScore')}</th
>
<th class="px-4 py-3 text-muted-foreground font-medium text-right hidden sm:table-cell"
>{$_('game.gamesPlayed')}</th
>
<th class="px-4 py-3 text-muted-foreground font-medium text-right hidden md:table-cell"
>{$_('game.totalPlayTime')}</th
>
</tr>
</thead>
<tbody>
{#each sortedStats as stat (stat.id)}
<tr class="border-b border-border/50 hover:bg-muted/30 transition-colors">
<td class="px-4 py-3 text-foreground">{getGameTitle(stat.gameId)}</td>
<td class="px-4 py-3 text-foreground font-mono text-right"
>{stat.highScore.toLocaleString()}</td
>
<td class="px-4 py-3 text-muted-foreground text-right hidden sm:table-cell"
>{stat.gamesPlayed}</td
>
<td class="px-4 py-3 text-muted-foreground text-right hidden md:table-cell"
>{formatPlayTime(stat.totalPlayTime)}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View file

@ -0,0 +1,202 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
const BACKEND_URL = import.meta.env.DEV
? 'http://localhost:3011'
: import.meta.env.PUBLIC_MANA_GAMES_BACKEND_URL || '';
let title = $state('');
let description = $state('');
let controls = $state('');
let difficulty = $state<'Einfach' | 'Mittel' | 'Schwer'>('Mittel');
let tags = $state('');
let htmlCode = $state('');
let authorName = $state('');
let isSubmitting = $state(false);
let submitResult = $state<{ success: boolean; message: string } | null>(null);
async function handleSubmit() {
if (!title.trim() || !htmlCode.trim() || !authorName.trim()) return;
isSubmitting = true;
submitResult = null;
try {
const response = await fetch(`${BACKEND_URL}/api/games/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
controls,
difficulty,
complexity: 'Mittel',
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
author: { name: authorName },
files: { html: htmlCode },
submittedAt: new Date().toISOString(),
}),
});
const data = await response.json();
submitResult = {
success: data.success,
message: data.success
? `Eingereicht! PR #${data.prNumber} erstellt.`
: data.error || 'Fehler beim Einreichen.',
};
if (data.success) {
title = '';
description = '';
controls = '';
tags = '';
htmlCode = '';
}
} catch {
submitResult = { success: false, message: 'Verbindungsfehler zum Backend.' };
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Spiel einreichen - Arcade</title>
</svelte:head>
<div class="max-w-2xl mx-auto space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Spiel einreichen</h1>
<p class="text-muted-foreground mt-1">Reiche dein eigenes HTML5-Spiel bei der Community ein.</p>
</div>
{#if !authStore.isAuthenticated}
<div class="rounded-xl border border-border bg-card p-6 text-center">
<p class="text-muted-foreground mb-4">Bitte melde dich an, um ein Spiel einzureichen.</p>
<a
href="/login"
class="inline-block px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Anmelden
</a>
</div>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<div>
<label for="title" class="block text-sm font-medium text-foreground mb-1">Titel *</label>
<input
id="title"
type="text"
bind:value={title}
required
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label for="author" class="block text-sm font-medium text-foreground mb-1">Autor *</label>
<input
id="author"
type="text"
bind:value={authorName}
required
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label for="desc" class="block text-sm font-medium text-foreground mb-1">Beschreibung</label
>
<textarea
id="desc"
bind:value={description}
rows="3"
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="controls" class="block text-sm font-medium text-foreground mb-1"
>Steuerung</label
>
<input
id="controls"
type="text"
bind:value={controls}
placeholder="Pfeiltasten, Maus..."
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label for="difficulty" class="block text-sm font-medium text-foreground mb-1"
>Schwierigkeit</label
>
<select
id="difficulty"
bind:value={difficulty}
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="Einfach">Einfach</option>
<option value="Mittel">Mittel</option>
<option value="Schwer">Schwer</option>
</select>
</div>
</div>
<div>
<label for="tags" class="block text-sm font-medium text-foreground mb-1"
>Tags (kommagetrennt)</label
>
<input
id="tags"
type="text"
bind:value={tags}
placeholder="Arcade, Action, Puzzle"
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div>
<label for="html" class="block text-sm font-medium text-foreground mb-1">HTML-Code *</label>
<textarea
id="html"
bind:value={htmlCode}
rows="12"
required
placeholder="<!DOCTYPE html>..."
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
></textarea>
</div>
{#if submitResult}
<div
class="rounded-lg border p-3 text-sm {submitResult.success
? 'border-green-500/30 bg-green-500/10 text-green-400'
: 'border-red-500/30 bg-red-500/10 text-red-400'}"
>
{submitResult.message}
</div>
{/if}
<button
type="submit"
disabled={!title.trim() || !htmlCode.trim() || !authorName.trim() || isSubmitting}
class="w-full px-4 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Wird eingereicht...' : 'Spiel einreichen'}
</button>
</form>
{/if}
</div>

View file

@ -0,0 +1,9 @@
<svelte:head>
<title>Tags - Arcade</title>
</svelte:head>
<div class="max-w-2xl mx-auto text-center py-12">
<p class="text-4xl mb-4">🏷️</p>
<h1 class="text-2xl font-bold text-foreground">Tags</h1>
<p class="text-muted-foreground mt-2">Demnächst verfügbar.</p>
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte';
import { THEME_DEFINITIONS, EXTENDED_THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
const allThemes = EXTENDED_THEME_VARIANTS;
</script>
<svelte:head>
<title>Themes - Arcade</title>
</svelte:head>
<div class="max-w-4xl mx-auto space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Themes</h1>
<p class="text-muted-foreground mt-1">Wähle ein Theme für Arcade</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{#each allThemes as variant}
{@const def = THEME_DEFINITIONS[variant]}
{#if def}
<button
onclick={() => theme.setVariant(variant)}
class="rounded-xl border p-4 text-left transition-all hover:-translate-y-0.5 {theme.variant ===
variant
? 'border-primary bg-primary/5 ring-2 ring-primary/30'
: 'border-border bg-card hover:border-primary/30'}"
>
<div class="text-2xl mb-2">{def.icon || '🎨'}</div>
<div class="font-medium text-foreground text-sm">{def.label}</div>
</button>
{/if}
{/each}
</div>
</div>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<svelte:head>
<title>Arcade - Passwort vergessen</title>
</svelte:head>
<ForgotPasswordPage {authStore} {goto} appName="Arcade" loginHref="/login" />

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<svelte:head>
<title>Arcade - Login</title>
</svelte:head>
<LoginPage
{authStore}
{goto}
appName="Arcade"
registerHref="/register"
forgotPasswordHref="/forgot-password"
primaryColor="#00ff88"
/>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<svelte:head>
<title>Arcade - Registrieren</title>
</svelte:head>
<RegisterPage {authStore} {goto} appName="Arcade" loginHref="/login" primaryColor="#00ff88" />

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
</script>
<svelte:head>
<title>Arcade - Passwort zurücksetzen</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center">
<div class="max-w-md w-full p-6 text-center">
<h1 class="text-xl font-bold text-foreground mb-4">Passwort zurücksetzen</h1>
<p class="text-muted-foreground mb-6">Funktion wird eingerichtet.</p>
<a href="/login" class="text-primary hover:underline">Zurück zum Login</a>
</div>
</div>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { isLoading as isLocaleLoading } from 'svelte-i18n';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { waitLocale } from '$lib/i18n';
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
let { children } = $props();
let loading = $state(true);
onMount(() => {
const cleanupErrorHandler = setupGlobalErrorHandler();
const init = async () => {
await waitLocale();
theme.initialize();
await authStore.initialize();
loading = false;
};
init();
return cleanupErrorHandler;
});
</script>
<ToastContainer />
{#if $isLocaleLoading || loading}
<AppLoadingSkeleton />
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}

View file

@ -0,0 +1,2 @@
// Disable SSR — all data is local-first (IndexedDB + mana-sync)
export const ssr = false;

View file

@ -0,0 +1,14 @@
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return new Response(
JSON.stringify({
status: 'ok',
service: 'arcade-web',
timestamp: new Date().toISOString(),
}),
{
headers: { 'Content-Type': 'application/json' },
}
);
};