Files
Trackeep/frontend/src/components/layout/Sidebar.tsx
T
Tomas Dvorak 4c812e376d feat(messages): implement integrated chat with voice/calls and tidy root go module
Add Discord-like messaging APIs, websocket realtime, smart suggestions, password vault flows, semantic indexing integration, and new /app/messages UI.

Add typing indicators, advanced message search filters, voice notes, browser-local optional transcription, and WebRTC call signaling (offer/answer/ice/hangup).

Clean root go.mod via go mod tidy and remove stale root go.sum.
2026-02-26 10:54:19 +01:00

285 lines
13 KiB
TypeScript

import { For, createSignal, onMount, Show } from 'solid-js'
import { A, useLocation } from '@solidjs/router'
import {
IconBookmark,
IconChecklist,
IconFolder,
IconHome,
IconNotebook,
IconSettings,
IconVideo,
IconFileText,
IconChevronDown,
IconTrash,
IconUsers,
IconBrain,
IconSchool,
IconChartLine,
IconBrandGithub,
IconClock,
IconCalendar,
IconMessageCircle,
IconLogout,
IconBuilding,
IconPlus,
IconX
} from '@tabler/icons-solidjs'
import { UpdateChecker } from '../ui/UpdateChecker'
const navigation = [
{ name: 'Home', href: '/app', icon: IconHome },
{ name: 'Bookmarks', href: '/app/bookmarks', icon: IconBookmark },
{ name: 'Tasks', href: '/app/tasks', icon: IconChecklist },
{ name: 'Time Tracking', href: '/app/time-tracking', icon: IconClock },
{ name: 'Calendar', href: '/app/calendar', icon: IconCalendar },
{ name: 'Files', href: '/app/files', icon: IconFolder },
{ name: 'Notes', href: '/app/notes', icon: IconNotebook },
{ name: 'Messages', href: '/app/messages', icon: IconMessageCircle },
{ name: 'YouTube', href: '/app/youtube', icon: IconVideo },
{ name: 'Members', href: '/app/members', icon: IconUsers },
{ name: 'Learning', href: '/app/learning-paths', icon: IconSchool },
{ name: 'Stats', href: '/app/stats', icon: IconChartLine },
{ name: 'GitHub', href: '/app/github', icon: IconBrandGithub },
{ name: 'AI Assistant', href: '/app/chat', icon: IconBrain },
]
const mockWorkspaces = [
{ id: '1', name: 'Trackeep Workspace', icon: IconFileText },
{ id: '2', name: 'Personal Projects', icon: IconBuilding },
{ id: '3', name: 'Team Collaboration', icon: IconUsers },
]
export interface SidebarProps {
class?: string
isOpen?: boolean
onClose?: () => void
}
export function Sidebar(props: SidebarProps) {
const location = useLocation()
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = createSignal(false)
const [selectedWorkspace, setSelectedWorkspace] = createSignal(mockWorkspaces[0])
const isActive = (href: string) => {
const currentPath = location.pathname
if (href === '/app' && currentPath === '/app') return true
return currentPath === href
}
const handleWorkspaceSelect = (workspace: typeof mockWorkspaces[0]) => {
setSelectedWorkspace(workspace)
setIsWorkspaceDropdownOpen(false)
}
const toggleWorkspaceDropdown = () => {
setIsWorkspaceDropdownOpen(!isWorkspaceDropdownOpen())
}
// Close dropdown when clicking outside
onMount(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (!target.closest('#workspace-selector')) {
setIsWorkspaceDropdownOpen(false)
}
}
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
})
return (
<>
{/* Mobile Close Button - Above sidebar */}
<Show when={props.isOpen}>
<button
onClick={props.onClose}
class="fixed top-4 right-4 z-50 md:hidden inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-8 w-8"
>
<IconX class="size-4" />
</button>
</Show>
<div class={`fixed inset-y-0 left-0 z-50 w-280px border-r border-r-border flex-shrink-0 bg-card transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 ${
props.isOpen ? 'translate-x-0' : '-translate-x-full'
}`}>
<div class="flex h-full">
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
{/* Organization Selector */}
<div class="p-4 pb-0 min-w-0 max-w-full" id="workspace-selector">
<div role="group" class="w-full relative">
<button
type="button"
onClick={toggleWorkspaceDropdown}
aria-haspopup="listbox"
aria-expanded={isWorkspaceDropdownOpen()}
class="flex w-full items-center justify-between border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 hover:bg-accent/50 transition rounded-lg h-auto pl-2"
>
<span class="flex items-center gap-2 min-w-0">
<span class="p-1.5 rounded text-lg font-bold flex items-center bg-muted light:border dark:bg-primary/10 transition flex-shrink-0">
{(() => {
const workspace = selectedWorkspace()
return <workspace.icon class="size-5.5" style="color: hsl(var(--primary))" />
})()}
</span>
<span class="truncate text-base font-medium">{selectedWorkspace().name}</span>
</span>
<div class="size-4 opacity-50 ml-2 flex-shrink-0 transition-transform duration-200" classList={{ "rotate-180": isWorkspaceDropdownOpen() }}>
<IconChevronDown class="size-4" />
</div>
</button>
{/* Dropdown Menu */}
<Show when={isWorkspaceDropdownOpen()}>
<div class="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-60 overflow-auto">
<div class="p-1" role="listbox">
<For each={mockWorkspaces}>
{(workspace) => (
<button
type="button"
onClick={() => handleWorkspaceSelect(workspace)}
class="flex w-full items-center gap-2 px-3 py-2 text-sm rounded-sm hover:bg-accent/50 transition-colors focus:bg-accent/50 focus:outline-none"
role="option"
classList={{ "bg-accent/30": workspace.id === selectedWorkspace().id }}
>
{(() => {
const Icon = workspace.icon
return <Icon class="size-4 text-muted-foreground" />
})()}
<span class="flex-1 text-left truncate">{workspace.name}</span>
<Show when={workspace.id === selectedWorkspace().id}>
<div class="w-2 h-2 bg-primary rounded-full"></div>
</Show>
</button>
)}
</For>
<div class="border-t border-border mt-1 pt-1">
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-sm rounded-sm hover:bg-accent/50 transition-colors focus:bg-accent/50 focus:outline-none text-muted-foreground"
>
<IconPlus class="size-4" />
<span>Create Workspace</span>
</button>
</div>
</div>
</div>
</Show>
</div>
</div>
{/* Navigation */}
<nav class="flex flex-col gap-0.5 mt-4 px-4">
<For each={navigation}>
{(item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<A
href={item.href}
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden"
classList={{
"bg-[#262626] text-white font-medium shadow-sm": active,
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !active
}}
>
<div class="relative z-10 flex items-center gap-2">
<Icon class={`size-5 transition-colors ${
active
? 'text-primary'
: 'text-[#a3a3a3] group-hover:text-primary'
}`} />
<div class={`transition-colors ${
active
? 'text-white font-medium'
: 'text-[#a3a3a3] group-hover:text-white'
}`}>{item.name}</div>
</div>
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200" classList={{
"bg-[#262626]": !active
}}></div>
</A>
)
}}
</For>
</nav>
{/* Bottom Navigation */}
<div class="flex-1"></div>
{/* Update Checker */}
<div class="px-4 mb-2">
<UpdateChecker />
</div>
<nav class="flex flex-col gap-0.5 px-4">
<A
href="/app/removed-stuff"
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden"
classList={{
"bg-[#262626] text-white font-medium shadow-sm": isActive('/app/removed-stuff'),
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !isActive('/app/removed-stuff')
}}
>
<div class="relative z-10 flex items-center gap-2">
<IconTrash class={`size-5 transition-colors ${
isActive('/app/removed-stuff')
? 'text-white'
: 'text-[#a3a3a3] group-hover:text-primary'
}`} />
<div class={`transition-colors ${
isActive('/app/removed-stuff')
? 'text-white font-medium'
: 'text-[#a3a3a3] group-hover:text-white'
}`}>Removed stuff</div>
</div>
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200" classList={{
"bg-[#262626]": !isActive('/app/removed-stuff')
}}></div>
</A>
<A
href="/app/settings"
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden"
classList={{
"bg-[#262626] text-white font-medium shadow-sm": isActive('/app/settings'),
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !isActive('/app/settings')
}}
>
<div class="relative z-10 flex items-center gap-2">
<IconSettings class={`size-5 transition-colors ${
isActive('/app/settings')
? 'text-white'
: 'text-[#a3a3a3] group-hover:text-primary'
}`} />
<div class={`transition-colors ${
isActive('/app/settings')
? 'text-white font-medium'
: 'text-[#a3a3a3] group-hover:text-white'
}`}>Settings</div>
</div>
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200" classList={{
"bg-[#262626]": !isActive('/app/settings')
}}></div>
</A>
<button
onClick={() => {
// Handle logout logic here
localStorage.removeItem('auth_token')
window.location.href = '/login'
}}
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate w-full relative overflow-hidden hover:bg-destructive/10 hover:text-destructive dark:text-muted-foreground"
>
<div class="relative z-10 flex items-center gap-2">
<IconLogout class={`size-5 transition-colors text-[#a3a3a3]`} />
<div class="transition-colors">Logout</div>
</div>
<div class="absolute inset-0 bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200"></div>
</button>
</nav>
</div>
</div>
</div>
</>
)
}