mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-25 10:34:39 +02:00
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:
parent
2874e202ea
commit
9e82e40e16
105 changed files with 86 additions and 80 deletions
10
games/arcade/apps/web/src/app.css
Normal file
10
games/arcade/apps/web/src/app.css
Normal 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";
|
||||
12
games/arcade/apps/web/src/app.html
Normal file
12
games/arcade/apps/web/src/app.html
Normal 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>
|
||||
12
games/arcade/apps/web/src/hooks.client.ts
Normal file
12
games/arcade/apps/web/src/hooks.client.ts
Normal 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);
|
||||
};
|
||||
28
games/arcade/apps/web/src/hooks.server.ts
Normal file
28
games/arcade/apps/web/src/hooks.server.ts
Normal 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;
|
||||
};
|
||||
54
games/arcade/apps/web/src/lib/components/GameCard.svelte
Normal file
54
games/arcade/apps/web/src/lib/components/GameCard.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
325
games/arcade/apps/web/src/lib/data/games.ts
Normal file
325
games/arcade/apps/web/src/lib/data/games.ts
Normal 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();
|
||||
}
|
||||
1
games/arcade/apps/web/src/lib/data/guest-seed.ts
Normal file
1
games/arcade/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// No guest seed data needed — games are static HTML files, stats build up from play
|
||||
55
games/arcade/apps/web/src/lib/data/local-store.ts
Normal file
55
games/arcade/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
24
games/arcade/apps/web/src/lib/data/queries.ts
Normal file
24
games/arcade/apps/web/src/lib/data/queries.ts
Normal 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[]);
|
||||
}
|
||||
38
games/arcade/apps/web/src/lib/i18n/index.ts
Normal file
38
games/arcade/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
66
games/arcade/apps/web/src/lib/i18n/locales/de.json
Normal file
66
games/arcade/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
66
games/arcade/apps/web/src/lib/i18n/locales/en.json
Normal file
66
games/arcade/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
games/arcade/apps/web/src/lib/services/feedback.ts
Normal file
8
games/arcade/apps/web/src/lib/services/feedback.ts
Normal 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(),
|
||||
});
|
||||
114
games/arcade/apps/web/src/lib/services/game-communication.ts
Normal file
114
games/arcade/apps/web/src/lib/services/game-communication.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () => {},
|
||||
});
|
||||
5
games/arcade/apps/web/src/lib/stores/auth.svelte.ts
Normal file
5
games/arcade/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createManaAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createManaAuthStore({
|
||||
devBackendPort: 3011,
|
||||
});
|
||||
5
games/arcade/apps/web/src/lib/stores/navigation.ts
Normal file
5
games/arcade/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
||||
|
||||
export const { isNavCollapsed } = createSimpleNavigationStores({
|
||||
storageKey: 'arcade',
|
||||
});
|
||||
6
games/arcade/apps/web/src/lib/stores/theme.svelte.ts
Normal file
6
games/arcade/apps/web/src/lib/stores/theme.svelte.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
export const theme = createThemeStore({
|
||||
appId: 'arcade',
|
||||
defaultVariant: 'lume',
|
||||
});
|
||||
18
games/arcade/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
18
games/arcade/apps/web/src/lib/stores/user-settings.svelte.ts
Normal 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(),
|
||||
});
|
||||
298
games/arcade/apps/web/src/routes/(app)/+layout.svelte
Normal file
298
games/arcade/apps/web/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
83
games/arcade/apps/web/src/routes/(app)/+page.svelte
Normal file
83
games/arcade/apps/web/src/routes/(app)/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
211
games/arcade/apps/web/src/routes/(app)/create/+page.svelte
Normal file
211
games/arcade/apps/web/src/routes/(app)/create/+page.svelte
Normal 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} · 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>
|
||||
11
games/arcade/apps/web/src/routes/(app)/feedback/+page.svelte
Normal file
11
games/arcade/apps/web/src/routes/(app)/feedback/+page.svelte
Normal 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} />
|
||||
47
games/arcade/apps/web/src/routes/(app)/help/+page.svelte
Normal file
47
games/arcade/apps/web/src/routes/(app)/help/+page.svelte
Normal 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>
|
||||
9
games/arcade/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
9
games/arcade/apps/web/src/routes/(app)/mana/+page.svelte
Normal 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>
|
||||
|
|
@ -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} · {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>
|
||||
132
games/arcade/apps/web/src/routes/(app)/play/[slug]/+page.svelte
Normal file
132
games/arcade/apps/web/src/routes/(app)/play/[slug]/+page.svelte
Normal 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">
|
||||
← {$_('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}
|
||||
18
games/arcade/apps/web/src/routes/(app)/profile/+page.svelte
Normal file
18
games/arcade/apps/web/src/routes/(app)/profile/+page.svelte
Normal 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}
|
||||
165
games/arcade/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
165
games/arcade/apps/web/src/routes/(app)/settings/+page.svelte
Normal 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>
|
||||
95
games/arcade/apps/web/src/routes/(app)/stats/+page.svelte
Normal file
95
games/arcade/apps/web/src/routes/(app)/stats/+page.svelte
Normal 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>
|
||||
202
games/arcade/apps/web/src/routes/(app)/submit/+page.svelte
Normal file
202
games/arcade/apps/web/src/routes/(app)/submit/+page.svelte
Normal 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>
|
||||
9
games/arcade/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
9
games/arcade/apps/web/src/routes/(app)/tags/+page.svelte
Normal 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>
|
||||
36
games/arcade/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
36
games/arcade/apps/web/src/routes/(app)/themes/+page.svelte
Normal 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>
|
||||
|
|
@ -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" />
|
||||
18
games/arcade/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
18
games/arcade/apps/web/src/routes/(auth)/login/+page.svelte
Normal 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"
|
||||
/>
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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>
|
||||
39
games/arcade/apps/web/src/routes/+layout.svelte
Normal file
39
games/arcade/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||
2
games/arcade/apps/web/src/routes/+layout.ts
Normal file
2
games/arcade/apps/web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Disable SSR — all data is local-first (IndexedDB + mana-sync)
|
||||
export const ssr = false;
|
||||
14
games/arcade/apps/web/src/routes/health/+server.ts
Normal file
14
games/arcade/apps/web/src/routes/health/+server.ts
Normal 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' },
|
||||
}
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue