feat(skilltree): add edit, level-up celebration, templates, and tree view

- Edit skill modal with delete confirmation
- Animated level-up celebration with particles
- Skill templates for quick onboarding (Web Dev, Fitness, Languages, etc.)
- Radial skill tree visualization (/tree)
- Export/import functionality for data backup
- Tree view link in header

https://claude.ai/code/session_015XCsTDS9aLZ64Zin4HU6ex
This commit is contained in:
Claude 2026-01-28 20:38:49 +00:00
parent 12ad8e83d5
commit 5b291c1a17
No known key found for this signature in database
6 changed files with 985 additions and 26 deletions

View file

@ -0,0 +1,196 @@
<script lang="ts">
import type { Skill, SkillBranch } from '$lib/types';
import { BRANCH_INFO } from '$lib/types';
import { X, Trash2 } from 'lucide-svelte';
interface Props {
skill: Skill;
onClose: () => void;
onSave: (updates: Partial<Skill>) => Promise<void>;
onDelete: () => void;
}
let { skill, onClose, onSave, onDelete }: Props = $props();
let name = $state(skill.name);
let description = $state(skill.description);
let branch = $state<SkillBranch>(skill.branch);
let saving = $state(false);
let showDeleteConfirm = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!name.trim()) return;
saving = true;
try {
await onSave({
name: name.trim(),
description: description.trim(),
branch,
});
onClose();
} finally {
saving = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function confirmDelete() {
onDelete();
onClose();
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-bold text-white">Skill bearbeiten</h2>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X class="h-5 w-5" />
</button>
</div>
{#if showDeleteConfirm}
<!-- Delete Confirmation -->
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-500/20">
<Trash2 class="h-8 w-8 text-red-500" />
</div>
<h3 class="mb-2 text-lg font-semibold text-white">Skill löschen?</h3>
<p class="mb-6 text-gray-400">
"{skill.name}" und alle zugehörigen Aktivitäten werden unwiderruflich gelöscht.
</p>
<div class="flex gap-3">
<button
onclick={() => (showDeleteConfirm = false)}
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={confirmDelete}
class="flex-1 rounded-lg bg-red-600 px-4 py-2 font-medium text-white transition-colors hover:bg-red-500"
>
Löschen
</button>
</div>
</div>
{:else}
<form onsubmit={handleSubmit} class="space-y-4">
<!-- Name -->
<div>
<label for="name" class="mb-1 block text-sm font-medium text-gray-300">
Name *
</label>
<input
id="name"
type="text"
bind:value={name}
placeholder="z.B. TypeScript"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
required
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
Beschreibung
</label>
<textarea
id="description"
bind:value={description}
placeholder="Worum geht es bei diesem Skill?"
rows="3"
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
></textarea>
</div>
<!-- Branch -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-300">
Kategorie
</label>
<div class="grid grid-cols-2 gap-2">
{#each Object.entries(BRANCH_INFO) as [key, info]}
<button
type="button"
onclick={() => (branch = key as SkillBranch)}
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {branch === key
? 'border-emerald-500 bg-emerald-500/20 text-white'
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
>
<span
class="h-3 w-3 rounded-full"
style="background-color: {info.color}"
></span>
{info.name}
</button>
{/each}
</div>
</div>
<!-- Stats (read-only) -->
<div class="rounded-lg bg-gray-700/50 p-3">
<div class="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<div class="text-gray-400">Level</div>
<div class="font-semibold text-white">{skill.level}</div>
</div>
<div>
<div class="text-gray-400">Total XP</div>
<div class="font-semibold text-white">{skill.totalXp.toLocaleString()}</div>
</div>
<div>
<div class="text-gray-400">Erstellt</div>
<div class="font-semibold text-white">
{new Date(skill.createdAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-4">
<button
type="button"
onclick={() => (showDeleteConfirm = true)}
class="rounded-lg bg-red-600/20 p-2 text-red-400 transition-colors hover:bg-red-600/30"
title="Löschen"
>
<Trash2 class="h-5 w-5" />
</button>
<button
type="button"
onclick={onClose}
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
>
Abbrechen
</button>
<button
type="submit"
disabled={!name.trim() || saving}
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</form>
{/if}
</div>
</div>

View file

@ -0,0 +1,171 @@
<script lang="ts">
import { LEVEL_NAMES } from '$lib/types';
import { Star, Trophy, Sparkles } from 'lucide-svelte';
import { onMount } from 'svelte';
interface Props {
skillName: string;
newLevel: number;
onClose: () => void;
}
let { skillName, newLevel, onClose }: Props = $props();
const levelName = LEVEL_NAMES[newLevel] ?? 'Unbekannt';
// Auto-close after 4 seconds
onMount(() => {
const timer = setTimeout(onClose, 4000);
return () => clearTimeout(timer);
});
function getLevelColor(level: number): string {
const colors = [
'from-gray-500 to-gray-600',
'from-blue-500 to-blue-600',
'from-purple-500 to-purple-600',
'from-pink-500 to-pink-600',
'from-orange-500 to-orange-600',
'from-yellow-400 to-yellow-500',
];
return colors[level] ?? colors[0];
}
</script>
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onclick={onClose}
role="dialog"
aria-modal="true"
>
<div class="celebration-container text-center">
<!-- Sparkle effects -->
<div class="sparkles">
{#each Array(12) as _, i}
<div
class="sparkle"
style="--delay: {i * 0.1}s; --angle: {i * 30}deg"
>
<Sparkles class="h-6 w-6 text-yellow-400" />
</div>
{/each}
</div>
<!-- Main content -->
<div class="relative z-10">
<!-- Trophy icon -->
<div class="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br {getLevelColor(newLevel)} level-up-bounce shadow-lg shadow-yellow-500/30">
<Trophy class="h-12 w-12 text-white" />
</div>
<!-- Level up text -->
<h2 class="mb-2 text-3xl font-bold text-white level-up-text">
LEVEL UP!
</h2>
<!-- Skill name -->
<p class="mb-4 text-xl text-gray-300">{skillName}</p>
<!-- New level badge -->
<div class="inline-flex items-center gap-2 rounded-full bg-gradient-to-r {getLevelColor(newLevel)} px-6 py-3 text-lg font-bold text-white shadow-lg">
<Star class="h-5 w-5 fill-current" />
Level {newLevel} - {levelName}
<Star class="h-5 w-5 fill-current" />
</div>
<!-- Stars -->
<div class="mt-6 flex justify-center gap-2">
{#each Array(newLevel) as _, i}
<Star
class="h-8 w-8 fill-yellow-400 text-yellow-400 star-pop"
style="animation-delay: {0.5 + i * 0.1}s"
/>
{/each}
</div>
<!-- Click to close -->
<p class="mt-6 text-sm text-gray-500">Klicken zum Schließen</p>
</div>
</div>
</div>
<style>
.celebration-container {
position: relative;
padding: 2rem;
}
.sparkles {
position: absolute;
inset: 0;
pointer-events: none;
}
.sparkle {
position: absolute;
top: 50%;
left: 50%;
animation: sparkle-fly 1s ease-out forwards;
animation-delay: var(--delay);
opacity: 0;
}
@keyframes sparkle-fly {
0% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-150px);
opacity: 0;
}
}
.level-up-bounce {
animation: level-bounce 0.6s ease-out;
}
@keyframes level-bounce {
0% {
transform: scale(0);
}
50% {
transform: scale(1.2);
}
70% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}
.level-up-text {
animation: text-glow 1s ease-in-out infinite alternate;
}
@keyframes text-glow {
from {
text-shadow: 0 0 10px rgba(251, 191, 36, 0.5);
}
to {
text-shadow: 0 0 30px rgba(251, 191, 36, 0.8), 0 0 60px rgba(251, 191, 36, 0.4);
}
}
:global(.star-pop) {
opacity: 0;
animation: star-pop 0.4s ease-out forwards;
}
@keyframes star-pop {
0% {
transform: scale(0) rotate(-180deg);
opacity: 0;
}
100% {
transform: scale(1) rotate(0deg);
opacity: 1;
}
}
</style>

View file

@ -91,6 +91,13 @@
<Plus class="h-4 w-4" />
XP hinzufügen
</button>
<button
onclick={onEdit}
class="rounded-lg bg-gray-600/20 p-2 text-gray-400 opacity-0 transition-all hover:bg-gray-600/30 hover:text-white group-hover:opacity-100"
title="Bearbeiten"
>
<Edit class="h-4 w-4" />
</button>
<button
onclick={onDelete}
class="rounded-lg bg-red-600/20 p-2 text-red-400 opacity-0 transition-all hover:bg-red-600/30 group-hover:opacity-100"

View file

@ -0,0 +1,211 @@
<script lang="ts">
import type { Skill, SkillBranch } from '$lib/types';
import { BRANCH_INFO } from '$lib/types';
import { X, Plus, Sparkles } from 'lucide-svelte';
interface Props {
onClose: () => void;
onAddSkill: (skill: Partial<Skill>) => Promise<void>;
}
let { onClose, onAddSkill }: Props = $props();
interface SkillTemplate {
name: string;
description: string;
branch: SkillBranch;
}
const templates: Record<string, SkillTemplate[]> = {
'Web Developer': [
{ name: 'HTML & CSS', description: 'Grundlagen der Webentwicklung', branch: 'intellect' },
{ name: 'JavaScript', description: 'Die Sprache des Webs', branch: 'intellect' },
{ name: 'TypeScript', description: 'Typsicheres JavaScript', branch: 'intellect' },
{ name: 'React', description: 'UI-Bibliothek für moderne Apps', branch: 'intellect' },
{ name: 'Node.js', description: 'Backend mit JavaScript', branch: 'intellect' },
{ name: 'Git', description: 'Versionskontrolle', branch: 'practical' },
],
'Fitness & Gesundheit': [
{ name: 'Krafttraining', description: 'Muskelaufbau und Stärke', branch: 'body' },
{ name: 'Ausdauer', description: 'Cardio und Kondition', branch: 'body' },
{ name: 'Yoga', description: 'Flexibilität und Balance', branch: 'body' },
{ name: 'Ernährung', description: 'Gesunde Essgewohnheiten', branch: 'body' },
{ name: 'Schlaf', description: 'Erholsamer Schlaf', branch: 'mindset' },
{ name: 'Stressmanagement', description: 'Umgang mit Stress', branch: 'mindset' },
],
'Kreative Künste': [
{ name: 'Zeichnen', description: 'Grundlagen des Zeichnens', branch: 'creativity' },
{ name: 'Malen', description: 'Farben und Techniken', branch: 'creativity' },
{ name: 'Fotografie', description: 'Bilder einfangen', branch: 'creativity' },
{ name: 'Musik', description: 'Instrument spielen', branch: 'creativity' },
{ name: 'Schreiben', description: 'Kreatives Schreiben', branch: 'creativity' },
{ name: 'Design', description: 'Visuelles Design', branch: 'creativity' },
],
'Sprachen': [
{ name: 'Englisch', description: 'Die Weltsprache', branch: 'intellect' },
{ name: 'Spanisch', description: 'Spanisch sprechen', branch: 'intellect' },
{ name: 'Französisch', description: 'La langue française', branch: 'intellect' },
{ name: 'Japanisch', description: '日本語', branch: 'intellect' },
{ name: 'Deutsch', description: 'Deutsche Sprache', branch: 'intellect' },
],
'Produktivität': [
{ name: 'Zeitmanagement', description: 'Zeit effektiv nutzen', branch: 'mindset' },
{ name: 'Fokus', description: 'Konzentration verbessern', branch: 'mindset' },
{ name: 'Organisation', description: 'Ordnung und Struktur', branch: 'practical' },
{ name: 'Kommunikation', description: 'Klar kommunizieren', branch: 'social' },
{ name: 'Problemlösung', description: 'Analytisches Denken', branch: 'intellect' },
],
'Kochen & Haushalt': [
{ name: 'Kochen', description: 'Leckere Gerichte zubereiten', branch: 'practical' },
{ name: 'Backen', description: 'Süßes und Brot', branch: 'practical' },
{ name: 'Haushaltsführung', description: 'Sauberkeit und Ordnung', branch: 'practical' },
{ name: 'Gartenarbeit', description: 'Grüner Daumen', branch: 'practical' },
{ name: 'Heimwerken', description: 'Reparaturen selbst machen', branch: 'practical' },
],
};
let selectedTemplate = $state<string | null>(null);
let addedSkills = $state<Set<string>>(new Set());
let adding = $state(false);
async function addSkill(template: SkillTemplate) {
if (addedSkills.has(template.name)) return;
adding = true;
try {
await onAddSkill(template);
addedSkills = new Set([...addedSkills, template.name]);
} finally {
adding = false;
}
}
async function addAllFromTemplate(templateName: string) {
const skills = templates[templateName];
if (!skills) return;
adding = true;
try {
for (const skill of skills) {
if (!addedSkills.has(skill.name)) {
await onAddSkill(skill);
addedSkills = new Set([...addedSkills, skill.name]);
}
}
} finally {
adding = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-2xl rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl my-8">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-2">
<Sparkles class="h-6 w-6 text-yellow-500" />
<h2 class="text-xl font-bold text-white">Skill-Vorlagen</h2>
</div>
<button
onclick={onClose}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X class="h-5 w-5" />
</button>
</div>
<p class="mb-6 text-gray-400">
Starte schnell mit vorgefertigten Skill-Sets. Wähle eine Vorlage und füge einzelne Skills oder alle auf einmal hinzu.
</p>
<!-- Template List -->
<div class="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
{#each Object.entries(templates) as [name, skills]}
<div class="rounded-xl border border-gray-700 bg-gray-900/50 overflow-hidden">
<!-- Template Header -->
<button
onclick={() => (selectedTemplate = selectedTemplate === name ? null : name)}
class="w-full flex items-center justify-between p-4 text-left hover:bg-gray-800/50 transition-colors"
>
<div>
<h3 class="font-semibold text-white">{name}</h3>
<p class="text-sm text-gray-400">{skills.length} Skills</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={(e) => {
e.stopPropagation();
addAllFromTemplate(name);
}}
disabled={adding}
class="rounded-lg bg-emerald-600/20 px-3 py-1.5 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-600/30 disabled:opacity-50"
>
Alle hinzufügen
</button>
<span class="text-gray-500 text-xl">
{selectedTemplate === name ? '' : '+'}
</span>
</div>
</button>
<!-- Expanded Skills -->
{#if selectedTemplate === name}
<div class="border-t border-gray-700 p-4 space-y-2">
{#each skills as skill}
{@const isAdded = addedSkills.has(skill.name)}
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-3 py-2">
<div class="flex items-center gap-3">
<span
class="h-3 w-3 rounded-full"
style="background-color: {BRANCH_INFO[skill.branch].color}"
></span>
<div>
<span class="font-medium text-white">{skill.name}</span>
<span class="text-gray-400 text-sm"> - {skill.description}</span>
</div>
</div>
<button
onclick={() => addSkill(skill)}
disabled={isAdded || adding}
class="rounded-lg p-1.5 transition-colors {isAdded
? 'bg-emerald-600/20 text-emerald-400'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
>
{#if isAdded}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{:else}
<Plus class="h-4 w-4" />
{/if}
</button>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<!-- Footer -->
<div class="mt-6 flex justify-end">
<button
onclick={onClose}
class="rounded-lg bg-gray-700 px-4 py-2 font-medium text-white transition-colors hover:bg-gray-600"
>
Fertig
</button>
</div>
</div>
</div>

View file

@ -1,37 +1,110 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '$lib/types';
import { BRANCH_INFO } from '$lib/types';
import type { Skill, SkillBranch } from '$lib/types';
import SkillCard from '$lib/components/SkillCard.svelte';
import AddSkillModal from '$lib/components/AddSkillModal.svelte';
import AddXpModal from '$lib/components/AddXpModal.svelte';
import EditSkillModal from '$lib/components/EditSkillModal.svelte';
import LevelUpCelebration from '$lib/components/LevelUpCelebration.svelte';
import StatsOverview from '$lib/components/StatsOverview.svelte';
import SkillTemplates from '$lib/components/SkillTemplates.svelte';
import {
Plus,
TreeDeciduous,
Trophy,
Zap,
TrendingUp,
Download,
Upload,
Sparkles,
Network,
} from 'lucide-svelte';
// Modal states
let showAddSkillModal = $state(false);
let showAddXpModal = $state(false);
let selectedSkillForXp = $state<Skill | null>(null);
let showEditSkillModal = $state(false);
let showTemplatesModal = $state(false);
let selectedSkill = $state<Skill | null>(null);
let selectedBranch = $state<SkillBranch | 'all'>('all');
// Level up celebration
let showLevelUp = $state(false);
let levelUpSkillName = $state('');
let levelUpNewLevel = $state(0);
const filteredSkills = $derived(() => {
if (selectedBranch === 'all') return skillStore.skills;
return skillStore.skills.filter((s) => s.branch === selectedBranch);
});
function openAddXpModal(skill: Skill) {
selectedSkillForXp = skill;
selectedSkill = skill;
showAddXpModal = true;
}
function closeAddXpModal() {
function openEditModal(skill: Skill) {
selectedSkill = skill;
showEditSkillModal = true;
}
function closeModals() {
showAddXpModal = false;
selectedSkillForXp = null;
showEditSkillModal = false;
selectedSkill = null;
}
function triggerLevelUp(skillName: string, newLevel: number) {
levelUpSkillName = skillName;
levelUpNewLevel = newLevel;
showLevelUp = true;
}
async function handleAddXp(xp: number, description: string, duration?: number) {
if (!selectedSkill) return;
const skillName = selectedSkill.name;
const result = await skillStore.addXp(selectedSkill.id, xp, description, duration);
closeModals();
if (result.leveledUp) {
triggerLevelUp(skillName, result.newLevel);
}
}
async function handleExport() {
const { exportData } = await import('$lib/services/storage');
const data = await exportData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `skilltree-backup-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
async function handleImport() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
const { importData } = await import('$lib/services/storage');
await importData(data);
// Reload the store
window.location.reload();
} catch (error) {
console.error('Import failed:', error);
alert('Import fehlgeschlagen. Bitte überprüfe die Datei.');
}
};
input.click();
}
</script>
@ -44,15 +117,49 @@
<TreeDeciduous class="h-8 w-8 text-emerald-500" />
<h1 class="text-2xl font-bold text-white">SkillTree</h1>
</div>
<div class="flex items-center gap-2">
<!-- Tree View -->
<a
href="/tree"
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-emerald-400"
title="Skill-Tree Ansicht"
>
<Network class="h-5 w-5" />
</a>
<!-- Templates -->
<button
onclick={() => (showTemplatesModal = true)}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-yellow-500"
title="Skill-Vorlagen"
>
<Sparkles class="h-5 w-5" />
</button>
<!-- Export/Import -->
<button
onclick={handleExport}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
title="Daten exportieren"
>
<Download class="h-5 w-5" />
</button>
<button
onclick={handleImport}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
title="Daten importieren"
>
<Upload class="h-5 w-5" />
</button>
<!-- Add Skill -->
<button
onclick={() => (showAddSkillModal = true)}
class="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500"
>
<Plus class="h-5 w-5" />
Skill hinzufügen
<span class="hidden sm:inline">Skill hinzufügen</span>
</button>
</div>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-8">
@ -110,7 +217,7 @@
<SkillCard
{skill}
onAddXp={() => openAddXpModal(skill)}
onEdit={() => {}}
onEdit={() => openEditModal(skill)}
onDelete={() => skillStore.deleteSkill(skill.id)}
/>
{/each}
@ -130,7 +237,7 @@
{#if skill}
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-4 py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-900/50 text-emerald-400">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-900/50 text-sm font-medium text-emerald-400">
+{activity.xpEarned}
</div>
<div>
@ -161,18 +268,44 @@
/>
{/if}
{#if showAddXpModal && selectedSkillForXp}
{#if showAddXpModal && selectedSkill}
<AddXpModal
skill={selectedSkillForXp}
onClose={closeAddXpModal}
onSave={async (xp, description, duration) => {
if (selectedSkillForXp) {
const result = await skillStore.addXp(selectedSkillForXp.id, xp, description, duration);
if (result.leveledUp) {
// Could show a level-up celebration here
skill={selectedSkill}
onClose={closeModals}
onSave={handleAddXp}
/>
{/if}
{#if showEditSkillModal && selectedSkill}
<EditSkillModal
skill={selectedSkill}
onClose={closeModals}
onSave={async (updates) => {
if (selectedSkill) {
await skillStore.updateSkill(selectedSkill.id, updates);
}
}}
onDelete={() => {
if (selectedSkill) {
skillStore.deleteSkill(selectedSkill.id);
}
closeAddXpModal();
}}
/>
{/if}
{#if showLevelUp}
<LevelUpCelebration
skillName={levelUpSkillName}
newLevel={levelUpNewLevel}
onClose={() => (showLevelUp = false)}
/>
{/if}
{#if showTemplatesModal}
<SkillTemplates
onClose={() => (showTemplatesModal = false)}
onAddSkill={async (skill) => {
await skillStore.addSkill(skill);
}}
/>
{/if}

View file

@ -0,0 +1,241 @@
<script lang="ts">
import { skillStore } from '$lib/stores/skills.svelte';
import { BRANCH_INFO, LEVEL_NAMES } from '$lib/types';
import type { SkillBranch } from '$lib/types';
import { ArrowLeft, Star } from 'lucide-svelte';
// Group skills by branch for radial layout
const branches = Object.keys(BRANCH_INFO) as SkillBranch[];
// Calculate position for each branch (radial layout)
function getBranchPosition(branchIndex: number, total: number) {
const angle = (branchIndex / total) * 2 * Math.PI - Math.PI / 2;
const radius = 280;
return {
x: 400 + Math.cos(angle) * radius,
y: 400 + Math.sin(angle) * radius,
angle: (angle * 180) / Math.PI,
};
}
// Calculate skill position within a branch
function getSkillPosition(
branchIndex: number,
skillIndex: number,
skillCount: number,
total: number
) {
const branchAngle = (branchIndex / total) * 2 * Math.PI - Math.PI / 2;
const spreadAngle = 0.3; // How much skills spread within a branch
const baseRadius = 180;
const radiusStep = 60;
// Spread skills in a small arc
const skillAngle = branchAngle + (skillIndex - (skillCount - 1) / 2) * (spreadAngle / Math.max(skillCount - 1, 1));
const radius = baseRadius + skillIndex * radiusStep * 0.3;
return {
x: 400 + Math.cos(skillAngle) * radius,
y: 400 + Math.sin(skillAngle) * radius,
};
}
function getLevelColor(level: number): string {
const colors = ['#6b7280', '#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#fbbf24'];
return colors[level] ?? colors[0];
}
function getNodeSize(level: number): number {
return 24 + level * 6;
}
</script>
<svelte:head>
<title>Skill Tree View - SkillTree</title>
</svelte:head>
<div class="min-h-screen bg-gray-900 text-white">
<!-- Header -->
<header class="border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm sticky top-0 z-40">
<div class="mx-auto max-w-7xl px-4 py-4">
<div class="flex items-center gap-4">
<a
href="/"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
>
<ArrowLeft class="h-5 w-5" />
Zurück
</a>
<h1 class="text-xl font-bold">Skill Tree Visualisierung</h1>
</div>
</div>
</header>
<main class="p-4">
{#if skillStore.skills.length === 0}
<div class="mt-16 text-center">
<p class="text-gray-400">Noch keine Skills vorhanden. Erstelle zuerst einige Skills!</p>
<a
href="/"
class="mt-4 inline-block rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white hover:bg-emerald-500"
>
Skills erstellen
</a>
</div>
{:else}
<!-- Legend -->
<div class="mb-6 flex flex-wrap justify-center gap-4">
{#each Object.entries(BRANCH_INFO) as [branch, info]}
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
{#if count > 0}
<div class="flex items-center gap-2 rounded-full bg-gray-800 px-3 py-1.5 text-sm">
<span class="h-3 w-3 rounded-full" style="background-color: {info.color}"></span>
{info.name} ({count})
</div>
{/if}
{/each}
</div>
<!-- Tree SVG -->
<div class="flex justify-center overflow-auto">
<svg
viewBox="0 0 800 800"
class="max-w-full"
style="min-width: 600px; height: auto; max-height: 80vh;"
>
<!-- Background circles -->
<circle cx="400" cy="400" r="120" fill="none" stroke="#374151" stroke-width="1" stroke-dasharray="4" />
<circle cx="400" cy="400" r="200" fill="none" stroke="#374151" stroke-width="1" stroke-dasharray="4" />
<circle cx="400" cy="400" r="280" fill="none" stroke="#374151" stroke-width="1" stroke-dasharray="4" />
<!-- Center node -->
<circle cx="400" cy="400" r="50" fill="#10b981" opacity="0.2" />
<circle cx="400" cy="400" r="40" fill="#10b981" opacity="0.4" />
<circle cx="400" cy="400" r="30" fill="#10b981" />
<text x="400" y="405" text-anchor="middle" fill="white" font-size="12" font-weight="bold">
YOU
</text>
<!-- Branch lines and labels -->
{#each branches as branch, i}
{@const pos = getBranchPosition(i, branches.length)}
{@const branchSkills = skillStore.skills.filter((s) => s.branch === branch)}
{#if branchSkills.length > 0}
<!-- Line from center to branch -->
<line
x1="400"
y1="400"
x2={pos.x}
y2={pos.y}
stroke={BRANCH_INFO[branch].color}
stroke-width="2"
opacity="0.3"
/>
<!-- Branch label -->
<text
x={pos.x}
y={pos.y}
text-anchor="middle"
fill={BRANCH_INFO[branch].color}
font-size="14"
font-weight="bold"
dy="-20"
>
{BRANCH_INFO[branch].name}
</text>
<!-- Skills in this branch -->
{#each branchSkills as skill, j}
{@const skillPos = getSkillPosition(i, j, branchSkills.length, branches.length)}
{@const size = getNodeSize(skill.level)}
<!-- Connection line -->
<line
x1="400"
y1="400"
x2={skillPos.x}
y2={skillPos.y}
stroke={BRANCH_INFO[branch].color}
stroke-width="1"
opacity="0.2"
/>
<!-- Skill node -->
<g class="tree-node cursor-pointer" transform="translate({skillPos.x}, {skillPos.y})">
<!-- Glow effect for high level -->
{#if skill.level >= 4}
<circle
r={size + 8}
fill={getLevelColor(skill.level)}
opacity="0.2"
class="animate-pulse"
/>
{/if}
<!-- Node background -->
<circle
r={size}
fill="#1f2937"
stroke={getLevelColor(skill.level)}
stroke-width="3"
/>
<!-- Level indicator -->
<text
text-anchor="middle"
dy="5"
fill={getLevelColor(skill.level)}
font-size="14"
font-weight="bold"
>
{skill.level}
</text>
<!-- Skill name (on hover/always for important skills) -->
<title>{skill.name} (Level {skill.level} - {skill.totalXp} XP)</title>
</g>
<!-- Skill label -->
<text
x={skillPos.x}
y={skillPos.y + size + 16}
text-anchor="middle"
fill="#9ca3af"
font-size="10"
class="pointer-events-none"
>
{skill.name.length > 12 ? skill.name.slice(0, 12) + '...' : skill.name}
</text>
{/each}
{/if}
{/each}
</svg>
</div>
<!-- Level Legend -->
<div class="mt-8 flex flex-wrap justify-center gap-4">
{#each LEVEL_NAMES as name, level}
<div class="flex items-center gap-2 text-sm">
<div
class="flex h-6 w-6 items-center justify-center rounded-full border-2 text-xs font-bold"
style="border-color: {getLevelColor(level)}; color: {getLevelColor(level)}"
>
{level}
</div>
<span class="text-gray-400">{name}</span>
</div>
{/each}
</div>
{/if}
</main>
</div>
<style>
.tree-node {
transition: transform 0.2s ease;
}
.tree-node:hover {
transform: scale(1.15);
}
</style>