feat: typography overhaul, custom scrollbars, import/export, settings UI
Includes changes from prior sessions: Epilogue + Space Mono fonts, OverlayScrollbars integration, markdown editor fixes, settings dialog, import/export buttons, and various UI refinements.
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
<title>OpenPylon</title>
|
<title>OpenPylon</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Epilogue:ital,wght@0,100..900;1,100..900&family=Instrument+Serif&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
61
src/App.tsx
61
src/App.tsx
@@ -39,30 +39,10 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, [init]);
|
}, [init]);
|
||||||
|
|
||||||
// Save window state + flush board saves before the app window closes
|
// Flush board saves before the app window closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleBeforeUnload() {
|
function handleBeforeUnload() {
|
||||||
useBoardStore.getState().closeBoard();
|
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);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
return () => {
|
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<typeof setTimeout> | 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
|
// Listen for custom event to open settings from TopBar or command palette
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleOpenSettings() {
|
function handleOpenSettings() {
|
||||||
|
|||||||
@@ -1,16 +1,63 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, ArrowUpDown } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
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 { staggerContainer, scaleIn, springs } from "@/lib/motion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { BoardCard } from "@/components/boards/BoardCard";
|
import { BoardCard } from "@/components/boards/BoardCard";
|
||||||
|
import { BoardCardOverlay } from "@/components/boards/BoardCardOverlay";
|
||||||
import { NewBoardDialog } from "@/components/boards/NewBoardDialog";
|
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<BoardSortOrder, string> = {
|
||||||
|
manual: "Manual",
|
||||||
|
title: "Name",
|
||||||
|
updated: "Last modified",
|
||||||
|
created: "Date created",
|
||||||
|
};
|
||||||
|
|
||||||
export function BoardList() {
|
export function BoardList() {
|
||||||
const boards = useAppStore((s) => s.boards);
|
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 [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [activeBoardId, setActiveBoardId] = useState<string | null>(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
|
// Listen for custom event to open new board dialog from command palette
|
||||||
useEffect(() => {
|
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) {
|
if (boards.length === 0) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -47,7 +121,7 @@ export function BoardList() {
|
|||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Create Board
|
Create Board
|
||||||
</Button>
|
</Button>
|
||||||
<ImportExportButtons />
|
<ImportButton />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
@@ -55,16 +129,51 @@ export function BoardList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeBoard = activeBoardId
|
||||||
|
? sortedBoards.find((b) => b.id === activeBoardId)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full overflow-y-auto p-6">
|
<OverlayScrollbarsComponent
|
||||||
|
className="h-full"
|
||||||
|
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true } }}
|
||||||
|
defer
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
{/* Heading row */}
|
{/* Heading row */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
|
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
|
||||||
Your Boards
|
Your Boards
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ImportExportButtons />
|
{/* Sort dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="size-3.5" />
|
||||||
|
<span className="font-mono text-xs">{SORT_LABELS[sortOrder]}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={sortOrder}
|
||||||
|
onValueChange={(v) => setSortOrder(v as BoardSortOrder)}
|
||||||
|
>
|
||||||
|
{(Object.keys(SORT_LABELS) as BoardSortOrder[]).map((key) => (
|
||||||
|
<DropdownMenuRadioItem key={key} value={key}>
|
||||||
|
{SORT_LABELS[key]}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<ImportButton />
|
||||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New
|
New
|
||||||
@@ -73,17 +182,35 @@ export function BoardList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Board grid */}
|
{/* Board grid */}
|
||||||
<motion.div
|
<DndContext
|
||||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
sensors={sensors}
|
||||||
variants={staggerContainer(0.05)}
|
collisionDetection={closestCenter}
|
||||||
initial="hidden"
|
onDragStart={handleDragStart}
|
||||||
animate="visible"
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
{boards.map((board) => (
|
<SortableContext
|
||||||
<BoardCard key={board.id} board={board} />
|
items={sortedBoards.map((b) => b.id)}
|
||||||
))}
|
strategy={rectSortingStrategy}
|
||||||
</motion.div>
|
>
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
variants={staggerContainer(0.05)}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
|
{sortedBoards.map((board) => (
|
||||||
|
<BoardCard key={board.id} board={board} sortable={isManual} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeBoard ? <BoardCardOverlay board={activeBoard} /> : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
|
||||||
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Plus, Check } from "lucide-react";
|
import { Plus, Check } from "lucide-react";
|
||||||
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -81,29 +82,35 @@ export function LabelPicker({
|
|||||||
|
|
||||||
{/* Existing labels */}
|
{/* Existing labels */}
|
||||||
{boardLabels.length > 0 && (
|
{boardLabels.length > 0 && (
|
||||||
<div className="flex max-h-40 flex-col gap-1 overflow-y-auto">
|
<OverlayScrollbarsComponent
|
||||||
{boardLabels.map((label) => {
|
className="max-h-40"
|
||||||
const isSelected = cardLabelIds.includes(label.id);
|
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
|
||||||
return (
|
defer
|
||||||
<button
|
>
|
||||||
key={label.id}
|
<div className="flex flex-col gap-1">
|
||||||
onClick={() => toggleCardLabel(cardId, label.id)}
|
{boardLabels.map((label) => {
|
||||||
className="flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-pylon-column"
|
const isSelected = cardLabelIds.includes(label.id);
|
||||||
>
|
return (
|
||||||
<span
|
<button
|
||||||
className="size-3 shrink-0 rounded-full"
|
key={label.id}
|
||||||
style={{ backgroundColor: label.color }}
|
onClick={() => toggleCardLabel(cardId, label.id)}
|
||||||
/>
|
className="flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-pylon-column"
|
||||||
<span className="flex-1 truncate text-pylon-text">
|
>
|
||||||
{label.name}
|
<span
|
||||||
</span>
|
className="size-3 shrink-0 rounded-full"
|
||||||
{isSelected && (
|
style={{ backgroundColor: label.color }}
|
||||||
<Check className="size-3.5 shrink-0 text-pylon-accent" />
|
/>
|
||||||
)}
|
<span className="flex-1 truncate text-pylon-text">
|
||||||
</button>
|
{label.name}
|
||||||
);
|
</span>
|
||||||
})}
|
{isSelected && (
|
||||||
</div>
|
<Check className="size-3.5 shrink-0 text-pylon-accent" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create new label */}
|
{/* Create new label */}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useBoardStore } from "@/stores/board-store";
|
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 {
|
interface MarkdownEditorProps {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -21,10 +26,13 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
|||||||
setDraft(value);
|
setDraft(value);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
// Auto-focus textarea when switching to edit mode
|
// Auto-focus and auto-size textarea when switching to edit mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "edit" && textareaRef.current) {
|
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]);
|
}, [mode]);
|
||||||
|
|
||||||
@@ -41,6 +49,10 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
|||||||
const text = e.target.value;
|
const text = e.target.value;
|
||||||
setDraft(text);
|
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
|
// Debounced auto-save
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
@@ -90,17 +102,25 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
|||||||
|
|
||||||
{/* Editor / Preview */}
|
{/* Editor / Preview */}
|
||||||
{mode === "edit" ? (
|
{mode === "edit" ? (
|
||||||
<textarea
|
<OverlayScrollbarsComponent
|
||||||
ref={textareaRef}
|
className="max-h-[160px] rounded-md border border-pylon-text-secondary/20 bg-pylon-surface focus-within:border-pylon-accent focus-within:ring-1 focus-within:ring-pylon-accent"
|
||||||
value={draft}
|
options={{ ...OS_OPTIONS, overflow: { x: "hidden" as const } }}
|
||||||
onChange={handleChange}
|
defer
|
||||||
onBlur={handleBlur}
|
>
|
||||||
placeholder="Add a description... (Markdown supported)"
|
<textarea
|
||||||
className="min-h-[100px] max-h-[160px] w-full resize-y rounded-md border border-pylon-text-secondary/20 bg-pylon-surface px-3 py-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:border-pylon-accent focus:ring-1 focus:ring-pylon-accent"
|
ref={textareaRef}
|
||||||
/>
|
value={draft}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="Add a description... (Markdown supported)"
|
||||||
|
className="min-h-[100px] w-full resize-none overflow-hidden bg-transparent px-3 py-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
|
||||||
|
/>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<OverlayScrollbarsComponent
|
||||||
className="min-h-[100px] max-h-[160px] overflow-y-auto cursor-pointer rounded-md border border-transparent px-1 py-1 transition-colors hover:border-pylon-text-secondary/20"
|
className="min-h-[100px] max-h-[160px] cursor-pointer rounded-md border border-transparent px-1 py-1 transition-colors hover:border-pylon-text-secondary/20"
|
||||||
|
options={{ ...OS_OPTIONS, overflow: { x: "hidden" as const } }}
|
||||||
|
defer
|
||||||
onClick={() => setMode("edit")}
|
onClick={() => setMode("edit")}
|
||||||
>
|
>
|
||||||
{draft ? (
|
{draft ? (
|
||||||
@@ -114,7 +134,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
|||||||
Click to add a description...
|
Click to add a description...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</OverlayScrollbarsComponent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,60 +1,23 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Download, Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useBoardStore } from "@/stores/board-store";
|
import { useBoardStore } from "@/stores/board-store";
|
||||||
import { useToastStore } from "@/stores/toast-store";
|
import { useToastStore } from "@/stores/toast-store";
|
||||||
import { saveBoard } from "@/lib/storage";
|
import { saveBoard } from "@/lib/storage";
|
||||||
import {
|
import {
|
||||||
exportBoardAsJson,
|
|
||||||
exportBoardAsCsv,
|
|
||||||
importBoardFromJson,
|
importBoardFromJson,
|
||||||
importFromTrelloJson,
|
importFromTrelloJson,
|
||||||
} from "@/lib/import-export";
|
} from "@/lib/import-export";
|
||||||
|
|
||||||
function downloadBlob(content: string, filename: string, mimeType: string) {
|
export function ImportButton() {
|
||||||
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<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
const board = useBoardStore((s) => s.board);
|
|
||||||
const refreshBoards = useAppStore((s) => s.refreshBoards);
|
const refreshBoards = useAppStore((s) => s.refreshBoards);
|
||||||
const setView = useAppStore((s) => s.setView);
|
const setView = useAppStore((s) => s.setView);
|
||||||
const addRecentBoard = useAppStore((s) => s.addRecentBoard);
|
const addRecentBoard = useAppStore((s) => s.addRecentBoard);
|
||||||
const openBoard = useBoardStore((s) => s.openBoard);
|
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");
|
|
||||||
addToast("Board exported as JSON", "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
addToast("Board exported as CSV", "success");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImportClick() {
|
function handleImportClick() {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}
|
}
|
||||||
@@ -94,8 +57,7 @@ export function ImportExportButtons() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<>
|
||||||
{/* Import button */}
|
|
||||||
<Button variant="outline" size="sm" onClick={handleImportClick}>
|
<Button variant="outline" size="sm" onClick={handleImportClick}>
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
Import
|
Import
|
||||||
@@ -107,24 +69,6 @@ export function ImportExportButtons() {
|
|||||||
onChange={handleFileSelected}
|
onChange={handleFileSelected}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
{/* Export dropdown */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" disabled={!board}>
|
|
||||||
<Download className="size-4" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={handleExportJson}>
|
|
||||||
Export as JSON
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleExportCsv}>
|
|
||||||
Export as CSV
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { springs, scaleIn, microInteraction } from "@/lib/motion";
|
import { springs, microInteraction } from "@/lib/motion";
|
||||||
import {
|
import {
|
||||||
Sun, Moon, Monitor, RotateCcw,
|
Sun, Moon, Monitor, RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -94,45 +94,68 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|||||||
const setDensity = useAppStore((s) => s.setDensity);
|
const setDensity = useAppStore((s) => s.setDensity);
|
||||||
const setDefaultColumnWidth = useAppStore((s) => s.setDefaultColumnWidth);
|
const setDefaultColumnWidth = useAppStore((s) => s.setDefaultColumnWidth);
|
||||||
|
|
||||||
|
const roRef = useRef<ResizeObserver | null>(null);
|
||||||
|
const [height, setHeight] = useState<number | "auto">("auto");
|
||||||
|
|
||||||
|
// Callback ref: sets up ResizeObserver when dialog content mounts in portal
|
||||||
|
const contentRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
if (roRef.current) {
|
||||||
|
roRef.current.disconnect();
|
||||||
|
roRef.current = null;
|
||||||
|
}
|
||||||
|
if (node) {
|
||||||
|
const measure = () => setHeight(node.getBoundingClientRect().height);
|
||||||
|
measure();
|
||||||
|
roRef.current = new ResizeObserver(measure);
|
||||||
|
roRef.current.observe(node);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="bg-pylon-surface sm:max-w-lg">
|
<DialogContent className="bg-pylon-surface sm:max-w-lg overflow-hidden p-0">
|
||||||
<DialogHeader>
|
<motion.div
|
||||||
<DialogTitle className="font-heading text-pylon-text">
|
animate={{ height: typeof height === "number" && height > 0 ? height : "auto" }}
|
||||||
Settings
|
initial={false}
|
||||||
</DialogTitle>
|
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||||
<DialogDescription className="text-pylon-text-secondary">
|
className="overflow-hidden"
|
||||||
Configure your OpenPylon preferences.
|
>
|
||||||
</DialogDescription>
|
<div ref={contentRef} className="flex flex-col gap-4 p-6">
|
||||||
</DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-heading text-pylon-text">
|
||||||
|
Settings
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-pylon-text-secondary">
|
||||||
|
Configure your OpenPylon preferences.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div className="flex gap-1 border-b border-border pb-2">
|
<div className="flex gap-1 border-b border-border pb-2">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<Button
|
<Button
|
||||||
key={t.value}
|
key={t.value}
|
||||||
variant={tab === t.value ? "secondary" : "ghost"}
|
variant={tab === t.value ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setTab(t.value)}
|
onClick={() => setTab(t.value)}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content — entire dialog height animates between tabs */}
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={tab}
|
key={tab}
|
||||||
className="flex flex-col gap-5 pt-1"
|
className="flex flex-col gap-5"
|
||||||
variants={scaleIn}
|
initial={{ opacity: 0 }}
|
||||||
initial="hidden"
|
animate={{ opacity: 1 }}
|
||||||
animate="visible"
|
exit={{ opacity: 0 }}
|
||||||
exit="exit"
|
transition={{ duration: 0.15 }}
|
||||||
transition={springs.snappy}
|
>
|
||||||
>
|
{tab === "appearance" && (
|
||||||
{tab === "appearance" && (
|
|
||||||
<>
|
<>
|
||||||
{/* Theme */}
|
{/* Theme */}
|
||||||
<div>
|
<div>
|
||||||
@@ -292,8 +315,10 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -51,8 +52,8 @@
|
|||||||
--color-pylon-text-secondary: var(--pylon-text-secondary);
|
--color-pylon-text-secondary: var(--pylon-text-secondary);
|
||||||
--color-pylon-danger: var(--pylon-danger);
|
--color-pylon-danger: var(--pylon-danger);
|
||||||
--font-heading: "Instrument Serif", Georgia, serif;
|
--font-heading: "Instrument Serif", Georgia, serif;
|
||||||
--font-body: "Satoshi", system-ui, -apple-system, sans-serif;
|
--font-body: "Epilogue", system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: "Geist Mono", "JetBrains Mono", "Fira Code", monospace;
|
--font-mono: "Space Mono", "Courier New", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -149,41 +150,15 @@
|
|||||||
scrollbar-color: oklch(80% 0 0 / 15%) transparent;
|
scrollbar-color: oklch(80% 0 0 / 15%) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for Chromium/WebKit (Tauri WebView) */
|
/* Hide native scrollbars — OverlayScrollbars renders custom ones */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 0;
|
||||||
height: 8px;
|
height: 0;
|
||||||
}
|
display: none;
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: oklch(50% 0 0 / 20%);
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: content-box;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: oklch(50% 0 0 / 35%);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: content-box;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-corner {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.dark ::-webkit-scrollbar-thumb {
|
|
||||||
background: oklch(80% 0 0 / 15%);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: content-box;
|
|
||||||
}
|
|
||||||
.dark ::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: oklch(80% 0 0 / 30%);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: content-box;
|
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: "Satoshi", system-ui, -apple-system, sans-serif;
|
font-family: "Epilogue", system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--pylon-accent);
|
outline: 2px solid var(--pylon-accent);
|
||||||
@@ -191,6 +166,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* OverlayScrollbars custom theme */
|
||||||
|
.os-theme-pylon {
|
||||||
|
--os-handle-bg: oklch(50% 0 0 / 22%);
|
||||||
|
--os-handle-bg-hover: oklch(50% 0 0 / 40%);
|
||||||
|
--os-handle-bg-active: oklch(50% 0 0 / 55%);
|
||||||
|
--os-size: 8px;
|
||||||
|
--os-handle-border-radius: 9999px;
|
||||||
|
--os-padding-perpendicular: 2px;
|
||||||
|
--os-padding-axis: 2px;
|
||||||
|
--os-handle-min-size: 30px;
|
||||||
|
}
|
||||||
|
.dark .os-theme-pylon {
|
||||||
|
--os-handle-bg: oklch(80% 0 0 / 18%);
|
||||||
|
--os-handle-bg-hover: oklch(80% 0 0 / 35%);
|
||||||
|
--os-handle-bg-active: oklch(80% 0 0 / 50%);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-contrast: more) {
|
@media (prefers-contrast: more) {
|
||||||
:root {
|
:root {
|
||||||
--pylon-text: oklch(10% 0.02 50);
|
--pylon-text: oklch(10% 0.02 50);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { OverlayScrollbars, ClickScrollPlugin } from "overlayscrollbars";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import "overlayscrollbars/overlayscrollbars.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
OverlayScrollbars.plugin(ClickScrollPlugin);
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
Reference in New Issue
Block a user