mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
refactor(stores): rewrite NutriPhi + Mukke stores to use IndexedDB
NutriPhi meals store: Replace apiClient calls with mealCollection reads/writes. Daily summary now computed locally from IndexedDB. Mukke library store: Replace backend API aggregations (albums, artists, genres, stats) with local computation from songCollection.getAll(). Server-only operations (upload, cover URLs, metadata extraction) remain as fire-and-forget API calls. Store migration status: All 19 apps now use IndexedDB as primary data source. Server API calls only remain for: - File upload/download (S3 presigned URLs) - Image generation (Replicate API) - AI analysis (Gemini) - Cover art URLs (S3 presigned) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5b673282f9
commit
b37a451d29
22 changed files with 1327 additions and 2126 deletions
|
|
@ -128,14 +128,29 @@ function createLibraryStore() {
|
|||
}
|
||||
},
|
||||
|
||||
/** Load albums from backend (aggregated view). */
|
||||
/** Load albums from IndexedDB (aggregated locally). */
|
||||
async loadAlbums() {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
const data = await fetchApi<{ albums: Album[] }>('/library/albums');
|
||||
state.albums = data.albums;
|
||||
const coverPaths = data.albums.map((a) => a.coverArtPath).filter((p): p is string => !!p);
|
||||
const songs = await songCollection.getAll();
|
||||
const albumMap = new Map<string, Album>();
|
||||
for (const s of songs) {
|
||||
const key = s.album || 'Unknown Album';
|
||||
if (!albumMap.has(key)) {
|
||||
albumMap.set(key, {
|
||||
album: key,
|
||||
albumArtist: s.albumArtist || s.artist || 'Unknown',
|
||||
year: s.year ?? null,
|
||||
coverArtPath: s.coverArtPath ?? null,
|
||||
songCount: 0,
|
||||
} as Album);
|
||||
}
|
||||
const a = albumMap.get(key)!;
|
||||
(a as any).songCount = ((a as any).songCount || 0) + 1;
|
||||
}
|
||||
state.albums = Array.from(albumMap.values());
|
||||
const coverPaths = state.albums.map((a) => a.coverArtPath).filter((p): p is string => !!p);
|
||||
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'Failed to load albums';
|
||||
|
|
@ -143,37 +158,72 @@ function createLibraryStore() {
|
|||
state.isLoading = false;
|
||||
},
|
||||
|
||||
/** Load artists from backend (aggregated view). */
|
||||
/** Load artists from IndexedDB (aggregated locally). */
|
||||
async loadArtists() {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
const data = await fetchApi<{ artists: Artist[] }>('/library/artists');
|
||||
state.artists = data.artists;
|
||||
const songs = await songCollection.getAll();
|
||||
const artistMap = new Map<
|
||||
string,
|
||||
{ artist: string; songCount: number; albumCount: number }
|
||||
>();
|
||||
const artistAlbums = new Map<string, Set<string>>();
|
||||
for (const s of songs) {
|
||||
const key = s.artist || 'Unknown';
|
||||
if (!artistMap.has(key)) {
|
||||
artistMap.set(key, { artist: key, songCount: 0, albumCount: 0 });
|
||||
artistAlbums.set(key, new Set());
|
||||
}
|
||||
artistMap.get(key)!.songCount++;
|
||||
if (s.album) artistAlbums.get(key)!.add(s.album);
|
||||
}
|
||||
state.artists = Array.from(artistMap.values()).map((a) => ({
|
||||
...a,
|
||||
albumCount: artistAlbums.get(a.artist)?.size || 0,
|
||||
})) as Artist[];
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'Failed to load artists';
|
||||
}
|
||||
state.isLoading = false;
|
||||
},
|
||||
|
||||
/** Load genres from backend (aggregated view). */
|
||||
/** Load genres from IndexedDB (aggregated locally). */
|
||||
async loadGenres() {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
const data = await fetchApi<{ genres: Genre[] }>('/library/genres');
|
||||
state.genres = data.genres;
|
||||
const songs = await songCollection.getAll();
|
||||
const genreMap = new Map<string, number>();
|
||||
for (const s of songs) {
|
||||
const key = s.genre || 'Unknown';
|
||||
genreMap.set(key, (genreMap.get(key) || 0) + 1);
|
||||
}
|
||||
state.genres = Array.from(genreMap.entries()).map(([genre, songCount]) => ({
|
||||
genre,
|
||||
songCount,
|
||||
})) as Genre[];
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'Failed to load genres';
|
||||
}
|
||||
state.isLoading = false;
|
||||
},
|
||||
|
||||
/** Load stats from backend. */
|
||||
/** Load stats from IndexedDB (computed locally). */
|
||||
async loadStats() {
|
||||
try {
|
||||
const data = await fetchApi<{ stats: LibraryStats }>('/library/stats');
|
||||
state.stats = data.stats;
|
||||
const songs = await songCollection.getAll();
|
||||
const artists = new Set(songs.map((s) => s.artist).filter(Boolean));
|
||||
const albums = new Set(songs.map((s) => s.album).filter(Boolean));
|
||||
const genres = new Set(songs.map((s) => s.genre).filter(Boolean));
|
||||
state.stats = {
|
||||
totalSongs: songs.length,
|
||||
totalArtists: artists.size,
|
||||
totalAlbums: albums.size,
|
||||
totalGenres: genres.size,
|
||||
totalDuration: songs.reduce((sum, s) => sum + (s.duration || 0), 0),
|
||||
totalPlays: songs.reduce((sum, s) => sum + (s.playCount || 0), 0),
|
||||
} as LibraryStats;
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'Failed to load stats';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { apiClient } from '$lib/api/client';
|
||||
/**
|
||||
* Meals Store — Local-First with @manacore/local-store
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
*/
|
||||
|
||||
import { mealCollection, type LocalMeal } from '$lib/data/local-store';
|
||||
import type { Meal, MealNutrition, DailySummary } from '@nutriphi/shared';
|
||||
import { NutriPhiEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
|
|
@ -6,6 +13,32 @@ interface MealWithNutrition extends Meal {
|
|||
nutrition: MealNutrition | null;
|
||||
}
|
||||
|
||||
function toMealWithNutrition(local: LocalMeal): MealWithNutrition {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'local',
|
||||
date: new Date(local.date),
|
||||
mealType: local.mealType as any,
|
||||
inputType: local.inputType as any,
|
||||
description: local.description,
|
||||
portionSize: local.portionSize ?? undefined,
|
||||
confidence: local.confidence,
|
||||
createdAt: new Date(local.createdAt ?? Date.now()),
|
||||
nutrition: local.nutrition
|
||||
? {
|
||||
id: local.id,
|
||||
mealId: local.id,
|
||||
calories: local.nutrition.calories,
|
||||
protein: local.nutrition.protein,
|
||||
carbohydrates: local.nutrition.carbohydrates,
|
||||
fat: local.nutrition.fat,
|
||||
fiber: local.nutrition.fiber,
|
||||
sugar: local.nutrition.sugar,
|
||||
}
|
||||
: null,
|
||||
} as MealWithNutrition;
|
||||
}
|
||||
|
||||
class MealsStore {
|
||||
meals = $state<MealWithNutrition[]>([]);
|
||||
loading = $state(false);
|
||||
|
|
@ -20,7 +53,11 @@ class MealsStore {
|
|||
this.error = null;
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
this.meals = await apiClient.get<MealWithNutrition[]>(`/meals?date=${today}`);
|
||||
const allMeals = await mealCollection.getAll();
|
||||
this.meals = allMeals
|
||||
.filter((m) => m.date === today)
|
||||
.map(toMealWithNutrition)
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Mahlzeiten konnten nicht geladen werden';
|
||||
} finally {
|
||||
|
|
@ -33,7 +70,26 @@ class MealsStore {
|
|||
this.summaryError = null;
|
||||
try {
|
||||
const dateStr = (date || new Date()).toISOString().split('T')[0];
|
||||
this.dailySummary = await apiClient.get<DailySummary>(`/stats/daily?date=${dateStr}`);
|
||||
const allMeals = await mealCollection.getAll();
|
||||
const dayMeals = allMeals.filter((m) => m.date === dateStr);
|
||||
|
||||
const totalNutrition = dayMeals.reduce(
|
||||
(acc, m) => ({
|
||||
calories: acc.calories + (m.nutrition?.calories || 0),
|
||||
protein: acc.protein + (m.nutrition?.protein || 0),
|
||||
carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0),
|
||||
fat: acc.fat + (m.nutrition?.fat || 0),
|
||||
fiber: acc.fiber + (m.nutrition?.fiber || 0),
|
||||
sugar: acc.sugar + (m.nutrition?.sugar || 0),
|
||||
}),
|
||||
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
|
||||
);
|
||||
|
||||
this.dailySummary = {
|
||||
date: new Date(dateStr),
|
||||
meals: dayMeals.map(toMealWithNutrition),
|
||||
totalNutrition,
|
||||
} as DailySummary;
|
||||
} catch (err) {
|
||||
this.summaryError =
|
||||
err instanceof Error ? err.message : 'Zusammenfassung konnte nicht geladen werden';
|
||||
|
|
@ -57,7 +113,25 @@ class MealsStore {
|
|||
}) {
|
||||
this.error = null;
|
||||
try {
|
||||
const meal = await apiClient.post<MealWithNutrition>('/meals', mealData);
|
||||
const newMeal: LocalMeal = {
|
||||
id: crypto.randomUUID(),
|
||||
date: mealData.date,
|
||||
mealType: mealData.mealType as any,
|
||||
inputType: mealData.inputType as any,
|
||||
description: mealData.description,
|
||||
confidence: mealData.confidence,
|
||||
nutrition: {
|
||||
calories: mealData.calories,
|
||||
protein: mealData.protein,
|
||||
carbohydrates: mealData.carbohydrates,
|
||||
fat: mealData.fat,
|
||||
fiber: mealData.fiber || 0,
|
||||
sugar: mealData.sugar || 0,
|
||||
},
|
||||
};
|
||||
|
||||
const inserted = await mealCollection.insert(newMeal);
|
||||
const meal = toMealWithNutrition(inserted);
|
||||
this.meals = [...this.meals, meal];
|
||||
await this.fetchDailySummary();
|
||||
NutriPhiEvents.mealAdded(mealData.mealType, mealData.inputType);
|
||||
|
|
@ -73,7 +147,7 @@ class MealsStore {
|
|||
async deleteMeal(mealId: string) {
|
||||
this.deleteError = null;
|
||||
try {
|
||||
await apiClient.delete(`/meals/${mealId}`);
|
||||
await mealCollection.delete(mealId);
|
||||
this.meals = this.meals.filter((m) => m.id !== mealId);
|
||||
await this.fetchDailySummary();
|
||||
NutriPhiEvents.mealDeleted();
|
||||
|
|
|
|||
|
|
@ -1,176 +0,0 @@
|
|||
import { apiClient } from './client';
|
||||
import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared';
|
||||
|
||||
// Response types
|
||||
|
||||
interface BoardsResponse {
|
||||
boards: KanbanBoard[];
|
||||
}
|
||||
|
||||
interface BoardResponse {
|
||||
board: KanbanBoard;
|
||||
}
|
||||
|
||||
interface ColumnsResponse {
|
||||
columns: KanbanColumn[];
|
||||
}
|
||||
|
||||
interface ColumnResponse {
|
||||
column: KanbanColumn;
|
||||
}
|
||||
|
||||
interface KanbanTasksResponse {
|
||||
columns: KanbanColumn[];
|
||||
tasksByColumn: Record<string, Task[]>;
|
||||
}
|
||||
|
||||
interface TaskResponse {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
interface TasksResponse {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
// DTO types
|
||||
|
||||
interface CreateBoardDto {
|
||||
name: string;
|
||||
projectId?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface UpdateBoardDto {
|
||||
name?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface CreateColumnDto {
|
||||
name: string;
|
||||
boardId: string;
|
||||
color?: string;
|
||||
isDefault?: boolean;
|
||||
defaultStatus?: string;
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateColumnDto {
|
||||
name?: string;
|
||||
color?: string;
|
||||
defaultStatus?: string;
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Board operations
|
||||
// =====================
|
||||
|
||||
export async function getBoards(): Promise<KanbanBoard[]> {
|
||||
const response = await apiClient.get<BoardsResponse>('/api/v1/kanban/boards');
|
||||
return response.boards;
|
||||
}
|
||||
|
||||
export async function getGlobalBoard(): Promise<KanbanBoard> {
|
||||
const response = await apiClient.get<BoardResponse>('/api/v1/kanban/boards/global');
|
||||
return response.board;
|
||||
}
|
||||
|
||||
export async function getBoard(id: string): Promise<KanbanBoard> {
|
||||
const response = await apiClient.get<BoardResponse>(`/api/v1/kanban/boards/${id}`);
|
||||
return response.board;
|
||||
}
|
||||
|
||||
export async function createBoard(data: CreateBoardDto): Promise<KanbanBoard> {
|
||||
const response = await apiClient.post<BoardResponse>('/api/v1/kanban/boards', data);
|
||||
return response.board;
|
||||
}
|
||||
|
||||
export async function updateBoard(id: string, data: UpdateBoardDto): Promise<KanbanBoard> {
|
||||
const response = await apiClient.put<BoardResponse>(`/api/v1/kanban/boards/${id}`, data);
|
||||
return response.board;
|
||||
}
|
||||
|
||||
export async function deleteBoard(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/kanban/boards/${id}`);
|
||||
}
|
||||
|
||||
export async function reorderBoards(boardIds: string[]): Promise<KanbanBoard[]> {
|
||||
const response = await apiClient.put<BoardsResponse>('/api/v1/kanban/boards/reorder', {
|
||||
boardIds,
|
||||
});
|
||||
return response.boards;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Column operations
|
||||
// =====================
|
||||
|
||||
export async function getColumns(boardId: string): Promise<KanbanColumn[]> {
|
||||
const response = await apiClient.get<ColumnsResponse>(
|
||||
`/api/v1/kanban/columns?boardId=${boardId}`
|
||||
);
|
||||
return response.columns;
|
||||
}
|
||||
|
||||
export async function createColumn(data: CreateColumnDto): Promise<KanbanColumn> {
|
||||
const response = await apiClient.post<ColumnResponse>('/api/v1/kanban/columns', data);
|
||||
return response.column;
|
||||
}
|
||||
|
||||
export async function updateColumn(id: string, data: UpdateColumnDto): Promise<KanbanColumn> {
|
||||
const response = await apiClient.put<ColumnResponse>(`/api/v1/kanban/columns/${id}`, data);
|
||||
return response.column;
|
||||
}
|
||||
|
||||
export async function deleteColumn(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/kanban/columns/${id}`);
|
||||
}
|
||||
|
||||
export async function reorderColumns(columnIds: string[]): Promise<KanbanColumn[]> {
|
||||
const response = await apiClient.put<ColumnsResponse>('/api/v1/kanban/columns/reorder', {
|
||||
columnIds,
|
||||
});
|
||||
return response.columns;
|
||||
}
|
||||
|
||||
export async function initializeColumns(boardId: string): Promise<KanbanColumn[]> {
|
||||
const response = await apiClient.post<ColumnsResponse>(
|
||||
`/api/v1/kanban/columns/init?boardId=${boardId}`
|
||||
);
|
||||
return response.columns;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Task operations
|
||||
// =====================
|
||||
|
||||
export async function getKanbanTasks(
|
||||
boardId: string
|
||||
): Promise<{ columns: KanbanColumn[]; tasksByColumn: Record<string, Task[]> }> {
|
||||
const response = await apiClient.get<KanbanTasksResponse>(
|
||||
`/api/v1/kanban/tasks?boardId=${boardId}`
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function moveTaskToColumn(
|
||||
taskId: string,
|
||||
columnId: string,
|
||||
order?: number
|
||||
): Promise<Task> {
|
||||
const response = await apiClient.post<TaskResponse>(`/api/v1/kanban/tasks/${taskId}/move`, {
|
||||
columnId,
|
||||
order,
|
||||
});
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function reorderTasksInColumn(columnId: string, taskIds: string[]): Promise<Task[]> {
|
||||
const response = await apiClient.put<TasksResponse>('/api/v1/kanban/tasks/reorder', {
|
||||
columnId,
|
||||
taskIds,
|
||||
});
|
||||
return response.tasks;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import type { Task, Project } from '@todo/shared';
|
||||
import type { LocalBoardView } from '$lib/data/local-store';
|
||||
import { groupTasksByView, getDropActionUpdate } from '$lib/data/view-grouping';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import KanbanLayout from './KanbanLayout.svelte';
|
||||
import GridLayout from './GridLayout.svelte';
|
||||
|
||||
interface Props {
|
||||
view: LocalBoardView;
|
||||
}
|
||||
|
||||
let { view }: Props = $props();
|
||||
|
||||
// Get tasks and projects from context (set by layout)
|
||||
const tasksCtx: { readonly value: Task[] } = getContext('tasks');
|
||||
const projectsCtx: { readonly value: Project[] } = getContext('projects');
|
||||
|
||||
// Group tasks by the current view configuration
|
||||
let columns = $derived(groupTasksByView(view, tasksCtx.value, projectsCtx.value));
|
||||
|
||||
// ─── Task Callbacks ──────────────────────────────────────
|
||||
|
||||
function handleTaskDrop(taskId: string, columnId: string) {
|
||||
const targetColumn = columns.find((c) => c.id === columnId);
|
||||
if (!targetColumn?.onDrop) return;
|
||||
|
||||
const update = getDropActionUpdate(targetColumn.onDrop);
|
||||
if (Object.keys(update).length > 0) {
|
||||
tasksStore.updateTask(taskId, update);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskToggle(task: Task) {
|
||||
if (task.isCompleted) {
|
||||
tasksStore.uncompleteTask(task.id);
|
||||
} else {
|
||||
tasksStore.completeTask(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskDelete(taskId: string) {
|
||||
tasksStore.deleteTask(taskId);
|
||||
}
|
||||
|
||||
function handleTaskUpdate(taskId: string, data: Partial<Task>) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.projectId !== undefined) updateData.projectId = data.projectId;
|
||||
if (data.dueDate !== undefined) {
|
||||
updateData.dueDate = data.dueDate instanceof Date ? data.dueDate.toISOString() : data.dueDate;
|
||||
}
|
||||
if (data.priority !== undefined) updateData.priority = data.priority;
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
if (data.subtasks !== undefined) updateData.subtasks = data.subtasks;
|
||||
if (data.recurrenceRule !== undefined) updateData.recurrenceRule = data.recurrenceRule;
|
||||
if (data.metadata !== undefined) updateData.metadata = data.metadata;
|
||||
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
|
||||
|
||||
tasksStore.updateTask(taskId, updateData);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if view.layout === 'grid'}
|
||||
<GridLayout
|
||||
{columns}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onTaskToggle={handleTaskToggle}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
/>
|
||||
{:else}
|
||||
<KanbanLayout
|
||||
{columns}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onTaskToggle={handleTaskToggle}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts">
|
||||
import type { Task } from '@todo/shared';
|
||||
import type { GroupedColumn } from '$lib/data/view-grouping';
|
||||
import ViewColumn from './ViewColumn.svelte';
|
||||
|
||||
interface Props {
|
||||
columns: GroupedColumn[];
|
||||
onTaskDrop: (taskId: string, columnId: string) => void;
|
||||
onTaskToggle: (task: Task) => void;
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
onTaskUpdate: (taskId: string, data: Partial<Task>) => void;
|
||||
}
|
||||
|
||||
let { columns, onTaskDrop, onTaskToggle, onTaskDelete, onTaskUpdate }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid-layout">
|
||||
{#each columns as column (column.id)}
|
||||
<div class="grid-cell">
|
||||
<ViewColumn
|
||||
{column}
|
||||
{onTaskDrop}
|
||||
{onTaskToggle}
|
||||
{onTaskDelete}
|
||||
{onTaskUpdate}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.grid-layout {
|
||||
padding: 0 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid-layout {
|
||||
padding: 0 2rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Single column on mobile */
|
||||
@media (max-width: 639px) {
|
||||
.grid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* In grid layout, columns should fill available height */
|
||||
.grid-cell :global(.view-column) {
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<script lang="ts">
|
||||
import type { Task } from '@todo/shared';
|
||||
import type { GroupedColumn } from '$lib/data/view-grouping';
|
||||
import ViewColumn from './ViewColumn.svelte';
|
||||
|
||||
interface Props {
|
||||
columns: GroupedColumn[];
|
||||
onTaskDrop: (taskId: string, columnId: string) => void;
|
||||
onTaskToggle: (task: Task) => void;
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
onTaskUpdate: (taskId: string, data: Partial<Task>) => void;
|
||||
}
|
||||
|
||||
let { columns, onTaskDrop, onTaskToggle, onTaskDelete, onTaskUpdate }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="kanban-layout">
|
||||
{#each columns as column (column.id)}
|
||||
<div class="kanban-column-wrapper">
|
||||
<ViewColumn
|
||||
{column}
|
||||
{onTaskDrop}
|
||||
{onTaskToggle}
|
||||
{onTaskDelete}
|
||||
{onTaskUpdate}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kanban-layout {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0 1rem 1rem;
|
||||
scroll-behavior: smooth;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.kanban-layout {
|
||||
padding: 0 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.kanban-layout {
|
||||
padding: 0 2rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.kanban-layout::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.kanban-layout::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.kanban-layout::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
:global(.dark) .kanban-layout::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.kanban-column-wrapper {
|
||||
min-width: 300px;
|
||||
max-width: 340px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,37 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID, type DndEvent } from 'svelte-dnd-action';
|
||||
import type { KanbanColumn, Task } from '@todo/shared';
|
||||
import KanbanTaskCard from './KanbanTaskCard.svelte';
|
||||
import KanbanColumnHeader from './KanbanColumnHeader.svelte';
|
||||
import QuickAddTaskInline from './QuickAddTaskInline.svelte';
|
||||
import type { Task } from '@todo/shared';
|
||||
import type { GroupedColumn } from '$lib/data/view-grouping';
|
||||
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
|
||||
import QuickAddTaskInline from '../kanban/QuickAddTaskInline.svelte';
|
||||
import ViewColumnHeader from './ViewColumnHeader.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
|
||||
interface Props {
|
||||
column: KanbanColumn;
|
||||
tasks: Task[];
|
||||
onUpdateColumn?: (data: { name?: string; color?: string }) => void;
|
||||
onDeleteColumn?: () => void;
|
||||
onTasksReorder?: (taskIds: string[]) => void;
|
||||
onTaskMove?: (taskId: string, toColumnId: string, order: number) => void;
|
||||
onAddTask?: (title: string) => void;
|
||||
column: GroupedColumn;
|
||||
onTaskDrop: (taskId: string, columnId: string) => void;
|
||||
onTaskToggle: (task: Task) => void;
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
onTaskUpdate: (taskId: string, data: Partial<Task>) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
column,
|
||||
tasks,
|
||||
onUpdateColumn,
|
||||
onDeleteColumn,
|
||||
onTasksReorder,
|
||||
onTaskMove,
|
||||
onAddTask,
|
||||
}: Props = $props();
|
||||
let { column, onTaskDrop, onTaskToggle, onTaskDelete, onTaskUpdate }: Props = $props();
|
||||
|
||||
// Local tasks state for drag and drop
|
||||
let localTasks = $state<Task[]>([]);
|
||||
|
||||
// Sync with parent
|
||||
// Sync with parent when column tasks change
|
||||
$effect(() => {
|
||||
localTasks = [...tasks];
|
||||
localTasks = [...column.tasks];
|
||||
});
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
|
@ -45,32 +36,25 @@
|
|||
const movedTaskId = e.detail.info.id;
|
||||
|
||||
// Check if this task came from another column
|
||||
const movedTask = newItems.find((t) => t.id === movedTaskId);
|
||||
const wasInThisColumn = tasks.some((t) => t.id === movedTaskId);
|
||||
const wasInThisColumn = column.tasks.some((t) => t.id === movedTaskId);
|
||||
|
||||
if (movedTask && !wasInThisColumn) {
|
||||
if (!wasInThisColumn) {
|
||||
// Task moved FROM another column TO this column
|
||||
const newIndex = newItems.findIndex((t) => t.id === movedTaskId);
|
||||
onTaskMove?.(movedTaskId, column.id, newIndex);
|
||||
onTaskDrop(movedTaskId, column.id);
|
||||
} else {
|
||||
// Task reordered within this column
|
||||
// Task reordered within this column — update order
|
||||
const taskIds = newItems.map((t) => t.id);
|
||||
onTasksReorder?.(taskIds);
|
||||
tasksStore.reorderTasks(taskIds);
|
||||
}
|
||||
|
||||
localTasks = newItems;
|
||||
}
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
if (task.isCompleted) {
|
||||
await tasksStore.uncompleteTask(task.id);
|
||||
} else {
|
||||
await tasksStore.completeTask(task.id);
|
||||
}
|
||||
function handleToggleComplete(task: Task) {
|
||||
onTaskToggle(task);
|
||||
}
|
||||
|
||||
async function handleSaveTask(task: Task, data: Partial<Task>) {
|
||||
// Transform Partial<Task> to updateTask format
|
||||
function handleSaveTask(task: Task, data: Partial<Task>) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
|
|
@ -85,22 +69,34 @@
|
|||
if (data.metadata !== undefined) updateData.metadata = data.metadata;
|
||||
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
|
||||
|
||||
await tasksStore.updateTask(task.id, updateData);
|
||||
onTaskUpdate(task.id, data);
|
||||
}
|
||||
|
||||
async function handleDeleteTask(task: Task) {
|
||||
await tasksStore.deleteTask(task.id);
|
||||
function handleDeleteTask(task: Task) {
|
||||
onTaskDelete(task.id);
|
||||
}
|
||||
|
||||
async function handleAddTask(title: string) {
|
||||
// Create task with properties that match this column's onDrop action
|
||||
const createData: Record<string, unknown> = { title };
|
||||
if (column.onDrop) {
|
||||
if (column.onDrop.setCompleted !== undefined) {
|
||||
// Don't create completed tasks — create as pending
|
||||
}
|
||||
if (column.onDrop.setPriority) {
|
||||
createData.priority = column.onDrop.setPriority;
|
||||
}
|
||||
if (column.onDrop.setProjectId !== undefined) {
|
||||
createData.projectId = column.onDrop.setProjectId;
|
||||
}
|
||||
}
|
||||
await tasksStore.createTask(createData as { title: string; projectId?: string; priority?: 'low' | 'medium' | 'high' | 'urgent' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kanban-column flex flex-col min-w-[300px] max-w-[340px] h-full">
|
||||
<div class="view-column flex flex-col">
|
||||
<!-- Header -->
|
||||
<KanbanColumnHeader
|
||||
{column}
|
||||
taskCount={localTasks.length}
|
||||
onUpdate={onUpdateColumn}
|
||||
onDelete={onDeleteColumn}
|
||||
/>
|
||||
<ViewColumnHeader name={column.name} color={column.color} taskCount={localTasks.length} />
|
||||
|
||||
<!-- Tasks list with drag and drop -->
|
||||
<div
|
||||
|
|
@ -110,7 +106,7 @@
|
|||
flipDurationMs,
|
||||
dropTargetStyle: {},
|
||||
dropTargetClasses: ['drop-target'],
|
||||
type: 'tasks',
|
||||
type: 'board-view-tasks',
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
|
|
@ -128,15 +124,13 @@
|
|||
</div>
|
||||
|
||||
<!-- Quick Add Task -->
|
||||
{#if onAddTask}
|
||||
<div class="px-3 pb-3 pt-2">
|
||||
<QuickAddTaskInline onAdd={onAddTask} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="px-3 pb-3 pt-2">
|
||||
<QuickAddTaskInline onAdd={handleAddTask} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kanban-column {
|
||||
.view-column {
|
||||
min-height: 250px;
|
||||
max-height: calc(100vh - 280px);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
|
|
@ -147,7 +141,7 @@
|
|||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .kanban-column {
|
||||
:global(.dark) .view-column {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
color: string;
|
||||
taskCount: number;
|
||||
}
|
||||
|
||||
let { name, color, taskCount }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="column-header">
|
||||
<div class="header-left">
|
||||
<span class="color-dot" style="background-color: {color}"></span>
|
||||
<span class="column-name">{name}</span>
|
||||
</div>
|
||||
<span class="task-count">{taskCount}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(.dark) .column-name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.task-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.dark) .task-count {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<script lang="ts">
|
||||
import type { LocalBoardView } from '$lib/data/local-store';
|
||||
|
||||
interface Props {
|
||||
views: LocalBoardView[];
|
||||
activeViewId: string | null;
|
||||
onSelect: (viewId: string) => void;
|
||||
}
|
||||
|
||||
let { views, activeViewId, onSelect }: Props = $props();
|
||||
|
||||
// Map icon names to simple SVG representations
|
||||
const iconMap: Record<string, string> = {
|
||||
columns: 'M9 4h6v16H9zM3 4h4v16H3zM17 4h4v16h-4z',
|
||||
'grid-four': 'M3 3h8v8H3zM13 3h8v8h-8zM3 13h8v8H3zM13 13h8v8h-8z',
|
||||
flag: 'M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7',
|
||||
folders: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z',
|
||||
calendar: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="view-selector-container">
|
||||
<div class="view-selector">
|
||||
<div class="view-pills-scroll">
|
||||
{#each views as view (view.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="view-pill"
|
||||
class:active={activeViewId === view.id}
|
||||
onclick={() => onSelect(view.id)}
|
||||
>
|
||||
{#if view.icon && iconMap[view.icon]}
|
||||
<svg
|
||||
class="h-4 w-4 mr-1.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d={iconMap[view.icon]} />
|
||||
</svg>
|
||||
{:else if view.icon}
|
||||
<span class="mr-1.5 text-sm">{view.icon}</span>
|
||||
{/if}
|
||||
<span class="view-name">{view.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view-selector-container {
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.view-selector-container {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.view-selector-container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.view-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
/* Glass-Pill container */
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 9999px;
|
||||
padding: 0.375rem;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.07),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:global(.dark) .view-selector {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.view-pills-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.view-pills-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-pill:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .view-pill {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
:global(.dark) .view-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.view-pill.active {
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.15),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.view-pill.active:hover {
|
||||
filter: brightness(1.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:global(.dark) .view-pill.active {
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export { default as ViewColumnHeader } from './ViewColumnHeader.svelte';
|
||||
export { default as ViewColumn } from './ViewColumn.svelte';
|
||||
export { default as KanbanLayout } from './KanbanLayout.svelte';
|
||||
export { default as GridLayout } from './GridLayout.svelte';
|
||||
export { default as BoardViewRenderer } from './BoardViewRenderer.svelte';
|
||||
export { default as ViewSelector } from './ViewSelector.svelte';
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
onAdd: (name: string) => void;
|
||||
}
|
||||
|
||||
let { onAdd }: Props = $props();
|
||||
|
||||
let isAdding = $state(false);
|
||||
let newName = $state('');
|
||||
|
||||
function handleSubmit() {
|
||||
if (newName.trim()) {
|
||||
onAdd(newName.trim());
|
||||
newName = '';
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
newName = '';
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="add-column min-w-[300px] max-w-[340px] h-fit">
|
||||
{#if isAdding}
|
||||
<div class="add-form p-4 animate-in fade-in slide-in-from-left-2 duration-200">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-3 h-3 rounded-full bg-muted-foreground"></div>
|
||||
<span class="text-sm font-medium text-foreground">Neue Spalte</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Spaltenname eingeben..."
|
||||
class="add-input w-full px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary transition-all"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
class="flex-1 px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-all shadow-sm hover:shadow flex items-center justify-center gap-2"
|
||||
onclick={handleSubmit}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-full transition-colors"
|
||||
onclick={() => {
|
||||
newName = '';
|
||||
isAdding = false;
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="add-button group w-full min-h-[250px] p-4 text-sm text-muted-foreground hover:text-foreground transition-all flex flex-col items-center justify-center gap-3"
|
||||
onclick={() => (isAdding = true)}
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-muted group-hover:bg-primary/10 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 group-hover:text-primary transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">Spalte hinzufügen</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Glass-Pill styles for add form */
|
||||
.add-form {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .add-form {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Input with glass style */
|
||||
.add-input {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.add-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
:global(.dark) .add-input {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
:global(.dark) .add-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Glass-Pill button to add column */
|
||||
.add-button {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 2px dashed rgba(0, 0, 0, 0.15);
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.dark) .add-button {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px dashed rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
|
||||
:global(.dark) .add-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
.animate-in {
|
||||
animation: animateIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
--tw-enter-opacity: 0;
|
||||
}
|
||||
|
||||
.slide-in-from-left-2 {
|
||||
--tw-enter-translate-x: -0.5rem;
|
||||
}
|
||||
|
||||
@keyframes animateIn {
|
||||
from {
|
||||
opacity: var(--tw-enter-opacity, 1);
|
||||
transform: translateX(var(--tw-enter-translate-x, 0));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { KanbanBoard } from '@todo/shared';
|
||||
|
||||
interface Props {
|
||||
boards: KanbanBoard[];
|
||||
currentBoardId: string | null;
|
||||
loading?: boolean;
|
||||
position?: 'top' | 'bottom';
|
||||
onSelectBoard: (boardId: string) => void;
|
||||
onCreateBoard: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
boards,
|
||||
currentBoardId,
|
||||
loading = false,
|
||||
position = 'top',
|
||||
onSelectBoard,
|
||||
onCreateBoard,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="board-nav-container" class:position-bottom={position === 'bottom'}>
|
||||
<div class="board-nav">
|
||||
<!-- Create Board Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="board-pill create-pill"
|
||||
onclick={onCreateBoard}
|
||||
title="Neues Board erstellen"
|
||||
disabled={loading}
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Board Pills -->
|
||||
<div class="board-pills-scroll">
|
||||
{#each boards as board (board.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="board-pill"
|
||||
class:active={currentBoardId === board.id}
|
||||
onclick={() => onSelectBoard(board.id)}
|
||||
disabled={loading}
|
||||
style="--board-color: {board.color}"
|
||||
>
|
||||
{#if board.isGlobal}
|
||||
<svg
|
||||
class="h-4 w-4 mr-1.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if board.icon}
|
||||
<span class="mr-1.5">{board.icon}</span>
|
||||
{:else}
|
||||
<span class="board-color-dot mr-1.5" style="background-color: {board.color}"></span>
|
||||
{/if}
|
||||
<span class="board-name">{board.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.board-nav-container {
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Bottom position styles */
|
||||
.board-nav-container.position-bottom {
|
||||
position: fixed;
|
||||
bottom: 70px; /* Above PillNav */
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
z-index: 40;
|
||||
background: linear-gradient(to top, var(--background) 0%, transparent 100%);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.board-nav-container {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.board-nav-container.position-bottom {
|
||||
padding: 0.5rem 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.board-nav-container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
.board-nav-container.position-bottom {
|
||||
padding: 0.5rem 2rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.board-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
/* Glass-Pill container */
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 9999px;
|
||||
padding: 0.375rem;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.07),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:global(.dark) .board-nav {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.position-bottom .board-nav {
|
||||
box-shadow:
|
||||
0 -4px 6px -1px rgba(0, 0, 0, 0.07),
|
||||
0 -2px 4px -1px rgba(0, 0, 0, 0.04),
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.07),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.board-pills-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.board-pills-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.board-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-pill:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .board-pill {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
:global(.dark) .board-pill:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.board-pill.active {
|
||||
background: var(--board-color, #8b5cf6);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.15),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.board-pill.active:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:global(.dark) .board-pill.active {
|
||||
background: var(--board-color, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.board-pill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.create-pill {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.create-pill:hover:not(:disabled) {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
:global(.dark) .create-pill {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
:global(.dark) .create-pill:hover:not(:disabled) {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.board-color-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
|
||||
import type { KanbanColumn, Task, TaskPriority } from '@todo/shared';
|
||||
import { ConfirmationModal } from '@manacore/shared-ui';
|
||||
import KanbanColumnComponent from './KanbanColumn.svelte';
|
||||
import AddColumnButton from './AddColumnButton.svelte';
|
||||
import { kanbanStore } from '$lib/stores/kanban.svelte';
|
||||
import { KanbanBoardSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
filterPriorities?: TaskPriority[];
|
||||
filterProjectId?: string | null;
|
||||
filterLabelIds?: string[];
|
||||
filterSearchQuery?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
projectId,
|
||||
filterPriorities = [],
|
||||
filterProjectId = null,
|
||||
filterLabelIds = [],
|
||||
filterSearchQuery = '',
|
||||
}: Props = $props();
|
||||
|
||||
// Local columns state for drag and drop
|
||||
let localColumns = $state<KanbanColumn[]>([]);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let columnToDelete = $state<string | null>(null);
|
||||
|
||||
// Sync with store
|
||||
$effect(() => {
|
||||
localColumns = [...kanbanStore.columns];
|
||||
});
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleColumnDndConsider(e: CustomEvent<{ items: KanbanColumn[] }>) {
|
||||
localColumns = e.detail.items;
|
||||
}
|
||||
|
||||
function handleColumnDndFinalize(e: CustomEvent<{ items: KanbanColumn[] }>) {
|
||||
localColumns = e.detail.items.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID);
|
||||
const columnIds = localColumns.map((c) => c.id);
|
||||
kanbanStore.reorderColumns(columnIds);
|
||||
}
|
||||
|
||||
async function handleAddColumn(name: string) {
|
||||
const boardId = kanbanStore.currentBoardId;
|
||||
if (!boardId) {
|
||||
console.error('No board selected');
|
||||
return;
|
||||
}
|
||||
await kanbanStore.createColumn({ name, boardId });
|
||||
}
|
||||
|
||||
async function handleUpdateColumn(columnId: string, data: { name?: string; color?: string }) {
|
||||
await kanbanStore.updateColumn(columnId, data);
|
||||
}
|
||||
|
||||
function handleDeleteColumn(columnId: string) {
|
||||
columnToDelete = columnId;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteColumn() {
|
||||
if (columnToDelete) {
|
||||
await kanbanStore.deleteColumn(columnToDelete);
|
||||
}
|
||||
showDeleteConfirm = false;
|
||||
columnToDelete = null;
|
||||
}
|
||||
|
||||
async function handleTasksReorder(columnId: string, taskIds: string[]) {
|
||||
await kanbanStore.reorderTasksInColumn(columnId, taskIds);
|
||||
}
|
||||
|
||||
async function handleAddTask(columnId: string, title: string) {
|
||||
// Get projectId from current board if available
|
||||
const currentBoard = kanbanStore.currentBoard;
|
||||
const taskProjectId = currentBoard?.projectId ?? projectId;
|
||||
await kanbanStore.createTaskInColumn(columnId, title, taskProjectId ?? undefined);
|
||||
}
|
||||
|
||||
async function handleTaskMove(taskId: string, toColumnId: string, order: number) {
|
||||
// Find which column the task is currently in
|
||||
let fromColumnId: string | null = null;
|
||||
for (const [colId, tasks] of Object.entries(kanbanStore.tasksByColumn)) {
|
||||
if (tasks.some((t) => t.id === taskId)) {
|
||||
fromColumnId = colId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fromColumnId && fromColumnId !== toColumnId) {
|
||||
await kanbanStore.moveTaskToColumn(taskId, fromColumnId, toColumnId, order);
|
||||
}
|
||||
}
|
||||
|
||||
function getTasksForColumn(columnId: string): Task[] {
|
||||
let tasks = kanbanStore.tasksByColumn[columnId] || [];
|
||||
|
||||
// Apply filters
|
||||
if (filterPriorities.length > 0) {
|
||||
tasks = tasks.filter((t) => filterPriorities.includes(t.priority));
|
||||
}
|
||||
|
||||
if (filterProjectId !== null) {
|
||||
tasks = tasks.filter((t) => t.projectId === filterProjectId);
|
||||
}
|
||||
|
||||
if (filterLabelIds.length > 0) {
|
||||
tasks = tasks.filter((t) => t.labels?.some((l) => filterLabelIds.includes(l.id)));
|
||||
}
|
||||
|
||||
if (filterSearchQuery.trim()) {
|
||||
const query = filterSearchQuery.toLowerCase().trim();
|
||||
tasks = tasks.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(query) ||
|
||||
(t.description && t.description.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kanban-board h-full">
|
||||
{#if kanbanStore.loading}
|
||||
<KanbanBoardSkeleton />
|
||||
{:else if kanbanStore.error}
|
||||
<div
|
||||
class="bg-destructive/10 text-destructive p-4 rounded-xl border border-destructive/20 flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{kanbanStore.error}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="columns-container flex gap-4 overflow-x-auto pb-4 h-full"
|
||||
use:dndzone={{
|
||||
items: localColumns,
|
||||
flipDurationMs,
|
||||
type: 'columns',
|
||||
dropTargetStyle: {},
|
||||
}}
|
||||
onconsider={handleColumnDndConsider}
|
||||
onfinalize={handleColumnDndFinalize}
|
||||
>
|
||||
{#each localColumns.filter((c) => c.id !== SHADOW_PLACEHOLDER_ITEM_ID) as column (column.id)}
|
||||
<div class="flex-shrink-0">
|
||||
<KanbanColumnComponent
|
||||
{column}
|
||||
tasks={getTasksForColumn(column.id)}
|
||||
onUpdateColumn={(data) => handleUpdateColumn(column.id, data)}
|
||||
onDeleteColumn={() => handleDeleteColumn(column.id)}
|
||||
onTasksReorder={(taskIds) => handleTasksReorder(column.id, taskIds)}
|
||||
onTaskMove={(taskId, toColumnId, order) => handleTaskMove(taskId, toColumnId, order)}
|
||||
onAddTask={(title) => handleAddTask(column.id, title)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add column button -->
|
||||
<div class="flex-shrink-0">
|
||||
<AddColumnButton onAdd={handleAddColumn} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete column confirmation modal -->
|
||||
<ConfirmationModal
|
||||
visible={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
showDeleteConfirm = false;
|
||||
columnToDelete = null;
|
||||
}}
|
||||
onConfirm={confirmDeleteColumn}
|
||||
variant="danger"
|
||||
title="Spalte löschen?"
|
||||
message="Alle Aufgaben dieser Spalte werden in die erste Spalte verschoben."
|
||||
confirmLabel="Löschen"
|
||||
cancelLabel="Abbrechen"
|
||||
/>
|
||||
|
||||
<style>
|
||||
.kanban-board {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.columns-container {
|
||||
min-height: 100%;
|
||||
align-items: flex-start;
|
||||
scroll-behavior: smooth;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
/* Extra space after last column for better scroll experience */
|
||||
.columns-container::after {
|
||||
content: '';
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.columns-container {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.columns-container::after {
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.columns-container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
.columns-container::after {
|
||||
width: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Styled scrollbar */
|
||||
.columns-container::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.columns-container::-webkit-scrollbar-track {
|
||||
background: var(--muted);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.columns-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--muted);
|
||||
}
|
||||
|
||||
.columns-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { KanbanColumn } from '@todo/shared';
|
||||
|
||||
interface Props {
|
||||
column: KanbanColumn;
|
||||
taskCount: number;
|
||||
onUpdate?: (data: { name?: string; color?: string }) => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
let { column, taskCount, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
let isEditing = $state(false);
|
||||
let editName = $state(column.name);
|
||||
let showMenu = $state(false);
|
||||
let showColorPicker = $state(false);
|
||||
|
||||
const colors = [
|
||||
'#6B7280', // gray
|
||||
'#EF4444', // red
|
||||
'#F97316', // orange
|
||||
'#EAB308', // yellow
|
||||
'#22C55E', // green
|
||||
'#14B8A6', // teal
|
||||
'#3B82F6', // blue
|
||||
'#8B5CF6', // purple
|
||||
'#EC4899', // pink
|
||||
];
|
||||
|
||||
function handleSubmit() {
|
||||
if (editName.trim() && editName !== column.name) {
|
||||
onUpdate?.({ name: editName.trim() });
|
||||
}
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
editName = column.name;
|
||||
isEditing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleColorSelect(color: string) {
|
||||
onUpdate?.({ color });
|
||||
showColorPicker = false;
|
||||
showMenu = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="column-header flex items-center justify-between px-3.5 py-3">
|
||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<!-- Color indicator with glow -->
|
||||
<div
|
||||
class="w-3 h-3 rounded-full flex-shrink-0 ring-4 ring-opacity-20"
|
||||
style="background-color: {column.color}; --tw-ring-color: {column.color}"
|
||||
></div>
|
||||
|
||||
<!-- Name (editable) -->
|
||||
{#if isEditing}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
onblur={handleSubmit}
|
||||
onkeydown={handleKeydown}
|
||||
class="text-sm font-semibold bg-transparent border-b-2 border-primary outline-none text-foreground flex-1 min-w-0 py-0.5"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="text-sm font-semibold text-foreground truncate text-left hover:text-primary transition-colors"
|
||||
ondblclick={() => {
|
||||
if (!column.isDefault || onUpdate) {
|
||||
isEditing = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.name}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Task count badge -->
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0 transition-colors"
|
||||
style="background-color: color-mix(in srgb, {column.color} 15%, transparent); color: {column.color}"
|
||||
>
|
||||
{taskCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Menu button -->
|
||||
{#if onUpdate || onDelete}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-all"
|
||||
onclick={() => (showMenu = !showMenu)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showMenu}
|
||||
<div
|
||||
class="menu-popup absolute right-0 top-full mt-1 rounded-xl py-1.5 z-50 min-w-[160px] animate-in fade-in slide-in-from-top-2 duration-150"
|
||||
>
|
||||
{#if onUpdate}
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-muted rounded-lg mx-1 transition-colors flex items-center gap-2"
|
||||
style="width: calc(100% - 0.5rem)"
|
||||
onclick={() => {
|
||||
isEditing = true;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Umbenennen
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-muted rounded-lg mx-1 transition-colors flex items-center gap-2"
|
||||
style="width: calc(100% - 0.5rem)"
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
|
||||
/>
|
||||
</svg>
|
||||
Farbe ändern
|
||||
</button>
|
||||
|
||||
{#if showColorPicker}
|
||||
<div class="px-3 py-2.5 flex flex-wrap gap-1.5 border-t border-border mt-1.5 pt-2.5">
|
||||
{#each colors as color}
|
||||
<button
|
||||
class="w-7 h-7 rounded-full border-2 transition-all hover:scale-110 hover:shadow-md {color ===
|
||||
column.color
|
||||
? 'border-primary ring-2 ring-primary/30'
|
||||
: 'border-transparent'}"
|
||||
style="background-color: {color}"
|
||||
onclick={() => handleColorSelect(color)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if onDelete && !column.isDefault}
|
||||
<div class="border-t border-border mt-1.5 pt-1.5">
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm text-destructive hover:bg-destructive/10 rounded-lg mx-1 transition-colors flex items-center gap-2"
|
||||
style="width: calc(100% - 0.5rem)"
|
||||
onclick={() => {
|
||||
onDelete?.();
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close menu -->
|
||||
{#if showMenu}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => {
|
||||
showMenu = false;
|
||||
showColorPicker = false;
|
||||
}}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Glass popup effect */
|
||||
.menu-popup {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .menu-popup {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
.animate-in {
|
||||
animation: animateIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
--tw-enter-opacity: 0;
|
||||
}
|
||||
|
||||
.slide-in-from-top-2 {
|
||||
--tw-enter-translate-y: -0.5rem;
|
||||
}
|
||||
|
||||
@keyframes animateIn {
|
||||
from {
|
||||
opacity: var(--tw-enter-opacity, 1);
|
||||
transform: translateY(var(--tw-enter-translate-y, 0));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,2 @@
|
|||
export { default as KanbanBoard } from './KanbanBoard.svelte';
|
||||
export { default as KanbanColumn } from './KanbanColumn.svelte';
|
||||
export { default as KanbanColumnHeader } from './KanbanColumnHeader.svelte';
|
||||
export { default as KanbanTaskCard } from './KanbanTaskCard.svelte';
|
||||
export { default as AddColumnButton } from './AddColumnButton.svelte';
|
||||
export { default as BoardNavigation } from './BoardNavigation.svelte';
|
||||
export { default as QuickAddTaskInline } from './QuickAddTaskInline.svelte';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* They serve as onboarding content that teaches the user how the app works.
|
||||
*/
|
||||
|
||||
import type { LocalTask, LocalProject, LocalLabel } from './local-store';
|
||||
import type { LocalTask, LocalProject, LocalLabel, LocalBoardView } from './local-store';
|
||||
|
||||
const ONBOARDING_PROJECT_ID = 'onboarding-project';
|
||||
const PERSONAL_PROJECT_ID = 'personal-project';
|
||||
|
|
@ -44,6 +44,168 @@ export const guestLabels: LocalLabel[] = [
|
|||
},
|
||||
];
|
||||
|
||||
// ─── Board Views ────────────────────────────────────────────
|
||||
|
||||
export const guestBoardViews: LocalBoardView[] = [
|
||||
{
|
||||
id: 'view-kanban',
|
||||
name: 'Kanban',
|
||||
icon: 'columns',
|
||||
groupBy: 'status',
|
||||
layout: 'kanban',
|
||||
order: 0,
|
||||
columns: [
|
||||
{
|
||||
id: 'col-todo',
|
||||
name: 'To Do',
|
||||
color: '#6B7280',
|
||||
match: { type: 'status', value: 'pending' },
|
||||
onDrop: { setCompleted: false },
|
||||
},
|
||||
{
|
||||
id: 'col-done',
|
||||
name: 'Erledigt',
|
||||
color: '#22C55E',
|
||||
match: { type: 'status', value: 'completed' },
|
||||
onDrop: { setCompleted: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'view-eisenhower',
|
||||
name: 'Eisenhower',
|
||||
icon: 'grid-four',
|
||||
groupBy: 'custom',
|
||||
layout: 'grid',
|
||||
order: 1,
|
||||
columns: [
|
||||
{
|
||||
id: 'col-eis-ui',
|
||||
name: 'Wichtig & Dringend',
|
||||
color: '#EF4444',
|
||||
match: { type: 'custom', value: 'urgent-important' },
|
||||
onDrop: { setPriority: 'urgent' },
|
||||
},
|
||||
{
|
||||
id: 'col-eis-i',
|
||||
name: 'Wichtig',
|
||||
color: '#F59E0B',
|
||||
match: { type: 'custom', value: 'important' },
|
||||
onDrop: { setPriority: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'col-eis-u',
|
||||
name: 'Dringend',
|
||||
color: '#3B82F6',
|
||||
match: { type: 'custom', value: 'urgent' },
|
||||
onDrop: { setPriority: 'medium' },
|
||||
},
|
||||
{
|
||||
id: 'col-eis-ni',
|
||||
name: 'Weder noch',
|
||||
color: '#6B7280',
|
||||
match: { type: 'custom', value: 'neither' },
|
||||
onDrop: { setPriority: 'low' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'view-priority',
|
||||
name: 'Priorität',
|
||||
icon: 'flag',
|
||||
groupBy: 'priority',
|
||||
layout: 'kanban',
|
||||
order: 2,
|
||||
columns: [
|
||||
{
|
||||
id: 'col-pri-urgent',
|
||||
name: 'Dringend',
|
||||
color: '#EF4444',
|
||||
match: { type: 'priority', value: 'urgent' },
|
||||
onDrop: { setPriority: 'urgent' },
|
||||
},
|
||||
{
|
||||
id: 'col-pri-high',
|
||||
name: 'Hoch',
|
||||
color: '#F59E0B',
|
||||
match: { type: 'priority', value: 'high' },
|
||||
onDrop: { setPriority: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'col-pri-medium',
|
||||
name: 'Mittel',
|
||||
color: '#3B82F6',
|
||||
match: { type: 'priority', value: 'medium' },
|
||||
onDrop: { setPriority: 'medium' },
|
||||
},
|
||||
{
|
||||
id: 'col-pri-low',
|
||||
name: 'Niedrig',
|
||||
color: '#6B7280',
|
||||
match: { type: 'priority', value: 'low' },
|
||||
onDrop: { setPriority: 'low' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'view-project',
|
||||
name: 'Projekte',
|
||||
icon: 'folders',
|
||||
groupBy: 'project',
|
||||
layout: 'kanban',
|
||||
order: 3,
|
||||
columns: [], // dynamically generated from projects
|
||||
},
|
||||
{
|
||||
id: 'view-due',
|
||||
name: 'Fälligkeit',
|
||||
icon: 'calendar',
|
||||
groupBy: 'dueDate',
|
||||
layout: 'kanban',
|
||||
order: 4,
|
||||
columns: [
|
||||
{
|
||||
id: 'col-due-overdue',
|
||||
name: 'Überfällig',
|
||||
color: '#EF4444',
|
||||
match: { type: 'dueDate', value: 'overdue' },
|
||||
},
|
||||
{
|
||||
id: 'col-due-today',
|
||||
name: 'Heute',
|
||||
color: '#F59E0B',
|
||||
match: { type: 'dueDate', value: 'today' },
|
||||
},
|
||||
{
|
||||
id: 'col-due-tomorrow',
|
||||
name: 'Morgen',
|
||||
color: '#3B82F6',
|
||||
match: { type: 'dueDate', value: 'tomorrow' },
|
||||
},
|
||||
{
|
||||
id: 'col-due-week',
|
||||
name: 'Diese Woche',
|
||||
color: '#8B5CF6',
|
||||
match: { type: 'dueDate', value: 'week' },
|
||||
},
|
||||
{
|
||||
id: 'col-due-later',
|
||||
name: 'Später',
|
||||
color: '#6B7280',
|
||||
match: { type: 'dueDate', value: 'later' },
|
||||
},
|
||||
{
|
||||
id: 'col-due-none',
|
||||
name: 'Ohne Datum',
|
||||
color: '#9CA3AF',
|
||||
match: { type: 'dueDate', value: 'none' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Task Seed Data ─────────────────────────────────────────
|
||||
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
|
@ -85,7 +247,7 @@ export const guestTasks: LocalTask[] = [
|
|||
},
|
||||
{
|
||||
id: 'onboard-4',
|
||||
title: 'Wechsle zur Kanban-Ansicht über die Navigation',
|
||||
title: 'Wechsle zur Board-Ansicht über die Navigation',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import type { Subtask as SharedSubtask } from '@todo/shared';
|
||||
import { guestProjects, guestTasks, guestLabels } from './guest-seed.js';
|
||||
import { guestProjects, guestTasks, guestLabels, guestBoardViews } from './guest-seed.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -60,6 +60,45 @@ export interface LocalReminder extends BaseRecord {
|
|||
status: 'pending' | 'sent' | 'failed';
|
||||
}
|
||||
|
||||
// ─── Board Views ────────────────────────────────────────────
|
||||
|
||||
export interface TaskMatcher {
|
||||
type: 'status' | 'priority' | 'project' | 'tag' | 'dueDate' | 'custom';
|
||||
value?: string | null;
|
||||
/** For 'custom' groupBy: manually assigned task IDs */
|
||||
taskIds?: string[];
|
||||
}
|
||||
|
||||
export interface DropAction {
|
||||
setCompleted?: boolean;
|
||||
setPriority?: 'low' | 'medium' | 'high' | 'urgent';
|
||||
setProjectId?: string | null;
|
||||
}
|
||||
|
||||
export interface ViewColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
match: TaskMatcher;
|
||||
onDrop?: DropAction;
|
||||
}
|
||||
|
||||
export interface ViewFilter {
|
||||
projectId?: string;
|
||||
tagIds?: string[];
|
||||
priorities?: string[];
|
||||
}
|
||||
|
||||
export interface LocalBoardView extends BaseRecord {
|
||||
name: string;
|
||||
icon: string;
|
||||
groupBy: 'status' | 'priority' | 'project' | 'dueDate' | 'tag' | 'custom';
|
||||
columns: ViewColumn[];
|
||||
filter?: ViewFilter;
|
||||
layout: 'kanban' | 'grid';
|
||||
order: number;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
|
@ -98,6 +137,11 @@ export const todoStore = createLocalStore({
|
|||
name: 'reminders',
|
||||
indexes: ['taskId'],
|
||||
},
|
||||
{
|
||||
name: 'boardViews',
|
||||
indexes: ['order', 'groupBy'],
|
||||
guestSeed: guestBoardViews,
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
|
|
@ -110,3 +154,4 @@ export const projectCollection = todoStore.collection<LocalProject>('projects');
|
|||
export const labelCollection = todoStore.collection<LocalLabel>('labels');
|
||||
export const taskLabelCollection = todoStore.collection<LocalTaskLabel>('taskLabels');
|
||||
export const reminderCollection = todoStore.collection<LocalReminder>('reminders');
|
||||
export const boardViewCollection = todoStore.collection<LocalBoardView>('boardViews');
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
|||
import {
|
||||
taskCollection,
|
||||
projectCollection,
|
||||
boardViewCollection,
|
||||
type LocalTask,
|
||||
type LocalProject,
|
||||
type LocalBoardView,
|
||||
} from './local-store';
|
||||
import type { Task, Project } from '@todo/shared';
|
||||
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
|
||||
|
|
@ -81,6 +83,17 @@ export function useAllProjects() {
|
|||
}, [] as Project[]);
|
||||
}
|
||||
|
||||
/** All board views, sorted by order. Auto-updates on any change. */
|
||||
export function useAllBoardViews() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await boardViewCollection.getAll(undefined, {
|
||||
sortBy: 'order',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
return locals;
|
||||
}, [] as LocalBoardView[]);
|
||||
}
|
||||
|
||||
// ─── Pure Filter Functions (for $derived) ──────────────────
|
||||
|
||||
export function filterIncomplete(tasks: Task[]): Task[] {
|
||||
|
|
|
|||
268
apps/todo/apps/web/src/lib/data/view-grouping.ts
Normal file
268
apps/todo/apps/web/src/lib/data/view-grouping.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
/**
|
||||
* View Grouping Engine — Pure Functions
|
||||
*
|
||||
* Groups tasks into columns based on a BoardView configuration.
|
||||
* No side effects, no store dependencies — easy to test.
|
||||
*/
|
||||
|
||||
import type { Task, Project } from '@todo/shared';
|
||||
import type { LocalBoardView, ViewColumn, DropAction } from './local-store';
|
||||
import { isToday, isPast, isTomorrow, startOfDay, addDays, isFuture } from 'date-fns';
|
||||
|
||||
// ─── Output Type ───────────────────────────────────────────
|
||||
|
||||
export interface GroupedColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
tasks: Task[];
|
||||
onDrop?: DropAction;
|
||||
}
|
||||
|
||||
// ─── Main Grouping Function ────────────────────────────────
|
||||
|
||||
export function groupTasksByView(
|
||||
view: LocalBoardView,
|
||||
tasks: Task[],
|
||||
projects: Project[]
|
||||
): GroupedColumn[] {
|
||||
// Only group incomplete tasks (unless status view includes completed)
|
||||
const activeTasks = view.groupBy === 'status' ? tasks : tasks.filter((t) => !t.isCompleted);
|
||||
|
||||
// Apply view-level filter
|
||||
const filtered = applyViewFilter(activeTasks, view.filter);
|
||||
|
||||
switch (view.groupBy) {
|
||||
case 'status':
|
||||
return groupByStatus(filtered, view.columns);
|
||||
case 'priority':
|
||||
return groupByPriority(filtered, view.columns);
|
||||
case 'project':
|
||||
return groupByProject(filtered, view.columns, projects);
|
||||
case 'dueDate':
|
||||
return groupByDueDate(filtered, view.columns);
|
||||
case 'tag':
|
||||
return groupByTag(filtered, view.columns);
|
||||
case 'custom':
|
||||
return groupByCustom(filtered, view);
|
||||
default:
|
||||
return groupByStatus(filtered, view.columns);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Group By Implementations ──────────────────────────────
|
||||
|
||||
function groupByStatus(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
|
||||
return columns.map((col) => ({
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
color: col.color,
|
||||
onDrop: col.onDrop,
|
||||
tasks: tasks.filter((t) => {
|
||||
if (col.match.value === 'completed') return t.isCompleted;
|
||||
if (col.match.value === 'pending') return !t.isCompleted;
|
||||
return false;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
function groupByPriority(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
|
||||
return columns.map((col) => ({
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
color: col.color,
|
||||
onDrop: col.onDrop,
|
||||
tasks: tasks.filter((t) => t.priority === col.match.value),
|
||||
}));
|
||||
}
|
||||
|
||||
function groupByProject(
|
||||
tasks: Task[],
|
||||
columns: ViewColumn[],
|
||||
projects: Project[]
|
||||
): GroupedColumn[] {
|
||||
// Dynamic: generate columns from projects
|
||||
if (columns.length === 0) {
|
||||
const activeProjects = projects.filter((p) => !p.isArchived);
|
||||
const dynamicColumns: GroupedColumn[] = [
|
||||
{
|
||||
id: 'col-inbox',
|
||||
name: 'Inbox',
|
||||
color: '#6B7280',
|
||||
tasks: tasks.filter((t) => !t.projectId),
|
||||
onDrop: { setProjectId: null },
|
||||
},
|
||||
...activeProjects.map((p) => ({
|
||||
id: `col-proj-${p.id}`,
|
||||
name: p.name,
|
||||
color: p.color,
|
||||
tasks: tasks.filter((t) => t.projectId === p.id),
|
||||
onDrop: { setProjectId: p.id } as DropAction,
|
||||
})),
|
||||
];
|
||||
return dynamicColumns;
|
||||
}
|
||||
// Static columns from config
|
||||
return columns.map((col) => ({
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
color: col.color,
|
||||
onDrop: col.onDrop,
|
||||
tasks: tasks.filter((t) =>
|
||||
col.match.value === null ? !t.projectId : t.projectId === col.match.value
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function groupByDueDate(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
|
||||
const today = startOfDay(new Date());
|
||||
const tomorrowDate = addDays(today, 1);
|
||||
const weekEnd = addDays(today, 7);
|
||||
|
||||
return columns.map((col) => ({
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
color: col.color,
|
||||
onDrop: col.onDrop,
|
||||
tasks: tasks.filter((t) => {
|
||||
if (!t.dueDate) return col.match.value === 'none';
|
||||
const d = new Date(t.dueDate);
|
||||
const dayStart = startOfDay(d);
|
||||
switch (col.match.value) {
|
||||
case 'overdue':
|
||||
return isPast(dayStart) && !isToday(d);
|
||||
case 'today':
|
||||
return isToday(d);
|
||||
case 'tomorrow':
|
||||
return isTomorrow(d);
|
||||
case 'week':
|
||||
return isFuture(d) && !isTomorrow(d) && d <= weekEnd;
|
||||
case 'later':
|
||||
return d > weekEnd;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
function groupByTag(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
|
||||
return columns.map((col) => ({
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
color: col.color,
|
||||
onDrop: col.onDrop,
|
||||
tasks: tasks.filter(
|
||||
(t) => t.labels?.some((l) => l.id === col.match.value) ?? false
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function groupByCustom(tasks: Task[], view: LocalBoardView): GroupedColumn[] {
|
||||
// Eisenhower matrix: priority + dueDate combination
|
||||
if (view.id === 'view-eisenhower') {
|
||||
return groupEisenhower(tasks, view.columns);
|
||||
}
|
||||
|
||||
// Generic custom: use taskIds per column
|
||||
const assigned = new Set<string>();
|
||||
const result = view.columns.map((col) => {
|
||||
const colTaskIds = new Set(col.match.taskIds ?? []);
|
||||
const colTasks = tasks.filter((t) => {
|
||||
if (colTaskIds.has(t.id)) {
|
||||
assigned.add(t.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return {
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
color: col.color,
|
||||
onDrop: col.onDrop,
|
||||
tasks: colTasks,
|
||||
};
|
||||
});
|
||||
|
||||
// Unassigned tasks go to last column
|
||||
const unassigned = tasks.filter((t) => !assigned.has(t.id));
|
||||
if (unassigned.length > 0 && result.length > 0) {
|
||||
result[result.length - 1].tasks = [...result[result.length - 1].tasks, ...unassigned];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Eisenhower Matrix ─────────────────────────────────────
|
||||
|
||||
function groupEisenhower(tasks: Task[], columns: ViewColumn[]): GroupedColumn[] {
|
||||
const today = startOfDay(new Date());
|
||||
const soonThreshold = addDays(today, 3);
|
||||
|
||||
function isImportant(t: Task): boolean {
|
||||
return t.priority === 'urgent' || t.priority === 'high';
|
||||
}
|
||||
|
||||
function isUrgent(t: Task): boolean {
|
||||
if (!t.dueDate) return false;
|
||||
const d = new Date(t.dueDate);
|
||||
return isPast(startOfDay(d)) || d <= soonThreshold;
|
||||
}
|
||||
|
||||
const buckets: Record<string, Task[]> = {
|
||||
'urgent-important': [],
|
||||
important: [],
|
||||
urgent: [],
|
||||
neither: [],
|
||||
};
|
||||
|
||||
for (const t of tasks.filter((t) => !t.isCompleted)) {
|
||||
const imp = isImportant(t);
|
||||
const urg = isUrgent(t);
|
||||
if (imp && urg) buckets['urgent-important'].push(t);
|
||||
else if (imp) buckets['important'].push(t);
|
||||
else if (urg) buckets['urgent'].push(t);
|
||||
else buckets['neither'].push(t);
|
||||
}
|
||||
|
||||
return columns.map((col) => ({
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
color: col.color,
|
||||
onDrop: col.onDrop,
|
||||
tasks: buckets[col.match.value ?? ''] ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function applyViewFilter(tasks: Task[], filter?: { projectId?: string; tagIds?: string[]; priorities?: string[] }): Task[] {
|
||||
if (!filter) return tasks;
|
||||
let result = tasks;
|
||||
|
||||
if (filter.projectId) {
|
||||
result = result.filter((t) => t.projectId === filter.projectId);
|
||||
}
|
||||
if (filter.priorities && filter.priorities.length > 0) {
|
||||
result = result.filter((t) => filter.priorities!.includes(t.priority));
|
||||
}
|
||||
if (filter.tagIds && filter.tagIds.length > 0) {
|
||||
result = result.filter((t) => t.labels?.some((l) => filter.tagIds!.includes(l.id)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a column's drop action to a task — returns the update payload.
|
||||
*/
|
||||
export function getDropActionUpdate(action: DropAction): Record<string, unknown> {
|
||||
const update: Record<string, unknown> = {};
|
||||
if (action.setCompleted !== undefined) {
|
||||
update.isCompleted = action.setCompleted;
|
||||
update.completedAt = action.setCompleted ? new Date().toISOString() : null;
|
||||
}
|
||||
if (action.setPriority) update.priority = action.setPriority;
|
||||
if (action.setProjectId !== undefined) update.projectId = action.setProjectId;
|
||||
return update;
|
||||
}
|
||||
84
apps/todo/apps/web/src/lib/stores/board-views.svelte.ts
Normal file
84
apps/todo/apps/web/src/lib/stores/board-views.svelte.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Board Views Store — Mutation-Only Service
|
||||
*
|
||||
* Reads via useLiveQuery (useAllBoardViews in task-queries.ts).
|
||||
* This store only handles create, update, delete, reorder.
|
||||
*/
|
||||
|
||||
import { boardViewCollection, type LocalBoardView, type ViewColumn } from '$lib/data/local-store';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const boardViewsStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
async createView(data: Omit<LocalBoardView, 'id'>) {
|
||||
error = null;
|
||||
try {
|
||||
const count = await boardViewCollection.count();
|
||||
const newView: LocalBoardView = {
|
||||
...data,
|
||||
id: crypto.randomUUID(),
|
||||
order: data.order ?? count,
|
||||
};
|
||||
return await boardViewCollection.insert(newView);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create view';
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async updateView(id: string, data: Partial<LocalBoardView>) {
|
||||
error = null;
|
||||
try {
|
||||
return await boardViewCollection.update(id, data as Partial<LocalBoardView>);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update view';
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteView(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await boardViewCollection.delete(id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete view';
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async reorderViews(viewIds: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
for (let i = 0; i < viewIds.length; i++) {
|
||||
await boardViewCollection.update(viewIds[i], { order: i } as Partial<LocalBoardView>);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder views';
|
||||
}
|
||||
},
|
||||
|
||||
/** Update a column's taskIds (for custom groupBy with manual task assignment) */
|
||||
async updateColumnTaskIds(viewId: string, columnId: string, taskIds: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
const view = await boardViewCollection.get(viewId);
|
||||
if (!view) return;
|
||||
|
||||
const updatedColumns = view.columns.map((col: ViewColumn) =>
|
||||
col.id === columnId
|
||||
? { ...col, match: { ...col.match, taskIds } }
|
||||
: col
|
||||
);
|
||||
await boardViewCollection.update(viewId, {
|
||||
columns: updatedColumns,
|
||||
} as Partial<LocalBoardView>);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update column';
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,465 +0,0 @@
|
|||
/**
|
||||
* Kanban Store - Manages kanban boards, columns, and tasks using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared';
|
||||
import * as kanbanApi from '$lib/api/kanban';
|
||||
import * as tasksApi from '$lib/api/tasks';
|
||||
|
||||
// Board state
|
||||
let boards = $state<KanbanBoard[]>([]);
|
||||
let currentBoardId = $state<string | null>(null);
|
||||
|
||||
// Column & Task state
|
||||
let columns = $state<KanbanColumn[]>([]);
|
||||
let tasksByColumn = $state<Record<string, Task[]>>({});
|
||||
|
||||
// Loading & Error state
|
||||
let loading = $state(false);
|
||||
let boardsLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const kanbanStore = {
|
||||
// =====================
|
||||
// Board Getters
|
||||
// =====================
|
||||
|
||||
get boards() {
|
||||
return boards;
|
||||
},
|
||||
get currentBoardId() {
|
||||
return currentBoardId;
|
||||
},
|
||||
get currentBoard() {
|
||||
return boards.find((b) => b.id === currentBoardId) ?? null;
|
||||
},
|
||||
get globalBoard() {
|
||||
return boards.find((b) => b.isGlobal) ?? null;
|
||||
},
|
||||
|
||||
// =====================
|
||||
// Column & Task Getters
|
||||
// =====================
|
||||
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
get tasksByColumn() {
|
||||
return tasksByColumn;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get boardsLoading() {
|
||||
return boardsLoading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
// =====================
|
||||
// Board Operations
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* Fetch all boards for the current user
|
||||
*/
|
||||
async fetchBoards() {
|
||||
boardsLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
boards = await kanbanApi.getBoards();
|
||||
|
||||
// If no current board selected, select global board or first board
|
||||
if (!currentBoardId && boards.length > 0) {
|
||||
const globalBoard = boards.find((b) => b.isGlobal);
|
||||
currentBoardId = globalBoard?.id ?? boards[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch boards';
|
||||
console.error('Failed to fetch boards:', e);
|
||||
} finally {
|
||||
boardsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get or create the global board
|
||||
*/
|
||||
async getOrCreateGlobalBoard() {
|
||||
error = null;
|
||||
try {
|
||||
const globalBoard = await kanbanApi.getGlobalBoard();
|
||||
|
||||
// Update or add to boards list
|
||||
const existingIndex = boards.findIndex((b) => b.id === globalBoard.id);
|
||||
if (existingIndex >= 0) {
|
||||
boards[existingIndex] = globalBoard;
|
||||
} else {
|
||||
boards = [globalBoard, ...boards];
|
||||
}
|
||||
|
||||
return globalBoard;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to get global board';
|
||||
console.error('Failed to get global board:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a board and load its data
|
||||
*/
|
||||
async selectBoard(boardId: string) {
|
||||
if (currentBoardId === boardId) return;
|
||||
|
||||
currentBoardId = boardId;
|
||||
await this.fetchKanbanData(boardId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new board
|
||||
*/
|
||||
async createBoard(data: { name: string; projectId?: string; color?: string; icon?: string }) {
|
||||
error = null;
|
||||
try {
|
||||
const newBoard = await kanbanApi.createBoard(data);
|
||||
boards = [...boards, newBoard];
|
||||
return newBoard;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create board';
|
||||
console.error('Failed to create board:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a board
|
||||
*/
|
||||
async updateBoard(id: string, data: { name?: string; color?: string; icon?: string }) {
|
||||
error = null;
|
||||
try {
|
||||
const updated = await kanbanApi.updateBoard(id, data);
|
||||
boards = boards.map((b) => (b.id === id ? updated : b));
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update board';
|
||||
console.error('Failed to update board:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a board
|
||||
*/
|
||||
async deleteBoard(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await kanbanApi.deleteBoard(id);
|
||||
boards = boards.filter((b) => b.id !== id);
|
||||
|
||||
// If deleted board was current, switch to global board
|
||||
if (currentBoardId === id) {
|
||||
const globalBoard = boards.find((b) => b.isGlobal);
|
||||
currentBoardId = globalBoard?.id ?? boards[0]?.id ?? null;
|
||||
if (currentBoardId) {
|
||||
await this.fetchKanbanData(currentBoardId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete board';
|
||||
console.error('Failed to delete board:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder boards (optimistic update)
|
||||
*/
|
||||
async reorderBoards(boardIds: string[]) {
|
||||
error = null;
|
||||
const previousBoards = [...boards];
|
||||
try {
|
||||
// Optimistic update
|
||||
boards = boardIds
|
||||
.map((id) => boards.find((b) => b.id === id))
|
||||
.filter((b): b is KanbanBoard => b !== undefined);
|
||||
|
||||
// Persist to server
|
||||
const updated = await kanbanApi.reorderBoards(boardIds);
|
||||
boards = updated;
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
boards = previousBoards;
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder boards';
|
||||
console.error('Failed to reorder boards:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
// =====================
|
||||
// Column & Task Operations
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* Fetch columns and tasks grouped by column for a board
|
||||
*/
|
||||
async fetchKanbanData(boardId: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const data = await kanbanApi.getKanbanTasks(boardId);
|
||||
columns = data.columns;
|
||||
tasksByColumn = data.tasksByColumn;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch kanban data';
|
||||
console.error('Failed to fetch kanban data:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch only columns for a board
|
||||
*/
|
||||
async fetchColumns(boardId: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
columns = await kanbanApi.getColumns(boardId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch columns';
|
||||
console.error('Failed to fetch columns:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new column
|
||||
*/
|
||||
async createColumn(data: {
|
||||
name: string;
|
||||
boardId: string;
|
||||
color?: string;
|
||||
defaultStatus?: string;
|
||||
autoComplete?: boolean;
|
||||
}) {
|
||||
error = null;
|
||||
try {
|
||||
const newColumn = await kanbanApi.createColumn(data);
|
||||
columns = [...columns, newColumn];
|
||||
tasksByColumn[newColumn.id] = [];
|
||||
return newColumn;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create column';
|
||||
console.error('Failed to create column:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a column
|
||||
*/
|
||||
async updateColumn(
|
||||
id: string,
|
||||
data: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
defaultStatus?: string;
|
||||
autoComplete?: boolean;
|
||||
}
|
||||
) {
|
||||
error = null;
|
||||
try {
|
||||
const updated = await kanbanApi.updateColumn(id, data);
|
||||
columns = columns.map((c) => (c.id === id ? updated : c));
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update column';
|
||||
console.error('Failed to update column:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a column
|
||||
*/
|
||||
async deleteColumn(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await kanbanApi.deleteColumn(id);
|
||||
columns = columns.filter((c) => c.id !== id);
|
||||
delete tasksByColumn[id];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete column';
|
||||
console.error('Failed to delete column:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder columns (optimistic update)
|
||||
*/
|
||||
async reorderColumns(columnIds: string[]) {
|
||||
error = null;
|
||||
const previousColumns = [...columns];
|
||||
try {
|
||||
// Optimistic update
|
||||
columns = columnIds
|
||||
.map((id) => columns.find((c) => c.id === id))
|
||||
.filter((c): c is KanbanColumn => c !== undefined);
|
||||
|
||||
// Persist to server
|
||||
const updated = await kanbanApi.reorderColumns(columnIds);
|
||||
columns = updated;
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
columns = previousColumns;
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder columns';
|
||||
console.error('Failed to reorder columns:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Move task to a different column (optimistic update)
|
||||
*/
|
||||
async moveTaskToColumn(taskId: string, fromColumnId: string, toColumnId: string, order?: number) {
|
||||
error = null;
|
||||
const previousTasksByColumn = { ...tasksByColumn };
|
||||
|
||||
try {
|
||||
// Find the task
|
||||
const task = tasksByColumn[fromColumnId]?.find((t) => t.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
tasksByColumn[fromColumnId] = tasksByColumn[fromColumnId].filter((t) => t.id !== taskId);
|
||||
|
||||
if (!tasksByColumn[toColumnId]) {
|
||||
tasksByColumn[toColumnId] = [];
|
||||
}
|
||||
|
||||
const insertIndex = order ?? tasksByColumn[toColumnId].length;
|
||||
const updatedTask = { ...task, columnId: toColumnId, columnOrder: insertIndex };
|
||||
tasksByColumn[toColumnId] = [
|
||||
...tasksByColumn[toColumnId].slice(0, insertIndex),
|
||||
updatedTask,
|
||||
...tasksByColumn[toColumnId].slice(insertIndex),
|
||||
];
|
||||
|
||||
// Persist to server
|
||||
await kanbanApi.moveTaskToColumn(taskId, toColumnId, order);
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
tasksByColumn = previousTasksByColumn;
|
||||
error = e instanceof Error ? e.message : 'Failed to move task';
|
||||
console.error('Failed to move task:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder tasks within a column (optimistic update)
|
||||
*/
|
||||
async reorderTasksInColumn(columnId: string, taskIds: string[]) {
|
||||
error = null;
|
||||
const previousTasks = [...(tasksByColumn[columnId] || [])];
|
||||
|
||||
try {
|
||||
// Optimistic update
|
||||
const columnTasks = tasksByColumn[columnId] || [];
|
||||
tasksByColumn[columnId] = taskIds
|
||||
.map((id) => columnTasks.find((t) => t.id === id))
|
||||
.filter((t): t is Task => t !== undefined);
|
||||
|
||||
// Persist to server
|
||||
await kanbanApi.reorderTasksInColumn(columnId, taskIds);
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
tasksByColumn[columnId] = previousTasks;
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder tasks';
|
||||
console.error('Failed to reorder tasks:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize default columns if none exist
|
||||
*/
|
||||
async initializeDefaultColumns(boardId: string) {
|
||||
error = null;
|
||||
try {
|
||||
const newColumns = await kanbanApi.initializeColumns(boardId);
|
||||
columns = newColumns;
|
||||
// Initialize empty task arrays for each column
|
||||
for (const col of newColumns) {
|
||||
if (!tasksByColumn[col.id]) {
|
||||
tasksByColumn[col.id] = [];
|
||||
}
|
||||
}
|
||||
return newColumns;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to initialize columns';
|
||||
console.error('Failed to initialize columns:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks for a specific column
|
||||
*/
|
||||
getTasksForColumn(columnId: string): Task[] {
|
||||
return tasksByColumn[columnId] || [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new task in a specific column
|
||||
*/
|
||||
async createTaskInColumn(columnId: string, title: string, projectId?: string) {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Find the column to get its default status
|
||||
const column = columns.find((c) => c.id === columnId);
|
||||
|
||||
// Create the task
|
||||
const newTask = await tasksApi.createTask({
|
||||
title,
|
||||
projectId,
|
||||
priority: 'medium',
|
||||
});
|
||||
|
||||
// Move task to the column (this will set columnId and status)
|
||||
const movedTask = await kanbanApi.moveTaskToColumn(newTask.id, columnId, 0);
|
||||
|
||||
// Add to local state at the beginning of the column
|
||||
if (!tasksByColumn[columnId]) {
|
||||
tasksByColumn[columnId] = [];
|
||||
}
|
||||
tasksByColumn[columnId] = [movedTask, ...tasksByColumn[columnId]];
|
||||
|
||||
return movedTask;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create task';
|
||||
console.error('Failed to create task in column:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (for logout)
|
||||
*/
|
||||
clear() {
|
||||
boards = [];
|
||||
currentBoardId = null;
|
||||
columns = [];
|
||||
tasksByColumn = {};
|
||||
loading = false;
|
||||
boardsLoading = false;
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,10 +1,45 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
import { kanbanStore } from '$lib/stores/kanban.svelte';
|
||||
import { KanbanBoard, BoardNavigation } from '$lib/components/kanban';
|
||||
import { useAllBoardViews } from '$lib/data/task-queries';
|
||||
import { ViewSelector, BoardViewRenderer } from '$lib/components/board-views';
|
||||
import TaskFilters from '$lib/components/TaskFilters.svelte';
|
||||
|
||||
// Live query for board views
|
||||
const boardViews = useAllBoardViews();
|
||||
|
||||
// Active view — persisted in localStorage
|
||||
const STORAGE_KEY = 'todo:activeViewId';
|
||||
let activeViewId = $state<string | null>(null);
|
||||
|
||||
// Initialize from localStorage
|
||||
onMount(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
activeViewId = stored;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-select first view when views load and nothing is selected
|
||||
$effect(() => {
|
||||
if (boardViews.value.length > 0 && !activeViewId) {
|
||||
activeViewId = boardViews.value[0].id;
|
||||
}
|
||||
// If stored view no longer exists, fall back to first
|
||||
if (activeViewId && boardViews.value.length > 0 && !boardViews.value.find((v) => v.id === activeViewId)) {
|
||||
activeViewId = boardViews.value[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
// Derive the active view object
|
||||
let activeView = $derived(boardViews.value.find((v) => v.id === activeViewId) ?? null);
|
||||
let boardTitle = $derived(activeView?.name ?? 'Board');
|
||||
|
||||
function handleSelectView(viewId: string) {
|
||||
activeViewId = viewId;
|
||||
localStorage.setItem(STORAGE_KEY, viewId);
|
||||
}
|
||||
|
||||
// Filter state
|
||||
let filterPriorities = $state<TaskPriority[]>([]);
|
||||
let filterProjectId = $state<string | null>(null);
|
||||
|
|
@ -12,53 +47,6 @@
|
|||
let filterSearchQuery = $state('');
|
||||
let showFilters = $state(false);
|
||||
|
||||
// Board creation state
|
||||
let showCreateBoard = $state(false);
|
||||
let newBoardName = $state('');
|
||||
let newBoardColor = $state('#8b5cf6');
|
||||
let isCreatingBoard = $state(false);
|
||||
|
||||
// Editable title state
|
||||
let isEditingTitle = $state(false);
|
||||
let editTitle = $state('');
|
||||
|
||||
// Responsive state - Mobile breakpoint at 768px
|
||||
let isMobile = $state(false);
|
||||
|
||||
function checkMobile() {
|
||||
isMobile = window.innerWidth < 768;
|
||||
}
|
||||
|
||||
// Get current board from store
|
||||
let currentBoard = $derived(kanbanStore.currentBoard);
|
||||
let boardTitle = $derived(currentBoard?.name ?? 'Kanban Board');
|
||||
|
||||
function startEditingTitle() {
|
||||
if (!currentBoard) return;
|
||||
editTitle = currentBoard.name;
|
||||
isEditingTitle = true;
|
||||
}
|
||||
|
||||
async function saveTitle() {
|
||||
if (!currentBoard || !editTitle.trim()) {
|
||||
isEditingTitle = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (editTitle.trim() !== currentBoard.name) {
|
||||
await kanbanStore.updateBoard(currentBoard.id, { name: editTitle.trim() });
|
||||
}
|
||||
isEditingTitle = false;
|
||||
}
|
||||
|
||||
function handleTitleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
saveTitle();
|
||||
} else if (event.key === 'Escape') {
|
||||
isEditingTitle = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filterPriorities = [];
|
||||
filterProjectId = null;
|
||||
|
|
@ -73,72 +61,16 @@
|
|||
filterSearchQuery.trim() !== ''
|
||||
);
|
||||
|
||||
// Board operations
|
||||
async function handleSelectBoard(boardId: string) {
|
||||
await kanbanStore.selectBoard(boardId);
|
||||
// Responsive state
|
||||
let isMobile = $state(false);
|
||||
|
||||
function checkMobile() {
|
||||
isMobile = window.innerWidth < 768;
|
||||
}
|
||||
|
||||
function openCreateBoard() {
|
||||
newBoardName = '';
|
||||
newBoardColor = '#8b5cf6';
|
||||
showCreateBoard = true;
|
||||
}
|
||||
|
||||
async function handleCreateBoard() {
|
||||
if (!newBoardName.trim()) return;
|
||||
|
||||
isCreatingBoard = true;
|
||||
try {
|
||||
const board = await kanbanStore.createBoard({
|
||||
name: newBoardName.trim(),
|
||||
color: newBoardColor,
|
||||
});
|
||||
showCreateBoard = false;
|
||||
await kanbanStore.selectBoard(board.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to create board:', e);
|
||||
} finally {
|
||||
isCreatingBoard = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateBoardKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !isCreatingBoard) {
|
||||
handleCreateBoard();
|
||||
} else if (event.key === 'Escape') {
|
||||
showCreateBoard = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Color options for board creation
|
||||
const boardColors = [
|
||||
'#8b5cf6', // violet
|
||||
'#3b82f6', // blue
|
||||
'#22c55e', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#6b7280', // gray
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
// Check initial mobile state
|
||||
onMount(() => {
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
// Fetch boards first
|
||||
await kanbanStore.fetchBoards();
|
||||
|
||||
// If no boards exist, get/create the global board
|
||||
if (kanbanStore.boards.length === 0) {
|
||||
await kanbanStore.getOrCreateGlobalBoard();
|
||||
}
|
||||
|
||||
// Fetch kanban data for current board
|
||||
if (kanbanStore.currentBoardId) {
|
||||
await kanbanStore.fetchKanbanData(kanbanStore.currentBoardId);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -152,52 +84,23 @@
|
|||
<title>{boardTitle} - Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="kanban-page">
|
||||
<!-- Board Navigation - Top on Desktop -->
|
||||
<div class="board-page">
|
||||
<!-- View Selector - Top on Desktop -->
|
||||
{#if !isMobile}
|
||||
<BoardNavigation
|
||||
boards={kanbanStore.boards}
|
||||
currentBoardId={kanbanStore.currentBoardId}
|
||||
loading={kanbanStore.boardsLoading}
|
||||
position="top"
|
||||
onSelectBoard={handleSelectBoard}
|
||||
onCreateBoard={openCreateBoard}
|
||||
<ViewSelector
|
||||
views={boardViews.value}
|
||||
{activeViewId}
|
||||
onSelect={handleSelectView}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Header with editable title -->
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<div class="editable-title">
|
||||
{#if isEditingTitle}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editTitle}
|
||||
onblur={saveTitle}
|
||||
onkeydown={handleTitleKeydown}
|
||||
class="title-input"
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button class="title-button" onclick={startEditingTitle} title="Klicken zum Bearbeiten">
|
||||
<h1>{boardTitle}</h1>
|
||||
<svg class="edit-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<h1 class="page-title">{boardTitle}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
class="filter-button px-4 py-2 text-sm font-medium transition-all flex items-center gap-2 {showFilters ||
|
||||
hasActiveFilters
|
||||
? 'active'
|
||||
: ''}"
|
||||
class="filter-button px-4 py-2 text-sm font-medium transition-all flex items-center gap-2 {showFilters || hasActiveFilters ? 'active' : ''}"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
|
||||
|
|
@ -236,104 +139,43 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Board Container -->
|
||||
<!-- Board Content -->
|
||||
<div class="board-container" class:mobile-bottom-padding={isMobile}>
|
||||
<KanbanBoard {filterPriorities} {filterProjectId} {filterLabelIds} {filterSearchQuery} />
|
||||
{#if activeView}
|
||||
<BoardViewRenderer view={activeView} />
|
||||
{:else if boardViews.value.length === 0}
|
||||
<div class="empty-state">
|
||||
<p class="text-muted-foreground">Board Views werden geladen...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Board Navigation - Bottom on Mobile -->
|
||||
<!-- View Selector - Bottom on Mobile -->
|
||||
{#if isMobile}
|
||||
<BoardNavigation
|
||||
boards={kanbanStore.boards}
|
||||
currentBoardId={kanbanStore.currentBoardId}
|
||||
loading={kanbanStore.boardsLoading}
|
||||
position="bottom"
|
||||
onSelectBoard={handleSelectBoard}
|
||||
onCreateBoard={openCreateBoard}
|
||||
/>
|
||||
<div class="mobile-selector">
|
||||
<ViewSelector
|
||||
views={boardViews.value}
|
||||
{activeViewId}
|
||||
onSelect={handleSelectView}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create Board Modal -->
|
||||
{#if showCreateBoard}
|
||||
<div class="modal-overlay" onclick={() => (showCreateBoard = false)}>
|
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||
<h2 class="modal-title">Neues Board erstellen</h2>
|
||||
|
||||
<div class="modal-body">
|
||||
<label class="input-label">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newBoardName}
|
||||
onkeydown={handleCreateBoardKeydown}
|
||||
class="input-field"
|
||||
placeholder="z.B. Projekt Alpha"
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="color-picker">
|
||||
<span class="input-label">Farbe</span>
|
||||
<div class="color-options">
|
||||
{#each boardColors as color}
|
||||
<button
|
||||
type="button"
|
||||
class="color-option"
|
||||
class:selected={newBoardColor === color}
|
||||
style="background-color: {color}"
|
||||
onclick={() => (newBoardColor = color)}
|
||||
>
|
||||
{#if newBoardColor === color}
|
||||
<svg
|
||||
class="check-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
onclick={() => (showCreateBoard = false)}
|
||||
disabled={isCreatingBoard}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-create"
|
||||
onclick={handleCreateBoard}
|
||||
disabled={isCreatingBoard || !newBoardName.trim()}
|
||||
>
|
||||
{#if isCreatingBoard}
|
||||
Erstelle...
|
||||
{:else}
|
||||
Erstellen
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.kanban-page {
|
||||
.board-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.board-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
|
@ -341,7 +183,25 @@
|
|||
}
|
||||
|
||||
.board-container.mobile-bottom-padding {
|
||||
padding-bottom: 70px; /* Space for fixed BoardNavigation */
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Mobile selector fixed at bottom */
|
||||
.mobile-selector {
|
||||
position: fixed;
|
||||
bottom: 70px; /* Above PillNav */
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
background: linear-gradient(to top, var(--background) 0%, transparent 100%);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Glass-Pill filter button */
|
||||
|
|
@ -394,240 +254,7 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
/* Editable title styles */
|
||||
.editable-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: -0.25rem -0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.title-button:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .title-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.title-button h1 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
opacity: 0;
|
||||
color: var(--muted-foreground);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.title-button:hover .edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--primary);
|
||||
outline: none;
|
||||
padding: 0.25rem 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--background);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
:global(.dark) .modal-content {
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--foreground);
|
||||
background: var(--input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.color-options {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.color-option {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
border-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-option:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-option.selected {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-create {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
background: #8b5cf6;
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-create:hover:not(:disabled) {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.btn-create:disabled,
|
||||
.btn-cancel:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: animateIn 0.2s ease-out;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue