mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
1ac74c9bf5
commit
c88626d26b
23 changed files with 2678 additions and 410 deletions
|
|
@ -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';
|
||||
|
|
|
|||
32
apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts
Normal file
32
apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts
Normal 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;
|
||||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
21
apps/todo/apps/backend/src/kanban/dto/create-board.dto.ts
Normal file
21
apps/todo/apps/backend/src/kanban/dto/create-board.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { IsString, IsArray } from 'class-validator';
|
||||
|
||||
export class ReorderBoardsDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
boardIds: string[];
|
||||
}
|
||||
18
apps/todo/apps/backend/src/kanban/dto/update-board.dto.ts
Normal file
18
apps/todo/apps/backend/src/kanban/dto/update-board.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue