Remove AI/chat/search features, add solidtime integration and workspace setup
CI/CD Pipeline / Test (push) Successful in 22m22s
CI/CD Pipeline / Security Scan (push) Successful in 12m37s
CI/CD Pipeline / Build and Push Images (push) Failing after 2m23s

This commit is contained in:
Tomas Dvorak
2026-06-10 10:41:31 +02:00
parent 4dfdd500b4
commit 43acbfab15
51 changed files with 1284 additions and 15385 deletions
+64 -22
View File
@@ -8,7 +8,6 @@ import { Bookmarks } from '@/pages/content/Bookmarks'
import { Tasks } from '@/pages/productivity/Tasks'
import { Files } from '@/pages/content/Files'
import { Notes } from '@/pages/content/Notes'
import Chat from '@/pages/communication/Chat'
import { Settings } from '@/pages/settings/Settings'
import { Login } from '@/pages/auth/Login'
import { Youtube } from '@/pages/content/Youtube'
@@ -23,11 +22,11 @@ import { LearningPaths } from '@/pages/content/LearningPaths'
import { GitHub } from '@/pages/content/GitHub'
import { TimeTracking } from '@/pages/productivity/TimeTracking'
import { Calendar } from '@/pages/productivity/Calendar'
import { WorkspaceSetup } from '@/pages/auth/WorkspaceSetup'
import { AuthCallback } from '@/pages/auth/AuthCallback'
import { AuthProvider, useAuth } from '@/lib/auth'
import { Search } from '@/pages/content/Search'
import { Analytics } from '@/pages/admin/Analytics'
import { Messages } from '@/pages/communication/Messages'
import { ShareTarget } from '@/pages/misc/ShareTarget'
import BrowserExtensionSettings from '@/pages/settings/BrowserExtensionSettings'
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
@@ -40,29 +39,78 @@ const initializeDarkMode = () => {
const savedTheme = localStorage.getItem('theme');
const user = localStorage.getItem('user') || localStorage.getItem('trackeep_user');
const root = document.documentElement;
root.style.removeProperty('--foreground');
root.style.removeProperty('--colors-foreground');
root.style.removeProperty('--background');
root.style.removeProperty('--colors-background');
root.style.removeProperty('--primary');
root.style.removeProperty('--colors-primary');
root.style.removeProperty('--muted');
root.style.removeProperty('--colors-muted');
root.style.removeProperty('--border');
root.style.removeProperty('--colors-border');
if (user) {
try {
const userData = JSON.parse(user);
// Prefer user's saved theme from profile, fallback to localStorage
const userTheme = userData.theme || savedTheme;
if (userTheme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark');
root.setAttribute('data-kb-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-kb-theme');
root.removeAttribute('data-kb-theme');
}
} catch (e) {
// Fallback to localStorage or dark mode if user data is invalid
if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark');
root.setAttribute('data-kb-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-kb-theme');
root.removeAttribute('data-kb-theme');
}
}
} else if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-kb-theme', 'dark');
root.setAttribute('data-kb-theme', 'dark');
} else {
// Default to dark mode
document.documentElement.setAttribute('data-kb-theme', 'dark');
root.setAttribute('data-kb-theme', 'dark');
}
const savedColorScheme = localStorage.getItem('colorScheme');
if (savedColorScheme && savedColorScheme !== 'default') {
const schemeColors: Record<string, string> = {
'ocean': '#0077be',
'forest': '#228b22',
'sunset': '#ff6b35',
'purple': '#8b5cf6',
};
const primary = schemeColors[savedColorScheme];
if (primary) {
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 hsl = hexToHsl(primary);
root.style.setProperty('--primary', hsl);
root.style.setProperty('--colors-primary', hsl);
}
}
};
@@ -143,6 +191,13 @@ function App() {
</Layout>
</ProtectedRoute>
)} />
<Route path="/app/workspace-setup" component={() => (
<ProtectedRoute>
<Layout title="Workspace Setup">
<WorkspaceSetup />
</Layout>
</ProtectedRoute>
)} />
<Route path="/app/bookmarks" component={() => (
<ProtectedRoute>
<Layout title="Bookmarks">
@@ -206,20 +261,7 @@ function App() {
</Layout>
</ProtectedRoute>
)} />
<Route path="/app/chat" component={() => (
<ProtectedRoute>
<Layout title="AI Chat" fullBleed>
<Chat />
</Layout>
</ProtectedRoute>
)} />
<Route path="/app/messages" component={() => (
<ProtectedRoute>
<Layout title="Messages" fullBleed>
<Messages />
</Layout>
</ProtectedRoute>
)} />
<Route path="/app/members" component={() => (
<ProtectedRoute>
<Layout title="Members">
@@ -1,95 +0,0 @@
import { createMemo, Show } from 'solid-js';
interface AIProviderIconProps {
providerId: string;
size?: string;
class?: string;
white?: boolean;
}
const inlineSVGs: Record<string, string> = {
openrouter: '<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>',
ollama: '<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.38-1.416.507-2.3.381a3.822 3.822 0 01-1.416-.507c-.727-.442-1.133-1.134-1.133-2.022 0-.705.375-1.413 1.005-1.94.646-.542 1.51-.855 2.446-.855z"></path></svg>',
grok: '<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>',
};
const iconPaths: Record<string, string> = {
mistral: '/assets/mistral-color.svg',
longcat: '/assets/longcat-color.svg',
deepseek: '/assets/deepseek-color.svg',
};
const fallbackIcons: Record<string, string> = {
mistral: 'M',
longcat: 'C',
grok: 'G',
deepseek: 'D',
ollama: 'O',
openrouter: 'OR',
};
export function AIProviderIcon(props: AIProviderIconProps) {
const inlineSVG = createMemo(() => inlineSVGs[props.providerId]);
const iconPath = createMemo(() => iconPaths[props.providerId]);
const fallbackIcon = createMemo(() => fallbackIcons[props.providerId] || 'AI');
// Use inline SVG if available (for openrouter, ollama, grok)
if (inlineSVG()) {
return (
<div
class={props.class}
style={{
width: props.size || "1.5rem",
height: props.size || "1.5rem",
display: "flex",
"align-items": "center",
"justify-content": "center",
color: props.white ? "white" : "currentColor"
}}
innerHTML={inlineSVG()}
/>
);
}
// Use image for other providers
return (
<Show when={iconPath()} fallback={
<span class={props.class} style={{
"font-size": props.size || "1rem",
color: props.white ? "white" : "currentColor"
}}>
{fallbackIcon()}
</span>
}>
<img
src={iconPath()}
alt={`${props.providerId} icon`}
class={props.class}
style={{
width: props.size || "1.5rem",
height: props.size || "1.5rem",
"object-fit": "contain",
filter: props.white ? "brightness(0) invert(1)" : "none"
}}
onError={(e) => {
// Fallback to emoji if SVG fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
if (target.nextElementSibling) {
(target.nextElementSibling as HTMLElement).style.display = 'inline';
}
}}
/>
<span
class={props.class}
style={{
"font-size": props.size || "1.5rem",
display: "none",
color: props.white ? "white" : "currentColor"
}}
>
{fallbackIcon()}
</span>
</Show>
);
}
@@ -1,228 +0,0 @@
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-gradient-to-br from-muted to-muted/90 border border-border/50 flex-shrink-0 shadow-sm">
<IconBrain class="size-4 text-primary animate-pulse" />
</div>
)}
<div class={`max-w-[280px] rounded-2xl p-3 shadow-sm transition-all duration-200 hover:shadow-md ${
message.role === 'user'
? 'bg-gradient-to-br from-primary to-primary/90 text-primary-foreground rounded-br-sm ml-auto'
: 'bg-gradient-to-br from-muted to-muted/90 border border-border/50 rounded-bl-sm'
}`}>
<p class="text-sm leading-relaxed">{message.content}</p>
<div class="flex items-center justify-between mt-2">
<p class="text-xs opacity-70">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
{message.role === 'user' && (
<div class="w-1.5 h-1.5 bg-primary-foreground/50 rounded-full"></div>
)}
</div>
</div>
{message.role === 'user' && (
<div class="flex items-center justify-center p-2 rounded-lg bg-gradient-to-br from-primary to-primary/90 flex-shrink-0 shadow-sm">
<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-border/50 bg-background/95 backdrop-blur-sm px-4 py-2 text-sm shadow-sm transition-all duration-200 focus:shadow-md focus:border-primary/50 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 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-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 disabled:pointer-events-none disabled:opacity-50 bg-gradient-to-r from-primary to-primary/90 text-primary-foreground shadow-sm hover:shadow-md hover:from-primary/90 hover:to-primary h-10 w-10 disabled:cursor-not-allowed"
>
<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"
>
<AIProviderIcon
providerId={aiModels.find(m => m.id === selectedModel())?.iconId || 'longcat'}
size="1rem"
class="rounded-full"
/>
<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-gradient-to-b from-background to-background/95 backdrop-blur-sm border border-border/50 rounded-xl 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-lg text-xs transition-all duration-200 ${
selectedModel() === model.id
? 'bg-gradient-to-r from-primary/10 to-primary/5 border border-primary/20'
: 'hover:bg-muted/50'
}`}
>
<div class="flex items-center gap-2">
<AIProviderIcon
providerId={model.iconId!}
size="0.75rem"
class="rounded-full flex-shrink-0"
/>
<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#ai" class="text-primary hover:underline">
AI settings
</a>
</div>
</div>
</div>
</div>
</div>
</>
)
}
+1 -54
View File
@@ -1,8 +1,6 @@
import { children, createSignal, onMount } from 'solid-js'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
import { AIChatPanel } from './AIChatPanel'
import { IconBrain } from '@tabler/icons-solidjs'
import { isEnvDemoMode } from '@/lib/demo-mode'
export interface LayoutProps {
@@ -14,7 +12,6 @@ export interface LayoutProps {
export function Layout(props: LayoutProps) {
const resolved = children(() => props.children)
const [isChatOpen, setIsChatOpen] = createSignal(false)
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
onMount(() => {
@@ -25,7 +22,6 @@ export function Layout(props: LayoutProps) {
setIsSidebarOpen(window.innerWidth >= 768)
}
// Initialize dark mode from localStorage or system preference
const savedTheme = localStorage.getItem('theme')
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
@@ -38,49 +34,38 @@ export function Layout(props: LayoutProps) {
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));
@@ -90,7 +75,6 @@ export function Layout(props: LayoutProps) {
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' },
@@ -103,43 +87,34 @@ export function Layout(props: LayoutProps) {
'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));
@@ -149,10 +124,6 @@ export function Layout(props: LayoutProps) {
}
})
const toggleChat = () => {
setIsChatOpen(!isChatOpen())
}
const toggleSidebar = () => {
const nextValue = !isSidebarOpen()
setIsSidebarOpen(nextValue)
@@ -166,43 +137,19 @@ export function Layout(props: LayoutProps) {
return (
<div class="min-h-screen font-sans text-sm font-400 bg-background text-foreground">
<div class="flex flex-row h-screen min-h-0 relative">
{/* Mobile Sidebar Overlay */}
{isSidebarOpen() && (
<div
class="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={closeSidebar}
/>
<div class="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={closeSidebar} />
)}
{/* Sidebar */}
<Sidebar isOpen={isSidebarOpen()} onClose={closeSidebar} />
{/* Main Content */}
<div class="flex-1 min-h-0 flex flex-col">
{/* Header */}
{!props.fullBleed && <Header title={props.title} onMenuClick={toggleSidebar} />}
{/* Page Content */}
<main class={`flex-1 ${props.fullBleed ? 'overflow-hidden' : 'overflow-auto w-full'}`}>
<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>
)
+251 -70
View File
@@ -12,16 +12,21 @@ import {
IconChevronDown,
IconTrash,
IconUsers,
IconBrain,
IconSchool,
IconChartLine,
IconBrandGithub,
IconClock,
IconCalendar,
IconMessageCircle,
IconLogout,
IconBuilding,
IconPlus
IconPlus,
IconWorld,
IconLock,
IconCheck,
IconStar,
IconHeart,
IconBriefcase,
IconEdit
} from '@tabler/icons-solidjs'
import { Input } from '../ui/Input'
import { Button } from '../ui/Button'
@@ -39,13 +44,11 @@ const navigation = [
{ 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 API_BASE_URL = getApiV1BaseUrl()
@@ -55,13 +58,25 @@ interface WorkspaceOption {
id: string
name: string
icon: typeof IconFileText
iconId?: string
description?: string
is_public?: boolean
}
const getWorkspaceIcon = (name: string) => {
const lower = name.toLowerCase()
if (lower.includes('team')) return IconUsers
if (lower.includes('personal')) return IconBuilding
return IconFileText
const WORKSPACE_ICONS = [
{ id: 'building', icon: IconBuilding },
{ id: 'world', icon: IconWorld },
{ id: 'lock', icon: IconLock },
{ id: 'check', icon: IconCheck },
{ id: 'star', icon: IconStar },
{ id: 'heart', icon: IconHeart },
{ id: 'home', icon: IconHome },
{ id: 'briefcase', icon: IconBriefcase },
]
const getWorkspaceIcon = (iconId: string) => {
const found = WORKSPACE_ICONS.find((i) => i.id === iconId)
return found ? found.icon : IconBuilding
}
const getAuthToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''
@@ -85,6 +100,14 @@ export function Sidebar(props: SidebarProps) {
const [workspaceIsPublic, setWorkspaceIsPublic] = createSignal(false)
const [isCreatingWorkspace, setIsCreatingWorkspace] = createSignal(false)
const [createWorkspaceError, setCreateWorkspaceError] = createSignal('')
const [isEditWorkspaceModalOpen, setIsEditWorkspaceModalOpen] = createSignal(false)
const [editingWorkspace, setEditingWorkspace] = createSignal<WorkspaceOption | null>(null)
const [editWorkspaceName, setEditWorkspaceName] = createSignal('')
const [editWorkspaceDescription, setEditWorkspaceDescription] = createSignal('')
const [editWorkspaceIsPublic, setEditWorkspaceIsPublic] = createSignal(false)
const [editWorkspaceIcon, setEditWorkspaceIcon] = createSignal('building')
const [isSavingWorkspace, setIsSavingWorkspace] = createSignal(false)
const [saveWorkspaceError, setSaveWorkspaceError] = createSignal('')
const selectedWorkspace = () => {
const list = workspaces()
@@ -140,48 +163,27 @@ export function Sidebar(props: SidebarProps) {
setIsWorkspaceDropdownOpen(!isWorkspaceDropdownOpen())
}
const normalizeWorkspace = (team: { id?: number | string; name?: string }): WorkspaceOption => {
const normalizeWorkspace = (team: { id?: number | string; name?: string; description?: string; is_public?: boolean; icon_id?: string }): WorkspaceOption => {
const name = team.name?.trim() || DEFAULT_WORKSPACE_NAME
const iconId = team.icon_id || 'building'
return {
id: String(team.id ?? `workspace-${Date.now()}`),
name,
icon: getWorkspaceIcon(name),
icon: getWorkspaceIcon(iconId),
iconId,
description: team.description,
is_public: team.is_public,
}
}
const createDefaultWorkspace = async (token: string): Promise<WorkspaceOption | null> => {
const response = await fetch(`${API_BASE_URL}/teams`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: DEFAULT_WORKSPACE_NAME,
description: 'Default workspace',
is_public: false,
}),
})
if (!response.ok) {
return null
}
const data = await response.json()
if (!data?.team) {
return null
}
return normalizeWorkspace(data.team)
}
const loadWorkspaces = async () => {
const token = getAuthToken()
if (!token) {
const fallbackWorkspace = {
id: 'local-default',
name: DEFAULT_WORKSPACE_NAME,
icon: IconFileText,
icon: IconBuilding,
iconId: 'building',
}
setWorkspaces([fallbackWorkspace])
setSelectedWorkspaceId(fallbackWorkspace.id)
@@ -204,20 +206,11 @@ export function Sidebar(props: SidebarProps) {
}
if (mappedWorkspaces.length === 0) {
const created = await createDefaultWorkspace(token)
if (created) {
mappedWorkspaces = [created]
const currentPath = window.location.pathname
if (currentPath === '/app' || currentPath === '/app/') {
window.location.href = '/app/workspace-setup'
}
}
if (mappedWorkspaces.length === 0) {
mappedWorkspaces = [
{
id: 'local-default',
name: DEFAULT_WORKSPACE_NAME,
icon: IconFileText,
},
]
return
}
setWorkspaces(mappedWorkspaces)
@@ -233,7 +226,8 @@ export function Sidebar(props: SidebarProps) {
const fallbackWorkspace = {
id: 'local-default',
name: DEFAULT_WORKSPACE_NAME,
icon: IconFileText,
icon: IconBuilding,
iconId: 'building',
}
setWorkspaces([fallbackWorkspace])
setSelectedWorkspaceId(fallbackWorkspace.id)
@@ -258,7 +252,8 @@ export function Sidebar(props: SidebarProps) {
const localWorkspace = {
id: `local-${Date.now()}`,
name: trimmed,
icon: getWorkspaceIcon(trimmed),
icon: getWorkspaceIcon('building'),
iconId: 'building',
}
setWorkspaces((prev) => [localWorkspace, ...prev])
handleWorkspaceSelect(localWorkspace)
@@ -307,6 +302,87 @@ export function Sidebar(props: SidebarProps) {
}
}
const openEditWorkspaceModal = (workspace: WorkspaceOption) => {
setEditingWorkspace(workspace)
setEditWorkspaceName(workspace.name)
setEditWorkspaceDescription(workspace.description || '')
setEditWorkspaceIsPublic(workspace.is_public || false)
setEditWorkspaceIcon(workspace.iconId || 'building')
setSaveWorkspaceError('')
setIsEditWorkspaceModalOpen(true)
setIsWorkspaceDropdownOpen(false)
}
const closeEditWorkspaceModal = () => {
if (isSavingWorkspace()) return
setIsEditWorkspaceModalOpen(false)
setEditingWorkspace(null)
}
const handleSaveWorkspace = async () => {
const workspace = editingWorkspace()
if (!workspace) return
const trimmed = editWorkspaceName().trim()
if (!trimmed) {
setSaveWorkspaceError('Workspace name required')
return
}
setSaveWorkspaceError('')
setIsSavingWorkspace(true)
const token = getAuthToken()
if (!token) {
setWorkspaces((prev) =>
prev.map((w) =>
w.id === workspace.id
? { ...w, name: trimmed, description: editWorkspaceDescription(), is_public: editWorkspaceIsPublic(), iconId: editWorkspaceIcon(), icon: getWorkspaceIcon(editWorkspaceIcon()) }
: w
)
)
setIsEditWorkspaceModalOpen(false)
setIsSavingWorkspace(false)
return
}
try {
const response = await fetch(`${API_BASE_URL}/teams/${workspace.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: trimmed,
description: editWorkspaceDescription().trim(),
is_public: editWorkspaceIsPublic(),
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data?.error || 'Failed to save workspace')
}
setWorkspaces((prev) =>
prev.map((w) =>
w.id === workspace.id
? { ...w, name: trimmed, description: editWorkspaceDescription(), is_public: editWorkspaceIsPublic(), iconId: editWorkspaceIcon(), icon: getWorkspaceIcon(editWorkspaceIcon()) }
: w
)
)
if (selectedWorkspaceId() === workspace.id) {
persistSelectedWorkspace({
...workspace,
name: trimmed,
icon: getWorkspaceIcon(editWorkspaceIcon()),
iconId: editWorkspaceIcon()
})
}
setIsEditWorkspaceModalOpen(false)
} catch (error) {
setSaveWorkspaceError(error instanceof Error ? error.message : 'Failed to save workspace')
} finally {
setIsSavingWorkspace(false)
}
}
// Close dropdown when clicking outside
onMount(() => {
void loadWorkspaces()
@@ -375,22 +451,31 @@ export function Sidebar(props: SidebarProps) {
<div class="p-1" role="listbox">
<For each={workspaces()}>
{(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>
<div class="flex items-center gap-1 px-2 py-1 rounded-sm hover:bg-accent/50 transition-colors group" classList={{ "bg-accent/30": workspace.id === selectedWorkspace().id }}>
<button
type="button"
onClick={() => handleWorkspaceSelect(workspace)}
class="flex flex-1 items-center gap-2 text-sm focus:outline-none"
role="option"
>
{(() => {
const Icon = workspace.icon
return <Icon class="size-4 text-muted-foreground" />
})()}
<span class="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>
<button
type="button"
onClick={() => openEditWorkspaceModal(workspace)}
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-accent/50 transition-opacity"
title="Edit workspace"
>
<IconEdit class="size-3.5 text-muted-foreground" />
</button>
</div>
)}
</For>
<div class="border-t border-border mt-1 pt-1">
@@ -596,6 +681,102 @@ export function Sidebar(props: SidebarProps) {
</>
</ModalPortal>
</Show>
<Show when={isEditWorkspaceModalOpen()}>
<ModalPortal>
<>
<div
class="fixed inset-0 z-[90] bg-black/50"
onClick={closeEditWorkspaceModal}
/>
<div class="fixed top-1/2 left-1/2 z-[100] w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4">
<div class="rounded-lg border border-border bg-card shadow-xl">
<div class="border-b border-border p-5">
<h3 class="text-lg font-semibold text-foreground">Edit Workspace</h3>
<p class="mt-1 text-sm text-muted-foreground">Update workspace details and icon.</p>
</div>
<div class="space-y-4 p-5">
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Name</label>
<Input
type="text"
placeholder="Workspace name"
value={editWorkspaceName()}
onInput={(event) => setEditWorkspaceName((event.currentTarget as HTMLInputElement).value)}
disabled={isSavingWorkspace()}
/>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-foreground">Description</label>
<textarea
rows={3}
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Optional description"
value={editWorkspaceDescription()}
onInput={(event) => setEditWorkspaceDescription((event.currentTarget as HTMLTextAreaElement).value)}
disabled={isSavingWorkspace()}
/>
</div>
<div>
<label class="text-sm font-medium text-foreground mb-2 block">Icon</label>
<div class="flex gap-2 flex-wrap">
{WORKSPACE_ICONS.map((item) => {
const Icon = item.icon
return (
<button
type="button"
onClick={() => setEditWorkspaceIcon(item.id)}
class={`p-2 rounded-lg border transition-colors ${
editWorkspaceIcon() === item.id
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
disabled={isSavingWorkspace()}
>
<Icon class="size-5" />
</button>
)
})}
</div>
</div>
<div class="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2">
<div>
<p class="text-sm font-medium text-foreground">Public workspace</p>
<p class="text-xs text-muted-foreground">Allow all members to discover this workspace.</p>
</div>
<Switch
checked={editWorkspaceIsPublic()}
onCheckedChange={setEditWorkspaceIsPublic}
disabled={isSavingWorkspace()}
/>
</div>
<Show when={saveWorkspaceError()}>
<p class="text-sm text-destructive">{saveWorkspaceError()}</p>
</Show>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={closeEditWorkspaceModal}
disabled={isSavingWorkspace()}
>
Cancel
</Button>
<Button onClick={() => void handleSaveWorkspace()} disabled={isSavingWorkspace()}>
{isSavingWorkspace() ? 'Saving...' : 'Save Workspace'}
</Button>
</div>
</div>
</div>
</div>
</>
</ModalPortal>
</Show>
</>
)
}
@@ -1,374 +0,0 @@
import { createSignal, For, Show } from 'solid-js';
import { IconSearch, IconExternalLink, IconLoader2, IconBookmark } from '@tabler/icons-solidjs';
import { type BraveSearchResult } from '@/lib/brave-search';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { isEnvDemoMode } from '@/lib/demo-mode';
import { getApiBaseUrl } from '@/lib/credentials';
export const BrowserSearch = () => {
const [searchQuery, setSearchQuery] = createSignal('');
const [searchResults, setSearchResults] = createSignal<BraveSearchResult[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [error, setError] = createSignal('');
const [hasSearched, setHasSearched] = createSignal(false);
const [searchType, setSearchType] = createSignal<'web' | 'news'>('web');
// Add debouncing and request cancellation
let searchTimeout: number | undefined;
let currentRequestId: number = 0;
// Check if we're in demo mode
const isDemo = () => {
return isEnvDemoMode();
};
const handleSearch = async () => {
const query = searchQuery().trim();
if (!query || isLoading()) return;
// Cancel any existing timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// Increment request ID for cancellation
const requestId = ++currentRequestId;
setIsLoading(true);
setError('');
setHasSearched(true);
try {
const isDemoMode = isDemo();
// Always use backend API for search to avoid CORS issues
const API_BASE_URL = getApiBaseUrl();
const token = localStorage.getItem('token') ||
localStorage.getItem('auth_token') ||
localStorage.getItem('trackeep_token');
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
try {
console.log(`Using backend search API: ${API_BASE_URL}${endpoint}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
},
body: JSON.stringify({ query, count: 8 }),
});
if (response.ok) {
const data = await response.json();
// Check if this request is still current
if (requestId === currentRequestId && data.results && data.results.length > 0) {
setSearchResults(data.results);
return;
}
} else {
console.warn('Backend search returned error:', response.status, response.statusText);
}
} catch (err) {
console.warn('Backend search failed:', err);
}
// In demo mode or as fallback, use the demo mode API interceptor
if (isDemoMode) {
console.log('Demo mode detected, using demo API interceptor...');
const API_BASE_URL = getApiBaseUrl();
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, count: 8 }),
});
if (response.ok) {
const data = await response.json();
// Check if this request is still current
if (requestId === currentRequestId) {
// Handle demo mode response format
const results = data.web?.results || data.news?.results || data.mixed?.results || data.results || [];
if (results.length > 0) {
setSearchResults(results);
return;
}
}
}
console.warn('Demo API failed, falling back to mock results...');
}
// If all APIs fail or return no results, show appropriate message
throw new Error('No search results available');
} catch (err) {
console.error('Search failed:', err);
// Only show demo data if all APIs fail
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const apiFailed = errorMessage.includes('API') || errorMessage.includes('fetch') || errorMessage.includes('No search results');
if (apiFailed) {
console.warn('All search APIs failed, showing demo results:', errorMessage);
// Check if this request is still current
if (requestId === currentRequestId) {
const mockResults: BraveSearchResult[] = [
{
title: `${query} - Search Result 1`,
url: `https://example.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
description: `This is a mock search result for "${query}" demonstrating the search functionality in demo mode.`,
published_date: new Date().toISOString().split('T')[0],
language: 'English'
},
{
title: `${query} - Search Result 2`,
url: `https://demo-site.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
description: `Another mock search result for "${query}" showing how the search interface works in demo mode.`,
published_date: new Date().toISOString().split('T')[0],
language: 'English'
}
];
setSearchResults(mockResults);
}
} else {
// Check if this request is still current before setting error
if (requestId === currentRequestId) {
setError('Search temporarily unavailable. Please try again later.');
}
}
} finally {
// Only update loading state if this is still the current request
if (requestId === currentRequestId) {
setIsLoading(false);
}
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleInput = (e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement;
if (target) {
setSearchQuery(target.value);
}
};
const openResult = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
const bookmarkResult = async (result: BraveSearchResult) => {
// If in demo mode, just show success message
if (isDemo()) {
// In demo mode, just show success without actual API call
console.log('Demo mode: Bookmark created for', result.title);
return;
}
try {
const API_BASE_URL = getApiBaseUrl();
const bookmarkData = {
title: result.title,
url: result.url,
description: result.description,
tags: ['web-search', 'browser-search']
};
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('token') ? `Bearer ${localStorage.getItem('token')}` : '',
},
body: JSON.stringify(bookmarkData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create bookmark');
}
} catch (err) {
console.error('Failed to bookmark search result:', err);
}
};
return (
<div class="space-y-6">
{/* Search Content */}
<div class="space-y-6">
{/* Search Bar */}
<Card class="p-6">
<div class="flex items-center justify-between mb-4">
<span class="text-xs text-muted-foreground uppercase tracking-wide">Search type</span>
<div class="inline-flex items-center gap-1 rounded-md bg-muted p-1">
<Button
variant={searchType() === 'web' ? 'default' : 'outline'}
size="sm"
class="h-7 px-3 text-xs"
onClick={() => setSearchType('web')}
>
Web
</Button>
<Button
variant={searchType() === 'news' ? 'default' : 'outline'}
size="sm"
class="h-7 px-3 text-xs"
onClick={() => setSearchType('news')}
>
News
</Button>
</div>
</div>
<div class="flex gap-4">
<div class="flex-1">
<Input
type="text"
placeholder={searchType() === 'news' ? 'Search news...' : 'Search the web...'}
value={searchQuery()}
onInput={handleInput}
onKeyDown={handleKeyDown}
class="text-base"
/>
</div>
<Button
onClick={handleSearch}
disabled={isLoading() || !searchQuery().trim()}
size="lg"
class="px-8"
>
{isLoading() ? (
<span class="flex items-center gap-2">
<IconLoader2 class="w-4 h-4 animate-spin" />
Searching...
</span>
) : (
<span class="flex items-center gap-2">
<IconSearch class="w-4 h-4" />
Search
</span>
)}
</Button>
</div>
</Card>
{/* Error Message */}
<Show when={error()}>
<Card class="p-4 border-red-500/20 bg-red-500/5">
<p class="text-red-400 text-sm">{error()}</p>
</Card>
</Show>
{/* Search Results */}
<Show when={hasSearched() && !isLoading()}>
<div class="space-y-4">
<Show when={searchResults().length > 0}>
<div class="flex items-center justify-between mb-4">
<span class="text-sm text-muted-foreground">
Found {searchResults().length} results
</span>
</div>
<div class="space-y-4">
<For each={searchResults()}>
{(result) => (
<Card class="p-6 hover:bg-accent/50 transition-colors cursor-pointer group">
<div class="space-y-2">
{/* URL */}
<div class="flex items-center gap-2">
<span class="text-xs text-primary truncate">
{result.url}
</span>
<IconExternalLink class="w-3.5 h-3.5 ml-1 text-primary flex-shrink-0" />
</div>
{/* Title */}
<h3
class="text-lg font-semibold hover:text-primary transition-colors"
onClick={() => openResult(result.url)}
>
{result.title}
</h3>
{/* Description */}
<p class="text-muted-foreground text-sm line-clamp-2">
{result.description}
</p>
{/* Meta info */}
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<Show when={result.published_date}>
<span>{result.published_date}</span>
</Show>
<Show when={result.language}>
<span>Language: {result.language}</span>
</Show>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => bookmarkResult(result)}
class="mt-2"
>
<IconBookmark class="w-3 h-3 mr-1" />
Bookmark
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openResult(result.url)}
class="mt-2"
>
<IconExternalLink class="w-3.5 h-3.5 mr-1" />
Visit
</Button>
</div>
</div>
</div>
</Card>
)}
</For>
</div>
</Show>
<Show when={searchResults().length === 0 && !error()}>
<Card class="p-12 text-center">
<div class="max-w-md mx-auto">
<IconSearch class="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 class="text-lg font-semibold mb-2">No results found</h3>
<p class="text-muted-foreground">
Try searching with different keywords or check your spelling.
</p>
</div>
</Card>
</Show>
</div>
</Show>
{/* Initial State */}
<Show when={!hasSearched()}>
<Card class="p-12 text-center">
<div class="max-w-md mx-auto">
<IconSearch class="w-16 h-16 text-primary mx-auto mb-4" />
<h3 class="text-lg font-semibold mb-2">Search the web using Brave Search API</h3>
<p class="text-muted-foreground">
Enter keywords above to search the web and bookmark results.
</p>
</div>
</Card>
</Show>
</div>
</div>
);
};
@@ -16,6 +16,7 @@ import {
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
interface SavedSearch {
id: number;
@@ -47,6 +48,8 @@ export const SavedSearches = () => {
const [loading, setLoading] = createSignal(false);
const [showCreateModal, setShowCreateModal] = createSignal(false);
const [editingSearch, setEditingSearch] = createSignal<SavedSearch | null>(null);
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
const [searchToDelete, setSearchToDelete] = createSignal<number | null>(null);
const [formData, setFormData] = createSignal<SavedSearchFormData>({
name: '',
query: '',
@@ -136,9 +139,13 @@ export const SavedSearches = () => {
// Delete saved search
const deleteSavedSearch = async (id: number) => {
if (!confirm('Are you sure you want to delete this saved search?')) {
return;
}
setSearchToDelete(id);
setShowDeleteModal(true);
};
const confirmDeleteSavedSearch = async () => {
const id = searchToDelete();
if (!id) return;
try {
const token = localStorage.getItem('token');
@@ -151,6 +158,8 @@ export const SavedSearches = () => {
if (response.ok) {
loadSavedSearches();
setShowDeleteModal(false);
setSearchToDelete(null);
}
} catch (error) {
console.error('Failed to delete saved search:', error);
@@ -479,6 +488,19 @@ export const SavedSearches = () => {
</Card>
</div>
</Show>
<ConfirmModal
isOpen={showDeleteModal()}
onClose={() => {
setShowDeleteModal(false);
setSearchToDelete(null);
}}
onConfirm={confirmDeleteSavedSearch}
title="Delete Saved Search"
message="Are you sure you want to delete this saved search? This action cannot be undone."
confirmText="Delete"
type="danger"
/>
</div>
);
};
@@ -81,13 +81,16 @@ export const ColorSwitcherDropdown = () => {
root.style.setProperty('--primary', hslColor);
root.style.setProperty('--colors-primary', hslColor);
// Ensure background stays theme-appropriate
if (isDark) {
root.style.setProperty('--background', '0 0% 10%');
root.style.setProperty('--colors-background', '0 0% 10%');
root.style.setProperty('--foreground', '0 0% 98%');
root.style.setProperty('--colors-foreground', '0 0% 98%');
} else {
root.style.setProperty('--background', '0 0% 100%');
root.style.setProperty('--colors-background', '0 0% 100%');
root.style.setProperty('--foreground', '0 0% 3.9%');
root.style.setProperty('--colors-foreground', '0 0% 3.9%');
}
if (closeDropdown) {
+5 -3
View File
@@ -22,9 +22,10 @@ const Switch = (props: SwitchProps) => {
type="button"
role="switch"
aria-checked={local.checked}
data-state={local.checked ? 'checked' : 'unchecked'}
class={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
local.checked ? 'data-[state=checked]' : 'data-[state=unchecked]',
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
local.checked ? 'bg-primary' : 'bg-input',
props.class
)}
onClick={handleClick}
@@ -33,7 +34,8 @@ const Switch = (props: SwitchProps) => {
<span
data-state={local.checked ? 'checked' : 'unchecked'}
class={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform',
local.checked ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
+19 -7
View File
@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/Button';
import { ModalPortal } from '@/components/ui/ModalPortal';
import { Show } from 'solid-js';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { Show, createSignal } from 'solid-js';
import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
@@ -70,6 +71,8 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
props.onUpdateNote(props.note.id, nextContent);
};
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
return (
<ModalPortal>
@@ -135,12 +138,7 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
</Button>
<Button
variant="ghost"
onClick={() => {
if (confirm('Are you sure you want to delete this note?')) {
props.onDelete(props.note!.id);
props.onClose();
}
}}
onClick={() => setShowDeleteModal(true)}
class="text-red-400 hover:text-red-300 p-1"
>
<IconTrash size={18} />
@@ -212,6 +210,20 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
</div>
</Show>
</>
<ConfirmModal
isOpen={showDeleteModal()}
onClose={() => setShowDeleteModal(false)}
onConfirm={() => {
props.onDelete(props.note!.id);
setShowDeleteModal(false);
props.onClose();
}}
title="Delete Note"
message="Are you sure you want to delete this note? This action cannot be undone."
confirmText="Delete"
type="danger"
/>
</ModalPortal>
);
};
-105
View File
@@ -1,105 +0,0 @@
// Brave Search API integration
import { isDemoMode } from '@/lib/demo-mode';
import { getApiV1BaseUrl } from '@/lib/api-url';
const BACKEND_API_URL = getApiV1BaseUrl();
const BRAVE_API_KEY = import.meta.env.VITE_BRAVE_API_KEY || 'BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT';
// Use the variable to avoid unused warning
console.log('Brave API key available:', !!BRAVE_API_KEY);
// Helper function to get auth headers
const getAuthHeaders = () => {
const isDemo = isDemoMode();
let token = null;
if (isDemo) {
// In demo mode, use a mock token
token = 'demo-token-' + Date.now();
} else {
// In normal mode, get token from localStorage
token = localStorage.getItem('token') || localStorage.getItem('trackeep_token');
}
return {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
};
};
export interface BraveSearchResult {
title: string;
url: string;
description: string;
published_date?: string;
language?: string;
family_friendly?: boolean;
type?: string;
subtype?: string;
}
export interface BraveSearchResponse {
web?: {
results: BraveSearchResult[];
};
news?: {
results: BraveSearchResult[];
};
mixed?: {
results: BraveSearchResult[];
};
query?: {
original: string;
display: string;
};
}
export async function searchBrave(query: string, count: number = 10, type: 'web' | 'news' = 'web'): Promise<BraveSearchResult[]> {
try {
// Use backend proxy to avoid CORS issues
const endpoint = type === 'news' ? '/search/news' : '/search/web';
const response = await fetch(`${BACKEND_API_URL}${endpoint}`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
query,
count,
}),
});
if (!response.ok) {
throw new Error(`Search API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// Return results from the backend response
if (data.results && Array.isArray(data.results)) {
return data.results;
}
return [];
} catch (error) {
console.error('Brave search error:', error);
throw error;
}
}
export async function searchWeb(query: string, count: number = 10): Promise<BraveSearchResult[]> {
return searchBrave(query, count, 'web');
}
export async function searchNews(query: string, count: number = 10): Promise<BraveSearchResult[]> {
return searchBrave(query, count, 'news');
}
export async function getQuickSearchSuggestions(query: string, limit: number = 5): Promise<string[]> {
try {
const results = await searchBrave(query, limit);
return results.map(result => result.title);
} catch (error) {
console.error('Failed to get search suggestions:', error);
return [];
}
}
-20
View File
@@ -540,17 +540,6 @@ const generateMockAISettings = () => ({
},
});
const generateMockSearchSettings = () => ({
brave_api_key: '',
brave_search_base_url: 'https://api.search.brave.com/res/v1/web/search',
serper_api_key: '',
serper_base_url: 'https://google.serper.dev/search',
search_api_provider: 'brave',
search_results_limit: 10,
search_cache_ttl: 300,
search_rate_limit: 100,
});
const generateMockEmailSettings = () => ({
smtp_enabled: false,
smtp_host: '',
@@ -1067,15 +1056,6 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
}
}
if (path.includes('/api/v1/auth/search/settings')) {
if (method === 'GET') {
return jsonResponse(generateMockSearchSettings());
}
if (method === 'PUT') {
return jsonResponse(body);
}
}
if (path.includes('/api/v1/auth/email/settings')) {
if (method === 'GET') {
return jsonResponse(generateMockEmailSettings());
+141
View File
@@ -0,0 +1,141 @@
const SOLIDTIME_BASE_URL = 'https://api.solidtime.io';
function getStoredCredentials() {
const apiKey = localStorage.getItem('solidtime_api_key') || '';
const orgId = localStorage.getItem('solidtime_org_id') || '';
return { apiKey, orgId };
}
async function solidtimeFetch(path: string, options: RequestInit = {}) {
const { apiKey, orgId } = getStoredCredentials();
if (!apiKey || !orgId) {
throw new Error('Solidtime API credentials not configured');
}
const url = `${SOLIDTIME_BASE_URL}/api/v1/organizations/${orgId}${path}`;
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers,
};
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Solidtime API error: ${response.status} ${errorText}`);
}
if (response.status === 204) {
return null;
}
return response.json();
}
export const solidtimeApi = {
getTimeEntries: async (params?: { start?: string; end?: string; active?: boolean }) => {
const query = new URLSearchParams();
if (params?.start) query.set('start', params.start);
if (params?.end) query.set('end', params.end);
if (params?.active) query.set('active', 'true');
const queryString = query.toString();
const data = await solidtimeFetch(`/time-entries${queryString ? `?${queryString}` : ''}`);
return data?.data || [];
},
getActiveTimeEntry: async () => {
const data = await solidtimeFetch('/time-entries?active=true');
return data?.data?.[0] || null;
},
startTimeEntry: async (payload: { description?: string; project_id?: string; task_id?: string; tags?: string[]; billable?: boolean }) => {
const data = await solidtimeFetch('/time-entries', {
method: 'POST',
body: JSON.stringify({
start: new Date().toISOString(),
...payload,
}),
});
return data?.data;
},
stopTimeEntry: async (id: string) => {
const data = await solidtimeFetch(`/time-entries/${id}`, {
method: 'PUT',
body: JSON.stringify({
end: new Date().toISOString(),
}),
});
return data?.data;
},
updateTimeEntry: async (id: string, payload: { description?: string; project_id?: string; task_id?: string; tags?: string[]; billable?: boolean; start?: string; end?: string }) => {
const data = await solidtimeFetch(`/time-entries/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
return data?.data;
},
deleteTimeEntry: async (id: string) => {
await solidtimeFetch(`/time-entries/${id}`, { method: 'DELETE' });
},
getTimeEntriesStats: async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const entries = await solidtimeApi.getTimeEntries({
start: today.toISOString(),
});
let total = 0;
let billable = 0;
let nonBillable = 0;
entries.forEach((entry: any) => {
const start = new Date(entry.start).getTime();
const end = entry.end ? new Date(entry.end).getTime() : Date.now();
const duration = Math.floor((end - start) / 1000);
total += duration;
if (entry.billable) {
billable += duration;
} else {
nonBillable += duration;
}
});
return { total, billable, nonBillable };
},
getProjects: async () => {
const data = await solidtimeFetch('/projects');
return data?.data || [];
},
getMembers: async () => {
const data = await solidtimeFetch('/members');
return data?.data || [];
},
getTasks: async () => {
const data = await solidtimeFetch('/tasks');
return data?.data || [];
},
getTags: async () => {
const data = await solidtimeFetch('/tags');
return data?.data || [];
},
setCredentials: (apiKey: string, orgId: string) => {
localStorage.setItem('solidtime_api_key', apiKey);
localStorage.setItem('solidtime_org_id', orgId);
},
getCredentials: getStoredCredentials,
clearCredentials: () => {
localStorage.removeItem('solidtime_api_key');
localStorage.removeItem('solidtime_org_id');
},
};
+2
View File
@@ -138,6 +138,8 @@ export const Login = () => {
fullName: formData().fullName,
};
await register(registerPayload);
navigate('/app/workspace-setup', { replace: true });
return;
}
navigate(getSafeNextPath(), { replace: true });
} catch (err) {
+146
View File
@@ -0,0 +1,146 @@
import { createSignal } from 'solid-js'
import { useNavigate } from '@solidjs/router'
import { getApiV1BaseUrl } from '@/lib/api-url'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import { IconBuilding, IconCheck, IconWorld, IconLock } from '@tabler/icons-solidjs'
import { useHaptics } from '@/lib/haptics'
const API_BASE_URL = getApiV1BaseUrl()
const WORKSPACE_ICONS = [
{ id: 'building', icon: IconBuilding, label: 'Building' },
{ id: 'world', icon: IconWorld, label: 'World' },
{ id: 'lock', icon: IconLock, label: 'Lock' },
{ id: 'check', icon: IconCheck, label: 'Check' },
]
export const WorkspaceSetup = () => {
const navigate = useNavigate()
const haptics = useHaptics()
const [name, setName] = createSignal('')
const [description, setDescription] = createSignal('')
const [isPublic, setIsPublic] = createSignal(false)
const [selectedIcon, setSelectedIcon] = createSignal('building')
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal('')
const handleCreate = async () => {
const trimmed = name().trim()
if (!trimmed) {
setError('Workspace name required')
return
}
setError('')
setIsLoading(true)
try {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token')
const response = await fetch(`${API_BASE_URL}/teams`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: trimmed,
description: description().trim(),
is_public: isPublic()
})
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to create workspace')
}
const data = await response.json()
const workspace = {
id: String(data.team?.id || data.id),
name: trimmed,
icon: selectedIcon()
}
localStorage.setItem('trackeep_workspace_id', workspace.id)
localStorage.setItem('trackeep_workspace_name', workspace.name)
localStorage.setItem('trackeep_workspace_icon', workspace.icon)
window.dispatchEvent(new CustomEvent('trackeep:workspace-changed', { detail: workspace }))
haptics.success()
navigate('/app', { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create workspace')
haptics.error()
} finally {
setIsLoading(false)
}
}
return (
<div class="p-6 max-w-lg mx-auto">
<h1 class="text-2xl font-bold text-foreground mb-2">Create Workspace</h1>
<p class="text-muted-foreground mb-6">Set up your first workspace to get started.</p>
<Card class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-foreground mb-2">Name</label>
<input
type="text"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
placeholder="My Workspace"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-2">Description</label>
<textarea
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="Optional description"
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
rows={3}
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-2">Icon</label>
<div class="flex gap-2">
{WORKSPACE_ICONS.map((item) => {
const Icon = item.icon
return (
<button
type="button"
onClick={() => setSelectedIcon(item.id)}
class={`p-3 rounded-lg border transition-colors ${
selectedIcon() === item.id
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
title={item.label}
>
<Icon class="size-5" />
</button>
)
})}
</div>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
checked={isPublic()}
onChange={(e) => setIsPublic(e.currentTarget.checked)}
class="rounded border-input"
/>
<label class="text-sm text-foreground">Public workspace</label>
</div>
{error() && <p class="text-sm text-destructive">{error()}</p>}
<Button onClick={handleCreate} disabled={isLoading()} class="w-full">
{isLoading() ? 'Creating...' : 'Create Workspace'}
</Button>
</Card>
</div>
)
}
-596
View File
@@ -1,596 +0,0 @@
import { createSignal, For, Show, onMount, createEffect } from 'solid-js'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Card } from '@/components/ui/Card'
import {
MessageCircle,
Brain,
Cog,
Send,
ChevronDown,
User,
Bot
} from 'lucide-solid'
import { AIProviderIcon } from '@/components/AIProviderIcon'
import { useHaptics } from '@/lib/haptics'
import { getApiOrigin } from '@/lib/api-url'
interface AIModel {
id: string
name: string
description: string
provider: string
category: string
iconId?: string
}
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
}
export const AIChat = () => {
const haptics = useHaptics();
const [activeView, setActiveView] = createSignal<'chat' | 'settings'>('chat')
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
// Chat state
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 [inputMessage, setInputMessage] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
// AI Model state
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat')
const [showModelPicker, setShowModelPicker] = createSignal(false)
const [aiModels, setAIModels] = createSignal<AIModel[]>([])
// Initialize AI models
onMount(() => {
const checkMobile = () => {
if (window.innerWidth < 768) {
setIsSidebarOpen(false)
}
}
checkMobile()
window.addEventListener('resize', checkMobile)
// Initialize AI models
initializeAIModels()
return () => window.removeEventListener('resize', checkMobile)
})
const initializeAIModels = () => {
const models: AIModel[] = [
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', 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' },
]
setAIModels(models)
}
const handleSendMessage = async () => {
const message = inputMessage().trim()
if (!message || isLoading()) return
// Add user message
const userMessage: Message = {
id: Date.now().toString(),
content: message,
role: 'user',
timestamp: new Date()
}
setMessages(prev => [...prev, userMessage])
setInputMessage('')
setIsLoading(true)
haptics.impact(); // Impact feedback for sending message
try {
// Call AI API
const response = await callAIAPI(message, selectedModel())
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: response,
timestamp: new Date()
}
setMessages(prev => [...prev, aiMessage])
haptics.success(); // Success feedback for AI response
} catch (error) {
console.error('AI API call failed:', error)
haptics.error(); // Error feedback
// Fallback response
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: 'I apologize, but I encountered an error while processing your request. Please try again later.',
timestamp: new Date()
}
setMessages(prev => [...prev, errorMessage])
} finally {
setIsLoading(false)
}
}
const callAIAPI = async (message: string, modelId: string): Promise<string> => {
const token = localStorage.getItem('token')
const apiUrl = getApiOrigin()
const response = await fetch(`${apiUrl}/api/v1/ai/chat`, {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
'Content-Type': 'application/json'
},
body: JSON.stringify({
message,
model: modelId,
stream: false
})
})
if (!response.ok) {
throw new Error(`API call failed: ${response.status}`)
}
const data = await response.json()
return data.response || data.content || 'I understand your message. Let me help you with that.'
}
// Close model picker when clicking outside
createEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('#model-picker-container')) {
setShowModelPicker(false)
}
}
if (showModelPicker()) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
})
const startNewChat = () => {
setMessages([{
id: '1',
role: 'assistant',
content: 'Hello! I\'m your AI assistant. How can I help you today?',
timestamp: new Date()
}])
setInputMessage('')
}
return (
<div class="h-full w-full flex flex-col bg-background">
{/* Header */}
<header class="border-b bg-card/95 backdrop-blur-sm z-10">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setIsSidebarOpen(!isSidebarOpen())}
class="md:hidden"
>
<MessageCircle class="h-4 w-4" />
</Button>
{/* AI Logo */}
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-muted rounded-lg flex items-center justify-center">
<Brain class="w-5 h-5 text-primary" />
</div>
<div class="flex flex-col">
<h1 class="font-semibold text-lg">AI Assistant</h1>
<p class="text-sm text-muted-foreground">Your intelligent workspace companion</p>
</div>
</div>
</div>
<div class="flex items-center gap-3">
{/* View Switcher */}
<div class="flex items-center gap-1 p-1 bg-muted rounded-lg">
<button
onClick={() => setActiveView('chat')}
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeView() === 'chat'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Chat
</button>
<button
onClick={() => setActiveView('settings')}
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeView() === 'settings'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Settings
</button>
</div>
</div>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
{/* Sidebar */}
<Show when={isSidebarOpen()}>
<aside class="w-80 border-r bg-card flex flex-col hidden md:flex">
{/* Sidebar Header */}
<div class="p-4 border-b">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Chat Sessions</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setActiveView('settings')}
>
<Cog class="h-4 w-4" />
</Button>
</div>
</div>
{/* Sessions List */}
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-3">
{/* New Chat Button */}
<Button
onClick={startNewChat}
class="w-full justify-start"
variant="outline"
>
<MessageCircle class="h-4 w-4 mr-2" />
New Chat
</Button>
{/* Chat Sessions */}
<div class="space-y-2">
<div class="text-sm text-muted-foreground font-medium px-3 py-2">
Recent Chats
</div>
{[
{ id: '1', title: 'Getting Started', message_count: 2, last_message: '2 hours ago' },
{ id: '2', title: 'Project Planning', message_count: 5, last_message: '1 day ago' },
{ id: '3', title: 'Technical Discussion', message_count: 3, last_message: '2 days ago' }
].map(session => (
<button
class="w-full text-left p-3 rounded-lg hover:bg-muted transition-colors"
onClick={() => {
setMessages([{
id: '1',
content: `This is the ${session.title} session. How can I help you?`,
role: 'assistant',
timestamp: new Date()
}])
}}
>
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<h4 class="font-medium truncate">{session.title}</h4>
<p class="text-sm text-muted-foreground">
{session.message_count} messages {session.last_message}
</p>
</div>
</div>
</button>
))}
</div>
</div>
</div>
</aside>
</Show>
{/* Main Content */}
<main class="flex-1 flex flex-col overflow-hidden">
{/* Chat View */}
<Show when={activeView() === 'chat'}>
<div class="flex-1 flex flex-col">
{/* Messages Area */}
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto space-y-6">
<For each={messages()}>
{message => (
<div
class={`flex gap-4 ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
class={`max-w-[80%] rounded-2xl p-4 shadow-sm transition-all duration-200 hover:shadow-md ${
message.role === 'user'
? 'bg-gradient-to-br from-primary to-primary/90 text-primary-foreground ml-auto'
: 'bg-gradient-to-br from-muted to-muted/90 border border-border/50'
}`}
>
<div class="flex items-start gap-3">
<div class={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition-transform hover:scale-105 ${
message.role === 'user' ? 'bg-primary-foreground/20 ring-2 ring-primary-foreground/30' : 'bg-primary/10 ring-2 ring-primary/20'
}`}>
{message.role === 'user' ? (
<User class="text-xs" />
) : (
<Bot class="text-xs animate-pulse" />
)}
</div>
<div class="flex-1">
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
<div class="flex items-center gap-2 mt-2">
<p class="text-xs opacity-70">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
{message.role === 'user' && (
<div class="w-2 h-2 bg-primary-foreground/50 rounded-full"></div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</For>
{isLoading() && (
<div class="flex justify-start">
<div class="bg-gradient-to-br from-muted to-muted/90 rounded-2xl p-4 max-w-[80%] border border-border/50 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary/10 ring-2 ring-primary/20 flex items-center justify-center">
<Bot class="text-xs animate-pulse" />
</div>
<div class="flex items-center gap-2">
<div class="flex gap-1">
<div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
</div>
<span class="text-xs text-muted-foreground ml-2">AI is thinking...</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Input Area */}
<div class="border-t bg-card/95 backdrop-blur-sm">
<div class="p-6">
<div class="max-w-4xl mx-auto">
<div class="flex gap-4">
{/* AI Model Switcher */}
<div id="model-picker-container" class="relative">
<button
onClick={() => setShowModelPicker(!showModelPicker())}
class="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-muted to-muted/80 hover:from-muted/90 hover:to-muted/70 rounded-xl text-sm transition-all duration-200 border border-border/50 shadow-sm hover:shadow-md"
>
<AIProviderIcon
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
size="1rem"
class="transition-transform hover:scale-110"
/>
<span class="text-sm font-medium">
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
</span>
<ChevronDown class={`h-4 w-4 transition-transform duration-200 ${showModelPicker() ? 'rotate-180' : ''}`} />
</button>
{/* Model Picker Dropdown */}
<Show when={showModelPicker()}>
<div class="absolute bottom-full left-0 mb-2 w-80 bg-gradient-to-b from-background to-background/95 backdrop-blur-sm border border-border/50 rounded-xl shadow-xl z-50 p-2 max-h-96 overflow-y-auto">
<div class="p-3 border-b border-border/50 mb-2 bg-muted/30 rounded-lg">
<h4 class="text-sm font-semibold text-foreground">Select AI Model</h4>
<p class="text-xs text-muted-foreground">Choose the best model for your needs</p>
</div>
<For each={aiModels()}>
{model => (
<button
onClick={() => {
setSelectedModel(model.id)
setShowModelPicker(false)
}}
class={`w-full text-left p-3 rounded-lg transition-all duration-200 ${
selectedModel() === model.id
? 'bg-gradient-to-r from-primary/10 to-primary/5 border border-primary/30 shadow-sm'
: 'hover:bg-muted/50 hover:border-border/30'
}`}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 flex-1">
<AIProviderIcon
providerId={model.iconId!}
size="1rem"
class="rounded-full flex-shrink-0"
/>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{model.name}</div>
<div class="text-xs text-muted-foreground mt-1 truncate">{model.description}</div>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
{model.provider}
</span>
<span class={`text-xs px-2 py-1 rounded-full ${
model.category === 'thinking'
? 'bg-purple-100 text-purple-800'
: model.category === 'fast'
? 'bg-green-100 text-green-800'
: model.category === 'advanced'
? 'bg-blue-100 text-blue-800'
: 'bg-muted text-muted-foreground'
}`}>
{model.category}
</span>
</div>
</div>
</div>
{selectedModel() === model.id && (
<div class="w-2 h-2 bg-primary rounded-full animate-pulse flex-shrink-0"></div>
)}
</div>
</button>
)}
</For>
</div>
</Show>
</div>
<div class="relative flex-1">
<div class="relative">
<Input
value={inputMessage()}
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
placeholder="Type your message..."
class="flex-1 pr-12 rounded-xl border-border/50 bg-background/95 backdrop-blur-sm shadow-sm transition-all duration-200 focus:shadow-md focus:border-primary/50"
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && inputMessage().trim()) {
handleSendMessage()
}
}}
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<div class="w-1 h-1 bg-muted-foreground/40 rounded-full animate-pulse"></div>
<div class="w-1 h-1 bg-muted-foreground/40 rounded-full animate-pulse" style="animation-delay: 0.2s"></div>
<div class="w-1 h-1 bg-muted-foreground/40 rounded-full animate-pulse" style="animation-delay: 0.4s"></div>
</div>
</div>
</div>
<Button
disabled={isLoading() || !inputMessage().trim()}
onClick={() => {
handleSendMessage();
haptics.impact();
}}
class="rounded-xl px-4 py-2.5 bg-gradient-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary shadow-sm hover:shadow-md transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send class="h-4 w-4" />
<span class="ml-2 text-sm font-medium">Send</span>
</Button>
</div>
</div>
</div>
</div>
</div>
</Show>
{/* Settings View */}
<Show when={activeView() === 'settings'}>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<h2 class="text-2xl font-bold mb-2">AI Settings</h2>
<p class="text-muted-foreground">Configure your AI models and preferences</p>
</div>
<Card class="p-6">
<h3 class="text-lg font-semibold mb-4">Available AI Models</h3>
<div class="space-y-4">
<For each={aiModels()}>
{(model) => (
<div
class={`p-4 border rounded-lg transition-all ${
selectedModel() === model.id
? 'border-primary bg-primary/5'
: 'border-border hover:bg-muted/50'
}`}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<AIProviderIcon
providerId={model.iconId!}
size="2rem"
class="text-primary"
/>
<div>
<h5 class="font-medium">{model.name}</h5>
<p class="text-sm text-muted-foreground">{model.description}</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
{model.provider}
</span>
<span class={`text-xs px-2 py-1 rounded-full ${
model.category === 'thinking'
? 'bg-purple-100 text-purple-800'
: model.category === 'fast'
? 'bg-green-100 text-green-800'
: model.category === 'advanced'
? 'bg-blue-100 text-blue-800'
: 'bg-muted text-muted-foreground'
}`}>
{model.category}
</span>
</div>
</div>
</div>
<button
onClick={() => setSelectedModel(model.id)}
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedModel() === model.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'
}`}
>
{selectedModel() === model.id ? 'Selected' : 'Select'}
</button>
</div>
</div>
)}
</For>
</div>
</Card>
<Card class="p-6 mt-6">
<h3 class="text-lg font-semibold mb-4">Current Selection</h3>
<div class="p-4 bg-muted/50 rounded-lg">
<div class="flex items-center gap-3">
<AIProviderIcon
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
size="1.5rem"
class="text-primary"
/>
<div>
<p class="font-medium">
{aiModels().find(m => m.id === selectedModel())?.name}
</p>
<p class="text-sm text-muted-foreground">
{aiModels().find(m => m.id === selectedModel())?.description}
</p>
</div>
</div>
</div>
</Card>
</div>
</div>
</Show>
</main>
</div>
</div>
)
}
export default AIChat
+3 -15
View File
@@ -3,7 +3,6 @@ import { getApiOrigin } from '@/lib/api-url'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Card } from '@/components/ui/Card'
import { AIProviderIcon } from '@/components/AIProviderIcon'
import {
Send,
CheckSquare,
@@ -736,11 +735,7 @@ const Chat = () => {
{message.role === 'user' ? (
<User class="w-4 h-4 text-xs" />
) : (
<AIProviderIcon
providerId={selectedModel()}
size="1rem"
class="text-primary animate-pulse"
/>
<Sparkles class="w-4 h-4 text-primary animate-pulse" />
)}
</div>
<div class="flex-1">
@@ -771,11 +766,7 @@ const Chat = () => {
onClick={() => setShowModelPicker(!showModelPicker())}
class="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-muted to-muted/80 hover:from-muted/90 hover:to-muted/70 rounded-xl text-sm transition-all duration-200 border border-border/50 shadow-sm hover:shadow-md"
>
<AIProviderIcon
providerId={selectedModel()}
size="1rem"
class="transition-transform hover:scale-110"
/>
<Sparkles class="w-4 h-4 transition-transform hover:scale-110" />
<span class="text-sm font-medium">
{getAIModels().find(m => m.id === selectedModel())?.name || 'Select Model'}
</span>
@@ -803,10 +794,7 @@ const Chat = () => {
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 flex-1">
<AIProviderIcon
providerId={model.id}
size="1rem"
/>
<Sparkles class="w-4 h-4" />
<div class="flex-1">
<div class="font-medium text-sm">{model.name}</div>
<div class="text-xs text-muted-foreground mt-1">{model.description}</div>
File diff suppressed because it is too large Load Diff
+44 -17
View File
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/Button';
import { BookmarkModal } from '@/components/ui/BookmarkModal';
import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal';
import { VideoUploadModal } from '@/components/ui/VideoUploadModal';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs';
@@ -267,27 +268,39 @@ export const Bookmarks = () => {
haptics.selection(); // Selection feedback for starring
};
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
const [bookmarkToDelete, setBookmarkToDelete] = createSignal<number | null>(null);
const [modalError, setModalError] = createSignal('');
const deleteBookmark = async (bookmarkId: number) => {
if (confirm('Are you sure you want to delete this bookmark?')) {
try {
const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, {
method: 'DELETE',
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
setBookmarkToDelete(bookmarkId);
setShowDeleteModal(true);
};
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete bookmark');
}
const confirmDeleteBookmark = async () => {
const bookmarkId = bookmarkToDelete();
if (!bookmarkId) return;
try {
const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, {
method: 'DELETE',
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId));
haptics.delete(); // Delete feedback
} catch (error) {
haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to delete bookmark');
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete bookmark');
}
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId));
haptics.delete(); // Delete feedback
setShowDeleteModal(false);
setBookmarkToDelete(null);
} catch (error) {
haptics.error(); // Error feedback
setModalError(error instanceof Error ? error.message : 'Failed to delete bookmark');
}
};
@@ -774,6 +787,20 @@ export const Bookmarks = () => {
onClose={() => setShowVideoModal(false)}
onSubmit={handleVideoSubmit}
/>
<ConfirmModal
isOpen={showDeleteModal()}
onClose={() => {
setShowDeleteModal(false);
setBookmarkToDelete(null);
setModalError('');
}}
onConfirm={confirmDeleteBookmark}
title="Delete Bookmark"
message={modalError() || 'Are you sure you want to delete this bookmark? This action cannot be undone.'}
confirmText="Delete"
type="danger"
/>
</div>
);
};
+1 -40
View File
@@ -24,12 +24,10 @@ import {
IconChartLine,
IconActivity,
IconSearch,
IconChevronDown,
IconVideo,
IconSchool,
IconX
} from '@tabler/icons-solidjs';
import { BrowserSearch } from '@/components/search/BrowserSearch';
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { Card } from '@/components/ui/Card';
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
@@ -210,7 +208,7 @@ export const Dashboard = () => {
const [documents, setDocuments] = createSignal<Document[]>([]);
const [, setRecentActivity] = createSignal<RecentActivity[]>([]);
const [githubActivityEvents, setGithubActivityEvents] = createSignal<GitHubActivityEvent[]>([]);
const [showBrowserSearch, setShowBrowserSearch] = createSignal(true);
const [showFilePreview, setShowFilePreview] = createSignal(false);
const [selectedFile, setSelectedFile] = createSignal<Document | null>(null);
const [currentPage, setCurrentPage] = createSignal(1);
@@ -293,9 +291,6 @@ export const Dashboard = () => {
};
onMount(async () => {
// Load browser search setting from localStorage
setShowBrowserSearch(localStorage.getItem('showBrowserSearch') !== 'false');
if (isDemoMode()) {
setDashboardStats(getMockStats());
setDocuments(getMockDocuments());
@@ -961,40 +956,6 @@ export const Dashboard = () => {
<Show when={isSearchAvailable()}>
<div class="mb-8">
<div class="border rounded-lg">
{/* Collapsible Header */}
<button
onClick={() => {
const newState = !showBrowserSearch();
setShowBrowserSearch(newState);
localStorage.setItem('showBrowserSearch', newState.toString());
}}
class="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors rounded-t-lg"
>
<div class="flex items-center gap-2">
<IconSearch class="size-4 text-primary" />
<h2 class="text-lg font-semibold">Browser Search</h2>
<span class="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
Powered by Brave Search
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{showBrowserSearch() ? 'Hide' : 'Show'}
</span>
<IconChevronDown
class={`size-4 text-muted-foreground transition-transform duration-200 ${
showBrowserSearch() ? 'rotate-180' : ''
}`}
/>
</div>
</button>
{/* Collapsible Content */}
<Show when={showBrowserSearch()}>
<div class="border-t border-border p-4">
<BrowserSearch />
</div>
</Show>
</div>
</div>
</Show>
+48 -20
View File
@@ -1,5 +1,6 @@
import { createEffect, createSignal, onMount, Show } from 'solid-js';
import { IconTrash, IconRestore, IconFileText, IconFileTypePpt, IconFileTypeDocx, IconClock, IconSettings, IconAlertTriangle } from '@tabler/icons-solidjs';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { getApiV1BaseUrl } from '@/lib/api-url';
interface RemovedItem {
@@ -31,6 +32,12 @@ export const RemovedStuff = () => {
const [showSettings, setShowSettings] = createSignal(false);
const [selectedItems, setSelectedItems] = createSignal<string[]>([]);
const [loadedRemovedItems, setLoadedRemovedItems] = createSignal(false);
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
const [deleteModalConfig, setDeleteModalConfig] = createSignal({
title: 'Delete Item',
message: 'Are you sure you want to delete this item?',
onConfirm: () => {}
});
createEffect(() => {
if (!loadedRemovedItems()) return;
@@ -167,48 +174,59 @@ export const RemovedStuff = () => {
};
const handleEmptyTrash = () => {
if (confirm('Are you sure you want to permanently delete all items in the trash? This action cannot be undone.')) {
setRemovedItems([]);
alert('Trash emptied successfully!');
}
setDeleteModalConfig({
title: 'Empty Trash',
message: 'Are you sure you want to permanently delete all items in the trash? This action cannot be undone.',
onConfirm: () => {
setRemovedItems([]);
setShowDeleteModal(false);
}
});
setShowDeleteModal(true);
};
const handleRestoreItem = (id: string) => {
const item = removedItems().find(item => item.id === id);
if (item) {
setRemovedItems(prev => prev.filter(item => item.id !== id));
alert(`"${item.name}" has been restored successfully!`);
}
};
const handlePermanentlyDelete = (id: string) => {
const item = removedItems().find(item => item.id === id);
if (item && confirm(`Are you sure you want to permanently delete "${item.name}"? This action cannot be undone.`)) {
setRemovedItems(prev => prev.filter(item => item.id !== id));
alert(`"${item.name}" has been permanently deleted!`);
if (item) {
setDeleteModalConfig({
title: 'Delete Permanently',
message: `Are you sure you want to permanently delete "${item.name}"? This action cannot be undone.`,
onConfirm: () => {
setRemovedItems(prev => prev.filter(item => item.id !== id));
setShowDeleteModal(false);
}
});
setShowDeleteModal(true);
}
};
const handleBulkRestore = () => {
if (selectedItems().length === 0) return;
if (confirm(`Are you sure you want to restore ${selectedItems().length} items?`)) {
const itemsToRestore = removedItems().filter(item => selectedItems().includes(item.id));
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
setSelectedItems([]);
alert(`${itemsToRestore.length} items have been restored successfully!`);
}
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
setSelectedItems([]);
};
const handleBulkDelete = () => {
if (selectedItems().length === 0) return;
if (confirm(`Are you sure you want to permanently delete ${selectedItems().length} items? This action cannot be undone.`)) {
const itemsToDelete = removedItems().filter(item => selectedItems().includes(item.id));
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
setSelectedItems([]);
alert(`${itemsToDelete.length} items have been permanently deleted!`);
}
setDeleteModalConfig({
title: 'Delete Permanently',
message: `Are you sure you want to permanently delete ${selectedItems().length} items? This action cannot be undone.`,
onConfirm: () => {
setRemovedItems(prev => prev.filter(item => !selectedItems().includes(item.id)));
setSelectedItems([]);
setShowDeleteModal(false);
}
});
setShowDeleteModal(true);
};
const getItemsReadyForAutoRemove = () => {
@@ -442,6 +460,16 @@ export const RemovedStuff = () => {
</tbody>
</table>
</div>
<ConfirmModal
isOpen={showDeleteModal()}
onClose={() => setShowDeleteModal(false)}
onConfirm={() => deleteModalConfig().onConfirm()}
title={deleteModalConfig().title}
message={deleteModalConfig().message}
confirmText="Delete"
type="danger"
/>
</div>
);
};
@@ -1,234 +0,0 @@
import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { IconBrain, IconFileText, IconChecklist, IconSparkles, IconRobot, IconSettings } from '@tabler/icons-solidjs';
import { AIProviderIcon } from '@/components/AIProviderIcon';
interface AIModel {
id: string;
name: string;
description: string;
provider: string;
category: string;
iconId?: string;
}
export const AIAssistant = () => {
const [activeTab, setActiveTab] = createSignal<'dashboard' | 'summarizer' | 'tasks' | 'content' | 'settings'>('dashboard');
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat');
const [aiModels, setAIModels] = createSignal<AIModel[]>([]);
const tabs = [
{ id: 'dashboard', label: 'AI Dashboard', icon: IconBrain },
{ id: 'summarizer', label: 'Content Summarizer', icon: IconFileText },
{ id: 'tasks', label: 'Task Suggestions', icon: IconChecklist },
{ id: 'content', label: 'Content Generation', icon: IconSparkles },
{ id: 'settings', label: 'AI Settings', icon: IconSettings },
];
// Initialize AI models on mount
onMount(() => {
const models: AIModel[] = [
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', 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' },
];
setAIModels(models);
});
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<IconRobot class="size-8 text-primary" />
AI Assistant
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
Leverage AI to enhance your productivity and content management
</p>
</div>
{aiModels().length > 0 && (
<div class="flex items-center gap-3 text-sm">
<span class="text-gray-500">Active:</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
<AIProviderIcon
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
size="1.25rem"
class="text-primary"
/>
<span class="font-medium text-blue-600 dark:text-blue-400">
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
</span>
</div>
</div>
</div>
)}
</div>
{/* Tabs */}
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-8">
{tabs.map((tab) => (
<button
onClick={() => setActiveTab(tab.id as any)}
class={`flex items-center gap-2 py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<tab.icon class="size-5" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Content */}
<div class="space-y-6">
{activeTab() === 'settings' && (
<Card class="p-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">AI Provider Settings</h3>
<div class="space-y-6">
{/* AI Models */}
<div>
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available AI Models</h4>
<div class="space-y-3">
{aiModels().map((model) => (
<div
class={`p-4 border rounded-lg transition-all ${
selectedModel() === model.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<AIProviderIcon
providerId={model.iconId!}
size="2rem"
class="text-primary"
/>
<div>
<h5 class="font-medium text-gray-900 dark:text-white">{model.name}</h5>
<p class="text-sm text-gray-600 dark:text-gray-400">{model.description}</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded-full">
{model.provider}
</span>
<span class={`text-xs px-2 py-1 rounded-full ${
model.category === 'thinking'
? 'bg-purple-100 text-purple-800'
: model.category === 'fast'
? 'bg-green-100 text-green-800'
: model.category === 'advanced'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}>
{model.category}
</span>
</div>
</div>
</div>
<button
onClick={() => setSelectedModel(model.id)}
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedModel() === model.id
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{selectedModel() === model.id ? 'Selected' : 'Select'}
</button>
</div>
</div>
))}
</div>
</div>
{/* Current Selection */}
<div>
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Current Selection</h4>
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex items-center gap-3">
<AIProviderIcon
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
size="1.5rem"
class="text-primary"
/>
<div>
<p class="font-medium text-gray-900 dark:text-white">
{aiModels().find(m => m.id === selectedModel())?.name}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{aiModels().find(m => m.id === selectedModel())?.description}
</p>
</div>
</div>
</div>
</div>
</div>
</Card>
)}
{activeTab() === 'dashboard' && (
<Card class="p-6 text-center">
<IconBrain class="size-12 text-primary mx-auto" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
AI Dashboard
</h3>
<p class="text-gray-600 dark:text-gray-400">
AI Dashboard component temporarily disabled.
</p>
</Card>
)}
{activeTab() === 'summarizer' && (
<Card class="p-6 text-center">
<IconFileText class="size-12 text-primary mx-auto" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
Content Summarizer
</h3>
<p class="text-gray-600 dark:text-gray-400">
Content Summarizer component temporarily disabled.
</p>
</Card>
)}
{activeTab() === 'tasks' && (
<Card class="p-6 text-center">
<IconChecklist class="size-12 text-primary mx-auto" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
Task Suggestions
</h3>
<p class="text-gray-600 dark:text-gray-400">
AI-powered task suggestions based on your calendar, deadlines, and habits.
</p>
<p class="text-sm text-gray-500 mt-2">
View and manage suggestions from the AI Dashboard.
</p>
</Card>
)}
{activeTab() === 'content' && (
<Card class="p-6 text-center">
<IconSparkles class="size-12 text-primary mx-auto" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
Content Generation
</h3>
<p class="text-gray-600 dark:text-gray-400">
Generate blog posts, code, emails, and more with AI assistance.
</p>
<p class="text-sm text-gray-500 mt-2">
Coming soon - Advanced AI content generation tools.
</p>
</Card>
)}
</div>
</div>
);
};
+43 -17
View File
@@ -2,6 +2,7 @@ import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { TaskModal } from '@/components/ui/TaskModal';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { useHaptics } from '@/lib/haptics';
@@ -28,6 +29,9 @@ export const Tasks = () => {
const [selectedPriority, setSelectedPriority] = createSignal('');
const [draggedTaskId, setDraggedTaskId] = createSignal<number | null>(null);
const [dragOverColumn, setDragOverColumn] = createSignal<string | null>(null);
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
const [taskToDelete, setTaskToDelete] = createSignal<number | null>(null);
const [modalError, setModalError] = createSignal('');
const [taskStatuses, setTaskStatuses] = createSignal<Record<number, 'todo' | 'inProgress' | 'done'>>({});
const haptics = useHaptics();
@@ -174,26 +178,34 @@ export const Tasks = () => {
};
const deleteTask = async (taskId: number) => {
if (confirm('Are you sure you want to delete this task?')) {
try {
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
method: 'DELETE',
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
setTaskToDelete(taskId);
setShowDeleteModal(true);
};
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete task');
}
const confirmDeleteTask = async () => {
const taskId = taskToDelete();
if (!taskId) return;
try {
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
method: 'DELETE',
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
});
setTasks(prev => prev.filter(task => task.id !== taskId));
haptics.delete(); // Delete feedback
} catch (error) {
haptics.error(); // Error feedback
alert(error instanceof Error ? error.message : 'Failed to delete task');
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete task');
}
setTasks(prev => prev.filter(task => task.id !== taskId));
haptics.delete(); // Delete feedback
setShowDeleteModal(false);
setTaskToDelete(null);
} catch (error) {
haptics.error(); // Error feedback
setModalError(error instanceof Error ? error.message : 'Failed to delete task');
}
};
@@ -342,6 +354,20 @@ export const Tasks = () => {
})}
</div>
)}
<ConfirmModal
isOpen={showDeleteModal()}
onClose={() => {
setShowDeleteModal(false);
setTaskToDelete(null);
setModalError('');
}}
onConfirm={confirmDeleteTask}
title="Delete Task"
message={modalError() || 'Are you sure you want to delete this task? This action cannot be undone.'}
confirmText="Delete"
type="danger"
/>
</div>
);
};
+186 -157
View File
@@ -1,204 +1,233 @@
import { createSignal, createEffect, onMount } from 'solid-js';
import { Timer } from '@/components/Timer';
import { TimeEntriesList } from '@/components/TimeEntriesList';
import { type TimeEntry, timeEntriesApi, demoTimeEntriesApi } from '@/lib/api';
import { IconClock, IconActivity, IconCurrencyDollar } from '@tabler/icons-solidjs';
import { isDemoMode } from '@/lib/demo-mode';
import { useHaptics } from '@/lib/haptics';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/badge';
import { LoadingState } from '@/components/ui/LoadingState';
import { solidtimeApi } from '@/lib/solidtime-api';
export const TimeTracking = () => {
const haptics = useHaptics();
const [refreshTrigger, setRefreshTrigger] = createSignal(0);
const [timeEntries, setTimeEntries] = createSignal<TimeEntry[]>([]);
const [loading, setLoading] = createSignal(true);
const [currentRunningEntry, setCurrentRunningEntry] = createSignal<TimeEntry | null>(null);
const [currentElapsedSeconds, setCurrentElapsedSeconds] = createSignal(0);
const [timeEntries, setTimeEntries] = createSignal<any[]>([]);
const [currentRunningEntry, setCurrentRunningEntry] = createSignal<any | null>(null);
const [elapsedSeconds, setElapsedSeconds] = createSignal(0);
const [stats, setStats] = createSignal({ total: 0, billable: 0, nonBillable: 0 });
// Use appropriate API based on demo mode
const getApi = () => isDemoMode() ? demoTimeEntriesApi : timeEntriesApi;
let timerInterval: ReturnType<typeof setInterval> | null = null;
const loadTimeEntries = async () => {
const loadData = async () => {
try {
setLoading(true);
const response = await getApi().getAll();
const entries = await solidtimeApi.getTimeEntries();
setTimeEntries(entries);
// Handle different response formats
let entries: TimeEntry[] = [];
if (response && response.time_entries) {
entries = response.time_entries;
} else if (response && Array.isArray(response)) {
entries = response;
const active = await solidtimeApi.getActiveTimeEntry();
setCurrentRunningEntry(active);
if (active) {
const start = new Date(active.start).getTime();
const now = Date.now();
setElapsedSeconds(Math.floor((now - start) / 1000));
} else {
console.warn('Unexpected response format:', response);
entries = [];
setElapsedSeconds(0);
}
setTimeEntries(entries);
const stats = await solidtimeApi.getTimeEntriesStats();
setStats(stats);
} catch (err) {
console.error('Failed to load time entries:', err);
setTimeEntries([]); // Ensure empty array on error
} finally {
setLoading(false);
}
};
// Calculate today's statistics including real-time running timer
const getTodayStats = () => {
const entries = timeEntries() || [];
const today = new Date().toDateString();
const todayEntries = entries.filter(entry =>
new Date(entry.start_time).toDateString() === today
);
// Start with completed entries
let totalSeconds = todayEntries.reduce((sum, entry) =>
sum + (entry.duration || 0), 0
);
let billableSeconds = todayEntries.reduce((sum, entry) =>
sum + (entry.duration || 0), 0
);
let totalBillableAmount = todayEntries.reduce((sum, entry) => {
if (entry.duration && entry.hourly_rate && entry.billable) {
return sum + (entry.duration / 3600 * entry.hourly_rate);
}
return sum;
}, 0);
// Add real-time data from currently running timer
const runningEntry = currentRunningEntry();
if (runningEntry && new Date(runningEntry.start_time).toDateString() === today) {
const elapsed = currentElapsedSeconds();
totalSeconds += elapsed;
if (runningEntry.billable) {
billableSeconds += elapsed;
if (runningEntry.hourly_rate) {
totalBillableAmount += (elapsed / 3600 * runningEntry.hourly_rate);
}
}
const startTimer = async () => {
try {
const entry = await solidtimeApi.startTimeEntry({
description: 'Working on Trackeep',
});
setCurrentRunningEntry(entry);
setElapsedSeconds(0);
haptics.impact();
loadData();
} catch (err) {
console.error('Failed to start timer:', err);
}
const runningCount = todayEntries.filter(entry => entry.is_running).length +
(runningEntry ? 1 : 0);
return {
totalSeconds,
totalEntries: todayEntries.length + (runningEntry ? 1 : 0),
billableSeconds,
totalBillableAmount,
runningCount
};
};
const formatTime = (seconds: number): string => {
const stopTimer = async () => {
if (!currentRunningEntry()) return;
try {
await solidtimeApi.stopTimeEntry(currentRunningEntry().id);
setCurrentRunningEntry(null);
setElapsedSeconds(0);
haptics.selection();
loadData();
} catch (err) {
console.error('Failed to stop timer:', err);
}
};
const formatTime = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
const secs = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const formatAmount = (amount: number): string => {
return `$${amount.toFixed(2)}`;
const formatDuration = (start: string, end?: string) => {
const startTime = new Date(start).getTime();
const endTime = end ? new Date(end).getTime() : Date.now();
const diff = Math.floor((endTime - startTime) / 1000);
return formatTime(diff);
};
const handleTimeEntryCreated = (newEntry: TimeEntry) => {
setTimeEntries(prev => [newEntry, ...prev]);
setRefreshTrigger(prev => prev + 1);
haptics.success(); // Success feedback for time entry creation
};
// Handle real-time timer updates
const handleTimerUpdate = (entry: TimeEntry | null, elapsedSeconds: number) => {
setCurrentRunningEntry(entry);
setCurrentElapsedSeconds(elapsedSeconds);
};
// Load time entries on mount and when refresh trigger changes
onMount(() => {
loadTimeEntries();
loadData();
});
createEffect(() => {
if (refreshTrigger() > 0) {
loadTimeEntries();
if (currentRunningEntry()) {
timerInterval = setInterval(() => {
setElapsedSeconds(s => s + 1);
}, 1000);
} else {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
return () => {
if (timerInterval) clearInterval(timerInterval);
};
});
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6">
{/* Simple loading indicator */}
{loading() && (
<div class="text-center text-sm text-muted-foreground py-2">
Loading time entries...
</div>
)}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Timer Component */}
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<Timer
onTimeEntryCreated={handleTimeEntryCreated}
onTimerUpdate={handleTimerUpdate}
/>
</div>
{/* Time Stats - Standardized Design */}
<div class="border rounded-lg p-4">
<h2 class="text-lg font-semibold mb-4">Today's Overview</h2>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconClock class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{formatTime(getTodayStats().totalSeconds)}</p>
<p class="text-sm text-muted-foreground">Total Time Today</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconActivity class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{getTodayStats().totalEntries}</p>
<p class="text-sm text-muted-foreground">Entries Today</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconCurrencyDollar class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{formatAmount(getTodayStats().totalBillableAmount)}</p>
<p class="text-sm text-muted-foreground">
Billable Today
{currentRunningEntry() && currentRunningEntry()?.billable && (
<span class="ml-1 text-green-600 dark:text-green-400">
Live
</span>
)}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconActivity class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{getTodayStats().runningCount}</p>
<p class="text-sm text-muted-foreground">Running Timers</p>
</div>
</div>
</div>
<h1 class="text-3xl font-bold tracking-tight">Time Tracking</h1>
<p class="text-muted-foreground mt-1">
Track your time with solidtime.io integration
</p>
</div>
</div>
{/* Time Entries List */}
<div>
<TimeEntriesList refreshTrigger={refreshTrigger()} />
<Card class="p-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
<IconClock class="w-6 h-6 text-primary" />
</div>
<div>
<h2 class="text-lg font-semibold">
{currentRunningEntry() ? 'Timer Running' : 'Ready to Track'}
</h2>
<p class="text-sm text-muted-foreground">
{currentRunningEntry()
? currentRunningEntry().description || 'No description'
: 'Start tracking your time'}
</p>
</div>
</div>
<div class="text-right">
<div class="text-4xl font-bold tabular-nums">
{formatTime(elapsedSeconds())}
</div>
<div class="text-sm text-muted-foreground">
{currentRunningEntry() ? 'Running' : 'Stopped'}
</div>
</div>
</div>
<div class="mt-6 flex gap-3">
{!currentRunningEntry() ? (
<Button onClick={startTimer} class="gap-2">
<IconActivity class="w-4 h-4" />
Start Timer
</Button>
) : (
<Button onClick={stopTimer} variant="destructive" class="gap-2">
<IconClock class="w-4 h-4" />
Stop Timer
</Button>
)}
</div>
</Card>
<div class="grid gap-4 md:grid-cols-3">
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<IconClock class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm text-muted-foreground">Total Today</p>
<p class="text-xl font-bold">{formatTime(stats().total)}</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-green-500/10 flex items-center justify-center">
<IconCurrencyDollar class="w-5 h-5 text-green-500" />
</div>
<div>
<p class="text-sm text-muted-foreground">Billable</p>
<p class="text-xl font-bold">{formatTime(stats().billable)}</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-orange-500/10 flex items-center justify-center">
<IconActivity class="w-5 h-5 text-orange-500" />
</div>
<div>
<p class="text-sm text-muted-foreground">Non-Billable</p>
<p class="text-xl font-bold">{formatTime(stats().nonBillable)}</p>
</div>
</div>
</Card>
</div>
<Card class="p-6">
<h3 class="text-lg font-semibold mb-4">Recent Time Entries</h3>
{loading() ? (
<LoadingState message="Loading entries..." center />
) : timeEntries().length === 0 ? (
<div class="text-center py-8 text-muted-foreground">
<IconClock class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No time entries yet</p>
<p class="text-sm">Start tracking your time to see entries here</p>
</div>
) : (
<div class="space-y-3">
{timeEntries().map((entry) => (
<div class="flex items-center justify-between p-3 rounded-lg border hover:bg-accent/50 transition-colors">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-primary" />
<div>
<p class="font-medium">{entry.description || 'Untitled'}</p>
<p class="text-sm text-muted-foreground">
{new Date(entry.start).toLocaleString()}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<Badge variant={entry.billable ? 'default' : 'secondary'}>
{entry.billable ? 'Billable' : 'Non-Billable'}
</Badge>
<span class="font-mono text-sm">
{formatDuration(entry.start, entry.end)}
</span>
</div>
</div>
))}
</div>
)}
</Card>
</div>
);
};
@@ -2,6 +2,7 @@ import { createSignal, createEffect, Show, For } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { toast } from '@/components/ui/Toast';
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
import { getApiV1BaseUrl } from '@/lib/api-url';
@@ -55,12 +56,19 @@ const BrowserExtensionSettings = () => {
'files:write'
]);
const [activeTab, setActiveTab] = createSignal<'overview' | 'api-keys' | 'extensions' | 'examples'>('api-keys');
const [showConfirmModal, setShowConfirmModal] = createSignal(false);
const [confirmModalConfig, setConfirmModalConfig] = createSignal({
title: 'Confirm Action',
message: 'Are you sure?',
onConfirm: () => {}
});
const [confirmTarget, setConfirmTarget] = createSignal<{type: 'key' | 'extension', id: number | string} | null>(null);
const quickStartGuides: QuickStartGuide[] = [
{
title: 'Generate API Key',
description: 'Create a secure API key for your browser extension',
icon: <Key class="w-5 h-5 text-blue-600" />,
icon: <Key class="w-5 h-5 text-primary" />,
steps: [
'Go to Settings → Browser Extension',
'Click "Generate New Key"',
@@ -71,7 +79,7 @@ const BrowserExtensionSettings = () => {
{
title: 'Configure Extension',
description: 'Set up your browser extension with the API key',
icon: <Settings class="w-5 h-5 text-green-600" />,
icon: <Settings class="w-5 h-5 text-primary" />,
steps: [
'Install the Trackeep browser extension',
'Open extension options',
@@ -83,7 +91,7 @@ const BrowserExtensionSettings = () => {
{
title: 'Security Best Practices',
description: 'Keep your API keys secure and monitor usage',
icon: <Shield class="w-5 h-5 text-purple-600" />,
icon: <Shield class="w-5 h-5 text-primary" />,
steps: [
'Use unique names for each key',
'Set expiration dates for temporary access',
@@ -258,12 +266,20 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
};
const revokeAPIKey = async (keyId: number) => {
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) {
return;
}
setConfirmTarget({type: 'key', id: keyId});
setConfirmModalConfig({
title: 'Revoke API Key',
message: 'Are you sure you want to revoke this API key? This action cannot be undone.',
onConfirm: confirmRevokeAPIKey
});
setShowConfirmModal(true);
};
const confirmRevokeAPIKey = async () => {
const target = confirmTarget();
if (!target || target.type !== 'key') return;
try {
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/${keyId}`, {
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/${target.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -279,16 +295,27 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
}
} catch (error) {
toast.error('Error revoking API key');
} finally {
setShowConfirmModal(false);
setConfirmTarget(null);
}
};
const revokeExtension = async (extensionId: string) => {
if (!confirm('Are you sure you want to revoke this extension? This action cannot be undone.')) {
return;
}
setConfirmTarget({type: 'extension', id: extensionId});
setConfirmModalConfig({
title: 'Revoke Extension',
message: 'Are you sure you want to revoke this extension? This action cannot be undone.',
onConfirm: confirmRevokeExtension
});
setShowConfirmModal(true);
};
const confirmRevokeExtension = async () => {
const target = confirmTarget();
if (!target || target.type !== 'extension') return;
try {
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions/${extensionId}`, {
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions/${target.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -304,6 +331,9 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
}
} catch (error) {
toast.error('Error revoking extension');
} finally {
setShowConfirmModal(false);
setConfirmTarget(null);
}
};
@@ -324,55 +354,55 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
return (
<div class="p-6">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 mb-2">Browser Extension Settings</h1>
<p class="text-gray-600">Manage API keys and browser extensions for secure access to your Trackeep account.</p>
<h1 class="text-2xl font-bold text-foreground mb-2">Browser Extension Settings</h1>
<p class="text-muted-foreground">Manage API keys and browser extensions for secure access to your Trackeep account.</p>
</div>
{/* Tab Navigation */}
<div class="border-b border-gray-200 mb-6">
<div class="border-b border-border mb-6">
<nav class="flex space-x-8">
<button
class={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'overview'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
class={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab() === 'overview'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
onClick={() => setActiveTab('overview')}
>
<Globe class="w-4 h-4 mr-2" />
<Globe class="w-4 h-4" />
Overview
</button>
<button
class={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'api-keys'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
class={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab() === 'api-keys'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
onClick={() => setActiveTab('api-keys')}
>
<Key class="w-4 h-4 mr-2" />
<Key class="w-4 h-4" />
API Keys
</button>
<button
class={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'extensions'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
class={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab() === 'extensions'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
onClick={() => setActiveTab('extensions')}
>
<Users class="w-4 h-4 mr-2" />
<Users class="w-4 h-4" />
Extensions
</button>
<button
class={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab() === 'examples'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
class={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-colors ${
activeTab() === 'examples'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
onClick={() => setActiveTab('examples')}
>
<Shield class="w-4 h-4 mr-2" />
<Shield class="w-4 h-4" />
Examples
</button>
</nav>
@@ -385,18 +415,18 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
{guide => (
<Card class="p-6">
<div class="text-center mb-4">
<div class="inline-flex items-center justify-center w-12 h-12 bg-blue-100 rounded-full mb-3">
<div class="inline-flex items-center justify-center w-12 h-12 bg-primary/10 rounded-full mb-3">
{guide.icon}
</div>
<h3 class="text-lg font-semibold text-gray-900">{guide.title}</h3>
<p class="text-gray-600 text-sm mb-4">{guide.description}</p>
<h3 class="text-lg font-semibold text-foreground">{guide.title}</h3>
<p class="text-muted-foreground text-sm mb-4">{guide.description}</p>
</div>
<div class="space-y-2">
<For each={guide.steps}>
{step => (
<div class="flex items-center space-x-2">
<CheckCircle class="w-4 h-4 text-green-500 flex-shrink-0" />
<span class="text-sm text-gray-700">{step}</span>
<span class="text-sm text-foreground">{step}</span>
</div>
)}
</For>
@@ -408,8 +438,8 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<AlertCircle class="w-5 h-5 text-orange-500 mr-2" />
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<AlertCircle class="w-5 h-5 text-orange-500" />
Security Status
</h3>
<div class="space-y-3">
@@ -417,42 +447,42 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
<div>
<div class="font-medium text-green-700">API Keys Secure</div>
<div class="text-sm text-gray-600">All keys using secure API key authentication</div>
<div class="text-sm text-muted-foreground">All keys using secure API key authentication</div>
</div>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
<div>
<div class="font-medium text-green-700">Extensions Registered</div>
<div class="text-sm text-gray-600">{extensions().length} active extensions</div>
<div class="text-sm text-muted-foreground">{extensions().length} active extensions</div>
</div>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div>
<div class="font-medium text-yellow-700">Quick Setup Available</div>
<div class="text-sm text-gray-600">Get started in under 5 minutes</div>
<div class="text-sm text-muted-foreground">Get started in under 5 minutes</div>
</div>
</div>
</div>
</Card>
<Card class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Clock class="w-5 h-5 text-blue-500 mr-2" />
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Clock class="w-5 h-5 text-primary" />
Recent Activity
</h3>
<div class="space-y-2">
<div class="text-sm text-gray-600">
<div class="font-medium text-gray-900">Last API key created:</div>
<div class="text-sm text-muted-foreground">
<div class="font-medium text-foreground">Last API key created:</div>
<div>{apiKeys().length > 0 ? new Date(apiKeys()[0].created_at).toLocaleDateString() : 'No keys created yet'}</div>
</div>
<div class="text-sm text-gray-600">
<div class="font-medium text-gray-900">Total API keys:</div>
<div class="text-sm text-muted-foreground">
<div class="font-medium text-foreground">Total API keys:</div>
<div>{apiKeys().length} active, {apiKeys().filter(k => !k.is_active).length} revoked</div>
</div>
<div class="text-sm text-gray-600">
<div class="font-medium text-gray-900">Extensions active:</div>
<div class="text-sm text-muted-foreground">
<div class="font-medium text-foreground">Extensions active:</div>
<div>{extensions().length} connected</div>
</div>
</div>
@@ -464,18 +494,18 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
<Show when={activeTab() === 'api-keys'}>
<Card class="mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">API Keys</h2>
<Button onClick={() => setShowCreateKey(true)} class="btn-primary">
<h2 class="text-xl font-semibold text-foreground">API Keys</h2>
<Button onClick={() => setShowCreateKey(true)}>
Generate New Key
</Button>
</div>
<Show when={showCreateKey()}>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h3 class="text-lg font-medium mb-4">Create New API Key</h3>
<div class="bg-primary/5 border border-primary/20 rounded-lg p-4 mb-4">
<h3 class="text-lg font-medium text-foreground mb-4">Create New API Key</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Key Name</label>
<label class="block text-sm font-medium text-foreground mb-2">Key Name</label>
<Input
value={newKeyName()}
onInput={(e: InputEvent) => setNewKeyName((e.target as HTMLInputElement).value)}
@@ -485,7 +515,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Permissions</label>
<label class="block text-sm font-medium text-foreground mb-2">Permissions</label>
<div class="space-y-2">
<For each={availablePermissions}>
{permission => (
@@ -494,11 +524,11 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
type="checkbox"
checked={newKeyPermissions().includes(permission.id)}
onChange={() => togglePermission(permission.id)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
class="rounded border-input text-primary focus:ring-primary"
/>
<div>
<span class="font-medium">{permission.label}</span>
<span class="text-sm text-gray-500">{permission.description}</span>
<span class="font-medium text-foreground">{permission.label}</span>
<span class="text-sm text-muted-foreground">{permission.description}</span>
</div>
</label>
)}
@@ -508,15 +538,14 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
<div class="flex space-x-2">
<Button
variant="outline"
onClick={() => setShowCreateKey(false)}
class="btn-secondary"
>
Cancel
</Button>
<Button
onClick={createAPIKey}
disabled={loading()}
class="btn-primary"
>
{loading() ? 'Creating...' : 'Create Key'}
</Button>
@@ -527,24 +556,24 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
<div class="space-y-3">
<For each={apiKeys()}>
{key => (
<div class="bg-white border border-gray-200 rounded-lg p-4">
<div class="bg-card border border-border rounded-lg p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-medium">{key.name}</h3>
<div class="text-sm text-gray-500 mb-2">
<h3 class="text-lg font-medium text-foreground">{key.name}</h3>
<div class="text-sm text-muted-foreground mb-2">
Created: {new Date(key.created_at).toLocaleDateString()}
</div>
<div class="flex flex-wrap gap-1">
<For each={key.permissions}>
{permission => (
<span class="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">
<span class="inline-block bg-primary/10 text-primary text-xs px-2 py-1 rounded">
{permission}
</span>
)}
</For>
</div>
{key.expires_at && (
<div class="text-sm text-orange-600">
<div class="text-sm text-orange-500">
Expires: {new Date(key.expires_at).toLocaleDateString()}
</div>
)}
@@ -556,8 +585,8 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
{key.is_active ? 'Active' : 'Revoked'}
</span>
<Button
variant="outline"
onClick={() => revokeAPIKey(key.id)}
class="btn-secondary btn-sm"
disabled={!key.is_active}
>
Revoke
@@ -572,51 +601,53 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
</Show>
{/* Browser Extensions Section */}
<Card>
<div class="mb-4">
<h2 class="text-xl font-semibold">Registered Extensions</h2>
<p class="text-sm text-gray-600">Manage browser extensions that have access to your account.</p>
</div>
<Show when={activeTab() === 'extensions'}>
<Card>
<div class="mb-4">
<h2 class="text-xl font-semibold text-foreground">Registered Extensions</h2>
<p class="text-sm text-muted-foreground">Manage browser extensions that have access to your account.</p>
</div>
<div class="space-y-3">
<For each={extensions()}>
{extension => (
<div class="bg-white border border-gray-200 rounded-lg p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-medium">{extension.name}</h3>
<div class="text-sm text-gray-500 mb-2">
Extension ID: {extension.extension_id}
</div>
<div class="text-sm text-gray-500 mb-2">
Registered: {new Date(extension.created_at).toLocaleDateString()}
</div>
{extension.last_seen && (
<div class="text-sm text-gray-500">
Last seen: {new Date(extension.last_seen).toLocaleDateString()}
<div class="space-y-3">
<For each={extensions()}>
{extension => (
<div class="bg-card border border-border rounded-lg p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-medium text-foreground">{extension.name}</h3>
<div class="text-sm text-muted-foreground mb-2">
Extension ID: {extension.extension_id}
</div>
)}
</div>
<div class="flex space-x-2">
<span class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
extension.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{extension.is_active ? 'Active' : 'Revoked'}
</span>
<Button
onClick={() => revokeExtension(extension.extension_id)}
class="btn-secondary btn-sm"
disabled={!extension.is_active}
>
Revoke
</Button>
<div class="text-sm text-muted-foreground mb-2">
Registered: {new Date(extension.created_at).toLocaleDateString()}
</div>
{extension.last_seen && (
<div class="text-sm text-muted-foreground">
Last seen: {new Date(extension.last_seen).toLocaleDateString()}
</div>
)}
</div>
<div class="flex space-x-2">
<span class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
extension.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{extension.is_active ? 'Active' : 'Revoked'}
</span>
<Button
variant="outline"
onClick={() => revokeExtension(extension.extension_id)}
disabled={!extension.is_active}
>
Revoke
</Button>
</div>
</div>
</div>
</div>
)}
</For>
</div>
</Card>
)}
</For>
</div>
</Card>
</Show>
{/* Examples Tab */}
<Show when={activeTab() === 'examples'}>
@@ -625,34 +656,33 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
{example => (
<Card class="p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2 flex items-center">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3">
<span class="text-blue-600 font-mono text-xs font-bold">{example.language.toUpperCase()}</span>
<h3 class="text-lg font-semibold text-foreground mb-2 flex items-center gap-3">
<div class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<span class="text-primary font-mono text-xs font-bold">{example.language.toUpperCase()}</span>
</div>
{example.title}
</h3>
<p class="text-gray-600 text-sm mb-4">{example.description}</p>
<p class="text-muted-foreground text-sm mb-4">{example.description}</p>
</div>
<div class="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre class="text-green-400 text-sm">
<div class="bg-muted rounded-lg p-4 overflow-x-auto">
<pre class="text-foreground text-sm">
<code>{example.code}</code>
</pre>
</div>
<div class="flex justify-between items-center mt-4">
<Button
variant="outline"
onClick={() => {
navigator.clipboard.writeText(example.code);
toast.success('Code copied to clipboard!');
}}
class="btn-secondary"
>
Copy Code
</Button>
<Button
onClick={() => window.open(`https://your-trackeep.com/api/v1/browser-extension/validate`, '_blank')}
class="btn-primary"
onClick={() => window.open(`${apiBaseUrl}/browser-extension/validate`, '_blank')}
>
Test API
</Button>
@@ -662,6 +692,19 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
</For>
</div>
</Show>
<ConfirmModal
isOpen={showConfirmModal()}
onClose={() => {
setShowConfirmModal(false);
setConfirmTarget(null);
}}
onConfirm={() => confirmModalConfig().onConfirm()}
title={confirmModalConfig().title}
message={confirmModalConfig().message}
confirmText="Revoke"
type="danger"
/>
</div>
);
};
File diff suppressed because it is too large Load Diff