diff --git a/src/components/boards/BoardList.tsx b/src/components/boards/BoardList.tsx
index be01a8d..992b6f3 100644
--- a/src/components/boards/BoardList.tsx
+++ b/src/components/boards/BoardList.tsx
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app-store";
import { BoardCard } from "@/components/boards/BoardCard";
import { NewBoardDialog } from "@/components/boards/NewBoardDialog";
+import { ImportExportButtons } from "@/components/import-export/ImportExportButtons";
export function BoardList() {
const boards = useAppStore((s) => s.boards);
@@ -27,10 +28,13 @@ export function BoardList() {
Create your first board
-
+
+
+
+
>
@@ -45,10 +49,13 @@ export function BoardList() {
Your Boards
-
+
+
+
+
{/* Board grid */}
diff --git a/src/components/import-export/ImportExportButtons.tsx b/src/components/import-export/ImportExportButtons.tsx
new file mode 100644
index 0000000..a9a6caf
--- /dev/null
+++ b/src/components/import-export/ImportExportButtons.tsx
@@ -0,0 +1,125 @@
+import { useRef } from "react";
+import { Download, Upload } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useAppStore } from "@/stores/app-store";
+import { useBoardStore } from "@/stores/board-store";
+import { saveBoard } from "@/lib/storage";
+import {
+ exportBoardAsJson,
+ exportBoardAsCsv,
+ importBoardFromJson,
+ importFromTrelloJson,
+} from "@/lib/import-export";
+
+function downloadBlob(content: string, filename: string, mimeType: string) {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+export function ImportExportButtons() {
+ const fileInputRef = useRef(null);
+ const board = useBoardStore((s) => s.board);
+ const refreshBoards = useAppStore((s) => s.refreshBoards);
+ const setView = useAppStore((s) => s.setView);
+ const addRecentBoard = useAppStore((s) => s.addRecentBoard);
+ const openBoard = useBoardStore((s) => s.openBoard);
+
+ function handleExportJson() {
+ if (!board) return;
+ const json = exportBoardAsJson(board);
+ const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
+ downloadBlob(json, `${safeName}.json`, "application/json");
+ }
+
+ function handleExportCsv() {
+ if (!board) return;
+ const csv = exportBoardAsCsv(board);
+ const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
+ downloadBlob(csv, `${safeName}.csv`, "text/csv");
+ }
+
+ function handleImportClick() {
+ fileInputRef.current?.click();
+ }
+
+ async function handleFileSelected(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ try {
+ const text = await file.text();
+ let imported;
+
+ // Try to detect if it's a Trello export
+ const parsed = JSON.parse(text);
+ if (parsed.lists && parsed.cards && !parsed.columns) {
+ // Looks like Trello format
+ imported = importFromTrelloJson(text);
+ } else {
+ imported = importBoardFromJson(text);
+ }
+
+ await saveBoard(imported);
+ await refreshBoards();
+ await openBoard(imported.id);
+ setView({ type: "board", boardId: imported.id });
+ addRecentBoard(imported.id);
+ } catch (err) {
+ console.error("Import failed:", err);
+ // Could show a toast here in the future
+ }
+
+ // Reset the input so the same file can be re-imported
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ }
+
+ return (
+
+ {/* Import button */}
+
+
+
+ {/* Export dropdown */}
+
+
+
+
+
+
+ Export as JSON
+
+
+ Export as CSV
+
+
+
+
+ );
+}
diff --git a/src/lib/import-export.ts b/src/lib/import-export.ts
new file mode 100644
index 0000000..41cbdc5
--- /dev/null
+++ b/src/lib/import-export.ts
@@ -0,0 +1,230 @@
+import { ulid } from "ulid";
+import { boardSchema } from "@/lib/schemas";
+import type { Board, Card, Column, Label, ChecklistItem } from "@/types/board";
+
+// ---------------------------------------------------------------------------
+// Export: JSON
+// ---------------------------------------------------------------------------
+
+export function exportBoardAsJson(board: Board): string {
+ return JSON.stringify(board, null, 2);
+}
+
+// ---------------------------------------------------------------------------
+// Export: CSV
+// ---------------------------------------------------------------------------
+
+function escapeCsv(value: string): string {
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
+ return `"${value.replace(/"/g, '""')}"`;
+ }
+ return value;
+}
+
+export function exportBoardAsCsv(board: Board): string {
+ const headers = [
+ "board",
+ "column",
+ "title",
+ "description",
+ "labels",
+ "dueDate",
+ "checklistTotal",
+ "checklistDone",
+ "createdAt",
+ "updatedAt",
+ ];
+
+ const rows: string[] = [headers.join(",")];
+
+ for (const column of board.columns) {
+ for (const cardId of column.cardIds) {
+ const card = board.cards[cardId];
+ if (!card) continue;
+
+ const labelNames = card.labels
+ .map((lid) => board.labels.find((l) => l.id === lid)?.name ?? lid)
+ .join(";");
+
+ const checklistTotal = card.checklist.length;
+ const checklistDone = card.checklist.filter((item) => item.checked).length;
+
+ const row = [
+ escapeCsv(board.title),
+ escapeCsv(column.title),
+ escapeCsv(card.title),
+ escapeCsv(card.description),
+ escapeCsv(labelNames),
+ card.dueDate ?? "",
+ String(checklistTotal),
+ String(checklistDone),
+ card.createdAt,
+ card.updatedAt,
+ ];
+
+ rows.push(row.join(","));
+ }
+ }
+
+ return rows.join("\n");
+}
+
+// ---------------------------------------------------------------------------
+// Import: JSON (OpenPylon format)
+// ---------------------------------------------------------------------------
+
+export function importBoardFromJson(jsonString: string): Board {
+ const data = JSON.parse(jsonString);
+ const board = boardSchema.parse(data) as Board;
+ return board;
+}
+
+// ---------------------------------------------------------------------------
+// Import: Trello JSON
+// ---------------------------------------------------------------------------
+
+interface TrelloCard {
+ name: string;
+ desc?: string;
+ due?: string | null;
+ labels?: { name: string; color: string }[];
+ idList: string;
+ closed?: boolean;
+ checklists?: {
+ name: string;
+ checkItems: { name: string; state: "complete" | "incomplete" }[];
+ }[];
+}
+
+interface TrelloList {
+ id: string;
+ name: string;
+ closed?: boolean;
+}
+
+interface TrelloBoard {
+ name: string;
+ lists?: TrelloList[];
+ cards?: TrelloCard[];
+ labels?: { name: string; color: string }[];
+}
+
+const TRELLO_COLOR_MAP: Record = {
+ green: "#22c55e",
+ yellow: "#eab308",
+ orange: "#f97316",
+ red: "#ef4444",
+ purple: "#8b5cf6",
+ blue: "#3b82f6",
+ sky: "#0ea5e9",
+ lime: "#84cc16",
+ pink: "#ec4899",
+ black: "#1e293b",
+};
+
+export function importFromTrelloJson(jsonString: string): Board {
+ const data = JSON.parse(jsonString) as TrelloBoard;
+
+ const ts = new Date().toISOString();
+ const boardId = ulid();
+
+ // Map Trello labels
+ const labels: Label[] = [];
+ const trelloLabelMap = new Map(); // trello label key -> our label id
+
+ if (data.labels) {
+ for (const tLabel of data.labels) {
+ if (!tLabel.name && !tLabel.color) continue;
+ const id = ulid();
+ const color = TRELLO_COLOR_MAP[tLabel.color] ?? "#6366f1";
+ labels.push({ id, name: tLabel.name || tLabel.color, color });
+ trelloLabelMap.set(`${tLabel.name}-${tLabel.color}`, id);
+ }
+ }
+
+ // Map Trello lists -> columns, filtering out archived lists
+ const openLists = (data.lists ?? []).filter((l) => !l.closed);
+ const listIdToColumnId = new Map();
+ const columns: Column[] = openLists.map((list) => {
+ const colId = ulid();
+ listIdToColumnId.set(list.id, colId);
+ return {
+ id: colId,
+ title: list.name,
+ cardIds: [],
+ width: "standard" as const,
+ };
+ });
+
+ // Map Trello cards
+ const cards: Record = {};
+
+ for (const tCard of data.cards ?? []) {
+ if (tCard.closed) continue;
+
+ const columnId = listIdToColumnId.get(tCard.idList);
+ if (!columnId) continue;
+
+ const cardId = ulid();
+
+ // Map labels
+ const cardLabelIds: string[] = [];
+ if (tCard.labels) {
+ for (const tl of tCard.labels) {
+ const key = `${tl.name}-${tl.color}`;
+ const existingId = trelloLabelMap.get(key);
+ if (existingId) {
+ cardLabelIds.push(existingId);
+ }
+ }
+ }
+
+ // Map checklists
+ const checklist: ChecklistItem[] = [];
+ if (tCard.checklists) {
+ for (const cl of tCard.checklists) {
+ for (const item of cl.checkItems) {
+ checklist.push({
+ id: ulid(),
+ text: item.name,
+ checked: item.state === "complete",
+ });
+ }
+ }
+ }
+
+ const card: Card = {
+ id: cardId,
+ title: tCard.name,
+ description: tCard.desc ?? "",
+ labels: cardLabelIds,
+ checklist,
+ dueDate: tCard.due ?? null,
+ attachments: [],
+ createdAt: ts,
+ updatedAt: ts,
+ };
+
+ cards[cardId] = card;
+
+ // Add card to column
+ const col = columns.find((c) => c.id === columnId);
+ if (col) {
+ col.cardIds.push(cardId);
+ }
+ }
+
+ const board: Board = {
+ id: boardId,
+ title: data.name || "Imported Board",
+ color: "#6366f1",
+ createdAt: ts,
+ updatedAt: ts,
+ columns,
+ cards,
+ labels,
+ settings: { attachmentMode: "link" },
+ };
+
+ return board;
+}