tasks: shared task board with sticker rewards, difficulty levels, and calendar
Complete household task management system behind task_users auth group: - Task CRUD with recurring schedules, assignees, tags, and optional difficulty - Blobcat SVG sticker rewards on completion, rarity weighted by difficulty - Sticker collection page with calendar view and progress tracking - Redesigned cards with left accent urgency strip, assignee PFP, round check button - Weekday-based due date labels for tasks within 7 days - Tasks link added to homepage LinksGrid
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import { getStickerById } from '$lib/utils/stickers';
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameMonth, isSameDay, isToday, format, addMonths, subMonths
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
let { completions = [], currentUser = '' } = $props();
|
||||
|
||||
let viewDate = $state(new Date());
|
||||
|
||||
let filteredCompletions = $derived(
|
||||
completions
|
||||
.filter((/** @type {any} */ c) => c.stickerId)
|
||||
.filter((/** @type {any} */ c) => !currentUser || c.completedBy === currentUser)
|
||||
);
|
||||
|
||||
// Build a map: "YYYY-MM-DD" -> sticker ids[]
|
||||
let stickersByDate = $derived.by(() => {
|
||||
/** @type {Map<string, any[]>} */
|
||||
const map = new Map();
|
||||
for (const c of filteredCompletions) {
|
||||
const key = format(new Date(c.completedAt), 'yyyy-MM-dd');
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)?.push(c);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
let calendarDays = $derived.by(() => {
|
||||
const monthStart = startOfMonth(viewDate);
|
||||
const monthEnd = endOfMonth(viewDate);
|
||||
const calStart = startOfWeek(monthStart, { locale: de });
|
||||
const calEnd = endOfWeek(monthEnd, { locale: de });
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd });
|
||||
});
|
||||
|
||||
let monthLabel = $derived(format(viewDate, 'MMMM yyyy', { locale: de }));
|
||||
|
||||
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
function prevMonth() { viewDate = subMonths(viewDate, 1); }
|
||||
function nextMonth() { viewDate = addMonths(viewDate, 1); }
|
||||
</script>
|
||||
|
||||
<div class="cal-container">
|
||||
<div class="cal-header">
|
||||
<button class="cal-nav" onclick={prevMonth}><ChevronLeft size={18} /></button>
|
||||
<span class="cal-month">{monthLabel}</span>
|
||||
<button class="cal-nav" onclick={nextMonth}><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div class="cal-grid">
|
||||
{#each weekdays as day}
|
||||
<div class="cal-weekday">{day}</div>
|
||||
{/each}
|
||||
|
||||
{#each calendarDays as day}
|
||||
{@const key = format(day, 'yyyy-MM-dd')}
|
||||
{@const dayStickers = stickersByDate.get(key) || []}
|
||||
{@const inMonth = isSameMonth(day, viewDate)}
|
||||
<div
|
||||
class="cal-day"
|
||||
class:outside={!inMonth}
|
||||
class:today={isToday(day)}
|
||||
class:has-stickers={dayStickers.length > 0}
|
||||
>
|
||||
<span class="cal-day-num">{format(day, 'd')}</span>
|
||||
{#if dayStickers.length > 0}
|
||||
<div class="cal-stickers">
|
||||
{#each dayStickers.slice(0, 6) as completion}
|
||||
{@const sticker = getStickerById(completion.stickerId)}
|
||||
{#if sticker}
|
||||
<img
|
||||
class="cal-sticker-img"
|
||||
src="/stickers/{sticker.image}"
|
||||
alt={sticker.name}
|
||||
title="{sticker.name} — {completion.taskTitle}"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if dayStickers.length > 6}
|
||||
<span class="cal-more">+{dayStickers.length - 6}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cal-container {
|
||||
background: var(--color-bg-primary, white);
|
||||
border: 1px solid var(--color-border, #e8e4dd);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.cal-month {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
min-width: 160px;
|
||||
text-align: center;
|
||||
}
|
||||
.cal-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary, #888);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 120ms;
|
||||
}
|
||||
.cal-nav:hover {
|
||||
background: var(--color-bg-secondary, #f0ede6);
|
||||
color: var(--color-text-primary, #333);
|
||||
}
|
||||
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.cal-weekday {
|
||||
text-align: center;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary, #999);
|
||||
padding: 0.3rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.cal-day {
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
padding: 0.3rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
transition: background 120ms;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cal-day.outside {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.cal-day.today {
|
||||
background: rgba(94, 129, 172, 0.08);
|
||||
border-color: rgba(94, 129, 172, 0.2);
|
||||
}
|
||||
.cal-day.today .cal-day-num {
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cal-day.has-stickers {
|
||||
background: rgba(163, 190, 140, 0.06);
|
||||
}
|
||||
|
||||
.cal-day-num {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #888);
|
||||
line-height: 1;
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.cal-stickers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
.cal-sticker-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
transition: transform 150ms;
|
||||
cursor: default;
|
||||
}
|
||||
.cal-sticker-img:hover {
|
||||
transform: scale(2);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));
|
||||
}
|
||||
.cal-more {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .cal-container {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .cal-nav:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .cal-day.today {
|
||||
background: rgba(94, 129, 172, 0.12);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .cal-day.has-stickers {
|
||||
background: rgba(163, 190, 140, 0.08);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .cal-container {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .cal-nav:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .cal-day.today {
|
||||
background: rgba(94, 129, 172, 0.12);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .cal-day.has-stickers {
|
||||
background: rgba(163, 190, 140, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.cal-day { min-height: 56px; padding: 0.2rem; }
|
||||
.cal-sticker-img { width: 22px; height: 22px; }
|
||||
.cal-stickers { gap: 2px; }
|
||||
.cal-month { font-size: 0.9rem; min-width: 130px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script>
|
||||
import { scale, fade } from 'svelte/transition';
|
||||
import { elasticOut } from 'svelte/easing';
|
||||
import { getRarityColor } from '$lib/utils/stickers';
|
||||
|
||||
let { sticker, onclose } = $props();
|
||||
|
||||
const rarityLabels = /** @type {Record<string, string>} */ ({
|
||||
common: 'Gewöhnlich',
|
||||
uncommon: 'Ungewöhnlich',
|
||||
rare: 'Selten',
|
||||
legendary: 'Legendär'
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="popup-backdrop" transition:fade={{ duration: 200 }} onclick={onclose} onkeydown={e => e.key === 'Escape' && onclose?.()}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="popup-card" transition:scale={{ start: 0.5, duration: 500, easing: elasticOut }} onclick={e => e.stopPropagation()}>
|
||||
<div class="sticker-display" style="--rarity-color: {getRarityColor(sticker.rarity)}">
|
||||
<img class="sticker-img" src="/stickers/{sticker.image}" alt={sticker.name} />
|
||||
</div>
|
||||
<div class="popup-text">
|
||||
<h3>Sticker erhalten!</h3>
|
||||
<p class="sticker-name">{sticker.name}</p>
|
||||
<p class="sticker-desc">{sticker.description}</p>
|
||||
<span class="rarity-badge" style="color: {getRarityColor(sticker.rarity)}">
|
||||
{rarityLabels[sticker.rarity] || sticker.rarity}
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn-close" onclick={onclose}>Toll!</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.popup-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.popup-card {
|
||||
background: var(--color-bg-primary, white);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
max-width: 320px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.sticker-display {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--rarity-color, var(--nord13)) 0%, transparent 70%);
|
||||
opacity: 0.95;
|
||||
}
|
||||
.sticker-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.15));
|
||||
}
|
||||
|
||||
.popup-text h3 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary, #333);
|
||||
}
|
||||
.sticker-name {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--nord10);
|
||||
}
|
||||
.sticker-desc {
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
.rarity-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.6rem 2rem;
|
||||
border: none;
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
border-radius: 100px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 150ms;
|
||||
}
|
||||
.btn-close:hover { background: var(--nord9); }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .popup-card {
|
||||
background: var(--nord1);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .popup-card {
|
||||
background: var(--nord1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,698 @@
|
||||
<script>
|
||||
import { X, Sparkles, Wind, Bath, UtensilsCrossed, CookingPot, WashingMachine,
|
||||
Flower2, Droplets, Leaf, ShoppingCart, Trash2, Shirt, Brush } from 'lucide-svelte';
|
||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||
|
||||
const USERS = ['anna', 'alexander'];
|
||||
|
||||
let {
|
||||
task = null,
|
||||
onclosed,
|
||||
onsaved
|
||||
} = $props();
|
||||
|
||||
/** @type {{tag: string, icon: any}[]} */
|
||||
const AVAILABLE_TAGS = [
|
||||
{ tag: 'putzen', icon: Sparkles },
|
||||
{ tag: 'saugen', icon: Wind },
|
||||
{ tag: 'wischen', icon: Brush },
|
||||
{ tag: 'bad', icon: Bath },
|
||||
{ tag: 'küche', icon: UtensilsCrossed },
|
||||
{ tag: 'kochen', icon: CookingPot },
|
||||
{ tag: 'abwasch', icon: Droplets },
|
||||
{ tag: 'wäsche', icon: WashingMachine },
|
||||
{ tag: 'bügeln', icon: Shirt },
|
||||
{ tag: 'pflanzen', icon: Flower2 },
|
||||
{ tag: 'gießen', icon: Droplets },
|
||||
{ tag: 'düngen', icon: Leaf },
|
||||
{ tag: 'garten', icon: Leaf },
|
||||
{ tag: 'einkaufen', icon: ShoppingCart },
|
||||
{ tag: 'müll', icon: Trash2 },
|
||||
];
|
||||
|
||||
let title = $state(task?.title || '');
|
||||
let description = $state(task?.description || '');
|
||||
/** @type {string[]} */
|
||||
let selectedAssignees = $state(task?.assignees ? [...task.assignees] : []);
|
||||
/** @type {string[]} */
|
||||
let selectedTags = $state(task?.tags ? [...task.tags] : []);
|
||||
let difficulty = $state(task?.difficulty || '');
|
||||
let isRecurring = $state(task?.isRecurring || false);
|
||||
let frequencyType = $state(task?.frequency?.type || 'weekly');
|
||||
let customDays = $state(task?.frequency?.customDays || 7);
|
||||
let nextDueDate = $state(
|
||||
task?.nextDueDate
|
||||
? new Date(task.nextDueDate).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0]
|
||||
);
|
||||
let saving = $state(false);
|
||||
/** @type {string | null} */
|
||||
let error = $state(null);
|
||||
|
||||
// Desktop tag input
|
||||
let tagInput = $state('');
|
||||
let tagDropdownOpen = $state(false);
|
||||
|
||||
let unselectedTags = $derived(AVAILABLE_TAGS.filter(t => !selectedTags.includes(t.tag)));
|
||||
let filteredDropdownTags = $derived(
|
||||
tagInput.trim() === ''
|
||||
? unselectedTags
|
||||
: unselectedTags.filter(t => t.tag.includes(tagInput.toLowerCase()))
|
||||
);
|
||||
|
||||
/** @param {string} tag */
|
||||
function toggleTag(tag) {
|
||||
if (selectedTags.includes(tag)) {
|
||||
selectedTags = selectedTags.filter(t => t !== tag);
|
||||
} else {
|
||||
selectedTags = [...selectedTags, tag];
|
||||
}
|
||||
}
|
||||
|
||||
function handleTagInputFocus() {
|
||||
tagDropdownOpen = true;
|
||||
}
|
||||
|
||||
/** @param {FocusEvent} _event */
|
||||
function handleTagInputBlur(_event) {
|
||||
setTimeout(() => {
|
||||
tagDropdownOpen = false;
|
||||
tagInput = '';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** @param {KeyboardEvent} event */
|
||||
function handleTagKeyDown(event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const value = tagInput.trim().toLowerCase();
|
||||
const matched = AVAILABLE_TAGS.find(t => t.tag === value) || filteredDropdownTags[0];
|
||||
if (matched && !selectedTags.includes(matched.tag)) {
|
||||
toggleTag(matched.tag);
|
||||
tagInput = '';
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
tagDropdownOpen = false;
|
||||
tagInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} tag */
|
||||
function selectDropdownTag(tag) {
|
||||
toggleTag(tag);
|
||||
tagInput = '';
|
||||
tagDropdownOpen = false;
|
||||
}
|
||||
|
||||
/** @param {string} tag */
|
||||
function getTagIcon(tag) {
|
||||
return AVAILABLE_TAGS.find(t => t.tag === tag)?.icon;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title.trim()) { error = 'Titel ist erforderlich'; return; }
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
assignees: selectedAssignees,
|
||||
tags: selectedTags,
|
||||
difficulty: difficulty || undefined,
|
||||
isRecurring,
|
||||
frequency: isRecurring ? {
|
||||
type: frequencyType,
|
||||
customDays: frequencyType === 'custom' ? customDays : undefined
|
||||
} : undefined,
|
||||
nextDueDate
|
||||
};
|
||||
|
||||
const url = task?._id ? `/api/tasks/${task._id}` : '/api/tasks';
|
||||
const method = task?._id ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
onsaved?.(new CustomEvent('saved'));
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.message || 'Fehler beim Speichern';
|
||||
}
|
||||
saving = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="task-form" onsubmit={e => { e.preventDefault(); handleSubmit(); }}>
|
||||
<div class="form-header">
|
||||
<h2>{task?._id ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}</h2>
|
||||
<button type="button" class="btn-close" onclick={onclosed}><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="title">Titel *</label>
|
||||
<input id="title" type="text" bind:value={title} placeholder="z.B. Staubsaugen" required />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description">Beschreibung</label>
|
||||
<textarea id="description" bind:value={description} placeholder="Optionale Details..." rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Zugewiesen an</label>
|
||||
<div class="assignee-buttons">
|
||||
{#each USERS as user}
|
||||
<button
|
||||
type="button"
|
||||
class="assignee-btn"
|
||||
class:selected={selectedAssignees.includes(user)}
|
||||
onclick={() => {
|
||||
if (selectedAssignees.includes(user)) {
|
||||
selectedAssignees = selectedAssignees.filter(a => a !== user);
|
||||
} else {
|
||||
selectedAssignees = [...selectedAssignees, user];
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ProfilePicture username={user} size={26} />
|
||||
<span class="assignee-name">{user}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags: desktop = input + dropdown, mobile = pill buttons -->
|
||||
<div class="field">
|
||||
<label>Tags</label>
|
||||
<span class="hint">Bestimmen Sticker-Belohnungen</span>
|
||||
|
||||
<!-- Desktop: input with dropdown -->
|
||||
<div class="tag-input-desktop">
|
||||
<div class="tag-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tagInput}
|
||||
onfocus={handleTagInputFocus}
|
||||
onblur={handleTagInputBlur}
|
||||
onkeydown={handleTagKeyDown}
|
||||
placeholder="Tag eingeben oder auswählen..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if tagDropdownOpen && filteredDropdownTags.length > 0}
|
||||
<div class="tag-dropdown">
|
||||
{#each filteredDropdownTags as { tag, icon }}
|
||||
<button type="button" class="tag-dropdown-item" onclick={() => selectDropdownTag(tag)}>
|
||||
<svelte:component this={icon} size={14} />
|
||||
{tag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: pill buttons -->
|
||||
<div class="tag-pills-mobile">
|
||||
{#each AVAILABLE_TAGS as { tag, icon }}
|
||||
<button
|
||||
type="button"
|
||||
class="tag-pill"
|
||||
class:selected={selectedTags.includes(tag)}
|
||||
onclick={() => toggleTag(tag)}
|
||||
>
|
||||
<svelte:component this={icon} size={14} />
|
||||
{tag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Selected tags (shown on desktop below input) -->
|
||||
{#if selectedTags.length > 0}
|
||||
<div class="selected-tags">
|
||||
{#each selectedTags as tag}
|
||||
{@const Icon = getTagIcon(tag)}
|
||||
<button type="button" class="tag-chip selected" onclick={() => toggleTag(tag)}>
|
||||
{#if Icon}
|
||||
<svelte:component this={Icon} size={13} />
|
||||
{/if}
|
||||
{tag}
|
||||
<span class="remove-x">×</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Schwierigkeit</label>
|
||||
<span class="hint">Schwerere Aufgaben geben seltenere Sticker</span>
|
||||
<div class="difficulty-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="diff-btn"
|
||||
class:selected={difficulty === '' || difficulty === undefined}
|
||||
onclick={() => difficulty = ''}
|
||||
>
|
||||
Keine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="diff-btn low"
|
||||
class:selected={difficulty === 'low'}
|
||||
onclick={() => difficulty = 'low'}
|
||||
>
|
||||
Leicht
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="diff-btn medium"
|
||||
class:selected={difficulty === 'medium'}
|
||||
onclick={() => difficulty = 'medium'}
|
||||
>
|
||||
Mittel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="diff-btn high"
|
||||
class:selected={difficulty === 'high'}
|
||||
onclick={() => difficulty = 'high'}
|
||||
>
|
||||
Schwer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="dueDate">Fällig am</label>
|
||||
<input id="dueDate" type="date" bind:value={nextDueDate} required />
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={isRecurring} />
|
||||
Wiederkehrend
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if isRecurring}
|
||||
<div class="field">
|
||||
<label for="frequency">Häufigkeit</label>
|
||||
<select id="frequency" bind:value={frequencyType}>
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
<option value="biweekly">Alle 2 Wochen</option>
|
||||
<option value="monthly">Monatlich</option>
|
||||
<option value="custom">Benutzerdefiniert</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if frequencyType === 'custom'}
|
||||
<div class="field">
|
||||
<label for="customDays">Alle X Tage</label>
|
||||
<input id="customDays" type="number" bind:value={customDays} min="1" max="365" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" onclick={onclosed}>Abbrechen</button>
|
||||
<button type="submit" class="btn-save" disabled={saving}>
|
||||
{saving ? 'Speichern...' : (task?._id ? 'Aktualisieren' : 'Erstellen')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.task-form {
|
||||
background: var(--color-bg-primary, white);
|
||||
border: 1px solid var(--color-border, #e8e4dd);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary, #999);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-close:hover { background: var(--color-bg-secondary, #f0ede6); }
|
||||
|
||||
.error {
|
||||
color: var(--nord11);
|
||||
font-size: 0.82rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: rgba(191, 97, 106, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--color-text-secondary, #666);
|
||||
}
|
||||
.field input[type="text"],
|
||||
.field input[type="date"],
|
||||
.field input[type="number"],
|
||||
.field textarea,
|
||||
.field select {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--color-bg-primary, white);
|
||||
color: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field textarea { resize: vertical; }
|
||||
.hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* ── Assignee buttons ── */
|
||||
.assignee-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.assignee-btn {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.75rem 0.35rem 0.35rem;
|
||||
border-radius: 100px;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--color-border, #ddd);
|
||||
background: transparent;
|
||||
transition: all 120ms;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.assignee-btn:hover {
|
||||
border-color: var(--nord10);
|
||||
background: rgba(94, 129, 172, 0.06);
|
||||
}
|
||||
.assignee-btn.selected {
|
||||
border-color: var(--nord10);
|
||||
background: rgba(94, 129, 172, 0.12);
|
||||
}
|
||||
.assignee-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, #333);
|
||||
}
|
||||
|
||||
/* ── Tag input (desktop) ── */
|
||||
.tag-input-desktop {
|
||||
position: relative;
|
||||
}
|
||||
.tag-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.tag-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background: var(--color-bg-primary, white);
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
padding: 0.35rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.tag-dropdown-item {
|
||||
all: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 100px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary, #666);
|
||||
background: var(--color-bg-secondary, #f0ede6);
|
||||
transition: all 100ms;
|
||||
}
|
||||
.tag-dropdown-item:hover {
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* ── Tag pills (mobile) ── */
|
||||
.tag-pills-mobile {
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tag-pill {
|
||||
all: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
border-radius: 100px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 120ms;
|
||||
background: var(--color-bg-secondary, #f0ede6);
|
||||
color: var(--color-text-secondary, #777);
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
.tag-pill:hover {
|
||||
background: rgba(94, 129, 172, 0.12);
|
||||
color: var(--nord10);
|
||||
}
|
||||
.tag-pill:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.tag-pill.selected {
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
border-color: var(--nord10);
|
||||
}
|
||||
.tag-pill.selected:hover {
|
||||
background: var(--nord9);
|
||||
border-color: var(--nord9);
|
||||
}
|
||||
|
||||
/* ── Selected tags (shown below input on desktop) ── */
|
||||
.selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.tag-chip {
|
||||
all: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 100px;
|
||||
cursor: pointer;
|
||||
transition: all 100ms;
|
||||
user-select: none;
|
||||
}
|
||||
.tag-chip.selected {
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
}
|
||||
.tag-chip.selected:hover {
|
||||
background: var(--nord9);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.remove-x {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
margin-left: 0.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Responsive: show pills on mobile, input on desktop */
|
||||
@media (max-width: 700px) {
|
||||
.tag-input-desktop { display: none; }
|
||||
.tag-pills-mobile { display: flex; }
|
||||
.selected-tags { display: none; }
|
||||
}
|
||||
@media (min-width: 701px) {
|
||||
.tag-pills-mobile { display: none; }
|
||||
}
|
||||
|
||||
/* ── Difficulty buttons ── */
|
||||
.difficulty-buttons {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.diff-btn {
|
||||
all: unset;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--color-border, #ddd);
|
||||
transition: all 120ms;
|
||||
color: var(--color-text-secondary, #777);
|
||||
}
|
||||
.diff-btn:hover {
|
||||
border-color: var(--nord10);
|
||||
background: rgba(94, 129, 172, 0.06);
|
||||
}
|
||||
.diff-btn.selected {
|
||||
border-color: var(--nord10);
|
||||
background: rgba(94, 129, 172, 0.1);
|
||||
color: var(--nord10);
|
||||
font-weight: 600;
|
||||
}
|
||||
.diff-btn.low.selected {
|
||||
border-color: var(--nord14);
|
||||
background: rgba(163, 190, 140, 0.12);
|
||||
color: var(--nord14);
|
||||
}
|
||||
.diff-btn.medium.selected {
|
||||
border-color: var(--nord13);
|
||||
background: rgba(235, 203, 139, 0.12);
|
||||
color: #b8a038;
|
||||
}
|
||||
.diff-btn.high.selected {
|
||||
border-color: var(--nord12);
|
||||
background: rgba(208, 135, 112, 0.12);
|
||||
color: var(--nord12);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-cancel {
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
.btn-save {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 150ms;
|
||||
}
|
||||
.btn-save:hover { background: var(--nord9); }
|
||||
.btn-save:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .task-form {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .field input,
|
||||
:global(:root:not([data-theme="light"])) .field textarea,
|
||||
:global(:root:not([data-theme="light"])) .field select {
|
||||
background: var(--nord0);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .tag-dropdown {
|
||||
background: var(--nord0);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .tag-dropdown-item {
|
||||
background: var(--nord2);
|
||||
color: var(--nord4);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .tag-pill {
|
||||
background: var(--nord2);
|
||||
color: var(--nord4);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .task-form {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .field input,
|
||||
:global(:root[data-theme="dark"]) .field textarea,
|
||||
:global(:root[data-theme="dark"]) .field select {
|
||||
background: var(--nord0);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .tag-dropdown {
|
||||
background: var(--nord0);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .tag-dropdown-item {
|
||||
background: var(--nord2);
|
||||
color: var(--nord4);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .tag-pill {
|
||||
background: var(--nord2);
|
||||
color: var(--nord4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
export interface Sticker {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string; // path under /stickers/
|
||||
description: string;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'legendary';
|
||||
category: string;
|
||||
}
|
||||
|
||||
// Blobcat sticker catalog — images from Tirifto's Blobcats (Free Art Licence) & tastytea (CC0)
|
||||
export const STICKERS: Sticker[] = [
|
||||
// Cleaning / household
|
||||
{ id: 'clean-cat', name: 'Putzkatze', image: 'blobcat_clean.svg', description: 'Alles blitzeblank!', rarity: 'common', category: 'cleaning' },
|
||||
{ id: 'sparkle-proud', name: 'Glitzerkatze', image: 'blobcat_sparkling_proud.svg', description: 'Stolz auf die Sauberkeit', rarity: 'uncommon', category: 'cleaning' },
|
||||
{ id: 'hammer-cat', name: 'Handwerkerkatze', image: 'blobcat_hammer.svg', description: 'Hier wird angepackt!', rarity: 'uncommon', category: 'cleaning' },
|
||||
{ id: 'in-box', name: 'Kartonkatze', image: 'blobcat_in_box.svg', description: 'Alles eingeräumt', rarity: 'rare', category: 'cleaning' },
|
||||
|
||||
// Plants / garden
|
||||
{ id: 'rose-red', name: 'Rosenkatze', image: 'blobcat_rose_red.svg', description: 'Eine rote Rose für dich', rarity: 'common', category: 'plants' },
|
||||
{ id: 'rose-pink', name: 'Blümchenkatze', image: 'blobcat_rose_pink.svg', description: 'Rosarote Blütenträume', rarity: 'common', category: 'plants' },
|
||||
{ id: 'strawberry', name: 'Erdbeerkatze', image: 'blobcat_strawberry.svg', description: 'Erntezeit!', rarity: 'uncommon', category: 'plants' },
|
||||
{ id: 'earth-cat', name: 'Erdkatze', image: 'blobcat_earth.svg', description: 'Die ganze Welt ist ein Garten', rarity: 'rare', category: 'plants' },
|
||||
|
||||
// Kitchen / cooking
|
||||
{ id: 'cutlery', name: 'Besteckkatze', image: 'blobcat_cutlery.svg', description: 'Messer und Gabel bereit', rarity: 'common', category: 'kitchen' },
|
||||
{ id: 'cupcake', name: 'Muffinkatze', image: 'blobcat_cupcake.svg', description: 'Süße Belohnung!', rarity: 'common', category: 'kitchen' },
|
||||
{ id: 'eating-cupcake', name: 'Naschkatze', image: 'blobcat_eating_cupcake.svg', description: 'Nom nom nom', rarity: 'uncommon', category: 'kitchen' },
|
||||
{ id: 'pot-cat', name: 'Topfkatze', image: 'blobcat_pot.svg', description: 'Was köchelt da?', rarity: 'uncommon', category: 'kitchen' },
|
||||
{ id: 'hungry', name: 'Hungrige Katze', image: 'blobcat_hungry_cutlery.svg', description: 'Wann gibt\'s Essen?', rarity: 'rare', category: 'kitchen' },
|
||||
|
||||
// Errands
|
||||
{ id: 'profit', name: 'Geschäftskatze', image: 'blobcat_profit.svg', description: 'Guter Deal!', rarity: 'common', category: 'errands' },
|
||||
{ id: 'idea-cat', name: 'Ideenkatze', image: 'blobcat_idea.svg', description: 'Heureka!', rarity: 'uncommon', category: 'errands' },
|
||||
|
||||
// General / universal — positive emotions
|
||||
{ id: 'happy', name: 'Zufriedene Katze', image: 'blobcat_content.svg', description: 'Alles gut!', rarity: 'common', category: 'general' },
|
||||
{ id: 'heart', name: 'Herzkatze', image: 'blobcat_heart.svg', description: 'Mit Liebe gemacht', rarity: 'common', category: 'general' },
|
||||
{ id: 'heart-eyes', name: 'Verliebte Katze', image: 'blobcat_heart_eyes.svg', description: 'So schön!', rarity: 'common', category: 'general' },
|
||||
{ id: 'hooray', name: 'Jubelkatze', image: 'blobcat_hooray.svg', description: 'Hurra!', rarity: 'common', category: 'general' },
|
||||
{ id: 'waving', name: 'Winkende Katze', image: 'blobcat_waving.svg', description: 'Hallo!', rarity: 'common', category: 'general' },
|
||||
{ id: 'laughing', name: 'Lachkatze', image: 'blobcat_laughing.svg', description: 'Hahaha!', rarity: 'common', category: 'general' },
|
||||
{ id: 'thumbs-up', name: 'Daumen-hoch-Katze', image: 'blobcat_thumbs_up.svg', description: 'Super gemacht!', rarity: 'uncommon', category: 'general' },
|
||||
{ id: 'sparkle-eyes', name: 'Funkelkatze', image: 'blobcat_sparkle_eyes.svg', description: 'Augen wie Sterne', rarity: 'uncommon', category: 'general' },
|
||||
{ id: 'finger-guns', name: 'Fingerpistolenkatze', image: 'blobcat_finger_guns.svg', description: 'Pew pew!', rarity: 'uncommon', category: 'general' },
|
||||
{ id: 'proud', name: 'Stolze Katze', image: 'blobcat_proud.svg', description: 'Richtig gut!', rarity: 'uncommon', category: 'general' },
|
||||
|
||||
// Achievement / reward
|
||||
{ id: 'crown', name: 'Königskatze', image: 'blobcat_crown.svg', description: 'Majestätisch!', rarity: 'rare', category: 'achievement' },
|
||||
{ id: 'cool', name: 'Coole Katze', image: 'blobcat_cool.svg', description: 'Eiskalt erledigt', rarity: 'rare', category: 'achievement' },
|
||||
{ id: 'hero', name: 'Heldenkatze', image: 'blobcat_hero.svg', description: 'Held des Tages!', rarity: 'rare', category: 'achievement' },
|
||||
{ id: '10-of-10', name: 'Perfekte Katze', image: 'blobcat_sign_10_of_10.svg', description: '10 von 10!', rarity: 'rare', category: 'achievement' },
|
||||
{ id: 'birthday', name: 'Geburtstagskatze', image: 'blobcat_birthday.svg', description: 'Party!', rarity: 'rare', category: 'achievement' },
|
||||
|
||||
// Cozy / relaxed
|
||||
{ id: 'sleeping', name: 'Schlafkatze', image: 'blobcat_sleeping.svg', description: 'Zzzzz...', rarity: 'uncommon', category: 'cozy' },
|
||||
{ id: 'tea', name: 'Teekatze', image: 'blobcat_drinking_tea.svg', description: 'Erstmal Tee', rarity: 'uncommon', category: 'cozy' },
|
||||
{ id: 'cocoa', name: 'Kakaokatze', image: 'blobcat_drinking_cocoa.svg', description: 'Mmh, Kakao!', rarity: 'uncommon', category: 'cozy' },
|
||||
{ id: 'book', name: 'Lesekatze', image: 'blobcat_book.svg', description: 'Ein gutes Buch', rarity: 'rare', category: 'cozy' },
|
||||
{ id: 'blanket', name: 'Deckenkatze', image: 'blobcat_blanket.svg', description: 'Eingekuschelt', rarity: 'rare', category: 'cozy' },
|
||||
{ id: 'teapot', name: 'Teekannenkatze', image: 'blobcat_teapot.svg', description: 'Tee ist fertig!', rarity: 'rare', category: 'cozy' },
|
||||
|
||||
// Rare / special
|
||||
{ id: 'rainbow', name: 'Regenbogenkatze', image: 'blobcat_rainbow.svg', description: 'Alle Farben!', rarity: 'rare', category: 'special' },
|
||||
{ id: 'angel', name: 'Engelskatze', image: 'blobcat_angel.svg', description: 'Himmlisch!', rarity: 'rare', category: 'special' },
|
||||
{ id: 'scientist', name: 'Wissenschaftskatze', image: 'blobcat_scientist.svg', description: 'Für die Wissenschaft!', rarity: 'rare', category: 'special' },
|
||||
|
||||
// Quirky / cute (general pool — drops for any task)
|
||||
{ id: 'adorable', name: 'Süße Katze', image: 'blobcat_adorable.svg', description: 'Unwiderstehlich süß!', rarity: 'uncommon', category: 'general' },
|
||||
{ id: 'adoring', name: 'Bewundernde Katze', image: 'blobcat_adoring.svg', description: 'So toll!', rarity: 'common', category: 'general' },
|
||||
{ id: 'joyful-surprise', name: 'Überraschte Katze', image: 'blobcat_joyful_surprise.svg', description: 'Oh! Was ist das?', rarity: 'uncommon', category: 'general' },
|
||||
{ id: 'purring', name: 'Schnurrkatze', image: 'blobcat_purring.svg', description: 'Prrrrr...', rarity: 'common', category: 'general' },
|
||||
{ id: 'x3', name: 'x3-Katze', image: 'blobcat_x3.svg', description: 'x3 UwU', rarity: 'rare', category: 'general' },
|
||||
{ id: 'heart-tastytea', name: 'Liebevolle Katze', image: 'blobcat_heart_tastytea.svg', description: 'Ganz viel Liebe!', rarity: 'uncommon', category: 'general' },
|
||||
{ id: 'blobcat-classic', name: 'Klassikatze', image: 'blobcat.svg', description: 'Die Originalkatze', rarity: 'rare', category: 'general' },
|
||||
|
||||
// Legendary
|
||||
{ id: 'astronaut', name: 'Astronautenkatze', image: 'blobcat_astronaut.svg', description: 'Ab ins All!', rarity: 'legendary', category: 'special' },
|
||||
{ id: 'space', name: 'Weltraumkatze', image: 'blobcat_space.svg', description: 'Zwischen den Sternen', rarity: 'legendary', category: 'special' },
|
||||
{ id: 'robot', name: 'Roboterkatze', image: 'blobcat_robot.svg', description: 'Beep boop!', rarity: 'legendary', category: 'special' },
|
||||
{ id: 'ghost', name: 'Geisterkatze', image: 'blobcat_ghost.svg', description: 'Buuuuh!', rarity: 'legendary', category: 'special' },
|
||||
];
|
||||
|
||||
// Tag to sticker category mapping
|
||||
const TAG_CATEGORY_MAP: Record<string, string[]> = {
|
||||
'putzen': ['cleaning', 'general'],
|
||||
'saugen': ['cleaning', 'general'],
|
||||
'wischen': ['cleaning', 'general'],
|
||||
'bad': ['cleaning', 'general'],
|
||||
'küche': ['kitchen', 'cleaning', 'general'],
|
||||
'kochen': ['kitchen', 'general'],
|
||||
'abwasch': ['kitchen', 'cleaning', 'general'],
|
||||
'wäsche': ['cleaning', 'general'],
|
||||
'bügeln': ['cleaning', 'general'],
|
||||
'pflanzen': ['plants', 'general'],
|
||||
'gießen': ['plants', 'general'],
|
||||
'düngen': ['plants', 'general'],
|
||||
'garten': ['plants', 'general'],
|
||||
'einkaufen': ['errands', 'general'],
|
||||
'müll': ['cleaning', 'general'],
|
||||
};
|
||||
|
||||
// Rarity weights per difficulty (higher = more likely)
|
||||
// low: mostly common, rare/legendary very unlikely
|
||||
// medium (default): balanced distribution
|
||||
// high: rare and legendary much more likely
|
||||
const DIFFICULTY_RARITY_WEIGHTS: Record<string, Record<string, number>> = {
|
||||
low: { common: 65, uncommon: 25, rare: 8, legendary: 2 },
|
||||
medium: { common: 50, uncommon: 30, rare: 15, legendary: 5 },
|
||||
high: { common: 25, uncommon: 30, rare: 30, legendary: 15 },
|
||||
};
|
||||
|
||||
export function getStickerForTags(tags: string[], difficulty?: string): Sticker {
|
||||
const weights = DIFFICULTY_RARITY_WEIGHTS[difficulty || 'medium'] || DIFFICULTY_RARITY_WEIGHTS.medium;
|
||||
|
||||
// Determine relevant categories from tags
|
||||
const categories = new Set<string>();
|
||||
for (const tag of tags) {
|
||||
const mapped = TAG_CATEGORY_MAP[tag.toLowerCase()];
|
||||
if (mapped) {
|
||||
mapped.forEach(c => categories.add(c));
|
||||
}
|
||||
}
|
||||
// Always include general + achievement/cozy/special so all stickers can drop
|
||||
categories.add('general');
|
||||
categories.add('achievement');
|
||||
categories.add('cozy');
|
||||
categories.add('special');
|
||||
|
||||
// Filter stickers by matching categories
|
||||
const pool = STICKERS.filter(s => categories.has(s.category));
|
||||
|
||||
// Weighted random selection by rarity
|
||||
const totalWeight = pool.reduce((sum, s) => sum + weights[s.rarity], 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
for (const sticker of pool) {
|
||||
random -= weights[sticker.rarity];
|
||||
if (random <= 0) return sticker;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
export function getStickerById(id: string): Sticker | undefined {
|
||||
return STICKERS.find(s => s.id === id);
|
||||
}
|
||||
|
||||
export function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case 'common': return 'var(--nord14)';
|
||||
case 'uncommon': return 'var(--nord9)';
|
||||
case 'rare': return 'var(--nord15)';
|
||||
case 'legendary': return 'var(--nord13)';
|
||||
default: return 'var(--nord4)';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user