mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 04:22:57 +00:00
feat(frontend): enhance API credentials system and build configuration
Add real API support in demo mode with credential checking, implement build-time version injection from package.json, and refactor update checking with 24-hour caching. Migrate landing page from Vue to Astro with comprehensive UI components including Hero, Features, Benefits, and Tech Stack sections. Update CI/CD workflow with expanded cache paths and security scanner version pinned.
This commit is contained in:
@@ -4,7 +4,8 @@ import { type BraveSearchResult } from '@/lib/brave-search';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
import { isEnvDemoMode, shouldUseRealSearch } from '@/lib/demo-mode';
|
||||
import { getSearchProvider, getApiBaseUrl } from '@/lib/credentials';
|
||||
|
||||
export const BrowserSearch = () => {
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
@@ -15,10 +16,15 @@ export const BrowserSearch = () => {
|
||||
const [searchType, setSearchType] = createSignal<'web' | 'news'>('web');
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
const isDemo = () => {
|
||||
return isEnvDemoMode();
|
||||
};
|
||||
|
||||
// Check if we should use real search APIs
|
||||
const shouldUseReal = () => {
|
||||
return shouldUseRealSearch();
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
const query = searchQuery().trim();
|
||||
if (!query) return;
|
||||
@@ -28,12 +34,65 @@ export const BrowserSearch = () => {
|
||||
setHasSearched(true);
|
||||
|
||||
try {
|
||||
const isDemo = isDemoMode();
|
||||
const isDemoMode = isDemo();
|
||||
const useRealAPIs = shouldUseReal();
|
||||
|
||||
// In demo mode, use the demo mode API interceptor
|
||||
if (isDemo) {
|
||||
console.log(`[BrowserSearch] Demo mode: ${isDemoMode}, Use real APIs: ${useRealAPIs}`);
|
||||
|
||||
// If we have credentials and should use real APIs, try them first
|
||||
if (useRealAPIs) {
|
||||
console.log('Using real search APIs...');
|
||||
|
||||
// Try the configured search provider first
|
||||
const searchProvider = getSearchProvider();
|
||||
console.log(`Using search provider: ${searchProvider}`);
|
||||
|
||||
if (searchProvider === 'brave' && import.meta.env.VITE_BRAVE_API_KEY) {
|
||||
try {
|
||||
const { searchBrave } = await import('@/lib/brave-search');
|
||||
const results = await searchBrave(query, 8, searchType());
|
||||
if (results && results.length > 0) {
|
||||
setSearchResults(results);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Brave Search failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Try backend as fallback
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const token = localStorage.getItem('token') ||
|
||||
localStorage.getItem('auth_token') ||
|
||||
localStorage.getItem('trackeep_token');
|
||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ query, count: 8 }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.results && data.results.length > 0) {
|
||||
setSearchResults(data.results);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Backend search failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// In demo mode or as fallback, use the demo mode API interceptor
|
||||
if (isDemoMode) {
|
||||
console.log('Demo mode detected, using demo API interceptor...');
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL?.replace('/api/v1', '') || 'http://localhost:8080';
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
@@ -53,43 +112,7 @@ export const BrowserSearch = () => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.warn('Demo API failed, falling back to direct Brave API...');
|
||||
}
|
||||
|
||||
// Try Brave Search API directly (for production mode or as fallback)
|
||||
const { searchBrave } = await import('@/lib/brave-search');
|
||||
const results = await searchBrave(query, 8, searchType());
|
||||
|
||||
if (results && results.length > 0) {
|
||||
setSearchResults(results);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no results from Brave API, try backend as last resort (only in non-demo mode)
|
||||
if (!isDemo) {
|
||||
console.warn('Brave Search returned no results, trying backend...');
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL?.replace('/api/v1', '') || 'http://localhost:8080';
|
||||
const token = localStorage.getItem('token') ||
|
||||
localStorage.getItem('auth_token') ||
|
||||
localStorage.getItem('trackeep_token');
|
||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ query, count: 8 }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.results && data.results.length > 0) {
|
||||
setSearchResults(data.results);
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.warn('Demo API failed, falling back to mock results...');
|
||||
}
|
||||
|
||||
// If all APIs fail or return no results, show appropriate message
|
||||
@@ -148,14 +171,14 @@ export const BrowserSearch = () => {
|
||||
|
||||
const bookmarkResult = async (result: BraveSearchResult) => {
|
||||
// If in demo mode, just show success message
|
||||
if (isDemoMode()) {
|
||||
if (isDemo()) {
|
||||
// In demo mode, just show success without actual API call
|
||||
console.log('Demo mode: Bookmark created for', result.title);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const bookmarkData = {
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { createSignal, For, onMount, createEffect, Show } from 'solid-js';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { createSignal, For, onMount, createEffect } from 'solid-js';
|
||||
import {
|
||||
IconBookmark,
|
||||
IconChecklist,
|
||||
@@ -42,10 +39,6 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
const [activities, setActivities] = createSignal<ActivityItem[]>([]);
|
||||
const [filter, setFilter] = createSignal<'all' | 'trackeep' | 'github'>('all');
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
// Debounce filter changes to prevent excessive re-renders
|
||||
const debouncedFilter = useDebounce(filter, 300);
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
|
||||
@@ -55,7 +55,7 @@ export const BookmarkModal = (props: BookmarkModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -74,7 +74,7 @@ export const EditBookmarkModal = (props: EditBookmarkModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { RichTextEditor } from '@/components/ui/RichTextEditor';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSignal, For, Show, createEffect } from 'solid-js';
|
||||
import { IconTag, IconX, IconChevronDown } from '@tabler/icons-solidjs';
|
||||
import { IconTag, IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface TagPickerProps {
|
||||
availableTags: string[];
|
||||
|
||||
@@ -81,7 +81,7 @@ export const TaskModal = (props: TaskModalProps) => {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, createEffect, onCleanup, Show } from 'solid-js';
|
||||
import { createSignal, createEffect, onCleanup } from 'solid-js';
|
||||
import { IconX, IconCheck, IconAlertTriangle, IconInfoCircle } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Toast {
|
||||
|
||||
@@ -39,6 +39,9 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
setUpdateInfo(response.updateInfo || null);
|
||||
setCurrentVersion(response.currentVersion);
|
||||
|
||||
// Save last check time
|
||||
localStorage.setItem('lastUpdateCheck', Date.now().toString());
|
||||
|
||||
if (response.updateAvailable && response.updateInfo) {
|
||||
setUpdateStatus(prev => ({ ...prev, available: true }));
|
||||
}
|
||||
@@ -96,14 +99,22 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Check for updates on component mount
|
||||
checkForUpdates();
|
||||
|
||||
// Set current version
|
||||
setCurrentVersion(updateService.getCurrentVersion());
|
||||
|
||||
// Check for updates periodically (every 30 minutes)
|
||||
const intervalId = setInterval(checkForUpdates, 30 * 60 * 1000);
|
||||
// Check for updates periodically (every 24 hours)
|
||||
const intervalId = setInterval(checkForUpdates, 24 * 60 * 60 * 1000);
|
||||
|
||||
// Check if last check was more than 24 hours ago
|
||||
const lastCheckTime = localStorage.getItem('lastUpdateCheck');
|
||||
const now = Date.now();
|
||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
|
||||
if (!lastCheckTime || (now - parseInt(lastCheckTime)) > twentyFourHours) {
|
||||
// Check for updates on component mount if it's been more than 24 hours
|
||||
checkForUpdates();
|
||||
localStorage.setItem('lastUpdateCheck', now.toString());
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(intervalId);
|
||||
@@ -134,7 +145,13 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class={`flex items-center gap-2 ${props.class || ''}`}>
|
||||
<div class={`flex flex-col gap-2 ${props.class || ''}`}>
|
||||
{/* Current Version Display */}
|
||||
<div class="text-xs text-muted-foreground px-2 text-center">
|
||||
Version {currentVersion()}
|
||||
</div>
|
||||
|
||||
{/* Check Updates Button */}
|
||||
<button
|
||||
onClick={() => updateAvailable() ? setShowUpdateModal(true) : checkForUpdates()}
|
||||
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden w-full"
|
||||
|
||||
Vendored
+3
@@ -38,3 +38,6 @@ declare module "*.bmp" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
// Global build-time constants
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Utility functions to check if credentials are configured in environment variables
|
||||
|
||||
// Check if database credentials are configured
|
||||
export const hasDatabaseCredentials = (): boolean => {
|
||||
return !!(import.meta.env.VITE_DB_HOST &&
|
||||
import.meta.env.VITE_DB_USER &&
|
||||
import.meta.env.VITE_DB_PASSWORD &&
|
||||
import.meta.env.VITE_DB_NAME);
|
||||
};
|
||||
|
||||
// Check if search API credentials are configured
|
||||
export const hasSearchCredentials = (): boolean => {
|
||||
return !!(import.meta.env.VITE_BRAVE_API_KEY ||
|
||||
import.meta.env.VITE_SERPER_API_KEY ||
|
||||
import.meta.env.VITE_SEARCH_API_PROVIDER);
|
||||
};
|
||||
|
||||
// Check if AI service credentials are configured
|
||||
export const hasAICredentials = (): boolean => {
|
||||
return !!(import.meta.env.VITE_LONGCAT_API_KEY ||
|
||||
import.meta.env.VITE_MISTRAL_API_KEY ||
|
||||
import.meta.env.VITE_GROK_API_KEY ||
|
||||
import.meta.env.VITE_DEEPSEEK_API_KEY ||
|
||||
import.meta.env.VITE_OPENROUTER_API_KEY ||
|
||||
import.meta.env.VITE_OLLAMA_BASE_URL);
|
||||
};
|
||||
|
||||
// Check if any credentials are configured
|
||||
export const hasAnyCredentials = (): boolean => {
|
||||
return hasDatabaseCredentials() ||
|
||||
hasSearchCredentials() ||
|
||||
hasAICredentials();
|
||||
};
|
||||
|
||||
// Check if backend should be available (based on database credentials)
|
||||
export const isBackendAvailable = (): boolean => {
|
||||
return hasDatabaseCredentials();
|
||||
};
|
||||
|
||||
// Check if search APIs should be available
|
||||
export const isSearchAvailable = (): boolean => {
|
||||
return hasSearchCredentials();
|
||||
};
|
||||
|
||||
// Check if AI services should be available
|
||||
export const isAIAvailable = (): boolean => {
|
||||
return hasAICredentials();
|
||||
};
|
||||
|
||||
// Get configured search provider
|
||||
export const getSearchProvider = (): string => {
|
||||
return import.meta.env.VITE_SEARCH_API_PROVIDER ||
|
||||
(import.meta.env.VITE_BRAVE_API_KEY ? 'brave' :
|
||||
import.meta.env.VITE_SERPER_API_KEY ? 'serper' : 'demo');
|
||||
};
|
||||
|
||||
// Get API base URL
|
||||
export const getApiBaseUrl = (): string => {
|
||||
return import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
};
|
||||
@@ -9,19 +9,7 @@ import {
|
||||
getMockTimeEntries,
|
||||
getMockVideos,
|
||||
getMockLearningPaths,
|
||||
getMockCalendarEvents,
|
||||
getMockActivities,
|
||||
getMockStats,
|
||||
getPopularTags,
|
||||
type MockDocument,
|
||||
type MockBookmark,
|
||||
type MockTask,
|
||||
type MockNote,
|
||||
type MockTimeEntry,
|
||||
type MockVideo,
|
||||
type MockLearningPath,
|
||||
type MockCalendarEvent,
|
||||
type MockActivity
|
||||
getMockStats
|
||||
} from './mockData';
|
||||
|
||||
// Check if we're in demo mode
|
||||
@@ -250,7 +238,7 @@ export class DemoModeApiClient {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async upload<T>(endpoint: string, formData: FormData): Promise<T> {
|
||||
async upload<T>(_endpoint: string, formData: FormData): Promise<T> {
|
||||
// For demo mode, simulate file upload
|
||||
const file = formData.get('file') as File;
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Demo mode API interceptor to provide mock data instead of making real API calls
|
||||
|
||||
import { hasAnyCredentials, isBackendAvailable, isSearchAvailable } from './credentials';
|
||||
|
||||
// Check if demo mode is enabled via environment variable
|
||||
export const isEnvDemoMode = (): boolean => {
|
||||
const result = import.meta.env.VITE_DEMO_MODE === 'true';
|
||||
@@ -13,6 +15,21 @@ export const isDemoMode = (): boolean => {
|
||||
return isEnvDemoMode();
|
||||
};
|
||||
|
||||
// Check if we should use real APIs even in demo mode
|
||||
export const shouldUseRealAPIs = (): boolean => {
|
||||
return hasAnyCredentials();
|
||||
};
|
||||
|
||||
// Check if we should use real backend API
|
||||
export const shouldUseRealBackend = (): boolean => {
|
||||
return isBackendAvailable();
|
||||
};
|
||||
|
||||
// Check if we should use real search APIs
|
||||
export const shouldUseRealSearch = (): boolean => {
|
||||
return isSearchAvailable();
|
||||
};
|
||||
|
||||
// Clear demo mode from localStorage
|
||||
export const clearDemoMode = (): void => {
|
||||
localStorage.removeItem('demoMode');
|
||||
@@ -181,6 +198,12 @@ const generateMockAIProviders = () => [
|
||||
|
||||
// Demo mode fetch interceptor
|
||||
export const demoFetch = async (url: string, options?: RequestInit): Promise<Response> => {
|
||||
// Check if we should use real APIs even in demo mode
|
||||
if (shouldUseRealAPIs()) {
|
||||
console.log('[Demo Mode] Real credentials detected, using real API for:', url);
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
if (!isDemoMode()) {
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ export const Bookmarks = () => {
|
||||
const faviconUrl = getFaviconUrl(bookmark);
|
||||
const screenshotUrl = getScreenshotUrl(bookmark);
|
||||
return (
|
||||
<Card class="p-6 hover:bg-accent transition-colors">
|
||||
<Card class="p-6 hover:bg-accent transition-colors group">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
{/* Left side: preview image + favicon + title + URL + tags */}
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -420,7 +420,7 @@ export const Bookmarks = () => {
|
||||
class="w-6 h-6 object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement!.innerHTML = `<span class=\"text-xs text-muted-foreground font-medium\">${bookmark.title.charAt(0).toUpperCase()}</span>`;
|
||||
e.currentTarget.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${bookmark.title.charAt(0).toUpperCase()}</span>`;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -438,7 +438,7 @@ export const Bookmarks = () => {
|
||||
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{bookmark.title}
|
||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current" />
|
||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current group-hover:text-white" />
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm truncate">{bookmark.url}</p>
|
||||
@@ -456,7 +456,7 @@ export const Bookmarks = () => {
|
||||
class={`px-2 py-1 text-xs rounded-md border transition-colors cursor-pointer
|
||||
${selectedTag() === tag
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted text-muted-foreground border-transparent hover:bg-primary hover:text-primary-foreground hover:border-primary'
|
||||
: 'bg-muted/80 text-muted-foreground border-transparent group-hover:bg-accent group-hover:text-accent-foreground group-hover:border-border'
|
||||
}`}
|
||||
title={`Click to filter by ${tag}`}
|
||||
>
|
||||
|
||||
@@ -771,7 +771,7 @@ export function Calendar() {
|
||||
{/* Event Creation Modal */}
|
||||
<Show when={showEventModal()}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0"
|
||||
onClick={(e) => {
|
||||
// Close modal only when clicking the backdrop, not the modal content
|
||||
if (e.target === e.currentTarget) {
|
||||
@@ -938,7 +938,7 @@ export function Calendar() {
|
||||
{/* Task Detail Modal */}
|
||||
<Show when={showTaskDetailModal() && selectedTask()}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowTaskDetailModal(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { createSignal, onMount, Show } from 'solid-js';
|
||||
import { IconPalette, IconCheck, IconRepeat, IconSun, IconMoon, IconDownload, IconUpload, IconEye, IconEyeOff } from '@tabler/icons-solidjs';
|
||||
|
||||
interface ColorScheme {
|
||||
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
getPopularTags
|
||||
} from '@/lib/mockData';
|
||||
import { formatDuration } from '@/lib/timeFormat';
|
||||
import {
|
||||
isSearchAvailable
|
||||
} from '@/lib/credentials';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
@@ -138,9 +141,20 @@ export const Dashboard = () => {
|
||||
};
|
||||
|
||||
const storagePercentage = () => {
|
||||
const used = parseFloat(stats().totalSize);
|
||||
const sizeString = stats().totalSize;
|
||||
let usedMB = 0;
|
||||
|
||||
// Parse the size string to extract the numeric value in MB
|
||||
if (sizeString.includes('MB')) {
|
||||
usedMB = parseFloat(sizeString);
|
||||
} else if (sizeString.includes('GB')) {
|
||||
usedMB = parseFloat(sizeString) * 1024;
|
||||
} else if (sizeString.includes('KB')) {
|
||||
usedMB = parseFloat(sizeString) / 1024;
|
||||
}
|
||||
|
||||
const total = 50 * 1024; // 50 GB in MB
|
||||
return Math.round((used / total) * 100);
|
||||
return Math.round((usedMB / total) * 100);
|
||||
};
|
||||
|
||||
// Modal handlers
|
||||
@@ -555,7 +569,7 @@ export const Dashboard = () => {
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-2 md:gap-3">
|
||||
<div class="relative flex items-end justify-between h-full gap-1 md:gap-2">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivity = stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
|
||||
const activity = weeklyActivity[index];
|
||||
@@ -565,20 +579,20 @@ export const Dashboard = () => {
|
||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
||||
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
|
||||
const minHeightPercent = (6 / containerHeight) * 100; // Minimum 6px height
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-6">
|
||||
<div class="relative w-full max-w-3 md:max-w-4 flex flex-col items-center">
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-4">
|
||||
<div class="relative w-full max-w-2 md:max-w-3 flex flex-col items-center">
|
||||
<span
|
||||
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5"
|
||||
>
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-3 md:max-w-4 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
|
||||
class="w-full max-w-2 md:max-w-3 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 6px;`}
|
||||
title={`${day}: ${activity} activities`}
|
||||
></div>
|
||||
</div>
|
||||
@@ -752,45 +766,47 @@ export const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Browser Search Section - Collapsible */}
|
||||
<div class="mb-8">
|
||||
<div class="border rounded-lg">
|
||||
{/* Collapsible Header */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newState = !showBrowserSearch();
|
||||
setShowBrowserSearch(newState);
|
||||
localStorage.setItem('showBrowserSearch', newState.toString());
|
||||
}}
|
||||
class="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors rounded-t-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconSearch class="size-4 text-primary" />
|
||||
<h2 class="text-lg font-semibold">Browser Search</h2>
|
||||
<span class="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
Powered by Brave Search
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{showBrowserSearch() ? 'Hide' : 'Show'}
|
||||
</span>
|
||||
<IconChevronDown
|
||||
class={`size-4 text-muted-foreground transition-transform duration-200 ${
|
||||
showBrowserSearch() ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Collapsible Content */}
|
||||
<Show when={showBrowserSearch()}>
|
||||
<div class="border-t border-border p-4">
|
||||
<BrowserSearch />
|
||||
</div>
|
||||
</Show>
|
||||
{/* Browser Search Section - Collapsible - Only show if search credentials are available */}
|
||||
<Show when={isSearchAvailable()}>
|
||||
<div class="mb-8">
|
||||
<div class="border rounded-lg">
|
||||
{/* Collapsible Header */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newState = !showBrowserSearch();
|
||||
setShowBrowserSearch(newState);
|
||||
localStorage.setItem('showBrowserSearch', newState.toString());
|
||||
}}
|
||||
class="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors rounded-t-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconSearch class="size-4 text-primary" />
|
||||
<h2 class="text-lg font-semibold">Browser Search</h2>
|
||||
<span class="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
Powered by Brave Search
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{showBrowserSearch() ? 'Hide' : 'Show'}
|
||||
</span>
|
||||
<IconChevronDown
|
||||
class={`size-4 text-muted-foreground transition-transform duration-200 ${
|
||||
showBrowserSearch() ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Collapsible Content */}
|
||||
<Show when={showBrowserSearch()}>
|
||||
<div class="border-t border-border p-4">
|
||||
<BrowserSearch />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Popular Tags Section */}
|
||||
<div class="mb-8">
|
||||
@@ -1000,7 +1016,7 @@ export const Dashboard = () => {
|
||||
|
||||
{/* Achievement Detail Modal */}
|
||||
<Show when={showAchievementModal() && selectedAchievement()}>
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 mt-0">
|
||||
<div class="bg-card border border-border rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Achievement Details</h3>
|
||||
@@ -1033,7 +1049,7 @@ export const Dashboard = () => {
|
||||
|
||||
{/* Deadline Detail Modal */}
|
||||
<Show when={showDeadlineModal() && selectedDeadline()}>
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 mt-0">
|
||||
<div class="bg-card border border-border rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Deadline Details</h3>
|
||||
|
||||
@@ -124,6 +124,13 @@ export const TimeTracking = () => {
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6">
|
||||
{/* Simple loading indicator */}
|
||||
{loading() && (
|
||||
<div class="text-center text-sm text-muted-foreground py-2">
|
||||
Loading time entries...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Timer Component */}
|
||||
<div>
|
||||
|
||||
+25
-123
@@ -157,7 +157,7 @@ export const Youtube = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Check if we're in demo mode
|
||||
// Check if we're in demo mode (for display purposes only)
|
||||
const isDemoMode = () => {
|
||||
const demoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
@@ -178,38 +178,9 @@ export const Youtube = () => {
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
// Get video info from YouTube API using video ID
|
||||
// Get video info from YouTube API using video ID (always use real data)
|
||||
const getVideoInfo = async (videoId: string) => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
// Use mock data in demo mode
|
||||
const mockVideos = getMockVideos();
|
||||
const mockVideo = mockVideos.find(v => v.id === videoId);
|
||||
if (mockVideo) {
|
||||
return {
|
||||
video_id: mockVideo.id,
|
||||
channel_name: mockVideo.channel,
|
||||
url: mockVideo.url,
|
||||
title: mockVideo.title,
|
||||
duration: mockVideo.duration,
|
||||
published_at: mockVideo.publishedAt,
|
||||
view_count: '1000',
|
||||
category: mockVideo.category
|
||||
};
|
||||
}
|
||||
// Fallback mock data
|
||||
return {
|
||||
video_id: videoId,
|
||||
channel_name: 'Demo Channel',
|
||||
url: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
title: `Demo Video ${videoId}`,
|
||||
duration: '10:30',
|
||||
published_at: '2024-01-15',
|
||||
view_count: '1000',
|
||||
category: 'Technology'
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -230,6 +201,7 @@ export const Youtube = () => {
|
||||
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
console.warn('Failed to get video info from API, using fallback:', err);
|
||||
// Return a fallback video object with basic info
|
||||
return {
|
||||
video_id: videoId,
|
||||
@@ -477,32 +449,8 @@ export const Youtube = () => {
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Check if we're in demo mode first
|
||||
if (isDemoMode()) {
|
||||
console.log('Using demo mode for search');
|
||||
const mockVideos = getMockVideos();
|
||||
const filteredVideos = mockVideos
|
||||
.filter(video =>
|
||||
video.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
video.description.toLowerCase().includes(query.toLowerCase()) ||
|
||||
video.channel.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 10)
|
||||
.map((video) => ({
|
||||
video_id: video.id,
|
||||
channel_name: video.channel,
|
||||
url: video.url,
|
||||
title: video.title,
|
||||
duration: video.duration,
|
||||
published_at: video.publishedAt,
|
||||
view_count: '1000',
|
||||
category: video.category || 'General'
|
||||
}));
|
||||
|
||||
setVideos(filteredVideos);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Always use real data, no demo mode check
|
||||
console.log('Searching YouTube with real data for:', query);
|
||||
|
||||
// Check if the input is a YouTube URL
|
||||
const videoId = extractVideoId(query);
|
||||
@@ -523,7 +471,7 @@ export const Youtube = () => {
|
||||
|
||||
setVideos([video]);
|
||||
} else {
|
||||
// It's a regular search query - use backend API for now (will be replaced with scraping service)
|
||||
// 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`, {
|
||||
@@ -553,54 +501,13 @@ export const Youtube = () => {
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.warn('Backend search API failed:', apiError);
|
||||
|
||||
// Fallback to demo mode
|
||||
console.log('Using demo mode fallback for search');
|
||||
const mockVideos = getMockVideos();
|
||||
const filteredVideos = mockVideos
|
||||
.filter(video =>
|
||||
video.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
video.description.toLowerCase().includes(query.toLowerCase()) ||
|
||||
video.channel.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 10)
|
||||
.map((video) => ({
|
||||
video_id: video.id,
|
||||
channel_name: video.channel,
|
||||
url: video.url,
|
||||
title: video.title,
|
||||
duration: video.duration,
|
||||
published_at: video.publishedAt,
|
||||
view_count: '1000',
|
||||
category: video.category || 'General'
|
||||
}));
|
||||
|
||||
setVideos(filteredVideos);
|
||||
throw new Error('Failed to search YouTube. Please try again.');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Search failed, falling back to demo mode:', err);
|
||||
// Fallback to demo mode
|
||||
const mockVideos = getMockVideos();
|
||||
const filteredVideos = mockVideos
|
||||
.filter(video =>
|
||||
video.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
video.description.toLowerCase().includes(query.toLowerCase()) ||
|
||||
video.channel.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 10)
|
||||
.map((video) => ({
|
||||
video_id: video.id,
|
||||
channel_name: video.channel,
|
||||
url: video.url,
|
||||
title: video.title,
|
||||
duration: video.duration,
|
||||
published_at: video.publishedAt,
|
||||
view_count: '1000',
|
||||
category: video.category || 'General'
|
||||
}));
|
||||
|
||||
setVideos(filteredVideos);
|
||||
console.error('Search failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to search YouTube');
|
||||
setTimeout(() => setError(''), 3000);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -626,20 +533,7 @@ export const Youtube = () => {
|
||||
|
||||
const handleSaveVideo = async (video: YouTubeVideo) => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
// Simulate save in demo mode
|
||||
console.log('Video saved (demo mode):', video);
|
||||
setSavedVideos((prev) => {
|
||||
if (prev.some((v) => v.video_id === video.video_id)) {
|
||||
return prev;
|
||||
}
|
||||
return [video, ...prev];
|
||||
});
|
||||
setSuccessMessage('Video saved successfully!');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -668,8 +562,16 @@ export const Youtube = () => {
|
||||
setSuccessMessage('Video saved successfully!');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save video');
|
||||
setTimeout(() => setError(''), 3000);
|
||||
console.warn('Failed to save video to backend:', err);
|
||||
// Fallback: simulate save locally
|
||||
setSavedVideos((prev) => {
|
||||
if (prev.some((v) => v.video_id === video.video_id)) {
|
||||
return prev;
|
||||
}
|
||||
return [video, ...prev];
|
||||
});
|
||||
setSuccessMessage('Video saved locally!');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -703,7 +605,7 @@ export const Youtube = () => {
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class={`w-4 h-4 ${activeTab() === 'search' ? 'text-black' : 'text-white'}`}
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -719,7 +621,7 @@ export const Youtube = () => {
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class={`w-4 h-4 ${activeTab() === 'predefined' ? 'text-black' : 'text-white'}`}
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -735,7 +637,7 @@ export const Youtube = () => {
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class={`w-4 h-4 ${activeTab() === 'bookmarked' ? 'text-black' : 'text-white'}`}
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1083,7 +985,7 @@ export const Youtube = () => {
|
||||
{/* Channel Editor Modal */}
|
||||
<Show when={showChannelEditor()}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowChannelEditor(false);
|
||||
|
||||
@@ -93,10 +93,10 @@ export const updateService = {
|
||||
}
|
||||
},
|
||||
|
||||
// Get current app version from package.json
|
||||
// Get current app version from build-time constant
|
||||
getCurrentVersion(): string {
|
||||
// This would typically be injected at build time
|
||||
return import.meta.env.VITE_APP_VERSION || '1.0.0';
|
||||
// Use build-time version from vite config, fallback to environment variable or default
|
||||
return (typeof __APP_VERSION__ !== 'undefined') ? __APP_VERSION__ : import.meta.env.VITE_APP_VERSION || '1.0.0';
|
||||
},
|
||||
|
||||
// Poll for update progress during installation
|
||||
|
||||
Reference in New Issue
Block a user