diff --git a/index.html b/index.html index 4f0765f..c42fcd3 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ OpenPylon - + diff --git a/src/App.tsx b/src/App.tsx index eeab6a9..883c8d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,30 +39,10 @@ export default function App() { }); }, [init]); - // Save window state + flush board saves before the app window closes + // Flush board saves before the app window closes useEffect(() => { function handleBeforeUnload() { useBoardStore.getState().closeBoard(); - - // Save window state synchronously-ish (fire and forget) - const appWindow = getCurrentWindow(); - Promise.all([ - appWindow.outerSize(), - appWindow.outerPosition(), - appWindow.isMaximized(), - ]).then(([size, position, maximized]) => { - const settings = useAppStore.getState().settings; - saveSettings({ - ...settings, - windowState: { - x: position.x, - y: position.y, - width: size.width, - height: size.height, - maximized, - }, - }); - }); } window.addEventListener("beforeunload", handleBeforeUnload); return () => { @@ -70,6 +50,45 @@ export default function App() { }; }, []); + // Save window state on resize/move (debounced) so it persists without blocking close + useEffect(() => { + const appWindow = getCurrentWindow(); + let timeout: ReturnType | null = null; + + async function saveWindowState() { + const [size, position, maximized] = await Promise.all([ + appWindow.outerSize(), + appWindow.outerPosition(), + appWindow.isMaximized(), + ]); + const settings = useAppStore.getState().settings; + await saveSettings({ + ...settings, + windowState: { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + maximized, + }, + }); + } + + function debouncedSave() { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(saveWindowState, 500); + } + + const unlistenResize = appWindow.onResized(debouncedSave); + const unlistenMove = appWindow.onMoved(debouncedSave); + + return () => { + if (timeout) clearTimeout(timeout); + unlistenResize.then((fn) => fn()); + unlistenMove.then((fn) => fn()); + }; + }, []); + // Listen for custom event to open settings from TopBar or command palette useEffect(() => { function handleOpenSettings() { diff --git a/src/components/boards/BoardList.tsx b/src/components/boards/BoardList.tsx index 28a7893..b109efe 100644 --- a/src/components/boards/BoardList.tsx +++ b/src/components/boards/BoardList.tsx @@ -1,16 +1,63 @@ -import { useState, useEffect } from "react"; -import { Plus } from "lucide-react"; +import { useState, useEffect, useCallback } from "react"; +import { Plus, ArrowUpDown } from "lucide-react"; import { motion } from "framer-motion"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import { + DndContext, + DragOverlay, + closestCenter, + PointerSensor, + KeyboardSensor, + useSensor, + useSensors, + type DragStartEvent, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + rectSortingStrategy, +} from "@dnd-kit/sortable"; import { staggerContainer, scaleIn, springs } from "@/lib/motion"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useAppStore } from "@/stores/app-store"; import { BoardCard } from "@/components/boards/BoardCard"; +import { BoardCardOverlay } from "@/components/boards/BoardCardOverlay"; import { NewBoardDialog } from "@/components/boards/NewBoardDialog"; -import { ImportExportButtons } from "@/components/import-export/ImportExportButtons"; +import { ImportButton } from "@/components/import-export/ImportExportButtons"; +import type { BoardSortOrder } from "@/types/settings"; + +const SORT_LABELS: Record = { + manual: "Manual", + title: "Name", + updated: "Last modified", + created: "Date created", +}; export function BoardList() { const boards = useAppStore((s) => s.boards); + const sortOrder = useAppStore((s) => s.settings.boardSortOrder); + const setSortOrder = useAppStore((s) => s.setBoardSortOrder); + const setBoardManualOrder = useAppStore((s) => s.setBoardManualOrder); + const getSortedBoards = useAppStore((s) => s.getSortedBoards); const [dialogOpen, setDialogOpen] = useState(false); + const [activeBoardId, setActiveBoardId] = useState(null); + + const sortedBoards = getSortedBoards(); + const isManual = sortOrder === "manual"; + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor) + ); // Listen for custom event to open new board dialog from command palette useEffect(() => { @@ -23,6 +70,33 @@ export function BoardList() { }; }, []); + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveBoardId(event.active.id as string); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + setActiveBoardId(null); + const { active, over } = event; + if (!over || active.id === over.id) return; + + const currentOrder = useAppStore.getState().getSortedBoards().map((b) => b.id); + const oldIndex = currentOrder.indexOf(active.id as string); + const newIndex = currentOrder.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1) return; + + const newOrder = [...currentOrder]; + newOrder.splice(oldIndex, 1); + newOrder.splice(newIndex, 0, active.id as string); + setBoardManualOrder(newOrder); + }, + [setBoardManualOrder] + ); + + const handleDragCancel = useCallback(() => { + setActiveBoardId(null); + }, []); + if (boards.length === 0) { return ( <> @@ -47,7 +121,7 @@ export function BoardList() { Create Board - + @@ -55,16 +129,51 @@ export function BoardList() { ); } + const activeBoard = activeBoardId + ? sortedBoards.find((b) => b.id === activeBoardId) + : null; + return ( <> -
+ +
{/* Heading row */}

