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:
@@ -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",
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user