mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
refactor(todo-web): extract reusable form components from TaskEditModal
Extract 5 form components to reduce TaskEditModal from 1220 to 604 lines (51%): - PrioritySelector: Task priority selection buttons with color indicators - StorypointsSelector: Fibonacci story point picker (1,2,3,5,8,13,21) - DurationPicker: Quick duration buttons + custom input for effective time - FunRatingPicker: 1-10 rating scale with color-coded visual feedback - LabelSelector: Dropdown for multi-select label assignment These components are now reusable across other task forms and views. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
306c74e3f9
commit
8b9337ac72
7 changed files with 794 additions and 636 deletions
|
|
@ -1,16 +1,15 @@
|
|||
<script lang="ts">
|
||||
import type {
|
||||
Task,
|
||||
Subtask,
|
||||
TaskPriority,
|
||||
TaskStatus,
|
||||
DurationUnit,
|
||||
EffectiveDuration,
|
||||
} from '@todo/shared';
|
||||
import type { Task, Subtask, TaskPriority, TaskStatus, EffectiveDuration } from '@todo/shared';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import SubtaskList from './SubtaskList.svelte';
|
||||
import {
|
||||
PrioritySelector,
|
||||
StorypointsSelector,
|
||||
DurationPicker,
|
||||
FunRatingPicker,
|
||||
LabelSelector,
|
||||
} from './form';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
|
|
@ -36,24 +35,13 @@
|
|||
let recurrenceRule = $state('');
|
||||
let notes = $state('');
|
||||
let storyPoints = $state<number | null>(null);
|
||||
let effectiveDurationValue = $state<number | null>(null);
|
||||
let effectiveDurationUnit = $state<DurationUnit>('hours');
|
||||
let effectiveDuration = $state<EffectiveDuration | null>(null);
|
||||
let funRating = $state<number | null>(null);
|
||||
let showCustomDuration = $state(false);
|
||||
|
||||
// UI state
|
||||
let showLabelDropdown = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
// Priority options
|
||||
const priorities: { value: TaskPriority; label: string; color: string }[] = [
|
||||
{ value: 'low', label: 'Niedrig', color: '#22c55e' },
|
||||
{ value: 'medium', label: 'Mittel', color: '#eab308' },
|
||||
{ value: 'high', label: 'Hoch', color: '#f97316' },
|
||||
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
|
||||
];
|
||||
|
||||
// Status options
|
||||
const statuses: { value: TaskStatus; label: string }[] = [
|
||||
{ value: 'pending', label: 'Ausstehend' },
|
||||
|
|
@ -72,34 +60,6 @@
|
|||
{ value: 'FREQ=YEARLY', label: 'Jährlich' },
|
||||
];
|
||||
|
||||
// Storypoints options (Fibonacci)
|
||||
const storyPointOptions = [1, 2, 3, 5, 8, 13, 21];
|
||||
|
||||
// Quick duration options
|
||||
const durationOptions: { label: string; value: number; unit: DurationUnit }[] = [
|
||||
{ label: '15m', value: 15, unit: 'minutes' },
|
||||
{ label: '30m', value: 30, unit: 'minutes' },
|
||||
{ label: '1h', value: 1, unit: 'hours' },
|
||||
{ label: '2h', value: 2, unit: 'hours' },
|
||||
{ label: '4h', value: 4, unit: 'hours' },
|
||||
{ label: '1d', value: 1, unit: 'days' },
|
||||
{ label: '2d', value: 2, unit: 'days' },
|
||||
];
|
||||
|
||||
// Duration unit options
|
||||
const durationUnitOptions: { value: DurationUnit; label: string }[] = [
|
||||
{ value: 'minutes', label: 'Minuten' },
|
||||
{ value: 'hours', label: 'Stunden' },
|
||||
{ value: 'days', label: 'Tage' },
|
||||
];
|
||||
|
||||
// Fun rating color helper
|
||||
function getFunRatingColor(rating: number): string {
|
||||
if (rating <= 3) return '#ef4444'; // red
|
||||
if (rating <= 6) return '#eab308'; // yellow
|
||||
return '#22c55e'; // green
|
||||
}
|
||||
|
||||
// Initialize form when task changes or modal opens
|
||||
$effect(() => {
|
||||
if (open && task) {
|
||||
|
|
@ -115,23 +75,9 @@
|
|||
subtasks = task.subtasks ? [...task.subtasks] : [];
|
||||
recurrenceRule = task.recurrenceRule || '';
|
||||
notes = task.metadata?.notes || '';
|
||||
// New metadata fields
|
||||
// Metadata fields
|
||||
storyPoints = task.metadata?.storyPoints ?? null;
|
||||
if (task.metadata?.effectiveDuration) {
|
||||
effectiveDurationValue = task.metadata.effectiveDuration.value;
|
||||
effectiveDurationUnit = task.metadata.effectiveDuration.unit;
|
||||
// Check if it's a custom value not in quick options
|
||||
const isQuickOption = durationOptions.some(
|
||||
(opt) =>
|
||||
opt.value === task.metadata?.effectiveDuration?.value &&
|
||||
opt.unit === task.metadata?.effectiveDuration?.unit
|
||||
);
|
||||
showCustomDuration = !isQuickOption;
|
||||
} else {
|
||||
effectiveDurationValue = null;
|
||||
effectiveDurationUnit = 'hours';
|
||||
showCustomDuration = false;
|
||||
}
|
||||
effectiveDuration = task.metadata?.effectiveDuration ?? null;
|
||||
funRating = task.metadata?.funRating ?? null;
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
|
|
@ -157,15 +103,6 @@
|
|||
|
||||
isLoading = true;
|
||||
try {
|
||||
// Build effective duration object
|
||||
let effectiveDuration: EffectiveDuration | null = null;
|
||||
if (effectiveDurationValue !== null && effectiveDurationValue > 0) {
|
||||
effectiveDuration = {
|
||||
value: effectiveDurationValue,
|
||||
unit: effectiveDurationUnit,
|
||||
};
|
||||
}
|
||||
|
||||
const data: Partial<Task> = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
|
|
@ -203,37 +140,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
function toggleLabel(labelId: string) {
|
||||
if (selectedLabelIds.includes(labelId)) {
|
||||
selectedLabelIds = selectedLabelIds.filter((id) => id !== labelId);
|
||||
} else {
|
||||
selectedLabelIds = [...selectedLabelIds, labelId];
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubtasksChange(newSubtasks: Subtask[]) {
|
||||
subtasks = newSubtasks;
|
||||
}
|
||||
|
||||
function selectQuickDuration(opt: { value: number; unit: DurationUnit }) {
|
||||
effectiveDurationValue = opt.value;
|
||||
effectiveDurationUnit = opt.unit;
|
||||
showCustomDuration = false;
|
||||
}
|
||||
|
||||
function isQuickDurationSelected(opt: { value: number; unit: DurationUnit }): boolean {
|
||||
return (
|
||||
effectiveDurationValue === opt.value &&
|
||||
effectiveDurationUnit === opt.unit &&
|
||||
!showCustomDuration
|
||||
);
|
||||
}
|
||||
|
||||
function clearDuration() {
|
||||
effectiveDurationValue = null;
|
||||
effectiveDurationUnit = 'hours';
|
||||
showCustomDuration = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
|
@ -304,20 +213,7 @@
|
|||
<!-- Priorität -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Priorität</label>
|
||||
<div class="priority-buttons">
|
||||
{#each priorities as p}
|
||||
<button
|
||||
type="button"
|
||||
class="priority-btn"
|
||||
class:selected={priority === p.value}
|
||||
style="--priority-color: {p.color}"
|
||||
onclick={() => (priority = p.value)}
|
||||
>
|
||||
<span class="priority-dot" style="background-color: {p.color}"></span>
|
||||
{p.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<PrioritySelector value={priority} onChange={(p) => (priority = p)} />
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
|
|
@ -346,68 +242,10 @@
|
|||
<!-- Labels -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Labels</label>
|
||||
<div class="label-selector">
|
||||
<button
|
||||
type="button"
|
||||
class="label-trigger"
|
||||
onclick={() => (showLabelDropdown = !showLabelDropdown)}
|
||||
>
|
||||
{#if selectedLabelIds.length === 0}
|
||||
<span class="text-muted">Labels auswählen...</span>
|
||||
{:else}
|
||||
<div class="selected-labels">
|
||||
{#each selectedLabelIds.slice(0, 3) as labelId}
|
||||
{@const label = labelsStore.getById(labelId)}
|
||||
{#if label}
|
||||
<span class="label-tag" style="--label-color: {label.color}">
|
||||
{label.name}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if selectedLabelIds.length > 3}
|
||||
<span class="label-more">+{selectedLabelIds.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<svg class="dropdown-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showLabelDropdown}
|
||||
<div class="label-dropdown">
|
||||
{#each labelsStore.labels as label}
|
||||
<button
|
||||
type="button"
|
||||
class="label-option"
|
||||
class:selected={selectedLabelIds.includes(label.id)}
|
||||
onclick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<span class="label-dot" style="background-color: {label.color}"></span>
|
||||
<span class="label-name">{label.name}</span>
|
||||
{#if selectedLabelIds.includes(label.id)}
|
||||
<svg class="check-icon" 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>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if labelsStore.labels.length === 0}
|
||||
<div class="no-labels">Keine Labels vorhanden</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<LabelSelector
|
||||
selectedIds={selectedLabelIds}
|
||||
onChange={(ids) => (selectedLabelIds = ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Subtasks -->
|
||||
|
|
@ -441,140 +279,22 @@
|
|||
<!-- Storypoints -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Storypoints</label>
|
||||
<div class="storypoint-buttons">
|
||||
{#each storyPointOptions as sp}
|
||||
<button
|
||||
type="button"
|
||||
class="storypoint-btn"
|
||||
class:selected={storyPoints === sp}
|
||||
onclick={() => (storyPoints = storyPoints === sp ? null : sp)}
|
||||
>
|
||||
{sp}
|
||||
</button>
|
||||
{/each}
|
||||
{#if storyPoints !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="storypoint-clear"
|
||||
onclick={() => (storyPoints = null)}
|
||||
title="Zurücksetzen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<StorypointsSelector value={storyPoints} onChange={(v) => (storyPoints = v)} />
|
||||
</div>
|
||||
|
||||
<!-- Effektive Dauer -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Effektive Dauer</label>
|
||||
<div class="duration-buttons">
|
||||
{#each durationOptions as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-btn"
|
||||
class:selected={isQuickDurationSelected(opt)}
|
||||
onclick={() => selectQuickDuration(opt)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-btn"
|
||||
class:selected={showCustomDuration}
|
||||
onclick={() => (showCustomDuration = !showCustomDuration)}
|
||||
>
|
||||
...
|
||||
</button>
|
||||
{#if effectiveDurationValue !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-clear"
|
||||
onclick={clearDuration}
|
||||
title="Zurücksetzen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showCustomDuration}
|
||||
<div class="duration-custom">
|
||||
<input
|
||||
type="number"
|
||||
class="form-input-sm duration-input"
|
||||
bind:value={effectiveDurationValue}
|
||||
placeholder="Wert"
|
||||
min="1"
|
||||
/>
|
||||
<select class="form-select duration-unit" bind:value={effectiveDurationUnit}>
|
||||
{#each durationUnitOptions as unit}
|
||||
<option value={unit.value}>{unit.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<DurationPicker value={effectiveDuration} onChange={(v) => (effectiveDuration = v)} />
|
||||
</div>
|
||||
|
||||
<!-- Spaß-Faktor -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">
|
||||
Spaß-Faktor{#if funRating !== null}: <span
|
||||
class="fun-rating-value"
|
||||
style="color: {getFunRatingColor(funRating)}">{funRating}</span
|
||||
Spaß-Faktor{#if funRating !== null}: <span class="fun-rating-value">{funRating}</span
|
||||
>{/if}
|
||||
</label>
|
||||
<div class="fun-rating">
|
||||
{#each Array(10) as _, i}
|
||||
{@const rating = i + 1}
|
||||
<button
|
||||
type="button"
|
||||
class="fun-rating-dot"
|
||||
class:filled={funRating !== null && rating <= funRating}
|
||||
style="--dot-color: {getFunRatingColor(rating)}"
|
||||
onclick={() => (funRating = funRating === rating ? null : rating)}
|
||||
title={rating}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if funRating !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="fun-rating-clear"
|
||||
onclick={() => (funRating = null)}
|
||||
title="Zurücksetzen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fun-rating-labels">
|
||||
<span>1</span>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
<FunRatingPicker value={funRating} onChange={(v) => (funRating = v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -788,184 +508,6 @@
|
|||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Priority buttons */
|
||||
.priority-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.priority-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .priority-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.priority-btn:hover {
|
||||
border-color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-btn.selected {
|
||||
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
|
||||
border-color: var(--priority-color);
|
||||
color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Label selector */
|
||||
.label-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.label-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .label-trigger {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.label-trigger:hover {
|
||||
border-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.selected-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--label-color) 15%, transparent);
|
||||
color: var(--label-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.label-more {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.label-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:global(.dark) .label-dropdown {
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.label-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.label-option:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .label-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.label-option.selected {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.label-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .label-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.no-labels {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
|
|
@ -1056,164 +598,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Storypoints */
|
||||
.storypoint-buttons {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.storypoint-btn {
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .storypoint-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.storypoint-btn:hover {
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.storypoint-btn.selected {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: #8b5cf6;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.storypoint-clear,
|
||||
.duration-clear,
|
||||
.fun-rating-clear {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.storypoint-clear:hover,
|
||||
.duration-clear:hover,
|
||||
.fun-rating-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Duration */
|
||||
.duration-buttons {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duration-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .duration-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.duration-btn:hover {
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.duration-btn.selected {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: #8b5cf6;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.duration-custom {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.duration-unit {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
/* Fun Rating */
|
||||
.fun-rating {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fun-rating-dot {
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.fun-rating-dot:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.fun-rating-dot .dot {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .fun-rating-dot .dot {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.fun-rating-dot.filled .dot {
|
||||
background: var(--dot-color);
|
||||
}
|
||||
|
||||
.fun-rating-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.fun-rating-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
238
apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte
Normal file
238
apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<script lang="ts">
|
||||
import type { DurationUnit, EffectiveDuration } from '@todo/shared';
|
||||
|
||||
interface Props {
|
||||
value: EffectiveDuration | null;
|
||||
onChange: (value: EffectiveDuration | null) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
let showCustom = $state(false);
|
||||
let customValue = $state<number | null>(null);
|
||||
let customUnit = $state<DurationUnit>('hours');
|
||||
|
||||
// Quick duration options
|
||||
const quickOptions: { label: string; value: number; unit: DurationUnit }[] = [
|
||||
{ label: '15m', value: 15, unit: 'minutes' },
|
||||
{ label: '30m', value: 30, unit: 'minutes' },
|
||||
{ label: '1h', value: 1, unit: 'hours' },
|
||||
{ label: '2h', value: 2, unit: 'hours' },
|
||||
{ label: '4h', value: 4, unit: 'hours' },
|
||||
{ label: '1d', value: 1, unit: 'days' },
|
||||
{ label: '2d', value: 2, unit: 'days' },
|
||||
];
|
||||
|
||||
const unitOptions: { value: DurationUnit; label: string }[] = [
|
||||
{ value: 'minutes', label: 'Minuten' },
|
||||
{ value: 'hours', label: 'Stunden' },
|
||||
{ value: 'days', label: 'Tage' },
|
||||
];
|
||||
|
||||
// Sync custom inputs with value prop
|
||||
$effect(() => {
|
||||
if (value) {
|
||||
const isQuickOption = quickOptions.some(
|
||||
(opt) => opt.value === value.value && opt.unit === value.unit
|
||||
);
|
||||
if (!isQuickOption) {
|
||||
showCustom = true;
|
||||
customValue = value.value;
|
||||
customUnit = value.unit;
|
||||
} else {
|
||||
showCustom = false;
|
||||
}
|
||||
} else {
|
||||
showCustom = false;
|
||||
customValue = null;
|
||||
customUnit = 'hours';
|
||||
}
|
||||
});
|
||||
|
||||
function selectQuick(opt: { value: number; unit: DurationUnit }) {
|
||||
showCustom = false;
|
||||
onChange({ value: opt.value, unit: opt.unit });
|
||||
}
|
||||
|
||||
function isQuickSelected(opt: { value: number; unit: DurationUnit }): boolean {
|
||||
return value !== null && value.value === opt.value && value.unit === opt.unit && !showCustom;
|
||||
}
|
||||
|
||||
function toggleCustom() {
|
||||
showCustom = !showCustom;
|
||||
if (showCustom && customValue && customValue > 0) {
|
||||
onChange({ value: customValue, unit: customUnit });
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomChange() {
|
||||
if (customValue && customValue > 0) {
|
||||
onChange({ value: customValue, unit: customUnit });
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
showCustom = false;
|
||||
customValue = null;
|
||||
customUnit = 'hours';
|
||||
onChange(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="duration-picker">
|
||||
<div class="duration-buttons">
|
||||
{#each quickOptions as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="duration-btn"
|
||||
class:selected={isQuickSelected(opt)}
|
||||
onclick={() => selectQuick(opt)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
<button type="button" class="duration-btn" class:selected={showCustom} onclick={toggleCustom}>
|
||||
...
|
||||
</button>
|
||||
{#if value !== null}
|
||||
<button type="button" class="duration-clear" onclick={clear} title="Zurücksetzen">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showCustom}
|
||||
<div class="duration-custom">
|
||||
<input
|
||||
type="number"
|
||||
class="duration-input"
|
||||
bind:value={customValue}
|
||||
oninput={handleCustomChange}
|
||||
placeholder="Wert"
|
||||
min="1"
|
||||
/>
|
||||
<select class="duration-unit" bind:value={customUnit} onchange={handleCustomChange}>
|
||||
{#each unitOptions as unit}
|
||||
<option value={unit.value}>{unit.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.duration-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.duration-buttons {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duration-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .duration-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.duration-btn:hover {
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.duration-btn.selected {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: #8b5cf6;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.duration-clear {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.duration-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.duration-custom {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 80px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .duration-input {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.duration-input:focus {
|
||||
outline: none;
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.duration-unit {
|
||||
width: 120px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .duration-unit {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.duration-unit:focus {
|
||||
outline: none;
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
function getRatingColor(rating: number): string {
|
||||
if (rating <= 3) return '#ef4444'; // red
|
||||
if (rating <= 6) return '#eab308'; // yellow
|
||||
return '#22c55e'; // green
|
||||
}
|
||||
|
||||
function handleSelect(rating: number) {
|
||||
onChange(value === rating ? null : rating);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
onChange(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fun-rating-picker">
|
||||
<div class="fun-rating">
|
||||
{#each Array(10) as _, i}
|
||||
{@const rating = i + 1}
|
||||
<button
|
||||
type="button"
|
||||
class="fun-rating-dot"
|
||||
class:filled={value !== null && rating <= value}
|
||||
style="--dot-color: {getRatingColor(rating)}"
|
||||
onclick={() => handleSelect(rating)}
|
||||
title={String(rating)}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if value !== null}
|
||||
<button type="button" class="fun-rating-clear" onclick={handleClear} title="Zurücksetzen">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fun-rating-labels">
|
||||
<span>1</span>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fun-rating-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.fun-rating {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fun-rating-dot {
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.fun-rating-dot:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.fun-rating-dot .dot {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .fun-rating-dot .dot {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.fun-rating-dot.filled .dot {
|
||||
background: var(--dot-color);
|
||||
}
|
||||
|
||||
.fun-rating-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.fun-rating-clear {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.fun-rating-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
</style>
|
||||
223
apps/todo/apps/web/src/lib/components/form/LabelSelector.svelte
Normal file
223
apps/todo/apps/web/src/lib/components/form/LabelSelector.svelte
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<script lang="ts">
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
|
||||
interface Props {
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
let { selectedIds, onChange }: Props = $props();
|
||||
|
||||
let showDropdown = $state(false);
|
||||
|
||||
function toggleLabel(labelId: string) {
|
||||
if (selectedIds.includes(labelId)) {
|
||||
onChange(selectedIds.filter((id) => id !== labelId));
|
||||
} else {
|
||||
onChange([...selectedIds, labelId]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTriggerClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
showDropdown = !showDropdown;
|
||||
}
|
||||
|
||||
function handleWindowClick() {
|
||||
showDropdown = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<div class="label-selector">
|
||||
<button type="button" class="label-trigger" onclick={handleTriggerClick}>
|
||||
{#if selectedIds.length === 0}
|
||||
<span class="text-muted">Labels auswählen...</span>
|
||||
{:else}
|
||||
<div class="selected-labels">
|
||||
{#each selectedIds.slice(0, 3) as labelId}
|
||||
{@const label = labelsStore.getById(labelId)}
|
||||
{#if label}
|
||||
<span class="label-tag" style="--label-color: {label.color}">
|
||||
{label.name}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if selectedIds.length > 3}
|
||||
<span class="label-more">+{selectedIds.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<svg class="dropdown-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div class="label-dropdown" onclick={(e) => e.stopPropagation()} role="listbox">
|
||||
{#each labelsStore.labels as label}
|
||||
<button
|
||||
type="button"
|
||||
class="label-option"
|
||||
class:selected={selectedIds.includes(label.id)}
|
||||
onclick={() => toggleLabel(label.id)}
|
||||
role="option"
|
||||
aria-selected={selectedIds.includes(label.id)}
|
||||
>
|
||||
<span class="label-dot" style="background-color: {label.color}"></span>
|
||||
<span class="label-name">{label.name}</span>
|
||||
{#if selectedIds.includes(label.id)}
|
||||
<svg class="check-icon" 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>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if labelsStore.labels.length === 0}
|
||||
<div class="no-labels">Keine Labels vorhanden</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.label-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.label-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .label-trigger {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.label-trigger:hover {
|
||||
border-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.selected-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--label-color) 15%, transparent);
|
||||
color: var(--label-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.label-more {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.label-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:global(.dark) .label-dropdown {
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.label-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.label-option:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .label-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.label-option.selected {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.label-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .label-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.no-labels {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<script lang="ts">
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
|
||||
interface Props {
|
||||
value: TaskPriority;
|
||||
onChange: (priority: TaskPriority) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
const priorities: { value: TaskPriority; label: string; color: string }[] = [
|
||||
{ value: 'low', label: 'Niedrig', color: '#22c55e' },
|
||||
{ value: 'medium', label: 'Mittel', color: '#eab308' },
|
||||
{ value: 'high', label: 'Hoch', color: '#f97316' },
|
||||
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="priority-buttons">
|
||||
{#each priorities as p}
|
||||
<button
|
||||
type="button"
|
||||
class="priority-btn"
|
||||
class:selected={value === p.value}
|
||||
style="--priority-color: {p.color}"
|
||||
onclick={() => onChange(p.value)}
|
||||
>
|
||||
<span class="priority-dot" style="background-color: {p.color}"></span>
|
||||
{p.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.priority-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.priority-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .priority-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.priority-btn:hover {
|
||||
border-color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-btn.selected {
|
||||
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
|
||||
border-color: var(--priority-color);
|
||||
color: var(--priority-color);
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
// Fibonacci sequence for story points
|
||||
const options = [1, 2, 3, 5, 8, 13, 21];
|
||||
|
||||
function handleSelect(sp: number) {
|
||||
onChange(value === sp ? null : sp);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
onChange(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="storypoint-buttons">
|
||||
{#each options as sp}
|
||||
<button
|
||||
type="button"
|
||||
class="storypoint-btn"
|
||||
class:selected={value === sp}
|
||||
onclick={() => handleSelect(sp)}
|
||||
>
|
||||
{sp}
|
||||
</button>
|
||||
{/each}
|
||||
{#if value !== null}
|
||||
<button type="button" class="storypoint-clear" onclick={handleClear} title="Zurücksetzen">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.storypoint-buttons {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.storypoint-btn {
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .storypoint-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.storypoint-btn:hover {
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.storypoint-btn.selected {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: #8b5cf6;
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.storypoint-clear {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.storypoint-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
</style>
|
||||
5
apps/todo/apps/web/src/lib/components/form/index.ts
Normal file
5
apps/todo/apps/web/src/lib/components/form/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { default as PrioritySelector } from './PrioritySelector.svelte';
|
||||
export { default as StorypointsSelector } from './StorypointsSelector.svelte';
|
||||
export { default as DurationPicker } from './DurationPicker.svelte';
|
||||
export { default as FunRatingPicker } from './FunRatingPicker.svelte';
|
||||
export { default as LabelSelector } from './LabelSelector.svelte';
|
||||
Loading…
Add table
Add a link
Reference in a new issue