feat: add app shell with top bar, view routing, and board factory
This commit is contained in:
34
src/App.tsx
34
src/App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
18
src/components/layout/AppShell.tsx
Normal file
18
src/components/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
src/components/layout/TopBar.tsx
Normal file
164
src/components/layout/TopBar.tsx
Normal 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
44
src/lib/board-factory.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user