mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 05:53:50 +00:00
feat: migrate to DragonflyDB and clean up environment configuration
- Replace Redis with DragonflyDB for better performance and memory efficiency - Remove redundant environment variables (POSTGRES_*, ENCRYPTION_KEY, OAUTH_SERVICE_URL) - Consolidate database configuration to use single DB_* variables - Use JWT_SECRET for both JWT tokens and encryption - Remove PORT variable redundancy, use BACKEND_PORT consistently - Clean up docker-compose configurations for dev/prod consistency - Add DragonflyDB configuration with optimized memory usage - Remove redis.conf as it's no longer needed - Update health checks to use Redis-compatible CLI for DragonflyDB - Add missing VITE_API_URL to production frontend - Fix GitHub Actions to use correct go.sum path - Clean up development directories and unused files
This commit is contained in:
@@ -29,6 +29,7 @@ import { Switch } from '../ui/Switch'
|
||||
import { ModalPortal } from '../ui/ModalPortal'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url'
|
||||
import { useHaptics } from '@/lib/haptics'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Home', href: '/app', icon: IconHome },
|
||||
@@ -74,6 +75,7 @@ export interface SidebarProps {
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const location = useLocation()
|
||||
const { logout } = useAuth()
|
||||
const haptics = useHaptics()
|
||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = createSignal(false)
|
||||
const [workspaces, setWorkspaces] = createSignal<WorkspaceOption[]>([])
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = createSignal<string>('')
|
||||
@@ -421,6 +423,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
"bg-[#262626] text-white font-medium shadow-sm": active,
|
||||
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !active
|
||||
}}
|
||||
onClick={() => haptics.navigation()}
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<Icon class={`size-5 transition-colors ${
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { splitProps, Show } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IconLoader2 } from '@tabler/icons-solidjs'
|
||||
import { useHaptics, type HapticPattern } from '@/lib/haptics'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-papra focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
@@ -46,6 +47,7 @@ export interface ButtonProps
|
||||
loading?: boolean
|
||||
onClick?: (e: MouseEvent) => void
|
||||
children: any
|
||||
haptic?: HapticPattern
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
@@ -58,15 +60,28 @@ export function Button(props: ButtonProps) {
|
||||
'loading',
|
||||
'onClick',
|
||||
'children',
|
||||
'haptic',
|
||||
])
|
||||
|
||||
const haptics = useHaptics()
|
||||
|
||||
const isDisabled = () => local.disabled || local.loading
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
// Trigger haptic feedback if specified
|
||||
if (local.haptic && !isDisabled()) {
|
||||
haptics[local.haptic]?.()
|
||||
}
|
||||
|
||||
// Call original onClick handler
|
||||
local.onClick?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
||||
disabled={isDisabled()}
|
||||
onClick={local.onClick}
|
||||
onClick={handleClick}
|
||||
{...others}
|
||||
>
|
||||
<Show when={local.loading}>
|
||||
|
||||
Vendored
+18
@@ -39,5 +39,23 @@ declare module "*.bmp" {
|
||||
export default content;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_DEMO_MODE?: string;
|
||||
readonly VITE_OAUTH_SERVICE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
importMetaEnv?: {
|
||||
VITE_API_URL?: string;
|
||||
VITE_DEMO_MODE?: string;
|
||||
VITE_OAUTH_SERVICE_URL?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Global build-time constants
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -72,12 +72,9 @@ 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()) {
|
||||
console.log('[Auth] onMount: Demo mode disabled, clearing demo-specific data only');
|
||||
// Only clear demo mode data, not legitimate user auth data
|
||||
localStorage.removeItem('demoMode');
|
||||
|
||||
@@ -88,7 +85,6 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
console.log('[Auth] onMount: Found existing auth, restoring:', user);
|
||||
setAuthState({
|
||||
user,
|
||||
token,
|
||||
@@ -107,7 +103,6 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
clearAuth();
|
||||
}
|
||||
} else {
|
||||
console.log('[Auth] onMount: No existing auth found, setting isLoading to false');
|
||||
setAuthState('isLoading', false);
|
||||
// Set dark mode by default when not authenticated
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
@@ -116,7 +111,6 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
// Demo mode is enabled - use in-memory auth only
|
||||
console.log('[Auth] onMount: Demo mode enabled, using in-memory auth');
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: '[email protected]',
|
||||
@@ -128,8 +122,6 @@ 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,
|
||||
@@ -146,7 +138,6 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
// Apply theme
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
document.title = 'Trackeep - Demo Mode';
|
||||
console.log('[Auth] onMount: Demo mode setup complete');
|
||||
});
|
||||
|
||||
|
||||
@@ -167,23 +158,18 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
};
|
||||
|
||||
const setAuth = (token: string, user: User) => {
|
||||
console.log('[Auth] setAuth called with:', { token, 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({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
console.log('[Auth] setAuth: Auth state updated');
|
||||
// Apply theme immediately
|
||||
if (user.theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Demo mode API interceptor to provide mock data instead of making real API calls
|
||||
|
||||
import { hasAnyCredentials, isBackendAvailable, isSearchAvailable, hasSearchCredentials, hasDatabaseCredentials, hasAICredentials } from './credentials';
|
||||
import { hasAnyCredentials, isBackendAvailable, isSearchAvailable } from './credentials';
|
||||
|
||||
// Check if demo mode is enabled via environment variable
|
||||
export const isEnvDemoMode = (): boolean => {
|
||||
@@ -14,7 +14,6 @@ export const isEnvDemoMode = (): boolean => {
|
||||
const buildTimeResult = import.meta.env.VITE_DEMO_MODE === 'true';
|
||||
|
||||
const result = runtimeResult || importMetaEnvResult || buildTimeResult;
|
||||
console.log('[Demo Mode] isEnvDemoMode:', result, 'runtime:', runtimeResult, 'importMetaEnv:', importMetaEnvResult, 'buildTime:', buildTimeResult, 'VITE_DEMO_MODE:', import.meta.env.VITE_DEMO_MODE, 'window.ENV:', (window as any).ENV, 'window.importMetaEnv:', (window as any).importMetaEnv);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -29,14 +28,6 @@ export const shouldUseRealAPIs = (): boolean => {
|
||||
const hasCredentials = hasAnyCredentials();
|
||||
const hasBackend = shouldUseRealBackend();
|
||||
const result = hasCredentials || hasBackend;
|
||||
console.log('[Demo Mode] shouldUseRealAPIs:', {
|
||||
hasCredentials,
|
||||
hasBackend,
|
||||
result,
|
||||
searchCreds: hasSearchCredentials(),
|
||||
dbCreds: hasDatabaseCredentials(),
|
||||
aiCreds: hasAICredentials()
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -944,11 +935,9 @@ const parseBody = (options?: RequestInit): Record<string, any> => {
|
||||
// Demo mode fetch interceptor
|
||||
export const demoFetch = async (url: string, options?: RequestInit): Promise<Response> => {
|
||||
const shouldUseReal = shouldUseRealAPIs();
|
||||
console.log('[Demo Mode] demoFetch called:', { url, shouldUseReal });
|
||||
|
||||
if (shouldUseReal) {
|
||||
if (url.includes('youtube') && Math.random() < 0.02) {
|
||||
console.log('[Demo Mode] Real credentials detected, using real API for:', url);
|
||||
}
|
||||
|
||||
if (url.includes('youtube')) {
|
||||
@@ -970,7 +959,6 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
||||
}
|
||||
|
||||
if (!isDemoMode()) {
|
||||
console.log('[Demo Mode] Not in demo mode, using real fetch for:', url);
|
||||
return (originalFetch || window.fetch)(url, options);
|
||||
}
|
||||
|
||||
@@ -986,8 +974,6 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
||||
|
||||
const method = (options?.method || 'GET').toUpperCase();
|
||||
const body = parseBody(options);
|
||||
|
||||
console.log(`[Demo Mode] Intercepting request to: ${method} ${path}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
|
||||
// Auth endpoints
|
||||
@@ -1946,7 +1932,6 @@ export const initializeDemoMode = () => {
|
||||
// Store original fetch to use for real API calls and restore later if needed
|
||||
originalFetch = window.fetch;
|
||||
window.fetch = demoFetch as typeof fetch;
|
||||
console.log('[Demo Mode] API interceptor initialized');
|
||||
return originalFetch;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
|
||||
// Haptic feedback patterns for different interactions
|
||||
export type HapticPattern =
|
||||
| "success" // Successful action completion
|
||||
| "error" // Error or failure
|
||||
| "warning" // Warning or caution
|
||||
| "notification" // New message or notification
|
||||
| "selection" // Item selection
|
||||
| "navigation" // Page navigation
|
||||
| "impact" // Light impact for button clicks
|
||||
| "completion" // Task or form completion
|
||||
| "delete" // Delete or destructive action
|
||||
| "longPress" // Long press gesture;
|
||||
|
||||
// Check if haptics/vibration is supported
|
||||
const isSupported = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return 'vibrate' in navigator;
|
||||
};
|
||||
|
||||
// Vibration patterns for different haptic types
|
||||
const vibrationPatterns: Record<HapticPattern, number[]> = {
|
||||
success: [10, 50, 10],
|
||||
error: [100, 50, 100],
|
||||
warning: [50, 30, 50],
|
||||
notification: [20, 100, 20],
|
||||
selection: [10],
|
||||
navigation: [15],
|
||||
impact: [5],
|
||||
completion: [10, 30, 10, 30, 10],
|
||||
delete: [100],
|
||||
longPress: [50],
|
||||
};
|
||||
|
||||
// Haptic feedback utility hook for SolidJS
|
||||
export const useHaptics = () => {
|
||||
const [isSupported, setIsSupported] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
setIsSupported(isSupported());
|
||||
});
|
||||
|
||||
// Trigger haptic feedback with fallback for non-supported devices
|
||||
const triggerHaptic = (pattern: HapticPattern) => {
|
||||
if (isSupported() && navigator.vibrate) {
|
||||
try {
|
||||
navigator.vibrate(vibrationPatterns[pattern] || [10]);
|
||||
} catch (error) {
|
||||
console.debug("Haptics failed:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Specific haptic methods for common actions
|
||||
const haptics = {
|
||||
// Success feedback for completed actions
|
||||
success: () => triggerHaptic("success"),
|
||||
|
||||
// Error feedback for failed actions
|
||||
error: () => triggerHaptic("error"),
|
||||
|
||||
// Warning feedback for caution states
|
||||
warning: () => triggerHaptic("warning"),
|
||||
|
||||
// New notification or message
|
||||
notification: () => triggerHaptic("notification"),
|
||||
|
||||
// Item selection (checkboxes, radio buttons, etc.)
|
||||
selection: () => triggerHaptic("selection"),
|
||||
|
||||
// Navigation between pages or sections
|
||||
navigation: () => triggerHaptic("navigation"),
|
||||
|
||||
// Light impact for button clicks
|
||||
impact: () => triggerHaptic("impact"),
|
||||
|
||||
// Task or form completion
|
||||
completion: () => triggerHaptic("completion"),
|
||||
|
||||
// Delete or destructive actions
|
||||
delete: () => triggerHaptic("delete"),
|
||||
|
||||
// Long press gestures
|
||||
longPress: () => triggerHaptic("longPress"),
|
||||
};
|
||||
|
||||
return haptics;
|
||||
};
|
||||
|
||||
// Utility function for direct haptic triggering (outside of components)
|
||||
export const triggerHaptic = (pattern: HapticPattern) => {
|
||||
if (isSupported() && navigator.vibrate) {
|
||||
try {
|
||||
navigator.vibrate(vibrationPatterns[pattern] || [10]);
|
||||
} catch (error) {
|
||||
console.debug("Haptics failed:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
export const getOAuthCallbackUrl = (): string => {
|
||||
return new URL('/auth/callback', window.location.origin).toString();
|
||||
};
|
||||
|
||||
export const startGitHubOAuth = (): void => {
|
||||
const apiBase = getApiV1BaseUrl();
|
||||
const frontendRedirect = getOAuthCallbackUrl();
|
||||
|
||||
window.location.href =
|
||||
`${apiBase}/auth/github?frontend_redirect=${encodeURIComponent(frontendRedirect)}`;
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Bot
|
||||
} from 'lucide-solid'
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
||||
import { useHaptics } from '@/lib/haptics'
|
||||
|
||||
interface AIModel {
|
||||
id: string
|
||||
@@ -30,6 +31,7 @@ interface Message {
|
||||
}
|
||||
|
||||
export const AIChat = () => {
|
||||
const haptics = useHaptics();
|
||||
const [activeView, setActiveView] = createSignal<'chat' | 'settings'>('chat')
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
|
||||
@@ -96,6 +98,7 @@ export const AIChat = () => {
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
haptics.impact(); // Impact feedback for sending message
|
||||
|
||||
try {
|
||||
// Call AI API
|
||||
@@ -109,8 +112,10 @@ export const AIChat = () => {
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
haptics.success(); // Success feedback for AI response
|
||||
} catch (error) {
|
||||
console.error('AI API call failed:', error)
|
||||
haptics.error(); // Error feedback
|
||||
|
||||
// Fallback response
|
||||
const errorMessage: Message = {
|
||||
@@ -488,7 +493,10 @@ export const AIChat = () => {
|
||||
</div>
|
||||
<Button
|
||||
disabled={isLoading() || !inputMessage().trim()}
|
||||
onClick={handleSendMessage}
|
||||
onClick={() => {
|
||||
handleSendMessage();
|
||||
haptics.impact();
|
||||
}}
|
||||
class="rounded-xl px-4 py-2.5 bg-gradient-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary shadow-sm hover:shadow-md transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
|
||||
@@ -10,13 +10,16 @@ import {
|
||||
IconDownload,
|
||||
IconSettings
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
export const Activity = () => {
|
||||
const haptics = useHaptics();
|
||||
const [refreshKey, setRefreshKey] = createSignal(0);
|
||||
const [showFilters, setShowFilters] = createSignal(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
haptics.selection(); // Selection feedback for refresh
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -33,7 +36,10 @@ export const Activity = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters())}
|
||||
onClick={() => {
|
||||
setShowFilters(!showFilters());
|
||||
haptics.selection();
|
||||
}}
|
||||
>
|
||||
<IconFilter class="size-4 mr-2" />
|
||||
Filters
|
||||
@@ -46,7 +52,7 @@ export const Activity = () => {
|
||||
<IconRefresh class="size-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" onClick={() => haptics.impact()}>
|
||||
<IconDownload class="size-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IconChecklist
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { ColorSwitcher } from './ColorSwitcher';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
interface ProjectStats {
|
||||
totalUsers: number;
|
||||
@@ -51,6 +52,7 @@ interface GitHubActivity {
|
||||
}
|
||||
|
||||
export const AdminDashboard = () => {
|
||||
const haptics = useHaptics();
|
||||
const [stats, setStats] = createSignal<ProjectStats>({
|
||||
totalUsers: 0,
|
||||
totalDocuments: 0,
|
||||
@@ -161,18 +163,22 @@ export const AdminDashboard = () => {
|
||||
const handleBackupDatabase = async () => {
|
||||
try {
|
||||
alert('Database backup initiated successfully!');
|
||||
haptics.success(); // Success feedback for backup
|
||||
// In real app, this would call the backup API
|
||||
} catch (error) {
|
||||
alert('Failed to backup database');
|
||||
haptics.error(); // Error feedback
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageUsers = () => {
|
||||
window.open('/app/members', '_blank');
|
||||
haptics.navigation(); // Navigation feedback
|
||||
};
|
||||
|
||||
const handleSystemSettings = () => {
|
||||
window.open('/app/admin-settings', '_blank');
|
||||
haptics.navigation(); // Navigation feedback
|
||||
};
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
interface AnalyticsData {
|
||||
period: {
|
||||
@@ -117,6 +118,7 @@ interface AnalyticsData {
|
||||
}
|
||||
|
||||
export const Analytics = () => {
|
||||
const haptics = useHaptics();
|
||||
const [analytics, setAnalytics] = createSignal<AnalyticsData | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
@@ -238,14 +240,20 @@ export const Analytics = () => {
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
value={selectedPeriod()}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSelectedPeriod(e.target.value);
|
||||
haptics.selection();
|
||||
}}
|
||||
class="px-3 py-2 border rounded-md bg-background"
|
||||
>
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
</select>
|
||||
<Button onClick={fetchAnalytics}>Refresh</Button>
|
||||
<Button onClick={() => {
|
||||
fetchAnalytics();
|
||||
haptics.selection();
|
||||
}}>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,28 +1,49 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export const AuthCallback = () => {
|
||||
const [status, setStatus] = createSignal<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = createSignal('Processing authentication...');
|
||||
const { setAuth } = useAuth();
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const apiBase = getApiV1BaseUrl();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get('token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
window.history.replaceState({}, '', '/auth/callback');
|
||||
|
||||
const res = await fetch(`${apiBase}/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data?.user) {
|
||||
throw new Error('Invalid authentication response');
|
||||
}
|
||||
|
||||
setAuth(token, data.user);
|
||||
|
||||
onMount(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('token');
|
||||
|
||||
if (token) {
|
||||
// Store the token from Trackeep backend
|
||||
localStorage.setItem('token', token);
|
||||
setStatus('success');
|
||||
setMessage('Authentication successful! Redirecting...');
|
||||
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/app';
|
||||
}, 2000);
|
||||
} else {
|
||||
window.location.replace('/app');
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
setMessage('Authentication failed. Please try again.');
|
||||
setMessage(error instanceof Error ? error.message : 'Authentication failed');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
@@ -102,6 +103,8 @@ export const Bookmarks = () => {
|
||||
const [showVideoModal, setShowVideoModal] = createSignal(false);
|
||||
const [editingBookmark, setEditingBookmark] = createSignal<Bookmark | null>(null);
|
||||
const [activeTab, setActiveTab] = createSignal<'bookmarks' | 'videos'>('bookmarks');
|
||||
|
||||
const haptics = useHaptics();
|
||||
// We no longer show inline HTML content previews, only the bookmark cards themselves
|
||||
|
||||
onMount(async () => {
|
||||
@@ -229,7 +232,9 @@ export const Bookmarks = () => {
|
||||
const newBookmark = adaptBookmarkFromApi(raw);
|
||||
setBookmarks(prev => [newBookmark, ...prev]);
|
||||
setShowAddModal(false);
|
||||
haptics.success(); // Success feedback for adding bookmark
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
alert(error instanceof Error ? error.message : 'Failed to add bookmark');
|
||||
}
|
||||
};
|
||||
@@ -242,6 +247,7 @@ export const Bookmarks = () => {
|
||||
: bookmark
|
||||
)
|
||||
);
|
||||
haptics.selection(); // Selection feedback for starring
|
||||
};
|
||||
|
||||
const deleteBookmark = async (bookmarkId: number) => {
|
||||
@@ -261,7 +267,9 @@ export const Bookmarks = () => {
|
||||
}
|
||||
|
||||
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId));
|
||||
haptics.delete(); // Delete feedback
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
alert(error instanceof Error ? error.message : 'Failed to delete bookmark');
|
||||
}
|
||||
}
|
||||
@@ -356,13 +364,13 @@ export const Bookmarks = () => {
|
||||
<h1 class="text-3xl font-bold text-foreground">Bookmarks</h1>
|
||||
</div>
|
||||
<Show when={activeTab() === 'bookmarks'}>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Button onClick={() => setShowAddModal(true)} haptic="impact">
|
||||
<IconBookmark class="size-4 mr-2" />
|
||||
Add Bookmark
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'videos'}>
|
||||
<Button onClick={() => setShowVideoModal(true)}>
|
||||
<Button onClick={() => setShowVideoModal(true)} haptic="impact">
|
||||
<IconVideo class="size-4 mr-2" />
|
||||
Add Video
|
||||
</Button>
|
||||
@@ -373,7 +381,10 @@ export const Bookmarks = () => {
|
||||
<div class="border-b border-border">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('bookmarks')}
|
||||
onClick={() => {
|
||||
setActiveTab('bookmarks');
|
||||
haptics.navigation();
|
||||
}}
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||
activeTab() === 'bookmarks'
|
||||
? 'border-primary text-primary'
|
||||
@@ -384,7 +395,10 @@ export const Bookmarks = () => {
|
||||
Web Bookmarks
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('videos')}
|
||||
onClick={() => {
|
||||
setActiveTab('videos');
|
||||
haptics.navigation();
|
||||
}}
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||
activeTab() === 'videos'
|
||||
? 'border-primary text-primary'
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { getMockCalendarEvents } from '@/lib/mockData';
|
||||
import { isDemoMode as isDemoModeEnabled } from '@/lib/demo-mode';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: number
|
||||
@@ -52,6 +53,7 @@ interface NewEvent {
|
||||
}
|
||||
|
||||
export function Calendar() {
|
||||
const haptics = useHaptics();
|
||||
const [upcomingEvents, setUpcomingEvents] = createSignal<CalendarEvent[]>([])
|
||||
const [todayEvents, setTodayEvents] = createSignal<CalendarEvent[]>([])
|
||||
const [deadlines, setDeadlines] = createSignal<CalendarEvent[]>([])
|
||||
@@ -238,6 +240,7 @@ export function Calendar() {
|
||||
is_all_day: false
|
||||
});
|
||||
fetchCalendarData();
|
||||
haptics.success(); // Success feedback for event creation
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,6 +269,9 @@ export function Calendar() {
|
||||
is_all_day: false
|
||||
})
|
||||
fetchCalendarData()
|
||||
haptics.success(); // Success feedback for event creation
|
||||
} else {
|
||||
haptics.error(); // Error feedback
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create event:', error)
|
||||
@@ -281,7 +287,7 @@ export function Calendar() {
|
||||
location: '',
|
||||
is_all_day: false
|
||||
});
|
||||
fetchCalendarData();
|
||||
haptics.error(); // Error feedback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +297,7 @@ export function Calendar() {
|
||||
// Simulate event completion toggle in demo mode
|
||||
console.log('Toggling event completion (demo mode):', eventId);
|
||||
fetchCalendarData();
|
||||
haptics.completion(); // Completion feedback
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,11 +314,15 @@ export function Calendar() {
|
||||
|
||||
if (response.ok) {
|
||||
fetchCalendarData()
|
||||
haptics.completion(); // Completion feedback
|
||||
} else {
|
||||
haptics.error(); // Error feedback
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle event completion:', error)
|
||||
// Fallback to demo mode behavior
|
||||
fetchCalendarData();
|
||||
haptics.error(); // Error feedback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +396,10 @@ export function Calendar() {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openEventModal(new Date())}
|
||||
onClick={() => {
|
||||
openEventModal(new Date());
|
||||
haptics.impact();
|
||||
}}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<IconPlus class="size-4" />
|
||||
@@ -400,7 +414,10 @@ export function Calendar() {
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigateMonth(-1)}
|
||||
onClick={() => {
|
||||
navigateMonth(-1);
|
||||
haptics.selection();
|
||||
}}
|
||||
class="p-2 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<IconChevronLeft class="size-4" />
|
||||
@@ -409,7 +426,10 @@ export function Calendar() {
|
||||
{currentDate().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => navigateMonth(1)}
|
||||
onClick={() => {
|
||||
navigateMonth(1);
|
||||
haptics.selection();
|
||||
}}
|
||||
class="p-2 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<IconChevronRight class="size-4" />
|
||||
@@ -417,7 +437,10 @@ export function Calendar() {
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onClick={() => setView('month')}
|
||||
onClick={() => {
|
||||
setView('month');
|
||||
haptics.selection();
|
||||
}}
|
||||
class={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
view() === 'month'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
@@ -427,7 +450,10 @@ export function Calendar() {
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('week')}
|
||||
onClick={() => {
|
||||
setView('week');
|
||||
haptics.selection();
|
||||
}}
|
||||
class={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
view() === 'week'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
@@ -437,7 +463,10 @@ export function Calendar() {
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('day')}
|
||||
onClick={() => {
|
||||
setView('day');
|
||||
haptics.selection();
|
||||
}}
|
||||
class={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
view() === 'day'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from '@/lib/credentials';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
@@ -174,6 +175,7 @@ const deriveFileType = (fileName: string): string => {
|
||||
|
||||
export const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const haptics = useHaptics();
|
||||
const [documents, setDocuments] = createSignal<Document[]>([]);
|
||||
const [, setRecentActivity] = createSignal<RecentActivity[]>([]);
|
||||
const [githubActivityEvents, setGithubActivityEvents] = createSignal<GitHubActivityEvent[]>([]);
|
||||
@@ -810,7 +812,10 @@ export const Dashboard = () => {
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
onClick={() => {
|
||||
setShowUploadModal(true);
|
||||
haptics.impact();
|
||||
}}
|
||||
class="w-full h-32 inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 items-start flex-col gap-4 py-6 px-6 text-left"
|
||||
>
|
||||
<IconUpload class="size-6" />
|
||||
@@ -824,7 +829,10 @@ export const Dashboard = () => {
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVideoModal(true)}
|
||||
onClick={() => {
|
||||
setShowVideoModal(true);
|
||||
haptics.impact();
|
||||
}}
|
||||
class="w-full inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-secondary text-secondary-foreground shadow hover:bg-secondary/90 items-start flex-col gap-4 py-4 px-4 text-left"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tabler-icon tabler-icon-video size-5">
|
||||
@@ -840,7 +848,10 @@ export const Dashboard = () => {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBookmarkModal(true)}
|
||||
onClick={() => {
|
||||
setShowBookmarkModal(true);
|
||||
haptics.impact();
|
||||
}}
|
||||
class="w-full inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-secondary text-secondary-foreground shadow hover:bg-secondary/90 items-start flex-col gap-4 py-4 px-4 text-left"
|
||||
>
|
||||
<IconBookmark class="size-5" />
|
||||
@@ -922,7 +933,10 @@ export const Dashboard = () => {
|
||||
<h3 class="font-semibold">Activity Feed</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActivityRefreshKey(prev => prev + 1)}
|
||||
onClick={() => {
|
||||
setActivityRefreshKey(prev => prev + 1);
|
||||
haptics.selection();
|
||||
}}
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-8 w-8"
|
||||
>
|
||||
<IconRefresh class="size-4" />
|
||||
@@ -993,11 +1007,12 @@ export const Dashboard = () => {
|
||||
{stats().topTags.map((tag) => (
|
||||
<button
|
||||
class="inline-flex gap-2 px-2.5 py-1 rounded-lg items-center bg-muted group hover:underline text-xs"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
navigate(
|
||||
`/app/search?tag=${encodeURIComponent(tag.name)}&query=${encodeURIComponent(tag.name)}`
|
||||
)
|
||||
}
|
||||
);
|
||||
haptics.navigation();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="size-1.5 rounded-full"
|
||||
|
||||
+754
-225
File diff suppressed because it is too large
Load Diff
+470
-101
@@ -3,6 +3,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { GitHubActivity } from '@/components/ui/GitHubActivity';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { startGitHubOAuth } from '@/lib/oauth';
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconTrendingUp,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
IconRefresh,
|
||||
IconActivity
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
interface GitHubRepo {
|
||||
id: number;
|
||||
@@ -32,6 +34,40 @@ interface GitHubRepo {
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
interface GitHubAppInstallation {
|
||||
installation_id: number;
|
||||
account_login: string;
|
||||
account_type: string;
|
||||
}
|
||||
|
||||
interface GitHubAppStatus {
|
||||
app_slug: string;
|
||||
install_enabled: boolean;
|
||||
credentials_configured: boolean;
|
||||
installed: boolean;
|
||||
installation?: GitHubAppInstallation;
|
||||
}
|
||||
|
||||
interface GitHubBackupRecord {
|
||||
id: number;
|
||||
repository_full_name: string;
|
||||
local_path: string;
|
||||
source: string;
|
||||
last_backup_status: string;
|
||||
last_backup_error?: string;
|
||||
last_backup_at?: string;
|
||||
last_backup_size: number;
|
||||
}
|
||||
|
||||
interface GitHubBackupResult {
|
||||
repository: string;
|
||||
status: string;
|
||||
local_path?: string;
|
||||
source: string;
|
||||
size_bytes?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GitHubStats {
|
||||
totalRepos: number;
|
||||
totalStars: number;
|
||||
@@ -54,6 +90,7 @@ interface GitHubStats {
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
export const GitHub = () => {
|
||||
const haptics = useHaptics();
|
||||
const [githubStats, setGithubStats] = createSignal<GitHubStats>({
|
||||
totalRepos: 0,
|
||||
totalStars: 0,
|
||||
@@ -65,13 +102,40 @@ export const GitHub = () => {
|
||||
});
|
||||
|
||||
const [weeklyActivity, setWeeklyActivity] = createSignal([0, 0, 0, 0, 0, 0, 0]);
|
||||
|
||||
const [username, setUsername] = createSignal('');
|
||||
const [isConnected, setIsConnected] = createSignal(false);
|
||||
const [appStatus, setAppStatus] = createSignal<GitHubAppStatus>({
|
||||
app_slug: '',
|
||||
install_enabled: false,
|
||||
credentials_configured: false,
|
||||
installed: false
|
||||
});
|
||||
const [backups, setBackups] = createSignal<GitHubBackupRecord[]>([]);
|
||||
const [backupRoot, setBackupRoot] = createSignal('');
|
||||
const [selectedRepos, setSelectedRepos] = createSignal<string[]>([]);
|
||||
const [isBackingUp, setIsBackingUp] = createSignal(false);
|
||||
const [isInstallingApp, setIsInstallingApp] = createSignal(false);
|
||||
const [backupMessage, setBackupMessage] = createSignal('');
|
||||
const [backupError, setBackupError] = createSignal('');
|
||||
|
||||
const weeklyTotal = () => weeklyActivity().reduce((a, b) => a + b, 0);
|
||||
const selectedCount = () => selectedRepos().length;
|
||||
|
||||
const getAuthToken = () => {
|
||||
return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Check if user is authenticated and has GitHub connected
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('github_app_installed') === '1') {
|
||||
setBackupMessage('GitHub App installation completed successfully.');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
const callbackError = params.get('github_app_error');
|
||||
if (callbackError) {
|
||||
setBackupError(`GitHub App setup failed: ${callbackError}`);
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
checkGitHubConnection();
|
||||
});
|
||||
|
||||
@@ -86,86 +150,12 @@ export const GitHub = () => {
|
||||
recentActivity: [],
|
||||
repos: []
|
||||
});
|
||||
};
|
||||
|
||||
const checkGitHubConnection = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
resetGitHubData();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
if (userData.user.github_id) {
|
||||
setIsConnected(true);
|
||||
setUsername(userData.user.username);
|
||||
await fetchGitHubStats();
|
||||
} else {
|
||||
resetGitHubData();
|
||||
}
|
||||
} else {
|
||||
resetGitHubData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check GitHub connection:', error);
|
||||
resetGitHubData();
|
||||
}
|
||||
};
|
||||
const fetchGitHubStats = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('No authentication token');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/github/repos`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch GitHub stats');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const repos = Array.isArray(data) ? data : (Array.isArray(data?.repos) ? data.repos : []);
|
||||
|
||||
// Process real GitHub data
|
||||
const languages = processLanguages(repos);
|
||||
const recentActivity = generateRecentActivity(repos);
|
||||
|
||||
const totalStars = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.stargazers_count, 0);
|
||||
const totalForks = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.forks_count, 0);
|
||||
const totalWatchers = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.watchers_count, 0);
|
||||
|
||||
setGithubStats({
|
||||
totalRepos: repos.length,
|
||||
totalStars,
|
||||
totalForks,
|
||||
totalWatchers,
|
||||
languages,
|
||||
recentActivity,
|
||||
repos
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub stats:', error);
|
||||
resetGitHubData();
|
||||
}
|
||||
setSelectedRepos([]);
|
||||
};
|
||||
|
||||
const processLanguages = (repos: GitHubRepo[]) => {
|
||||
const languageMap = new Map<string, number>();
|
||||
|
||||
|
||||
repos.forEach(repo => {
|
||||
if (repo.language) {
|
||||
languageMap.set(repo.language, (languageMap.get(repo.language) || 0) + 1);
|
||||
@@ -180,8 +170,7 @@ export const GitHub = () => {
|
||||
};
|
||||
|
||||
const generateRecentActivity = (repos: GitHubRepo[]) => {
|
||||
// Sort repos by updated_at and take recent ones
|
||||
const sortedRepos = repos
|
||||
const sortedRepos = [...repos]
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
@@ -193,15 +182,291 @@ export const GitHub = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const updateStatsFromRepos = (repos: GitHubRepo[]) => {
|
||||
const languages = processLanguages(repos);
|
||||
const recentActivity = generateRecentActivity(repos);
|
||||
const totalStars = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.stargazers_count, 0);
|
||||
const totalForks = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.forks_count, 0);
|
||||
const totalWatchers = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.watchers_count, 0);
|
||||
|
||||
setGithubStats({
|
||||
totalRepos: repos.length,
|
||||
totalStars,
|
||||
totalForks,
|
||||
totalWatchers,
|
||||
languages,
|
||||
recentActivity,
|
||||
repos
|
||||
});
|
||||
setSelectedRepos(prev => prev.filter(repoName => repos.some(repo => repo.full_name === repoName)));
|
||||
};
|
||||
|
||||
const extractRepos = (payload: unknown): GitHubRepo[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as GitHubRepo[];
|
||||
}
|
||||
if (payload && typeof payload === 'object' && Array.isArray((payload as { repos?: unknown[] }).repos)) {
|
||||
return (payload as { repos: GitHubRepo[] }).repos;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const fetchGitHubAppStatus = async (): Promise<GitHubAppStatus | null> => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/github/app/status`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GitHubAppStatus;
|
||||
setAppStatus({
|
||||
app_slug: data.app_slug || '',
|
||||
install_enabled: Boolean(data.install_enabled),
|
||||
credentials_configured: Boolean(data.credentials_configured),
|
||||
installed: Boolean(data.installed),
|
||||
installation: data.installation
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub App status:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubBackups = async () => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/github/backups`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const backupList = Array.isArray(data?.backups) ? (data.backups as GitHubBackupRecord[]) : [];
|
||||
setBackups(backupList);
|
||||
setBackupRoot(typeof data?.backup_root === 'string' ? data.backup_root : '');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub backups:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRepos = async (endpoint: string): Promise<boolean> => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error('No authentication token');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch GitHub repositories');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const repos = extractRepos(data);
|
||||
updateStatsFromRepos(repos);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub repositories:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubStats = async () => {
|
||||
const loaded = await fetchRepos('/github/repos');
|
||||
if (!loaded) {
|
||||
resetGitHubData();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubAppRepos = async () => {
|
||||
const loaded = await fetchRepos('/github/app/repos');
|
||||
if (!loaded) {
|
||||
resetGitHubData();
|
||||
}
|
||||
};
|
||||
|
||||
const checkGitHubConnection = async () => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
setIsConnected(false);
|
||||
setUsername('');
|
||||
setAppStatus({
|
||||
app_slug: '',
|
||||
install_enabled: false,
|
||||
credentials_configured: false,
|
||||
installed: false
|
||||
});
|
||||
setBackups([]);
|
||||
setBackupRoot('');
|
||||
setBackupMessage('');
|
||||
setBackupError('');
|
||||
resetGitHubData();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const appInfo = await fetchGitHubAppStatus();
|
||||
await fetchGitHubBackups();
|
||||
if (!response.ok) {
|
||||
resetGitHubData();
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
const hasOAuthConnection = Boolean(userData?.user?.github_id);
|
||||
setUsername(typeof userData?.user?.username === 'string' ? userData.user.username : '');
|
||||
setIsConnected(hasOAuthConnection);
|
||||
|
||||
if (hasOAuthConnection) {
|
||||
await fetchGitHubStats();
|
||||
return;
|
||||
}
|
||||
|
||||
if (appInfo?.installed && appInfo.credentials_configured) {
|
||||
await fetchGitHubAppRepos();
|
||||
return;
|
||||
}
|
||||
|
||||
resetGitHubData();
|
||||
} catch (error) {
|
||||
console.error('Failed to check GitHub connection:', error);
|
||||
resetGitHubData();
|
||||
}
|
||||
};
|
||||
|
||||
const connectGitHub = () => {
|
||||
// Redirect to centralized OAuth service
|
||||
window.location.href = 'https://oauth.trackeep.org/auth/github?redirect_uri=' + encodeURIComponent(window.location.origin + '/api/v1/auth/oauth/callback');
|
||||
startGitHubOAuth();
|
||||
};
|
||||
|
||||
const installGitHubApp = async () => {
|
||||
try {
|
||||
setIsInstallingApp(true);
|
||||
setBackupError('');
|
||||
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error('No authentication token');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/github/app/install-url`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.install_url) {
|
||||
const message = typeof data?.error === 'string' ? data.error : 'Failed to create install URL';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
window.location.href = data.install_url as string;
|
||||
} catch (error) {
|
||||
console.error('Failed to start GitHub App installation:', error);
|
||||
setBackupError(error instanceof Error ? error.message : 'Failed to start GitHub App installation');
|
||||
haptics.warning();
|
||||
} finally {
|
||||
setIsInstallingApp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRepoSelection = (repoFullName: string) => {
|
||||
setSelectedRepos(prev => (
|
||||
prev.includes(repoFullName)
|
||||
? prev.filter(name => name !== repoFullName)
|
||||
: [...prev, repoFullName]
|
||||
));
|
||||
};
|
||||
|
||||
const selectAllRepos = () => {
|
||||
setSelectedRepos(githubStats().repos.map(repo => repo.full_name));
|
||||
};
|
||||
|
||||
const clearRepoSelection = () => {
|
||||
setSelectedRepos([]);
|
||||
};
|
||||
|
||||
const backupSelectedRepositories = async () => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error('No authentication token');
|
||||
}
|
||||
if (selectedRepos().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBackingUp(true);
|
||||
setBackupMessage('');
|
||||
setBackupError('');
|
||||
|
||||
const source = appStatus().installed ? 'github_app' : 'oauth';
|
||||
const response = await fetch(`${API_BASE_URL}/github/backups`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repositories: selectedRepos(),
|
||||
source
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorMessage = typeof data?.error === 'string' ? data.error : 'Backup failed';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const results = Array.isArray(data?.results) ? (data.results as GitHubBackupResult[]) : [];
|
||||
const failed = Number(data?.failed ?? 0);
|
||||
const backedUp = Number(data?.backed_up ?? 0);
|
||||
const firstFailure = results.find(result => result.status !== 'success');
|
||||
if (failed > 0 && firstFailure?.error) {
|
||||
setBackupError(firstFailure.error);
|
||||
}
|
||||
|
||||
setBackupMessage(`Backups completed: ${backedUp} succeeded, ${failed} failed.`);
|
||||
await fetchGitHubBackups();
|
||||
haptics.impact();
|
||||
} catch (error) {
|
||||
console.error('Failed to backup repositories:', error);
|
||||
setBackupError(error instanceof Error ? error.message : 'Failed to backup repositories');
|
||||
haptics.warning();
|
||||
} finally {
|
||||
setIsBackingUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectGitHub = async () => {
|
||||
try {
|
||||
// In a real implementation, you might want to disconnect the GitHub account
|
||||
// For now, we'll just clear the local state
|
||||
setIsConnected(false);
|
||||
setUsername('');
|
||||
resetGitHubData();
|
||||
@@ -228,18 +493,25 @@ export const GitHub = () => {
|
||||
<p class="text-muted-foreground mt-2">Track your GitHub repositories and activity</p>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
checkGitHubConnection();
|
||||
haptics.selection();
|
||||
}}>
|
||||
<IconRefresh class="size-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
{isConnected() ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchGitHubStats()}>
|
||||
<IconRefresh class="size-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={disconnectGitHub}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
disconnectGitHub();
|
||||
haptics.warning();
|
||||
}}>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={connectGitHub}>
|
||||
<Button onClick={() => {
|
||||
connectGitHub();
|
||||
haptics.impact();
|
||||
}}>
|
||||
<IconBrandGithub class="size-4 mr-2" />
|
||||
Connect GitHub
|
||||
</Button>
|
||||
@@ -248,20 +520,61 @@ export const GitHub = () => {
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
{isConnected() && (
|
||||
{(isConnected() || (appStatus().installed && appStatus().credentials_configured)) && (
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-2 rounded-lg">
|
||||
<IconBrandGithub class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Connected as @{username()}</p>
|
||||
<p class="text-xs text-muted-foreground">Syncing data from GitHub API</p>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{isConnected() ? `Connected via OAuth as @${username()}` : `Connected via GitHub App as @${username()}`}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{isConnected() ? 'Syncing data from GitHub OAuth API' : 'Syncing data from GitHub App installation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* GitHub App Status */}
|
||||
<Card class="p-4">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">GitHub App Backup Access</p>
|
||||
{appStatus().install_enabled ? (
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{appStatus().installed
|
||||
? `Installed${appStatus().installation?.account_login ? ` for ${appStatus().installation?.account_login}` : ''}`
|
||||
: 'Not installed yet'}
|
||||
</p>
|
||||
) : (
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
GitHub App install is not configured on this server.
|
||||
</p>
|
||||
)}
|
||||
{appStatus().install_enabled && !appStatus().credentials_configured && (
|
||||
<p class="text-xs text-amber-500 mt-1">
|
||||
App credentials are missing (`GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={() => fetchGitHubAppStatus()}>
|
||||
Refresh App Status
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!appStatus().install_enabled || isInstallingApp()}
|
||||
onClick={() => installGitHubApp()}
|
||||
>
|
||||
{isInstallingApp() ? 'Opening...' : (appStatus().installed ? 'Reinstall GitHub App' : 'Install GitHub App')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stats Overview - 2-column layout with larger left column */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Column - Main Stats */}
|
||||
@@ -456,15 +769,66 @@ export const GitHub = () => {
|
||||
|
||||
{/* Repositories */}
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Repositories</h3>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Repositories</h3>
|
||||
{githubStats().repos.length > 0 && (
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={() => selectAllRepos()}>
|
||||
Select All
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => clearRepoSelection()} disabled={selectedCount() === 0}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => backupSelectedRepositories()}
|
||||
disabled={selectedCount() === 0 || isBackingUp()}
|
||||
>
|
||||
{isBackingUp() ? 'Backing Up...' : `Backup Selected (${selectedCount()})`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{backupMessage() && (
|
||||
<p class="text-sm text-emerald-500 mb-3">{backupMessage()}</p>
|
||||
)}
|
||||
{backupError() && (
|
||||
<p class="text-sm text-red-500 mb-3">{backupError()}</p>
|
||||
)}
|
||||
{backupRoot() && (
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
Backup root: <span class="font-mono break-all">{backupRoot()}</span>
|
||||
</p>
|
||||
)}
|
||||
{backups().length > 0 && (
|
||||
<div class="mb-4 p-3 border border-border rounded-lg bg-muted/30 space-y-2">
|
||||
<p class="text-xs font-medium text-foreground">Recent Local Backups</p>
|
||||
{backups().slice(0, 5).map((backup) => (
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span class="truncate pr-2">{backup.repository_full_name}</span>
|
||||
<span class={backup.last_backup_status === 'success' ? 'text-emerald-500' : 'text-red-500'}>
|
||||
{backup.last_backup_status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{githubStats().repos.length === 0 ? (
|
||||
<p class="text-sm text-muted-foreground">No repositories available yet.</p>
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{githubStats().repos.map((repo) => (
|
||||
<div class="border border-border rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border accent-primary"
|
||||
checked={selectedRepos().includes(repo.full_name)}
|
||||
onChange={() => toggleRepoSelection(repo.full_name)}
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="text-lg font-medium text-foreground">{repo.name}</h4>
|
||||
{repo.language && (
|
||||
@@ -493,7 +857,12 @@ export const GitHub = () => {
|
||||
<span>Updated {formatDate(repo.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
|
||||
aria-label={`Open ${repo.full_name}`}
|
||||
>
|
||||
<IconExternalLink class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
IconBrain,
|
||||
IconBook
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
@@ -65,6 +66,7 @@ interface LearningPath {
|
||||
}
|
||||
|
||||
export const LearningPaths = () => {
|
||||
const haptics = useHaptics();
|
||||
const [learningPaths, setLearningPaths] = createSignal<LearningPath[]>([]);
|
||||
const [categories, setCategories] = createSignal<string[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
@@ -207,6 +209,7 @@ export const LearningPaths = () => {
|
||||
setEnrolledPaths(prev => new Set(prev).add(pathId));
|
||||
setSuccessMessage('Successfully enrolled in learning path!');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
haptics.success(); // Success feedback for enrollment
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -221,6 +224,7 @@ export const LearningPaths = () => {
|
||||
setEnrolledPaths(prev => new Set(prev).add(pathId));
|
||||
setSuccessMessage('Successfully enrolled in learning path!');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
haptics.success(); // Success feedback for enrollment
|
||||
} else {
|
||||
throw new Error('Failed to enroll');
|
||||
}
|
||||
@@ -228,6 +232,7 @@ export const LearningPaths = () => {
|
||||
console.error('Error enrolling in learning path:', error);
|
||||
setErrorMessage('Failed to enroll. Please try again.');
|
||||
setTimeout(() => setErrorMessage(''), 3000);
|
||||
haptics.error(); // Error feedback
|
||||
}
|
||||
};
|
||||
|
||||
@@ -327,7 +332,10 @@ export const LearningPaths = () => {
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
|
||||
<Button onClick={handleSearch} class="whitespace-nowrap">
|
||||
<Button onClick={() => {
|
||||
handleSearch();
|
||||
haptics.selection();
|
||||
}} class="whitespace-nowrap">
|
||||
<IconFilter class="size-4 mr-2" />
|
||||
Apply Filters
|
||||
</Button>
|
||||
@@ -442,6 +450,7 @@ export const LearningPaths = () => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnroll(path.id);
|
||||
haptics.impact();
|
||||
}}
|
||||
disabled={enrolledPaths().has(path.id)}
|
||||
class="flex-1"
|
||||
@@ -465,7 +474,7 @@ export const LearningPaths = () => {
|
||||
setSearchTerm('');
|
||||
setSelectedCategory('');
|
||||
setSelectedDifficulty('');
|
||||
fetchData();
|
||||
haptics.selection();
|
||||
}}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { createSignal, onMount } from 'solid-js';
|
||||
import { useAuth, type LoginRequest, type RegisterRequest } from '@/lib/auth';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { startGitHubOAuth } from '@/lib/oauth';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { IconBrandGithub } from '@tabler/icons-solidjs';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
@@ -81,6 +83,10 @@ export const Login = () => {
|
||||
fullName: '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Default to login mode if backend returns error
|
||||
setIsLogin(true);
|
||||
setRegistrationDisabled(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to check if users exist:', err);
|
||||
@@ -283,6 +289,21 @@ export const Login = () => {
|
||||
>
|
||||
{loading() ? 'Please wait...' : isLogin() ? 'Sign In' : 'Sign Up'}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-[#262626]"></div>
|
||||
<span class="text-xs uppercase tracking-[0.2em] text-[#6b7280]">or</span>
|
||||
<div class="h-px flex-1 bg-[#262626]"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGitHubOAuth}
|
||||
class="w-full flex items-center justify-center gap-2 bg-[#0f0f10] text-[#fafafa] py-2 px-4 rounded-md border border-[#262626] hover:border-[#39b9ff]/50 hover:bg-[#18181b] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:ring-offset-2 focus:ring-offset-[#141415] transition-colors"
|
||||
>
|
||||
<IconBrandGithub class="size-4" />
|
||||
Continue with GitHub
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
IconUpload,
|
||||
IconX,
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
import './Messages.css';
|
||||
|
||||
interface MemberOption {
|
||||
@@ -158,6 +159,7 @@ type TriStateFilter = 'any' | 'yes' | 'no';
|
||||
type CallStatus = 'idle' | 'starting' | 'calling' | 'in_call' | 'error';
|
||||
|
||||
export const Messages = () => {
|
||||
const haptics = useHaptics();
|
||||
const [conversations, setConversations] = createSignal<ConversationListItem[]>([]);
|
||||
const [messages, setMessages] = createSignal<Message[]>([]);
|
||||
const [selectedConversationId, setSelectedConversationId] = createSignal<number | null>(null);
|
||||
@@ -283,6 +285,7 @@ export const Messages = () => {
|
||||
const openConversation = (conversationId: number) => {
|
||||
setSelectedConversationId(conversationId);
|
||||
setActiveScreen('conversation');
|
||||
haptics.selection(); // Selection feedback for opening conversation
|
||||
};
|
||||
|
||||
const closeConversation = () => {
|
||||
@@ -1680,8 +1683,10 @@ export const Messages = () => {
|
||||
setMentionOptions([]);
|
||||
setMentionOpen(false);
|
||||
setMentionQuery('');
|
||||
haptics.success(); // Success feedback for sending message
|
||||
} catch (error) {
|
||||
toast.error('Failed to send message', error instanceof Error ? error.message : 'Unknown error');
|
||||
haptics.error(); // Error feedback
|
||||
} finally {
|
||||
setUploadProgress(null);
|
||||
}
|
||||
@@ -1943,7 +1948,9 @@ export const Messages = () => {
|
||||
onCleanup(() => clearInterval(wsWatcher));
|
||||
|
||||
return (
|
||||
<div class={`messages-shell ${activeScreen() === 'conversation' ? 'messages-shell-conversation' : 'messages-shell-list'}`}>
|
||||
<div class="mt-4 pb-32 max-w-7xl mx-auto">
|
||||
<div class="bg-background rounded-lg border shadow-sm">
|
||||
<div class={`messages-shell ${activeScreen() === 'conversation' ? 'messages-shell-conversation' : 'messages-shell-list'}`}>
|
||||
<aside class="messages-sidebar">
|
||||
<div class="messages-sidebar-header">
|
||||
<div class="messages-title-row">
|
||||
@@ -2021,7 +2028,10 @@ export const Messages = () => {
|
||||
<section class="messages-main">
|
||||
<header class="messages-main-header">
|
||||
<div class="messages-header-main">
|
||||
<Button size="sm" variant="ghost" class="messages-back-button" onClick={closeConversation}>
|
||||
<Button size="sm" variant="ghost" class="messages-back-button" onClick={() => {
|
||||
closeConversation();
|
||||
haptics.navigation();
|
||||
}}>
|
||||
<IconChevronLeft class="size-4" />
|
||||
</Button>
|
||||
<div class="messages-header-meta">
|
||||
@@ -2491,7 +2501,10 @@ export const Messages = () => {
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => sendMessage()}
|
||||
onClick={() => {
|
||||
sendMessage();
|
||||
haptics.impact();
|
||||
}}
|
||||
disabled={
|
||||
sendingMessage() ||
|
||||
(!inputText().trim() && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0)
|
||||
@@ -2976,6 +2989,8 @@ export const Messages = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IconPin, IconTrash, IconEdit, IconCopy, IconDownload, IconPaperclip } f
|
||||
import { getMockNotes } from '@/lib/mockData';
|
||||
import { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
@@ -66,6 +67,7 @@ const renderPlainTextPreviewHtml = (content: string): string => {
|
||||
};
|
||||
|
||||
export const Notes = () => {
|
||||
const haptics = useHaptics();
|
||||
const [notes, setNotes] = createSignal<Note[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
@@ -518,7 +520,10 @@ export const Notes = () => {
|
||||
<p class="text-sm text-[#b9b9b9]">Your personal notes</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
onClick={() => {
|
||||
setShowAddModal(true);
|
||||
haptics.impact();
|
||||
}}
|
||||
class="bg-[#5865f2] hover:bg-[#4752c4] text-white border-0"
|
||||
>
|
||||
Add Note
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { GitHubActivity } from '@/components/ui/GitHubActivity';
|
||||
import { IconSettings } from '@tabler/icons-solidjs';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
export const Profile = () => {
|
||||
const haptics = useHaptics();
|
||||
// Custom events for Profile page
|
||||
const profileEvents = [
|
||||
{
|
||||
@@ -72,7 +74,7 @@ export const Profile = () => {
|
||||
<p class="text-muted-foreground mt-2">Track your contributions and activity over time</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" onClick={() => haptics.impact()}>
|
||||
<IconSettings class="size-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
@@ -4,9 +4,11 @@ import { IconUser, IconLock, IconKey, IconBrain, IconMail, IconSend, IconShield,
|
||||
import { TwoFactorAuth } from '@/components/TwoFactorAuth';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
export const Settings = () => {
|
||||
const { authState, updateProfile, changePassword } = useAuth();
|
||||
const haptics = useHaptics();
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [message, setMessage] = createSignal('');
|
||||
const [profileData, setProfileData] = createSignal({
|
||||
@@ -276,8 +278,10 @@ export const Settings = () => {
|
||||
localStorage.setItem('showBrowserSearch', profileData().showBrowserSearch.toString());
|
||||
|
||||
setMessage('Profile updated successfully!');
|
||||
haptics.success();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to update profile');
|
||||
haptics.error();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -286,6 +290,7 @@ export const Settings = () => {
|
||||
const handleChangePassword = async () => {
|
||||
if (passwordData().newPassword !== passwordData().confirmPassword) {
|
||||
setMessage('New passwords do not match');
|
||||
haptics.warning();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -299,8 +304,10 @@ export const Settings = () => {
|
||||
});
|
||||
setMessage('Password changed successfully!');
|
||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
haptics.success();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to change password');
|
||||
haptics.error();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -358,7 +365,10 @@ export const Settings = () => {
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
haptics.selection();
|
||||
}}
|
||||
class={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab() === tab.id
|
||||
? 'border-primary text-primary'
|
||||
@@ -480,7 +490,7 @@ export const Settings = () => {
|
||||
type="button"
|
||||
onClick={handleUpdateProfile}
|
||||
disabled={isLoading()}
|
||||
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4 w-full"
|
||||
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-10 px-4 py-2 disabled:opacity-50"
|
||||
>
|
||||
{isLoading() ? 'Updating...' : 'Update Profile'}
|
||||
</button>
|
||||
@@ -626,8 +636,9 @@ export const Settings = () => {
|
||||
...settings,
|
||||
mistral: { ...settings.mistral, enabled: !settings.mistral.enabled }
|
||||
});
|
||||
haptics.selection();
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
class="justify-start"
|
||||
>
|
||||
<AIProviderIcon
|
||||
providerId="mistral"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { TaskModal } from '@/components/ui/TaskModal';
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
@@ -27,6 +28,8 @@ export const Tasks = () => {
|
||||
const [filter, setFilter] = createSignal<'all' | 'active' | 'completed'>('all');
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [selectedPriority, setSelectedPriority] = createSignal('');
|
||||
|
||||
const haptics = useHaptics();
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -91,7 +94,9 @@ export const Tasks = () => {
|
||||
const newTask = await response.json();
|
||||
setTasks(prev => [newTask, ...prev]);
|
||||
setShowAddModal(false);
|
||||
haptics.success(); // Success feedback for adding task
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
alert(error instanceof Error ? error.message : 'Failed to add task');
|
||||
}
|
||||
};
|
||||
@@ -122,7 +127,9 @@ export const Tasks = () => {
|
||||
);
|
||||
setShowEditModal(false);
|
||||
setEditingTask(null);
|
||||
haptics.success(); // Success feedback for editing task
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
alert(error instanceof Error ? error.message : 'Failed to update task');
|
||||
}
|
||||
};
|
||||
@@ -133,7 +140,9 @@ export const Tasks = () => {
|
||||
setTasks(prev => prev.map(task =>
|
||||
task.id === taskId ? { ...task, completed: !task.completed } : task
|
||||
));
|
||||
haptics.completion(); // Completion feedback for toggling task
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
console.error('Failed to update task:', error);
|
||||
}
|
||||
};
|
||||
@@ -154,7 +163,9 @@ export const Tasks = () => {
|
||||
}
|
||||
|
||||
setTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
haptics.delete(); // Delete feedback
|
||||
} catch (error) {
|
||||
haptics.error(); // Error feedback
|
||||
alert(error instanceof Error ? error.message : 'Failed to delete task');
|
||||
}
|
||||
}
|
||||
@@ -188,7 +199,7 @@ export const Tasks = () => {
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-[#fafafa]">Tasks</h1>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Button onClick={() => setShowAddModal(true)} haptic="impact">
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
@@ -245,6 +256,7 @@ export const Tasks = () => {
|
||||
variant={filter() === filterOption ? 'default' : 'outline'}
|
||||
onClick={() => setFilter(filterOption)}
|
||||
class="capitalize"
|
||||
haptic="selection"
|
||||
>
|
||||
{filterOption}
|
||||
</Button>
|
||||
@@ -297,6 +309,7 @@ export const Tasks = () => {
|
||||
editTask(task);
|
||||
}}
|
||||
class="text-blue-400 hover:text-blue-300"
|
||||
haptic="impact"
|
||||
>
|
||||
<IconEdit class="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -308,6 +321,7 @@ export const Tasks = () => {
|
||||
deleteTask(task.id);
|
||||
}}
|
||||
class="text-red-400 hover:text-red-300"
|
||||
haptic="warning"
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { TimeEntriesList } from '@/components/TimeEntriesList';
|
||||
import { type TimeEntry, timeEntriesApi, demoTimeEntriesApi } from '@/lib/api';
|
||||
import { IconClock, IconActivity, IconCurrencyDollar } from '@tabler/icons-solidjs';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
export const TimeTracking = () => {
|
||||
const haptics = useHaptics();
|
||||
const [refreshTrigger, setRefreshTrigger] = createSignal(0);
|
||||
const [timeEntries, setTimeEntries] = createSignal<TimeEntry[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
@@ -100,9 +102,10 @@ export const TimeTracking = () => {
|
||||
return `$${amount.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const handleTimeEntryCreated = (_timeEntry: TimeEntry) => {
|
||||
// Trigger refresh of the time entries list
|
||||
const handleTimeEntryCreated = (newEntry: TimeEntry) => {
|
||||
setTimeEntries(prev => [newEntry, ...prev]);
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
haptics.success(); // Success feedback for time entry creation
|
||||
};
|
||||
|
||||
// Handle real-time timer updates
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import {
|
||||
IconAlertCircle
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
|
||||
type TabType = 'search' | 'predefined' | 'bookmarked';
|
||||
|
||||
@@ -40,6 +41,7 @@ interface VideoCardProps {
|
||||
video: YouTubeVideo;
|
||||
onPreview: (video: YouTubeVideo) => void;
|
||||
onSave?: (video: YouTubeVideo) => void;
|
||||
haptics: ReturnType<typeof useHaptics>;
|
||||
}
|
||||
|
||||
const VideoCard = (props: VideoCardProps) => (
|
||||
@@ -58,7 +60,10 @@ const VideoCard = (props: VideoCardProps) => (
|
||||
{/* Play button overlay */}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 cursor-pointer"
|
||||
onClick={() => props.onPreview(props.video)}
|
||||
onClick={() => {
|
||||
props.onPreview(props.video);
|
||||
props.haptics.impact();
|
||||
}}
|
||||
role="button"
|
||||
aria-label="Play video"
|
||||
tabIndex={0}
|
||||
@@ -100,14 +105,20 @@ const VideoCard = (props: VideoCardProps) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => props.onPreview(props.video)}
|
||||
onClick={() => {
|
||||
props.onPreview(props.video);
|
||||
props.haptics.impact();
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
{props.onSave && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => props.onSave?.(props.video)}
|
||||
onClick={() => {
|
||||
props.onSave?.(props.video);
|
||||
props.haptics.success();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
@@ -119,6 +130,7 @@ const VideoCard = (props: VideoCardProps) => (
|
||||
);
|
||||
|
||||
export const Youtube = () => {
|
||||
const haptics = useHaptics();
|
||||
const [activeTab, setActiveTab] = createSignal<TabType>('search');
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [videos, setVideos] = createSignal<YouTubeVideo[]>([]);
|
||||
@@ -438,6 +450,7 @@ export const Youtube = () => {
|
||||
// Load predefined videos when tab is switched to predefined
|
||||
const handleTabChange = (tab: TabType) => {
|
||||
setActiveTab(tab);
|
||||
haptics.selection(); // Selection feedback for tab switching
|
||||
if (tab === 'predefined' && predefinedVideos().length === 0) {
|
||||
loadPredefinedVideos();
|
||||
}
|
||||
@@ -449,6 +462,7 @@ export const Youtube = () => {
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
haptics.impact(); // Impact feedback for search
|
||||
|
||||
try {
|
||||
// Always use real data, no demo mode check
|
||||
@@ -473,6 +487,7 @@ export const Youtube = () => {
|
||||
};
|
||||
|
||||
setVideos([video]);
|
||||
haptics.success(); // Success feedback for found video
|
||||
} else {
|
||||
// It's a regular search query - use backend API
|
||||
try {
|
||||
@@ -499,6 +514,7 @@ export const Youtube = () => {
|
||||
}));
|
||||
|
||||
setVideos(videos);
|
||||
haptics.success(); // Success feedback for search results
|
||||
} else {
|
||||
throw new Error('Search API failed');
|
||||
}
|
||||
@@ -511,6 +527,7 @@ export const Youtube = () => {
|
||||
console.error('Search failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to search YouTube');
|
||||
setTimeout(() => setError(''), 3000);
|
||||
haptics.error(); // Error feedback
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -807,7 +824,7 @@ export const Youtube = () => {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={videos()}>
|
||||
{(video) => (
|
||||
<VideoCard video={video} onPreview={handlePreviewVideo} onSave={handleSaveVideo} />
|
||||
<VideoCard video={video} onPreview={handlePreviewVideo} onSave={handleSaveVideo} haptics={haptics} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
@@ -821,7 +838,7 @@ export const Youtube = () => {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={predefinedVideos()}>
|
||||
{(video) => (
|
||||
<VideoCard video={video} onPreview={handlePreviewVideo} onSave={handleSaveVideo} />
|
||||
<VideoCard video={video} onPreview={handlePreviewVideo} onSave={handleSaveVideo} haptics={haptics} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
@@ -835,7 +852,7 @@ export const Youtube = () => {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={savedVideos()}>
|
||||
{(video) => (
|
||||
<VideoCard video={video} onPreview={handlePreviewVideo} />
|
||||
<VideoCard video={video} onPreview={handlePreviewVideo} haptics={haptics} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
@@ -862,7 +879,7 @@ export const Youtube = () => {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={savedVideos()}>
|
||||
{(video) => (
|
||||
<VideoCard video={video} onPreview={handlePreviewVideo} />
|
||||
<VideoCard video={video} onPreview={handlePreviewVideo} haptics={haptics} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,6 @@ export const updateService = {
|
||||
async checkForUpdates(): Promise<UpdateCheckResponse> {
|
||||
// If in demo mode, return mock update data
|
||||
if (isDemoMode()) {
|
||||
console.log('[Demo Mode] Using mock update data');
|
||||
return {
|
||||
updateAvailable: true,
|
||||
currentVersion: '1.0.0',
|
||||
@@ -84,7 +83,6 @@ export const updateService = {
|
||||
async installUpdate(version: string): Promise<{ message: string; version: string }> {
|
||||
// If in demo mode, simulate update installation
|
||||
if (isDemoMode()) {
|
||||
console.log('[Demo Mode] Simulating update installation for version:', version);
|
||||
return {
|
||||
message: 'Update started',
|
||||
version: version
|
||||
@@ -116,7 +114,6 @@ export const updateService = {
|
||||
async getUpdateProgress(): Promise<UpdateStatus> {
|
||||
// If in demo mode, return mock progress
|
||||
if (isDemoMode()) {
|
||||
console.log('[Demo Mode] Using mock update progress');
|
||||
return {
|
||||
available: true,
|
||||
downloading: false,
|
||||
|
||||
Reference in New Issue
Block a user