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:
Tomas Dvorak
2026-02-10 16:25:57 +01:00
parent d27cf14110
commit b083dac3f0
95 changed files with 17610 additions and 2692 deletions
+4 -4
View File
@@ -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}`}
>
+2 -2
View File
@@ -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 -1
View File
@@ -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 {
+64 -48
View File
@@ -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>
+7
View File
@@ -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
View File
@@ -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);