Перейти к содержимому

34. Полноценное приложение на Solid.js

В этом уроке мы строим Kanban-доску — реальное приложение, которое объединяет всё, что вы изучили. Это финальный урок курса, и здесь вы увидите, как все концепции работают вместе в одном production-ready решении.

Хорошая архитектура Solid-приложения разделяет:

src/
├── routes/ # Маршруты (SolidStart)
│ ├── index.tsx # Главная страница / Канбан
│ └── board/[id].tsx
├── components/ # UI компоненты
│ ├── Card.tsx
│ ├── Column.tsx
│ ├── Modal.tsx
│ └── Toolbar.tsx
├── stores/ # Глобальное состояние
│ └── board.ts # createStore для канбана
├── primitives/ # Кастомные примитивы
│ ├── createDnd.ts # Drag-and-drop примитив
│ └── createModal.ts
└── types.ts # TypeScript типы
types.ts
export interface Card {
id: string;
title: string;
description: string;
priority: 'low' | 'medium' | 'high';
tags: string[];
createdAt: number;
}
export interface Column {
id: string;
title: string;
color: string;
cardIds: string[];
}
export interface BoardStore {
columns: Column[];
cards: Record<string, Card>;
}
stores/board.ts
import { createStore, produce } from 'solid-js/store';
import type { BoardStore, Card, Column } from '../types';
// Персистентный стор с localStorage
function createBoardStore() {
const saved = localStorage.getItem('kanban-board');
const initial: BoardStore = saved
? JSON.parse(saved)
: {
columns: [
{ id: 'todo', title: 'К выполнению', color: '#2c67d5', cardIds: [] },
{ id: 'progress', title: 'В работе', color: '#fb923c', cardIds: [] },
{ id: 'done', title: 'Готово', color: '#34d399', cardIds: [] },
],
cards: {},
};
const [board, setBoard] = createStore<BoardStore>(initial);
// Авто-сохранение при изменениях
createEffect(() => {
localStorage.setItem('kanban-board', JSON.stringify(board));
});
const addCard = (columnId: string, data: Omit<Card, 'id' | 'createdAt'>) => {
const id = crypto.randomUUID();
setBoard(produce(b => {
b.cards[id] = { ...data, id, createdAt: Date.now() };
b.columns.find(c => c.id === columnId)?.cardIds.push(id);
}));
};
const moveCard = (cardId: string, fromColumnId: string, toColumnId: string) => {
setBoard(produce(b => {
const from = b.columns.find(c => c.id === fromColumnId);
const to = b.columns.find(c => c.id === toColumnId);
if (!from || !to) return;
from.cardIds = from.cardIds.filter(id => id !== cardId);
if (!to.cardIds.includes(cardId)) to.cardIds.push(cardId);
}));
};
const deleteCard = (cardId: string) => {
setBoard(produce(b => {
b.columns.forEach(col => {
col.cardIds = col.cardIds.filter(id => id !== cardId);
});
delete b.cards[cardId];
}));
};
return { board, addCard, moveCard, deleteCard };
}
export const boardStore = createBoardStore();
components/Card.tsx
function KanbanCard(props: {
card: Card;
columnId: string;
onEdit: () => void;
onDelete: () => void;
onMove: (toColumnId: string) => void;
}) {
const priorityColors = {
low: '#34d399',
medium: '#fb923c',
high: '#f87171',
};
return (
<div
draggable
onDragStart={e => e.dataTransfer!.setData('cardId', props.card.id)}
class="card"
>
{/* Индикатор приоритета */}
<div
style={{ background: priorityColors[props.card.priority] }}
class="priority-bar"
/>
<h4>{props.card.title}</h4>
<Show when={props.card.description}>
<p>{props.card.description}</p>
</Show>
<div class="tags">
<For each={props.card.tags}>
{tag => <span class="tag">{tag}</span>}
</For>
</div>
<div class="card-actions">
<button onClick={props.onEdit}>✏️</button>
<button onClick={props.onDelete}>🗑️</button>
</div>
</div>
);
}
components/Column.tsx
function KanbanColumn(props: { column: Column; cards: Card[] }) {
const [isDragOver, setIsDragOver] = createSignal(false);
const handleDrop = (e: DragEvent) => {
e.preventDefault();
const cardId = e.dataTransfer?.getData('cardId');
if (cardId) {
boardStore.moveCard(cardId, /* fromColumnId */, props.column.id);
}
setIsDragOver(false);
};
return (
<div
class="column"
classList={{ 'drag-over': isDragOver() }}
onDragOver={e => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
>
<div class="column-header" style={{ 'border-color': props.column.color }}>
<h3>{props.column.title}</h3>
<span class="badge">{props.cards.length}</span>
</div>
<div class="cards-list">
<For each={props.cards}>
{card => (
<KanbanCard
card={card}
columnId={props.column.id}
onEdit={() => openEditModal(card)}
onDelete={() => boardStore.deleteCard(card.id)}
onMove={toColumnId => boardStore.moveCard(card.id, props.column.id, toColumnId)}
/>
)}
</For>
</div>
<button
class="add-card-btn"
onClick={() => openAddModal(props.column.id)}
>
+ Добавить карточку
</button>
</div>
);
}
// Контекст позволяет любому компоненту в дереве получить доступ к store
const BoardContext = createContext<ReturnType<typeof createBoardStore>>();
export const useBoardStore = () => {
const ctx = useContext(BoardContext);
if (!ctx) throw new Error('useBoardStore must be used within BoardProvider');
return ctx;
};
function BoardProvider(props: { children: JSXElement }) {
const store = createBoardStore();
return (
<BoardContext.Provider value={store}>
{props.children}
</BoardContext.Provider>
);
}
// Использование в App
function App() {
return (
<BoardProvider>
<KanbanBoard />
</BoardProvider>
);
}
// Мемоизация отфильтрованных карточек
const filteredCards = createMemo(() => {
const search = searchQuery().toLowerCase();
if (!search) return boardStore.board.cards;
return Object.fromEntries(
Object.entries(boardStore.board.cards).filter(([_, card]) =>
card.title.toLowerCase().includes(search) ||
card.tags.some(tag => tag.toLowerCase().includes(search))
)
);
});
// createSelector для точечных обновлений колонок
const isColumnDragTarget = createSelector(
() => dragTargetColumnId(),
(columnId, targetId) => columnId === targetId
);