feat: per-user module access, classification watchlists, watchlist persistence & move
**Module access control (admin)** - Added modules column to users table (JSON array of allowed module keys) - auth.me returns modules; admin.setUserModules/disableUser/enableUser/deleteUser procedures - UserActions dropdown with Reset Password, Manage Modules, Disable/Enable, Delete - useFeatureAccess hook + FeatureGate component for page-level gating - SidebarNav, CommandPalette, MobileTabNav filter by modules **Classification watchlists (auto-generated sector/thematic/style/region)** - watchlists schema: added kind, class_key, class_label columns - materializeClassificationWatchlists(): lazy idempotent materialization of system lists from Yahoo sector data - 11 GICS sector lists, Semiconductors, Biotech thematic lists, Small Cap style, Intl region - Materialization triggered on listWatchlists + addSymbol/removeSymbol/add - Sector thematic filter uses industry keywords (semiconductors, biotech) to avoid misclassification - Symbol overrides fixed: IREN removed from SMH theme, bitcoin miners XLE->XLK, SLNH added as Technology **Watchlist persistence & move** - active-watchlist-store.ts: Zustand persist store mirroring active-symbol-store pattern - moveSymbol procedure: removes from source, adds to target (idempotent, preserves empty lists) - Action menu (⋯) per row: Move to + Remove, click-outside close - Active watchlist survives navigation and page reloads **List protections** - default list: non-deletable, non-renamable, keeps empty row when pruned - System lists (sector/thematic etc.): read-only, add form hidden, no remove/move/delete **Per-user module restrictions** - ProtectedProcedure blocks non-active users - deleteUser refuses self-delete and last-admin-delete, cleans halt_state manually - Module gating on execution/analytics pages, sidebar, command palette, mobile nav Also fixed: schema.sql index moved to migration, materialize DB column fixes, test updates.
This commit is contained in:
@@ -24,6 +24,7 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||
|
||||
interface NavCommand {
|
||||
label: string;
|
||||
@@ -31,25 +32,26 @@ interface NavCommand {
|
||||
icon: LucideIcon;
|
||||
goTo?: string;
|
||||
group: "Page";
|
||||
module?: ModuleKey;
|
||||
}
|
||||
|
||||
const NAV_COMMANDS: NavCommand[] = [
|
||||
{ label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page" },
|
||||
{ label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page" },
|
||||
{ label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page" },
|
||||
{ label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page" },
|
||||
{ label: "Trade Plan", href: "/trade-plan", icon: ClipboardList, goTo: "t", group: "Page" },
|
||||
{ label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page" },
|
||||
{ label: "Trade Closure", href: "/trade-closure", icon: XCircle, goTo: "x", group: "Page" },
|
||||
{ label: "Daily Focus", href: "/daily-focus", icon: Target, goTo: "d", group: "Page" },
|
||||
{ label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page" },
|
||||
{ label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page" },
|
||||
{ label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page" },
|
||||
{ label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page" },
|
||||
{ label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page" },
|
||||
{ label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page" },
|
||||
{ label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page" },
|
||||
{ label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page" },
|
||||
{ label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page", module: "research" },
|
||||
{ label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page", module: "research" },
|
||||
{ label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page", module: "research" },
|
||||
{ label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page", module: "research" },
|
||||
{ label: "Trade Plan", href: "/trade-plan", icon: ClipboardList, goTo: "t", group: "Page", module: "execution" },
|
||||
{ label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page", module: "execution" },
|
||||
{ label: "Trade Closure", href: "/trade-closure", icon: XCircle, goTo: "x", group: "Page", module: "execution" },
|
||||
{ label: "Daily Focus", href: "/daily-focus", icon: Target, goTo: "d", group: "Page", module: "execution" },
|
||||
{ label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page", module: "analytics" },
|
||||
{ label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page", module: "analytics" },
|
||||
{ label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page", module: "analytics" },
|
||||
{ label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page", module: "settings" },
|
||||
{ label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page", module: "admin" },
|
||||
{ label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page", module: "admin" },
|
||||
{ label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page", module: "admin" },
|
||||
{ label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page", module: "admin" },
|
||||
];
|
||||
|
||||
interface Command {
|
||||
@@ -84,6 +86,7 @@ function fuzzyScore(query: string, text: string): number {
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const { modules } = useFeatureAccess();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
@@ -124,16 +127,26 @@ export function CommandPalette() {
|
||||
const typing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement)?.isContentEditable;
|
||||
if (open || typing || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (gPressedAt.current && Date.now() - gPressedAt.current < 1200) {
|
||||
const map: Record<string, string> = {
|
||||
o: "/", c: "/chart-lab", i: "/institutional", m: "/market-outlook",
|
||||
t: "/trade-plan", e: "/execution", x: "/trade-closure", d: "/daily-focus",
|
||||
r: "/risk", f: "/filings", p: "/options-dd", s: "/settings", a: "/admin",
|
||||
};
|
||||
const href = map[e.key.toLowerCase()];
|
||||
if (href) {
|
||||
const goToMap: Array<{ key: string; href: string; module?: ModuleKey }> = [
|
||||
{ key: "o", href: "/", module: "research" },
|
||||
{ key: "c", href: "/chart-lab", module: "research" },
|
||||
{ key: "i", href: "/institutional", module: "research" },
|
||||
{ key: "m", href: "/market-outlook", module: "research" },
|
||||
{ key: "t", href: "/trade-plan", module: "execution" },
|
||||
{ key: "e", href: "/execution", module: "execution" },
|
||||
{ key: "x", href: "/trade-closure", module: "execution" },
|
||||
{ key: "d", href: "/daily-focus", module: "execution" },
|
||||
{ key: "r", href: "/risk", module: "analytics" },
|
||||
{ key: "f", href: "/filings", module: "analytics" },
|
||||
{ key: "p", href: "/options-dd", module: "analytics" },
|
||||
{ key: "s", href: "/settings", module: "settings" },
|
||||
{ key: "a", href: "/admin", module: "admin" },
|
||||
];
|
||||
const entry = goToMap.find((m) => m.key === e.key.toLowerCase());
|
||||
if (entry && (!entry.module || modules.includes(entry.module))) {
|
||||
e.preventDefault();
|
||||
gPressedAt.current = 0;
|
||||
router.push(href);
|
||||
router.push(entry.href);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -143,17 +156,19 @@ export function CommandPalette() {
|
||||
};
|
||||
window.addEventListener("keydown", onGoTo);
|
||||
return () => window.removeEventListener("keydown", onGoTo);
|
||||
}, [open, router]);
|
||||
}, [open, router, modules]);
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const pageCmds: Command[] = NAV_COMMANDS.map((c) => ({
|
||||
id: `page:${c.href}`,
|
||||
label: c.label,
|
||||
hint: c.goTo ? `g ${c.goTo}` : undefined,
|
||||
group: c.group,
|
||||
icon: c.icon,
|
||||
run: () => router.push(c.href),
|
||||
}));
|
||||
const pageCmds: Command[] = NAV_COMMANDS
|
||||
.filter((c) => !c.module || modules.includes(c.module))
|
||||
.map((c) => ({
|
||||
id: `page:${c.href}`,
|
||||
label: c.label,
|
||||
hint: c.goTo ? `g ${c.goTo}` : undefined,
|
||||
group: c.group,
|
||||
icon: c.icon,
|
||||
run: () => router.push(c.href),
|
||||
}));
|
||||
const symbolCmds: Command[] = symbols.map((sym) => ({
|
||||
id: `sym:${sym}`,
|
||||
label: `Open ${sym}`,
|
||||
@@ -166,7 +181,7 @@ export function CommandPalette() {
|
||||
},
|
||||
}));
|
||||
return [...pageCmds, ...symbolCmds];
|
||||
}, [symbols, router]);
|
||||
}, [symbols, router, modules]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) return commands;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||
|
||||
interface FeatureGateProps {
|
||||
module: ModuleKey;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FeatureGate({ module, children, fallback }: FeatureGateProps) {
|
||||
const { modules, loading } = useFeatureAccess();
|
||||
const router = useRouter();
|
||||
const allowed = modules.includes(module);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !allowed) {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [loading, allowed, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
if (fallback) return <>{fallback}</>;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-4">
|
||||
<p className="text-lg font-medium text-fg">Access Restricted</p>
|
||||
<p className="text-sm text-fg-muted">You don't have access to this module.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||
|
||||
/**
|
||||
* Mobile tab navigation bar.
|
||||
@@ -12,24 +13,27 @@ interface TabItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
module: ModuleKey;
|
||||
}
|
||||
|
||||
const TABS: TabItem[] = [
|
||||
{ label: "Overview", href: "/", icon: "📊" },
|
||||
{ label: "Filings", href: "/filings", icon: "📄" },
|
||||
{ label: "Options", href: "/options-dd", icon: "🔮" },
|
||||
{ label: "Alerts", href: "/alerts", icon: "🔔" },
|
||||
{ label: "Execution", href: "/execution", icon: "⚡" },
|
||||
{ label: "Trade Plan", href: "/trade-plan", icon: "📋" },
|
||||
{ label: "Settings", href: "/settings", icon: "⚙️" },
|
||||
{ label: "Overview", href: "/", icon: "📊", module: "research" },
|
||||
{ label: "Filings", href: "/filings", icon: "📄", module: "analytics" },
|
||||
{ label: "Options", href: "/options-dd", icon: "🔮", module: "analytics" },
|
||||
{ label: "Alerts", href: "/alerts", icon: "🔔", module: "analytics" },
|
||||
{ label: "Execution", href: "/execution", icon: "⚡", module: "execution" },
|
||||
{ label: "Trade Plan", href: "/trade-plan", icon: "📋", module: "execution" },
|
||||
{ label: "Settings", href: "/settings", icon: "⚙️", module: "settings" },
|
||||
];
|
||||
|
||||
export function MobileTabNav() {
|
||||
const pathname = usePathname();
|
||||
const { modules } = useFeatureAccess();
|
||||
const visibleTabs = TABS.filter((t) => modules.includes(t.module));
|
||||
|
||||
return (
|
||||
<nav className="flex md:hidden border-t border-line-strong bg-surface-raised">
|
||||
{TABS.map((tab) => {
|
||||
{visibleTabs.map((tab) => {
|
||||
const isActive = pathname === tab.href;
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { api, type AlertEventRow } from "@/lib/trpc";
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
info: "text-blue-400 bg-blue-950/30 border-blue-500/30",
|
||||
warning: "text-amber-400 bg-amber-950/30 border-amber-500/30",
|
||||
critical: "text-red-400 bg-red-950/30 border-red-500/30",
|
||||
};
|
||||
|
||||
const POLL_INTERVAL = 30_000;
|
||||
|
||||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [alerts, setAlerts] = useState<AlertEventRow[]>([]);
|
||||
const [unackedCount, setUnackedCount] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchAlerts = useCallback(async () => {
|
||||
try {
|
||||
const [evts, cnt] = await Promise.all([
|
||||
api.alerts.list(20),
|
||||
api.alerts.unackedCount(),
|
||||
]);
|
||||
setAlerts(evts);
|
||||
setUnackedCount(cnt.count);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts();
|
||||
const id = setInterval(fetchAlerts, POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchAlerts]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
if (open) {
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
if (open) {
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const dismiss = async (id: string) => {
|
||||
await api.alerts.acknowledge(id);
|
||||
setAlerts((prev) =>
|
||||
prev.map((a) => (a.id === id ? { ...a, acknowledged: true } : a)),
|
||||
);
|
||||
setUnackedCount((c) => Math.max(0, c - 1));
|
||||
window.dispatchEvent(new CustomEvent("alert-dismissed"));
|
||||
};
|
||||
|
||||
const unackedAlerts = alerts.filter((a) => !a.acknowledged);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label={`Notifications${unackedCount > 0 ? ` (${unackedCount} unread)` : ""}`}
|
||||
className="relative rounded-lg p-1.5 text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unackedCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white leading-none">
|
||||
{unackedCount > 99 ? "99+" : unackedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 z-50 w-80 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-line px-4 py-2.5">
|
||||
<span className="text-xs font-semibold text-fg">Notifications</span>
|
||||
{unackedCount > 0 && (
|
||||
<span className="rounded-full bg-danger/20 px-2 py-0.5 text-[10px] font-medium text-danger">
|
||||
{unackedCount} unread
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{unackedAlerts.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Bell className="mx-auto h-5 w-5 text-fg-faint/40 mb-2" />
|
||||
<p className="text-xs text-fg-faint">No new notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-line">
|
||||
{unackedAlerts.map((a) => (
|
||||
<div key={a.id} className="px-4 py-2.5 hover:bg-surface-sunken transition-colors">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 mb-0.5 flex-wrap">
|
||||
<span className={`rounded border px-1 py-0.5 text-[9px] font-medium ${SEVERITY_COLORS[a.severity] ?? ""}`}>{a.severity}</span>
|
||||
<span className="text-xs font-medium text-fg truncate">{a.title}</span>
|
||||
{a.symbol && <span className="rounded bg-surface-dark px-1 py-0.5 text-[9px] text-fg-faint">{a.symbol}</span>}
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-faint line-clamp-2">{a.description}</p>
|
||||
<p className="text-[9px] text-fg-faint/50 mt-0.5">
|
||||
{new Date(a.createdAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss(a.id)}
|
||||
className="shrink-0 rounded border border-line px-1.5 py-0.5 text-[9px] text-fg-faint hover:text-fg hover:bg-surface-dark transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/alerts"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block border-t border-line px-4 py-2 text-[10px] font-medium text-accent hover:bg-surface-sunken transition-colors text-center"
|
||||
>
|
||||
View all alerts
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Bell,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||
|
||||
/**
|
||||
* Left sidebar navigation with collapsible sections.
|
||||
@@ -29,6 +29,7 @@ interface NavItem {
|
||||
interface NavSection {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
module: ModuleKey;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
@@ -36,6 +37,7 @@ const SECTIONS: NavSection[] = [
|
||||
{
|
||||
title: "Research",
|
||||
icon: BarChart3,
|
||||
module: "research",
|
||||
items: [
|
||||
{ label: "Overview", href: "/" },
|
||||
{ label: "Market Outlook", href: "/market-outlook" },
|
||||
@@ -46,6 +48,7 @@ const SECTIONS: NavSection[] = [
|
||||
{
|
||||
title: "Execution",
|
||||
icon: Zap,
|
||||
module: "execution",
|
||||
items: [
|
||||
{ label: "Trade Plan", href: "/trade-plan" },
|
||||
{ label: "Execution", href: "/execution" },
|
||||
@@ -56,6 +59,7 @@ const SECTIONS: NavSection[] = [
|
||||
{
|
||||
title: "Analytics",
|
||||
icon: Microscope,
|
||||
module: "analytics",
|
||||
items: [
|
||||
{ label: "Risk Posture", href: "/risk" },
|
||||
{ label: "Filings", href: "/filings" },
|
||||
@@ -66,6 +70,7 @@ const SECTIONS: NavSection[] = [
|
||||
{
|
||||
title: "Admin",
|
||||
icon: Shield,
|
||||
module: "admin",
|
||||
items: [
|
||||
{ label: "Overview", href: "/admin" },
|
||||
{ label: "Users", href: "/admin/users" },
|
||||
@@ -78,13 +83,14 @@ const SECTIONS: NavSection[] = [
|
||||
{
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
module: "settings",
|
||||
items: [{ label: "Profile / Auth", href: "/settings" }],
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "iflow-nav-collapsed";
|
||||
|
||||
function getInitialCollapsed(pathname: string): Record<string, boolean> {
|
||||
function getInitialCollapsed(pathname: string, visibleSections: NavSection[]): Record<string, boolean> {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -93,7 +99,7 @@ function getInitialCollapsed(pathname: string): Record<string, boolean> {
|
||||
}
|
||||
const initial: Record<string, boolean> = {};
|
||||
const norm = (pathname || "/").replace(/\/+$/, "") || "/";
|
||||
for (const section of SECTIONS) {
|
||||
for (const section of visibleSections) {
|
||||
const isActive = section.items.some((item) => item.href === norm);
|
||||
initial[section.title] = !isActive;
|
||||
}
|
||||
@@ -103,18 +109,25 @@ function getInitialCollapsed(pathname: string): Record<string, boolean> {
|
||||
export function SidebarNav() {
|
||||
const pathname = usePathname();
|
||||
const normalizedPath = (pathname || "/").replace(/\/+$/, "") || "/";
|
||||
const { modules, loading } = useFeatureAccess();
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [unackedCount, setUnackedCount] = useState(0);
|
||||
|
||||
const visibleSections = SECTIONS.filter((s) => modules.includes(s.module));
|
||||
|
||||
useEffect(() => {
|
||||
api.alerts.unackedCount().then((r) => setUnackedCount(r.count)).catch(() => {});
|
||||
const fetch = () => api.alerts.unackedCount().then((r) => setUnackedCount(r.count)).catch(() => {});
|
||||
fetch();
|
||||
const handler = () => fetch();
|
||||
window.addEventListener("alert-dismissed", handler);
|
||||
return () => window.removeEventListener("alert-dismissed", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsed(getInitialCollapsed(pathname));
|
||||
setCollapsed(getInitialCollapsed(pathname, visibleSections));
|
||||
setInitialized(true);
|
||||
}, []);
|
||||
}, [loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
@@ -134,7 +147,7 @@ export function SidebarNav() {
|
||||
<aside className="w-52 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block">
|
||||
<nav className="flex h-full flex-col p-3">
|
||||
<div className="space-y-0.5 flex-1">
|
||||
{SECTIONS.map((section) => {
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<div key={section.title} className="mb-1">
|
||||
@@ -182,7 +195,7 @@ export function SidebarNav() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search affordance — opens the command palette */}
|
||||
{/* Search affordance -- opens the command palette */}
|
||||
<button
|
||||
onClick={openPalette}
|
||||
className="mt-2 flex items-center gap-2 w-full rounded-md border border-line bg-surface-sunken px-2 py-1.5 text-xs text-fg-muted hover:text-fg hover:bg-surface-overlay transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type Snapshot } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { NotificationBell } from "@/components/NotificationBell";
|
||||
|
||||
/**
|
||||
* Persistent top bar showing the active symbol, price, change, and search.
|
||||
@@ -115,6 +116,7 @@ export function SymbolHeader() {
|
||||
</form>
|
||||
|
||||
{/* Theme switcher */}
|
||||
<NotificationBell />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { useActiveWatchlist } from "@/stores/active-watchlist-store";
|
||||
import { api, type WatchlistEntry, type Quote, type PortfolioHolding } from "@/lib/trpc";
|
||||
import { chart as CHART } from "@/lib/chart-theme";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
@@ -15,6 +16,9 @@ interface WatchlistMeta {
|
||||
symbol_count: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
kind?: string;
|
||||
class_key?: string | null;
|
||||
class_label?: string | null;
|
||||
}
|
||||
|
||||
interface WatchlistSidebarProps {
|
||||
@@ -30,11 +34,19 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||
const [watchlists, setWatchlists] = useState<WatchlistMeta[]>([]);
|
||||
const [activeWatchlist, setActiveWatchlist] = useState("default");
|
||||
const activeWatchlist = useActiveWatchlist((s) => s.activeWatchlist);
|
||||
const setActiveWatchlist = useActiveWatchlist((s) => s.setActiveWatchlist);
|
||||
const [showListPicker, setShowListPicker] = useState(false);
|
||||
const [newListName, setNewListName] = useState("");
|
||||
const [menuOpenFor, setMenuOpenFor] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const userLists = watchlists.filter((w) => w.kind !== 'sector' && w.kind !== 'thematic' && w.kind !== 'style' && w.kind !== 'region');
|
||||
const systemLists = watchlists.filter((w) => w.kind === 'sector' || w.kind === 'thematic' || w.kind === 'style' || w.kind === 'region');
|
||||
const isSystemList = (name: string) => systemLists.some((w) => w.name === name);
|
||||
const activeUserList = userLists.find((w) => w.name === activeWatchlist) ? activeWatchlist : 'default';
|
||||
|
||||
// Portfolio summary
|
||||
useEffect(() => {
|
||||
api.portfolio.holdings().then(setHoldings).catch(() => {});
|
||||
@@ -46,8 +58,10 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
try {
|
||||
const wls = await api.watchlists.listWatchlists();
|
||||
setWatchlists(wls);
|
||||
if (wls.length > 0 && !wls.find((w) => w.name === activeWatchlist)) {
|
||||
setActiveWatchlist(wls[0].name);
|
||||
const allNames = wls.map((w) => w.name);
|
||||
if (activeWatchlist && !allNames.includes(activeWatchlist)) {
|
||||
const fallback = wls.find((w) => w.kind === 'user')?.name ?? 'default';
|
||||
setActiveWatchlist(fallback);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
@@ -87,6 +101,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
|
||||
const add = async () => {
|
||||
if (!draft.trim()) return;
|
||||
if (isSystemList(activeWatchlist)) return;
|
||||
const sym = draft.trim().toUpperCase();
|
||||
await api.watchlists.addSymbol(sym, activeWatchlist === "default" ? undefined : activeWatchlist);
|
||||
setDraft("");
|
||||
@@ -95,11 +110,20 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
};
|
||||
|
||||
const remove = async (symbol: string) => {
|
||||
if (isSystemList(activeWatchlist)) return;
|
||||
await api.watchlists.removeSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist);
|
||||
load();
|
||||
loadWatchlists();
|
||||
};
|
||||
|
||||
const moveSymbol = async (symbol: string, toWatchlist: string) => {
|
||||
if (isSystemList(activeWatchlist)) return;
|
||||
await api.watchlists.moveSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist, toWatchlist === "default" ? undefined : toWatchlist);
|
||||
load();
|
||||
loadWatchlists();
|
||||
setMenuOpenFor(null);
|
||||
};
|
||||
|
||||
const createWatchlist = async () => {
|
||||
if (!newListName.trim()) return;
|
||||
await api.watchlists.create(newListName.trim());
|
||||
@@ -110,6 +134,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
};
|
||||
|
||||
const deleteWatchlist = async (name: string) => {
|
||||
if (name === "default" || isSystemList(name)) return;
|
||||
await api.watchlists.delete(name);
|
||||
loadWatchlists();
|
||||
if (activeWatchlist === name) {
|
||||
@@ -130,6 +155,18 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
}
|
||||
}, [showListPicker]);
|
||||
|
||||
// Close action menu on outside click
|
||||
useEffect(() => {
|
||||
if (!menuOpenFor) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpenFor(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [menuOpenFor]);
|
||||
|
||||
const containerClass = compact
|
||||
? "w-full border-b border-line bg-surface-raised flex-shrink-0"
|
||||
: "w-56 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block";
|
||||
@@ -175,9 +212,22 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
</button>
|
||||
|
||||
{showListPicker && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{watchlists.map((wl) => (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden max-h-60 overflow-y-auto">
|
||||
{systemLists.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-[9px] uppercase tracking-wider text-fg-muted bg-surface-sunken">Sectors & Themes</div>
|
||||
{systemLists.map((wl) => (
|
||||
<div key={wl.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-surface-sunken cursor-pointer text-xs" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
|
||||
<span className={`flex-1 truncate ${wl.name === activeWatchlist ? "text-accent font-medium" : "text-fg"}`}>{wl.name}</span>
|
||||
<span className="text-fg-faint">{wl.symbol_count}</span>
|
||||
<span className="text-[8px] text-fg-muted">●</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={systemLists.length > 0 ? "border-t border-line" : ""}>
|
||||
<div className="px-2 py-1 text-[9px] uppercase tracking-wider text-fg-muted bg-surface-sunken">My Lists</div>
|
||||
{userLists.map((wl) => (
|
||||
<div key={wl.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-surface-sunken cursor-pointer text-xs" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
|
||||
<span className={`flex-1 truncate ${wl.name === activeWatchlist ? "text-accent font-medium" : "text-fg"}`}>{wl.name}</span>
|
||||
<span className="text-fg-faint">{wl.symbol_count}</span>
|
||||
@@ -208,8 +258,8 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add symbol form */}
|
||||
{!compact && (
|
||||
{/* Add symbol form — hidden for read-only system/sector lists */}
|
||||
{!compact && !isSystemList(activeWatchlist) && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
||||
<label className="sr-only" htmlFor="watchlist-add-symbol">Add watchlist symbol</label>
|
||||
<input
|
||||
@@ -286,14 +336,32 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
) : (
|
||||
<span className="w-14" />
|
||||
)}
|
||||
{!compact && (
|
||||
<button
|
||||
onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }}
|
||||
aria-label={`Remove ${e.symbol} from watchlist`}
|
||||
className="text-[9px] text-fg-muted hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded ml-auto"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{!compact && !isSystemList(activeWatchlist) && (
|
||||
<div className="relative" ref={menuOpenFor === e.symbol ? menuRef : undefined}>
|
||||
<button
|
||||
onClick={(ev) => { ev.stopPropagation(); setMenuOpenFor(menuOpenFor === e.symbol ? null : e.symbol); }}
|
||||
aria-label={`Actions for ${e.symbol}`}
|
||||
className="text-[9px] text-fg-muted hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded ml-1"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpenFor === e.symbol && (
|
||||
<div ref={menuRef} className="absolute right-0 top-full mt-1 z-50 rounded-md border border-line-strong bg-surface-raised shadow-xl py-1 w-36">
|
||||
{userLists.filter((w) => w.name !== activeWatchlist).length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[9px] uppercase tracking-wider text-fg-muted">Move to…</div>
|
||||
{userLists.filter((w) => w.name !== activeWatchlist).map((wl) => (
|
||||
<button key={wl.id} onClick={(ev) => { ev.stopPropagation(); moveSymbol(e.symbol, wl.name); }} className="block w-full text-left px-3 py-1.5 text-xs text-fg hover:bg-surface-sunken transition">{wl.name}</button>
|
||||
))}
|
||||
<hr className="my-1 border-line" />
|
||||
<button onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }} className="block w-full text-left px-3 py-1.5 text-xs text-danger hover:bg-surface-sunken transition">Remove from list</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }} className="block w-full text-left px-3 py-1.5 text-xs text-danger hover:bg-surface-sunken transition">Remove from list</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user