mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 05:53:50 +00:00
Remove AI/chat/search features, add solidtime integration and workspace setup
This commit is contained in:
+64
-22
@@ -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,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>
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user