Includes changes from prior sessions: Epilogue + Space Mono fonts, OverlayScrollbars integration, markdown editor fixes, settings dialog, import/export buttons, and various UI refinements.
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { useState, useRef, useCallback } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { springs, microInteraction } from "@/lib/motion";
|
|
import {
|
|
Sun, Moon, Monitor, RotateCcw,
|
|
} from "lucide-react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useAppStore } from "@/stores/app-store";
|
|
import type { AppSettings } from "@/types/settings";
|
|
import type { ColumnWidth } from "@/types/board";
|
|
|
|
interface SettingsDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
type Tab = "appearance" | "boards" | "shortcuts" | "about";
|
|
|
|
const THEME_OPTIONS: {
|
|
value: AppSettings["theme"];
|
|
label: string;
|
|
icon: typeof Sun;
|
|
}[] = [
|
|
{ value: "light", label: "Light", icon: Sun },
|
|
{ value: "dark", label: "Dark", icon: Moon },
|
|
{ value: "system", label: "System", icon: Monitor },
|
|
];
|
|
|
|
const ACCENT_PRESETS: { hue: string; label: string }[] = [
|
|
{ hue: "160", label: "Teal" },
|
|
{ hue: "240", label: "Blue" },
|
|
{ hue: "300", label: "Purple" },
|
|
{ hue: "350", label: "Pink" },
|
|
{ hue: "25", label: "Red" },
|
|
{ hue: "55", label: "Orange" },
|
|
{ hue: "85", label: "Yellow" },
|
|
{ hue: "130", label: "Lime" },
|
|
{ hue: "200", label: "Cyan" },
|
|
{ hue: "0", label: "Slate" },
|
|
];
|
|
|
|
const DENSITY_OPTIONS: {
|
|
value: AppSettings["density"];
|
|
label: string;
|
|
}[] = [
|
|
{ value: "compact", label: "Compact" },
|
|
{ value: "comfortable", label: "Comfortable" },
|
|
{ value: "spacious", label: "Spacious" },
|
|
];
|
|
|
|
const WIDTH_OPTIONS: { value: ColumnWidth; label: string }[] = [
|
|
{ value: "narrow", label: "Narrow" },
|
|
{ value: "standard", label: "Standard" },
|
|
{ value: "wide", label: "Wide" },
|
|
];
|
|
|
|
const SHORTCUTS: { key: string; description: string; category: string }[] = [
|
|
{ key: "Ctrl+K", description: "Open command palette", category: "Navigation" },
|
|
{ key: "Ctrl+Z", description: "Undo", category: "Board" },
|
|
{ key: "Ctrl+Shift+Z", description: "Redo", category: "Board" },
|
|
{ key: "?", description: "Keyboard shortcuts", category: "Navigation" },
|
|
{ key: "Escape", description: "Close modal / cancel", category: "Navigation" },
|
|
];
|
|
|
|
const TABS: { value: Tab; label: string }[] = [
|
|
{ value: "appearance", label: "Appearance" },
|
|
{ value: "boards", label: "Boards" },
|
|
{ value: "shortcuts", label: "Shortcuts" },
|
|
{ value: "about", label: "About" },
|
|
];
|
|
|
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<label className="mb-2 block font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|
const [tab, setTab] = useState<Tab>("appearance");
|
|
const settings = useAppStore((s) => s.settings);
|
|
const setTheme = useAppStore((s) => s.setTheme);
|
|
const setAccentColor = useAppStore((s) => s.setAccentColor);
|
|
const setUiZoom = useAppStore((s) => s.setUiZoom);
|
|
const setDensity = useAppStore((s) => s.setDensity);
|
|
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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="bg-pylon-surface sm:max-w-lg overflow-hidden p-0">
|
|
<motion.div
|
|
animate={{ height: typeof height === "number" && height > 0 ? height : "auto" }}
|
|
initial={false}
|
|
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div ref={contentRef} className="flex flex-col gap-4 p-6">
|
|
<DialogHeader>
|
|
<DialogTitle className="font-heading text-pylon-text">
|
|
Settings
|
|
</DialogTitle>
|
|
<DialogDescription className="text-pylon-text-secondary">
|
|
Configure your OpenPylon preferences.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Tab bar */}
|
|
<div className="flex gap-1 border-b border-border pb-2">
|
|
{TABS.map((t) => (
|
|
<Button
|
|
key={t.value}
|
|
variant={tab === t.value ? "secondary" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setTab(t.value)}
|
|
className="font-mono text-xs"
|
|
>
|
|
{t.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab content — entire dialog height animates between tabs */}
|
|
<AnimatePresence mode="popLayout" initial={false}>
|
|
<motion.div
|
|
key={tab}
|
|
className="flex flex-col gap-5"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
{tab === "appearance" && (
|
|
<>
|
|
{/* Theme */}
|
|
<div>
|
|
<SectionLabel>Theme</SectionLabel>
|
|
<div className="flex gap-2">
|
|
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
|
|
<Button
|
|
key={value}
|
|
type="button"
|
|
variant={settings.theme === value ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setTheme(value)}
|
|
className="flex-1 gap-2"
|
|
>
|
|
<Icon className="size-4" />
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* UI Zoom */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<SectionLabel>UI Zoom</SectionLabel>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs text-pylon-text-secondary">
|
|
{Math.round(settings.uiZoom * 100)}%
|
|
</span>
|
|
{settings.uiZoom !== 1 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => setUiZoom(1)}
|
|
className="text-pylon-text-secondary hover:text-pylon-text"
|
|
>
|
|
<RotateCcw className="size-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0.75"
|
|
max="1.5"
|
|
step="0.05"
|
|
value={settings.uiZoom}
|
|
onChange={(e) => setUiZoom(parseFloat(e.target.value))}
|
|
className="w-full accent-pylon-accent"
|
|
/>
|
|
<div className="mt-1 flex justify-between font-mono text-[10px] text-pylon-text-secondary">
|
|
<span>75%</span>
|
|
<span>100%</span>
|
|
<span>150%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Accent Color */}
|
|
<div>
|
|
<SectionLabel>Accent Color</SectionLabel>
|
|
<div className="flex flex-wrap gap-2">
|
|
{ACCENT_PRESETS.map(({ hue, label }) => {
|
|
const isAchromatic = hue === "0";
|
|
const bg = isAchromatic
|
|
? "oklch(50% 0 0)"
|
|
: `oklch(55% 0.12 ${hue})`;
|
|
return (
|
|
<motion.button
|
|
key={hue}
|
|
type="button"
|
|
onClick={() => setAccentColor(hue)}
|
|
className="size-7 rounded-full"
|
|
style={{
|
|
backgroundColor: bg,
|
|
outline: settings.accentColor === hue ? "2px solid currentColor" : "none",
|
|
outlineOffset: "2px",
|
|
}}
|
|
whileHover={microInteraction.hover}
|
|
whileTap={microInteraction.tap}
|
|
transition={springs.snappy}
|
|
aria-label={label}
|
|
title={label}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Density */}
|
|
<div>
|
|
<SectionLabel>Density</SectionLabel>
|
|
<div className="flex gap-2">
|
|
{DENSITY_OPTIONS.map(({ value, label }) => (
|
|
<Button
|
|
key={value}
|
|
type="button"
|
|
variant={settings.density === value ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setDensity(value)}
|
|
className="flex-1"
|
|
>
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{tab === "boards" && (
|
|
<div>
|
|
<SectionLabel>Default Column Width</SectionLabel>
|
|
<div className="flex gap-2">
|
|
{WIDTH_OPTIONS.map(({ value, label }) => (
|
|
<Button
|
|
key={value}
|
|
type="button"
|
|
variant={settings.defaultColumnWidth === value ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setDefaultColumnWidth(value)}
|
|
className="flex-1"
|
|
>
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "shortcuts" && (
|
|
<div className="flex flex-col gap-1">
|
|
{SHORTCUTS.map(({ key, description }) => (
|
|
<div key={key} className="flex items-center justify-between py-1">
|
|
<span className="text-sm text-pylon-text">{description}</span>
|
|
<kbd className="rounded bg-pylon-column px-2 py-0.5 font-mono text-xs text-pylon-text-secondary">
|
|
{key}
|
|
</kbd>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{tab === "about" && (
|
|
<div className="space-y-2 text-sm text-pylon-text">
|
|
<p className="font-heading text-lg">OpenPylon</p>
|
|
<p className="text-pylon-text-secondary">
|
|
v0.1.0 · Local-first Kanban board
|
|
</p>
|
|
<p className="text-pylon-text-secondary">
|
|
Built with Tauri, React, and TypeScript.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</motion.div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|