Your Boards

- + {/* Sort dropdown */} + + + + + + setSortOrder(v as BoardSortOrder)} + > + {(Object.keys(SORT_LABELS) as BoardSortOrder[]).map((key) => ( + + {SORT_LABELS[key]} + + ))} + + + + +
{/* Board grid */} - - {boards.map((board) => ( - - ))} - + b.id)} + strategy={rectSortingStrategy} + > + + {sortedBoards.map((board) => ( + + ))} + + + + + {activeBoard ? : null} + +
+ diff --git a/src/components/card-detail/LabelPicker.tsx b/src/components/card-detail/LabelPicker.tsx index ff453b7..fe64368 100644 --- a/src/components/card-detail/LabelPicker.tsx +++ b/src/components/card-detail/LabelPicker.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Plus, Check } from "lucide-react"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { Button } from "@/components/ui/button"; import { Popover, @@ -81,29 +82,35 @@ export function LabelPicker({ {/* Existing labels */} {boardLabels.length > 0 && ( -
- {boardLabels.map((label) => { - const isSelected = cardLabelIds.includes(label.id); - return ( - - ); - })} -
+ +
+ {boardLabels.map((label) => { + const isSelected = cardLabelIds.includes(label.id); + return ( + + ); + })} +
+
)} {/* Create new label */} diff --git a/src/components/card-detail/MarkdownEditor.tsx b/src/components/card-detail/MarkdownEditor.tsx index 031ac2a..c046bea 100644 --- a/src/components/card-detail/MarkdownEditor.tsx +++ b/src/components/card-detail/MarkdownEditor.tsx @@ -1,9 +1,14 @@ import { useState, useRef, useEffect, useCallback } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { Button } from "@/components/ui/button"; import { useBoardStore } from "@/stores/board-store"; +const OS_OPTIONS = { + scrollbars: { theme: "os-theme-pylon" as const, autoHide: "scroll" as const, autoHideDelay: 600, clickScroll: true }, +}; + interface MarkdownEditorProps { cardId: string; value: string; @@ -21,10 +26,13 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) { setDraft(value); }, [value]); - // Auto-focus textarea when switching to edit mode + // Auto-focus and auto-size textarea when switching to edit mode useEffect(() => { if (mode === "edit" && textareaRef.current) { - textareaRef.current.focus(); + const el = textareaRef.current; + el.style.height = "auto"; + el.style.height = el.scrollHeight + "px"; + el.focus(); } }, [mode]); @@ -41,6 +49,10 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) { const text = e.target.value; setDraft(text); + // Auto-size textarea to fit content (parent OverlayScrollbars handles overflow) + e.target.style.height = "auto"; + e.target.style.height = e.target.scrollHeight + "px"; + // Debounced auto-save if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { @@ -90,17 +102,25 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) { {/* Editor / Preview */} {mode === "edit" ? ( -