mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 04:22:57 +00:00
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:
+48
-6
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(>: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(>: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);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
+1526
-300
File diff suppressed because it is too large
Load Diff
@@ -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)}`);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1521
-1694
File diff suppressed because it is too large
Load Diff
@@ -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: []
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user