feat(todo): add multiple kanban boards with task editing features

- Add multiple boards support with board navigation (pill-style)
- Add global board for all tasks
- Board CRUD operations with color/icon customization
- Task detail modal: click on card opens full edit modal
- Inline title editing: double-click on title for quick edit
- Right-click context menu: edit, complete, delete actions
- Responsive board navigation (top on desktop, bottom on mobile)
- Remove flip animations for cleaner board switching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-09 14:41:50 +01:00
parent 1ac74c9bf5
commit c88626d26b
23 changed files with 2678 additions and 410 deletions

View file

@ -1,4 +1,5 @@
export * from './projects.schema';
export * from './kanban-boards.schema';
export * from './kanban-columns.schema';
export * from './tasks.schema';
export * from './labels.schema';

View file

@ -0,0 +1,32 @@
import { pgTable, uuid, timestamp, varchar, boolean, integer, index } from 'drizzle-orm/pg-core';
import { projects } from './projects.schema';
export const kanbanBoards = pgTable(
'kanban_boards',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
// Board properties
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#8b5cf6'),
icon: varchar('icon', { length: 50 }),
order: integer('order').default(0).notNull(),
// Special flags
isGlobal: boolean('is_global').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('kanban_boards_user_idx').on(table.userId),
projectIdx: index('kanban_boards_project_idx').on(table.projectId),
orderIdx: index('kanban_boards_order_idx').on(table.userId, table.order),
globalIdx: index('kanban_boards_global_idx').on(table.userId, table.isGlobal),
})
);
export type KanbanBoard = typeof kanbanBoards.$inferSelect;
export type NewKanbanBoard = typeof kanbanBoards.$inferInsert;

View file

@ -1,5 +1,5 @@
import { pgTable, uuid, timestamp, varchar, boolean, integer, index } from 'drizzle-orm/pg-core';
import { projects } from './projects.schema';
import { kanbanBoards } from './kanban-boards.schema';
// Define locally to avoid circular dependency with tasks.schema
export type KanbanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
@ -9,7 +9,9 @@ export const kanbanColumns = pgTable(
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
boardId: uuid('board_id')
.references(() => kanbanBoards.id, { onDelete: 'cascade' })
.notNull(),
// Column properties
name: varchar('name', { length: 100 }).notNull(),
@ -26,8 +28,8 @@ export const kanbanColumns = pgTable(
},
(table) => ({
userIdx: index('kanban_columns_user_idx').on(table.userId),
projectIdx: index('kanban_columns_project_idx').on(table.projectId),
orderIdx: index('kanban_columns_order_idx').on(table.userId, table.projectId, table.order),
boardIdx: index('kanban_columns_board_idx').on(table.boardId),
orderIdx: index('kanban_columns_order_idx').on(table.boardId, table.order),
})
);

View file

@ -0,0 +1,21 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class CreateBoardDto {
@IsString()
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
projectId?: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
}

View file

@ -6,15 +6,14 @@ export class CreateColumnDto {
@MaxLength(100)
name: string;
@IsString()
boardId: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsString()
projectId?: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;

View file

@ -1,4 +1,12 @@
// Board DTOs
export * from './create-board.dto';
export * from './update-board.dto';
export * from './reorder-boards.dto';
// Column DTOs
export * from './create-column.dto';
export * from './update-column.dto';
export * from './reorder-columns.dto';
// Task DTOs
export * from './move-task.dto';

View file

@ -0,0 +1,7 @@
import { IsString, IsArray } from 'class-validator';
export class ReorderBoardsDto {
@IsArray()
@IsString({ each: true })
boardIds: string[];
}

View file

@ -0,0 +1,18 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class UpdateBoardDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
@MaxLength(7)
color?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
}

View file

@ -2,6 +2,9 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } fro
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { KanbanService } from './kanban.service';
import {
CreateBoardDto,
UpdateBoardDto,
ReorderBoardsDto,
CreateColumnDto,
UpdateColumnDto,
ReorderColumnsDto,
@ -14,11 +17,63 @@ import {
export class KanbanController {
constructor(private readonly kanbanService: KanbanService) {}
// =====================
// Board endpoints
// =====================
@Get('boards')
async getBoards(@CurrentUser() user: CurrentUserData) {
const boards = await this.kanbanService.findAllBoards(user.userId);
return { boards };
}
@Get('boards/global')
async getGlobalBoard(@CurrentUser() user: CurrentUserData) {
const board = await this.kanbanService.getOrCreateGlobalBoard(user.userId);
return { board };
}
@Get('boards/:id')
async getBoard(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const board = await this.kanbanService.findBoardByIdOrThrow(id, user.userId);
return { board };
}
@Post('boards')
async createBoard(@CurrentUser() user: CurrentUserData, @Body() dto: CreateBoardDto) {
const board = await this.kanbanService.createBoard(user.userId, dto);
return { board };
}
@Put('boards/reorder')
async reorderBoards(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderBoardsDto) {
const boards = await this.kanbanService.reorderBoards(user.userId, dto.boardIds);
return { boards };
}
@Put('boards/:id')
async updateBoard(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateBoardDto
) {
const board = await this.kanbanService.updateBoard(id, user.userId, dto);
return { board };
}
@Delete('boards/:id')
async deleteBoard(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.kanbanService.deleteBoard(id, user.userId);
return { success: true };
}
// =====================
// Column endpoints
// =====================
@Get('columns')
async getColumns(@CurrentUser() user: CurrentUserData, @Query('projectId') projectId?: string) {
const columns = await this.kanbanService.findAllColumns(user.userId, projectId);
async getColumns(@CurrentUser() user: CurrentUserData, @Query('boardId') boardId: string) {
const columns = await this.kanbanService.findAllColumns(boardId, user.userId);
return { columns };
}
@ -51,22 +106,18 @@ export class KanbanController {
}
@Post('columns/init')
async initializeColumns(
@CurrentUser() user: CurrentUserData,
@Query('projectId') projectId?: string
) {
const columns = await this.kanbanService.initializeDefaultColumns(user.userId, projectId);
async initializeColumns(@CurrentUser() user: CurrentUserData, @Query('boardId') boardId: string) {
const columns = await this.kanbanService.initializeDefaultColumns(boardId, user.userId);
return { columns };
}
// =====================
// Task endpoints
// =====================
@Get('tasks')
async getTasksGrouped(
@CurrentUser() user: CurrentUserData,
@Query('projectId') projectId?: string
) {
const result = await this.kanbanService.getTasksGroupedByColumn(user.userId, projectId);
async getTasksGrouped(@CurrentUser() user: CurrentUserData, @Query('boardId') boardId: string) {
const result = await this.kanbanService.getTasksGroupedByColumn(boardId, user.userId);
return result;
}

View file

@ -1,7 +1,12 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and, asc, isNull } from 'drizzle-orm';
import { eq, and, asc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
kanbanBoards,
type KanbanBoard,
type NewKanbanBoard,
} from '../db/schema/kanban-boards.schema';
import {
kanbanColumns,
type KanbanColumn,
@ -9,10 +14,10 @@ import {
type KanbanTaskStatus,
} from '../db/schema/kanban-columns.schema';
import { tasks, type Task } from '../db/schema/tasks.schema';
import { CreateColumnDto, UpdateColumnDto } from './dto';
import { CreateBoardDto, UpdateBoardDto, CreateColumnDto, UpdateColumnDto } from './dto';
// Default columns configuration
const DEFAULT_COLUMNS: Omit<NewKanbanColumn, 'userId' | 'projectId'>[] = [
const DEFAULT_COLUMNS: Omit<NewKanbanColumn, 'userId' | 'boardId'>[] = [
{
name: 'To Do',
color: '#6B7280',
@ -43,19 +48,152 @@ const DEFAULT_COLUMNS: Omit<NewKanbanColumn, 'userId' | 'projectId'>[] = [
export class KanbanService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
// Column operations
// =====================
// Board operations
// =====================
async findAllColumns(userId: string, projectId?: string | null): Promise<KanbanColumn[]> {
if (projectId) {
return this.db.query.kanbanColumns.findMany({
where: and(eq(kanbanColumns.userId, userId), eq(kanbanColumns.projectId, projectId)),
orderBy: [asc(kanbanColumns.order)],
});
async findAllBoards(userId: string): Promise<KanbanBoard[]> {
return this.db.query.kanbanBoards.findMany({
where: eq(kanbanBoards.userId, userId),
orderBy: [asc(kanbanBoards.order)],
});
}
async findBoardById(id: string, userId: string): Promise<KanbanBoard | null> {
const result = await this.db.query.kanbanBoards.findFirst({
where: and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId)),
});
return result ?? null;
}
async findBoardByIdOrThrow(id: string, userId: string): Promise<KanbanBoard> {
const board = await this.findBoardById(id, userId);
if (!board) {
throw new NotFoundException(`Board with id ${id} not found`);
}
return board;
}
async getOrCreateGlobalBoard(userId: string): Promise<KanbanBoard> {
// Check if global board exists
const existingGlobal = await this.db.query.kanbanBoards.findFirst({
where: and(eq(kanbanBoards.userId, userId), eq(kanbanBoards.isGlobal, true)),
});
if (existingGlobal) {
return existingGlobal;
}
// Global columns (no project)
// Create global board
const [globalBoard] = await this.db
.insert(kanbanBoards)
.values({
userId,
name: 'Alle Aufgaben',
color: '#8b5cf6',
order: 0,
isGlobal: true,
})
.returning();
// Initialize default columns for the global board
await this.initializeDefaultColumns(globalBoard.id, userId);
return globalBoard;
}
async createBoard(userId: string, dto: CreateBoardDto): Promise<KanbanBoard> {
// Get the highest order value
const existingBoards = await this.findAllBoards(userId);
const maxOrder = existingBoards.reduce((max, b) => Math.max(max, b.order ?? 0), -1);
const newBoard: NewKanbanBoard = {
userId,
projectId: dto.projectId ?? null,
name: dto.name,
color: dto.color ?? '#8b5cf6',
icon: dto.icon ?? null,
order: maxOrder + 1,
isGlobal: false,
};
const [created] = await this.db.insert(kanbanBoards).values(newBoard).returning();
// Initialize default columns for the new board
await this.initializeDefaultColumns(created.id, userId);
return created;
}
async updateBoard(id: string, userId: string, dto: UpdateBoardDto): Promise<KanbanBoard> {
await this.findBoardByIdOrThrow(id, userId);
const [updated] = await this.db
.update(kanbanBoards)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId)))
.returning();
return updated;
}
async deleteBoard(id: string, userId: string): Promise<void> {
const board = await this.findBoardByIdOrThrow(id, userId);
if (board.isGlobal) {
throw new BadRequestException('Cannot delete the global board');
}
// Get global board to move tasks to
const globalBoard = await this.getOrCreateGlobalBoard(userId);
const globalColumns = await this.findAllColumns(globalBoard.id, userId);
const firstGlobalColumn = globalColumns[0];
if (firstGlobalColumn) {
// Get all columns for this board
const boardColumns = await this.findAllColumns(id, userId);
// Move tasks from board columns to first global column
for (const column of boardColumns) {
await this.db
.update(tasks)
.set({
columnId: firstGlobalColumn.id,
updatedAt: new Date(),
})
.where(eq(tasks.columnId, column.id));
}
}
// Delete the board (columns will cascade delete)
await this.db
.delete(kanbanBoards)
.where(and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId)));
}
async reorderBoards(userId: string, boardIds: string[]): Promise<KanbanBoard[]> {
const updates = boardIds.map((id, index) =>
this.db
.update(kanbanBoards)
.set({ order: index, updatedAt: new Date() })
.where(and(eq(kanbanBoards.id, id), eq(kanbanBoards.userId, userId)))
);
await Promise.all(updates);
return this.findAllBoards(userId);
}
// =====================
// Column operations
// =====================
async findAllColumns(boardId: string, userId: string): Promise<KanbanColumn[]> {
return this.db.query.kanbanColumns.findMany({
where: and(eq(kanbanColumns.userId, userId), isNull(kanbanColumns.projectId)),
where: and(eq(kanbanColumns.boardId, boardId), eq(kanbanColumns.userId, userId)),
orderBy: [asc(kanbanColumns.order)],
});
}
@ -76,13 +214,16 @@ export class KanbanService {
}
async createColumn(userId: string, dto: CreateColumnDto): Promise<KanbanColumn> {
// Get the highest order value for this scope
const existingColumns = await this.findAllColumns(userId, dto.projectId);
// Verify board exists
await this.findBoardByIdOrThrow(dto.boardId, userId);
// Get the highest order value for this board
const existingColumns = await this.findAllColumns(dto.boardId, userId);
const maxOrder = existingColumns.reduce((max, c) => Math.max(max, c.order ?? 0), -1);
const newColumn: NewKanbanColumn = {
userId,
projectId: dto.projectId ?? null,
boardId: dto.boardId,
name: dto.name,
color: dto.color ?? '#6B7280',
order: maxOrder + 1,
@ -114,7 +255,7 @@ export class KanbanService {
const column = await this.findColumnByIdOrThrow(id, userId);
// Get first column to move tasks to
const columns = await this.findAllColumns(userId, column.projectId);
const columns = await this.findAllColumns(column.boardId, userId);
const firstColumn = columns.find((c) => c.id !== id);
if (!firstColumn) {
@ -146,17 +287,17 @@ export class KanbanService {
await Promise.all(updates);
// Determine projectId from first column
// Determine boardId from first column
const firstColumn = await this.findColumnById(columnIds[0], userId);
return this.findAllColumns(userId, firstColumn?.projectId);
if (!firstColumn) {
return [];
}
return this.findAllColumns(firstColumn.boardId, userId);
}
async initializeDefaultColumns(
userId: string,
projectId?: string | null
): Promise<KanbanColumn[]> {
async initializeDefaultColumns(boardId: string, userId: string): Promise<KanbanColumn[]> {
// Check if columns already exist
const existing = await this.findAllColumns(userId, projectId);
const existing = await this.findAllColumns(boardId, userId);
if (existing.length > 0) {
return existing;
}
@ -165,35 +306,51 @@ export class KanbanService {
const columnsToCreate: NewKanbanColumn[] = DEFAULT_COLUMNS.map((col) => ({
...col,
userId,
projectId: projectId ?? null,
boardId,
}));
await this.db.insert(kanbanColumns).values(columnsToCreate);
return this.findAllColumns(userId, projectId);
return this.findAllColumns(boardId, userId);
}
// =====================
// Task operations
// =====================
async getTasksGroupedByColumn(
userId: string,
projectId?: string | null
boardId: string,
userId: string
): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record<string, Task[]> }> {
// Ensure columns exist
const columns = await this.initializeDefaultColumns(userId, projectId);
// Get board to check if it's global
const board = await this.findBoardByIdOrThrow(boardId, userId);
// Get all tasks for this user
// Ensure columns exist
const columns = await this.initializeDefaultColumns(boardId, userId);
// Get tasks based on board type
let userTasks: Task[];
if (projectId) {
userTasks = await this.db.query.tasks.findMany({
where: and(eq(tasks.userId, userId), eq(tasks.projectId, projectId)),
orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)],
});
} else {
if (board.isGlobal) {
// Global board: all tasks
userTasks = await this.db.query.tasks.findMany({
where: eq(tasks.userId, userId),
orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)],
});
} else if (board.projectId) {
// Project-specific board: only project tasks
userTasks = await this.db.query.tasks.findMany({
where: and(eq(tasks.userId, userId), eq(tasks.projectId, board.projectId)),
orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)],
});
} else {
// Custom board without project: tasks assigned to this board's columns
const columnIds = columns.map((c) => c.id);
userTasks = await this.db.query.tasks.findMany({
where: eq(tasks.userId, userId),
orderBy: [asc(tasks.columnOrder), asc(tasks.createdAt)],
});
// Filter to only tasks in this board's columns
userTasks = userTasks.filter((t) => t.columnId && columnIds.includes(t.columnId));
}
// Group tasks by column

View file

@ -1,5 +1,15 @@
import { apiClient } from './client';
import type { KanbanColumn, Task } from '@todo/shared';
import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared';
// Response types
interface BoardsResponse {
boards: KanbanBoard[];
}
interface BoardResponse {
board: KanbanBoard;
}
interface ColumnsResponse {
columns: KanbanColumn[];
@ -22,10 +32,25 @@ 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;
projectId?: string;
isDefault?: boolean;
defaultStatus?: string;
autoComplete?: boolean;
@ -38,11 +63,54 @@ interface UpdateColumnDto {
autoComplete?: boolean;
}
// Column operations
// =====================
// Board operations
// =====================
export async function getColumns(projectId?: string): Promise<KanbanColumn[]> {
const query = projectId ? `?projectId=${projectId}` : '';
const response = await apiClient.get<ColumnsResponse>(`/api/v1/kanban/columns${query}`);
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;
}
@ -67,19 +135,23 @@ export async function reorderColumns(columnIds: string[]): Promise<KanbanColumn[
return response.columns;
}
export async function initializeColumns(projectId?: string): Promise<KanbanColumn[]> {
const query = projectId ? `?projectId=${projectId}` : '';
const response = await apiClient.post<ColumnsResponse>(`/api/v1/kanban/columns/init${query}`);
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(
projectId?: string
boardId: string
): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record<string, Task[]> }> {
const query = projectId ? `?projectId=${projectId}` : '';
const response = await apiClient.get<KanbanTasksResponse>(`/api/v1/kanban/tasks${query}`);
const response = await apiClient.get<KanbanTasksResponse>(
`/api/v1/kanban/tasks?boardId=${boardId}`
);
return response;
}

View file

@ -26,26 +26,38 @@
}
</script>
<div class="add-column min-w-[280px] max-w-[320px] h-fit">
<div class="add-column min-w-[300px] max-w-[340px] h-fit">
{#if isAdding}
<div class="bg-muted/50 rounded-xl p-3">
<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..."
class="w-full px-3 py-2 text-sm bg-card border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary text-foreground placeholder:text-muted-foreground"
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-2">
<div class="flex gap-2 mt-3">
<button
class="flex-1 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
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-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
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;
@ -57,13 +69,116 @@
</div>
{:else}
<button
class="w-full p-3 text-sm text-muted-foreground hover:text-foreground bg-muted/30 hover:bg-muted/50 rounded-xl border-2 border-dashed border-border hover:border-muted-foreground transition-colors flex items-center justify-center gap-2"
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)}
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Spalte hinzufügen
<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

@ -0,0 +1,243 @@
<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,6 +1,5 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import type { KanbanColumn, Task, TaskPriority } from '@todo/shared';
import KanbanColumnComponent from './KanbanColumn.svelte';
import AddColumnButton from './AddColumnButton.svelte';
@ -43,7 +42,12 @@
}
async function handleAddColumn(name: string) {
await kanbanStore.createColumn({ name, projectId });
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 }) {
@ -61,7 +65,10 @@
}
async function handleAddTask(columnId: string, title: string) {
await kanbanStore.createTaskInColumn(columnId, title, projectId);
// 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) {
@ -109,15 +116,19 @@
</script>
<div class="kanban-board h-full">
{#if kanbanStore.loading}
<div class="flex items-center justify-center py-12">
<div
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
></div>
</div>
{:else if kanbanStore.error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
{kanbanStore.error}
{#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
@ -132,7 +143,7 @@
onfinalize={handleColumnDndFinalize}
>
{#each localColumns.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as column (column.id)}
<div animate:flip={{ duration: flipDurationMs }} class="flex-shrink-0">
<div class="flex-shrink-0">
<KanbanColumnComponent
{column}
tasks={getTasksForColumn(column.id)}
@ -161,20 +172,52 @@
.columns-container {
min-height: 100%;
align-items: flex-start;
scroll-behavior: smooth;
padding-left: 1rem;
padding-right: 1rem;
}
/* Hide scrollbar but keep functionality */
/* 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: 8px;
height: 10px;
}
.columns-container::-webkit-scrollbar-track {
background: transparent;
background: var(--muted);
border-radius: 5px;
}
.columns-container::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
border-radius: 5px;
border: 2px solid var(--muted);
}
.columns-container::-webkit-scrollbar-thumb:hover {

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import type { KanbanColumn, Task } from '@todo/shared';
import KanbanTaskCard from './KanbanTaskCard.svelte';
import KanbanColumnHeader from './KanbanColumnHeader.svelte';
@ -71,9 +70,31 @@
await tasksStore.completeTask(task.id);
}
}
async function handleSaveTask(task: Task, data: Partial<Task>) {
// Transform data to match updateTask API (convert null to undefined)
const updateData: Parameters<typeof tasksStore.updateTask>[1] = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description ?? undefined;
if (data.projectId !== undefined) updateData.projectId = data.projectId;
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ?? undefined;
if (data.priority !== undefined) updateData.priority = data.priority;
if (data.status !== undefined) updateData.status = data.status;
if (data.subtasks !== undefined) updateData.subtasks = data.subtasks ?? undefined;
if (data.recurrenceRule !== undefined)
updateData.recurrenceRule = data.recurrenceRule ?? undefined;
if (data.metadata !== undefined) updateData.metadata = data.metadata;
if ((data as any).labelIds !== undefined) (updateData as any).labelIds = (data as any).labelIds;
await tasksStore.updateTask(task.id, updateData);
}
async function handleDeleteTask(task: Task) {
await tasksStore.deleteTask(task.id);
}
</script>
<div class="kanban-column flex flex-col bg-muted/50 rounded-xl min-w-[280px] max-w-[320px] h-full">
<div class="kanban-column flex flex-col min-w-[300px] max-w-[340px] h-full">
<!-- Header -->
<KanbanColumnHeader
{column}
@ -84,7 +105,7 @@
<!-- Tasks list with drag and drop -->
<div
class="flex-1 overflow-y-auto px-2 pb-2"
class="tasks-container flex-1 overflow-y-auto px-3 pb-3"
use:dndzone={{
items: localTasks,
flipDurationMs,
@ -96,21 +117,20 @@
onfinalize={handleDndFinalize}
>
{#each localTasks.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID) as task (task.id)}
<div animate:flip={{ duration: flipDurationMs }} class="mb-2">
<KanbanTaskCard {task} onToggleComplete={() => handleToggleComplete(task)} />
<div class="mb-2.5 last:mb-0">
<KanbanTaskCard
{task}
onToggleComplete={() => handleToggleComplete(task)}
onSave={(data) => handleSaveTask(task, data)}
onDelete={() => handleDeleteTask(task)}
/>
</div>
{/each}
{#if localTasks.length === 0}
<div class="text-center py-4 text-muted-foreground text-sm">
<p>Keine Aufgaben</p>
</div>
{/if}
</div>
<!-- Quick Add Task -->
{#if onAddTask}
<div class="px-2 pb-2">
<div class="px-3 pb-3 pt-2">
<QuickAddTaskInline onAdd={onAddTask} />
</div>
{/if}
@ -118,12 +138,55 @@
<style>
.kanban-column {
min-height: 200px;
min-height: 250px;
max-height: calc(100vh - 280px);
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) .kanban-column {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tasks-container {
min-height: 80px;
}
/* Custom scrollbar for tasks */
.tasks-container::-webkit-scrollbar {
width: 6px;
}
.tasks-container::-webkit-scrollbar-track {
background: transparent;
}
.tasks-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
}
:global(.dark) .tasks-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
.tasks-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
:global(.dark) .tasks-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
:global(.drop-target) {
outline: 2px dashed var(--primary);
outline: 2px dashed #8b5cf6;
outline-offset: -2px;
border-radius: 0.5rem;
border-radius: 1.5rem;
background: rgba(139, 92, 246, 0.05);
}
</style>

View file

@ -50,10 +50,13 @@
}
</script>
<div class="column-header flex items-center justify-between p-3 pb-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<!-- Color indicator -->
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: {column.color}"></div>
<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}
@ -62,7 +65,7 @@
bind:value={editName}
onblur={handleSubmit}
onkeydown={handleKeydown}
class="text-sm font-semibold bg-transparent border-b border-primary outline-none text-foreground flex-1 min-w-0"
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}
@ -78,8 +81,11 @@
</button>
{/if}
<!-- Task count -->
<span class="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded-full flex-shrink-0">
<!-- 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>
@ -88,7 +94,7 @@
{#if onUpdate || onDelete}
<div class="relative">
<button
class="p-1 text-muted-foreground hover:text-foreground rounded transition-colors"
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">
@ -103,17 +109,23 @@
{#if showMenu}
<div
class="absolute right-0 top-full mt-1 bg-card border border-border rounded-lg shadow-lg py-1 z-50 min-w-[150px]"
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-accent flex items-center gap-2"
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" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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"
@ -125,10 +137,16 @@
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-accent flex items-center gap-2"
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" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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"
@ -140,12 +158,13 @@
</button>
{#if showColorPicker}
<div class="px-3 py-2 flex flex-wrap gap-1 border-t border-border mt-1 pt-2">
<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-6 h-6 rounded-full border-2 transition-transform hover:scale-110"
class:border-primary={color === column.color}
class:border-transparent={color !== column.color}
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>
@ -155,23 +174,26 @@
{/if}
{#if onDelete && !column.isDefault}
<button
class="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
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 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}
@ -189,3 +211,45 @@
}}
></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

@ -27,11 +27,31 @@
onClearFilters,
}: Props = $props();
const priorities: { value: TaskPriority; label: string; color: string }[] = [
{ value: 'urgent', label: 'Dringend', color: 'bg-red-500' },
{ value: 'high', label: 'Hoch', color: 'bg-orange-500' },
{ value: 'medium', label: 'Mittel', color: 'bg-yellow-500' },
{ value: 'low', label: 'Niedrig', color: 'bg-blue-500' },
const priorities: { value: TaskPriority; label: string; color: string; bgColor: string }[] = [
{
value: 'urgent',
label: 'Dringend',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-500',
},
{
value: 'high',
label: 'Hoch',
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-500',
},
{
value: 'medium',
label: 'Mittel',
color: 'text-yellow-600 dark:text-yellow-400',
bgColor: 'bg-yellow-500',
},
{
value: 'low',
label: 'Niedrig',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500',
},
];
let showLabelsDropdown = $state(false);
@ -60,134 +80,241 @@
);
</script>
<div class="kanban-filters flex flex-wrap items-center gap-3 p-3 bg-muted/30 rounded-lg">
<!-- Search -->
<div class="relative flex-shrink-0">
<input
type="text"
value={searchQuery}
oninput={(e) => onSearchChange(e.currentTarget.value)}
placeholder="Suchen..."
class="w-40 px-3 py-1.5 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 placeholder:text-muted-foreground"
/>
{#if searchQuery}
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onclick={() => onSearchChange('')}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="kanban-filters glass-pill rounded-full p-4">
<div class="flex flex-col gap-4">
<!-- Row 1: Search and Clear -->
<div class="flex items-center gap-4">
<div class="relative flex-1 max-w-xs">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 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="M6 18L18 6M6 6l12 12"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</button>
{/if}
</div>
<!-- Priority filters -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-muted-foreground mr-1">Priorität:</span>
{#each priorities as priority}
<button
class="px-2 py-1 text-xs rounded-full transition-all {selectedPriorities.includes(
priority.value
)
? `${priority.color} text-white`
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => togglePriority(priority.value)}
>
{priority.label}
</button>
{/each}
</div>
<!-- Project filter -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-muted-foreground">Projekt:</span>
<select
class="px-2 py-1 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
value={selectedProjectId || ''}
onchange={(e) => onProjectChange(e.currentTarget.value || null)}
>
<option value="">Alle</option>
{#each projectsStore.activeProjects as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
<!-- Labels filter -->
<div class="relative">
<button
class="flex items-center gap-1.5 px-2 py-1 text-sm bg-background border border-border rounded-lg hover:bg-muted/50 transition-colors"
onclick={() => (showLabelsDropdown = !showLabelsDropdown)}
>
<span class="text-xs text-muted-foreground">Labels:</span>
{#if selectedLabelIds.length > 0}
<span class="px-1.5 py-0.5 text-xs bg-primary text-primary-foreground rounded">
{selectedLabelIds.length}
</span>
{:else}
<span class="text-muted-foreground">Alle</span>
{/if}
<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="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showLabelsDropdown}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showLabelsDropdown = false)}></div>
<div
class="absolute top-full left-0 mt-1 z-50 min-w-[200px] bg-popover border border-border rounded-lg shadow-lg p-2"
>
{#if labelsStore.labels.length === 0}
<p class="text-sm text-muted-foreground p-2">Keine Labels vorhanden</p>
{:else}
{#each labelsStore.labels as label}
<button
class="w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-muted/50 transition-colors"
onclick={() => toggleLabel(label.id)}
>
<div class="w-3 h-3 rounded-full" style="background-color: {label.color}"></div>
<span class="flex-1 text-left">{label.name}</span>
{#if selectedLabelIds.includes(label.id)}
<svg
class="w-4 h-4 text-primary"
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}
<input
type="text"
value={searchQuery}
oninput={(e) => onSearchChange(e.currentTarget.value)}
placeholder="Aufgaben suchen..."
class="w-full pl-10 pr-8 py-2 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary placeholder:text-muted-foreground transition-all"
/>
{#if searchQuery}
<button
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground rounded-full hover:bg-muted transition-colors"
onclick={() => onSearchChange('')}
>
<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}
</div>
<!-- Clear filters -->
{#if hasActiveFilters}
<button
class="ml-auto px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded transition-colors"
onclick={onClearFilters}
>
Filter zurücksetzen
</button>
{/if}
{#if hasActiveFilters}
<button
class="ml-auto px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors flex items-center gap-2"
onclick={onClearFilters}
>
<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>
Zurücksetzen
</button>
{/if}
</div>
<!-- Row 2: Filter Pills -->
<div class="flex flex-wrap items-center gap-3">
<!-- Priority filters -->
<div class="filter-group flex items-center gap-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide"
>Priorität</span
>
<div class="flex items-center gap-1">
{#each priorities as priority}
<button
class="filter-pill px-3 py-1.5 text-xs font-medium rounded-full transition-all border {selectedPriorities.includes(
priority.value
)
? `${priority.bgColor} text-white border-transparent shadow-sm`
: 'bg-background border-border text-foreground hover:border-primary/50 hover:bg-muted/50'}"
onclick={() => togglePriority(priority.value)}
>
{priority.label}
</button>
{/each}
</div>
</div>
<div class="h-6 w-px bg-border hidden sm:block"></div>
<!-- Project filter -->
<div class="filter-group flex items-center gap-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide"
>Projekt</span
>
<select
class="px-3 py-1.5 text-sm bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all cursor-pointer"
value={selectedProjectId || ''}
onchange={(e) => onProjectChange(e.currentTarget.value || null)}
>
<option value="">Alle Projekte</option>
{#each projectsStore.activeProjects as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
<div class="h-6 w-px bg-border hidden sm:block"></div>
<!-- Labels filter -->
<div class="filter-group flex items-center gap-2 relative">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Labels</span
>
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-background border border-border rounded-lg hover:border-primary/50 hover:bg-muted/50 transition-all"
onclick={() => (showLabelsDropdown = !showLabelsDropdown)}
>
{#if selectedLabelIds.length > 0}
<div class="flex items-center gap-1">
{#each selectedLabelIds.slice(0, 3) as labelId}
{@const label = labelsStore.labels.find((l) => l.id === labelId)}
{#if label}
<div
class="w-3 h-3 rounded-full ring-2 ring-background"
style="background-color: {label.color}"
></div>
{/if}
{/each}
{#if selectedLabelIds.length > 3}
<span class="text-xs text-muted-foreground">+{selectedLabelIds.length - 3}</span>
{/if}
</div>
{:else}
<span class="text-muted-foreground">Auswählen</span>
{/if}
<svg
class="w-4 h-4 text-muted-foreground transition-transform {showLabelsDropdown
? 'rotate-180'
: ''}"
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 showLabelsDropdown}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showLabelsDropdown = false)}></div>
<div
class="absolute top-full left-0 mt-2 z-50 min-w-[220px] bg-popover border border-border rounded-xl shadow-lg p-2 animate-in fade-in slide-in-from-top-2 duration-150"
>
{#if labelsStore.labels.length === 0}
<p class="text-sm text-muted-foreground p-3 text-center">Keine Labels vorhanden</p>
{:else}
<div class="max-h-[200px] overflow-y-auto">
{#each labelsStore.labels as label}
<button
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-muted/50 transition-colors"
onclick={() => toggleLabel(label.id)}
>
<div
class="w-4 h-4 rounded-full flex-shrink-0 ring-2 ring-offset-2 ring-offset-popover transition-all {selectedLabelIds.includes(
label.id
)
? 'ring-primary'
: 'ring-transparent'}"
style="background-color: {label.color}"
></div>
<span class="flex-1 text-left truncate">{label.name}</span>
{#if selectedLabelIds.includes(label.id)}
<svg
class="w-4 h-4 text-primary flex-shrink-0"
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}
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
/* Glass pill effect matching PillNavigation */
.glass-pill {
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);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .glass-pill {
background: rgba(255, 255, 255, 0.12);
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

@ -2,17 +2,33 @@
import type { Task } from '@todo/shared';
import { format, isToday, isPast, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
import TaskEditModal from '../TaskEditModal.svelte';
interface Props {
task: Task;
onToggleComplete?: () => void;
onSave?: (data: Partial<Task>) => void;
onDelete?: () => void;
}
let { task, onToggleComplete }: Props = $props();
let { task, onToggleComplete, onSave, onDelete }: Props = $props();
// Priority colors
// Modal state
let showModal = $state(false);
// Inline edit state
let isEditingTitle = $state(false);
let editTitle = $state('');
let titleInputRef = $state<HTMLInputElement | null>(null);
// Context menu state
let showContextMenu = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
// Priority colors (consistent with KanbanFilters)
const priorityColors: Record<string, string> = {
low: 'bg-green-500',
low: 'bg-blue-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
urgent: 'bg-red-500',
@ -40,107 +56,501 @@
const completed = task.subtasks.filter((s) => s.isCompleted).length;
return `${completed}/${task.subtasks.length}`;
});
// Click to open modal
function handleCardClick(e: MouseEvent) {
// Don't open modal if clicking on checkbox or during inline edit
if (isEditingTitle) return;
const target = e.target as HTMLElement;
if (target.closest('.task-checkbox')) return;
showModal = true;
}
// Double-click to edit title inline
function handleTitleDoubleClick(e: MouseEvent) {
e.stopPropagation();
editTitle = task.title;
isEditingTitle = true;
// Focus input after render
setTimeout(() => {
titleInputRef?.focus();
titleInputRef?.select();
}, 0);
}
// Save inline title edit
function saveInlineTitle() {
if (editTitle.trim() && editTitle.trim() !== task.title) {
onSave?.({ title: editTitle.trim() });
}
isEditingTitle = false;
}
// Cancel inline title edit
function cancelInlineTitle() {
isEditingTitle = false;
editTitle = '';
}
// Handle title input keydown
function handleTitleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
saveInlineTitle();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelInlineTitle();
}
}
// Right-click context menu
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenuX = e.clientX;
contextMenuY = e.clientY;
showContextMenu = true;
}
// Close context menu when clicking outside
function handleClickOutside() {
showContextMenu = false;
}
// Context menu actions
function handleContextEdit() {
showContextMenu = false;
showModal = true;
}
function handleContextToggleComplete() {
showContextMenu = false;
onToggleComplete?.();
}
function handleContextDelete() {
showContextMenu = false;
if (confirm('Aufgabe wirklich löschen?')) {
onDelete?.();
}
}
// Modal handlers
function handleModalClose() {
showModal = false;
}
function handleModalSave(data: Partial<Task>) {
onSave?.(data);
showModal = false;
}
function handleModalDelete(taskId: string) {
onDelete?.();
showModal = false;
}
</script>
<svelte:window onclick={handleClickOutside} />
<div
class="kanban-card group bg-card border border-border rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow cursor-grab active:cursor-grabbing"
class:opacity-60={task.isCompleted}
class="kanban-card group"
class:completed={task.isCompleted}
onclick={handleCardClick}
oncontextmenu={handleContextMenu}
role="button"
tabindex="0"
>
<!-- Priority indicator -->
<div class="flex items-start gap-2">
<div
class="priority-dot {priorityColors[task.priority]} w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
></div>
<div class="priority-dot" style="background-color: {priorityColors[task.priority]}"></div>
<div class="flex-1 min-w-0">
<!-- Title -->
<h4
class="text-sm font-medium text-foreground line-clamp-2"
<!-- Checkbox -->
{#if onToggleComplete}
<button class="task-checkbox" class:checked={task.isCompleted} onclick={onToggleComplete}>
{#if task.isCompleted}
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
{/if}
<!-- Content -->
<div class="task-content">
{#if isEditingTitle}
<input
bind:this={titleInputRef}
type="text"
class="title-input"
bind:value={editTitle}
onkeydown={handleTitleKeydown}
onblur={saveInlineTitle}
/>
{:else}
<span
class="task-title"
class:line-through={task.isCompleted}
ondblclick={handleTitleDoubleClick}
>
{task.title}
</h4>
</span>
{/if}
<!-- Meta info -->
{#if dueDateText() || subtaskProgress() || (task.labels && task.labels.length > 0)}
<div class="flex items-center gap-2 mt-2 flex-wrap">
{#if dueDateText()}
<span
class="text-xs flex items-center gap-1 px-1.5 py-0.5 rounded {isOverdue()
? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
: 'bg-muted text-muted-foreground'}"
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{dueDateText()}
</span>
{/if}
{#if subtaskProgress()}
<span
class="text-xs text-muted-foreground flex items-center gap-1 bg-muted px-1.5 py-0.5 rounded"
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
{subtaskProgress()}
</span>
{/if}
{#if task.labels && task.labels.length > 0}
<div class="flex items-center gap-1">
{#each task.labels.slice(0, 2) as label}
<span
class="w-4 h-1.5 rounded-full"
style="background-color: {label.color}"
title={label.name}
></span>
{/each}
{#if task.labels.length > 2}
<span class="text-xs text-muted-foreground">+{task.labels.length - 2}</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Complete button -->
{#if onToggleComplete}
<button
class="opacity-0 group-hover:opacity-100 flex-shrink-0 w-5 h-5 rounded-full border-2 border-muted-foreground hover:border-primary flex items-center justify-center transition-opacity"
class:bg-primary={task.isCompleted}
class:border-primary={task.isCompleted}
onclick={onToggleComplete}
>
{#if task.isCompleted}
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
<!-- Meta info -->
{#if dueDateText() || subtaskProgress() || (task.labels && task.labels.length > 0)}
<div class="task-meta">
{#if dueDateText()}
<span class="meta-item" class:overdue={isOverdue()}>
<svg class="meta-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{dueDateText()}
</span>
{/if}
</button>
{#if subtaskProgress()}
<span class="meta-item">
<svg class="meta-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
{subtaskProgress()}
</span>
{/if}
{#if task.labels && task.labels.length > 0}
{#each task.labels.slice(0, 2) as label}
<span class="label-tag" style="--label-color: {label.color}">
{label.name}
</span>
{/each}
{#if task.labels.length > 2}
<span class="meta-item">+{task.labels.length - 2}</span>
{/if}
{/if}
</div>
{/if}
</div>
</div>
<!-- Context Menu -->
{#if showContextMenu}
<div
class="context-menu"
style="left: {contextMenuX}px; top: {contextMenuY}px"
onclick={(e) => e.stopPropagation()}
>
<button class="context-item" onclick={handleContextEdit}>
<svg class="context-icon" 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>
Bearbeiten
</button>
<button class="context-item" onclick={handleContextToggleComplete}>
<svg class="context-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{#if task.isCompleted}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
{/if}
</svg>
{task.isCompleted ? 'Wiederherstellen' : 'Erledigen'}
</button>
<div class="context-divider"></div>
<button class="context-item danger" onclick={handleContextDelete}>
<svg class="context-icon" 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}
<!-- Task Edit Modal -->
<TaskEditModal
{task}
open={showModal}
onClose={handleModalClose}
onSave={handleModalSave}
onDelete={handleModalDelete}
/>
<style>
.kanban-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
border-radius: 9999px;
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);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
transition: all 0.2s;
user-select: none;
cursor: pointer;
}
.kanban-card:active {
cursor: grabbing;
}
:global(.dark) .kanban-card {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.kanban-card:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .kanban-card:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.kanban-card.completed {
opacity: 0.6;
}
/* Priority dot */
.priority-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
}
/* Checkbox */
.task-checkbox {
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
border: 2px solid rgba(0, 0, 0, 0.2);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
padding: 0;
}
:global(.dark) .task-checkbox {
border-color: rgba(255, 255, 255, 0.3);
}
.task-checkbox:hover {
border-color: #8b5cf6;
background: rgba(139, 92, 246, 0.1);
}
.task-checkbox.checked {
background: #8b5cf6;
border-color: #8b5cf6;
}
.check-icon {
width: 0.75rem;
height: 0.75rem;
color: white;
}
/* Content */
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.task-title {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
:global(.dark) .task-title {
color: #f3f4f6;
}
.task-title.line-through {
text-decoration: line-through;
color: #9ca3af;
}
/* Inline title input */
.title-input {
width: 100%;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #8b5cf6;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
outline: none;
}
:global(.dark) .title-input {
background: rgba(30, 30, 30, 0.9);
color: #f3f4f6;
border-color: #8b5cf6;
}
/* Meta info */
.task-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
:global(.dark) .meta-item {
color: #9ca3af;
}
.meta-item.overdue {
color: #ef4444;
}
.meta-icon {
width: 0.75rem;
height: 0.75rem;
}
.label-tag {
font-size: 0.625rem;
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;
}
/* Context Menu */
.context-menu {
position: fixed;
z-index: 100;
min-width: 160px;
padding: 0.375rem;
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);
border-radius: 0.75rem;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15);
}
:global(.dark) .context-menu {
background: rgba(40, 40, 40, 0.95);
border-color: rgba(255, 255, 255, 0.15);
}
.context-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
font-size: 0.875rem;
color: #374151;
cursor: pointer;
border-radius: 0.5rem;
transition: background 0.15s;
text-align: left;
}
:global(.dark) .context-item {
color: #e5e7eb;
}
.context-item:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .context-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.context-item.danger {
color: #ef4444;
}
.context-item.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.context-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.context-divider {
height: 1px;
background: rgba(0, 0, 0, 0.1);
margin: 0.25rem 0.5rem;
}
:global(.dark) .context-divider {
background: rgba(255, 255, 255, 0.1);
}
</style>

View file

@ -38,26 +38,34 @@
<div class="quick-add-inline">
{#if isAdding}
<div class="bg-card border border-border rounded-lg p-2 shadow-sm">
<div class="add-form p-3">
<input
bind:this={inputRef}
bind:value={title}
onkeydown={handleKeydown}
onblur={handleBlur}
{placeholder}
class="w-full px-2 py-1.5 text-sm bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
class="w-full px-0 py-1 text-sm bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
autofocus
/>
<div class="flex justify-between items-center mt-2">
<div class="flex justify-between items-center mt-2 pt-2 border-t border-border/50">
<button
class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors"
class="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-all shadow-sm flex items-center gap-1.5"
onmousedown={(e) => e.preventDefault()}
onclick={handleSubmit}
>
<svg class="w-3.5 h-3.5" 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="p-1 text-muted-foreground hover:text-foreground transition-colors"
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded-full transition-colors"
onmousedown={(e) => e.preventDefault()}
onclick={() => {
title = '';
@ -77,13 +85,54 @@
</div>
{:else}
<button
class="w-full p-2 text-sm text-muted-foreground hover:text-foreground hover:bg-card/50 rounded-lg transition-colors flex items-center gap-2"
class="add-trigger group w-full p-2.5 text-sm text-muted-foreground hover:text-foreground transition-all flex items-center gap-2"
onclick={() => (isAdding = true)}
>
<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>
Aufgabe hinzufügen
<div
class="w-5 h-5 rounded-full border-2 border-dashed border-current group-hover:border-primary group-hover:text-primary flex items-center justify-center transition-colors"
>
<svg class="w-3 h-3" 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="group-hover:text-foreground transition-colors">Aufgabe hinzufügen</span>
</button>
{/if}
</div>
<style>
/* Glass-Pill add form */
.add-form {
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: 1rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .add-form {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
/* Trigger button with subtle glass effect */
.add-trigger {
border-radius: 9999px;
}
.add-trigger:hover {
background: rgba(255, 255, 255, 0.5);
}
:global(.dark) .add-trigger:hover {
background: rgba(255, 255, 255, 0.1);
}
</style>

View file

@ -4,3 +4,4 @@ export { default as KanbanColumnHeader } from './KanbanColumnHeader.svelte';
export { default as KanbanTaskCard } from './KanbanTaskCard.svelte';
export { default as AddColumnButton } from './AddColumnButton.svelte';
export { default as KanbanFilters } from './KanbanFilters.svelte';
export { default as BoardNavigation } from './BoardNavigation.svelte';

View file

@ -1,19 +1,46 @@
/**
* Kanban Store - Manages kanban board state using Svelte 5 runes
* Kanban Store - Manages kanban boards, columns, and tasks using Svelte 5 runes
*/
import type { KanbanColumn, Task } from '@todo/shared';
import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared';
import * as kanbanApi from '$lib/api/kanban';
import * as tasksApi from '$lib/api/tasks';
// State
// 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 = {
// Getters
// =====================
// 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;
},
@ -23,18 +50,165 @@ export const kanbanStore = {
get loading() {
return loading;
},
get boardsLoading() {
return boardsLoading;
},
get error() {
return error;
},
// =====================
// Board Operations
// =====================
/**
* Fetch columns and tasks grouped by column
* Fetch all boards for the current user
*/
async fetchKanbanData(projectId?: string) {
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(projectId);
const data = await kanbanApi.getKanbanTasks(boardId);
columns = data.columns;
tasksByColumn = data.tasksByColumn;
} catch (e) {
@ -46,13 +220,13 @@ export const kanbanStore = {
},
/**
* Fetch only columns
* Fetch only columns for a board
*/
async fetchColumns(projectId?: string) {
async fetchColumns(boardId: string) {
loading = true;
error = null;
try {
columns = await kanbanApi.getColumns(projectId);
columns = await kanbanApi.getColumns(boardId);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch columns';
console.error('Failed to fetch columns:', e);
@ -66,8 +240,8 @@ export const kanbanStore = {
*/
async createColumn(data: {
name: string;
boardId: string;
color?: string;
projectId?: string;
defaultStatus?: string;
autoComplete?: boolean;
}) {
@ -216,10 +390,10 @@ export const kanbanStore = {
/**
* Initialize default columns if none exist
*/
async initializeDefaultColumns(projectId?: string) {
async initializeDefaultColumns(boardId: string) {
error = null;
try {
const newColumns = await kanbanApi.initializeColumns(projectId);
const newColumns = await kanbanApi.initializeColumns(boardId);
columns = newColumns;
// Initialize empty task arrays for each column
for (const col of newColumns) {
@ -250,7 +424,6 @@ export const kanbanStore = {
try {
// Find the column to get its default status
const column = columns.find((c) => c.id === columnId);
const status = column?.defaultStatus || 'pending';
// Create the task
const newTask = await tasksApi.createTask({
@ -280,9 +453,12 @@ export const kanbanStore = {
* Clear all state (for logout)
*/
clear() {
boards = [];
currentBoardId = null;
columns = [];
tasksByColumn = {};
loading = false;
boardsLoading = false;
error = null;
},
};

View file

@ -1,16 +1,64 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import type { TaskPriority } from '@todo/shared';
import { authStore } from '$lib/stores/auth.svelte';
import { kanbanStore } from '$lib/stores/kanban.svelte';
import { KanbanBoard, KanbanFilters } from '$lib/components/kanban';
import { KanbanBoard, KanbanFilters, BoardNavigation } from '$lib/components/kanban';
// Filter state
let filterPriorities = $state<TaskPriority[]>([]);
let filterProjectId = $state<string | null>(null);
let filterLabelIds = $state<string[]>([]);
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 = [];
@ -19,52 +67,274 @@
filterSearchQuery = '';
}
let hasActiveFilters = $derived(
filterPriorities.length > 0 ||
filterProjectId !== null ||
filterLabelIds.length > 0 ||
filterSearchQuery.trim() !== ''
);
// Board operations
async function handleSelectBoard(boardId: string) {
await kanbanStore.selectBoard(boardId);
}
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 () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Fetch kanban data (columns + tasks grouped by column)
await kanbanStore.fetchKanbanData();
// Check initial mobile state
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(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', checkMobile);
}
});
</script>
<svelte:head>
<title>Kanban Board - Todo</title>
<title>{boardTitle} - Todo</title>
</svelte:head>
<div class="kanban-page h-full">
<header class="mb-4">
<h1 class="text-2xl font-bold text-foreground">Kanban Board</h1>
<p class="text-muted-foreground text-sm mt-1">
Ziehe Aufgaben zwischen Spalten, um ihren Status zu ändern
</p>
</header>
<div class="mb-4">
<KanbanFilters
selectedPriorities={filterPriorities}
selectedProjectId={filterProjectId}
selectedLabelIds={filterLabelIds}
searchQuery={filterSearchQuery}
onPrioritiesChange={(priorities) => (filterPriorities = priorities)}
onProjectChange={(projectId) => (filterProjectId = projectId)}
onLabelsChange={(labelIds) => (filterLabelIds = labelIds)}
onSearchChange={(query) => (filterSearchQuery = query)}
onClearFilters={clearFilters}
<div class="kanban-page">
<!-- Board Navigation - Top on Desktop -->
{#if !isMobile}
<BoardNavigation
boards={kanbanStore.boards}
currentBoardId={kanbanStore.currentBoardId}
loading={kanbanStore.boardsLoading}
position="top"
onSelectBoard={handleSelectBoard}
onCreateBoard={openCreateBoard}
/>
{/if}
<!-- Header with editable title -->
<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>
<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'
: ''}"
>
<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" />
</svg>
Filter
{#if hasActiveFilters}
<span
class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold rounded-full bg-primary-foreground text-primary"
>
{filterPriorities.length +
(filterProjectId ? 1 : 0) +
filterLabelIds.length +
(filterSearchQuery ? 1 : 0)}
</span>
{/if}
</button>
</div>
<div class="board-container">
<!-- Collapsible Filters -->
{#if showFilters}
<div class="mb-6 px-4 sm:px-6 lg:px-8 animate-in slide-in-from-top-2 duration-200">
<KanbanFilters
selectedPriorities={filterPriorities}
selectedProjectId={filterProjectId}
selectedLabelIds={filterLabelIds}
searchQuery={filterSearchQuery}
onPrioritiesChange={(priorities) => (filterPriorities = priorities)}
onProjectChange={(projectId) => (filterProjectId = projectId)}
onLabelsChange={(labelIds) => (filterLabelIds = labelIds)}
onSearchChange={(query) => (filterSearchQuery = query)}
onClearFilters={clearFilters}
/>
</div>
{/if}
<!-- Board Container -->
<div class="board-container" class:mobile-bottom-padding={isMobile}>
<KanbanBoard {filterPriorities} {filterProjectId} {filterLabelIds} {filterSearchQuery} />
</div>
<!-- Board Navigation - Bottom on Mobile -->
{#if isMobile}
<BoardNavigation
boards={kanbanStore.boards}
currentBoardId={kanbanStore.currentBoardId}
loading={kanbanStore.boardsLoading}
position="bottom"
onSelectBoard={handleSelectBoard}
onCreateBoard={openCreateBoard}
/>
{/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 {
display: flex;
flex-direction: column;
height: calc(100vh - 140px);
}
.board-container {
@ -72,4 +342,311 @@
min-height: 0;
overflow: hidden;
}
.board-container.mobile-bottom-padding {
padding-bottom: 70px; /* Space for fixed BoardNavigation */
}
/* Glass-Pill filter button */
.filter-button {
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;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
color: #6b7280;
}
:global(.dark) .filter-button {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #9ca3af;
}
.filter-button:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
color: #374151;
}
:global(.dark) .filter-button:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
color: #f3f4f6;
}
.filter-button.active {
background: #8b5cf6;
border-color: #8b5cf6;
color: white;
box-shadow:
0 4px 6px -1px rgba(139, 92, 246, 0.3),
0 2px 4px -1px rgba(139, 92, 246, 0.2);
}
.filter-button.active:hover {
background: #7c3aed;
border-color: #7c3aed;
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;
}
.slide-in-from-top-2 {
--tw-enter-translate-y: -0.5rem;
}
@keyframes animateIn {
from {
opacity: 0;
transform: translateY(var(--tw-enter-translate-y, 0));
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -1,11 +1,43 @@
import type { TaskStatus } from './task';
export interface KanbanColumn {
// Kanban Board
export interface KanbanBoard {
id: string;
userId: string;
projectId?: string | null;
name: string;
color: string;
icon?: string | null;
order: number;
isGlobal: boolean;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface CreateBoardInput {
name: string;
projectId?: string;
color?: string;
icon?: string;
}
export interface UpdateBoardInput {
name?: string;
color?: string;
icon?: string;
}
export interface ReorderBoardsInput {
boardIds: string[];
}
// Kanban Column
export interface KanbanColumn {
id: string;
userId: string;
boardId: string;
name: string;
color: string;
order: number;
isDefault: boolean;
defaultStatus?: TaskStatus | null;
@ -16,8 +48,8 @@ export interface KanbanColumn {
export interface CreateColumnInput {
name: string;
boardId: string;
color?: string;
projectId?: string;
isDefault?: boolean;
defaultStatus?: TaskStatus;
autoComplete?: boolean;