414 lines
20 KiB
TypeScript
414 lines
20 KiB
TypeScript
import { type Component, For, Show } from "solid-js";
|
|
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle } from "lucide-solid";
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "./ui/button";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { store, currentTaskContext, setCurrentTaskContext } from "@/store";
|
|
import { pb } from "@/lib/pocketbase";
|
|
import { createResource, createSignal, createEffect } from "solid-js";
|
|
|
|
interface NavItem {
|
|
icon: any;
|
|
label: string;
|
|
view: string;
|
|
}
|
|
|
|
// Desktop nav items (all)
|
|
const desktopNavItems: NavItem[] = [
|
|
{ icon: ListTodo, label: "Focus", view: "critical" },
|
|
{ icon: Clock, label: "Urgency", view: "urgency" },
|
|
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
|
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
|
{ icon: Snowflake, label: "Snowball", view: "snowball" },
|
|
{ icon: Pickaxe, label: "Dig In", view: "dig_in" },
|
|
{ icon: BarChart3, label: "Progress", view: "progress" },
|
|
];
|
|
|
|
// Mobile nav groups
|
|
interface MobileNavGroup {
|
|
icon: any;
|
|
label: string;
|
|
items?: NavItem[];
|
|
view?: string; // Direct navigation (no sub-items)
|
|
}
|
|
|
|
const mobileNavGroups: MobileNavGroup[] = [
|
|
{ icon: ListTodo, label: "Focus", view: "critical" },
|
|
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
|
{
|
|
icon: TrendingUp, label: "Value", items: [
|
|
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
|
{ icon: Clock, label: "Urgency", view: "urgency" },
|
|
]
|
|
},
|
|
{
|
|
icon: Gauge, label: "Flow", items: [
|
|
{ icon: Snowflake, label: "Snowball", view: "snowball" },
|
|
{ icon: Pickaxe, label: "Dig In", view: "dig_in" },
|
|
{ icon: BarChart3, label: "Progress", view: "progress" },
|
|
]
|
|
},
|
|
{ icon: Settings, label: "Settings", view: "settings" },
|
|
];
|
|
|
|
export const BottomNav: Component<{ currentView: string; setView: (v: string) => void }> = (props) => {
|
|
const isGroupActive = (group: MobileNavGroup) => {
|
|
if (group.view) return props.currentView === group.view;
|
|
return group.items?.some(item => props.currentView === item.view) ?? false;
|
|
};
|
|
|
|
return (
|
|
<nav class="fixed bottom-0 left-0 right-0 bg-background border-t border-border flex justify-around items-center h-16 md:hidden z-50">
|
|
<For each={mobileNavGroups}>
|
|
{(group) => (
|
|
<Show when={group.items} fallback={
|
|
<button
|
|
onClick={() => group.view && props.setView(group.view)}
|
|
class={cn(
|
|
"flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors",
|
|
isGroupActive(group) ? "text-primary" : "text-muted-foreground"
|
|
)}
|
|
>
|
|
<group.icon size={20} />
|
|
<span class="text-[0.625rem] font-medium uppercase tracking-wider">{group.label}</span>
|
|
</button>
|
|
}>
|
|
<Popover>
|
|
<PopoverTrigger
|
|
class={cn(
|
|
"flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors",
|
|
isGroupActive(group) ? "text-primary" : "text-muted-foreground"
|
|
)}
|
|
>
|
|
<div class="relative">
|
|
<group.icon size={20} />
|
|
<ChevronDown size={10} class="absolute -right-2 -bottom-0.5 opacity-60" />
|
|
</div>
|
|
<span class="text-[0.625rem] font-medium uppercase tracking-wider">{group.label}</span>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-40 p-1 bg-card border-border shadow-xl mb-2">
|
|
<div class="space-y-0.5">
|
|
<For each={group.items}>
|
|
{(item) => (
|
|
<button
|
|
class={cn(
|
|
"w-full flex items-center gap-3 p-2.5 rounded-lg transition-colors text-left group",
|
|
props.currentView === item.view
|
|
? "bg-primary text-primary-foreground"
|
|
: "hover:bg-muted text-foreground"
|
|
)}
|
|
onClick={() => props.setView(item.view)}
|
|
>
|
|
<item.icon size={16} />
|
|
<span class="text-sm font-medium">{item.label}</span>
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Show>
|
|
)}
|
|
</For>
|
|
</nav>
|
|
);
|
|
};
|
|
|
|
export const ContextSwitcher: Component<{
|
|
isLocked?: boolean;
|
|
isPeeking?: boolean;
|
|
setIsPeeking?: (v: boolean) => void;
|
|
onOpenChange?: (open: boolean) => void;
|
|
isMobile?: boolean;
|
|
}> = (props) => {
|
|
const currentUserId = pb.authStore.model?.id;
|
|
const [open, setOpen] = createSignal(false);
|
|
|
|
// Sync with parent if needed
|
|
createEffect(() => {
|
|
props.onOpenChange?.(open());
|
|
});
|
|
|
|
// Close popover when sidebar disappears (Desktop only)
|
|
createEffect(() => {
|
|
if (!props.isMobile && !props.isLocked && !props.isPeeking) {
|
|
setOpen(false);
|
|
}
|
|
});
|
|
|
|
const [oversightUsers] = createResource(
|
|
() => [...new Set(store.shareRules.filter(r => r.targetUserId === currentUserId && r.type === 'all').map(r => r.ownerId))],
|
|
async (ownerIds) => {
|
|
if (ownerIds.length === 0) return [];
|
|
try {
|
|
// Fetch user names for these IDs in a single query
|
|
const filter = ownerIds.map(id => `id="${id}"`).join(' || ');
|
|
const users = await pb.collection('users').getFullList({
|
|
filter,
|
|
fields: 'id,name,email',
|
|
requestKey: null // Disable auto-cancellation
|
|
});
|
|
return users.map(u => ({ id: u.id, name: u.name || u.email }));
|
|
} catch (e: any) {
|
|
if (e.isAbort) return []; // Ignore explicit aborts
|
|
console.error("Failed to load oversight users", e);
|
|
return [];
|
|
}
|
|
}
|
|
);
|
|
|
|
const ctx = currentTaskContext; // Accessor
|
|
|
|
const label = () => {
|
|
const c = ctx();
|
|
return c === 'mine' ? "My Bucket" : c.name;
|
|
};
|
|
|
|
const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => {
|
|
if (type === 'mine') {
|
|
setCurrentTaskContext('mine');
|
|
} else if (type === 'bucket') {
|
|
setCurrentTaskContext({ bucketId: id, name });
|
|
} else {
|
|
setCurrentTaskContext({ userId: id, name });
|
|
}
|
|
|
|
// Close popover
|
|
setOpen(false);
|
|
|
|
// Collapse sidebar if peaking (Desktop only)
|
|
if (!props.isMobile && props.setIsPeeking && !props.isLocked && props.isPeeking) {
|
|
props.setIsPeeking(false);
|
|
}
|
|
};
|
|
|
|
const hasContexts = () => (oversightUsers() && oversightUsers()!.length > 0) || store.subscribedBuckets.length > 0;
|
|
|
|
return (
|
|
<Show when={hasContexts()}>
|
|
<Popover open={open()} onOpenChange={setOpen}>
|
|
<PopoverTrigger class={cn(props.isMobile ? "w-auto" : "w-full")}>
|
|
<div class={cn(
|
|
"flex items-center justify-between transition-all duration-200 group",
|
|
props.isMobile
|
|
? "h-10 w-10 flex items-center justify-center bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm rounded-xl"
|
|
: "px-3 py-2.5 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent hover:border-border/50 w-full text-sm",
|
|
)}>
|
|
<Show when={props.isMobile} fallback={
|
|
<>
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<div class={cn(
|
|
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
|
|
ctx() === 'mine' ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
|
|
)}>
|
|
<div class="w-2 h-2 rounded-full bg-current" />
|
|
</div>
|
|
<span class="font-medium truncate">{label()}</span>
|
|
</div>
|
|
<ChevronDown size={14} class="opacity-50 group-hover:opacity-100 transition-opacity ml-2" />
|
|
</>
|
|
}>
|
|
<div class="relative">
|
|
<Users size={18} class={cn("transition-transform duration-300", open() && "scale-110", ctx() !== 'mine' && "text-orange-500")} />
|
|
{ctx() !== 'mine' && (
|
|
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-orange-500 border border-background" />
|
|
)}
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-[200px] p-1 bg-card border-border shadow-xl">
|
|
<div class="space-y-0.5">
|
|
<button
|
|
onClick={() => handleSwitch('mine', '', 'mine')}
|
|
class={cn(
|
|
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
|
|
ctx() === 'mine' ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
|
|
)}
|
|
>
|
|
<div class="w-4 h-4 rounded-full bg-primary/20 flex items-center justify-center">
|
|
<div class="w-2 h-2 rounded-full bg-primary" />
|
|
</div>
|
|
My Bucket
|
|
</button>
|
|
<Show when={store.subscribedBuckets.length > 0}>
|
|
<div class="py-1">
|
|
<div class="h-px bg-border/50" />
|
|
</div>
|
|
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
|
<span>Pinned Global Buckets</span>
|
|
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{store.buckets.filter(b => store.subscribedBuckets.includes(b.id)).length}</span>
|
|
</div>
|
|
<For each={store.buckets.filter(b => store.subscribedBuckets.includes(b.id))}>
|
|
{(bucket) => (
|
|
<button
|
|
onClick={() => handleSwitch(bucket.id, bucket.name, 'bucket')}
|
|
class={cn(
|
|
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
|
|
(ctx() as any).bucketId === bucket.id ? "bg-muted text-foreground font-semibold ring-1 ring-border" : "hover:bg-muted text-foreground"
|
|
)}
|
|
>
|
|
<div
|
|
class="w-4 h-4 rounded-md flex items-center justify-center text-white"
|
|
style={{ "background-color": bucket.color }}
|
|
>
|
|
<Box size={10} />
|
|
</div>
|
|
<span class="truncate">{bucket.name}</span>
|
|
</button>
|
|
)}
|
|
</For>
|
|
</Show>
|
|
|
|
<Show when={oversightUsers() && oversightUsers()!.length > 0}>
|
|
<div class="py-1">
|
|
<div class="h-px bg-border/50" />
|
|
</div>
|
|
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
|
<span>Shared User Buckets</span>
|
|
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{oversightUsers()?.length}</span>
|
|
</div>
|
|
<For each={oversightUsers()}>
|
|
{(user) => (
|
|
<button
|
|
onClick={() => handleSwitch(user.id, user.name, 'user')}
|
|
class={cn(
|
|
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
|
|
(ctx() as any).userId === user.id ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
|
|
)}
|
|
>
|
|
<div class="w-4 h-4 rounded-full bg-orange-500/20 flex items-center justify-center">
|
|
<div class="w-2 h-2 rounded-full bg-orange-500" />
|
|
</div>
|
|
<span class="truncate">{user.name}</span>
|
|
</button>
|
|
)}
|
|
</For>
|
|
</Show>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Show>
|
|
);
|
|
};
|
|
|
|
export const Sidebar: Component<{
|
|
currentView: string;
|
|
setView: (v: string) => void;
|
|
isLocked: boolean;
|
|
setIsLocked: (v: boolean) => void;
|
|
isPeeking: boolean;
|
|
setIsPeeking: (v: boolean) => void;
|
|
isEmbed?: boolean;
|
|
onDropdownOpenChange?: (open: boolean) => void;
|
|
}> = (props) => {
|
|
const [switcherOpen, setSwitcherOpen] = createSignal(false);
|
|
|
|
return (
|
|
<aside
|
|
onMouseEnter={() => !props.isLocked && props.setIsPeeking(true)}
|
|
onMouseLeave={() => {
|
|
if (!props.isLocked && !switcherOpen()) {
|
|
props.setIsPeeking(false);
|
|
}
|
|
}}
|
|
class={cn(
|
|
"hidden md:flex flex-col border-r border-border bg-card h-screen z-[50]",
|
|
!props.isEmbed && "fixed left-0 top-0 transition-all duration-150 ease-in-out",
|
|
!props.isEmbed && (props.isLocked ? "w-48 translate-x-0" : "w-48 -translate-x-full"),
|
|
!props.isEmbed && !props.isLocked && props.isPeeking && "translate-x-0 shadow-2xl ring-1 ring-border",
|
|
props.isEmbed && "w-full h-full border-r-0 bg-transparent"
|
|
)}
|
|
>
|
|
<div class={cn("p-4 flex items-center justify-between", !props.isLocked && "pl-14")}>
|
|
<h1 class={cn(
|
|
"text-xl font-bold tracking-tighter text-foreground px-2 transition-opacity",
|
|
(props.isLocked || props.isPeeking)
|
|
? "opacity-100 duration-500 delay-[50ms]"
|
|
: "opacity-0 duration-116 delay-0"
|
|
)}>
|
|
Tasgrid
|
|
</h1>
|
|
{/* Close Button - Only visible when Locked (to collapse) or Peeking (manual close) */}
|
|
<Show when={props.isLocked}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="h-8 w-8 text-muted-foreground hover:text-foreground"
|
|
onClick={() => {
|
|
props.setIsLocked(false);
|
|
props.setIsPeeking(false);
|
|
}}
|
|
>
|
|
<PanelLeftClose size={16} />
|
|
</Button>
|
|
</Show>
|
|
</div>
|
|
|
|
|
|
|
|
<nav class="flex-1 px-3 space-y-1 mt-2">
|
|
<For each={desktopNavItems}>
|
|
{(item) => (
|
|
<button
|
|
onClick={() => props.setView(item.view)}
|
|
class={cn(
|
|
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
|
|
props.currentView === item.view
|
|
? "bg-primary text-primary-foreground shadow-sm"
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
)}
|
|
>
|
|
<item.icon size={16} class={cn("transition-transform group-hover:scale-110")} />
|
|
<span class="font-medium">{item.label}</span>
|
|
</button>
|
|
)}
|
|
</For>
|
|
</nav>
|
|
|
|
{/* Context Switcher - Only visible if there are oversight contexts */}
|
|
<div class={cn(
|
|
"px-3 mb-2 transition-opacity duration-200",
|
|
(!props.isLocked && !props.isPeeking) ? "opacity-0 pointer-events-none" : "opacity-100 delay-100"
|
|
)}>
|
|
<ContextSwitcher
|
|
isLocked={props.isLocked}
|
|
isPeeking={props.isPeeking}
|
|
setIsPeeking={props.setIsPeeking}
|
|
onOpenChange={(open) => {
|
|
setSwitcherOpen(open);
|
|
props.onDropdownOpenChange?.(open);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div class="px-3 border-t border-border mt-auto pt-3 space-y-1 pb-3">
|
|
<button
|
|
onClick={() => props.setView("help")}
|
|
class={cn(
|
|
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
|
|
props.currentView === "help"
|
|
? "bg-primary text-primary-foreground shadow-sm"
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
)}
|
|
>
|
|
<HelpCircle size={16} class={cn("transition-transform group-hover:scale-110")} />
|
|
<span class="font-medium">Help Guide</span>
|
|
</button>
|
|
<button
|
|
onClick={() => props.setView("settings")}
|
|
class={cn(
|
|
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
|
|
props.currentView === "settings"
|
|
? "bg-primary text-primary-foreground shadow-sm"
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
)}
|
|
>
|
|
<Settings size={16} class={cn("transition-transform group-hover:scale-110")} />
|
|
<span class="font-medium">Settings</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
);
|
|
};
|