feat: add app shell with top bar, view routing, and board factory

This commit is contained in:
Your Name
2026-02-15 18:44:19 +02:00
parent be933f5383
commit d369ae6644
4 changed files with 259 additions and 3 deletions

View File

@@ -1,7 +1,37 @@
import { useEffect } from "react";
import { useAppStore } from "@/stores/app-store";
import { AppShell } from "@/components/layout/AppShell";
export default function App() {
const initialized = useAppStore((s) => s.initialized);
const init = useAppStore((s) => s.init);
const view = useAppStore((s) => s.view);
useEffect(() => {
init();
}, [init]);
if (!initialized) {
return (
<div className="h-screen flex items-center justify-center bg-background text-foreground">
<h1 className="text-2xl font-bold">OpenPylon</h1>
<div className="flex h-screen items-center justify-center bg-pylon-bg">
<span className="font-heading text-lg text-pylon-text-secondary">
Loading...
</span>
</div>
);
}
return (
<AppShell>
{view.type === "board-list" ? (
<div className="flex h-full items-center justify-center text-pylon-text-secondary">
Board List
</div>
) : (
<div className="flex h-full items-center justify-center text-pylon-text-secondary">
Board View
</div>
)}
</AppShell>
);
}

View File

@@ -0,0 +1,18 @@
import type { ReactNode } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { TopBar } from "@/components/layout/TopBar";
interface AppShellProps {
children: ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<TooltipProvider>
<div className="flex h-screen flex-col bg-pylon-bg">
<TopBar />
<main className="flex-1 overflow-hidden">{children}</main>
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { ArrowLeft, Settings, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store";
export function TopBar() {
const view = useAppStore((s) => s.view);
const setView = useAppStore((s) => s.setView);
const board = useBoardStore((s) => s.board);
const updateBoardTitle = useBoardStore((s) => s.updateBoardTitle);
const saving = useBoardStore((s) => s.saving);
const lastSaved = useBoardStore((s) => s.lastSaved);
const isBoardView = view.type === "board";
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const startEditing = useCallback(() => {
if (board) {
setEditValue(board.title);
setEditing(true);
}
}, [board]);
const commitEdit = useCallback(() => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== board?.title) {
updateBoardTitle(trimmed);
}
setEditing(false);
}, [editValue, board?.title, updateBoardTitle]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
commitEdit();
} else if (e.key === "Escape") {
setEditing(false);
}
},
[commitEdit]
);
const savingStatus = saving
? "Saving..."
: lastSaved
? `Saved ${formatTimeAgo(lastSaved)}`
: null;
return (
<header
data-tauri-drag-region
className="flex h-12 shrink-0 items-center gap-2 border-b border-border bg-pylon-surface px-3"
>
{/* Left section */}
<div className="flex items-center gap-2">
{isBoardView && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setView({ type: "board-list" })}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ArrowLeft className="size-4" />
<span>Boards</span>
</Button>
</TooltipTrigger>
<TooltipContent>Back to board list</TooltipContent>
</Tooltip>
)}
</div>
{/* Center section */}
<div className="flex flex-1 items-center justify-center">
{isBoardView && board ? (
editing ? (
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
className="h-7 rounded-md border border-border bg-transparent px-2 text-center font-heading text-lg text-pylon-text outline-none focus:border-pylon-accent"
/>
) : (
<button
onClick={startEditing}
className="rounded-md px-2 py-0.5 font-heading text-lg text-pylon-text hover:bg-pylon-column transition-colors"
>
{board.title}
</button>
)
) : (
<span className="font-heading text-lg text-pylon-text">
OpenPylon
</span>
)}
</div>
{/* Right section */}
<div className="flex items-center gap-1">
{savingStatus && (
<span className="mr-2 font-mono text-xs text-pylon-text-secondary">
{savingStatus}
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
>
<Search className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Command palette{" "}
<kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+K</kbd>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
>
<Settings className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</div>
</header>
);
}
function formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ago`;
}

44
src/lib/board-factory.ts Normal file
View File

@@ -0,0 +1,44 @@
import { ulid } from "ulid";
import type { Board, ColumnWidth } from "@/types/board";
type Template = "blank" | "kanban" | "sprint";
export function createBoard(
title: string,
color: string,
template: Template = "blank"
): Board {
const ts = new Date().toISOString();
const board: Board = {
id: ulid(),
title,
color,
createdAt: ts,
updatedAt: ts,
columns: [],
cards: {},
labels: [],
settings: { attachmentMode: "link" },
};
const col = (t: string, w: ColumnWidth = "standard") => ({
id: ulid(),
title: t,
cardIds: [] as string[],
width: w,
});
if (template === "kanban") {
board.columns = [col("To Do"), col("In Progress"), col("Done")];
} else if (template === "sprint") {
board.columns = [
col("Backlog"),
col("To Do"),
col("In Progress", "wide"),
col("Review"),
col("Done", "narrow"),
];
}
return board;
}