first test

This commit is contained in:
Tomas Dvorak
2026-02-08 14:14:55 +01:00
parent 18aa702174
commit d27cf14110
372 changed files with 98087 additions and 2583 deletions
@@ -0,0 +1,237 @@
import { createSignal, For, Show } from 'solid-js'
import { IconSend, IconX, IconBrain, IconUser, IconChevronDown } from '@tabler/icons-solidjs'
import { AIProviderIcon } from '../AIProviderIcon'
interface AIChatPanelProps {
isOpen: boolean
onClose: () => void
}
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
}
interface AIModel {
id: string
name: string
description: string
provider: string
category: string
iconId?: string
}
export function AIChatPanel(props: AIChatPanelProps) {
const [messages, setMessages] = createSignal<Message[]>([
{
id: '1',
role: 'assistant',
content: 'Hello! I\'m your AI assistant. How can I help you today?',
timestamp: new Date()
}
])
const [inputValue, setInputValue] = createSignal('')
const [selectedModel, setSelectedModel] = createSignal('longcat-flash-chat')
const [showModelPicker, setShowModelPicker] = createSignal(false)
const aiModels: AIModel[] = [
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient', provider: 'longcat', category: 'fast', iconId: 'longcat' },
{ id: 'mistral-standard', name: 'Mistral Standard', description: 'Mistral default model', provider: 'mistral', category: 'standard', iconId: 'mistral' },
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
]
const handleSendMessage = () => {
const value = inputValue().trim()
if (!value) return
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: value,
timestamp: new Date()
}
setMessages(prev => [...prev, userMessage])
setInputValue('')
// Simulate AI response
setTimeout(() => {
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: 'I understand your question. Let me help you with that...',
timestamp: new Date()
}
setMessages(prev => [...prev, aiMessage])
}, 1000)
}
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
return (
<>
{/* Chat Panel */}
<div class={`fixed right-0 top-0 h-full bg-card border-l border-border shadow-xl transition-transform duration-300 z-50 ${
props.isOpen ? 'translate-x-0' : 'translate-x-full'
}`} style="width: min(420px, 100vw); max-width: 100vw;">
{/* Header */}
<div class="flex items-center justify-between p-4 border-b border-border">
<div class="flex items-center gap-2">
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10">
<IconBrain class="size-5 text-primary" />
</div>
<div>
<h3 class="font-semibold">AI Assistant</h3>
<p class="text-xs text-muted-foreground">Always here to help</p>
</div>
</div>
<div class="flex items-center gap-2">
{/* Close Button */}
<button
onClick={props.onClose}
class="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 text-foreground" />
</button>
</div>
</div>
{/* Messages */}
<div class="flex-1 overflow-y-auto p-4 space-y-4" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
<For each={messages()}>
{(message) => (
<div class={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{message.role === 'assistant' && (
<div class="flex items-center justify-center p-2 rounded-lg bg-muted flex-shrink-0">
<IconBrain class="size-4 text-primary" />
</div>
)}
<div class={`max-w-[280px] rounded-2xl p-3 ${
message.role === 'user'
? 'bg-primary text-primary-foreground rounded-br-sm'
: 'bg-muted rounded-bl-sm'
}`}>
<p class="text-sm leading-relaxed">{message.content}</p>
<p class="text-xs opacity-70 mt-2">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
{message.role === 'user' && (
<div class="flex items-center justify-center p-2 rounded-lg bg-primary flex-shrink-0">
<IconUser class="size-4 text-primary-foreground" />
</div>
)}
</div>
)}
</For>
</div>
{/* Input */}
<div class="p-4 border-t border-border bg-card">
<div class="flex gap-2">
<input
type="text"
value={inputValue()}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
class="flex-1 h-10 w-full rounded-full border border-input bg-transparent px-4 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<button
onClick={handleSendMessage}
disabled={!inputValue().trim()}
class="inline-flex items-center justify-center rounded-full 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-primary text-primary-foreground shadow hover:bg-primary/90 h-10 w-10"
>
<IconSend class="size-4 text-primary-foreground" />
</button>
</div>
{/* Model Picker at Bottom */}
<div class="mt-3 pt-3 border-t border-border">
<div class="flex items-center justify-between">
<div class="relative">
<button
onClick={() => setShowModelPicker(!showModelPicker())}
class="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-muted/80 rounded-full text-xs transition-colors"
>
<Show when={aiModels.find(m => m.id === selectedModel())?.iconId}>
<AIProviderIcon
providerId={aiModels.find(m => m.id === selectedModel())?.iconId || 'longcat'}
size="1rem"
class="rounded-full"
/>
</Show>
<Show when={!aiModels.find(m => m.id === selectedModel())?.iconId}>
<div class="w-4 h-4 rounded-full bg-gradient-to-r from-blue-500 to-purple-500"></div>
</Show>
<span class="text-muted-foreground">
{aiModels.find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
</span>
<IconChevronDown class={`size-3 transition-transform ${showModelPicker() ? 'rotate-180' : ''}`} />
</button>
<Show when={showModelPicker()}>
<div class="absolute bottom-full left-0 mb-2 w-64 bg-background border rounded-lg shadow-lg z-50 p-1 max-h-48 overflow-y-auto">
<For each={aiModels}>
{model => (
<button
onClick={() => {
setSelectedModel(model.id)
setShowModelPicker(false)
}}
class={`w-full text-left p-2 rounded text-xs transition-colors ${
selectedModel() === model.id
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-muted'
}`}
>
<div class="flex items-center gap-2">
<Show when={model.iconId}>
<AIProviderIcon
providerId={model.iconId!}
size="0.75rem"
class="rounded-full flex-shrink-0"
/>
</Show>
<Show when={!model.iconId}>
<div class={`w-3 h-3 rounded-full flex-shrink-0 ${
model.provider === 'LongCat' ? 'bg-gradient-to-r from-orange-500 to-red-500' :
model.provider === 'OpenAI' ? 'bg-gradient-to-r from-green-500 to-emerald-500' :
'bg-gradient-to-r from-purple-500 to-pink-500'
}`}></div>
</Show>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{model.name}</div>
<div class="text-muted-foreground text-xs truncate">{model.description}</div>
</div>
</div>
</button>
)}
</For>
</div>
</Show>
</div>
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span>{aiModels.find(m => m.id === selectedModel())?.provider || 'LongCat'}</span>
<a href="/app/settings" class="text-primary hover:underline">
AI settings
</a>
</div>
</div>
</div>
</div>
</div>
</>
)
}
@@ -0,0 +1,185 @@
import { createSignal, Show } from 'solid-js'
import { IconX, IconSend, IconUser, IconChevronDown } from '@tabler/icons-solidjs'
import longcatIcon from '@/assets/longcat-color.svg'
interface FloatingAIProps {
onToggleChat: () => void
isChatOpen: boolean
}
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
}
export function FloatingAI(props: FloatingAIProps) {
const [messages, setMessages] = createSignal<Message[]>([
{
id: '1',
role: 'assistant',
content: 'Hello! I\'m your AI assistant. How can I help you today?',
timestamp: new Date()
}
])
const [inputValue, setInputValue] = createSignal('')
const [selectedModel, setSelectedModel] = createSignal('longcat-flash-chat')
const [showModelSelector, setShowModelSelector] = createSignal(false)
const aiModels = [
{ id: 'longcat-flash-chat', name: 'LongCat Flash', description: 'Fast and efficient' },
{ id: 'gpt-4', name: 'GPT-4', description: 'Most capable' },
{ id: 'claude-3', name: 'Claude 3', description: 'Balanced performance' }
]
const handleSendMessage = () => {
const value = inputValue().trim()
if (!value) return
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: value,
timestamp: new Date()
}
setMessages(prev => [...prev, userMessage])
setInputValue('')
// Simulate AI response
setTimeout(() => {
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: 'I understand your question. Let me help you with that...',
timestamp: new Date()
}
setMessages(prev => [...prev, aiMessage])
}, 1000)
}
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
return (
<>
{/* Floating AI Button */}
<button
onClick={props.onToggleChat}
class="fixed bottom-6 right-8 z-40 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-all duration-200 hover:scale-110 w-14 h-14"
title="AI Assistant"
>
<img src={longcatIcon} alt="AI Assistant" class="size-6" />
</button>
{/* AI Chat Modal */}
<Show when={props.isChatOpen}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-card border border-border rounded-lg shadow-xl max-w-md w-full max-h-[600px] flex flex-col" style="width: 420px;">
{/* Header */}
<div class="flex items-center justify-between p-4 border-b border-border bg-gradient-to-r from-primary/10 to-primary/5">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center p-3 rounded-lg bg-primary/20">
<img src={longcatIcon} alt="AI Assistant" class="size-5" />
</div>
<div>
<h3 class="font-semibold text-foreground">AI Assistant</h3>
<div class="flex items-center gap-2">
<p class="text-xs text-muted-foreground">Always here to help</p>
<div class="relative">
<button
onClick={() => setShowModelSelector(!showModelSelector())}
class="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
>
{aiModels.find(m => m.id === selectedModel())?.name || 'LongCat Flash'}
<IconChevronDown class="size-3" />
</button>
{/* Model Selector Dropdown */}
<Show when={showModelSelector()}>
<div class="absolute bottom-full left-0 mb-2 w-48 bg-popover border border-border rounded-md shadow-lg z-10">
{aiModels.map((model) => (
<button
onClick={() => {
setSelectedModel(model.id)
setShowModelSelector(false)
}}
class="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors first:rounded-t-md last:rounded-b-md"
>
<div class="font-medium">{model.name}</div>
<div class="text-xs text-muted-foreground">{model.description}</div>
</button>
))}
</div>
</Show>
</div>
</div>
</div>
</div>
<button
onClick={props.onToggleChat}
class="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 text-foreground" />
</button>
</div>
{/* Messages */}
<div class="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-background to-muted/20" style="max-height: 400px;">
{messages().map((message) => (
<div class={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'} animate-in slide-in-from-bottom-2 duration-200`}>
{message.role === 'assistant' && (
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10 flex-shrink-0">
<img src={longcatIcon} alt="AI Assistant" class="size-4" />
</div>
)}
<div class={`max-w-[300px] rounded-lg p-3 shadow-sm ${
message.role === 'user'
? 'bg-primary text-primary-foreground ml-auto'
: 'bg-muted border border-border'
}`}>
<p class="text-sm leading-relaxed">{message.content}</p>
<p class="text-xs opacity-70 mt-2">
{message.timestamp.toLocaleTimeString()}
</p>
</div>
{message.role === 'user' && (
<div class="flex items-center justify-center p-2 rounded-lg bg-primary flex-shrink-0">
<IconUser class="size-4 text-primary-foreground" />
</div>
)}
</div>
))}
</div>
{/* Input */}
<div class="p-4 border-t border-border bg-muted/30">
<div class="flex gap-2">
<input
type="text"
value={inputValue()}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
class="flex-1 h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
onClick={handleSendMessage}
disabled={!inputValue().trim()}
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 hover:shadow-md h-10 px-4"
>
<IconSend class="size-4" />
</button>
</div>
</div>
</div>
</div>
</Show>
</>
)
}
+95 -62
View File
@@ -1,14 +1,15 @@
import {
IconBell,
IconSearch,
IconPlus,
IconUpload,
IconMoon,
IconLogout,
IconUser
IconSettings,
IconMenu2
} from '@tabler/icons-solidjs'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { cn } from '@/lib/utils'
import { QuickSearch } from '@/components/search/QuickSearch'
import { ColorSwitcherDropdown } from '@/components/ui/ColorSwitcherDropdown'
import { UploadModal } from '@/components/ui/UploadModal'
import { UserProfileDropdown } from '@/components/ui/UserProfileDropdown'
import { createSignal } from 'solid-js'
import { useAuth } from '@/lib/auth'
export interface HeaderProps {
@@ -16,70 +17,102 @@ export interface HeaderProps {
title?: string
}
export function Header(props: HeaderProps) {
const { authState, logout } = useAuth();
export function Header(_props: HeaderProps) {
const [showUploadModal, setShowUploadModal] = createSignal(false);
const { authState, updateProfile } = useAuth();
const handleLogout = async () => {
await logout();
const handleThemeToggle = async () => {
const currentTheme = document.documentElement.getAttribute('data-kb-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
// Apply theme immediately
if (newTheme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-kb-theme');
}
// Save to localStorage
localStorage.setItem('theme', newTheme);
// Update user profile if authenticated
if (authState.isAuthenticated && authState.user) {
try {
await updateProfile({ theme: newTheme });
} catch (error) {
console.error('Failed to update theme in profile:', error);
// Still keep the local theme change even if profile update fails
}
}
// Reload the page so all Papra CSS and color schemes re-initialize
// and the theme change is fully applied without manual refresh
window.location.reload();
};
return (
<header class={cn('flex h-16 items-center justify-between border-b border-[#262626] bg-[#141415] px-6', props.class)}>
{/* Page Title */}
<div class="flex items-center space-x-4">
<h1 class="text-xl font-semibold text-[#fafafa]">
{props.title || 'Dashboard'}
</h1>
</div>
{/* Search and Actions */}
<div class="flex items-center space-x-4">
{/* Search */}
<div class="relative">
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#a3a3a3]" />
<Input
type="search"
placeholder="Search bookmarks, tasks, files..."
class="w-80 pl-10 bg-[#141415] border-[#262626] text-[#fafafa] placeholder-[#a3a3a3]"
/>
<>
<div class="flex justify-between px-6 pt-4 pb-4">
{/* Left side */}
<div class="flex items-center">
{/* Mobile menu button */}
<button type="button" aria-haspopup="dialog" aria-expanded="false" data-closed="" class="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-9 w-9 md:hidden mr-2">
<IconMenu2 class="size-6" />
</button>
{/* Quick Search */}
<QuickSearch />
</div>
{/* Quick Actions */}
<div class="flex items-center space-x-2">
<Button variant="ghost" size="icon" class="text-[#a3a3a3] hover:text-[#fafafa]">
<IconPlus class="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" class="text-[#a3a3a3] hover:text-[#fafafa]">
<IconBell class="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" class="text-[#a3a3a3] hover:text-[#fafafa]">
<IconMoon class="h-5 w-5" />
</Button>
{/* User Menu */}
<div class="flex items-center space-x-2 pl-4 border-l border-[#262626]">
<div class="flex items-center space-x-2">
<div class="text-right">
<p class="text-sm font-medium text-[#fafafa]">{authState.user?.full_name || authState.user?.username}</p>
<p class="text-xs text-[#a3a3a3]">{authState.user?.email}</p>
</div>
<Button variant="ghost" size="icon" class="text-[#a3a3a3] hover:text-[#fafafa]">
<IconUser class="h-5 w-5" />
</Button>
{/* Right side */}
<div class="flex items-center gap-2">
{/* Drop zone overlay */}
<div class="fixed top-0 left-0 w-screen h-screen z-80 bg-background bg-opacity-50 backdrop-blur transition-colors hidden">
<div class="flex items-center justify-center h-full text-center flex-col">
<IconPlus class="text-6xl text-muted-foreground mx-auto" />
<div class="text-xl my-2 font-semibold text-muted-foreground">Drop files here</div>
<div class="text-base text-muted-foreground">Drag and drop files here to import them</div>
</div>
<Button
variant="ghost"
size="icon"
class="text-[#a3a3a3] hover:text-[#fafafa]"
onClick={handleLogout}
>
<IconLogout class="h-5 w-5" />
</Button>
</div>
{/* Import button */}
<button
type="button"
onClick={() => setShowUploadModal(true)}
class="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-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2"
>
<IconUpload class="size-4" />
<span class="hidden sm:inline ml-2">Import a document</span>
</button>
{/* Color switcher dropdown */}
<ColorSwitcherDropdown />
{/* Theme switcher */}
<button
type="button"
onClick={handleThemeToggle}
class="items-center justify-center rounded-md font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-8 px-3 py-1 text-base hidden sm:flex"
>
<IconMoon class="size-4" />
</button>
{/* Admin link */}
<a href="/app/admin-settings" class="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 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2 hidden sm:flex gap-2">
<IconSettings class="size-4" />
Admin
</a>
{/* User menu */}
<UserProfileDropdown />
</div>
</div>
</header>
{/* Upload Modal */}
<UploadModal
isOpen={showUploadModal()}
onClose={() => setShowUploadModal(false)}
/>
</>
)
}
+162 -13
View File
@@ -1,31 +1,180 @@
import { children } from 'solid-js'
import { children, createSignal, onMount } from 'solid-js'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
import { cn } from '@/lib/utils'
import { AIChatPanel } from './AIChatPanel'
import { UpdateNotification } from '../ui/UpdateNotification'
import { IconBrain } from '@tabler/icons-solidjs'
export interface LayoutProps {
children: any
title?: string
class?: string
fullBleed?: boolean
}
export function Layout(props: LayoutProps) {
const resolved = children(() => props.children)
const [isChatOpen, setIsChatOpen] = createSignal(false)
onMount(() => {
// Initialize dark mode from localStorage or system preference
const savedTheme = localStorage.getItem('theme')
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
document.documentElement.setAttribute('data-kb-theme', 'dark')
} else {
document.documentElement.removeAttribute('data-kb-theme')
}
// Initialize color scheme from localStorage
const savedColorScheme = localStorage.getItem('colorScheme');
const savedCustomColors = localStorage.getItem('customColors');
if (savedColorScheme === 'custom' && savedCustomColors) {
try {
const colors = JSON.parse(savedCustomColors);
// Apply custom colors
const hexToHsl = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return '0 0% 100%';
let r = parseInt(result[1], 16) / 255;
let g = parseInt(result[2], 16) / 255;
let b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
const root = document.documentElement;
root.style.setProperty('--primary', hexToHsl(colors.primary));
root.style.setProperty('--background', hexToHsl(colors.background));
root.style.setProperty('--foreground', hexToHsl(colors.foreground));
root.style.setProperty('--muted', hexToHsl(colors.muted));
root.style.setProperty('--border', colors.border);
// Also set as CSS custom properties for direct use
root.style.setProperty('--colors-primary', hexToHsl(colors.primary));
root.style.setProperty('--colors-background', hexToHsl(colors.background));
root.style.setProperty('--colors-foreground', hexToHsl(colors.foreground));
root.style.setProperty('--colors-muted', hexToHsl(colors.muted));
root.style.setProperty('--colors-border', colors.border);
} catch (e) {
console.error('Failed to load custom colors:', e);
}
} else if (savedColorScheme) {
// Apply predefined scheme
const predefinedSchemes: Record<string, any> = {
'default': { primary: '#5ab9ff', background: savedTheme === 'dark' ? '#1a1a1a' : '#ffffff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#262727' : '#f5f5f5', border: '#262626' },
'ocean': { primary: '#0077be', background: savedTheme === 'dark' ? '#001f3f' : '#e6f3ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#003366' : '#cce7ff', border: '#004080' },
'forest': { primary: '#228b22', background: savedTheme === 'dark' ? '#0d2818' : '#f0f8f0', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#1a431a' : '#d4edd4', border: '#2d5a2d' },
'sunset': { primary: '#ff6b35', background: savedTheme === 'dark' ? '#2c1810' : '#fff5f0', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#5c2e00' : '#ffe4d6', border: '#8b4513' },
'purple': { primary: '#8b5cf6', background: savedTheme === 'dark' ? '#1a0033' : '#f8f5ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#330066' : '#ede9fe', border: '#4d0099' },
'rose': { primary: '#f43f5e', background: savedTheme === 'dark' ? '#2d1111' : '#fff1f2', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#5a1a1a' : '#ffe4e6', border: '#881337' },
'amber': { primary: '#f59e0b', background: savedTheme === 'dark' ? '#2d1a00' : '#fffbeb', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#5c4a00' : '#fef3c7', border: '#78350f' },
'emerald': { primary: '#10b981', background: savedTheme === 'dark' ? '#022c22' : '#ecfdf5', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#064e3b' : '#d1fae5', border: '#047857' },
'cyan': { primary: '#06b6d4', background: savedTheme === 'dark' ? '#022c3a' : '#ecfeff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#164e63' : '#cffafe', border: '#0891b2' },
'indigo': { primary: '#6366f1', background: savedTheme === 'dark' ? '#1e1b4b' : '#eef2ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#312e81' : '#e0e7ff', border: '#4338ca' }
};
const scheme = predefinedSchemes[savedColorScheme];
if (scheme) {
const hexToHsl = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return '0 0% 100%';
let r = parseInt(result[1], 16) / 255;
let g = parseInt(result[2], 16) / 255;
let b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
const root = document.documentElement;
root.style.setProperty('--primary', hexToHsl(scheme.primary));
root.style.setProperty('--background', hexToHsl(scheme.background));
root.style.setProperty('--foreground', hexToHsl(scheme.foreground));
root.style.setProperty('--muted', hexToHsl(scheme.muted));
root.style.setProperty('--border', scheme.border);
// Also set as CSS custom properties for direct use
root.style.setProperty('--colors-primary', hexToHsl(scheme.primary));
root.style.setProperty('--colors-background', hexToHsl(scheme.background));
root.style.setProperty('--colors-foreground', hexToHsl(scheme.foreground));
root.style.setProperty('--colors-muted', hexToHsl(scheme.muted));
root.style.setProperty('--colors-border', hexToHsl(scheme.border));
}
}
})
const toggleChat = () => {
setIsChatOpen(!isChatOpen())
}
return (
<div class={cn('flex h-screen bg-[#18181b]', props.class)}>
{/* Sidebar */}
<Sidebar />
<div class="min-h-screen font-sans text-sm font-400 bg-background text-foreground">
{/* Update Notification - Above everything */}
<UpdateNotification />
{/* Main Content */}
<div class="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<Header title={props.title} />
<div class="flex flex-row h-screen min-h-0">
{/* Sidebar */}
<Sidebar />
{/* Page Content */}
<main class="flex-1 overflow-y-auto bg-[#18181b] p-6">
{resolved()}
</main>
{/* Main Content */}
<div class="flex-1 min-h-0 flex flex-col">
{/* Header */}
<Header title={props.title} />
{/* Page Content */}
<main class="flex-1 overflow-auto max-w-screen">
<div class={props.fullBleed ? "h-full" : "p-2 max-w-7xl mx-auto"}>
{resolved()}
</div>
</main>
</div>
{/* Floating AI Button */}
<button
onClick={toggleChat}
class="fixed bottom-6 right-8 z-40 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-all duration-200 hover:scale-110 w-14 h-14"
title="AI Assistant"
>
<IconBrain class="size-6 text-primary-foreground" />
</button>
{/* AI Chat Panel */}
<AIChatPanel isOpen={isChatOpen()} onClose={() => setIsChatOpen(false)} />
</div>
</div>
)
+236 -43
View File
@@ -1,70 +1,263 @@
import { For } from 'solid-js'
import { A } from '@solidjs/router'
import { For, createSignal, onMount, Show } from 'solid-js'
import { A, useLocation } from '@solidjs/router'
import {
IconBookmark,
IconChecklist,
IconFolder,
IconHome,
IconNotebook,
IconSettings
IconSettings,
IconVideo,
IconFileText,
IconChevronDown,
IconTrash,
IconUsers,
IconBrain,
IconSchool,
IconChartLine,
IconBrandGithub,
IconClock,
IconCalendar,
IconLogout,
IconBuilding,
IconPlus
} from '@tabler/icons-solidjs'
import { cn } from '@/lib/utils'
import { UpdateChecker } from '../ui/UpdateChecker'
const navigation = [
{ name: 'Dashboard', href: '/app', icon: IconHome },
{ 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: 'Settings', href: '/app/settings', icon: IconSettings },
{ 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
}
export function Sidebar(props: SidebarProps) {
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 (
<div class={cn('flex h-full w-64 flex-col bg-[#141415] border-r border-[#262626]', props.class)}>
{/* Logo */}
<div class="flex h-16 items-center px-6 border-b border-[#262626]">
<div class="flex items-center space-x-3">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-500">
<span class="text-sm font-bold text-white">T</span>
</div>
<span class="text-xl font-semibold text-[#fafafa]">Trackeep</span>
</div>
</div>
{/* Navigation */}
<nav class="flex-1 space-y-1 px-3 py-4">
<For each={navigation}>
{(item) => {
const Icon = item.icon
return (
<A
href={item.href}
class="flex items-center px-3 py-2 text-sm font-medium rounded-md text-[#a3a3a3] hover:text-[#fafafa] hover:bg-[#262626] transition-colors group"
activeClass="bg-primary-600 text-white hover:bg-primary-700"
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<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"
>
<Icon class="mr-3 h-5 w-5" />
{item.name}
</A>
)
}}
</For>
</nav>
<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>
{/* User Section */}
<div class="border-t border-[#262626] p-4">
<div class="flex items-center space-x-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-[#262626]">
<span class="text-sm font-medium text-[#a3a3a3]">U</span>
{/* 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>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-[#fafafa] truncate">User</p>
<p class="text-xs text-[#a3a3a3] truncate">user@trackeep.local</p>
{/* 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>