refactor(stores): rewrite NutriPhi + Mukke stores to use IndexedDB

NutriPhi meals store: Replace apiClient calls with mealCollection
reads/writes. Daily summary now computed locally from IndexedDB.

Mukke library store: Replace backend API aggregations (albums, artists,
genres, stats) with local computation from songCollection.getAll().
Server-only operations (upload, cover URLs, metadata extraction) remain
as fire-and-forget API calls.

Store migration status: All 19 apps now use IndexedDB as primary
data source. Server API calls only remain for:
- File upload/download (S3 presigned URLs)
- Image generation (Replicate API)
- AI analysis (Gemini)
- Cover art URLs (S3 presigned)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 03:12:37 +01:00
parent 5b673282f9
commit b37a451d29
22 changed files with 1327 additions and 2126 deletions

View file

@ -128,14 +128,29 @@ function createLibraryStore() {
}
},
/** Load albums from backend (aggregated view). */
/** Load albums from IndexedDB (aggregated locally). */
async loadAlbums() {
state.isLoading = true;
state.error = null;
try {
const data = await fetchApi<{ albums: Album[] }>('/library/albums');
state.albums = data.albums;
const coverPaths = data.albums.map((a) => a.coverArtPath).filter((p): p is string => !!p);
const songs = await songCollection.getAll();
const albumMap = new Map<string, Album>();
for (const s of songs) {
const key = s.album || 'Unknown Album';
if (!albumMap.has(key)) {
albumMap.set(key, {
album: key,
albumArtist: s.albumArtist || s.artist || 'Unknown',
year: s.year ?? null,
coverArtPath: s.coverArtPath ?? null,
songCount: 0,
} as Album);
}
const a = albumMap.get(key)!;
(a as any).songCount = ((a as any).songCount || 0) + 1;
}
state.albums = Array.from(albumMap.values());
const coverPaths = state.albums.map((a) => a.coverArtPath).filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load albums';
@ -143,37 +158,72 @@ function createLibraryStore() {
state.isLoading = false;
},
/** Load artists from backend (aggregated view). */
/** Load artists from IndexedDB (aggregated locally). */
async loadArtists() {
state.isLoading = true;
state.error = null;
try {
const data = await fetchApi<{ artists: Artist[] }>('/library/artists');
state.artists = data.artists;
const songs = await songCollection.getAll();
const artistMap = new Map<
string,
{ artist: string; songCount: number; albumCount: number }
>();
const artistAlbums = new Map<string, Set<string>>();
for (const s of songs) {
const key = s.artist || 'Unknown';
if (!artistMap.has(key)) {
artistMap.set(key, { artist: key, songCount: 0, albumCount: 0 });
artistAlbums.set(key, new Set());
}
artistMap.get(key)!.songCount++;
if (s.album) artistAlbums.get(key)!.add(s.album);
}
state.artists = Array.from(artistMap.values()).map((a) => ({
...a,
albumCount: artistAlbums.get(a.artist)?.size || 0,
})) as Artist[];
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load artists';
}
state.isLoading = false;
},
/** Load genres from backend (aggregated view). */
/** Load genres from IndexedDB (aggregated locally). */
async loadGenres() {
state.isLoading = true;
state.error = null;
try {
const data = await fetchApi<{ genres: Genre[] }>('/library/genres');
state.genres = data.genres;
const songs = await songCollection.getAll();
const genreMap = new Map<string, number>();
for (const s of songs) {
const key = s.genre || 'Unknown';
genreMap.set(key, (genreMap.get(key) || 0) + 1);
}
state.genres = Array.from(genreMap.entries()).map(([genre, songCount]) => ({
genre,
songCount,
})) as Genre[];
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load genres';
}
state.isLoading = false;
},
/** Load stats from backend. */
/** Load stats from IndexedDB (computed locally). */
async loadStats() {
try {
const data = await fetchApi<{ stats: LibraryStats }>('/library/stats');
state.stats = data.stats;
const songs = await songCollection.getAll();
const artists = new Set(songs.map((s) => s.artist).filter(Boolean));
const albums = new Set(songs.map((s) => s.album).filter(Boolean));
const genres = new Set(songs.map((s) => s.genre).filter(Boolean));
state.stats = {
totalSongs: songs.length,
totalArtists: artists.size,
totalAlbums: albums.size,
totalGenres: genres.size,
totalDuration: songs.reduce((sum, s) => sum + (s.duration || 0), 0),
totalPlays: songs.reduce((sum, s) => sum + (s.playCount || 0), 0),
} as LibraryStats;
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load stats';
}

View file

@ -1,4 +1,11 @@
import { apiClient } from '$lib/api/client';
/**
* Meals Store Local-First with @manacore/local-store
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
*/
import { mealCollection, type LocalMeal } from '$lib/data/local-store';
import type { Meal, MealNutrition, DailySummary } from '@nutriphi/shared';
import { NutriPhiEvents } from '@manacore/shared-utils/analytics';
@ -6,6 +13,32 @@ interface MealWithNutrition extends Meal {
nutrition: MealNutrition | null;
}
function toMealWithNutrition(local: LocalMeal): MealWithNutrition {
return {
id: local.id,
userId: 'local',
date: new Date(local.date),
mealType: local.mealType as any,
inputType: local.inputType as any,
description: local.description,
portionSize: local.portionSize ?? undefined,
confidence: local.confidence,
createdAt: new Date(local.createdAt ?? Date.now()),
nutrition: local.nutrition
? {
id: local.id,
mealId: local.id,
calories: local.nutrition.calories,
protein: local.nutrition.protein,
carbohydrates: local.nutrition.carbohydrates,
fat: local.nutrition.fat,
fiber: local.nutrition.fiber,
sugar: local.nutrition.sugar,
}
: null,
} as MealWithNutrition;
}
class MealsStore {
meals = $state<MealWithNutrition[]>([]);
loading = $state(false);
@ -20,7 +53,11 @@ class MealsStore {
this.error = null;
try {
const today = new Date().toISOString().split('T')[0];
this.meals = await apiClient.get<MealWithNutrition[]>(`/meals?date=${today}`);
const allMeals = await mealCollection.getAll();
this.meals = allMeals
.filter((m) => m.date === today)
.map(toMealWithNutrition)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
} catch (err) {
this.error = err instanceof Error ? err.message : 'Mahlzeiten konnten nicht geladen werden';
} finally {
@ -33,7 +70,26 @@ class MealsStore {
this.summaryError = null;
try {
const dateStr = (date || new Date()).toISOString().split('T')[0];
this.dailySummary = await apiClient.get<DailySummary>(`/stats/daily?date=${dateStr}`);
const allMeals = await mealCollection.getAll();
const dayMeals = allMeals.filter((m) => m.date === dateStr);
const totalNutrition = dayMeals.reduce(
(acc, m) => ({
calories: acc.calories + (m.nutrition?.calories || 0),
protein: acc.protein + (m.nutrition?.protein || 0),
carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0),
fat: acc.fat + (m.nutrition?.fat || 0),
fiber: acc.fiber + (m.nutrition?.fiber || 0),
sugar: acc.sugar + (m.nutrition?.sugar || 0),
}),
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
);
this.dailySummary = {
date: new Date(dateStr),
meals: dayMeals.map(toMealWithNutrition),
totalNutrition,
} as DailySummary;
} catch (err) {
this.summaryError =
err instanceof Error ? err.message : 'Zusammenfassung konnte nicht geladen werden';
@ -57,7 +113,25 @@ class MealsStore {
}) {
this.error = null;
try {
const meal = await apiClient.post<MealWithNutrition>('/meals', mealData);
const newMeal: LocalMeal = {
id: crypto.randomUUID(),
date: mealData.date,
mealType: mealData.mealType as any,
inputType: mealData.inputType as any,
description: mealData.description,
confidence: mealData.confidence,
nutrition: {
calories: mealData.calories,
protein: mealData.protein,
carbohydrates: mealData.carbohydrates,
fat: mealData.fat,
fiber: mealData.fiber || 0,
sugar: mealData.sugar || 0,
},
};
const inserted = await mealCollection.insert(newMeal);
const meal = toMealWithNutrition(inserted);
this.meals = [...this.meals, meal];
await this.fetchDailySummary();
NutriPhiEvents.mealAdded(mealData.mealType, mealData.inputType);
@ -73,7 +147,7 @@ class MealsStore {
async deleteMeal(mealId: string) {
this.deleteError = null;
try {
await apiClient.delete(`/meals/${mealId}`);
await mealCollection.delete(mealId);
this.meals = this.meals.filter((m) => m.id !== mealId);
await this.fetchDailySummary();
NutriPhiEvents.mealDeleted();

View file

@ -1,176 +0,0 @@
import { apiClient } from './client';
import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared';
// Response types
interface BoardsResponse {
boards: KanbanBoard[];
}
interface BoardResponse {
board: KanbanBoard;
}
interface ColumnsResponse {
columns: KanbanColumn[];
}
interface ColumnResponse {
column: KanbanColumn;
}
interface KanbanTasksResponse {
columns: KanbanColumn[];
tasksByColumn: Record<string, Task[]>;
}
interface TaskResponse {
task: Task;
}
interface TasksResponse {
tasks: Task[];
}
// DTO types
interface CreateBoardDto {
name: string;
projectId?: string;
color?: string;
icon?: string;
}
interface UpdateBoardDto {
name?: string;
color?: string;
icon?: string;
}
interface CreateColumnDto {
name: string;
boardId: string;
color?: string;
isDefault?: boolean;
defaultStatus?: string;
autoComplete?: boolean;
}
interface UpdateColumnDto {
name?: string;
color?: string;
defaultStatus?: string;
autoComplete?: boolean;
}
// =====================
// Board operations
// =====================
export async function getBoards(): Promise<KanbanBoard[]> {
const response = await apiClient.get<BoardsResponse>('/api/v1/kanban/boards');
return response.boards;
}
export async function getGlobalBoard(): Promise<KanbanBoard> {
const response = await apiClient.get<BoardResponse>('/api/v1/kanban/boards/global');
return response.board;
}
export async function getBoard(id: string): Promise<KanbanBoard> {
const response = await apiClient.get<BoardResponse>(`/api/v1/kanban/boards/${id}`);
return response.board;
}
export async function createBoard(data: CreateBoardDto): Promise<KanbanBoard> {
const response = await apiClient.post<BoardResponse>('/api/v1/kanban/boards', data);
return response.board;
}
export async function updateBoard(id: string, data: UpdateBoardDto): Promise<KanbanBoard> {
const response = await apiClient.put<BoardResponse>(`/api/v1/kanban/boards/${id}`, data);
return response.board;
}
export async function deleteBoard(id: string): Promise<void> {
await apiClient.delete(`/api/v1/kanban/boards/${id}`);
}
export async function reorderBoards(boardIds: string[]): Promise<KanbanBoard[]> {
const response = await apiClient.put<BoardsResponse>('/api/v1/kanban/boards/reorder', {
boardIds,
});
return response.boards;
}
// =====================
// Column operations
// =====================
export async function getColumns(boardId: string): Promise<KanbanColumn[]> {
const response = await apiClient.get<ColumnsResponse>(
`/api/v1/kanban/columns?boardId=${boardId}`
);
return response.columns;
}
export async function createColumn(data: CreateColumnDto): Promise<KanbanColumn> {
const response = await apiClient.post<ColumnResponse>('/api/v1/kanban/columns', data);
return response.column;
}
export async function updateColumn(id: string, data: UpdateColumnDto): Promise<KanbanColumn> {
const response = await apiClient.put<ColumnResponse>(`/api/v1/kanban/columns/${id}`, data);
return response.column;
}
export async function deleteColumn(id: string): Promise<void> {
await apiClient.delete(`/api/v1/kanban/columns/${id}`);
}
export async function reorderColumns(columnIds: string[]): Promise<KanbanColumn[]> {
const response = await apiClient.put<ColumnsResponse>('/api/v1/kanban/columns/reorder', {
columnIds,
});
return response.columns;
}
export async function initializeColumns(boardId: string): Promise<KanbanColumn[]> {
const response = await apiClient.post<ColumnsResponse>(
`/api/v1/kanban/columns/init?boardId=${boardId}`
);
return response.columns;
}
// =====================
// Task operations
// =====================
export async function getKanbanTasks(
boardId: string
): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record<string, Task[]> }> {
const response = await apiClient.get<KanbanTasksResponse>(
`/api/v1/kanban/tasks?boardId=${boardId}`
);
return response;
}
export async function moveTaskToColumn(
taskId: string,
columnId: string,
order?: number
): Promise<Task> {
const response = await apiClient.post<TaskResponse>(`/api/v1/kanban/tasks/${taskId}/move`, {
columnId,
order,
});
return response.task;
}
export async function reorderTasksInColumn(columnId: string, taskIds: string[]): Promise<Task[]> {
const response = await apiClient.put<TasksResponse>('/api/v1/kanban/tasks/reorder', {
columnId,
taskIds,
});
return response.tasks;
}

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Task, Project } from '@todo/shared';
import type { LocalBoardView } from '$lib/data/local-store';
import { groupTasksByView, getDropActionUpdate } from '$lib/data/view-grouping';
import { tasksStore } from '$lib/stores/tasks.svelte';
import KanbanLayout from './KanbanLayout.svelte';
import GridLayout from './GridLayout.svelte';
interface Props {
view: LocalBoardView;
}
let { view }: Props = $props();
// Get tasks and projects from context (set by layout)
const tasksCtx: { readonly value: Task[] } = getContext('tasks');
const projectsCtx: { readonly value: Project[] } = getContext('projects');
// Group tasks by the current view configuration
let columns = $derived(groupTasksByView(view, tasksCtx.value, projectsCtx.value));
// ─── Task Callbacks ──────────────────────────────────────
function handleTaskDrop(taskId: string, columnId: string) {
const targetColumn = columns.find((c) => c.id === columnId);
if (!targetColumn?.onDrop) return;
const update = getDropActionUpdate(targetColumn.onDrop);
if (Object.keys(update).length > 0) {
tasksStore.updateTask(taskId, update);
}
}
function handleTaskToggle(task: Task) {
if (task.isCompleted) {
tasksStore.uncompleteTask(task.id);
} else {
tasksStore.completeTask(task.id);
}
}
function handleTaskDelete(taskId: string) {
tasksStore.deleteTask(taskId);
}
function handleTaskUpdate(taskId: string, data: Partial<Task>) {
const updateData: Record<string, unknown> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.projectId !== undefined) updateData.projectId = data.projectId;
if (data.dueDate !== undefined) {
updateData.dueDate = data.dueDate instanceof Date ? data.dueDate.toISOString() : data.dueDate;
}
if (data.priority !== undefined) updateData.priority = data.priority;
if (data.status !== undefined) updateData.status = data.status;
if (data.subtasks !== undefined) updateData.subtasks = data.subtasks;
if (data.recurrenceRule !== undefined) updateData.recurrenceRule = data.recurrenceRule;
if (data.metadata !== undefined) updateData.metadata = data.metadata;
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
tasksStore.updateTask(taskId, updateData);
}
</script>
{#if view.layout === 'grid'}
<GridLayout
{columns}
onTaskDrop={handleTaskDrop}
onTaskToggle={handleTaskToggle}
onTaskDelete={handleTaskDelete}
onTaskUpdate={handleTaskUpdate}
/>
{:else}
<KanbanLayout
{columns}
onTaskDrop={handleTaskDrop}
onTaskToggle={handleTaskToggle}
onTaskDelete={handleTaskDelete}
onTaskUpdate={handleTaskUpdate}
/>
{/if}

View file

@ -0,0 +1,72 @@
<script lang="ts">
import type { Task } from '@todo/shared';
import type { GroupedColumn } from '$lib/data/view-grouping';
import ViewColumn from './ViewColumn.svelte';
interface Props {
columns: GroupedColumn[];
onTaskDrop: (taskId: string, columnId: string) => void;
onTaskToggle: (task: Task) => void;
onTaskDelete: (taskId: string) => void;
onTaskUpdate: (taskId: string, data: Partial<Task>) => void;
}
let { columns, onTaskDrop, onTaskToggle, onTaskDelete, onTaskUpdate }: Props = $props();
</script>
<div class="grid-layout">
{#each columns as column (column.id)}
<div class="grid-cell">
<ViewColumn
{column}
{onTaskDrop}
{onTaskToggle}
{onTaskDelete}
{onTaskUpdate}
/>
</div>
{/each}
</div>
<style>
.grid-layout {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 1rem;
height: 100%;
padding: 0 1rem 1rem;
}
@media (min-width: 640px) {
.grid-layout {
padding: 0 1.5rem 1rem;
}
}
@media (min-width: 1024px) {
.grid-layout {
padding: 0 2rem 1rem;
}
}
/* Single column on mobile */
@media (max-width: 639px) {
.grid-layout {
grid-template-columns: 1fr;
grid-template-rows: auto;
overflow-y: auto;
}
}
.grid-cell {
min-height: 0;
overflow: hidden;
}
/* In grid layout, columns should fill available height */
.grid-cell :global(.view-column) {
height: 100%;
max-height: none;
}
</style>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import type { Task } from '@todo/shared';
import type { GroupedColumn } from '$lib/data/view-grouping';
import ViewColumn from './ViewColumn.svelte';
interface Props {
columns: GroupedColumn[];
onTaskDrop: (taskId: string, columnId: string) => void;
onTaskToggle: (task: Task) => void;
onTaskDelete: (taskId: string) => void;
onTaskUpdate: (taskId: string, data: Partial<Task>) => void;
}
let { columns, onTaskDrop, onTaskToggle, onTaskDelete, onTaskUpdate }: Props = $props();
</script>
<div class="kanban-layout">
{#each columns as column (column.id)}
<div class="kanban-column-wrapper">
<ViewColumn
{column}
{onTaskDrop}
{onTaskToggle}
{onTaskDelete}
{onTaskUpdate}
/>
</div>
{/each}
</div>
<style>
.kanban-layout {
display: flex;
gap: 1rem;
height: 100%;
overflow-x: auto;
padding: 0 1rem 1rem;
scroll-behavior: smooth;
-ms-overflow-style: none;
scrollbar-width: thin;
}
@media (min-width: 640px) {
.kanban-layout {
padding: 0 1.5rem 1rem;
}
}
@media (min-width: 1024px) {
.kanban-layout {
padding: 0 2rem 1rem;
}
}
.kanban-layout::-webkit-scrollbar {
height: 6px;
}
.kanban-layout::-webkit-scrollbar-track {
background: transparent;
}
.kanban-layout::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
}
:global(.dark) .kanban-layout::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
.kanban-column-wrapper {
min-width: 300px;
max-width: 340px;
flex-shrink: 0;
}
</style>

View file

@ -1,37 +1,28 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID, type DndEvent } from 'svelte-dnd-action';
import type { KanbanColumn, Task } from '@todo/shared';
import KanbanTaskCard from './KanbanTaskCard.svelte';
import KanbanColumnHeader from './KanbanColumnHeader.svelte';
import QuickAddTaskInline from './QuickAddTaskInline.svelte';
import type { Task } from '@todo/shared';
import type { GroupedColumn } from '$lib/data/view-grouping';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
import QuickAddTaskInline from '../kanban/QuickAddTaskInline.svelte';
import ViewColumnHeader from './ViewColumnHeader.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
interface Props {
column: KanbanColumn;
tasks: Task[];
onUpdateColumn?: (data: { name?: string; color?: string }) => void;
onDeleteColumn?: () => void;
onTasksReorder?: (taskIds: string[]) => void;
onTaskMove?: (taskId: string, toColumnId: string, order: number) => void;
onAddTask?: (title: string) => void;
column: GroupedColumn;
onTaskDrop: (taskId: string, columnId: string) => void;
onTaskToggle: (task: Task) => void;
onTaskDelete: (taskId: string) => void;
onTaskUpdate: (taskId: string, data: Partial<Task>) => void;
}
let {
column,
tasks,
onUpdateColumn,
onDeleteColumn,
onTasksReorder,
onTaskMove,
onAddTask,
}: Props = $props();
let { column, onTaskDrop, onTaskToggle, onTaskDelete, onTaskUpdate }: Props = $props();
// Local tasks state for drag and drop
let localTasks = $state<Task[]>([]);
// Sync with parent
// Sync with parent when column tasks change
$effect(() => {
localTasks = [...tasks];
localTasks = [...column.tasks];
});
const flipDurationMs = 200;
@ -45,32 +36,25 @@
const movedTaskId = e.detail.info.id;
// Check if this task came from another column
const movedTask = newItems.find((t) => t.id === movedTaskId);
const wasInThisColumn = tasks.some((t) => t.id === movedTaskId);
const wasInThisColumn = column.tasks.some((t) => t.id === movedTaskId);
if (movedTask && !wasInThisColumn) {
if (!wasInThisColumn) {
// Task moved FROM another column TO this column
const newIndex = newItems.findIndex((t) => t.id === movedTaskId);
onTaskMove?.(movedTaskId, column.id, newIndex);
onTaskDrop(movedTaskId, column.id);
} else {
// Task reordered within this column
// Task reordered within this column — update order
const taskIds = newItems.map((t) => t.id);
onTasksReorder?.(taskIds);
tasksStore.reorderTasks(taskIds);
}
localTasks = newItems;
}
async function handleToggleComplete(task: Task) {
if (task.isCompleted) {
await tasksStore.uncompleteTask(task.id);
} else {
await tasksStore.completeTask(task.id);
}
function handleToggleComplete(task: Task) {
onTaskToggle(task);
}
async function handleSaveTask(task: Task, data: Partial<Task>) {
// Transform Partial<Task> to updateTask format
function handleSaveTask(task: Task, data: Partial<Task>) {
const updateData: Record<string, unknown> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
@ -85,22 +69,34 @@
if (data.metadata !== undefined) updateData.metadata = data.metadata;
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
await tasksStore.updateTask(task.id, updateData);
onTaskUpdate(task.id, data);
}
async function handleDeleteTask(task: Task) {
await tasksStore.deleteTask(task.id);
function handleDeleteTask(task: Task) {
onTaskDelete(task.id);
}
async function handleAddTask(title: string) {
// Create task with properties that match this column's onDrop action
const createData: Record<string, unknown> = { title };
if (column.onDrop) {
if (column.onDrop.setCompleted !== undefined) {
// Don't create completed tasks — create as pending
}
if (column.onDrop.setPriority) {
createData.priority = column.onDrop.setPriority;
}
if (column.onDrop.setProjectId !== undefined) {
createData.projectId = column.onDrop.setProjectId;
}
}
await tasksStore.createTask(createData as { title: string; projectId?: string; priority?: 'low' | 'medium' | 'high' | 'urgent' });
}
</script>
<div class="kanban-column flex flex-col min-w-[300px] max-w-[340px] h-full">
<div class="view-column flex flex-col">
<!-- Header -->
<KanbanColumnHeader
{column}
taskCount={localTasks.length}
onUpdate={onUpdateColumn}
onDelete={onDeleteColumn}
/>
<ViewColumnHeader name={column.name} color={column.color} taskCount={localTasks.length} />
<!-- Tasks list with drag and drop -->
<div
@ -110,7 +106,7 @@
flipDurationMs,
dropTargetStyle: {},
dropTargetClasses: ['drop-target'],
type: 'tasks',
type: 'board-view-tasks',
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
@ -128,15 +124,13 @@
</div>
<!-- Quick Add Task -->
{#if onAddTask}
<div class="px-3 pb-3 pt-2">
<QuickAddTaskInline onAdd={onAddTask} />
</div>
{/if}
<div class="px-3 pb-3 pt-2">
<QuickAddTaskInline onAdd={handleAddTask} />
</div>
</div>
<style>
.kanban-column {
.view-column {
min-height: 250px;
max-height: calc(100vh - 280px);
background: rgba(255, 255, 255, 0.5);
@ -147,7 +141,7 @@
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
:global(.dark) .kanban-column {
:global(.dark) .view-column {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}

View file

@ -0,0 +1,68 @@
<script lang="ts">
interface Props {
name: string;
color: string;
taskCount: number;
}
let { name, color, taskCount }: Props = $props();
</script>
<div class="column-header">
<div class="header-left">
<span class="color-dot" style="background-color: {color}"></span>
<span class="column-name">{name}</span>
</div>
<span class="task-count">{taskCount}</span>
</div>
<style>
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.color-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
flex-shrink: 0;
}
.column-name {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .column-name {
color: #f3f4f6;
}
.task-count {
font-size: 0.75rem;
font-weight: 500;
color: #9ca3af;
background: rgba(0, 0, 0, 0.05);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
}
:global(.dark) .task-count {
background: rgba(255, 255, 255, 0.1);
color: #6b7280;
}
</style>

View file

@ -0,0 +1,160 @@
<script lang="ts">
import type { LocalBoardView } from '$lib/data/local-store';
interface Props {
views: LocalBoardView[];
activeViewId: string | null;
onSelect: (viewId: string) => void;
}
let { views, activeViewId, onSelect }: Props = $props();
// Map icon names to simple SVG representations
const iconMap: Record<string, string> = {
columns: 'M9 4h6v16H9zM3 4h4v16H3zM17 4h4v16h-4z',
'grid-four': 'M3 3h8v8H3zM13 3h8v8h-8zM3 13h8v8H3zM13 13h8v8h-8z',
flag: 'M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7',
folders: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z',
calendar: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
};
</script>
<div class="view-selector-container">
<div class="view-selector">
<div class="view-pills-scroll">
{#each views as view (view.id)}
<button
type="button"
class="view-pill"
class:active={activeViewId === view.id}
onclick={() => onSelect(view.id)}
>
{#if view.icon && iconMap[view.icon]}
<svg
class="h-4 w-4 mr-1.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={iconMap[view.icon]} />
</svg>
{:else if view.icon}
<span class="mr-1.5 text-sm">{view.icon}</span>
{/if}
<span class="view-name">{view.name}</span>
</button>
{/each}
</div>
</div>
</div>
<style>
.view-selector-container {
padding: 0 1rem;
margin-bottom: 0.75rem;
}
@media (min-width: 640px) {
.view-selector-container {
padding: 0 1.5rem;
}
}
@media (min-width: 1024px) {
.view-selector-container {
padding: 0 2rem;
}
}
.view-selector {
display: flex;
align-items: center;
gap: 0.5rem;
/* Glass-Pill container */
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 9999px;
padding: 0.375rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.07),
0 2px 4px -1px rgba(0, 0, 0, 0.04);
}
:global(.dark) .view-selector {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.view-pills-scroll {
display: flex;
align-items: center;
gap: 0.25rem;
overflow-x: auto;
scroll-behavior: smooth;
-ms-overflow-style: none;
scrollbar-width: none;
}
.view-pills-scroll::-webkit-scrollbar {
display: none;
}
.view-pill {
display: flex;
align-items: center;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
}
.view-pill:hover {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .view-pill {
color: #9ca3af;
}
:global(.dark) .view-pill:hover {
background: rgba(255, 255, 255, 0.12);
color: #f3f4f6;
}
.view-pill.active {
background: #8b5cf6;
color: white;
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.15),
0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
.view-pill.active:hover {
filter: brightness(1.1);
color: white;
}
:global(.dark) .view-pill.active {
background: #8b5cf6;
color: white;
}
.view-name {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -0,0 +1,6 @@
export { default as ViewColumnHeader } from './ViewColumnHeader.svelte';
export { default as ViewColumn } from './ViewColumn.svelte';
export { default as KanbanLayout } from './KanbanLayout.svelte';
export { default as GridLayout } from './GridLayout.svelte';
export { default as BoardViewRenderer } from './BoardViewRenderer.svelte';
export { default as ViewSelector } from './ViewSelector.svelte';

View file

@ -1,184 +0,0 @@
<script lang="ts">
interface Props {
onAdd: (name: string) => void;
}
let { onAdd }: Props = $props();
let isAdding = $state(false);
let newName = $state('');
function handleSubmit() {
if (newName.trim()) {
onAdd(newName.trim());
newName = '';
isAdding = false;
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleSubmit();
} else if (event.key === 'Escape') {
newName = '';
isAdding = false;
}
}
</script>
<div class="add-column min-w-[300px] max-w-[340px] h-fit">
{#if isAdding}
<div class="add-form p-4 animate-in fade-in slide-in-from-left-2 duration-200">
<div class="flex items-center gap-2 mb-3">
<div class="w-3 h-3 rounded-full bg-muted-foreground"></div>
<span class="text-sm font-medium text-foreground">Neue Spalte</span>
</div>
<input
type="text"
bind:value={newName}
onkeydown={handleKeydown}
placeholder="Spaltenname eingeben..."
class="add-input w-full px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary transition-all"
autofocus
/>
<div class="flex gap-2 mt-3">
<button
class="flex-1 px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-all shadow-sm hover:shadow flex items-center justify-center gap-2"
onclick={handleSubmit}
>
<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="M12 4v16m8-8H4"
/>
</svg>
Hinzufügen
</button>
<button
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-full transition-colors"
onclick={() => {
newName = '';
isAdding = false;
}}
>
Abbrechen
</button>
</div>
</div>
{:else}
<button
class="add-button group w-full min-h-[250px] p-4 text-sm text-muted-foreground hover:text-foreground transition-all flex flex-col items-center justify-center gap-3"
onclick={() => (isAdding = true)}
>
<div
class="w-12 h-12 rounded-full bg-muted group-hover:bg-primary/10 flex items-center justify-center transition-colors"
>
<svg
class="w-6 h-6 group-hover:text-primary transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</div>
<span class="font-medium">Spalte hinzufügen</span>
</button>
{/if}
</div>
<style>
/* Glass-Pill styles for add form */
.add-form {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
:global(.dark) .add-form {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Input with glass style */
.add-input {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
color: #374151;
}
.add-input::placeholder {
color: #9ca3af;
}
:global(.dark) .add-input {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #f3f4f6;
}
:global(.dark) .add-input::placeholder {
color: #9ca3af;
}
/* Glass-Pill button to add column */
.add-button {
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 2px dashed rgba(0, 0, 0, 0.15);
border-radius: 1.5rem;
}
:global(.dark) .add-button {
background: rgba(255, 255, 255, 0.05);
border: 2px dashed rgba(255, 255, 255, 0.15);
}
.add-button:hover {
background: rgba(255, 255, 255, 0.5);
border-color: rgba(139, 92, 246, 0.5);
}
:global(.dark) .add-button:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(139, 92, 246, 0.5);
}
/* Animation utilities */
.animate-in {
animation: animateIn 0.2s ease-out;
}
.fade-in {
--tw-enter-opacity: 0;
}
.slide-in-from-left-2 {
--tw-enter-translate-x: -0.5rem;
}
@keyframes animateIn {
from {
opacity: var(--tw-enter-opacity, 1);
transform: translateX(var(--tw-enter-translate-x, 0));
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>

View file

@ -1,243 +0,0 @@
<script lang="ts">
import type { KanbanBoard } from '@todo/shared';
interface Props {
boards: KanbanBoard[];
currentBoardId: string | null;
loading?: boolean;
position?: 'top' | 'bottom';
onSelectBoard: (boardId: string) => void;
onCreateBoard: () => void;
}
let {
boards,
currentBoardId,
loading = false,
position = 'top',
onSelectBoard,
onCreateBoard,
}: Props = $props();
</script>
<div class="board-nav-container" class:position-bottom={position === 'bottom'}>
<div class="board-nav">
<!-- Create Board Button -->
<button
type="button"
class="board-pill create-pill"
onclick={onCreateBoard}
title="Neues Board erstellen"
disabled={loading}
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<!-- Board Pills -->
<div class="board-pills-scroll">
{#each boards as board (board.id)}
<button
type="button"
class="board-pill"
class:active={currentBoardId === board.id}
onclick={() => onSelectBoard(board.id)}
disabled={loading}
style="--board-color: {board.color}"
>
{#if board.isGlobal}
<svg
class="h-4 w-4 mr-1.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else if board.icon}
<span class="mr-1.5">{board.icon}</span>
{:else}
<span class="board-color-dot mr-1.5" style="background-color: {board.color}"></span>
{/if}
<span class="board-name">{board.name}</span>
</button>
{/each}
</div>
</div>
</div>
<style>
.board-nav-container {
padding: 0 1rem;
margin-bottom: 0.75rem;
}
/* Bottom position styles */
.board-nav-container.position-bottom {
position: fixed;
bottom: 70px; /* Above PillNav */
left: 0;
right: 0;
margin-bottom: 0;
padding: 0.5rem 1rem;
z-index: 40;
background: linear-gradient(to top, var(--background) 0%, transparent 100%);
padding-bottom: 0.75rem;
}
@media (min-width: 640px) {
.board-nav-container {
padding: 0 1.5rem;
}
.board-nav-container.position-bottom {
padding: 0.5rem 1.5rem;
padding-bottom: 0.75rem;
}
}
@media (min-width: 1024px) {
.board-nav-container {
padding: 0 2rem;
}
.board-nav-container.position-bottom {
padding: 0.5rem 2rem;
padding-bottom: 0.75rem;
}
}
.board-nav {
display: flex;
align-items: center;
gap: 0.5rem;
/* Glass-Pill container */
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 9999px;
padding: 0.375rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.07),
0 2px 4px -1px rgba(0, 0, 0, 0.04);
}
:global(.dark) .board-nav {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.position-bottom .board-nav {
box-shadow:
0 -4px 6px -1px rgba(0, 0, 0, 0.07),
0 -2px 4px -1px rgba(0, 0, 0, 0.04),
0 4px 6px -1px rgba(0, 0, 0, 0.07),
0 2px 4px -1px rgba(0, 0, 0, 0.04);
}
.board-pills-scroll {
display: flex;
align-items: center;
gap: 0.25rem;
overflow-x: auto;
scroll-behavior: smooth;
-ms-overflow-style: none;
scrollbar-width: none;
}
.board-pills-scroll::-webkit-scrollbar {
display: none;
}
.board-pill {
display: flex;
align-items: center;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
}
.board-pill:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .board-pill {
color: #9ca3af;
}
:global(.dark) .board-pill:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
color: #f3f4f6;
}
.board-pill.active {
background: var(--board-color, #8b5cf6);
color: white;
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.15),
0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
.board-pill.active:hover:not(:disabled) {
filter: brightness(1.1);
color: white;
}
:global(.dark) .board-pill.active {
background: var(--board-color, #8b5cf6);
color: white;
}
.board-pill:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.create-pill {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
padding: 0.5rem;
}
.create-pill:hover:not(:disabled) {
background: rgba(139, 92, 246, 0.2);
color: #7c3aed;
}
:global(.dark) .create-pill {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
}
:global(.dark) .create-pill:hover:not(:disabled) {
background: rgba(139, 92, 246, 0.3);
color: #c4b5fd;
}
.board-color-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.board-name {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -1,254 +0,0 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { KanbanColumn, Task, TaskPriority } from '@todo/shared';
import { ConfirmationModal } from '@manacore/shared-ui';
import KanbanColumnComponent from './KanbanColumn.svelte';
import AddColumnButton from './AddColumnButton.svelte';
import { kanbanStore } from '$lib/stores/kanban.svelte';
import { KanbanBoardSkeleton } from '$lib/components/skeletons';
interface Props {
projectId?: string;
filterPriorities?: TaskPriority[];
filterProjectId?: string | null;
filterLabelIds?: string[];
filterSearchQuery?: string;
}
let {
projectId,
filterPriorities = [],
filterProjectId = null,
filterLabelIds = [],
filterSearchQuery = '',
}: Props = $props();
// Local columns state for drag and drop
let localColumns = $state<KanbanColumn[]>([]);
let showDeleteConfirm = $state(false);
let columnToDelete = $state<string | null>(null);
// Sync with store
$effect(() => {
localColumns = [...kanbanStore.columns];
});
const flipDurationMs = 200;
function handleColumnDndConsider(e: CustomEvent<{ items: KanbanColumn[] }>) {
localColumns = e.detail.items;
}
function handleColumnDndFinalize(e: CustomEvent<{ items: KanbanColumn[] }>) {
localColumns = e.detail.items.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID);
const columnIds = localColumns.map((c) => c.id);
kanbanStore.reorderColumns(columnIds);
}
async function handleAddColumn(name: string) {
const boardId = kanbanStore.currentBoardId;
if (!boardId) {
console.error('No board selected');
return;
}
await kanbanStore.createColumn({ name, boardId });
}
async function handleUpdateColumn(columnId: string, data: { name?: string; color?: string }) {
await kanbanStore.updateColumn(columnId, data);
}
function handleDeleteColumn(columnId: string) {
columnToDelete = columnId;
showDeleteConfirm = true;
}
async function confirmDeleteColumn() {
if (columnToDelete) {
await kanbanStore.deleteColumn(columnToDelete);
}
showDeleteConfirm = false;
columnToDelete = null;
}
async function handleTasksReorder(columnId: string, taskIds: string[]) {
await kanbanStore.reorderTasksInColumn(columnId, taskIds);
}
async function handleAddTask(columnId: string, title: string) {
// Get projectId from current board if available
const currentBoard = kanbanStore.currentBoard;
const taskProjectId = currentBoard?.projectId ?? projectId;
await kanbanStore.createTaskInColumn(columnId, title, taskProjectId ?? undefined);
}
async function handleTaskMove(taskId: string, toColumnId: string, order: number) {
// Find which column the task is currently in
let fromColumnId: string | null = null;
for (const [colId, tasks] of Object.entries(kanbanStore.tasksByColumn)) {
if (tasks.some((t) => t.id === taskId)) {
fromColumnId = colId;
break;
}
}
if (fromColumnId && fromColumnId !== toColumnId) {
await kanbanStore.moveTaskToColumn(taskId, fromColumnId, toColumnId, order);
}
}
function getTasksForColumn(columnId: string): Task[] {
let tasks = kanbanStore.tasksByColumn[columnId] || [];
// Apply filters
if (filterPriorities.length > 0) {
tasks = tasks.filter((t) => filterPriorities.includes(t.priority));
}
if (filterProjectId !== null) {
tasks = tasks.filter((t) => t.projectId === filterProjectId);
}
if (filterLabelIds.length > 0) {
tasks = tasks.filter((t) => t.labels?.some((l) => filterLabelIds.includes(l.id)));
}
if (filterSearchQuery.trim()) {
const query = filterSearchQuery.toLowerCase().trim();
tasks = tasks.filter(
(t) =>
t.title.toLowerCase().includes(query) ||
(t.description && t.description.toLowerCase().includes(query))
);
}
return tasks;
}
</script>
<div class="kanban-board h-full">
{#if kanbanStore.loading}
<KanbanBoardSkeleton />
{:else if kanbanStore.error}
<div
class="bg-destructive/10 text-destructive p-4 rounded-xl border border-destructive/20 flex items-center gap-3"
>
<svg class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{kanbanStore.error}</span>
</div>
{:else}
<div
class="columns-container flex gap-4 overflow-x-auto pb-4 h-full"
use:dndzone={{
items: localColumns,
flipDurationMs,
type: 'columns',
dropTargetStyle: {},
}}
onconsider={handleColumnDndConsider}
onfinalize={handleColumnDndFinalize}
>
{#each localColumns.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as column (column.id)}
<div class="flex-shrink-0">
<KanbanColumnComponent
{column}
tasks={getTasksForColumn(column.id)}
onUpdateColumn={(data) => handleUpdateColumn(column.id, data)}
onDeleteColumn={() => handleDeleteColumn(column.id)}
onTasksReorder={(taskIds) => handleTasksReorder(column.id, taskIds)}
onTaskMove={(taskId, toColumnId, order) => handleTaskMove(taskId, toColumnId, order)}
onAddTask={(title) => handleAddTask(column.id, title)}
/>
</div>
{/each}
<!-- Add column button -->
<div class="flex-shrink-0">
<AddColumnButton onAdd={handleAddColumn} />
</div>
</div>
{/if}
</div>
<!-- Delete column confirmation modal -->
<ConfirmationModal
visible={showDeleteConfirm}
onClose={() => {
showDeleteConfirm = false;
columnToDelete = null;
}}
onConfirm={confirmDeleteColumn}
variant="danger"
title="Spalte löschen?"
message="Alle Aufgaben dieser Spalte werden in die erste Spalte verschoben."
confirmLabel="Löschen"
cancelLabel="Abbrechen"
/>
<style>
.kanban-board {
min-height: 400px;
}
.columns-container {
min-height: 100%;
align-items: flex-start;
scroll-behavior: smooth;
padding-left: 1rem;
padding-right: 1rem;
}
/* Extra space after last column for better scroll experience */
.columns-container::after {
content: '';
flex-shrink: 0;
width: 1rem;
}
@media (min-width: 640px) {
.columns-container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.columns-container::after {
width: 1.5rem;
}
}
@media (min-width: 1024px) {
.columns-container {
padding-left: 2rem;
padding-right: 2rem;
}
.columns-container::after {
width: 2rem;
}
}
/* Styled scrollbar */
.columns-container::-webkit-scrollbar {
height: 10px;
}
.columns-container::-webkit-scrollbar-track {
background: var(--muted);
border-radius: 5px;
}
.columns-container::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 5px;
border: 2px solid var(--muted);
}
.columns-container::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
</style>

View file

@ -1,255 +0,0 @@
<script lang="ts">
import type { KanbanColumn } from '@todo/shared';
interface Props {
column: KanbanColumn;
taskCount: number;
onUpdate?: (data: { name?: string; color?: string }) => void;
onDelete?: () => void;
}
let { column, taskCount, onUpdate, onDelete }: Props = $props();
let isEditing = $state(false);
let editName = $state(column.name);
let showMenu = $state(false);
let showColorPicker = $state(false);
const colors = [
'#6B7280', // gray
'#EF4444', // red
'#F97316', // orange
'#EAB308', // yellow
'#22C55E', // green
'#14B8A6', // teal
'#3B82F6', // blue
'#8B5CF6', // purple
'#EC4899', // pink
];
function handleSubmit() {
if (editName.trim() && editName !== column.name) {
onUpdate?.({ name: editName.trim() });
}
isEditing = false;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleSubmit();
} else if (event.key === 'Escape') {
editName = column.name;
isEditing = false;
}
}
function handleColorSelect(color: string) {
onUpdate?.({ color });
showColorPicker = false;
showMenu = false;
}
</script>
<div class="column-header flex items-center justify-between px-3.5 py-3">
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<!-- Color indicator with glow -->
<div
class="w-3 h-3 rounded-full flex-shrink-0 ring-4 ring-opacity-20"
style="background-color: {column.color}; --tw-ring-color: {column.color}"
></div>
<!-- Name (editable) -->
{#if isEditing}
<input
type="text"
bind:value={editName}
onblur={handleSubmit}
onkeydown={handleKeydown}
class="text-sm font-semibold bg-transparent border-b-2 border-primary outline-none text-foreground flex-1 min-w-0 py-0.5"
autofocus
/>
{:else}
<button
class="text-sm font-semibold text-foreground truncate text-left hover:text-primary transition-colors"
ondblclick={() => {
if (!column.isDefault || onUpdate) {
isEditing = true;
}
}}
>
{column.name}
</button>
{/if}
<!-- Task count badge -->
<span
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0 transition-colors"
style="background-color: color-mix(in srgb, {column.color} 15%, transparent); color: {column.color}"
>
{taskCount}
</span>
</div>
<!-- Menu button -->
{#if onUpdate || onDelete}
<div class="relative">
<button
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-all"
onclick={() => (showMenu = !showMenu)}
>
<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="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
{#if showMenu}
<div
class="menu-popup absolute right-0 top-full mt-1 rounded-xl py-1.5 z-50 min-w-[160px] animate-in fade-in slide-in-from-top-2 duration-150"
>
{#if onUpdate}
<button
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-muted rounded-lg mx-1 transition-colors flex items-center gap-2"
style="width: calc(100% - 0.5rem)"
onclick={() => {
isEditing = true;
showMenu = false;
}}
>
<svg
class="w-4 h-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Umbenennen
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-muted rounded-lg mx-1 transition-colors flex items-center gap-2"
style="width: calc(100% - 0.5rem)"
onclick={() => (showColorPicker = !showColorPicker)}
>
<svg
class="w-4 h-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
Farbe ändern
</button>
{#if showColorPicker}
<div class="px-3 py-2.5 flex flex-wrap gap-1.5 border-t border-border mt-1.5 pt-2.5">
{#each colors as color}
<button
class="w-7 h-7 rounded-full border-2 transition-all hover:scale-110 hover:shadow-md {color ===
column.color
? 'border-primary ring-2 ring-primary/30'
: 'border-transparent'}"
style="background-color: {color}"
onclick={() => handleColorSelect(color)}
></button>
{/each}
</div>
{/if}
{/if}
{#if onDelete && !column.isDefault}
<div class="border-t border-border mt-1.5 pt-1.5">
<button
class="w-full px-3 py-2 text-left text-sm text-destructive hover:bg-destructive/10 rounded-lg mx-1 transition-colors flex items-center gap-2"
style="width: calc(100% - 0.5rem)"
onclick={() => {
onDelete?.();
showMenu = false;
}}
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Löschen
</button>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Click outside to close menu -->
{#if showMenu}
<button
class="fixed inset-0 z-40"
onclick={() => {
showMenu = false;
showColorPicker = false;
}}
></button>
{/if}
<style>
/* Glass popup effect */
.menu-popup {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
-webkit-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),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .menu-popup {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
/* Animation utilities */
.animate-in {
animation: animateIn 0.15s ease-out;
}
.fade-in {
--tw-enter-opacity: 0;
}
.slide-in-from-top-2 {
--tw-enter-translate-y: -0.5rem;
}
@keyframes animateIn {
from {
opacity: var(--tw-enter-opacity, 1);
transform: translateY(var(--tw-enter-translate-y, 0));
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -1,6 +1,2 @@
export { default as KanbanBoard } from './KanbanBoard.svelte';
export { default as KanbanColumn } from './KanbanColumn.svelte';
export { default as KanbanColumnHeader } from './KanbanColumnHeader.svelte';
export { default as KanbanTaskCard } from './KanbanTaskCard.svelte';
export { default as AddColumnButton } from './AddColumnButton.svelte';
export { default as BoardNavigation } from './BoardNavigation.svelte';
export { default as QuickAddTaskInline } from './QuickAddTaskInline.svelte';

View file

@ -5,7 +5,7 @@
* They serve as onboarding content that teaches the user how the app works.
*/
import type { LocalTask, LocalProject, LocalLabel } from './local-store';
import type { LocalTask, LocalProject, LocalLabel, LocalBoardView } from './local-store';
const ONBOARDING_PROJECT_ID = 'onboarding-project';
const PERSONAL_PROJECT_ID = 'personal-project';
@ -44,6 +44,168 @@ export const guestLabels: LocalLabel[] = [
},
];
// ─── Board Views ────────────────────────────────────────────
export const guestBoardViews: LocalBoardView[] = [
{
id: 'view-kanban',
name: 'Kanban',
icon: 'columns',
groupBy: 'status',
layout: 'kanban',
order: 0,
columns: [
{
id: 'col-todo',
name: 'To Do',
color: '#6B7280',
match: { type: 'status', value: 'pending' },
onDrop: { setCompleted: false },
},
{
id: 'col-done',
name: 'Erledigt',
color: '#22C55E',
match: { type: 'status', value: 'completed' },
onDrop: { setCompleted: true },
},
],
},
{
id: 'view-eisenhower',
name: 'Eisenhower',
icon: 'grid-four',
groupBy: 'custom',
layout: 'grid',
order: 1,
columns: [
{
id: 'col-eis-ui',
name: 'Wichtig & Dringend',
color: '#EF4444',
match: { type: 'custom', value: 'urgent-important' },
onDrop: { setPriority: 'urgent' },
},
{
id: 'col-eis-i',
name: 'Wichtig',
color: '#F59E0B',
match: { type: 'custom', value: 'important' },
onDrop: { setPriority: 'high' },
},
{
id: 'col-eis-u',
name: 'Dringend',
color: '#3B82F6',
match: { type: 'custom', value: 'urgent' },
onDrop: { setPriority: 'medium' },
},
{
id: 'col-eis-ni',
name: 'Weder noch',
color: '#6B7280',
match: { type: 'custom', value: 'neither' },
onDrop: { setPriority: 'low' },
},
],
},
{
id: 'view-priority',
name: 'Priorität',
icon: 'flag',
groupBy: 'priority',
layout: 'kanban',
order: 2,
columns: [
{
id: 'col-pri-urgent',
name: 'Dringend',
color: '#EF4444',
match: { type: 'priority', value: 'urgent' },
onDrop: { setPriority: 'urgent' },
},
{
id: 'col-pri-high',
name: 'Hoch',
color: '#F59E0B',
match: { type: 'priority', value: 'high' },
onDrop: { setPriority: 'high' },
},
{
id: 'col-pri-medium',
name: 'Mittel',
color: '#3B82F6',
match: { type: 'priority', value: 'medium' },
onDrop: { setPriority: 'medium' },
},
{
id: 'col-pri-low',
name: 'Niedrig',
color: '#6B7280',
match: { type: 'priority', value: 'low' },
onDrop: { setPriority: 'low' },
},
],
},
{
id: 'view-project',
name: 'Projekte',
icon: 'folders',
groupBy: 'project',
layout: 'kanban',
order: 3,
columns: [], // dynamically generated from projects
},
{
id: 'view-due',
name: 'Fälligkeit',
icon: 'calendar',
groupBy: 'dueDate',
layout: 'kanban',
order: 4,
columns: [
{
id: 'col-due-overdue',
name: 'Überfällig',
color: '#EF4444',
match: { type: 'dueDate', value: 'overdue' },
},
{
id: 'col-due-today',
name: 'Heute',
color: '#F59E0B',
match: { type: 'dueDate', value: 'today' },
},
{
id: 'col-due-tomorrow',
name: 'Morgen',
color: '#3B82F6',
match: { type: 'dueDate', value: 'tomorrow' },
},
{
id: 'col-due-week',
name: 'Diese Woche',
color: '#8B5CF6',
match: { type: 'dueDate', value: 'week' },
},
{
id: 'col-due-later',
name: 'Später',
color: '#6B7280',
match: { type: 'dueDate', value: 'later' },
},
{
id: 'col-due-none',
name: 'Ohne Datum',
color: '#9CA3AF',
match: { type: 'dueDate', value: 'none' },
},
],
},
];
// ─── Task Seed Data ─────────────────────────────────────────
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
@ -85,7 +247,7 @@ export const guestTasks: LocalTask[] = [
},
{
id: 'onboard-4',
title: 'Wechsle zur Kanban-Ansicht über die Navigation',
title: 'Wechsle zur Board-Ansicht über die Navigation',
projectId: ONBOARDING_PROJECT_ID,
priority: 'low',
isCompleted: false,

View file

@ -7,7 +7,7 @@
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import type { Subtask as SharedSubtask } from '@todo/shared';
import { guestProjects, guestTasks, guestLabels } from './guest-seed.js';
import { guestProjects, guestTasks, guestLabels, guestBoardViews } from './guest-seed.js';
// ─── Types ──────────────────────────────────────────────────
@ -60,6 +60,45 @@ export interface LocalReminder extends BaseRecord {
status: 'pending' | 'sent' | 'failed';
}
// ─── Board Views ────────────────────────────────────────────
export interface TaskMatcher {
type: 'status' | 'priority' | 'project' | 'tag' | 'dueDate' | 'custom';
value?: string | null;
/** For 'custom' groupBy: manually assigned task IDs */
taskIds?: string[];
}
export interface DropAction {
setCompleted?: boolean;
setPriority?: 'low' | 'medium' | 'high' | 'urgent';
setProjectId?: string | null;
}
export interface ViewColumn {
id: string;
name: string;
color: string;
match: TaskMatcher;
onDrop?: DropAction;
}
export interface ViewFilter {
projectId?: string;
tagIds?: string[];
priorities?: string[];
}
export interface LocalBoardView extends BaseRecord {
name: string;
icon: string;
groupBy: 'status' | 'priority' | 'project' | 'dueDate' | 'tag' | 'custom';
columns: ViewColumn[];
filter?: ViewFilter;
layout: 'kanban' | 'grid';
order: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
@ -98,6 +137,11 @@ export const todoStore = createLocalStore({
name: 'reminders',
indexes: ['taskId'],
},
{
name: 'boardViews',
indexes: ['order', 'groupBy'],
guestSeed: guestBoardViews,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
@ -110,3 +154,4 @@ export const projectCollection = todoStore.collection<LocalProject>('projects');
export const labelCollection = todoStore.collection<LocalLabel>('labels');
export const taskLabelCollection = todoStore.collection<LocalTaskLabel>('taskLabels');
export const reminderCollection = todoStore.collection<LocalReminder>('reminders');
export const boardViewCollection = todoStore.collection<LocalBoardView>('boardViews');

View file

@ -10,8 +10,10 @@ import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
taskCollection,
projectCollection,
boardViewCollection,
type LocalTask,
type LocalProject,
type LocalBoardView,
} from './local-store';
import type { Task, Project } from '@todo/shared';
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
@ -81,6 +83,17 @@ export function useAllProjects() {
}, [] as Project[]);
}
/** All board views, sorted by order. Auto-updates on any change. */
export function useAllBoardViews() {
return useLiveQueryWithDefault(async () => {
const locals = await boardViewCollection.getAll(undefined, {
sortBy: 'order',
sortDirection: 'asc',
});
return locals;
}, [] as LocalBoardView[]);
}
// ─── Pure Filter Functions (for $derived) ──────────────────
export function filterIncomplete(tasks: Task[]): Task[] {

View file

@ -0,0 +1,268 @@
/**
* View Grouping Engine Pure Functions
*
* Groups tasks into columns based on a BoardView configuration.
* No side effects, no store dependencies easy to test.
*/
import type { Task, Project } from '@todo/shared';
import type { LocalBoardView, ViewColumn, DropAction } from './local-store';
import { isToday, isPast, isTomorrow, startOfDay, addDays, isFuture } from 'date-fns';
// ─── Output Type ───────────────────────────────────────────
export interface GroupedColumn {
id: string;
name: string;
color: string;
tasks: Task[];
onDrop?: DropAction;
}
// ─── Main Grouping Function ────────────────────────────────
export function groupTasksByView(
view: LocalBoardView,
tasks: Task[],
projects: Project[]
): GroupedColumn[] {
// Only group incomplete tasks (unless status view includes completed)
const activeTasks = view.groupBy === 'status' ? tasks : tasks.filter((t) => !t.isCompleted);
// Apply view-level filter
const filtered = applyViewFilter(activeTasks, view.filter);
switch (view.groupBy) {
case 'status':
return groupByStatus(filtered, view.columns);
case 'priority':
return groupByPriority(filtered, view.columns);
case 'project':
return groupByProject(filtered, view.columns, projects);
case 'dueDate':
return groupByDueDate(filtered, view.columns);
case 'tag':
return groupByTag(filtered, view.columns);
case 'custom':
return groupByCustom(filtered, view);
default:
return groupByStatus(filtered, view.columns);
}
}
// ─── Group By Implementations ──────────────────────────────
function groupByStatus(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
return columns.map((col) => ({
id: col.id,
name: col.name,
color: col.color,
onDrop: col.onDrop,
tasks: tasks.filter((t) => {
if (col.match.value === 'completed') return t.isCompleted;
if (col.match.value === 'pending') return !t.isCompleted;
return false;
}),
}));
}
function groupByPriority(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
return columns.map((col) => ({
id: col.id,
name: col.name,
color: col.color,
onDrop: col.onDrop,
tasks: tasks.filter((t) => t.priority === col.match.value),
}));
}
function groupByProject(
tasks: Task[],
columns: ViewColumn[],
projects: Project[]
): GroupedColumn[] {
// Dynamic: generate columns from projects
if (columns.length === 0) {
const activeProjects = projects.filter((p) => !p.isArchived);
const dynamicColumns: GroupedColumn[] = [
{
id: 'col-inbox',
name: 'Inbox',
color: '#6B7280',
tasks: tasks.filter((t) => !t.projectId),
onDrop: { setProjectId: null },
},
...activeProjects.map((p) => ({
id: `col-proj-${p.id}`,
name: p.name,
color: p.color,
tasks: tasks.filter((t) => t.projectId === p.id),
onDrop: { setProjectId: p.id } as DropAction,
})),
];
return dynamicColumns;
}
// Static columns from config
return columns.map((col) => ({
id: col.id,
name: col.name,
color: col.color,
onDrop: col.onDrop,
tasks: tasks.filter((t) =>
col.match.value === null ? !t.projectId : t.projectId === col.match.value
),
}));
}
function groupByDueDate(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
const today = startOfDay(new Date());
const tomorrowDate = addDays(today, 1);
const weekEnd = addDays(today, 7);
return columns.map((col) => ({
id: col.id,
name: col.name,
color: col.color,
onDrop: col.onDrop,
tasks: tasks.filter((t) => {
if (!t.dueDate) return col.match.value === 'none';
const d = new Date(t.dueDate);
const dayStart = startOfDay(d);
switch (col.match.value) {
case 'overdue':
return isPast(dayStart) && !isToday(d);
case 'today':
return isToday(d);
case 'tomorrow':
return isTomorrow(d);
case 'week':
return isFuture(d) && !isTomorrow(d) && d <= weekEnd;
case 'later':
return d > weekEnd;
default:
return false;
}
}),
}));
}
function groupByTag(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
return columns.map((col) => ({
id: col.id,
name: col.name,
color: col.color,
onDrop: col.onDrop,
tasks: tasks.filter(
(t) => t.labels?.some((l) => l.id === col.match.value) ?? false
),
}));
}
function groupByCustom(tasks: Task[], view: LocalBoardView): GroupedColumn[] {
// Eisenhower matrix: priority + dueDate combination
if (view.id === 'view-eisenhower') {
return groupEisenhower(tasks, view.columns);
}
// Generic custom: use taskIds per column
const assigned = new Set<string>();
const result = view.columns.map((col) => {
const colTaskIds = new Set(col.match.taskIds ?? []);
const colTasks = tasks.filter((t) => {
if (colTaskIds.has(t.id)) {
assigned.add(t.id);
return true;
}
return false;
});
return {
id: col.id,
name: col.name,
color: col.color,
onDrop: col.onDrop,
tasks: colTasks,
};
});
// Unassigned tasks go to last column
const unassigned = tasks.filter((t) => !assigned.has(t.id));
if (unassigned.length > 0 && result.length > 0) {
result[result.length - 1].tasks = [...result[result.length - 1].tasks, ...unassigned];
}
return result;
}
// ─── Eisenhower Matrix ─────────────────────────────────────
function groupEisenhower(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
const today = startOfDay(new Date());
const soonThreshold = addDays(today, 3);
function isImportant(t: Task): boolean {
return t.priority === 'urgent' || t.priority === 'high';
}
function isUrgent(t: Task): boolean {
if (!t.dueDate) return false;
const d = new Date(t.dueDate);
return isPast(startOfDay(d)) || d <= soonThreshold;
}
const buckets: Record<string, Task[]> = {
'urgent-important': [],
important: [],
urgent: [],
neither: [],
};
for (const t of tasks.filter((t) => !t.isCompleted)) {
const imp = isImportant(t);
const urg = isUrgent(t);
if (imp && urg) buckets['urgent-important'].push(t);
else if (imp) buckets['important'].push(t);
else if (urg) buckets['urgent'].push(t);
else buckets['neither'].push(t);
}
return columns.map((col) => ({
id: col.id,
name: col.name,
color: col.color,
onDrop: col.onDrop,
tasks: buckets[col.match.value ?? ''] ?? [],
}));
}
// ─── Helpers ───────────────────────────────────────────────
function applyViewFilter(tasks: Task[], filter?: { projectId?: string; tagIds?: string[]; priorities?: string[] }): Task[] {
if (!filter) return tasks;
let result = tasks;
if (filter.projectId) {
result = result.filter((t) => t.projectId === filter.projectId);
}
if (filter.priorities && filter.priorities.length > 0) {
result = result.filter((t) => filter.priorities!.includes(t.priority));
}
if (filter.tagIds && filter.tagIds.length > 0) {
result = result.filter((t) => t.labels?.some((l) => filter.tagIds!.includes(l.id)));
}
return result;
}
/**
* Apply a column's drop action to a task returns the update payload.
*/
export function getDropActionUpdate(action: DropAction): Record<string, unknown> {
const update: Record<string, unknown> = {};
if (action.setCompleted !== undefined) {
update.isCompleted = action.setCompleted;
update.completedAt = action.setCompleted ? new Date().toISOString() : null;
}
if (action.setPriority) update.priority = action.setPriority;
if (action.setProjectId !== undefined) update.projectId = action.setProjectId;
return update;
}

View file

@ -0,0 +1,84 @@
/**
* Board Views Store Mutation-Only Service
*
* Reads via useLiveQuery (useAllBoardViews in task-queries.ts).
* This store only handles create, update, delete, reorder.
*/
import { boardViewCollection, type LocalBoardView, type ViewColumn } from '$lib/data/local-store';
let error = $state<string | null>(null);
export const boardViewsStore = {
get error() {
return error;
},
async createView(data: Omit<LocalBoardView, 'id'>) {
error = null;
try {
const count = await boardViewCollection.count();
const newView: LocalBoardView = {
...data,
id: crypto.randomUUID(),
order: data.order ?? count,
};
return await boardViewCollection.insert(newView);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create view';
throw e;
}
},
async updateView(id: string, data: Partial<LocalBoardView>) {
error = null;
try {
return await boardViewCollection.update(id, data as Partial<LocalBoardView>);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update view';
throw e;
}
},
async deleteView(id: string) {
error = null;
try {
await boardViewCollection.delete(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete view';
throw e;
}
},
async reorderViews(viewIds: string[]) {
error = null;
try {
for (let i = 0; i < viewIds.length; i++) {
await boardViewCollection.update(viewIds[i], { order: i } as Partial<LocalBoardView>);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder views';
}
},
/** Update a column's taskIds (for custom groupBy with manual task assignment) */
async updateColumnTaskIds(viewId: string, columnId: string, taskIds: string[]) {
error = null;
try {
const view = await boardViewCollection.get(viewId);
if (!view) return;
const updatedColumns = view.columns.map((col: ViewColumn) =>
col.id === columnId
? { ...col, match: { ...col.match, taskIds } }
: col
);
await boardViewCollection.update(viewId, {
columns: updatedColumns,
} as Partial<LocalBoardView>);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update column';
throw e;
}
},
};

View file

@ -1,465 +0,0 @@
/**
* Kanban Store - Manages kanban boards, columns, and tasks using Svelte 5 runes
*/
import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared';
import * as kanbanApi from '$lib/api/kanban';
import * as tasksApi from '$lib/api/tasks';
// Board state
let boards = $state<KanbanBoard[]>([]);
let currentBoardId = $state<string | null>(null);
// Column & Task state
let columns = $state<KanbanColumn[]>([]);
let tasksByColumn = $state<Record<string, Task[]>>({});
// Loading & Error state
let loading = $state(false);
let boardsLoading = $state(false);
let error = $state<string | null>(null);
export const kanbanStore = {
// =====================
// Board Getters
// =====================
get boards() {
return boards;
},
get currentBoardId() {
return currentBoardId;
},
get currentBoard() {
return boards.find((b) => b.id === currentBoardId) ?? null;
},
get globalBoard() {
return boards.find((b) => b.isGlobal) ?? null;
},
// =====================
// Column & Task Getters
// =====================
get columns() {
return columns;
},
get tasksByColumn() {
return tasksByColumn;
},
get loading() {
return loading;
},
get boardsLoading() {
return boardsLoading;
},
get error() {
return error;
},
// =====================
// Board Operations
// =====================
/**
* Fetch all boards for the current user
*/
async fetchBoards() {
boardsLoading = true;
error = null;
try {
boards = await kanbanApi.getBoards();
// If no current board selected, select global board or first board
if (!currentBoardId && boards.length > 0) {
const globalBoard = boards.find((b) => b.isGlobal);
currentBoardId = globalBoard?.id ?? boards[0].id;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch boards';
console.error('Failed to fetch boards:', e);
} finally {
boardsLoading = false;
}
},
/**
* Get or create the global board
*/
async getOrCreateGlobalBoard() {
error = null;
try {
const globalBoard = await kanbanApi.getGlobalBoard();
// Update or add to boards list
const existingIndex = boards.findIndex((b) => b.id === globalBoard.id);
if (existingIndex >= 0) {
boards[existingIndex] = globalBoard;
} else {
boards = [globalBoard, ...boards];
}
return globalBoard;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to get global board';
console.error('Failed to get global board:', e);
throw e;
}
},
/**
* Select a board and load its data
*/
async selectBoard(boardId: string) {
if (currentBoardId === boardId) return;
currentBoardId = boardId;
await this.fetchKanbanData(boardId);
},
/**
* Create a new board
*/
async createBoard(data: { name: string; projectId?: string; color?: string; icon?: string }) {
error = null;
try {
const newBoard = await kanbanApi.createBoard(data);
boards = [...boards, newBoard];
return newBoard;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create board';
console.error('Failed to create board:', e);
throw e;
}
},
/**
* Update a board
*/
async updateBoard(id: string, data: { name?: string; color?: string; icon?: string }) {
error = null;
try {
const updated = await kanbanApi.updateBoard(id, data);
boards = boards.map((b) => (b.id === id ? updated : b));
return updated;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update board';
console.error('Failed to update board:', e);
throw e;
}
},
/**
* Delete a board
*/
async deleteBoard(id: string) {
error = null;
try {
await kanbanApi.deleteBoard(id);
boards = boards.filter((b) => b.id !== id);
// If deleted board was current, switch to global board
if (currentBoardId === id) {
const globalBoard = boards.find((b) => b.isGlobal);
currentBoardId = globalBoard?.id ?? boards[0]?.id ?? null;
if (currentBoardId) {
await this.fetchKanbanData(currentBoardId);
}
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete board';
console.error('Failed to delete board:', e);
throw e;
}
},
/**
* Reorder boards (optimistic update)
*/
async reorderBoards(boardIds: string[]) {
error = null;
const previousBoards = [...boards];
try {
// Optimistic update
boards = boardIds
.map((id) => boards.find((b) => b.id === id))
.filter((b): b is KanbanBoard => b !== undefined);
// Persist to server
const updated = await kanbanApi.reorderBoards(boardIds);
boards = updated;
} catch (e) {
// Rollback on error
boards = previousBoards;
error = e instanceof Error ? e.message : 'Failed to reorder boards';
console.error('Failed to reorder boards:', e);
throw e;
}
},
// =====================
// Column & Task Operations
// =====================
/**
* Fetch columns and tasks grouped by column for a board
*/
async fetchKanbanData(boardId: string) {
loading = true;
error = null;
try {
const data = await kanbanApi.getKanbanTasks(boardId);
columns = data.columns;
tasksByColumn = data.tasksByColumn;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch kanban data';
console.error('Failed to fetch kanban data:', e);
} finally {
loading = false;
}
},
/**
* Fetch only columns for a board
*/
async fetchColumns(boardId: string) {
loading = true;
error = null;
try {
columns = await kanbanApi.getColumns(boardId);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch columns';
console.error('Failed to fetch columns:', e);
} finally {
loading = false;
}
},
/**
* Create a new column
*/
async createColumn(data: {
name: string;
boardId: string;
color?: string;
defaultStatus?: string;
autoComplete?: boolean;
}) {
error = null;
try {
const newColumn = await kanbanApi.createColumn(data);
columns = [...columns, newColumn];
tasksByColumn[newColumn.id] = [];
return newColumn;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create column';
console.error('Failed to create column:', e);
throw e;
}
},
/**
* Update a column
*/
async updateColumn(
id: string,
data: {
name?: string;
color?: string;
defaultStatus?: string;
autoComplete?: boolean;
}
) {
error = null;
try {
const updated = await kanbanApi.updateColumn(id, data);
columns = columns.map((c) => (c.id === id ? updated : c));
return updated;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update column';
console.error('Failed to update column:', e);
throw e;
}
},
/**
* Delete a column
*/
async deleteColumn(id: string) {
error = null;
try {
await kanbanApi.deleteColumn(id);
columns = columns.filter((c) => c.id !== id);
delete tasksByColumn[id];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete column';
console.error('Failed to delete column:', e);
throw e;
}
},
/**
* Reorder columns (optimistic update)
*/
async reorderColumns(columnIds: string[]) {
error = null;
const previousColumns = [...columns];
try {
// Optimistic update
columns = columnIds
.map((id) => columns.find((c) => c.id === id))
.filter((c): c is KanbanColumn => c !== undefined);
// Persist to server
const updated = await kanbanApi.reorderColumns(columnIds);
columns = updated;
} catch (e) {
// Rollback on error
columns = previousColumns;
error = e instanceof Error ? e.message : 'Failed to reorder columns';
console.error('Failed to reorder columns:', e);
throw e;
}
},
/**
* Move task to a different column (optimistic update)
*/
async moveTaskToColumn(taskId: string, fromColumnId: string, toColumnId: string, order?: number) {
error = null;
const previousTasksByColumn = { ...tasksByColumn };
try {
// Find the task
const task = tasksByColumn[fromColumnId]?.find((t) => t.id === taskId);
if (!task) {
throw new Error('Task not found');
}
// Optimistic update
tasksByColumn[fromColumnId] = tasksByColumn[fromColumnId].filter((t) => t.id !== taskId);
if (!tasksByColumn[toColumnId]) {
tasksByColumn[toColumnId] = [];
}
const insertIndex = order ?? tasksByColumn[toColumnId].length;
const updatedTask = { ...task, columnId: toColumnId, columnOrder: insertIndex };
tasksByColumn[toColumnId] = [
...tasksByColumn[toColumnId].slice(0, insertIndex),
updatedTask,
...tasksByColumn[toColumnId].slice(insertIndex),
];
// Persist to server
await kanbanApi.moveTaskToColumn(taskId, toColumnId, order);
} catch (e) {
// Rollback on error
tasksByColumn = previousTasksByColumn;
error = e instanceof Error ? e.message : 'Failed to move task';
console.error('Failed to move task:', e);
throw e;
}
},
/**
* Reorder tasks within a column (optimistic update)
*/
async reorderTasksInColumn(columnId: string, taskIds: string[]) {
error = null;
const previousTasks = [...(tasksByColumn[columnId] || [])];
try {
// Optimistic update
const columnTasks = tasksByColumn[columnId] || [];
tasksByColumn[columnId] = taskIds
.map((id) => columnTasks.find((t) => t.id === id))
.filter((t): t is Task => t !== undefined);
// Persist to server
await kanbanApi.reorderTasksInColumn(columnId, taskIds);
} catch (e) {
// Rollback on error
tasksByColumn[columnId] = previousTasks;
error = e instanceof Error ? e.message : 'Failed to reorder tasks';
console.error('Failed to reorder tasks:', e);
throw e;
}
},
/**
* Initialize default columns if none exist
*/
async initializeDefaultColumns(boardId: string) {
error = null;
try {
const newColumns = await kanbanApi.initializeColumns(boardId);
columns = newColumns;
// Initialize empty task arrays for each column
for (const col of newColumns) {
if (!tasksByColumn[col.id]) {
tasksByColumn[col.id] = [];
}
}
return newColumns;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to initialize columns';
console.error('Failed to initialize columns:', e);
throw e;
}
},
/**
* Get tasks for a specific column
*/
getTasksForColumn(columnId: string): Task[] {
return tasksByColumn[columnId] || [];
},
/**
* Create a new task in a specific column
*/
async createTaskInColumn(columnId: string, title: string, projectId?: string) {
error = null;
try {
// Find the column to get its default status
const column = columns.find((c) => c.id === columnId);
// Create the task
const newTask = await tasksApi.createTask({
title,
projectId,
priority: 'medium',
});
// Move task to the column (this will set columnId and status)
const movedTask = await kanbanApi.moveTaskToColumn(newTask.id, columnId, 0);
// Add to local state at the beginning of the column
if (!tasksByColumn[columnId]) {
tasksByColumn[columnId] = [];
}
tasksByColumn[columnId] = [movedTask, ...tasksByColumn[columnId]];
return movedTask;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create task';
console.error('Failed to create task in column:', e);
throw e;
}
},
/**
* Clear all state (for logout)
*/
clear() {
boards = [];
currentBoardId = null;
columns = [];
tasksByColumn = {};
loading = false;
boardsLoading = false;
error = null;
},
};

View file

@ -1,10 +1,45 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { TaskPriority } from '@todo/shared';
import { kanbanStore } from '$lib/stores/kanban.svelte';
import { KanbanBoard, BoardNavigation } from '$lib/components/kanban';
import { useAllBoardViews } from '$lib/data/task-queries';
import { ViewSelector, BoardViewRenderer } from '$lib/components/board-views';
import TaskFilters from '$lib/components/TaskFilters.svelte';
// Live query for board views
const boardViews = useAllBoardViews();
// Active view — persisted in localStorage
const STORAGE_KEY = 'todo:activeViewId';
let activeViewId = $state<string | null>(null);
// Initialize from localStorage
onMount(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
activeViewId = stored;
}
});
// Auto-select first view when views load and nothing is selected
$effect(() => {
if (boardViews.value.length > 0 && !activeViewId) {
activeViewId = boardViews.value[0].id;
}
// If stored view no longer exists, fall back to first
if (activeViewId && boardViews.value.length > 0 && !boardViews.value.find((v) => v.id === activeViewId)) {
activeViewId = boardViews.value[0].id;
}
});
// Derive the active view object
let activeView = $derived(boardViews.value.find((v) => v.id === activeViewId) ?? null);
let boardTitle = $derived(activeView?.name ?? 'Board');
function handleSelectView(viewId: string) {
activeViewId = viewId;
localStorage.setItem(STORAGE_KEY, viewId);
}
// Filter state
let filterPriorities = $state<TaskPriority[]>([]);
let filterProjectId = $state<string | null>(null);
@ -12,53 +47,6 @@
let filterSearchQuery = $state('');
let showFilters = $state(false);
// Board creation state
let showCreateBoard = $state(false);
let newBoardName = $state('');
let newBoardColor = $state('#8b5cf6');
let isCreatingBoard = $state(false);
// Editable title state
let isEditingTitle = $state(false);
let editTitle = $state('');
// Responsive state - Mobile breakpoint at 768px
let isMobile = $state(false);
function checkMobile() {
isMobile = window.innerWidth < 768;
}
// Get current board from store
let currentBoard = $derived(kanbanStore.currentBoard);
let boardTitle = $derived(currentBoard?.name ?? 'Kanban Board');
function startEditingTitle() {
if (!currentBoard) return;
editTitle = currentBoard.name;
isEditingTitle = true;
}
async function saveTitle() {
if (!currentBoard || !editTitle.trim()) {
isEditingTitle = false;
return;
}
if (editTitle.trim() !== currentBoard.name) {
await kanbanStore.updateBoard(currentBoard.id, { name: editTitle.trim() });
}
isEditingTitle = false;
}
function handleTitleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
saveTitle();
} else if (event.key === 'Escape') {
isEditingTitle = false;
}
}
function clearFilters() {
filterPriorities = [];
filterProjectId = null;
@ -73,72 +61,16 @@
filterSearchQuery.trim() !== ''
);
// Board operations
async function handleSelectBoard(boardId: string) {
await kanbanStore.selectBoard(boardId);
// Responsive state
let isMobile = $state(false);
function checkMobile() {
isMobile = window.innerWidth < 768;
}
function openCreateBoard() {
newBoardName = '';
newBoardColor = '#8b5cf6';
showCreateBoard = true;
}
async function handleCreateBoard() {
if (!newBoardName.trim()) return;
isCreatingBoard = true;
try {
const board = await kanbanStore.createBoard({
name: newBoardName.trim(),
color: newBoardColor,
});
showCreateBoard = false;
await kanbanStore.selectBoard(board.id);
} catch (e) {
console.error('Failed to create board:', e);
} finally {
isCreatingBoard = false;
}
}
function handleCreateBoardKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !isCreatingBoard) {
handleCreateBoard();
} else if (event.key === 'Escape') {
showCreateBoard = false;
}
}
// Color options for board creation
const boardColors = [
'#8b5cf6', // violet
'#3b82f6', // blue
'#22c55e', // green
'#f59e0b', // amber
'#ef4444', // red
'#ec4899', // pink
'#06b6d4', // cyan
'#6b7280', // gray
];
onMount(async () => {
// Check initial mobile state
onMount(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
// Fetch boards first
await kanbanStore.fetchBoards();
// If no boards exist, get/create the global board
if (kanbanStore.boards.length === 0) {
await kanbanStore.getOrCreateGlobalBoard();
}
// Fetch kanban data for current board
if (kanbanStore.currentBoardId) {
await kanbanStore.fetchKanbanData(kanbanStore.currentBoardId);
}
});
onDestroy(() => {
@ -152,52 +84,23 @@
<title>{boardTitle} - Todo</title>
</svelte:head>
<div class="kanban-page">
<!-- Board Navigation - Top on Desktop -->
<div class="board-page">
<!-- View Selector - Top on Desktop -->
{#if !isMobile}
<BoardNavigation
boards={kanbanStore.boards}
currentBoardId={kanbanStore.currentBoardId}
loading={kanbanStore.boardsLoading}
position="top"
onSelectBoard={handleSelectBoard}
onCreateBoard={openCreateBoard}
<ViewSelector
views={boardViews.value}
{activeViewId}
onSelect={handleSelectView}
/>
{/if}
<!-- Header with editable title -->
<!-- Header -->
<div class="mb-6 flex items-center justify-between px-4 sm:px-6 lg:px-8">
<div class="editable-title">
{#if isEditingTitle}
<input
type="text"
bind:value={editTitle}
onblur={saveTitle}
onkeydown={handleTitleKeydown}
class="title-input"
autofocus
/>
{:else}
<button class="title-button" onclick={startEditingTitle} title="Klicken zum Bearbeiten">
<h1>{boardTitle}</h1>
<svg class="edit-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
{/if}
</div>
<h1 class="page-title">{boardTitle}</h1>
<button
type="button"
onclick={() => (showFilters = !showFilters)}
class="filter-button px-4 py-2 text-sm font-medium transition-all flex items-center gap-2 {showFilters ||
hasActiveFilters
? 'active'
: ''}"
class="filter-button px-4 py-2 text-sm font-medium transition-all flex items-center gap-2 {showFilters || hasActiveFilters ? 'active' : ''}"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
@ -236,104 +139,43 @@
</div>
{/if}
<!-- Board Container -->
<!-- Board Content -->
<div class="board-container" class:mobile-bottom-padding={isMobile}>
<KanbanBoard {filterPriorities} {filterProjectId} {filterLabelIds} {filterSearchQuery} />
{#if activeView}
<BoardViewRenderer view={activeView} />
{:else if boardViews.value.length === 0}
<div class="empty-state">
<p class="text-muted-foreground">Board Views werden geladen...</p>
</div>
{/if}
</div>
<!-- Board Navigation - Bottom on Mobile -->
<!-- View Selector - Bottom on Mobile -->
{#if isMobile}
<BoardNavigation
boards={kanbanStore.boards}
currentBoardId={kanbanStore.currentBoardId}
loading={kanbanStore.boardsLoading}
position="bottom"
onSelectBoard={handleSelectBoard}
onCreateBoard={openCreateBoard}
/>
<div class="mobile-selector">
<ViewSelector
views={boardViews.value}
{activeViewId}
onSelect={handleSelectView}
/>
</div>
{/if}
</div>
<!-- Create Board Modal -->
{#if showCreateBoard}
<div class="modal-overlay" onclick={() => (showCreateBoard = false)}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<h2 class="modal-title">Neues Board erstellen</h2>
<div class="modal-body">
<label class="input-label">
Name
<input
type="text"
bind:value={newBoardName}
onkeydown={handleCreateBoardKeydown}
class="input-field"
placeholder="z.B. Projekt Alpha"
autofocus
/>
</label>
<div class="color-picker">
<span class="input-label">Farbe</span>
<div class="color-options">
{#each boardColors as color}
<button
type="button"
class="color-option"
class:selected={newBoardColor === color}
style="background-color: {color}"
onclick={() => (newBoardColor = color)}
>
{#if newBoardColor === color}
<svg
class="check-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</button>
{/each}
</div>
</div>
</div>
<div class="modal-actions">
<button
type="button"
class="btn-cancel"
onclick={() => (showCreateBoard = false)}
disabled={isCreatingBoard}
>
Abbrechen
</button>
<button
type="button"
class="btn-create"
onclick={handleCreateBoard}
disabled={isCreatingBoard || !newBoardName.trim()}
>
{#if isCreatingBoard}
Erstelle...
{:else}
Erstellen
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.kanban-page {
.board-page {
display: flex;
flex-direction: column;
height: calc(100vh - 140px);
}
.page-title {
font-size: 1.875rem;
font-weight: 700;
color: var(--foreground);
margin: 0;
}
.board-container {
flex: 1;
min-height: 0;
@ -341,7 +183,25 @@
}
.board-container.mobile-bottom-padding {
padding-bottom: 70px; /* Space for fixed BoardNavigation */
padding-bottom: 70px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
/* Mobile selector fixed at bottom */
.mobile-selector {
position: fixed;
bottom: 70px; /* Above PillNav */
left: 0;
right: 0;
z-index: 40;
background: linear-gradient(to top, var(--background) 0%, transparent 100%);
padding-bottom: 0.75rem;
}
/* Glass-Pill filter button */
@ -394,240 +254,7 @@
color: white;
}
/* Editable title styles */
.editable-title {
display: flex;
align-items: center;
}
.title-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
padding: 0.25rem 0.5rem;
margin: -0.25rem -0.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 0.15s;
}
.title-button:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .title-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.title-button h1 {
font-size: 1.875rem;
font-weight: 700;
color: var(--foreground);
margin: 0;
}
.edit-icon {
width: 1rem;
height: 1rem;
opacity: 0;
color: var(--muted-foreground);
transition: opacity 0.15s;
}
.title-button:hover .edit-icon {
opacity: 1;
}
.title-input {
font-size: 1.875rem;
font-weight: 700;
color: var(--foreground);
background: transparent;
border: none;
border-bottom: 2px solid var(--primary);
outline: none;
padding: 0.25rem 0;
min-width: 200px;
}
/* Modal styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 0.15s ease-out;
}
.modal-content {
background: var(--background);
border-radius: 1rem;
padding: 1.5rem;
width: 90%;
max-width: 400px;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(0, 0, 0, 0.05);
animation: slideUp 0.2s ease-out;
}
:global(.dark) .modal-content {
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.1);
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--foreground);
margin: 0 0 1.5rem;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.input-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--muted-foreground);
margin-bottom: 0.375rem;
}
.input-field {
width: 100%;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
color: var(--foreground);
background: var(--input);
border: 1px solid var(--border);
border-radius: 0.5rem;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.input-field:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
}
.color-picker {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.color-options {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.color-option {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition:
transform 0.15s,
border-color 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.selected {
border-color: var(--foreground);
}
.check-icon {
width: 1rem;
height: 1rem;
color: white;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn-cancel,
.btn-create {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s;
}
.btn-cancel {
background: transparent;
border: 1px solid var(--border);
color: var(--muted-foreground);
}
.btn-cancel:hover:not(:disabled) {
background: var(--accent);
color: var(--foreground);
}
.btn-create {
background: #8b5cf6;
border: none;
color: white;
}
.btn-create:hover:not(:disabled) {
background: #7c3aed;
}
.btn-create:disabled,
.btn-cancel:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: animateIn 0.2s ease-out;
}