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; +}