T5/Phase 3: SPA shell + navigation

- TanStack Router with type-safe file-based routes
- Sidebar: collapsible, workspace switcher, 12 nav links, user menu
- Topbar: search, quick-add, notifications, user avatar
- cmdk command palette: nav + create + search + @mentions
- Keyboard shortcuts: Cmd+K palette, g+t/h/p/n/c/g/s navigation, ? help
- Theme: dark default, accent picker (slate/blue/green/purple/orange), persisted to localStorage
- Auth: login -> /, logout -> /login, 401 redirect
- Placeholder pages for all 13 nav routes (real in T6/T7)
- Typed API client with useApiQuery/useApiMutation hooks
This commit is contained in:
Hermes
2026-08-01 02:00:24 +00:00
parent c277e3a14f
commit e86a7c0672
66 changed files with 3781 additions and 32 deletions
+88
View File
@@ -0,0 +1,88 @@
import { useQuery, useMutation, type UseQueryOptions, type UseMutationOptions } from "@tanstack/react-query";
const API_BASE = "/api";
interface ApiError {
code: string;
message: string;
}
class AuthError extends Error {
constructor() {
super("Not authenticated");
this.name = "AuthError";
}
}
async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE}${path}`;
const res = await fetch(url, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
});
if (res.status === 401) {
// Redirect to login
window.location.href = "/login";
throw new AuthError();
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const error: ApiError = body.error || { code: "UNKNOWN", message: res.statusText };
throw new Error(error.message);
}
return res.json();
}
// Typed API client
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, { method: "PUT", body: body ? JSON.stringify(body) : undefined }),
patch: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) =>
apiFetch<T>(path, { method: "DELETE" }),
};
// React Query hooks
export function useApiQuery<T>(
key: string[],
path: string,
options?: Omit<UseQueryOptions<T>, "queryKey" | "queryFn">
) {
return useQuery<T>({
queryKey: key,
queryFn: () => api.get<T>(path),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: true,
...options,
});
}
export function useApiMutation<TData, TVariables = void>(
method: "post" | "put" | "patch" | "delete",
path: string,
options?: Omit<UseMutationOptions<TData, Error, TVariables>, "mutationFn">
) {
return useMutation<TData, Error, TVariables>({
mutationFn: (variables) =>
(api[method] as <T>(p: string, b?: unknown) => Promise<T>)<TData>(
path,
variables as unknown
),
...options,
});
}
@@ -0,0 +1,17 @@
import { create } from "zustand";
type ItemType = "task" | "project" | "habit" | "note" | "event" | null;
interface CreateDialogState {
type: ItemType;
open: boolean;
openCreate: (type: ItemType) => void;
closeCreate: () => void;
}
export const useCreateDialogStore = create<CreateDialogState>((set) => ({
type: null,
open: false,
openCreate: (type: ItemType) => set({ type, open: true }),
closeCreate: () => set({ type: null, open: false }),
}));
@@ -0,0 +1,52 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface Shortcut {
key: string;
description: string;
action: string;
enabled: boolean;
}
interface KeyboardShortcutsState {
enabled: boolean;
shortcuts: Shortcut[];
setEnabled: (enabled: boolean) => void;
updateShortcut: (key: string, updates: Partial<Shortcut>) => void;
resetShortcuts: () => void;
}
const defaultShortcuts: Shortcut[] = [
{ key: "g+d", description: "Go to Dashboard", action: "navigate_dashboard", enabled: true },
{ key: "g+t", description: "Go to Tasks", action: "navigate_tasks", enabled: true },
{ key: "g+h", description: "Go to Habits", action: "navigate_habits", enabled: true },
{ key: "g+p", description: "Go to Projects", action: "navigate_projects", enabled: true },
{ key: "g+n", description: "Go to Notes", action: "navigate_notes", enabled: true },
{ key: "g+c", description: "Go to Calendar", action: "navigate_calendar", enabled: true },
{ key: "g+g", description: "Go to Graph", action: "navigate_graph", enabled: true },
{ key: "g+s", description: "Go to Settings", action: "navigate_settings", enabled: true },
{ key: "/", description: "Focus search", action: "focus_search", enabled: true },
{ key: "?", description: "Show shortcuts help", action: "show_help", enabled: true },
];
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>()(
persist(
(set) => ({
enabled: true,
shortcuts: defaultShortcuts,
setEnabled: (enabled) => set({ enabled }),
updateShortcut: (key, updates) =>
set((state) => ({
shortcuts: state.shortcuts.map((s) =>
s.key === key ? { ...s, ...updates } : s
),
})),
resetShortcuts: () => set({ shortcuts: defaultShortcuts }),
}),
{
name: "project-e-keyboard-shortcuts",
}
)
);
@@ -0,0 +1,27 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface SidebarState {
collapsed: boolean;
mobileOpen: boolean;
toggle: () => void;
setCollapsed: (collapsed: boolean) => void;
setMobileOpen: (open: boolean) => void;
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set) => ({
collapsed: false,
mobileOpen: false,
toggle: () => set((state) => ({ collapsed: !state.collapsed })),
setCollapsed: (collapsed) => set({ collapsed }),
setMobileOpen: (mobileOpen) => set({ mobileOpen }),
}),
{
name: "project-e-sidebar",
}
)
);
@@ -0,0 +1,36 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type ThemeMode = "light" | "dark" | "system";
export type AccentColor = "slate" | "blue" | "green" | "purple" | "orange";
interface ThemeState {
mode: ThemeMode;
accent: AccentColor;
setMode: (mode: ThemeMode) => void;
setAccent: (accent: AccentColor) => void;
}
export const ACCENT_PALETTE: Record<AccentColor, { name: string; hsl: string }> = {
slate: { name: "Slate", hsl: "215 16% 47%" },
blue: { name: "Blue", hsl: "217 91% 60%" },
green: { name: "Green", hsl: "142 71% 45%" },
purple: { name: "Purple", hsl: "271 81% 56%" },
orange: { name: "Orange", hsl: "24 95% 53%" },
};
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
mode: "dark",
accent: "blue",
setMode: (mode) => set({ mode }),
setAccent: (accent) => set({ accent }),
}),
{
name: "project-e-theme",
}
)
);