mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
12ad8e83d5
commit
5b291c1a17
6 changed files with 985 additions and 26 deletions
196
apps/skilltree/apps/web/src/lib/components/EditSkillModal.svelte
Normal file
196
apps/skilltree/apps/web/src/lib/components/EditSkillModal.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
211
apps/skilltree/apps/web/src/lib/components/SkillTemplates.svelte
Normal file
211
apps/skilltree/apps/web/src/lib/components/SkillTemplates.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
241
apps/skilltree/apps/web/src/routes/tree/+page.svelte
Normal file
241
apps/skilltree/apps/web/src/routes/tree/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue