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:
2026-04-02 07:32:53 +02:00
parent c76c6e8cbe
commit 81c70df78e
69 changed files with 11158 additions and 1 deletions
@@ -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>
+698
View File
@@ -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">&times;</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>
+157
View File
@@ -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)';
}
}