feat: major feature updates and cleanup

- Add Redis architecture implementation
- Update browser extension functionality
- Clean up deprecated files and documentation
- Enhance backend handlers for auth, messages, search
- Add new configuration options and settings
- Update Docker and deployment configurations
This commit is contained in:
Tomas Dvorak
2026-03-03 11:03:37 +01:00
parent 446bc7acfb
commit 083373a24f
241 changed files with 46662 additions and 24880 deletions
+48 -6
View File
@@ -24,12 +24,14 @@ import { GitHub } from '@/pages/GitHub'
import { TimeTracking } from '@/pages/TimeTracking'
import { Calendar } from '@/pages/Calendar'
import { AuthCallback } from '@/pages/AuthCallback'
import { AuthProvider } from '@/lib/auth'
import { AuthProvider, useAuth } from '@/lib/auth'
import { Search } from '@/pages/Search'
import { Analytics } from '@/pages/Analytics'
import { Messages } from '@/pages/Messages'
import BrowserExtensionSettings from '@/pages/BrowserExtensionSettings'
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
import { onMount } from 'solid-js'
import { onMount, createEffect } from 'solid-js'
import { useNavigate } from '@solidjs/router'
// Initialize dark mode immediately before anything else
const initializeDarkMode = () => {
@@ -76,6 +78,42 @@ const queryClient = new QueryClient({
},
})
// Component to handle root route logic
const RootRoute = () => {
const { authState } = useAuth();
const navigate = useNavigate();
createEffect(() => {
// If demo mode is enabled and user is authenticated, navigate to app
if (isEnvDemoMode() && authState.isAuthenticated && !authState.isLoading) {
navigate('/app', { replace: true });
return;
}
// If not demo mode and user is authenticated, navigate to app
if (!isEnvDemoMode() && authState.isAuthenticated && !authState.isLoading) {
navigate('/app', { replace: true });
return;
}
// If not authenticated and not loading, show login
if (!authState.isAuthenticated && !authState.isLoading) {
navigate('/login', { replace: true });
return;
}
});
// Show loading spinner while checking auth
return (
<div class="min-h-screen bg-[#18181b] flex items-center justify-center px-4">
<div class="text-center">
<div class="inline-block w-8 h-8 border-2 border-[#39b9ff] border-r-transparent rounded-full animate-spin mb-3"></div>
<p class="text-sm text-[#a3a3a3]">Loading...</p>
</div>
</div>
);
};
function App() {
// Initialize demo mode API interceptor and cleanup old demo data
onMount(() => {
@@ -93,10 +131,7 @@ function App() {
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Router>
<Route path="/" component={() => {
// Always show login page, demo mode will be handled there
return <Login />;
}} />
<Route path="/" component={RootRoute} />
<Route path="/login" component={Login} />
<Route path="/auth/callback" component={AuthCallback} />
<Route path="/app" component={() => (
@@ -141,6 +176,13 @@ function App() {
</Layout>
</ProtectedRoute>
)} />
<Route path="/app/browser-extension" component={() => (
<ProtectedRoute>
<Layout title="Browser Extension Settings">
<BrowserExtensionSettings />
</Layout>
</ProtectedRoute>
)} />
<Route path="/app/files" component={() => (
<ProtectedRoute>
<Layout title="Files">
+60 -57
View File
@@ -1,6 +1,5 @@
import { createSignal, onMount, Show } from 'solid-js';
import { createSignal, onMount, Show, For } from 'solid-js';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
interface TOTPSetupResponse {
secret: string;
@@ -272,10 +271,10 @@ export function TwoFactorAuth() {
return (
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-white">Two-Factor Authentication</h2>
<h2 class="text-2xl font-bold text-foreground">Two-Factor Authentication</h2>
<div class="flex items-center space-x-2">
<div class={`w-3 h-3 rounded-full ${totpStatus()?.enabled ? 'bg-primary' : 'bg-muted'}`}></div>
<span class="text-gray-300">
<span class="text-muted-foreground">
{totpStatus()?.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
@@ -295,42 +294,42 @@ export function TwoFactorAuth() {
</Show>
{/* Current Status */}
<Card class="p-6">
<h3 class="text-lg font-semibold text-white mb-4">Current Status</h3>
<div class="border rounded-lg p-6">
<h3 class="text-lg font-semibold text-foreground mb-4">Current Status</h3>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-gray-300">2FA Status:</span>
<span class="text-muted-foreground">2FA Status:</span>
<span class={`font-medium ${totpStatus()?.enabled ? 'text-primary' : 'text-muted-foreground'}`}>
{totpStatus()?.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-300">Setup Status:</span>
<span class={`font-medium ${totpStatus()?.setup ? 'text-blue-400' : 'text-gray-400'}`}>
<span class="text-muted-foreground">Setup Status:</span>
<span class={`font-medium ${totpStatus()?.setup ? 'text-blue-500' : 'text-muted-foreground'}`}>
{totpStatus()?.setup ? 'Configured' : 'Not Configured'}
</span>
</div>
</div>
</Card>
</div>
{/* Setup TOTP */}
<Show when={!totpStatus()?.enabled}>
<Card class="p-6">
<h3 class="text-lg font-semibold text-white mb-4">Setup Two-Factor Authentication</h3>
<p class="text-gray-300 mb-4">
<div class="border rounded-lg p-6">
<h3 class="text-lg font-semibold text-foreground mb-4">Setup Two-Factor Authentication</h3>
<p class="text-muted-foreground mb-4">
Enable 2FA to add an extra layer of security to your account. You'll need a TOTP app like Google Authenticator or Authy.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<label class="block text-sm font-medium text-muted-foreground mb-2">
Confirm Password
</label>
<input
type="password"
value={setupPassword()}
onInput={(e) => setSetupPassword(e.currentTarget.value)}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
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"
placeholder="Enter your password"
/>
</div>
@@ -343,59 +342,61 @@ export function TwoFactorAuth() {
{loading() ? 'Setting up...' : 'Setup 2FA'}
</Button>
</div>
</Card>
</div>
</Show>
{/* TOTP Setup Process */}
<Show when={showSetup() && setupData()}>
<Card class="p-6">
<h3 class="text-lg font-semibold text-white mb-4">Complete 2FA Setup</h3>
<div class="border rounded-lg p-6">
<h3 class="text-lg font-semibold text-foreground mb-4">Complete 2FA Setup</h3>
<div class="space-y-6">
{/* QR Code */}
<div class="text-center">
<h4 class="text-md font-medium text-gray-300 mb-3">Scan QR Code</h4>
<h4 class="text-md font-medium text-foreground mb-3">Scan QR Code</h4>
<img
src={setupData()!.qr_code}
alt="TOTP QR Code"
class="mx-auto border-2 border-gray-600 rounded-lg"
class="mx-auto border-2 border-border rounded-lg"
/>
<p class="text-sm text-gray-400 mt-2">
<p class="text-sm text-muted-foreground mt-2">
Or manually enter this secret in your TOTP app:
</p>
<code class="block bg-gray-800 px-3 py-2 rounded text-blue-400 break-all">
<code class="block bg-muted px-3 py-2 rounded text-primary break-all">
{setupData()!.secret}
</code>
</div>
{/* Backup Codes */}
<div>
<h4 class="text-md font-medium text-gray-300 mb-3">Backup Codes</h4>
<p class="text-sm text-gray-400 mb-3">
<h4 class="text-md font-medium text-foreground mb-3">Backup Codes</h4>
<p class="text-sm text-muted-foreground mb-3">
Save these backup codes in a secure location. You can use them to access your account if you lose your TOTP device.
</p>
<div class="grid grid-cols-2 gap-2">
{backupCodes().map((code) => (
<code class="bg-gray-800 px-3 py-2 rounded text-gray-300 text-sm">
{code}
</code>
))}
<For each={backupCodes()}>
{(code) => (
<code class="bg-muted px-3 py-2 rounded text-foreground text-sm">
{code}
</code>
)}
</For>
</div>
</div>
{/* Verification */}
<div>
<h4 class="text-md font-medium text-gray-300 mb-3">Verify Setup</h4>
<h4 class="text-md font-medium text-foreground mb-3">Verify Setup</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<label class="block text-sm font-medium text-muted-foreground mb-2">
Enter 6-digit code
</label>
<input
type="text"
value={verifyCode()}
onInput={(e) => setVerifyCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
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"
placeholder="000000"
maxlength={6}
/>
@@ -413,7 +414,7 @@ export function TwoFactorAuth() {
<Button
onClick={enableTOTP}
disabled={loading() || verifyCode().length !== 6}
variant="papra"
variant="secondary"
class="flex-1"
>
{loading() ? 'Enabling...' : 'Enable 2FA'}
@@ -422,41 +423,41 @@ export function TwoFactorAuth() {
</div>
</div>
</div>
</Card>
</div>
</Show>
{/* Disable 2FA */}
<Show when={totpStatus()?.enabled}>
<Card class="p-6">
<h3 class="text-lg font-semibold text-white mb-4">Disable Two-Factor Authentication</h3>
<p class="text-gray-300 mb-4">
<div class="border rounded-lg p-6">
<h3 class="text-lg font-semibold text-foreground mb-4">Disable Two-Factor Authentication</h3>
<p class="text-muted-foreground mb-4">
Disabling 2FA will make your account less secure. You'll need to provide your current TOTP code and password.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<label class="block text-sm font-medium text-muted-foreground mb-2">
TOTP Code
</label>
<input
type="text"
value={disableCode()}
onInput={(e) => setDisableCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
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"
placeholder="000000"
maxlength={6}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<label class="block text-sm font-medium text-muted-foreground mb-2">
Password
</label>
<input
type="password"
value={disablePassword()}
onInput={(e) => setDisablePassword(e.currentTarget.value)}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
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"
placeholder="Enter your password"
/>
</div>
@@ -470,24 +471,24 @@ export function TwoFactorAuth() {
{loading() ? 'Disabling...' : 'Disable 2FA'}
</Button>
</div>
</Card>
</div>
</Show>
{/* Backup Code Management */}
<Show when={totpStatus()?.enabled}>
<Card class="p-6">
<h3 class="text-lg font-semibold text-white mb-4">Backup Code Management</h3>
<div class="border rounded-lg p-6">
<h3 class="text-lg font-semibold text-foreground mb-4">Backup Code Management</h3>
<div class="space-y-6">
{/* Verify Backup Code */}
<div>
<h4 class="text-md font-medium text-gray-300 mb-3">Verify Backup Code</h4>
<h4 class="text-md font-medium text-foreground mb-3">Verify Backup Code</h4>
<div class="space-y-4">
<input
type="text"
value={backupCodeVerify()}
onInput={(e) => setBackupCodeVerify(e.currentTarget.value)}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
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"
placeholder="Enter backup code"
/>
@@ -503,8 +504,8 @@ export function TwoFactorAuth() {
{/* Regenerate Backup Codes */}
<div>
<h4 class="text-md font-medium text-gray-300 mb-3">Regenerate Backup Codes</h4>
<p class="text-sm text-gray-400 mb-3">
<h4 class="text-md font-medium text-foreground mb-3">Regenerate Backup Codes</h4>
<p class="text-sm text-muted-foreground mb-3">
This will invalidate all existing backup codes and generate new ones.
</p>
@@ -513,7 +514,7 @@ export function TwoFactorAuth() {
type="text"
value={regenerateCode()}
onInput={(e) => setRegenerateCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
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"
placeholder="Current TOTP code"
maxlength={6}
/>
@@ -532,21 +533,23 @@ export function TwoFactorAuth() {
{/* Show New Backup Codes */}
<Show when={backupCodes().length > 0}>
<div>
<h4 class="text-md font-medium text-gray-300 mb-3">New Backup Codes</h4>
<p class="text-sm text-gray-400 mb-3">
<h4 class="text-md font-medium text-foreground mb-3">New Backup Codes</h4>
<p class="text-sm text-muted-foreground mb-3">
Save these new backup codes in a secure location:
</p>
<div class="grid grid-cols-2 gap-2">
{backupCodes().map((code) => (
<code class="bg-gray-800 px-3 py-2 rounded text-gray-300 text-sm">
{code}
</code>
))}
<For each={backupCodes()}>
{(code) => (
<code class="bg-muted px-3 py-2 rounded text-foreground text-sm">
{code}
</code>
)}
</For>
</div>
</div>
</Show>
</div>
</Card>
</div>
</Show>
</div>
);
+20 -15
View File
@@ -112,22 +112,27 @@ export function AIChatPanel(props: AIChatPanelProps) {
{(message) => (
<div class={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{message.role === 'assistant' && (
<div class="flex items-center justify-center p-2 rounded-lg bg-muted flex-shrink-0">
<IconBrain class="size-4 text-primary" />
<div 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 ${
<div class={`max-w-[280px] rounded-2xl p-3 shadow-sm transition-all duration-200 hover:shadow-md ${
message.role === 'user'
? 'bg-primary text-primary-foreground rounded-br-sm'
: 'bg-muted rounded-bl-sm'
? '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>
<p class="text-xs opacity-70 mt-2">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</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-primary flex-shrink-0">
<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>
)}
@@ -145,12 +150,12 @@ export function AIChatPanel(props: AIChatPanelProps) {
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
class="flex-1 h-10 w-full rounded-full border border-input bg-transparent px-4 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
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-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-10 w-10"
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>
@@ -176,7 +181,7 @@ export function AIChatPanel(props: AIChatPanelProps) {
</button>
<Show when={showModelPicker()}>
<div class="absolute bottom-full left-0 mb-2 w-64 bg-background border rounded-lg shadow-lg z-50 p-1 max-h-48 overflow-y-auto">
<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
@@ -184,10 +189,10 @@ export function AIChatPanel(props: AIChatPanelProps) {
setSelectedModel(model.id)
setShowModelPicker(false)
}}
class={`w-full text-left p-2 rounded text-xs transition-colors ${
class={`w-full text-left p-2 rounded-lg text-xs transition-all duration-200 ${
selectedModel() === model.id
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-muted'
? '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">
@@ -23,7 +23,6 @@ import {
IconBuilding,
IconPlus
} from '@tabler/icons-solidjs'
import { UpdateChecker } from '../ui/UpdateChecker'
import { Input } from '../ui/Input'
import { Button } from '../ui/Button'
import { Switch } from '../ui/Switch'
@@ -447,11 +446,6 @@ export function Sidebar(props: SidebarProps) {
{/* Bottom Navigation */}
<div class="flex-1"></div>
{/* Update Checker */}
<div class="px-4 mb-2">
<UpdateChecker />
</div>
<nav class="flex flex-col gap-0.5 px-4">
<A
href="/app/removed-stuff"
@@ -134,13 +134,14 @@ export const EnhancedSearch = () => {
if (response.ok) {
const data = await response.json();
const results = Array.isArray(data?.results) ? data.results : [];
if (resetOffset) {
setSearchResults(data.results);
setSearchResults(results);
} else {
setSearchResults(prev => [...prev, ...data.results]);
setSearchResults(prev => [...prev, ...results]);
}
setTotal(data.results.length); // Semantic search doesn't return total count
setTook(data.took);
setTotal(results.length); // Semantic search doesn't return total count
setTook(Number(data?.took) || 0);
}
} else {
// Use enhanced full-text search API
@@ -155,14 +156,15 @@ export const EnhancedSearch = () => {
if (response.ok) {
const data: SearchResponse = await response.json();
const results = Array.isArray((data as any)?.results) ? (data as any).results : [];
if (resetOffset) {
setSearchResults(data.results);
setSearchResults(results);
} else {
setSearchResults(prev => [...prev, ...data.results]);
setSearchResults(prev => [...prev, ...results]);
}
setTotal(data.total);
setAggregations(data.aggregations);
setTook(data.took);
setTotal(Number((data as any)?.total) || results.length);
setAggregations((data as any)?.aggregations && typeof (data as any).aggregations === 'object' ? (data as any).aggregations : {});
setTook(Number((data as any)?.took) || 0);
}
}
@@ -12,6 +12,8 @@ import {
IconExternalLink
} from '@tabler/icons-solidjs';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { isDemoMode } from '@/lib/demo-mode';
import { getMockActivities } from '@/lib/mockData';
const API_BASE_URL = getApiV1BaseUrl();
@@ -79,6 +81,42 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
const combinedActivities: ActivityItem[] = [];
// Use demo data if in demo mode
if (isDemoMode()) {
const mockActivities = getMockActivities();
mockActivities.forEach((activity, index) => {
combinedActivities.push({
id: String(activity.id ?? `activity-${index}`),
type: normalizeActivityType(activity.type || ''),
title: activity.title || 'Activity',
description: activity.action || 'trackeep',
timestamp: activity.timestamp || new Date().toISOString(),
displayTimestamp: activity.timestamp || '',
source: 'trackeep',
});
});
// Sort by timestamp (most recent first)
combinedActivities.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// Apply filter
const filteredActivities = filter() === 'all'
? combinedActivities
: combinedActivities.filter(a => a.source === filter());
// Apply limit
const limitedActivities = props.limit
? filteredActivities.slice(0, props.limit)
: filteredActivities;
setActivities(limitedActivities);
setLoading(false);
return;
}
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/dashboard/stats`, {
headers: {
+4 -4
View File
@@ -188,8 +188,8 @@ export const ColorPicker = (props: ColorPickerProps) => {
<div class="flex items-center gap-2.5 border-b border-stroke-soft-200 p-5">
<div class="flex flex-1 -space-x-px">
{/* Hex Input */}
<div class="group relative flex w-full overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-lg hover:[&:not(:has(input:focus)):has(&gt;:only-child)]:before:ring-transparent flex-[2] rounded-l-10 rounded-r-none focus-within:z-10 hover:[&:not(:focus-within)]:before:!ring-stroke-soft-200" data-rac="" data-channel="hex">
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:[&:not(&:has(input:focus))]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-2.5">
<div class="group relative flex w-full overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-lg hover:not-[:has(input:focus)]:before:ring-transparent flex-[2] rounded-l-10 rounded-r-none focus-within:z-10 hover:not-[:focus-within]:before:!ring-stroke-soft-200" data-rac="" data-channel="hex">
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:not-[:has(input:focus)]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-2.5">
<div class="flex items-center gap-2">
<div class="h-3 w-3 shrink-0 rounded-full ring-0" style={{ 'background-color': currentColor() }}></div>
<input
@@ -210,8 +210,8 @@ export const ColorPicker = (props: ColorPickerProps) => {
</div>
{/* Alpha Input */}
<div class="group relative flex w-full overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-lg hover:[&:not(:has(input:focus)):has(&gt;:only-child)]:before:ring-transparent max-w-[57px] flex-1 rounded-l-none rounded-r-10 focus-within:z-10 hover:[&:not(:focus-within)]:before:!ring-stroke-soft-200" data-rac="" data-channel="alpha">
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:[&:not(&:has(input:focus))]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-2.5">
<div class="group relative flex w-full overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-lg hover:not-[:has(input:focus)]:before:ring-transparent max-w-[57px] flex-1 rounded-l-none rounded-r-10 focus-within:z-10 hover:not-[:focus-within]:before:!ring-stroke-soft-200" data-rac="" data-channel="alpha">
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:not-[:has(input:focus)]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-2.5">
<input
aria-label="Alpha"
id="alpha-input"
@@ -13,7 +13,7 @@ export const ColorSwitcherDropdown = () => {
onMount(() => {
// Load saved color scheme from localStorage
const savedScheme = localStorage.getItem('trackeep-color-scheme');
const savedScheme = localStorage.getItem('colorScheme');
if (savedScheme) {
setCurrentScheme(savedScheme);
}
@@ -40,11 +40,15 @@ export const ColorSwitcherDropdown = () => {
setCurrentScheme(scheme.name);
// Save to localStorage for persistence
localStorage.setItem('trackeep-color-scheme', scheme.name);
localStorage.setItem('colorScheme', scheme.name);
// Apply only primary color to CSS variables
// Apply only primary color to CSS variables, preserve other colors
const root = document.documentElement;
// Get current theme to preserve background
const currentTheme = document.documentElement.getAttribute('data-kb-theme');
const isDark = currentTheme === 'dark';
// Convert hex to HSL for CSS variables
const hexToHsl = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
@@ -72,9 +76,19 @@ export const ColorSwitcherDropdown = () => {
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
// Apply only the primary color
root.style.setProperty('--primary', hexToHsl(scheme.primary));
root.style.setProperty('--colors-primary', hexToHsl(scheme.primary));
// Apply only primary color, preserve theme-based background
const hslColor = hexToHsl(scheme.primary);
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%');
} else {
root.style.setProperty('--background', '0 0% 100%');
root.style.setProperty('--colors-background', '0 0% 100%');
}
if (closeDropdown) {
setIsOpen(false);
+84 -1
View File
@@ -12,6 +12,7 @@ import {
IconGitPullRequest,
IconGitCommit
} from '@tabler/icons-solidjs';
import { isDemoMode } from '@/lib/demo-mode';
interface ActivityData {
date: string;
@@ -61,8 +62,90 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
});
};
const setDemoData = () => {
// Generate mock contribution data for the last year
const mockActivities: ActivityData[] = [];
const today = new Date();
for (let i = 364; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
// Random activity level (0-5), with higher probability of 0-2
const random = Math.random();
let level = 0;
if (random > 0.7) level = 1;
if (random > 0.85) level = 2;
if (random > 0.93) level = 3;
if (random > 0.97) level = 4;
if (random > 0.99) level = 5;
mockActivities.push({
date: date.toISOString().split('T')[0],
count: level,
level: level
});
}
// Calculate stats
const totalContributions = mockActivities.reduce((sum, day) => sum + day.count, 0);
const currentStreak = Math.floor(Math.random() * 15) + 5; // 5-20 days
const longestStreak = Math.floor(Math.random() * 30) + 20; // 20-50 days
// Mock recent events
const mockEvents: ActivityEvent[] = [
{
type: 'push',
title: 'Pushed 3 commits to trackeep/frontend',
date: '2 hours ago',
repo: 'trackeep',
action: 'pushed'
},
{
type: 'pull_request',
title: 'Opened PR: Add dark mode support',
date: '1 day ago',
repo: 'trackeep',
action: 'opened PR'
},
{
type: 'merge',
title: 'Merged PR: Fix responsive design issues',
date: '2 days ago',
repo: 'trackeep',
action: 'merged'
},
{
type: 'commit',
title: 'Commit: Update API documentation',
date: '3 days ago',
repo: 'trackeep',
action: 'committed'
},
{
type: 'push',
title: 'Pushed 5 commits to trackeep/backend',
date: '1 week ago',
repo: 'trackeep',
action: 'pushed'
}
];
setActivities(mockActivities);
setRecentEvents(mockEvents);
setStats({
totalContributions,
currentStreak,
longestStreak
});
};
onMount(() => {
setEmptyData();
if (isDemoMode()) {
setDemoData();
} else {
setEmptyData();
}
});
const getMonthLabels = () => {
+15 -9
View File
@@ -73,6 +73,7 @@ export const AuthProvider: ParentComponent = (props) => {
// Initialize auth state from localStorage
onMount(() => {
console.log('[Auth] onMount: Initializing auth state');
console.log('[Auth] onMount: Demo mode check:', isDemoMode());
// First check if demo mode should be cleared
if (!isDemoMode()) {
@@ -128,16 +129,24 @@ export const AuthProvider: ParentComponent = (props) => {
const mockToken = 'demo-token-' + Date.now();
console.log('[Auth] onMount: Setting mock auth state:', { mockUser, mockToken });
setAuthState({
user: mockUser,
token: mockToken,
isAuthenticated: true,
isLoading: false,
});
// Keep legacy token lookups working in demo mode across all pages.
localStorage.setItem('trackeep_token', mockToken);
localStorage.setItem('token', mockToken);
localStorage.setItem('trackeep_user', JSON.stringify(mockUser));
localStorage.setItem('user', JSON.stringify(mockUser));
// Apply theme
document.documentElement.setAttribute('data-kb-theme', 'dark');
document.title = 'Trackeep - Demo Mode';
console.log('[Auth] onMount: Demo mode setup complete');
});
@@ -159,15 +168,12 @@ export const AuthProvider: ParentComponent = (props) => {
const setAuth = (token: string, user: User) => {
console.log('[Auth] setAuth called with:', { token, user });
// Only store in localStorage if not in demo mode
if (!isDemoMode()) {
localStorage.setItem('trackeep_token', token);
localStorage.setItem('trackeep_user', JSON.stringify(user));
// Also set the legacy keys for compatibility
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
}
localStorage.setItem('trackeep_token', token);
localStorage.setItem('trackeep_user', JSON.stringify(user));
// Also set the legacy keys for compatibility
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
console.log('[Auth] setAuth: Updating auth state');
setAuthState({
File diff suppressed because it is too large Load Diff
+29 -1
View File
@@ -59,6 +59,24 @@ export interface MessageReference {
deep_link: string;
}
export interface MessageReferenceInput {
entity_type: string;
entity_id: number;
deep_link: string;
}
export interface MessageSendPayload {
body?: string;
attachments?: Array<{
kind: string;
file_id?: number;
url?: string;
title?: string;
}>;
metadata?: Record<string, unknown>;
references?: MessageReferenceInput[];
}
export interface MessageSuggestion {
id: number;
message_id: number;
@@ -159,7 +177,7 @@ export const messagesApi = {
apiRequest<{ messages: Message[]; next_cursor?: number }>(
`/conversations/${conversationId}/messages?limit=${limit}${cursor ? `&cursor=${cursor}` : ''}`
),
sendMessage: (conversationId: number, payload: any) => apiRequest<{ message: Message; warning?: string }>(
sendMessage: (conversationId: number, payload: MessageSendPayload) => apiRequest<{ message: Message; warning?: string }>(
`/conversations/${conversationId}/messages`,
{
method: 'POST',
@@ -285,6 +303,16 @@ export class MessagesRealtimeClient {
const token = getToken();
if (!token) return;
const isDemo =
import.meta.env.VITE_DEMO_MODE === 'true' ||
(window as any).ENV?.VITE_DEMO_MODE === 'true' ||
(window as any).importMetaEnv?.VITE_DEMO_MODE === 'true';
if (isDemo) {
// Demo mode uses mocked fetch handlers and does not provide a realtime WS backend.
this.onStatus?.('connected');
return;
}
const wsBase = API_BASE_URL.replace(/^http/, 'ws');
this.ws = new WebSocket(`${wsBase}/api/v1/messages/ws?token=${encodeURIComponent(token)}`);
+55 -35
View File
@@ -330,27 +330,32 @@ export const AIChat = () => {
}`}
>
<div
class={`max-w-[80%] rounded-lg p-4 ${
class={`max-w-[80%] rounded-2xl p-4 shadow-sm transition-all duration-200 hover:shadow-md ${
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
? '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 ${
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
<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" />
<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>
<p class="text-xs opacity-70 mt-2">
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</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>
@@ -360,15 +365,18 @@ export const AIChat = () => {
{isLoading() && (
<div class="flex justify-start">
<div class="bg-muted rounded-lg p-4 max-w-[80%]">
<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 flex items-center justify-center">
<Bot class="text-xs" />
<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 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 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>
@@ -386,22 +394,23 @@ export const AIChat = () => {
<div id="model-picker-container" class="relative">
<button
onClick={() => setShowModelPicker(!showModelPicker())}
class="flex items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/80 rounded-lg text-sm transition-colors border border-border/50"
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 ${showModelPicker() ? 'rotate-180' : ''}`} />
<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-background border rounded-lg shadow-lg z-50 p-2 max-h-96 overflow-y-auto">
<div class="p-2 border-b mb-2">
<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>
@@ -412,10 +421,10 @@ export const AIChat = () => {
setSelectedModel(model.id)
setShowModelPicker(false)
}}
class={`w-full text-left p-3 rounded-lg transition-colors ${
class={`w-full text-left p-3 rounded-lg transition-all duration-200 ${
selectedModel() === model.id
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-muted'
? '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">
@@ -447,7 +456,7 @@ export const AIChat = () => {
</div>
</div>
{selectedModel() === model.id && (
<div class="w-2 h-2 bg-primary rounded-full flex-shrink-0"></div>
<div class="w-2 h-2 bg-primary rounded-full animate-pulse flex-shrink-0"></div>
)}
</div>
</button>
@@ -457,22 +466,33 @@ export const AIChat = () => {
</Show>
</div>
<Input
value={inputMessage()}
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
placeholder="Type your message..."
class="flex-1"
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && inputMessage().trim()) {
handleSendMessage()
}
}}
/>
<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}
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>
+57 -1
View File
@@ -122,9 +122,64 @@ export const Analytics = () => {
const [error, setError] = createSignal<string | null>(null);
const [selectedPeriod, setSelectedPeriod] = createSignal('30');
const createFallbackAnalyticsData = (): AnalyticsData => ({
period: {
start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
end_date: new Date().toISOString(),
days: 30,
},
summary: {
hours_tracked: 0,
tasks_completed: 0,
bookmarks_added: 0,
notes_created: 0,
courses_completed: 0,
github_commits: 0,
},
analytics: [],
productivity_metrics: [],
learning_analytics: [],
github_analytics: [],
goals: [],
habit_analytics: [],
});
const normalizeAnalyticsData = (raw: any): AnalyticsData => {
const fallback = createFallbackAnalyticsData();
if (!raw || typeof raw !== 'object') {
return fallback;
}
const periodRaw = raw.period && typeof raw.period === 'object' ? raw.period : {};
const summaryRaw = raw.summary && typeof raw.summary === 'object' ? raw.summary : {};
return {
period: {
start_date: typeof periodRaw.start_date === 'string' ? periodRaw.start_date : fallback.period.start_date,
end_date: typeof periodRaw.end_date === 'string' ? periodRaw.end_date : fallback.period.end_date,
days: Number(periodRaw.days) || fallback.period.days,
},
summary: {
hours_tracked: Number(summaryRaw.hours_tracked) || 0,
tasks_completed: Number(summaryRaw.tasks_completed) || 0,
bookmarks_added: Number(summaryRaw.bookmarks_added) || 0,
notes_created: Number(summaryRaw.notes_created) || 0,
courses_completed: Number(summaryRaw.courses_completed) || 0,
github_commits: Number(summaryRaw.github_commits) || 0,
},
analytics: Array.isArray(raw.analytics) ? raw.analytics : [],
productivity_metrics: Array.isArray(raw.productivity_metrics) ? raw.productivity_metrics : [],
learning_analytics: Array.isArray(raw.learning_analytics) ? raw.learning_analytics : [],
github_analytics: Array.isArray(raw.github_analytics) ? raw.github_analytics : [],
goals: Array.isArray(raw.goals) ? raw.goals : [],
habit_analytics: Array.isArray(raw.habit_analytics) ? raw.habit_analytics : [],
};
};
const fetchAnalytics = async () => {
try {
setLoading(true);
setError(null);
const token = localStorage.getItem('token');
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/analytics/dashboard?days=${selectedPeriod()}`, {
headers: {
@@ -138,9 +193,10 @@ export const Analytics = () => {
}
const data = await response.json();
setAnalytics(data);
setAnalytics(normalizeAnalyticsData(data));
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setAnalytics(createFallbackAnalyticsData());
} finally {
setLoading(false);
}
+9 -41
View File
@@ -35,6 +35,11 @@ interface Bookmark {
}
export const Bookmarks = () => {
const getBookmarkInitial = (title?: string) => {
const safeTitle = typeof title === 'string' ? title.trim() : '';
return (safeTitle.charAt(0) || '?').toUpperCase();
};
const adaptBookmarkFromApi = (raw: any): Bookmark => {
const rawTags: BookmarkTag[] | string[] | undefined = raw.tags;
let tags: string[] = [];
@@ -65,24 +70,9 @@ export const Bookmarks = () => {
};
const getFaviconUrl = (bookmark: Bookmark) => {
if (bookmark.favicon) return bookmark.favicon;
try {
const url = new URL(bookmark.url);
const baseUrl = `${url.protocol}//${url.hostname}`;
// Try multiple favicon sources
const faviconSources = [
`${baseUrl}/favicon.ico`,
`${baseUrl}/favicon.png`,
`${baseUrl}/img/favicons/favicon-32x32.png`,
`${baseUrl}/img/favicons/favicon-16x16.png`,
`${baseUrl}/logo-without-border.svg`,
`${baseUrl}/logo.svg`,
`${baseUrl}/icon.svg`,
`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`
];
return faviconSources[0]; // Return first source, fallback will be handled by error
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`;
} catch {
return '';
}
@@ -481,35 +471,13 @@ export const Bookmarks = () => {
class="w-6 h-6 object-contain"
onError={(e) => {
const img = e.currentTarget;
const url = new URL(bookmark.url);
const baseUrl = `${url.protocol}//${url.hostname}`;
// Try next favicon source
const faviconSources = [
`${baseUrl}/favicon.ico`,
`${baseUrl}/favicon.png`,
`${baseUrl}/img/favicons/favicon-32x32.png`,
`${baseUrl}/img/favicons/favicon-16x16.png`,
`${baseUrl}/logo-without-border.svg`,
`${baseUrl}/logo.svg`,
`${baseUrl}/icon.svg`,
`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`
];
const currentSrc = img.src;
const currentIndex = faviconSources.findIndex(src => currentSrc.includes(src));
if (currentIndex < faviconSources.length - 1) {
img.src = faviconSources[currentIndex + 1];
} else {
img.style.display = 'none';
img.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${bookmark.title.charAt(0).toUpperCase()}</span>`;
}
img.style.display = 'none';
img.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${getBookmarkInitial(bookmark.title)}</span>`;
}}
/>
) : (
<span class="text-xs text-muted-foreground font-medium">
{bookmark.title.charAt(0).toUpperCase()}
{getBookmarkInitial(bookmark.title)}
</span>
)}
</div>
@@ -0,0 +1,667 @@
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 { toast } from '../components/ui/Toast';
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
interface APIKey {
id: number;
name: string;
permissions: string[];
is_active: boolean;
last_used?: string;
expires_at?: string;
created_at: string;
updated_at: string;
}
interface BrowserExtension {
id: number;
extension_id: string;
name: string;
is_active: boolean;
last_seen?: string;
created_at: string;
updated_at: string;
}
interface QuickStartGuide {
title: string;
description: string;
icon: any;
steps: string[];
}
interface Example {
title: string;
description: string;
code: string;
language: string;
}
const BrowserExtensionSettings = () => {
const [apiKeys, setApiKeys] = createSignal<APIKey[]>([]);
const [extensions, setExtensions] = createSignal<BrowserExtension[]>([]);
const [loading, setLoading] = createSignal(false);
const [showCreateKey, setShowCreateKey] = createSignal(false);
const [newKeyName, setNewKeyName] = createSignal('');
const [newKeyPermissions, setNewKeyPermissions] = createSignal<string[]>([
'bookmarks:read',
'bookmarks:write',
'files:read',
'files:write'
]);
const [activeTab, setActiveTab] = createSignal<'overview' | 'api-keys' | 'extensions' | 'examples'>('api-keys');
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" />,
steps: [
'Go to Settings → Browser Extension',
'Click "Generate New Key"',
'Choose permissions and name',
'Copy the generated key'
]
},
{
title: 'Configure Extension',
description: 'Set up your browser extension with the API key',
icon: <Settings class="w-5 h-5 text-green-600" />,
steps: [
'Install the Trackeep browser extension',
'Open extension options',
'Paste your API key',
'Test connection',
'Start saving bookmarks!'
]
},
{
title: 'Security Best Practices',
description: 'Keep your API keys secure and monitor usage',
icon: <Shield class="w-5 h-5 text-purple-600" />,
steps: [
'Use unique names for each key',
'Set expiration dates for temporary access',
'Revoke unused keys immediately',
'Monitor key usage regularly',
'Never share keys publicly'
]
}
];
const codeExamples: Example[] = [
{
title: 'JavaScript Extension',
description: 'Basic API key validation and bookmark creation',
code: `// Validate API key
const response = await fetch('/api/v1/browser-extension/validate', {
headers: {
'Authorization': 'Bearer tk_your_api_key_here'
}
});
// Create bookmark
const bookmarkData = {
title: 'My Awesome Bookmark',
url: 'https://example.com',
description: 'A useful website',
tags: ['development', 'tools']
};
const createResponse = await fetch('/api/v1/bookmarks', {
method: 'POST',
headers: {
'Authorization': 'Bearer tk_your_api_key_here',
'Content-Type': 'application/json'
},
body: JSON.stringify(bookmarkData)
});`,
language: 'javascript'
},
{
title: 'Python Integration',
description: 'Server-side API integration example',
code: `import requests
# Validate API key
response = requests.get(
'https://your-trackeep.com/api/v1/browser-extension/validate',
headers={'Authorization': 'Bearer tk_your_api_key_here'}
)
# Create bookmark
bookmark_data = {
'title': 'My Awesome Bookmark',
'url': 'https://example.com',
'description': 'A useful website',
'tags': ['development', 'tools']
}
response = requests.post(
'https://your-trackeep.com/api/v1/bookmarks',
json=bookmark_data,
headers={
'Authorization': 'Bearer tk_your_api_key_here',
'Content-Type': 'application/json'
}
)`,
language: 'python'
},
{
title: 'cURL Command',
description: 'Command line testing for API endpoints',
code: `# Validate API key
curl -X GET \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n https://your-trackeep.com/api/v1/browser-extension/validate
# Create bookmark
curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Content-Type: application/json" \\\n -d '{"title":"My Bookmark","url":"https://example.com"}' \\\n https://your-trackeep.com/api/v1/bookmarks`,
language: 'bash'
}
];
const availablePermissions = [
{ id: 'bookmarks:read', label: 'Read Bookmarks', description: 'View and read your bookmarks' },
{ id: 'bookmarks:write', label: 'Write Bookmarks', description: 'Create, edit, and delete bookmarks' },
{ id: 'files:read', label: 'Read Files', description: 'View and download your files' },
{ id: 'files:write', label: 'Write Files', description: 'Upload, edit, and delete files' },
{ id: 'notes:read', label: 'Read Notes', description: 'View your notes' },
{ id: 'notes:write', label: 'Write Notes', description: 'Create, edit, and delete notes' },
{ id: 'tasks:read', label: 'Read Tasks', description: 'View your tasks' },
{ id: 'tasks:write', label: 'Write Tasks', description: 'Create, edit, and delete tasks' }
];
const loadApiKeys = async () => {
try {
const response = await fetch('/api/v1/browser-extension/api-keys', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
setApiKeys(data);
} else {
toast.error('Failed to load API keys');
}
} catch (error) {
toast.error('Error loading API keys');
}
};
const loadExtensions = async () => {
try {
const response = await fetch('/api/v1/browser-extension/extensions', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
setExtensions(data);
} else {
toast.error('Failed to load extensions');
}
} catch (error) {
toast.error('Error loading extensions');
}
};
const createAPIKey = async () => {
if (!newKeyName().trim()) {
toast.error('Please enter a name for the API key');
return;
}
if (newKeyPermissions().length === 0) {
toast.error('Please select at least one permission');
return;
}
setLoading(true);
try {
const response = await fetch('/api/v1/browser-extension/api-keys/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
name: newKeyName(),
permissions: newKeyPermissions(),
expires_in: 365 // 1 year
})
});
if (response.ok) {
const data = await response.json();
toast.success(`API key "${data.name}" created successfully!`);
setNewKeyName('');
setNewKeyPermissions(['bookmarks:read', 'bookmarks:write', 'files:read', 'files:write']);
setShowCreateKey(false);
await loadApiKeys();
} else {
const error = await response.json();
toast.error(error.error || 'Failed to create API key');
}
} catch (error) {
toast.error('Error creating API key');
} finally {
setLoading(false);
}
};
const revokeAPIKey = async (keyId: number) => {
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/v1/browser-extension/api-keys/${keyId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
toast.success('API key revoked successfully');
await loadApiKeys();
} else {
const error = await response.json();
toast.error(error.error || 'Failed to revoke API key');
}
} catch (error) {
toast.error('Error revoking API key');
}
};
const revokeExtension = async (extensionId: string) => {
if (!confirm('Are you sure you want to revoke this extension? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/v1/browser-extension/extensions/${extensionId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
toast.success('Extension revoked successfully');
await loadExtensions();
} else {
const error = await response.json();
toast.error(error.error || 'Failed to revoke extension');
}
} catch (error) {
toast.error('Error revoking extension');
}
};
const togglePermission = (permissionId: string) => {
const current = newKeyPermissions();
if (current.includes(permissionId)) {
setNewKeyPermissions(current.filter(p => p !== permissionId));
} else {
setNewKeyPermissions([...current, permissionId]);
}
};
createEffect(() => {
loadApiKeys();
loadExtensions();
}, []);
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>
</div>
{/* Tab Navigation */}
<div class="border-b border-gray-200 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'
}`}
onClick={() => setActiveTab('overview')}
>
<Globe class="w-4 h-4 mr-2" />
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'
}`}
onClick={() => setActiveTab('api-keys')}
>
<Key class="w-4 h-4 mr-2" />
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'
}`}
onClick={() => setActiveTab('extensions')}
>
<Users class="w-4 h-4 mr-2" />
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'
}`}
onClick={() => setActiveTab('examples')}
>
<Shield class="w-4 h-4 mr-2" />
Examples
</button>
</nav>
</div>
{/* Overview Tab */}
<Show when={activeTab() === 'overview'}>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<For each={quickStartGuides}>
{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">
{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>
</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>
</div>
)}
</For>
</div>
</Card>
)}
</For>
</div>
<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" />
Security Status
</h3>
<div class="space-y-3">
<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">API Keys Secure</div>
<div class="text-sm text-gray-600">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>
</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>
</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" />
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>{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>{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>{extensions().length} connected</div>
</div>
</div>
</Card>
</div>
</Show>
{/* API Keys Section */}
<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">
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="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Key Name</label>
<Input
value={newKeyName()}
onInput={(e) => setNewKeyName((e.target as HTMLInputElement).value)}
placeholder="e.g., Chrome Extension, Laptop Backup"
class="w-full"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Permissions</label>
<div class="space-y-2">
<For each={availablePermissions}>
{permission => (
<label class="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={newKeyPermissions().includes(permission.id)}
onChange={() => togglePermission(permission.id)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<span class="font-medium">{permission.label}</span>
<span class="text-sm text-gray-500">{permission.description}</span>
</div>
</label>
)}
</For>
</div>
</div>
<div class="flex space-x-2">
<Button
onClick={() => setShowCreateKey(false)}
class="btn-secondary"
>
Cancel
</Button>
<Button
onClick={createAPIKey}
disabled={loading()}
class="btn-primary"
>
{loading() ? 'Creating...' : 'Create Key'}
</Button>
</div>
</div>
</Show>
<div class="space-y-3">
<For each={apiKeys()}>
{key => (
<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">{key.name}</h3>
<div class="text-sm text-gray-500 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">
{permission}
</span>
)}
</For>
</div>
{key.expires_at && (
<div class="text-sm text-orange-600">
Expires: {new Date(key.expires_at).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 ${
key.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{key.is_active ? 'Active' : 'Revoked'}
</span>
<Button
onClick={() => revokeAPIKey(key.id)}
class="btn-secondary btn-sm"
disabled={!key.is_active}
>
Revoke
</Button>
</div>
</div>
</div>
)}
</For>
</div>
</Card>
</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>
<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>
)}
</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>
</div>
</div>
)}
</For>
</div>
</Card>
{/* Examples Tab */}
<Show when={activeTab() === 'examples'}>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<For each={codeExamples}>
{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>
</div>
{example.title}
</h3>
<p class="text-gray-600 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">
<code>{example.code}</code>
</pre>
</div>
<div class="flex justify-between items-center mt-4">
<Button
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"
>
Test API
</Button>
</div>
</Card>
)}
</For>
</div>
</Show>
</div>
);
};
export default BrowserExtensionSettings;
-12
View File
@@ -368,18 +368,6 @@ export function Calendar() {
return (
<div class="space-y-6">
{/* Demo Mode Indicator */}
<Show when={isDemoModeEnabled()}>
<div class="bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-3 mb-4">
<p class="text-yellow-800 dark:text-yellow-200 text-sm font-medium">
Demo Mode Active - Showing sample calendar data
</p>
<p class="text-yellow-700 dark:text-yellow-300 text-xs mt-1">
Today: {todayEvents().length} events | Upcoming: {upcomingEvents().length} events | Deadlines: {deadlines().length}
</p>
</div>
</Show>
{/* Header with Current Time */}
<div class="flex items-center justify-between">
<div>
+105 -29
View File
@@ -57,6 +57,7 @@ const Chat = () => {
// Load AI providers and settings on mount
onMount(async () => {
parseChatDeeplink()
await loadAIProviders()
await loadAISettings()
})
@@ -181,7 +182,8 @@ const Chat = () => {
},
})
if (!response.ok) throw new Error('Failed to fetch sessions')
return response.json() as Promise<ChatSession[]>
const data = await response.json()
return Array.isArray(data) ? data : []
} catch (error) {
console.error('Failed to fetch sessions:', error)
return [] as ChatSession[]
@@ -192,6 +194,10 @@ const Chat = () => {
const [currentSessionId, setCurrentSessionId] = createSignal<string | null>(null)
const [messages, setMessages] = createSignal<ChatMessage[]>([])
const [deeplinkSessionId, setDeeplinkSessionId] = createSignal<string | null>(null)
const [deeplinkMessageId, setDeeplinkMessageId] = createSignal<number | null>(null)
const [deeplinkResolved, setDeeplinkResolved] = createSignal(false)
const [highlightedMessageId, setHighlightedMessageId] = createSignal<number | null>(null)
const [inputMessage, setInputMessage] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
const [showSettings, setShowSettings] = createSignal(false)
@@ -214,6 +220,23 @@ const Chat = () => {
{ id: 'content', label: 'Content Generation', icon: Sparkles, description: 'Generate content using AI assistance' }
]
const parseChatDeeplink = () => {
const params = new URLSearchParams(window.location.search)
const rawSession = (params.get('session') || '').trim()
const rawMessage = (params.get('message') || '').trim()
setDeeplinkSessionId(rawSession || null)
const parsedMessageId = Number(rawMessage)
setDeeplinkMessageId(Number.isFinite(parsedMessageId) && parsedMessageId > 0 ? parsedMessageId : null)
}
const scrollToHighlightedMessage = (messageId: number) => {
window.requestAnimationFrame(() => {
const element = document.getElementById(`chat-message-${messageId}`)
if (!element) return
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
const loadSessionMessages = async (sessionId: string) => {
try {
const token = getToken()
@@ -238,6 +261,18 @@ const Chat = () => {
}))
setMessages(parsedMessages)
const targetMessageId = deeplinkMessageId()
if (targetMessageId && String(deeplinkSessionId() || '') === sessionId) {
if (parsedMessages.some((message) => message.id === targetMessageId)) {
setHighlightedMessageId(targetMessageId)
scrollToHighlightedMessage(targetMessageId)
window.setTimeout(() => {
setHighlightedMessageId((current) => (current === targetMessageId ? null : current))
}, 6000)
} else {
setHighlightedMessageId(null)
}
}
} catch (error) {
console.error('Failed to load session messages:', error)
setMessages([])
@@ -246,7 +281,25 @@ const Chat = () => {
createEffect(() => {
const loadedSessions = sessions()
if (!loadedSessions || loadedSessions.length === 0 || currentSessionId()) {
if (!loadedSessions || loadedSessions.length === 0) {
return
}
if (!deeplinkResolved()) {
const requestedSessionId = deeplinkSessionId()
if (requestedSessionId) {
const matchingSession = loadedSessions.find((session) => String(session.id) === requestedSessionId)
if (matchingSession) {
setCurrentSessionId(requestedSessionId)
setDeeplinkResolved(true)
void loadSessionMessages(requestedSessionId)
return
}
}
setDeeplinkResolved(true)
}
if (currentSessionId()) {
return
}
@@ -770,15 +823,16 @@ const Chat = () => {
}`}
>
<div
class={`max-w-[80%] rounded-lg p-4 ${
id={`chat-message-${message.id}`}
class={`max-w-[80%] rounded-2xl p-4 shadow-sm transition-all duration-200 hover:shadow-md ${
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}
? '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'
} ${highlightedMessageId() === message.id ? 'ring-2 ring-primary/50 ring-offset-2 ring-offset-background' : ''}`}
>
<div class="flex items-start gap-3">
<div class={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
<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="w-4 h-4 text-xs" />
@@ -786,12 +840,20 @@ const Chat = () => {
<AIProviderIcon
providerId={selectedModel()}
size="1rem"
class="text-primary"
class="text-primary 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">
{new Date(message.created_at).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>
@@ -808,21 +870,22 @@ const Chat = () => {
<div class="relative">
<button
onClick={() => setShowModelPicker(!showModelPicker())}
class="flex items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/80 rounded-lg text-sm transition-colors border border-border/50"
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"
/>
<span class="text-sm font-medium">
{getAIModels().find(m => m.id === selectedModel())?.name || 'Select Model'}
</span>
<ChevronDown class={`h-4 w-4 transition-transform ${showModelPicker() ? 'rotate-180' : ''}`} />
<ChevronDown class={`h-4 w-4 transition-transform duration-200 ${showModelPicker() ? 'rotate-180' : ''}`} />
</button>
<Show when={showModelPicker()}>
<div class="absolute bottom-full left-0 mb-2 w-80 bg-background border rounded-lg shadow-lg z-50 p-2 max-h-96 overflow-y-auto">
<div class="p-2 border-b mb-2">
<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>
@@ -849,13 +912,13 @@ const Chat = () => {
<div class="font-medium text-sm">{model.name}</div>
<div class="text-xs text-muted-foreground mt-1">{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">
<span class="text-xs px-2 py-1 bg-gradient-to-r from-primary/10 to-primary/5 text-primary rounded-full border border-primary/20">
{model.provider}
</span>
<span class={`text-xs px-2 py-1 rounded-full ${
<span class={`text-xs px-2 py-1 rounded-full border ${
model.category === 'available'
? 'bg-green-10 text-green-600'
: 'bg-muted text-muted-foreground'
? 'bg-gradient-to-r from-green-50 to-green-100 text-green-700 border-green-200'
: 'bg-gradient-to-r from-muted/50 to-muted text-muted-foreground border-border/50'
}`}>
{model.category === 'available' ? 'Available' : 'Disabled'}
</span>
@@ -863,7 +926,9 @@ const Chat = () => {
</div>
</div>
{selectedModel() === model.id && (
<div class="w-2 h-2 bg-primary rounded-full"></div>
<div class="ml-2">
<div class="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
</div>
)}
</div>
</button>
@@ -873,22 +938,33 @@ const Chat = () => {
</Show>
</div>
<Input
value={inputMessage()}
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
placeholder="Type your message..."
class="flex-1"
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && inputMessage().trim()) {
handleSendMessage()
}
}}
/>
<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}
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>
+23 -7
View File
@@ -15,13 +15,21 @@ export const ColorSwitcher = () => {
const [schemes, setSchemes] = createSignal<ColorScheme[]>([]);
const [currentScheme, setCurrentScheme] = createSignal('default');
const [isDarkMode, setIsDarkMode] = createSignal(false);
const [customColors, setCustomColors] = createSignal({
primary: '#5ab9ff',
background: '#000000',
foreground: '#ffffff',
muted: '#262727',
border: '#262626'
});
// Initialize custom colors with proper defaults
const getInitialCustomColors = () => {
const currentTheme = document.documentElement.getAttribute('data-kb-theme');
const darkMode = currentTheme === 'dark';
return {
primary: '#5ab9ff',
background: darkMode ? '#1a1a1a' : '#ffffff',
foreground: darkMode ? '#ffffff' : '#000000',
muted: darkMode ? '#262727' : '#f5f5f5',
border: '#262626'
};
};
const [customColors, setCustomColors] = createSignal(getInitialCustomColors());
const [showAdvanced, setShowAdvanced] = createSignal(false);
const [savedSchemes, setSavedSchemes] = createSignal<ColorScheme[]>([]);
const [showPreview, setShowPreview] = createSignal(true);
@@ -144,6 +152,14 @@ export const ColorSwitcher = () => {
localStorage.setItem('theme', 'light');
}
// Update custom colors for new theme
setCustomColors(prev => ({
...prev,
background: newDarkMode ? '#1a1a1a' : '#ffffff',
foreground: newDarkMode ? '#ffffff' : '#000000',
muted: newDarkMode ? '#262727' : '#f5f5f5'
}));
// Update schemes with new theme
updateSchemesForTheme(newDarkMode);
};
+47 -1
View File
@@ -6,6 +6,8 @@ import { FileUpload } from '@/components/ui/FileUpload';
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { isDemoMode } from '@/lib/demo-mode';
import { getMockDocuments } from '@/lib/mockData';
import {
IconUpload,
IconEye,
@@ -52,6 +54,29 @@ export const Files = () => {
onMount(async () => {
try {
// Use demo data if in demo mode
if (isDemoMode()) {
const mockDocuments = getMockDocuments();
const mappedFiles: FileItem[] = mockDocuments.map((doc, index) => ({
id: index + 1,
name: doc.name,
size: parseFloat(doc.size) * 1024, // Convert KB to bytes for display
type: doc.type,
uploadedAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(), // Random time within last 30 days
description: doc.description,
tags: doc.tags.map(tag => tag.name),
url: '#',
isLink: false,
preview: doc.content,
downloadUrl: '#',
viewUrl: '#',
shareUrl: '#'
}));
setFiles(mappedFiles);
setIsLoading(false);
return;
}
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/files`, {
headers: {
@@ -84,7 +109,28 @@ export const Files = () => {
setFiles(mappedFiles);
} catch (error) {
console.error('Failed to load files:', error);
setFiles([]);
// Fallback to demo data if API fails
if (isDemoMode()) {
const mockDocuments = getMockDocuments();
const mappedFiles: FileItem[] = mockDocuments.map((doc, index) => ({
id: index + 1,
name: doc.name,
size: parseFloat(doc.size) * 1024,
type: doc.type,
uploadedAt: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
description: doc.description,
tags: doc.tags.map(tag => tag.name),
url: '#',
isLink: false,
preview: doc.content,
downloadUrl: '#',
viewUrl: '#',
shareUrl: '#'
}));
setFiles(mappedFiles);
} else {
setFiles([]);
}
} finally {
setIsLoading(false);
}
+2 -2
View File
@@ -137,7 +137,7 @@ export const GitHub = () => {
}
const data = await response.json();
const repos = data.repos || [];
const repos = Array.isArray(data) ? data : (Array.isArray(data?.repos) ? data.repos : []);
// Process real GitHub data
const languages = processLanguages(repos);
@@ -195,7 +195,7 @@ export const GitHub = () => {
const connectGitHub = () => {
// Redirect to centralized OAuth service
window.location.href = 'https://oauth.tdvorak.dev/auth/github?redirect_uri=' + encodeURIComponent(window.location.origin + '/api/v1/auth/oauth/callback');
window.location.href = 'https://oauth.trackeep.org/auth/github?redirect_uri=' + encodeURIComponent(window.location.origin + '/api/v1/auth/oauth/callback');
};
const disconnectGitHub = async () => {
+8 -10
View File
@@ -32,12 +32,17 @@ export const Login = () => {
onMount(async () => {
// Auto-fill demo credentials if in demo mode
if (isEnvDemoMode()) {
setIsLogin(true);
setFormData({
email: 'demo@trackeep.com',
password: 'demo123',
username: 'demo',
fullName: 'Demo User',
});
// Auto-login in demo mode
setTimeout(() => {
handleSubmit(new Event('submit') as any);
}, 1000);
return;
}
@@ -79,6 +84,9 @@ export const Login = () => {
}
} catch (err) {
console.warn('Failed to check if users exist:', err);
// Default to login mode if backend is unavailable
setIsLogin(true);
setRegistrationDisabled(true);
}
});
@@ -160,16 +168,6 @@ export const Login = () => {
{/* Demo Mode - Show only demo button */}
{isEnvDemoMode() ? (
<div class="space-y-6">
<div class="text-center">
<div class="mb-6 bg-green-500/10 border border-green-500/50 text-green-400 px-4 py-3 rounded">
<div class="flex items-center gap-2 mb-1">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
<span class="font-medium">Demo Mode Active</span>
</div>
<p class="text-xs">Experience Trackeep with mock data - no login required</p>
</div>
</div>
<button
type="button"
onClick={() => {
+296 -137
View File
@@ -1,11 +1,12 @@
/* Modern Messages Styling - Matching Chat.tsx Design System */
/* Main container with modern backdrop */
.messages-shell {
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 70%, hsl(var(--muted) / 0.18) 100%);
}
/* Screen management */
.messages-shell-list .messages-main {
display: none;
}
@@ -14,6 +15,7 @@
display: none;
}
/* Modern sidebar styling */
.messages-sidebar {
border-inline: 1px solid hsl(var(--border));
background: hsl(var(--card));
@@ -22,13 +24,16 @@
min-height: 0;
width: min(100%, 980px);
margin: 0 auto;
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1);
backdrop-filter: blur(10px);
}
.messages-sidebar-header {
padding: 0.9rem;
padding: 1.5rem;
border-bottom: 1px solid hsl(var(--border));
display: grid;
gap: 0.6rem;
background: hsl(var(--card) / 0.95);
backdrop-filter: blur(10px);
}
.messages-title-row,
@@ -37,64 +42,71 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
gap: 0.75rem;
}
.messages-title-wrap {
display: inline-flex;
align-items: center;
gap: 0.45rem;
gap: 0.75rem;
}
.messages-title {
font-size: 1.02rem;
font-weight: 650;
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.messages-status-row {
font-size: 0.7rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
letter-spacing: 0.02em;
letter-spacing: 0.025em;
}
/* Modern conversation list */
.messages-sidebar-list {
flex: 1;
overflow-y: auto;
padding: 0.55rem;
padding: 1rem;
display: grid;
gap: 0.35rem;
gap: 0.5rem;
align-content: start;
}
.messages-list-empty {
border: 1px dashed hsl(var(--border));
border-radius: 0.72rem;
border: 2px dashed hsl(var(--border));
border-radius: 1rem;
background: hsl(var(--muted) / 0.3);
padding: 1rem;
padding: 2rem;
text-align: center;
font-size: 0.82rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
/* Modern conversation items */
.conversation-item {
border: 1px solid transparent;
border-radius: 0.72rem;
padding: 0.58rem 0.65rem;
border-radius: 0.75rem;
padding: 1rem;
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
transition: background-color 120ms ease, border-color 120ms ease;
gap: 0.75rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.conversation-item:hover {
background: hsl(var(--muted) / 0.6);
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.08);
}
.conversation-item-active {
background: hsl(var(--primary) / 0.14);
border-color: hsl(var(--primary) / 0.45);
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.2);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.08);
}
.conversation-item-main {
@@ -102,35 +114,39 @@
}
.conversation-item-name {
font-size: 0.84rem;
font-weight: 620;
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: hsl(var(--foreground));
}
.conversation-item-preview {
font-size: 0.72rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.25rem;
}
.conversation-item-unread {
min-width: 1.2rem;
height: 1.2rem;
min-width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.68rem;
font-weight: 650;
padding: 0 0.25rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0 0.5rem;
box-shadow: 0 2px 4px hsl(var(--primary) / 0.2);
}
/* Modern main conversation area */
.messages-main {
width: min(100%, 1180px);
margin: 0 auto;
@@ -140,21 +156,26 @@
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
border-inline: 1px solid hsl(var(--border));
background: hsl(var(--card));
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1);
backdrop-filter: blur(10px);
}
.messages-main-header {
border-bottom: 1px solid hsl(var(--border));
padding: 0.85rem 1rem;
padding: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
gap: 1rem;
background: hsl(var(--card) / 0.95);
backdrop-filter: blur(10px);
}
.messages-header-main {
display: inline-flex;
align-items: center;
gap: 0.45rem;
gap: 0.75rem;
min-width: 0;
}
@@ -167,56 +188,63 @@
}
.messages-header-title {
font-size: 1rem;
font-weight: 650;
font-size: 1.125rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: hsl(var(--foreground));
}
.messages-header-subtitle {
color: hsl(var(--muted-foreground));
font-size: 0.72rem;
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.25rem;
}
.messages-header-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
gap: 0.75rem;
}
.messages-main-empty {
display: grid;
place-items: center;
color: hsl(var(--muted-foreground));
font-size: 0.85rem;
padding: 1.5rem;
font-size: 0.875rem;
padding: 3rem;
}
/* Modern call and transcript strips */
.messages-call-strip,
.messages-transcript-preview {
padding: 0.55rem 1rem;
padding: 1rem 1.5rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
gap: 1rem;
background: hsl(var(--muted) / 0.3);
}
/* Modern message timeline */
.messages-timeline {
padding: 1rem;
padding: 1.5rem;
overflow-y: auto;
display: grid;
gap: 0.8rem;
gap: 1.5rem;
background: hsl(var(--background));
}
.message-row {
display: flex;
margin-bottom: 0.5rem;
}
.message-row-me {
@@ -227,35 +255,44 @@
justify-content: flex-start;
}
/* Modern message bubbles */
.message-bubble {
max-width: min(76%, 900px);
border-radius: 0.95rem;
max-width: min(76%, 600px);
border-radius: 1rem;
border: 1px solid hsl(var(--border));
padding: 0.68rem 0.74rem;
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.08);
padding: 1rem;
box-shadow: 0 2px 8px hsl(0 0% 0% / 0.06);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.message-bubble:hover {
transform: translateY(-1px);
box-shadow: 0 8px 16px hsl(0 0% 0% / 0.08);
}
.message-bubble-me {
background: hsl(var(--primary) / 0.17);
border-color: hsl(var(--primary) / 0.48);
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.9) 100%);
border-color: hsl(var(--primary) / 0.3);
color: hsl(var(--primary-foreground));
}
.message-bubble-them {
background: hsl(var(--card));
background: linear-gradient(135deg, hsl(var(--muted)) 0%, hsl(var(--muted) / 0.9) 100%);
border-color: hsl(var(--border) / 0.5);
}
.message-meta {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.72rem;
gap: 0.75rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin-bottom: 0.35rem;
margin-bottom: 0.75rem;
}
.message-avatar {
width: 1.4rem;
height: 1.4rem;
width: 2rem;
height: 2rem;
border-radius: 999px;
overflow: hidden;
background: hsl(var(--muted));
@@ -263,8 +300,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
font-size: 0.75rem;
font-weight: 700;
border: 2px solid hsl(var(--primary) / 0.2);
}
.message-time {
@@ -272,49 +310,40 @@
}
.message-edited {
font-size: 0.63rem;
font-size: 0.625rem;
opacity: 0.7;
}
.message-body {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.32rem;
font-size: 0.9rem;
}
.message-sensitive-banner {
margin-top: 0.55rem;
border-radius: 0.52rem;
padding: 0.42rem 0.5rem;
border: 1px solid hsl(var(--warning) / 0.4);
background: hsl(var(--warning) / 0.12);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.73rem;
line-height: 1.5;
font-size: 0.875rem;
}
/* Modern attachments */
.message-attachments {
margin-top: 0.6rem;
margin-top: 1rem;
display: grid;
gap: 0.4rem;
gap: 0.5rem;
}
.message-attachment-link,
.message-voice-note {
border: 1px solid hsl(var(--border));
border-radius: 0.55rem;
padding: 0.45rem 0.55rem;
border-radius: 0.5rem;
padding: 0.75rem;
display: flex;
align-items: center;
gap: 0.45rem;
gap: 0.5rem;
font-size: 0.75rem;
text-decoration: none;
transition: all 0.2s ease;
}
.message-attachment-link:hover {
background: hsl(var(--muted) / 0.55);
background: hsl(var(--muted) / 0.6);
transform: translateY(-1px);
}
.message-voice-note {
@@ -322,127 +351,159 @@
align-items: flex-start;
}
/* Modern references */
.message-reference-wrap {
margin-top: 0.5rem;
margin-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
gap: 0.5rem;
}
.message-reference-pill {
border-radius: 999px;
border: 1px solid hsl(var(--border));
padding: 0.14rem 0.45rem;
font-size: 0.68rem;
padding: 0.25rem 0.75rem;
font-size: 0.625rem;
color: hsl(var(--muted-foreground));
text-decoration: none;
transition: all 0.2s ease;
background: hsl(var(--muted) / 0.5);
}
.message-reference-pill:hover {
background: hsl(var(--muted) / 0.8);
transform: translateY(-1px);
}
/* Modern suggestions */
.message-suggestions {
margin-top: 0.6rem;
margin-top: 1rem;
display: grid;
gap: 0.45rem;
gap: 0.75rem;
}
.message-suggestion-card {
border: 1px solid hsl(var(--border));
border-radius: 0.55rem;
padding: 0.45rem;
background: hsl(var(--muted) / 0.35);
border-radius: 0.5rem;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
transition: all 0.2s ease;
}
.message-suggestion-card:hover {
background: hsl(var(--muted) / 0.5);
transform: translateY(-1px);
}
.message-suggestion-title {
font-size: 0.72rem;
font-size: 0.75rem;
text-transform: capitalize;
margin-bottom: 0.35rem;
margin-bottom: 0.75rem;
font-weight: 600;
}
.message-suggestion-actions {
display: flex;
gap: 0.4rem;
gap: 0.5rem;
}
/* Modern reactions */
.message-reaction-panel {
margin-top: 0.62rem;
margin-top: 1rem;
display: grid;
gap: 0.35rem;
gap: 0.5rem;
}
.message-reaction-add-row {
display: inline-flex;
align-items: center;
gap: 0.2rem;
gap: 0.25rem;
}
.reaction-add-btn {
width: 1.6rem;
height: 1.6rem;
border-radius: 0.4rem;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
transition: all 0.2s ease;
}
.reaction-add-btn:hover {
border-color: hsl(var(--border));
background: hsl(var(--muted) / 0.55);
background: hsl(var(--muted) / 0.6);
color: hsl(var(--foreground));
transform: scale(1.1);
}
.message-reaction-summary {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
gap: 0.25rem;
}
.reaction-pill {
border-radius: 999px;
border: 1px solid hsl(var(--border));
padding: 0.15rem 0.45rem;
padding: 0.25rem 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.68rem;
font-size: 0.625rem;
background: hsl(var(--card));
transition: all 0.2s ease;
}
.reaction-pill:hover {
transform: scale(1.1);
}
.reaction-pill-me {
border-color: hsl(var(--primary) / 0.55);
background: hsl(var(--primary) / 0.16);
border-color: hsl(var(--primary) / 0.5);
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
}
/* Modern composer */
.messages-composer {
position: relative;
border-top: 1px solid hsl(var(--border));
padding: 0.72rem 1rem 0.9rem;
padding: 1.5rem;
display: grid;
gap: 0.5rem;
background: hsl(var(--card));
gap: 0.75rem;
background: hsl(var(--card) / 0.95);
backdrop-filter: blur(10px);
}
.messages-composer-drag {
background: hsl(var(--primary) / 0.08);
border-color: hsl(var(--primary) / 0.2);
}
.messages-typing-line {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.73rem;
gap: 0.75rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
padding: 0.5rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
margin-bottom: 0.5rem;
}
.typing-dots {
display: inline-flex;
align-items: center;
gap: 0.22rem;
gap: 0.25rem;
}
.typing-dots span {
width: 0.28rem;
height: 0.28rem;
width: 0.25rem;
height: 0.25rem;
border-radius: 999px;
background: hsl(var(--primary));
animation: typingBounce 1.1s infinite ease-in-out;
@@ -467,21 +528,29 @@
}
}
/* Modern composer elements */
.composer-chip-wrap {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.composer-chip {
border-radius: 999px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.45);
padding: 0.18rem 0.36rem;
background: hsl(var(--muted) / 0.4);
padding: 0.25rem 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.28rem;
font-size: 0.68rem;
gap: 0.25rem;
font-size: 0.625rem;
transition: all 0.2s ease;
}
.composer-chip:hover {
background: hsl(var(--muted) / 0.6);
transform: translateY(-1px);
}
.composer-chip-remove {
@@ -489,18 +558,30 @@
align-items: center;
justify-content: center;
border-radius: 999px;
cursor: pointer;
transition: all 0.2s ease;
}
.composer-chip-remove:hover {
background: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
.messages-recording-line {
color: hsl(var(--destructive));
font-size: 0.73rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.75rem;
background: hsl(var(--destructive) / 0.1);
border-radius: 0.5rem;
margin-bottom: 0.5rem;
border: 1px solid hsl(var(--destructive) / 0.2);
}
.messages-composer-row {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
gap: 0.45rem;
gap: 0.75rem;
align-items: end;
}
@@ -509,16 +590,17 @@
}
.messages-composer-textarea {
min-height: 2.6rem;
min-height: 3rem;
max-height: 9rem;
width: 100%;
resize: none;
border: 1px solid hsl(var(--border));
border-radius: 0.62rem;
border-radius: 0.75rem;
background: hsl(var(--background));
padding: 0.58rem 0.64rem;
font-size: 0.88rem;
padding: 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
transition: all 0.2s ease;
}
.messages-composer-textarea:focus {
@@ -527,24 +609,26 @@
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15);
}
/* Modern mention menu */
.mention-menu {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + 0.4rem);
bottom: calc(100% + 0.75rem);
border: 1px solid hsl(var(--border));
border-radius: 0.65rem;
border-radius: 0.75rem;
background: hsl(var(--card));
box-shadow: 0 8px 26px hsl(0 0% 0% / 0.24);
max-height: 16rem;
overflow-y: auto;
z-index: 20;
backdrop-filter: blur(10px);
}
.mention-menu-empty {
padding: 0.55rem 0.65rem;
padding: 0.75rem;
color: hsl(var(--muted-foreground));
font-size: 0.74rem;
font-size: 0.75rem;
}
.mention-option {
@@ -552,15 +636,17 @@
text-align: left;
border: none;
background: transparent;
padding: 0.52rem 0.62rem;
padding: 0.75rem;
display: flex;
align-items: center;
gap: 0.45rem;
gap: 0.75rem;
transition: all 0.2s ease;
cursor: pointer;
}
.mention-option-active,
.mention-option:hover {
background: hsl(var(--muted) / 0.65);
background: hsl(var(--muted) / 0.6);
}
.mention-option-copy {
@@ -568,15 +654,15 @@
}
.mention-option-title {
font-size: 0.79rem;
font-weight: 620;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mention-option-sub {
font-size: 0.68rem;
font-size: 0.625rem;
color: hsl(var(--muted-foreground));
}
@@ -584,22 +670,25 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
gap: 1rem;
margin-top: 0.75rem;
}
.messages-inline-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
gap: 0.5rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
/* Responsive design */
@media (max-width: 980px) {
.messages-sidebar,
.messages-main {
width: 100%;
border-inline: none;
border-radius: 0;
}
.message-bubble {
@@ -609,7 +698,7 @@
@media (max-width: 760px) {
.messages-main {
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
grid-template-rows: auto auto auto auto minmax(0, 1fr) auto;
}
.messages-composer-row {
@@ -620,4 +709,74 @@
grid-column: 3;
justify-self: end;
}
.messages-timeline {
padding: 1rem;
}
.message-bubble {
padding: 0.75rem;
}
.conversation-item {
padding: 0.75rem;
}
}
/* Modern animations and transitions */
.message-bubble,
.conversation-item,
.message-attachment-link,
.message-reference-pill,
.message-suggestion-card,
.reaction-pill,
.composer-chip,
.mention-option {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Focus states */
.messages-composer-textarea:focus,
.mention-option:focus {
outline: 2px solid hsl(var(--primary));
outline-offset: 2px;
}
/* Loading states */
.messages-composer-textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Dark mode enhancements */
[data-kb-theme="dark"] .message-bubble-them {
background: linear-gradient(135deg, hsl(var(--muted)) 0%, hsl(var(--muted) / 0.8) 100%);
border-color: hsl(var(--border) / 0.3);
}
[data-kb-theme="dark"] .conversation-item:hover {
background: hsl(var(--muted) / 0.4);
}
/* Accessibility improvements */
.message-bubble:focus,
.conversation-item:focus,
.mention-option:focus {
outline: 2px solid hsl(var(--primary));
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.message-bubble {
border-width: 2px;
}
.conversation-item {
border-width: 2px;
}
.reaction-pill {
border-width: 2px;
}
}
+547 -20
View File
@@ -11,6 +11,7 @@ import type {
ConversationListItem,
ConversationMember,
Message,
MessageReferenceInput,
MessageSuggestion,
UserFile,
} from '@/lib/messages';
@@ -63,7 +64,46 @@ interface MentionFileOption {
label: string;
}
type MentionOption = MentionUserOption | MentionFileOption;
interface AIShareSessionOption {
id: number;
title: string;
message_count: number;
created_at: string;
last_message_at?: string;
}
interface AIShareMessageOption {
id: number;
session_id: number;
content: string;
role: 'user' | 'assistant';
created_at: string;
}
interface ComposerAIReference {
entity_type: 'ai_chat_session' | 'ai_chat_message';
entity_id: number;
deep_link: string;
title: string;
subtitle: string;
mention_token: string;
attachment_title: string;
}
interface MentionAISessionOption {
type: 'ai_chat_session';
session: AIShareSessionOption;
label: string;
}
interface MentionAIMessageOption {
type: 'ai_chat_message';
session: AIShareSessionOption;
message: AIShareMessageOption;
label: string;
}
type MentionOption = MentionUserOption | MentionFileOption | MentionAISessionOption | MentionAIMessageOption;
interface ComposerLibraryFile {
id: number;
@@ -71,6 +111,11 @@ interface ComposerLibraryFile {
mime_type: string;
}
interface AIProviderOption {
id: string;
name: string;
}
type ReactionKey = 'thumb_up' | 'heart' | 'bolt' | 'check' | 'sparkles';
const REACTION_PRESETS: Array<{ key: ReactionKey; label: string; icon: any }> = [
@@ -105,6 +150,8 @@ const REFERENCE_TYPE_OPTIONS = [
'learning_path',
'saved_search',
'github',
'ai_chat_session',
'ai_chat_message',
];
type TriStateFilter = 'any' | 'yes' | 'no';
@@ -172,6 +219,18 @@ export const Messages = () => {
const [isDragOverComposer, setIsDragOverComposer] = createSignal(false);
const [revealedSensitiveMessages, setRevealedSensitiveMessages] = createSignal<Record<number, string>>({});
const [uploadProgress, setUploadProgress] = createSignal<{ done: number; total: number } | null>(null);
const [aiReferenceEnabled, setAiReferenceEnabled] = createSignal(false);
const [aiReferenceModel, setAiReferenceModel] = createSignal('longcat');
const [aiReferenceContext, setAiReferenceContext] = createSignal('');
const [aiProviders, setAiProviders] = createSignal<AIProviderOption[]>([]);
const [aiSettings, setAiSettings] = createSignal<Record<string, { enabled?: boolean; model?: string; model_thinking?: string }>>({});
const [showAiSharePicker, setShowAiSharePicker] = createSignal(false);
const [aiShareSessions, setAiShareSessions] = createSignal<AIShareSessionOption[]>([]);
const [aiShareMessagesBySession, setAiShareMessagesBySession] = createSignal<Record<number, AIShareMessageOption[]>>({});
const [aiShareSelectedSessionId, setAiShareSelectedSessionId] = createSignal<number | null>(null);
const [aiShareLoadingSessions, setAiShareLoadingSessions] = createSignal(false);
const [aiShareLoadingMessages, setAiShareLoadingMessages] = createSignal(false);
const [composerAiReferences, setComposerAiReferences] = createSignal<ComposerAIReference[]>([]);
const getCurrentUserId = () => {
const raw = localStorage.getItem('trackeep_user') || localStorage.getItem('user');
@@ -326,6 +385,69 @@ export const Messages = () => {
setSearchMentionOnly(false);
};
const referenceKey = (ref: { entity_type: string; entity_id: number }) => `${ref.entity_type}:${ref.entity_id}`;
const sanitizeMentionToken = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 40) || 'ai-reference';
const availableAiReferenceModels = () => {
const configured = Object.entries(aiSettings())
.filter(([, config]) => config?.enabled)
.map(([provider]) => provider);
const providerIds = aiProviders().map((provider) => provider.id);
const combined = Array.from(new Set([...configured, ...providerIds]));
if (combined.length === 0) {
return [aiReferenceModel() || 'longcat'];
}
return combined;
};
const buildAiSessionReference = (session: AIShareSessionOption): ComposerAIReference => {
const token = `@ai-session:${sanitizeMentionToken(session.title || `session-${session.id}`)}`;
return {
entity_type: 'ai_chat_session',
entity_id: session.id,
deep_link: `/app/chat?session=${session.id}`,
title: session.title || `AI Session #${session.id}`,
subtitle: `${session.message_count} messages`,
mention_token: token,
attachment_title: `AI session: ${session.title || `#${session.id}`}`,
};
};
const buildAiMessageReference = (session: AIShareSessionOption, message: AIShareMessageOption): ComposerAIReference => {
const preview = (message.content || '').trim().slice(0, 64) || `Message #${message.id}`;
const token = `@ai-msg:${sanitizeMentionToken(preview)}`;
return {
entity_type: 'ai_chat_message',
entity_id: message.id,
deep_link: `/app/chat?session=${session.id}&message=${message.id}`,
title: `${session.title || `Session #${session.id}`}`,
subtitle: preview,
mention_token: token,
attachment_title: `AI message: ${preview}`,
};
};
const addComposerAiReference = (next: ComposerAIReference) => {
setComposerAiReferences((prev) => {
if (prev.some((entry) => referenceKey(entry) === referenceKey(next))) {
return prev;
}
return [...prev, next];
});
};
const removeComposerAiReference = (entityType: ComposerAIReference['entity_type'], entityId: number) => {
setComposerAiReferences((prev) =>
prev.filter((entry) => !(entry.entity_type === entityType && entry.entity_id === entityId))
);
};
const activeTypingUserNames = () => {
const conversationID = selectedConversationId();
if (!conversationID) return [] as string[];
@@ -361,6 +483,7 @@ export const Messages = () => {
};
const refreshMentionOptions = (query: string) => {
const normalizedQuery = query.trim().toLowerCase();
const conversationUserOptions: MentionUserOption[] = conversationMembers()
.map((member) => {
const username = (member.user?.username || '').trim();
@@ -375,14 +498,48 @@ export const Messages = () => {
.filter((option) => option.id !== currentUserId())
.filter((option, index, arr) => arr.findIndex((entry) => entry.id === option.id) === index)
.filter((option) =>
!query ? true : `${option.label} ${option.username}`.toLowerCase().includes(query)
!normalizedQuery ? true : `${option.label} ${option.username}`.toLowerCase().includes(normalizedQuery)
)
.slice(0, 6);
if (!aiShareSessions().length && (normalizedQuery.startsWith('ai') || normalizedQuery.startsWith('chat'))) {
void loadAIShareSessions();
}
const aiSessionOptions: MentionAISessionOption[] = aiShareSessions()
.filter((session) =>
!normalizedQuery
? true
: `${session.title} ai session ${session.id}`.toLowerCase().includes(normalizedQuery)
)
.slice(0, 3)
.map((session) => ({
type: 'ai_chat_session',
session,
label: `AI Session: ${session.title}`,
}));
const aiMessageOptions: MentionAIMessageOption[] = aiShareSessions()
.slice(0, 4)
.flatMap((session) =>
(aiShareMessagesBySession()[session.id] || []).slice(0, 8).map((message) => ({
type: 'ai_chat_message' as const,
session,
message,
label: `AI Message: ${(message.content || '').slice(0, 48) || `#${message.id}`}`,
}))
)
.filter((option) =>
!normalizedQuery
? true
: `${option.session.title} ${option.message.content} ai message`.toLowerCase().includes(normalizedQuery)
)
.slice(0, 3);
setMentionLoading(true);
const token = ++mentionSearchToken;
void messagesApi
.listUserFiles(query, 8)
.listUserFiles(normalizedQuery, 8)
.then((files) => {
if (token !== mentionSearchToken) return;
const fileOptions: MentionFileOption[] = (files || []).map((file) => ({
@@ -390,13 +547,13 @@ export const Messages = () => {
file,
label: file.original_name || 'Attachment',
}));
const merged: MentionOption[] = [...conversationUserOptions, ...fileOptions];
const merged: MentionOption[] = [...conversationUserOptions, ...fileOptions, ...aiSessionOptions, ...aiMessageOptions];
setMentionOptions(merged);
setMentionHighlightedIndex(0);
})
.catch(() => {
if (token !== mentionSearchToken) return;
setMentionOptions(conversationUserOptions);
setMentionOptions([...conversationUserOptions, ...aiSessionOptions, ...aiMessageOptions]);
})
.finally(() => {
if (token === mentionSearchToken) {
@@ -590,11 +747,19 @@ export const Messages = () => {
}
};
const postMessage = async (body: string, attachments: any[], explicitConversationId?: number) => {
const postMessage = async (
body: string,
attachments: any[],
explicitConversationId?: number,
options?: {
metadata?: Record<string, unknown>;
references?: MessageReferenceInput[];
}
) => {
const conversationID = explicitConversationId || selectedConversationId();
if (!conversationID) return;
const trimmedBody = body.trim();
if (!trimmedBody && attachments.length === 0) return;
if (!trimmedBody && attachments.length === 0 && (options?.references || []).length === 0) return;
markTypingStopped();
setSendingMessage(true);
@@ -602,6 +767,8 @@ export const Messages = () => {
const response = await messagesApi.sendMessage(conversationID, {
body: trimmedBody,
attachments,
metadata: options?.metadata,
references: options?.references,
});
const created = response.message;
@@ -657,7 +824,7 @@ export const Messages = () => {
const extension = recorder.mimeType.includes('ogg') ? 'ogg' : recorder.mimeType.includes('mp4') ? 'm4a' : 'webm';
const file = new File([blob], `voice-note-${Date.now()}.${extension}`, { type: recorder.mimeType || 'audio/webm' });
const uploaded = await uploadChatFile(file);
const attachments = [{
const attachments: Array<{ kind: string; file_id?: number; title?: string; url?: string }> = [{
kind: 'voice_note',
file_id: uploaded.id,
title: uploaded.original_name || 'Voice note',
@@ -667,9 +834,36 @@ export const Messages = () => {
const transcript = `${voiceFinalTranscript} ${voiceInterimTranscript}`.trim();
const transcriptBody = transcript ? `Transcript (local): ${transcript}` : '';
const composedBody = [inputText().trim(), transcriptBody].filter(Boolean).join('\n\n');
const references: MessageReferenceInput[] = composerAiReferences().map((reference) => ({
entity_type: reference.entity_type,
entity_id: reference.entity_id,
deep_link: reference.deep_link,
}));
for (const reference of composerAiReferences()) {
attachments.push({
kind: 'activity',
title: reference.attachment_title,
url: reference.deep_link,
});
}
const metadata =
aiReferenceEnabled() || references.length > 0 || aiReferenceContext().trim()
? {
ai_reference: {
enabled: aiReferenceEnabled(),
provider: aiReferenceModel(),
context: aiReferenceContext().trim(),
references,
},
}
: undefined;
await postMessage(composedBody, attachments, conversationID);
await postMessage(composedBody, attachments, conversationID, {
metadata,
references,
});
setInputText('');
setComposerAiReferences([]);
if (transcriptBody) {
toast.success('Voice note sent', 'Local transcript attached.');
} else {
@@ -1050,6 +1244,117 @@ export const Messages = () => {
}
};
const loadAIProviders = async () => {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
try {
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/ai/providers`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return;
const data = await res.json();
const providers = (data.providers || []) as Array<{ id?: string; name?: string }>;
const mapped = providers
.map((provider) => ({
id: (provider.id || '').trim(),
name: (provider.name || provider.id || 'AI provider').trim(),
}))
.filter((provider) => provider.id);
setAiProviders(mapped);
} catch {
// ignore provider loading failures
}
};
const loadAISettings = async () => {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
try {
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return;
const data = await res.json();
if (!data || typeof data !== 'object') return;
const normalized: Record<string, { enabled?: boolean; model?: string; model_thinking?: string }> = {};
for (const [provider, raw] of Object.entries(data as Record<string, unknown>)) {
if (!raw || typeof raw !== 'object') continue;
const cfg = raw as Record<string, unknown>;
normalized[provider] = {
enabled: Boolean(cfg.enabled),
model: typeof cfg.model === 'string' ? cfg.model : undefined,
model_thinking: typeof cfg.model_thinking === 'string' ? cfg.model_thinking : undefined,
};
}
setAiSettings(normalized);
const firstEnabledProvider = Object.entries(normalized).find(([, cfg]) => cfg.enabled)?.[0];
if (firstEnabledProvider) {
setAiReferenceModel(firstEnabledProvider);
}
} catch {
// ignore settings loading failures
}
};
const loadAIShareSessions = async () => {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
setAiShareLoadingSessions(true);
try {
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error('Failed to load AI sessions');
}
const data = (await res.json()) as Array<Record<string, unknown>>;
const mapped: AIShareSessionOption[] = (Array.isArray(data) ? data : []).map((session) => ({
id: Number(session.id || 0),
title: typeof session.title === 'string' && session.title.trim() ? session.title : `Session #${session.id}`,
message_count: Number(session.message_count || 0),
created_at: typeof session.created_at === 'string' ? session.created_at : new Date().toISOString(),
last_message_at: typeof session.last_message_at === 'string' ? session.last_message_at : undefined,
})).filter((session) => session.id > 0);
setAiShareSessions(mapped);
if (mapped.length > 0 && !aiShareSelectedSessionId()) {
setAiShareSelectedSessionId(mapped[0].id);
void loadAIShareMessages(mapped[0].id);
}
} catch {
toast.error('Failed to load AI sessions');
} finally {
setAiShareLoadingSessions(false);
}
};
const loadAIShareMessages = async (sessionId: number) => {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
if (!sessionId) return;
if (aiShareMessagesBySession()[sessionId]) return;
setAiShareLoadingMessages(true);
try {
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions/${sessionId}/messages`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error('Failed to load AI messages');
}
const data = (await res.json()) as Array<Record<string, unknown>>;
const mapped: AIShareMessageOption[] = (Array.isArray(data) ? data : []).map((message) => ({
id: Number(message.id || 0),
session_id: sessionId,
content: typeof message.content === 'string' ? message.content : '',
role: (message.role === 'assistant' ? 'assistant' : 'user') as 'assistant' | 'user',
created_at: typeof message.created_at === 'string' ? message.created_at : new Date().toISOString(),
})).filter((message) => message.id > 0);
setAiShareMessagesBySession((prev) => ({
...prev,
[sessionId]: mapped,
}));
} catch {
toast.error('Failed to load AI messages');
} finally {
setAiShareLoadingMessages(false);
}
};
const handleWsEvent = (event: any) => {
const eventType = event?.type || '';
const eventConversationId = Number(event?.conversation_id || event?.data?.conversation_id || 0);
@@ -1223,7 +1528,7 @@ export const Messages = () => {
if (option.type === 'user') {
inserted = `@${option.username} `;
} else {
} else if (option.type === 'file') {
const readableFileName = (option.file.original_name || 'file').trim() || 'file';
inserted = `@${readableFileName.replace(/\s+/g, '_')} `;
setAttachedLibraryFiles((prev) =>
@@ -1238,6 +1543,14 @@ export const Messages = () => {
},
]
);
} else if (option.type === 'ai_chat_session') {
const reference = buildAiSessionReference(option.session);
inserted = `${reference.mention_token} `;
addComposerAiReference(reference);
} else {
const reference = buildAiMessageReference(option.session, option.message);
inserted = `${reference.mention_token} `;
addComposerAiReference(reference);
}
const updated = `${currentText.slice(0, range.start)}${inserted}${currentText.slice(range.end)}`;
@@ -1298,7 +1611,7 @@ export const Messages = () => {
const sendMessage = async () => {
if (!selectedConversationId()) return;
const body = inputText().trim();
if (!body && selectedFiles().length === 0 && attachedLibraryFiles().length === 0) return;
if (!body && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0) return;
try {
const localFiles = [...selectedFiles()];
@@ -1330,10 +1643,40 @@ export const Messages = () => {
});
}
await postMessage(body, attachments);
const references: MessageReferenceInput[] = composerAiReferences().map((reference) => ({
entity_type: reference.entity_type,
entity_id: reference.entity_id,
deep_link: reference.deep_link,
}));
for (const reference of composerAiReferences()) {
attachments.push({
kind: 'activity',
title: reference.attachment_title,
url: reference.deep_link,
});
}
const metadata =
aiReferenceEnabled() || references.length > 0 || aiReferenceContext().trim()
? {
ai_reference: {
enabled: aiReferenceEnabled(),
provider: aiReferenceModel(),
context: aiReferenceContext().trim(),
references,
},
}
: undefined;
await postMessage(body, attachments, undefined, {
metadata,
references,
});
setInputText('');
setSelectedFiles([]);
setAttachedLibraryFiles([]);
setComposerAiReferences([]);
setMentionOptions([]);
setMentionOpen(false);
setMentionQuery('');
@@ -1494,7 +1837,7 @@ export const Messages = () => {
};
onMount(async () => {
await Promise.all([loadConversations(), loadMembers(), loadTeams()]);
await Promise.all([loadConversations(), loadMembers(), loadTeams(), loadAIProviders(), loadAISettings()]);
startRealtime();
typingCleanupTimer = window.setInterval(() => {
const cutoff = Date.now() - 6000;
@@ -1527,6 +1870,9 @@ export const Messages = () => {
if (showCreateConversation()) {
setShowCreateConversation(false);
}
if (showAiSharePicker()) {
setShowAiSharePicker(false);
}
if (mentionOpen()) {
setMentionOpen(false);
}
@@ -1938,7 +2284,7 @@ export const Messages = () => {
</div>
</Show>
<Show when={selectedFiles().length > 0 || attachedLibraryFiles().length > 0}>
<Show when={selectedFiles().length > 0 || attachedLibraryFiles().length > 0 || composerAiReferences().length > 0}>
<div class="composer-chip-wrap">
<For each={selectedFiles()}>
{(file, index) => (
@@ -1962,6 +2308,20 @@ export const Messages = () => {
</div>
)}
</For>
<For each={composerAiReferences()}>
{(reference) => (
<div class="composer-chip">
<IconSparkles class="size-3.5" />
<span>{reference.title}</span>
<button
class="composer-chip-remove"
onClick={() => removeComposerAiReference(reference.entity_type, reference.entity_id)}
>
<IconX class="size-3.5" />
</button>
</div>
)}
</For>
</div>
</Show>
@@ -1990,6 +2350,24 @@ export const Messages = () => {
<IconPaperclip class="size-4" />
</Button>
<Button
size="sm"
variant={showAiSharePicker() ? 'default' : 'outline'}
onClick={async () => {
if (!aiShareSessions().length) {
await loadAIShareSessions();
}
const selected = aiShareSelectedSessionId() || aiShareSessions()[0]?.id;
if (selected) {
setAiShareSelectedSessionId(selected);
await loadAIShareMessages(selected);
}
setShowAiSharePicker((prev) => !prev);
}}
>
<IconSparkles class="size-4" />
</Button>
<Button
size="sm"
variant={isRecordingVoice() ? 'default' : 'outline'}
@@ -2014,7 +2392,7 @@ export const Messages = () => {
}}
value={inputText()}
class="messages-composer-textarea"
placeholder='Type a message. Use "@" for people or files.'
placeholder='Type a message. Use "@" for people, files, and AI references.'
rows={1}
onInput={(event) => handleComposerInput(event as any)}
onKeyDown={(event) => {
@@ -2079,14 +2457,29 @@ export const Messages = () => {
selectMentionOption(option, token);
}}
>
<Show when={option.type === 'user'} fallback={<IconFile class="size-4" />}>
<Show
when={option.type === 'user'}
fallback={
<Show when={option.type === 'file'} fallback={<IconSparkles class="size-4" />}>
<IconFile class="size-4" />
</Show>
}
>
<IconAt class="size-4" />
</Show>
<div class="mention-option-copy">
<div class="mention-option-title">{option.label}</div>
<Show when={option.type === 'user'} fallback={
<div class="mention-option-sub">Attach file to message</div>
}>
<Show
when={option.type === 'user'}
fallback={
<Show
when={option.type === 'file'}
fallback={<div class="mention-option-sub">Insert AI reference</div>}
>
<div class="mention-option-sub">Attach file to message</div>
</Show>
}
>
<div class="mention-option-sub">@{(option as MentionUserOption).username}</div>
</Show>
</div>
@@ -2101,7 +2494,7 @@ export const Messages = () => {
onClick={() => sendMessage()}
disabled={
sendingMessage() ||
(!inputText().trim() && selectedFiles().length === 0 && attachedLibraryFiles().length === 0)
(!inputText().trim() && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0)
}
>
<IconSend class="size-4" />
@@ -2109,6 +2502,27 @@ export const Messages = () => {
</div>
<div class="messages-composer-footer">
<label class="messages-inline-toggle">
<input
type="checkbox"
checked={aiReferenceEnabled()}
onChange={(event) => setAiReferenceEnabled(event.currentTarget.checked)}
/>
AI reference metadata
</label>
<Show when={aiReferenceEnabled()}>
<select
class="h-8 rounded border bg-background px-2 text-xs"
value={aiReferenceModel()}
onChange={(event) => setAiReferenceModel(event.currentTarget.value)}
>
<For each={availableAiReferenceModels()}>
{(providerId) => (
<option value={providerId}>{providerId}</option>
)}
</For>
</select>
</Show>
<label class="messages-inline-toggle">
<input
type="checkbox"
@@ -2137,6 +2551,13 @@ export const Messages = () => {
)}
</Show>
</div>
<Show when={aiReferenceEnabled()}>
<Input
value={aiReferenceContext()}
onInput={(event) => setAiReferenceContext((event.currentTarget as HTMLInputElement).value)}
placeholder="AI context to attach as metadata (optional)"
/>
</Show>
</div>
</Show>
</section>
@@ -2248,6 +2669,112 @@ export const Messages = () => {
</div>
</Show>
<Show when={showAiSharePicker()}>
<div
class="fixed inset-0 z-50 bg-black/45 flex items-center justify-center p-4"
onClick={(event) => {
if (event.target === event.currentTarget) {
setShowAiSharePicker(false);
}
}}
>
<div class="bg-card border rounded-lg w-full max-w-5xl p-4 space-y-3 max-h-[85vh] overflow-hidden">
<div class="flex items-center justify-between">
<h3 class="font-semibold">Share AI Chat Reference</h3>
<Button size="sm" variant="ghost" onClick={() => setShowAiSharePicker(false)}>
<IconX class="size-4" />
</Button>
</div>
<div class="grid md:grid-cols-[280px,1fr] gap-3 min-h-0 h-[65vh]">
<div class="border rounded-md p-2 overflow-y-auto space-y-2">
<div class="text-xs text-muted-foreground px-1">AI sessions</div>
<Show when={!aiShareLoadingSessions()} fallback={<div class="text-sm text-muted-foreground p-2">Loading sessions</div>}>
<For each={aiShareSessions()}>
{(session) => (
<button
class={`w-full text-left rounded border p-2 ${
aiShareSelectedSessionId() === session.id ? 'bg-primary/10 border-primary/40' : 'hover:bg-muted'
}`}
onClick={async () => {
setAiShareSelectedSessionId(session.id);
await loadAIShareMessages(session.id);
}}
>
<div class="text-sm font-medium truncate">{session.title}</div>
<div class="text-xs text-muted-foreground">{session.message_count} messages</div>
<div class="mt-2 flex gap-2">
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation();
addComposerAiReference(buildAiSessionReference(session));
}}
>
Add Session
</Button>
</div>
</button>
)}
</For>
</Show>
</div>
<div class="border rounded-md p-2 overflow-y-auto space-y-2">
<div class="text-xs text-muted-foreground px-1">Messages</div>
<Show
when={aiShareSelectedSessionId()}
fallback={<div class="text-sm text-muted-foreground p-2">Select a session.</div>}
>
{(sessionId) => {
const session = () => aiShareSessions().find((entry) => entry.id === sessionId()) || null;
const messagesInSession = () => aiShareMessagesBySession()[sessionId()] || [];
return (
<>
<Show
when={!aiShareLoadingMessages()}
fallback={<div class="text-sm text-muted-foreground p-2">Loading messages</div>}
>
<For each={messagesInSession()}>
{(message) => (
<div class="rounded border p-2 bg-background">
<div class="flex items-center justify-between gap-2">
<div class="text-xs text-muted-foreground">
{message.role} {new Date(message.created_at).toLocaleString()}
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
const selectedSession = session();
if (!selectedSession) return;
addComposerAiReference(buildAiMessageReference(selectedSession, message));
}}
>
Add Message
</Button>
</div>
<div class="text-sm mt-1 whitespace-pre-wrap break-words">
{(message.content || '').slice(0, 420) || '[empty message]'}
</div>
</div>
)}
</For>
<Show when={messagesInSession().length === 0}>
<div class="text-sm text-muted-foreground p-2">No messages in this session.</div>
</Show>
</Show>
</>
);
}}
</Show>
</div>
</div>
</div>
</div>
</Show>
<Show when={searchOpen()}>
<div
class="fixed inset-0 z-50 bg-black/45 flex items-start justify-center p-4"
+471 -267
View File
@@ -1,10 +1,11 @@
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { NoteModal } from '@/components/ui/NoteModal';
import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
import { IconPin, IconTrash, IconEdit, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
import { getMockNotes } from '@/lib/mockData';
import { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode';
import { getApiV1BaseUrl } from '@/lib/api-url';
const API_BASE_URL = getApiV1BaseUrl();
@@ -79,23 +80,34 @@ export const Notes = () => {
onMount(async () => {
try {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes`, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
let notesData: any[] = [];
if (!response.ok) {
throw new Error('Failed to load notes');
// Check if we should use demo mode or real API
if (isDemoMode() && !shouldUseRealBackend()) {
console.log('[Notes] Loading demo notes data');
// Load mock notes data for demo mode
const mockNotesData = getMockNotes();
notesData = mockNotesData;
} else {
console.log('[Notes] Loading notes from real API');
// Load from real API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes`, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!response.ok) {
throw new Error('Failed to load notes');
}
notesData = await response.json();
}
const notesData = await response.json();
const adaptedNotes: Note[] = (Array.isArray(notesData) ? notesData : []).map((note: any, index) => {
const tags = Array.isArray(note.tags)
? note.tags
.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name))
.filter(Boolean)
? note.tags.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name)).filter(Boolean)
: [];
const content = note.content || '';
@@ -109,7 +121,7 @@ export const Notes = () => {
createdAt,
updatedAt,
tags,
pinned: Boolean(note.pinned ?? note.is_pinned),
pinned: Boolean(note.pinned ?? note.is_pinned ?? tags.includes('pinned')),
attachments: Array.isArray(note.attachments)
? note.attachments.map((att: any, attachmentIndex: number) => ({
id: String(att.id || `att_${attachmentIndex}`),
@@ -127,7 +139,44 @@ export const Notes = () => {
setNotes(adaptedNotes);
} catch (error) {
console.error('Failed to load notes:', error);
setNotes([]);
// Fallback to demo data if API fails
if (!isDemoMode()) {
console.log('[Notes] API failed, falling back to demo data');
const mockNotesData = getMockNotes();
const adaptedNotes: Note[] = mockNotesData.map((note: any, index) => {
const tags = Array.isArray(note.tags)
? note.tags.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name)).filter(Boolean)
: [];
const content = note.content || '';
const createdAt = note.created_at || note.createdAt || new Date().toISOString();
const updatedAt = note.updated_at || note.updatedAt || createdAt;
return {
id: Number(note.id || index + 1),
title: note.title || 'Untitled note',
content,
createdAt,
updatedAt,
tags,
pinned: Boolean(note.pinned ?? note.is_pinned ?? tags.includes('pinned')),
attachments: Array.isArray(note.attachments)
? note.attachments.map((att: any, attachmentIndex: number) => ({
id: String(att.id || `att_${attachmentIndex}`),
name: att.name || 'attachment',
type: att.type || 'file',
size: att.size || '',
url: att.url,
}))
: [],
isMarkdown: content.includes('#') || content.includes('*'),
isHtml: content.includes('<') && content.includes('>'),
};
});
setNotes(adaptedNotes);
} else {
setNotes([]);
}
} finally {
setIsLoading(false);
}
@@ -172,19 +221,53 @@ export const Notes = () => {
const handleAddNote = async (noteData: any) => {
try {
// TODO: Replace with actual API call
const note: Note = {
id: Date.now(),
title: noteData.title,
content: noteData.content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: noteData.tags,
pinned: false
};
setNotes(prev => [note, ...prev]);
setShowAddModal(false);
if (isDemoMode() && !shouldUseRealBackend()) {
// Demo mode: Add note locally
const note: Note = {
id: Date.now(),
title: noteData.title,
content: noteData.content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: noteData.tags,
pinned: false
};
setNotes(prev => [note, ...prev]);
setShowAddModal(false);
} else {
// Real API: Create note via API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(noteData),
});
if (!response.ok) {
throw new Error('Failed to create note');
}
const newNote = await response.json();
const adaptedNote: Note = {
id: newNote.id,
title: newNote.title,
content: newNote.content,
createdAt: newNote.created_at || newNote.createdAt,
updatedAt: newNote.updated_at || newNote.updatedAt,
tags: Array.isArray(newNote.tags) ? newNote.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.name) : [],
pinned: Boolean(newNote.pinned ?? newNote.is_pinned),
attachments: [],
isMarkdown: newNote.content.includes('#') || newNote.content.includes('*'),
isHtml: newNote.content.includes('<') && newNote.content.includes('>'),
};
setNotes(prev => [adaptedNote, ...prev]);
setShowAddModal(false);
}
} catch (error) {
console.error('Failed to add note:', error);
}
@@ -192,20 +275,52 @@ export const Notes = () => {
const handleEditNote = async (noteData: any) => {
try {
// TODO: Replace with actual API call
setNotes(prev => prev.map(note =>
note.id === noteData.id
? {
...note,
title: noteData.title,
content: noteData.content,
tags: noteData.tags,
updatedAt: new Date().toISOString()
}
: note
));
setShowEditModal(false);
setEditingNote(null);
if (isDemoMode() && !shouldUseRealBackend()) {
// Demo mode: Update note locally
setNotes(prev => prev.map(note =>
note.id === noteData.id
? {
...note,
title: noteData.title,
content: noteData.content,
tags: noteData.tags,
updatedAt: new Date().toISOString()
}
: note
));
setShowEditModal(false);
setEditingNote(null);
} else {
// Real API: Update note via API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes/${noteData.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(noteData),
});
if (!response.ok) {
throw new Error('Failed to update note');
}
const updatedNote = await response.json();
setNotes(prev => prev.map(note =>
note.id === noteData.id
? {
...note,
title: updatedNote.title || noteData.title,
content: updatedNote.content || noteData.content,
tags: Array.isArray(updatedNote.tags) ? updatedNote.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.name) : noteData.tags,
updatedAt: updatedNote.updated_at || updatedNote.updatedAt || new Date().toISOString()
}
: note
));
setShowEditModal(false);
setEditingNote(null);
}
} catch (error) {
console.error('Failed to update note:', error);
}
@@ -213,10 +328,32 @@ export const Notes = () => {
const togglePin = async (noteId: number) => {
try {
// TODO: Replace with actual API call
setNotes(prev => prev.map(note =>
note.id === noteId ? { ...note, pinned: !note.pinned } : note
));
if (isDemoMode() && !shouldUseRealBackend()) {
// Demo mode: Toggle pin locally
setNotes(prev => prev.map(note =>
note.id === noteId ? { ...note, pinned: !note.pinned } : note
));
} else {
// Real API: Toggle pin via API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const note = notes().find(n => n.id === noteId);
const response = await fetch(`${API_BASE_URL}/notes/${noteId}/pin`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ pinned: !note?.pinned }),
});
if (!response.ok) {
throw new Error('Failed to toggle pin');
}
setNotes(prev => prev.map(note =>
note.id === noteId ? { ...note, pinned: !note.pinned } : note
));
}
} catch (error) {
console.error('Failed to toggle pin:', error);
}
@@ -224,8 +361,25 @@ export const Notes = () => {
const deleteNote = async (noteId: number) => {
try {
// TODO: Replace with actual API call
setNotes(prev => prev.filter(note => note.id !== noteId));
if (isDemoMode() && !shouldUseRealBackend()) {
// Demo mode: Delete note locally
setNotes(prev => prev.filter(note => note.id !== noteId));
} else {
// Real API: Delete note via API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes/${noteId}`, {
method: 'DELETE',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!response.ok) {
throw new Error('Failed to delete note');
}
setNotes(prev => prev.filter(note => note.id !== noteId));
}
} catch (error) {
console.error('Failed to delete note:', error);
}
@@ -329,7 +483,7 @@ export const Notes = () => {
});
return (
<div class="p-6 space-y-6">
<div class="fixed inset-0 bg-background flex flex-col">
<style>
{`
.note-checkbox {
@@ -355,232 +509,282 @@ export const Notes = () => {
}
`}
</style>
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-[#fafafa]">Notes</h1>
<Button onClick={() => setShowAddModal(true)}>
Add Note
</Button>
{/* Header - Signal/Discord style */}
<div class="bg-[#2c2c2e] border-b border-[#404040] px-4 py-3 flex-shrink-0">
<div class="flex justify-between items-center">
<div>
<h1 class="text-xl font-semibold text-white">Notes</h1>
<p class="text-sm text-[#b9b9b9]">Your personal notes</p>
</div>
<Button
onClick={() => setShowAddModal(true)}
class="bg-[#5865f2] hover:bg-[#4752c4] text-white border-0"
>
Add Note
</Button>
</div>
</div>
<SearchTagFilterBar
searchPlaceholder="Search notes..."
searchValue={searchTerm()}
onSearchChange={(value) => setSearchTerm(value)}
tagOptions={allTags()}
selectedTag={selectedTags()[0] || ''}
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
onReset={() => {
setSearchTerm('');
setSelectedTags([]);
}}
/>
<Show when={copiedContent()}>
<div class="bg-primary/15 text-primary px-3 py-1 rounded-md text-sm">
Content copied!
{/* Main Content Area */}
<div class="flex-1 flex overflow-hidden">
{/* Sidebar - Discord style */}
<div class="w-64 bg-[#202225] border-r border-[#404040] flex-shrink-0 overflow-y-auto">
<div class="p-4">
<h3 class="text-xs font-semibold text-[#8e9297] uppercase tracking-wide mb-3">Search & Filter</h3>
<SearchTagFilterBar
searchPlaceholder="Search notes..."
searchValue={searchTerm()}
onSearchChange={(value) => setSearchTerm(value)}
tagOptions={allTags()}
selectedTag={selectedTags()[0] || ''}
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
onReset={() => {
setSearchTerm('');
setSelectedTags([]);
}}
/>
<div class="mt-6">
<h3 class="text-xs font-semibold text-[#8e9297] uppercase tracking-wide mb-3">Quick Stats</h3>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-sm text-[#b9b9b9]">Total Notes</span>
<span class="text-sm font-medium text-white">{filteredNotes().length}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#b9b9b9]">Pinned</span>
<span class="text-sm font-medium text-white">{filteredNotes().filter(n => n.pinned).length}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#b9b9b9]">Tags</span>
<span class="text-sm font-medium text-white">{allTags().length}</span>
</div>
</div>
</div>
</div>
</div>
</Show>
{isLoading() ? (
<div class="space-y-4">
{[...Array(3)].map(() => (
<Card class="p-6">
<div class="animate-pulse">
<div class="h-6 bg-[#262626] rounded mb-2"></div>
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
{/* Notes List - Signal style chat */}
<div class="flex-1 bg-[#36393f] overflow-y-auto">
<div class="p-4 space-y-2">
<Show when={copiedContent()}>
<div class="bg-green-500/20 border border-green-500/50 text-green-400 px-3 py-2 rounded-lg text-sm mb-4">
Content copied!
</div>
</Card>
))}
</div>
) : (
<div class="space-y-4">
{filteredNotes().map((note) => (
<Card
data-note-id={note.id}
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:bg-[#1a1a1a] ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
onClick={() => viewNote(note)}
>
<div class="flex justify-between items-start mb-3">
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold text-[#fafafa]">{note.title}</h3>
{note.pinned && <IconPin class="size-4 text-primary" />}
{note.isMarkdown && <span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">MD</span>}
{note.isHtml && <span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">HTML</span>}
</div>
<div class="flex gap-1">
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
copyNoteContent(note);
}}
class="text-white hover:text-white/80 p-1"
>
<IconCopy size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
exportNote(note);
}}
class="text-white hover:text-white/80 p-1"
>
<IconDownload size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditNote(note);
}}
class="text-white hover:text-white/80 p-1"
>
<IconEdit size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
togglePin(note.id);
}}
class="text-primary hover:text-primary/80 p-1"
{...{title: note.pinned ? "Unpin note" : "Pin note"}}
>
<IconPin size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
deleteNote(note.id);
}}
class="text-destructive hover:text-destructive/80 p-1"
>
<IconTrash size={16} />
</Button>
</div>
</div>
<div class="text-[#a3a3a3] text-sm mb-3">
<div class="prose prose-invert max-w-none">
<Show
when={expandedNotes().has(note.id)}
fallback={
<div
class="overflow-hidden"
style={{
display: '-webkit-box',
'-webkit-line-clamp': '3',
'-webkit-box-orient': 'vertical',
'max-height': '4.5em',
'line-height': '1.5em'
}}
innerHTML={
note.isHtml
? note.content
: note.isMarkdown
? renderMarkdownPreviewHtml(note.content)
: renderPlainTextPreviewHtml(note.content)
}
/>
}
>
<div
innerHTML={
note.isHtml
? note.content
: note.isMarkdown
? note.content.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-2">$1</h1>')
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1</h2>')
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1</h3>')
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1</h4>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1</code>')
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1</blockquote>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\n\n+/g, '</p><p class="mb-2">')
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
}
/>
</Show>
</div>
<button
onClick={(e) => {
e.stopPropagation();
console.log('Show more clicked for note:', note.title);
toggleNoteExpansion(note.id);
}}
class="mt-2 text-xs text-primary hover:text-primary/80 font-medium cursor-pointer transition-colors"
>
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
</button>
</div>
{/* Attachments */}
<Show when={note.attachments && note.attachments.length > 0}>
<div class="mb-3">
<div class="flex items-center gap-2 mb-2">
<IconPaperclip class="size-4 text-[#a3a3a3]" />
<span class="text-xs text-[#a3a3a3]">Attachments ({note.attachments?.length || 0})</span>
</Show>
{isLoading() ? (
<div class="space-y-3">
{[...Array(3)].map(() => (
<div class="bg-[#2c2c2e] rounded-lg p-4 animate-pulse">
<div class="h-4 bg-[#404040] rounded mb-2"></div>
<div class="h-3 bg-[#404040] rounded w-3/4"></div>
</div>
<div class="flex flex-wrap gap-2">
<For each={note.attachments || []}>
{(attachment) => (
<div class="flex items-center gap-2 px-2 py-1 bg-[#262626] rounded-md text-xs">
<span class="text-[#a3a3a3]">{attachment.name}</span>
<span class="text-[#666]">({attachment.size})</span>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex flex-wrap gap-2 mb-3">
<For each={note.tags}>
{(tag) => (
<button
onClick={(e) => {
e.stopPropagation();
toggleTag(tag);
}}
class="px-2 py-1 bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground text-xs rounded-md transition-colors cursor-pointer"
))}
</div>
) : (
<>
{filteredNotes().length > 0 ? (
filteredNotes().map((note) => (
<div
data-note-id={note.id}
class={`bg-[#2c2c2e] hover:bg-[#35363c] rounded-lg p-4 cursor-pointer transition-all border border-transparent ${note.pinned ? 'border-l-4 border-l-[#5865f2]' : ''}`}
onClick={() => viewNote(note)}
>
{tag}
</button>
)}
</For>
</div>
<p class="text-[#a3a3a3] text-xs">
Updated: {note.updatedAt && !isNaN(new Date(note.updatedAt).getTime()) ? new Date(note.updatedAt).toLocaleDateString() : 'Invalid Date'}
</p>
</Card>
))}
{filteredNotes().length === 0 && (
<Card class="p-12 text-center">
<p class="text-muted-foreground">
{searchTerm() || selectedTags().length > 0
? 'No notes found matching your search or filters.'
: 'No notes yet. Add your first note!'}
</p>
</Card>
)}
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
<h3 class="text-white font-medium">{note.title}</h3>
{note.pinned && <IconPin class="size-4 text-[#faa61a]" />}
{note.isMarkdown && <span class="text-xs px-2 py-1 bg-[#5865f2]/20 text-[#5865f2] rounded">MD</span>}
{note.isHtml && <span class="text-xs px-2 py-1 bg-[#5865f2]/20 text-[#5865f2] rounded">HTML</span>}
</div>
<div class="flex gap-1">
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
copyNoteContent(note);
}}
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
>
<IconCopy size={14} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
exportNote(note);
}}
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
>
<IconDownload size={14} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditNote(note);
}}
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
>
<IconEdit size={14} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
togglePin(note.id);
}}
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
{...{title: note.pinned ? "Unpin note" : "Pin note"}}
>
<IconPin size={14} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
deleteNote(note.id);
}}
class="text-[#ed4245] hover:text-[#ff6b6b] p-1 h-6 w-6"
>
<IconTrash size={14} />
</Button>
</div>
</div>
{/* Note Preview */}
<div class="text-[#dcddde] text-sm">
<div class="prose prose-invert max-w-none">
<Show
when={expandedNotes().has(note.id)}
fallback={
<div
class="overflow-hidden"
style={{
display: '-webkit-box',
'-webkit-line-clamp': '2',
'-webkit-box-orient': 'vertical',
'max-height': '3em',
'line-height': '1.5em'
}}
innerHTML={
note.isHtml
? note.content
: note.isMarkdown
? renderMarkdownPreviewHtml(note.content)
: renderPlainTextPreviewHtml(note.content)
}
/>
}
>
<div
innerHTML={
note.isHtml
? note.content
: note.isMarkdown
? note.content.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-2">$1</h1>')
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1</h2>')
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1</h3>')
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1</h4>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
.replace(/`(.*?)`/g, '<code class="bg-[#404040] px-1 py-0.5 rounded text-xs">$1</code>')
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#404040] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#404040] pl-3 italic text-[#b9b9b9] mb-2">$1</blockquote>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-[#5865f2] hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\n\n+/g, '</p><p class="mb-2">')
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-[#5865f2] hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
}
/>
</Show>
</div>
<button
onClick={(e) => {
e.stopPropagation();
console.log('Show more clicked for note:', note.title);
toggleNoteExpansion(note.id);
}}
class="mt-2 text-xs text-[#5865f2] hover:text-[#7289da] font-medium cursor-pointer transition-colors"
>
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
</button>
</div>
{/* Tags */}
<div class="flex flex-wrap gap-1 mt-3">
<For each={note.tags}>
{(tag) => (
<button
onClick={(e) => {
e.stopPropagation();
toggleTag(tag);
}}
class="px-2 py-1 bg-[#404040] hover:bg-[#4a4b4e] text-[#b9b9b9] hover:text-white text-xs rounded-md transition-colors cursor-pointer"
>
{tag}
</button>
)}
</For>
</div>
{/* Attachments */}
<Show when={note.attachments && note.attachments.length > 0}>
<div class="mt-3">
<div class="flex items-center gap-2 mb-2">
<IconPaperclip class="size-3 text-[#b9b9b9]" />
<span class="text-xs text-[#b9b9b9]">Attachments ({note.attachments?.length || 0})</span>
</div>
<div class="flex flex-wrap gap-2">
<For each={note.attachments || []}>
{(attachment) => (
<div class="flex items-center gap-2 px-2 py-1 bg-[#404040] rounded-md text-xs">
<span class="text-[#dcddde]">{attachment.name}</span>
<span class="text-[#8e9297]">({attachment.size})</span>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex justify-between items-center mt-3 pt-3 border-t border-[#404040]">
<p class="text-xs text-[#8e9297]">
Updated: {note.updatedAt && !isNaN(new Date(note.updatedAt).getTime()) ? new Date(note.updatedAt).toLocaleDateString() : 'Invalid Date'}
</p>
</div>
</div>
))
) : (
<div class="bg-[#2c2c2e] rounded-lg p-8 text-center">
<div class="text-6xl mb-4">📝</div>
<p class="text-[#b9b9b9] text-lg font-medium mb-2">
{searchTerm() || selectedTags().length > 0
? 'No notes found matching your search or filters.'
: 'No notes yet. Add your first note!'}
</p>
<p class="text-[#8e9297] text-sm">
{searchTerm() || selectedTags().length > 0
? 'Try adjusting your search terms or filters.'
: 'Click the "Add Note" button to get started.'}
</p>
</div>
)}
</>
)}
</div>
</div>
)}
</div>
{/* Add Note Modal */}
<NoteModal
+75 -7
View File
@@ -1,5 +1,6 @@
import { createEffect, createSignal, onMount, Show } from 'solid-js';
import { IconTrash, IconRestore, IconFileText, IconFileTypePpt, IconFileTypeDocx, IconClock, IconSettings, IconAlertTriangle } from '@tabler/icons-solidjs';
import { getApiV1BaseUrl } from '@/lib/api-url';
interface RemovedItem {
id: string;
@@ -18,6 +19,8 @@ interface AutoRemoveSettings {
autoEmpty: boolean;
}
const API_BASE_URL = getApiV1BaseUrl();
export const RemovedStuff = () => {
const [removedItems, setRemovedItems] = createSignal<RemovedItem[]>([]);
const [autoRemoveSettings, setAutoRemoveSettings] = createSignal<AutoRemoveSettings>({
@@ -27,8 +30,10 @@ export const RemovedStuff = () => {
});
const [showSettings, setShowSettings] = createSignal(false);
const [selectedItems, setSelectedItems] = createSignal<string[]>([]);
const [loadedRemovedItems, setLoadedRemovedItems] = createSignal(false);
createEffect(() => {
if (!loadedRemovedItems()) return;
localStorage.setItem('removedItems', JSON.stringify(removedItems()));
});
@@ -39,15 +44,78 @@ export const RemovedStuff = () => {
setAutoRemoveSettings(JSON.parse(savedSettings));
}
const savedItems = localStorage.getItem('removedItems');
if (savedItems) {
// Try to load from API first, then fallback to localStorage
const loadRemovedItems = async () => {
try {
const parsedItems = JSON.parse(savedItems);
setRemovedItems(Array.isArray(parsedItems) ? parsedItems : []);
} catch {
setRemovedItems([]);
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/trash`, {
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
}
});
if (response.ok) {
const data = await response.json();
if (Array.isArray(data)) {
setRemovedItems(data);
localStorage.setItem('removedItems', JSON.stringify(data));
return;
}
}
} catch (error) {
console.warn('Failed to load removed items from API:', error);
} finally {
setLoadedRemovedItems(true);
}
}
// Fallback to localStorage
const savedItems = localStorage.getItem('removedItems');
if (savedItems) {
try {
const parsedItems = JSON.parse(savedItems);
setRemovedItems(Array.isArray(parsedItems) ? parsedItems : []);
} catch {
setRemovedItems([]);
}
} else {
// Set demo data if no saved data
setRemovedItems([
{
id: '1',
name: 'Old Project Documentation.pdf',
type: 'pdf',
removedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toLocaleDateString(),
removedBy: 'Demo User',
size: '2.4 MB',
path: '/documents/projects/',
daysInTrash: 5
},
{
id: '2',
name: 'Backup Database.sql',
type: 'sql',
removedAt: new Date(Date.now() - 12 * 24 * 60 * 60 * 1000).toLocaleDateString(),
removedBy: 'Demo User',
size: '15.7 MB',
path: '/backups/',
daysInTrash: 12
},
{
id: '3',
name: 'Draft Presentation.pptx',
type: 'pptx',
removedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toLocaleDateString(),
removedBy: 'Demo User',
size: '8.2 MB',
path: '/presentations/',
daysInTrash: 3
}
]);
}
};
loadRemovedItems();
// Check for auto-remove on mount
checkAutoRemove();
File diff suppressed because it is too large Load Diff
+13 -8
View File
@@ -111,20 +111,25 @@ export const Stats = () => {
]);
const statsData = statsRes.status === 'fulfilled' && statsRes.value.ok ? await statsRes.value.json() : null;
const filesData: Array<any> = filesRes.status === 'fulfilled' && filesRes.value.ok ? await filesRes.value.json() : [];
const tasksData: Array<any> = tasksRes.status === 'fulfilled' && tasksRes.value.ok ? await tasksRes.value.json() : [];
const filesRaw = filesRes.status === 'fulfilled' && filesRes.value.ok ? await filesRes.value.json() : [];
const tasksRaw = tasksRes.status === 'fulfilled' && tasksRes.value.ok ? await tasksRes.value.json() : [];
const filesData: Array<any> = Array.isArray(filesRaw) ? filesRaw : [];
const tasksData: Array<any> = Array.isArray(tasksRaw) ? tasksRaw : [];
const completedTasks = tasksData.filter((task) => task.status === 'completed').length;
const activeTasks = tasksData.filter((task) => task.status !== 'completed').length;
const totalSizeBytes = filesData.reduce((acc: number, file: any) => acc + Number(file.file_size || 0), 0);
const storageUsedMb = totalSizeBytes / (1024 * 1024);
const storageTotalMb = 50 * 1024;
const totalBookmarks = Number(statsData?.totalBookmarks ?? statsData?.total_bookmarks ?? 0);
const totalTasks = Number(statsData?.totalTasks ?? statsData?.total_tasks ?? tasksData.length ?? 0);
const totalNotes = Number(statsData?.totalNotes ?? statsData?.total_notes ?? 0);
setStats({
totalBookmarks: Number(statsData?.totalBookmarks || 0),
totalBookmarks,
totalDocuments: filesData.length,
totalTasks: Number(statsData?.totalTasks || tasksData.length || 0),
totalNotes: Number(statsData?.totalNotes || 0),
totalTasks,
totalNotes,
completedTasks,
activeTasks,
storageUsed: `${storageUsedMb.toFixed(2)} MB`,
@@ -138,10 +143,10 @@ export const Stats = () => {
},
topCategories: [],
recentActivity: [
{ type: 'Bookmarks', count: Number(statsData?.totalBookmarks || 0), change: 0 },
{ type: 'Bookmarks', count: totalBookmarks, change: 0 },
{ type: 'Documents', count: filesData.length, change: 0 },
{ type: 'Tasks', count: Number(statsData?.totalTasks || tasksData.length || 0), change: 0 },
{ type: 'Notes', count: Number(statsData?.totalNotes || 0), change: 0 }
{ type: 'Tasks', count: totalTasks, change: 0 },
{ type: 'Notes', count: totalNotes, change: 0 }
],
contributionGraph: []
});
+14 -22
View File
@@ -7,6 +7,7 @@ import { ModalPortal } from '@/components/ui/ModalPortal';
import { getMockVideos } from '@/lib/mockData';
import { getAuthHeaders } from '@/lib/auth';
import { isDemoMode } from '@/lib/demo-mode';
import { getApiV1BaseUrl } from '@/lib/api-url';
import {
IconAlertCircle
} from '@tabler/icons-solidjs';
@@ -18,6 +19,7 @@ interface YouTubeVideo {
channel_name: string;
url: string;
title: string;
thumbnail?: string;
duration?: string;
published_at?: string;
view_count?: string;
@@ -31,6 +33,8 @@ interface FeaturedChannel {
description?: string;
}
const API_BASE_URL = getApiV1BaseUrl();
// VideoCard component
interface VideoCardProps {
video: YouTubeVideo;
@@ -43,12 +47,11 @@ const VideoCard = (props: VideoCardProps) => (
{/* Thumbnail */}
<div class="relative aspect-video bg-muted overflow-hidden">
<img
src={`https://img.youtube.com/vi/${props.video.video_id}/maxresdefault.jpg`}
src={isDemoMode() ? '/trackeep.svg' : (props.video.thumbnail || `https://img.youtube.com/vi/${props.video.video_id}/hqdefault.jpg`)}
alt={props.video.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
onError={(e) => {
// Fallback to default thumbnail if maxresdefault fails
(e.target as HTMLImageElement).src = `https://img.youtube.com/vi/${props.video.video_id}/hqdefault.jpg`;
(e.target as HTMLImageElement).src = '/trackeep.svg';
}}
/>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200"></div>
@@ -170,7 +173,6 @@ export const Youtube = () => {
// Get video info from YouTube API using video ID (always use real data)
const getVideoInfo = async (videoId: string) => {
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/youtube/video-details`, {
method: 'POST',
headers: getAuthHeaders(),
@@ -197,6 +199,7 @@ export const Youtube = () => {
channel_name: 'Unknown Channel',
url: `https://www.youtube.com/watch?v=${videoId}`,
title: `Video ${videoId}`,
thumbnail: '',
duration: 'Unknown',
published_at: 'Unknown',
view_count: '0',
@@ -222,9 +225,6 @@ export const Youtube = () => {
const channels = featuredChannels();
console.log('Using integrated YouTube service for featured channels');
// Use the integrated backend API
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
try {
// Fetch videos from all featured channels using the integrated API
const channelPromises = channels.map(async (channel) => {
@@ -268,6 +268,7 @@ export const Youtube = () => {
channel_name: video.channel || 'Unknown Channel',
url: `https://www.youtube.com/watch?v=${video.video_id}`,
title: video.title || 'Untitled Video',
thumbnail: video.thumbnail || '',
duration: video.length || 'Unknown',
published_at: video.published_date || video.published_text || 'Unknown',
view_count: video.views ? video.views.toLocaleString() : '0',
@@ -290,11 +291,8 @@ export const Youtube = () => {
console.warn('YouTube scraping service failed:', scraperError);
}
// Fallback to backend API
const YOUTUBE_API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
try {
const response = await fetch(`${YOUTUBE_API_BASE_URL}/youtube/predefined-channels`, {
const response = await fetch(`${API_BASE_URL}/youtube/predefined-channels`, {
method: 'GET',
headers: getAuthHeaders(),
});
@@ -308,6 +306,7 @@ export const Youtube = () => {
channel_name: video.channel_title || 'Unknown Channel',
url: `https://www.youtube.com/watch?v=${video.id}`,
title: video.title,
thumbnail: video.thumbnail || '',
duration: video.duration || 'Unknown',
published_at: video.published_at || 'Unknown',
view_count: video.view_count?.toString() || '0',
@@ -339,6 +338,7 @@ export const Youtube = () => {
channel_name: video.channel,
url: video.url,
title: video.title,
thumbnail: video.thumbnail || '',
duration: video.duration,
published_at: video.publishedAt,
view_count: '1000',
@@ -378,7 +378,6 @@ export const Youtube = () => {
setIsLoadingSaved(true);
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/video-bookmarks`, {
headers: getAuthHeaders(),
});
@@ -392,6 +391,7 @@ export const Youtube = () => {
channel_name: bookmark.channel || 'Unknown Channel',
url: bookmark.url,
title: bookmark.title || 'Untitled Video',
thumbnail: bookmark.thumbnail || '',
duration: 'Unknown',
published_at: bookmark.created_at || 'Unknown',
view_count: '0',
@@ -465,6 +465,7 @@ export const Youtube = () => {
channel_name: data.channel_name,
url: data.url,
title: data.title,
thumbnail: data.thumbnail || '',
duration: data.duration || 'Unknown',
published_at: data.published_at || 'Unknown',
view_count: data.view_count || '0',
@@ -475,7 +476,6 @@ export const Youtube = () => {
} else {
// It's a regular search query - use backend API
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/youtube/search`, {
method: 'POST',
headers: getAuthHeaders(),
@@ -491,6 +491,7 @@ export const Youtube = () => {
channel_name: video.channel_title || 'Unknown Channel',
url: `https://www.youtube.com/watch?v=${video.id}`,
title: video.title,
thumbnail: video.thumbnail || '',
duration: video.duration || 'Unknown',
published_at: video.published_at || 'Unknown',
view_count: video.view_count?.toString() || '0',
@@ -536,7 +537,6 @@ export const Youtube = () => {
const handleSaveVideo = async (video: YouTubeVideo) => {
try {
// Always try to save to backend, no demo mode check
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const bookmarkData = {
url: video.url,
description: `Video from ${video.channel_name}`,
@@ -586,14 +586,6 @@ export const Youtube = () => {
<div>
<h1 class="text-3xl font-bold tracking-tight mb-2">YouTube Video Storage</h1>
<p class="text-muted-foreground">Search, discover, and store YouTube videos</p>
<Show when={isDemoMode()}>
<div class="flex items-center gap-2 mt-2">
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded-full">
Demo Mode
</span>
<span class="text-sm text-muted-foreground">Using sample data</span>
</div>
</Show>
</div>
</div>
</div>