mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 12:32:58 +00:00
first test
This commit is contained in:
@@ -2,7 +2,7 @@ import { createQuery, useQueryClient, createMutation } from '@tanstack/solid-que
|
||||
import { getAuthHeaders } from './auth';
|
||||
|
||||
// API base URL
|
||||
const API_BASE_URL = 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
// Retry configuration
|
||||
const DEFAULT_RETRY_CONFIG = {
|
||||
|
||||
+269
-5
@@ -1,5 +1,21 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
// Helper function to get auth headers
|
||||
const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
};
|
||||
};
|
||||
|
||||
// Generic API client
|
||||
class ApiClient {
|
||||
private baseURL: string;
|
||||
@@ -12,11 +28,16 @@ class ApiClient {
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// If in demo mode, use mock data
|
||||
if (isDemoMode()) {
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders(),
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
@@ -26,17 +47,186 @@ class ApiClient {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
// If backend fails, fall back to demo mode
|
||||
console.warn(`API endpoint ${endpoint} failed, falling back to demo mode`);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
throw error;
|
||||
console.warn(`API request failed for ${endpoint}, falling back to demo mode:`, error);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
}
|
||||
|
||||
private async getMockResponse<T>(endpoint: string, options: RequestInit): Promise<T> {
|
||||
// Import mock data dynamically to avoid circular dependencies
|
||||
const {
|
||||
getMockStats,
|
||||
getMockDocuments,
|
||||
getMockBookmarks,
|
||||
getMockTasks,
|
||||
getMockNotes,
|
||||
getMockTimeEntries,
|
||||
getMockLearningPaths,
|
||||
getMockVideos
|
||||
} = await import('./mockData');
|
||||
|
||||
const method = options.method || 'GET';
|
||||
|
||||
// Dashboard stats
|
||||
if (endpoint.includes('/dashboard/stats')) {
|
||||
return getMockStats() as T;
|
||||
}
|
||||
|
||||
// Documents/Files
|
||||
if (endpoint.includes('/documents') || endpoint.includes('/files')) {
|
||||
if (method === 'GET') {
|
||||
return getMockDocuments() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarks
|
||||
if (endpoint.includes('/bookmarks')) {
|
||||
if (method === 'GET') {
|
||||
return getMockBookmarks() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks
|
||||
if (endpoint.includes('/tasks')) {
|
||||
if (method === 'GET') {
|
||||
return getMockTasks() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (endpoint.includes('/notes')) {
|
||||
if (method === 'GET') {
|
||||
return getMockNotes() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Time entries
|
||||
if (endpoint.includes('/time-entries')) {
|
||||
if (method === 'GET') {
|
||||
const mockEntries = getMockTimeEntries();
|
||||
// Convert mock entries to TimeEntry format
|
||||
const timeEntries = mockEntries.map(entry => ({
|
||||
id: parseInt(entry.id.replace('time_', '')),
|
||||
user_id: 1,
|
||||
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
|
||||
start_time: `${entry.date}T${entry.startTime}:00Z`,
|
||||
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
|
||||
duration: entry.duration,
|
||||
description: entry.description,
|
||||
tags: entry.tags,
|
||||
billable: entry.billable,
|
||||
hourly_rate: entry.hourlyRate,
|
||||
is_running: false,
|
||||
source: 'demo',
|
||||
created_at: `${entry.date}T${entry.startTime}:00Z`,
|
||||
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
|
||||
}));
|
||||
return { time_entries: timeEntries } as T;
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const mockEntries = getMockTimeEntries();
|
||||
const entry = mockEntries[0];
|
||||
// Convert mock entry to TimeEntry format
|
||||
const timeEntry = {
|
||||
id: parseInt(entry.id.replace('time_', '')),
|
||||
user_id: 1,
|
||||
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
|
||||
start_time: `${entry.date}T${entry.startTime}:00Z`,
|
||||
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
|
||||
duration: entry.duration,
|
||||
description: entry.description,
|
||||
tags: entry.tags,
|
||||
billable: entry.billable,
|
||||
hourly_rate: entry.hourlyRate,
|
||||
is_running: false,
|
||||
source: 'demo',
|
||||
created_at: `${entry.date}T${entry.startTime}:00Z`,
|
||||
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
|
||||
};
|
||||
return { time_entry: timeEntry } as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
if (endpoint.includes('/auth/login-totp')) {
|
||||
return {
|
||||
token: 'demo-token',
|
||||
user: { id: 1, email: 'demo@trackeep.com', name: 'Demo User' }
|
||||
} as T;
|
||||
}
|
||||
|
||||
// GitHub repos
|
||||
if (endpoint.includes('/github/repos')) {
|
||||
return {
|
||||
repositories: [
|
||||
{ id: 1, name: 'trackeep', full_name: 'tdvorak/trackeep', stars: 245, forks: 43, watchers: 65, language: 'Go' },
|
||||
{ id: 2, name: 'frontend', full_name: 'tdvorak/frontend', stars: 89, forks: 12, watchers: 23, language: 'TypeScript' },
|
||||
{ id: 3, name: 'mobile-app', full_name: 'tdvorak/mobile-app', stars: 34, forks: 8, watchers: 15, language: 'TypeScript' }
|
||||
],
|
||||
totalStars: 368,
|
||||
totalForks: 63,
|
||||
totalWatchers: 103
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Learning paths
|
||||
if (endpoint.includes('/learning-paths/categories')) {
|
||||
return {
|
||||
categories: ['Web Development', 'DevOps', 'Programming', 'Design', 'Business', 'Data Science']
|
||||
} as T;
|
||||
}
|
||||
|
||||
if (endpoint.includes('/learning-paths')) {
|
||||
return getMockLearningPaths() as T;
|
||||
}
|
||||
|
||||
// Chat sessions
|
||||
if (endpoint.includes('/chat/sessions')) {
|
||||
return {
|
||||
sessions: [
|
||||
{ id: '1', title: 'Project Planning', created_at: '2024-01-15T10:00:00Z', updated_at: '2024-01-15T11:30:00Z' },
|
||||
{ id: '2', title: 'Technical Discussion', created_at: '2024-01-14T14:00:00Z', updated_at: '2024-01-14T15:45:00Z' }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// AI providers
|
||||
if (endpoint.includes('/ai/providers')) {
|
||||
return {
|
||||
providers: [
|
||||
{ id: 'longcat', name: 'LongCat AI', enabled: true, models: ['LongCat-Flash-Chat', 'LongCat-Flash-Thinking'] },
|
||||
{ id: 'mistral', name: 'Mistral AI', enabled: false, models: ['mistral-small-latest', 'mistral-large-latest'] },
|
||||
{ id: 'openai', name: 'OpenAI', enabled: false, models: ['gpt-4', 'gpt-3.5-turbo'] }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// YouTube endpoints
|
||||
if (endpoint.includes('/youtube/video-details')) {
|
||||
return getMockVideos()[0] as T;
|
||||
}
|
||||
|
||||
if (endpoint.includes('/youtube/predefined-channels')) {
|
||||
return {
|
||||
channels: [
|
||||
{ id: 'UC8butISFwT-Wy7pm24E6Icg', name: 'NetworkChuck', latestVideos: getMockVideos().slice(0, 2) },
|
||||
{ id: 'UCWv7vHwRQdGJtU2i9hJ8X7A', name: 'Fireship', latestVideos: getMockVideos().slice(1, 3) },
|
||||
{ id: 'UCsXVk37bltHxD1rDPgtNG6A', name: 'Beyond Fireship', latestVideos: getMockVideos().slice(0, 1) }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Default empty response
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'GET' });
|
||||
}
|
||||
@@ -65,6 +255,9 @@ class ApiClient {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': getAuthHeaders().Authorization || '',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -134,6 +327,36 @@ export interface File {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: number;
|
||||
user_id: number;
|
||||
task_id?: number;
|
||||
bookmark_id?: number;
|
||||
note_id?: number;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
duration?: number;
|
||||
description: string;
|
||||
tags: string[];
|
||||
billable: boolean;
|
||||
hourly_rate?: number;
|
||||
is_running: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
task?: Task;
|
||||
bookmark?: Bookmark;
|
||||
note?: Note;
|
||||
}
|
||||
|
||||
export interface TimeStats {
|
||||
total_time_seconds: number;
|
||||
total_entries: number;
|
||||
running_entries: number;
|
||||
billable_time_seconds: number;
|
||||
total_billable_amount: number;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
export const bookmarksApi = {
|
||||
getAll: () => api.get<Bookmark[]>('/bookmarks'),
|
||||
@@ -191,4 +414,45 @@ export const filesApi = {
|
||||
download: (id: number) => `${API_BASE_URL}/files/${id}/download`,
|
||||
};
|
||||
|
||||
export const timeEntriesApi = {
|
||||
getAll: (startDate?: string, endDate?: string, isRunning?: boolean) => {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
if (isRunning !== undefined) params.append('is_running', isRunning.toString());
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
return api.get<{ time_entries: TimeEntry[] }>(`/time-entries${query}`);
|
||||
},
|
||||
getById: (id: number) => api.get<{ time_entry: TimeEntry }>(`/time-entries/${id}`),
|
||||
create: (timeEntry: {
|
||||
task_id?: number;
|
||||
bookmark_id?: number;
|
||||
note_id?: number;
|
||||
description: string;
|
||||
tags?: string[];
|
||||
billable?: boolean;
|
||||
hourly_rate?: number;
|
||||
source?: string;
|
||||
}) => api.post<{ time_entry: TimeEntry }>('/time-entries', timeEntry),
|
||||
update: (id: number, timeEntry: {
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
billable?: boolean;
|
||||
hourly_rate?: number;
|
||||
end_time?: string;
|
||||
}) => api.put<{ time_entry: TimeEntry }>(`/time-entries/${id}`, timeEntry),
|
||||
stop: (id: number) => api.post<{ time_entry: TimeEntry }>(`/time-entries/${id}/stop`),
|
||||
delete: (id: number) => api.delete<{ message: string }>(`/time-entries/${id}`),
|
||||
getStats: () => api.get<{ stats: TimeStats }>('/time-entries/stats'),
|
||||
};
|
||||
|
||||
import {
|
||||
demoBookmarksApi,
|
||||
demoTasksApi,
|
||||
demoNotesApi,
|
||||
demoFilesApi,
|
||||
demoTimeEntriesApi
|
||||
} from './demo-api';
|
||||
|
||||
export default api;
|
||||
export { demoBookmarksApi, demoTasksApi, demoNotesApi, demoFilesApi, demoTimeEntriesApi };
|
||||
|
||||
+147
-21
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, type ParentComponent, onMount } from 'solid-js';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import { isDemoMode } from './demo-mode';
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
@@ -37,7 +38,7 @@ export interface AuthResponse {
|
||||
}
|
||||
|
||||
// API base URL
|
||||
const API_BASE_URL = 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
// Create auth context
|
||||
const AuthContext = createContext<AuthContextType>();
|
||||
@@ -49,6 +50,9 @@ export interface AuthContextType {
|
||||
logout: () => void;
|
||||
updateProfile: (data: { fullName?: string; theme?: string }) => Promise<void>;
|
||||
changePassword: (data: { currentPassword: string; newPassword: string }) => Promise<void>;
|
||||
requestPasswordReset: (email: string) => Promise<void>;
|
||||
confirmPasswordReset: (code: string, password: string) => Promise<void>;
|
||||
setAuth: (token: string, user: User) => void;
|
||||
}
|
||||
|
||||
// Auth provider component
|
||||
@@ -57,52 +61,123 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
isLoading: false, // Start with false to avoid loading spinner in ProtectedRoute
|
||||
});
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
onMount(() => {
|
||||
const token = localStorage.getItem('trackeep_token');
|
||||
const userStr = localStorage.getItem('trackeep_user');
|
||||
console.log('[Auth] onMount: Initializing auth state');
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
setAuthState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse user data:', error);
|
||||
clearAuth();
|
||||
// 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');
|
||||
|
||||
// Check for existing non-demo auth
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
const userStr = localStorage.getItem('trackeep_user') || localStorage.getItem('user');
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
console.log('[Auth] onMount: Found existing auth, restoring:', user);
|
||||
setAuthState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Apply theme
|
||||
if (user.theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth] onMount: Failed to parse user data:', error);
|
||||
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');
|
||||
}
|
||||
} else {
|
||||
setAuthState('isLoading', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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: 'demo@trackeep.com',
|
||||
username: 'demo',
|
||||
full_name: 'Demo User',
|
||||
theme: 'dark',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const mockToken = 'demo-token-' + Date.now();
|
||||
|
||||
setAuthState({
|
||||
user: mockUser,
|
||||
token: mockToken,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Apply theme
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
document.title = 'Trackeep - Demo Mode';
|
||||
});
|
||||
|
||||
|
||||
const clearAuth = () => {
|
||||
localStorage.removeItem('trackeep_token');
|
||||
localStorage.removeItem('trackeep_user');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('demoMode');
|
||||
setAuthState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
// Reset to dark mode on logout
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
};
|
||||
|
||||
const setAuth = (token: string, user: User) => {
|
||||
localStorage.setItem('trackeep_token', token);
|
||||
localStorage.setItem('trackeep_user', JSON.stringify(user));
|
||||
console.log('[Auth] setAuth called with:', { token, user });
|
||||
|
||||
// Only store in localStorage if not in demo mode
|
||||
if (!isDemoMode()) {
|
||||
localStorage.setItem('trackeep_token', token);
|
||||
localStorage.setItem('trackeep_user', JSON.stringify(user));
|
||||
// Also set the legacy keys for compatibility
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
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');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
@@ -188,7 +263,15 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
const updatedUser = result.user;
|
||||
|
||||
localStorage.setItem('trackeep_user', JSON.stringify(updatedUser));
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser)); // Keep legacy key
|
||||
setAuthState('user', updatedUser);
|
||||
|
||||
// Apply theme change immediately
|
||||
if (updatedUser.theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Profile update error:', error);
|
||||
throw error;
|
||||
@@ -216,6 +299,46 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const requestPasswordReset = async (email: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/password-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Password reset request failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password reset request error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPasswordReset = async (code: string, password: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/password-reset/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Password reset confirmation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password reset confirmation error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const authContextValue: AuthContextType = {
|
||||
authState,
|
||||
login,
|
||||
@@ -223,6 +346,9 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
logout,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
requestPasswordReset,
|
||||
confirmPasswordReset,
|
||||
setAuth,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -243,7 +369,7 @@ export const useAuth = () => {
|
||||
|
||||
// Helper function to get auth headers for API requests
|
||||
export const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem('trackeep_token');
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Brave Search API integration
|
||||
const BRAVE_API_KEY = import.meta.env.VITE_BRAVE_API_KEY || 'BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT';
|
||||
const BRAVE_WEB_API_BASE = 'https://api.search.brave.com/res/v1/web/search';
|
||||
const BRAVE_NEWS_API_BASE = 'https://api.search.brave.com/res/v1/news/search';
|
||||
|
||||
export interface BraveSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
published_date?: string;
|
||||
language?: string;
|
||||
family_friendly?: boolean;
|
||||
type?: string;
|
||||
subtype?: string;
|
||||
}
|
||||
|
||||
export interface BraveSearchResponse {
|
||||
web?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
news?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
mixed?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
query?: {
|
||||
original: string;
|
||||
display: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchBrave(query: string, count: number = 10, type: 'web' | 'news' = 'web'): Promise<BraveSearchResult[]> {
|
||||
try {
|
||||
const apiBase = type === 'news' ? BRAVE_NEWS_API_BASE : BRAVE_WEB_API_BASE;
|
||||
const response = await fetch(`${apiBase}?q=${encodeURIComponent(query)}&count=${count}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': BRAVE_API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: BraveSearchResponse = await response.json();
|
||||
|
||||
// Return results from appropriate search type
|
||||
if (type === 'news' && data.news?.results) {
|
||||
return data.news.results;
|
||||
} else if (data.web?.results) {
|
||||
return data.web.results;
|
||||
} else if (data.mixed?.results) {
|
||||
return data.mixed.results;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Brave search error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchWeb(query: string, count: number = 10): Promise<BraveSearchResult[]> {
|
||||
return searchBrave(query, count, 'web');
|
||||
}
|
||||
|
||||
export async function searchNews(query: string, count: number = 10): Promise<BraveSearchResult[]> {
|
||||
return searchBrave(query, count, 'news');
|
||||
}
|
||||
|
||||
export async function getQuickSearchSuggestions(query: string, limit: number = 5): Promise<string[]> {
|
||||
try {
|
||||
const results = await searchBrave(query, limit);
|
||||
return results.map(result => result.title);
|
||||
} catch (error) {
|
||||
console.error('Failed to get search suggestions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Demo mode API wrapper for Trackeep
|
||||
// Provides mock data when backend is not available
|
||||
|
||||
import {
|
||||
getMockDocuments,
|
||||
getMockBookmarks,
|
||||
getMockTasks,
|
||||
getMockNotes,
|
||||
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
|
||||
} from './mockData';
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
// Demo mode API client that falls back to mock data
|
||||
export class DemoModeApiClient {
|
||||
public baseURL: string;
|
||||
private demoMode: boolean;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
this.demoMode = isDemoMode();
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// If in demo mode, return mock data immediately
|
||||
if (this.demoMode) {
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
// If backend fails, fall back to demo mode
|
||||
console.warn(`API endpoint ${endpoint} failed, falling back to demo mode`);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn(`API request failed for ${endpoint}, falling back to demo mode:`, error);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
}
|
||||
|
||||
private getMockResponse<T>(endpoint: string, options: RequestInit): T {
|
||||
const method = options.method || 'GET';
|
||||
|
||||
// Dashboard stats
|
||||
if (endpoint.includes('/dashboard/stats')) {
|
||||
return getMockStats() as T;
|
||||
}
|
||||
|
||||
// Documents
|
||||
if (endpoint.includes('/documents') || endpoint.includes('/files')) {
|
||||
if (method === 'GET') {
|
||||
return { documents: getMockDocuments() } as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarks
|
||||
if (endpoint.includes('/bookmarks')) {
|
||||
if (method === 'GET') {
|
||||
return getMockBookmarks() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks
|
||||
if (endpoint.includes('/tasks')) {
|
||||
if (method === 'GET') {
|
||||
return getMockTasks() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (endpoint.includes('/notes')) {
|
||||
if (method === 'GET') {
|
||||
return getMockNotes() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Time entries
|
||||
if (endpoint.includes('/time-entries')) {
|
||||
if (method === 'GET') {
|
||||
const mockEntries = getMockTimeEntries();
|
||||
// Convert mock entries to TimeEntry format
|
||||
const timeEntries = mockEntries.map(entry => ({
|
||||
id: parseInt(entry.id.replace('time_', '')),
|
||||
user_id: 1,
|
||||
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
|
||||
start_time: `${entry.date}T${entry.startTime}:00Z`,
|
||||
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
|
||||
duration: entry.duration,
|
||||
description: entry.description,
|
||||
tags: entry.tags,
|
||||
billable: entry.billable,
|
||||
hourly_rate: entry.hourlyRate,
|
||||
is_running: false,
|
||||
source: 'demo',
|
||||
created_at: `${entry.date}T${entry.startTime}:00Z`,
|
||||
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
|
||||
}));
|
||||
return { time_entries: timeEntries } as T;
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const mockEntries = getMockTimeEntries();
|
||||
const entry = mockEntries[0];
|
||||
// Convert mock entry to TimeEntry format
|
||||
const timeEntry = {
|
||||
id: parseInt(entry.id.replace('time_', '')),
|
||||
user_id: 1,
|
||||
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
|
||||
start_time: `${entry.date}T${entry.startTime}:00Z`,
|
||||
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
|
||||
duration: entry.duration,
|
||||
description: entry.description,
|
||||
tags: entry.tags,
|
||||
billable: entry.billable,
|
||||
hourly_rate: entry.hourlyRate,
|
||||
is_running: false,
|
||||
source: 'demo',
|
||||
created_at: `${entry.date}T${entry.startTime}:00Z`,
|
||||
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
|
||||
};
|
||||
return { time_entry: timeEntry } as T;
|
||||
}
|
||||
}
|
||||
|
||||
// YouTube
|
||||
if (endpoint.includes('/youtube')) {
|
||||
if (endpoint.includes('predefined-channels')) {
|
||||
return {
|
||||
channels: [
|
||||
{ id: 'UC8butISFwT-Wy7pm24E6Icg', name: 'NetworkChuck' },
|
||||
{ id: 'UCsBjURrPoezyKlLJRzKwBA', name: 'Fireship' },
|
||||
{ id: 'UCsXVk37bltJxDpvrMzOXvQ', name: 'Beyond Fireship' }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
if (endpoint.includes('video-details')) {
|
||||
return getMockVideos()[0] as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Learning paths
|
||||
if (endpoint.includes('/learning-paths')) {
|
||||
if (endpoint.includes('categories')) {
|
||||
return {
|
||||
categories: ['Web Development', 'DevOps', 'Programming', 'Design', 'Business']
|
||||
} as T;
|
||||
}
|
||||
return getMockLearningPaths() as T;
|
||||
}
|
||||
|
||||
// GitHub
|
||||
if (endpoint.includes('/github/repos')) {
|
||||
return {
|
||||
repositories: [
|
||||
{ name: 'trackeep', stars: 245, forks: 43, watchers: 65 },
|
||||
{ name: 'solidjs-app', stars: 123, forks: 21, watchers: 34 }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Chat sessions
|
||||
if (endpoint.includes('/chat/sessions')) {
|
||||
return {
|
||||
sessions: [
|
||||
{ id: '1', title: 'General Chat', created_at: new Date().toISOString() },
|
||||
{ id: '2', title: 'Development Help', created_at: new Date().toISOString() }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// AI providers
|
||||
if (endpoint.includes('/ai/providers')) {
|
||||
return {
|
||||
providers: [
|
||||
{ id: 'longcat', name: 'LongCat AI', enabled: true },
|
||||
{ id: 'mistral', name: 'Mistral AI', enabled: false },
|
||||
{ id: 'openai', name: 'OpenAI', enabled: false }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
if (endpoint.includes('/auth/login-totp')) {
|
||||
return {
|
||||
token: 'demo-token',
|
||||
user: { id: 1, email: 'demo@trackeep.com', name: 'Demo User' }
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Default empty response
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'GET' });
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, data?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async put<T>(endpoint: string, data?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async upload<T>(endpoint: string, formData: FormData): Promise<T> {
|
||||
// For demo mode, simulate file upload
|
||||
const file = formData.get('file') as File;
|
||||
return {
|
||||
id: Date.now(),
|
||||
original_name: file?.name || 'demo-file',
|
||||
file_name: `demo-${Date.now()}`,
|
||||
file_size: file?.size || 1024,
|
||||
mime_type: file?.type || 'application/octet-stream',
|
||||
created_at: new Date().toISOString()
|
||||
} as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Create demo mode API client
|
||||
const demoApi = new DemoModeApiClient(import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1');
|
||||
|
||||
// Export demo mode API functions that match the regular API
|
||||
export const demoBookmarksApi = {
|
||||
getAll: () => demoApi.get<any[]>('/bookmarks'),
|
||||
getById: (id: number) => demoApi.get<any>(`/bookmarks/${id}`),
|
||||
create: (bookmark: any) => demoApi.post<any>('/bookmarks', bookmark),
|
||||
update: (id: number, bookmark: any) => demoApi.put<any>(`/bookmarks/${id}`, bookmark),
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/bookmarks/${id}`),
|
||||
};
|
||||
|
||||
export const demoTasksApi = {
|
||||
getAll: () => demoApi.get<any[]>('/tasks'),
|
||||
getById: (id: number) => demoApi.get<any>(`/tasks/${id}`),
|
||||
create: (task: any) => demoApi.post<any>('/tasks', task),
|
||||
update: (id: number, task: any) => demoApi.put<any>(`/tasks/${id}`, task),
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/tasks/${id}`),
|
||||
};
|
||||
|
||||
export const demoNotesApi = {
|
||||
getAll: (_search?: string, _tag?: string) => demoApi.get<any[]>('/notes'),
|
||||
getById: (id: number) => demoApi.get<any>(`/notes/${id}`),
|
||||
create: (note: any) => demoApi.post<any>('/notes', note),
|
||||
update: (id: number, note: any) => demoApi.put<any>(`/notes/${id}`, note),
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/notes/${id}`),
|
||||
getStats: () => demoApi.get<any>('/notes/stats'),
|
||||
};
|
||||
|
||||
export const demoFilesApi = {
|
||||
getAll: () => demoApi.get<any[]>('/files'),
|
||||
getById: (id: number) => demoApi.get<any>(`/files/${id}`),
|
||||
upload: (file: Blob, description?: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (description) formData.append('description', description);
|
||||
return demoApi.upload<any>('/files/upload', formData);
|
||||
},
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/files/${id}`),
|
||||
download: (id: number) => `${demoApi.baseURL}/files/${id}/download`,
|
||||
};
|
||||
|
||||
export const demoTimeEntriesApi = {
|
||||
getAll: (_startDate?: string, _endDate?: string, _isRunning?: boolean) =>
|
||||
demoApi.get<{ time_entries: any[] }>('/time-entries'),
|
||||
getById: (id: number) => demoApi.get<{ time_entry: any }>(`/time-entries/${id}`),
|
||||
create: (timeEntry: any) => demoApi.post<{ time_entry: any }>('/time-entries', timeEntry),
|
||||
update: (id: number, timeEntry: any) => demoApi.put<{ time_entry: any }>(`/time-entries/${id}`, timeEntry),
|
||||
stop: (id: number) => demoApi.post<{ time_entry: any }>(`/time-entries/${id}/stop`),
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/time-entries/${id}`),
|
||||
getStats: () => demoApi.get<{ stats: any }>('/time-entries/stats'),
|
||||
};
|
||||
|
||||
export default demoApi;
|
||||
@@ -0,0 +1,577 @@
|
||||
// Demo mode API interceptor to provide mock data instead of making real API calls
|
||||
|
||||
// Check if demo mode is enabled via environment variable
|
||||
export const isEnvDemoMode = (): boolean => {
|
||||
const result = import.meta.env.VITE_DEMO_MODE === 'true';
|
||||
console.log('[Demo Mode] isEnvDemoMode:', result, 'VITE_DEMO_MODE:', import.meta.env.VITE_DEMO_MODE);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Check if demo mode is active (environment variable only)
|
||||
export const isDemoMode = (): boolean => {
|
||||
// Only check environment variable - no localStorage persistence
|
||||
return isEnvDemoMode();
|
||||
};
|
||||
|
||||
// Clear demo mode from localStorage
|
||||
export const clearDemoMode = (): void => {
|
||||
localStorage.removeItem('demoMode');
|
||||
// Only clear demo tokens, not legitimate user tokens
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// Only clear if they look like demo tokens (contain 'demo-token')
|
||||
if (token && token.includes('demo-token')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('trackeep_token');
|
||||
localStorage.removeItem('trackeep_user');
|
||||
}
|
||||
};
|
||||
|
||||
// Set demo mode (no-op - environment variable only)
|
||||
export const setDemoMode = (): void => {
|
||||
// Demo mode is controlled by environment variable only
|
||||
// No localStorage persistence
|
||||
};
|
||||
|
||||
// Mock data generators
|
||||
const generateMockStats = () => ({
|
||||
total_tasks: 42,
|
||||
completed_tasks: 28,
|
||||
total_bookmarks: 156,
|
||||
total_notes: 89,
|
||||
total_files: 234,
|
||||
total_time_tracked: 125000, // seconds
|
||||
recent_activity: [
|
||||
{ type: 'task', action: 'completed', title: 'Complete project documentation', timestamp: new Date(Date.now() - 3600000).toISOString() },
|
||||
{ type: 'bookmark', action: 'added', title: 'SolidJS Documentation', timestamp: new Date(Date.now() - 7200000).toISOString() },
|
||||
{ type: 'note', action: 'created', title: 'Meeting notes - Q1 planning', timestamp: new Date(Date.now() - 10800000).toISOString() },
|
||||
{ type: 'file', action: 'uploaded', title: 'project-roadmap.pdf', timestamp: new Date(Date.now() - 14400000).toISOString() },
|
||||
]
|
||||
});
|
||||
|
||||
const generateMockGitHubRepos = () => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'trackeep',
|
||||
full_name: 'tdvorak/trackeep',
|
||||
description: 'Your Self-Hosted Productivity Hub',
|
||||
private: false,
|
||||
stargazers_count: 245,
|
||||
forks_count: 65,
|
||||
watchers_count: 43,
|
||||
language: 'Go',
|
||||
updated_at: new Date().toISOString(),
|
||||
html_url: 'https://github.com/tdvorak/trackeep'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'solidjs-components',
|
||||
full_name: 'tdvorak/solidjs-components',
|
||||
description: 'Reusable SolidJS components library',
|
||||
private: false,
|
||||
stargazers_count: 89,
|
||||
forks_count: 12,
|
||||
watchers_count: 8,
|
||||
language: 'TypeScript',
|
||||
updated_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
html_url: 'https://github.com/tdvorak/solidjs-components'
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockTimeEntries = () => [
|
||||
{
|
||||
id: 1,
|
||||
description: 'Working on Trackeep frontend',
|
||||
start_time: new Date(Date.now() - 7200000).toISOString(),
|
||||
end_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
duration: 3600,
|
||||
billable: true,
|
||||
hourly_rate: 75,
|
||||
task_id: 1,
|
||||
created_at: new Date(Date.now() - 3600000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
description: 'Meeting with team',
|
||||
start_time: new Date(Date.now() - 14400000).toISOString(),
|
||||
end_time: new Date(Date.now() - 12600000).toISOString(),
|
||||
duration: 1800,
|
||||
billable: false,
|
||||
hourly_rate: 0,
|
||||
task_id: null,
|
||||
created_at: new Date(Date.now() - 12600000).toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockYouTubeVideos = () => [
|
||||
{
|
||||
id: 1,
|
||||
video_id: 'dQw4w9WgXcQ',
|
||||
title: 'Never Gonna Give You Up',
|
||||
description: 'Classic music video',
|
||||
channel_name: 'Rick Astley',
|
||||
thumbnail_url: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
|
||||
duration: '3:33',
|
||||
published_at: '2009-10-25T06:57:33Z',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockLearningPaths = () => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'SolidJS Mastery',
|
||||
description: 'Complete guide to SolidJS framework',
|
||||
category: 'frontend',
|
||||
difficulty: 'intermediate',
|
||||
estimated_hours: 20,
|
||||
progress: 65,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Go Backend Development',
|
||||
description: 'Build scalable backend with Go',
|
||||
category: 'backend',
|
||||
difficulty: 'advanced',
|
||||
estimated_hours: 40,
|
||||
progress: 30,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockChatSessions = () => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Project Planning Discussion',
|
||||
created_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
message_count: 15
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Code Review Help',
|
||||
created_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
updated_at: new Date(Date.now() - 7200000).toISOString(),
|
||||
message_count: 8
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockAIProviders = () => [
|
||||
{
|
||||
id: 'longcat',
|
||||
name: 'LongCat AI',
|
||||
description: 'Fast and efficient AI models',
|
||||
models: ['LongCat-Flash-Chat', 'LongCat-Flash-Thinking'],
|
||||
enabled: true,
|
||||
api_key_configured: true
|
||||
},
|
||||
{
|
||||
id: 'mistral',
|
||||
name: 'Mistral AI',
|
||||
description: 'European AI models',
|
||||
models: ['mistral-small-latest', 'mistral-large-latest'],
|
||||
enabled: false,
|
||||
api_key_configured: false
|
||||
}
|
||||
];
|
||||
|
||||
// Demo mode fetch interceptor
|
||||
export const demoFetch = async (url: string, options?: RequestInit): Promise<Response> => {
|
||||
if (!isDemoMode()) {
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
// Parse URL to determine which mock data to return
|
||||
let path: string;
|
||||
try {
|
||||
// Handle relative URLs by providing a base URL
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
path = urlObj.pathname;
|
||||
} catch (error) {
|
||||
// If URL construction fails, treat the url as the path directly
|
||||
path = url;
|
||||
console.warn('[Demo Mode] URL construction failed, using url as path:', url);
|
||||
}
|
||||
|
||||
console.log(`[Demo Mode] Intercepting request to: ${path}`);
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Return mock data based on the endpoint
|
||||
if (path.includes('/api/v1/auth/login') || path.includes('/api/v1/auth/login-totp')) {
|
||||
// Handle demo login
|
||||
return new Response(JSON.stringify({
|
||||
token: 'demo-token-' + Date.now(),
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'demo@trackeep.com',
|
||||
username: 'demo',
|
||||
full_name: 'Demo User',
|
||||
theme: 'dark',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/dashboard/stats')) {
|
||||
return new Response(JSON.stringify(generateMockStats()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/tasks') && (!options?.method || options.method === 'GET')) {
|
||||
const { getMockTasks } = await import('./mockData');
|
||||
const mockTasks = getMockTasks().map((task, index) => ({
|
||||
id: index + 1,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
completed: task.status === 'completed',
|
||||
priority: task.priority,
|
||||
createdAt: task.createdAt,
|
||||
dueDate: task.dueDate,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify(mockTasks), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/github/repos')) {
|
||||
return new Response(JSON.stringify(generateMockGitHubRepos()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/time-entries')) {
|
||||
return new Response(JSON.stringify(generateMockTimeEntries()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/video-bookmarks')) {
|
||||
if (options?.method === 'GET') {
|
||||
// Return empty bookmarks for demo
|
||||
return new Response(JSON.stringify({ bookmarks: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (options?.method === 'POST') {
|
||||
// Simulate creating a bookmark
|
||||
return new Response(JSON.stringify({
|
||||
id: Date.now(),
|
||||
message: 'Bookmark created successfully (demo mode)'
|
||||
}), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/youtube/search')) {
|
||||
const { getMockVideos } = await import('./mockData');
|
||||
const body = options?.body && typeof options.body === 'string' ? JSON.parse(options.body) : {};
|
||||
const query = body.query || '';
|
||||
|
||||
const mockVideos = getMockVideos().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) => ({
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
channel_title: video.channel,
|
||||
duration: video.duration,
|
||||
published_at: video.publishedAt,
|
||||
view_count: Math.floor(Math.random() * 100000) + 1000
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ videos: mockVideos }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/youtube/channel-videos')) {
|
||||
const { getMockVideos } = await import('./mockData');
|
||||
const mockVideos = getMockVideos().slice(0, 5).map((video) => ({
|
||||
video_id: video.id,
|
||||
title: video.title,
|
||||
channel: video.channel,
|
||||
length: video.duration,
|
||||
views: Math.floor(Math.random() * 100000) + 1000,
|
||||
published_date: video.publishedAt,
|
||||
published_text: new Date(video.publishedAt).toLocaleDateString()
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ videos: mockVideos }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/youtube/video-details')) {
|
||||
return new Response(JSON.stringify(generateMockYouTubeVideos()[0]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/youtube/predefined-channels')) {
|
||||
return new Response(JSON.stringify(generateMockYouTubeVideos()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/learning-paths/categories')) {
|
||||
return new Response(JSON.stringify(['frontend', 'backend', 'fullstack', 'devops', 'mobile', 'design']), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/learning-paths')) {
|
||||
return new Response(JSON.stringify(generateMockLearningPaths()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/chat/sessions')) {
|
||||
return new Response(JSON.stringify(generateMockChatSessions()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/ai/providers')) {
|
||||
return new Response(JSON.stringify(generateMockAIProviders()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Handle update checking endpoints
|
||||
if (path.includes('/api/updates/check')) {
|
||||
return new Response(JSON.stringify({
|
||||
updateAvailable: false,
|
||||
currentVersion: '1.0.0-demo',
|
||||
latestVersion: '1.0.0-demo'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/updates/progress')) {
|
||||
return new Response(JSON.stringify({
|
||||
progress: 0,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
error: null
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// For POST requests that create data
|
||||
if (options?.method === 'POST') {
|
||||
if (path.includes('/api/v1/time-entries')) {
|
||||
const newEntry = { ...JSON.parse(options.body as string), id: Date.now() };
|
||||
return new Response(JSON.stringify(newEntry), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/tasks')) {
|
||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||
const newTask = {
|
||||
id: Date.now(),
|
||||
title: body.title || 'Untitled task',
|
||||
description: body.description || '',
|
||||
completed: body.completed ?? false,
|
||||
priority: body.priority || 'medium',
|
||||
createdAt: body.createdAt || new Date().toISOString(),
|
||||
dueDate: body.dueDate || '',
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(newTask), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.method === 'PUT' && path.includes('/api/v1/tasks')) {
|
||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||
const pathParts = path.split('/');
|
||||
const idFromPath = parseInt(pathParts[pathParts.length - 1] || '0', 10);
|
||||
const updatedTask = {
|
||||
id: idFromPath || body.id || Date.now(),
|
||||
title: body.title || 'Untitled task',
|
||||
description: body.description || '',
|
||||
completed: body.completed ?? false,
|
||||
priority: body.priority || 'medium',
|
||||
createdAt: body.createdAt || new Date().toISOString(),
|
||||
dueDate: body.dueDate || '',
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(updatedTask), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.method === 'DELETE' && path.includes('/api/v1/tasks')) {
|
||||
return new Response(JSON.stringify({ message: 'Task deleted (demo mode)' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Handle search API endpoints
|
||||
if (path.includes('/api/v1/search/web') || path.includes('/res/v1/web/search')) {
|
||||
let queryParam: string;
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
queryParam = urlObj.searchParams.get('q') ||
|
||||
(options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
|
||||
} catch {
|
||||
queryParam = (options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
|
||||
}
|
||||
|
||||
if (!queryParam) {
|
||||
return new Response(JSON.stringify({ error: 'Query parameter required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const mockSearchResults = [
|
||||
{
|
||||
title: `${queryParam} - Demo Search Result 1`,
|
||||
url: `https://demo.example.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `This is a demo search result for "${queryParam}" showing how the search functionality works in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'web',
|
||||
subtype: 'search'
|
||||
},
|
||||
{
|
||||
title: `${queryParam} - Demo Search Result 2`,
|
||||
url: `https://demo-search.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Another demo search result for "${queryParam}" demonstrating the search interface in demo mode.`,
|
||||
published_date: new Date(Date.now() - 86400000).toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'web',
|
||||
subtype: 'search'
|
||||
},
|
||||
{
|
||||
title: `Learn more about ${queryParam}`,
|
||||
url: `https://demo-learning.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Educational content about ${queryParam} for demo purposes. This shows how search results can include learning resources.`,
|
||||
published_date: new Date(Date.now() - 172800000).toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'web',
|
||||
subtype: 'educational'
|
||||
}
|
||||
];
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
web: { results: mockSearchResults },
|
||||
query: { original: queryParam, display: queryParam },
|
||||
mixed: { results: mockSearchResults }
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/search/news') || path.includes('/res/v1/news/search')) {
|
||||
let queryParam: string;
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
queryParam = urlObj.searchParams.get('q') ||
|
||||
(options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
|
||||
} catch {
|
||||
queryParam = (options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
|
||||
}
|
||||
|
||||
if (!queryParam) {
|
||||
return new Response(JSON.stringify({ error: 'Query parameter required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const mockNewsResults = [
|
||||
{
|
||||
title: `Breaking News: ${queryParam} Update`,
|
||||
url: `https://demo-news.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Latest news about ${queryParam} - this is a demo news search result showing how news search works in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'news',
|
||||
subtype: 'article'
|
||||
},
|
||||
{
|
||||
title: `${queryParam} - Industry Report`,
|
||||
url: `https://demo-industry.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Industry analysis and reports about ${queryParam}. This demo result shows how news search can include industry content.`,
|
||||
published_date: new Date(Date.now() - 86400000).toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'news',
|
||||
subtype: 'report'
|
||||
}
|
||||
];
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
news: { results: mockNewsResults },
|
||||
query: { original: queryParam, display: queryParam },
|
||||
mixed: { results: mockNewsResults }
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Default fallback - return a successful empty response
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
|
||||
// Override global fetch for demo mode
|
||||
export const initializeDemoMode = () => {
|
||||
if (isDemoMode()) {
|
||||
// Store original fetch to restore later if needed
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = demoFetch as typeof fetch;
|
||||
console.log('[Demo Mode] API interceptor initialized');
|
||||
return originalFetch;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
// Utility functions for formatting time durations
|
||||
|
||||
export interface TimeDuration {
|
||||
years: number;
|
||||
months: number;
|
||||
weeks: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export function formatDuration(totalHours: number): string {
|
||||
if (totalHours < 1) {
|
||||
const minutes = Math.round(totalHours * 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const duration = breakDownDuration(totalHours);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (duration.years > 0) {
|
||||
parts.push(`${duration.years}y`);
|
||||
}
|
||||
if (duration.months > 0) {
|
||||
parts.push(`${duration.months}mo`);
|
||||
}
|
||||
if (duration.weeks > 0) {
|
||||
parts.push(`${duration.weeks}w`);
|
||||
}
|
||||
if (duration.days > 0) {
|
||||
parts.push(`${duration.days}d`);
|
||||
}
|
||||
if (duration.hours > 0) {
|
||||
parts.push(`${duration.hours}h`);
|
||||
}
|
||||
|
||||
// If we have multiple parts, show the most significant 2-3
|
||||
if (parts.length > 3) {
|
||||
return parts.slice(0, 3).join(' ');
|
||||
}
|
||||
|
||||
// If we have just hours and it's less than 24, show just hours
|
||||
if (parts.length === 1 && parts[0].includes('h')) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function formatDurationShort(totalHours: number): string {
|
||||
if (totalHours < 24) {
|
||||
return `${Math.round(totalHours)}h`;
|
||||
}
|
||||
|
||||
const duration = breakDownDuration(totalHours);
|
||||
|
||||
if (duration.years > 0) {
|
||||
return `${duration.years}y ${duration.months}mo`;
|
||||
}
|
||||
if (duration.months > 0) {
|
||||
return `${duration.months}mo ${duration.weeks}w`;
|
||||
}
|
||||
if (duration.weeks > 0) {
|
||||
return `${duration.weeks}w ${duration.days}d`;
|
||||
}
|
||||
if (duration.days > 0) {
|
||||
return `${duration.days}d ${duration.hours}h`;
|
||||
}
|
||||
|
||||
return `${duration.hours}h`;
|
||||
}
|
||||
|
||||
export function formatDurationDetailed(totalHours: number): string {
|
||||
const duration = breakDownDuration(totalHours);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (duration.years > 0) parts.push(`${duration.years} year${duration.years !== 1 ? 's' : ''}`);
|
||||
if (duration.months > 0) parts.push(`${duration.months} month${duration.months !== 1 ? 's' : ''}`);
|
||||
if (duration.weeks > 0) parts.push(`${duration.weeks} week${duration.weeks !== 1 ? 's' : ''}`);
|
||||
if (duration.days > 0) parts.push(`${duration.days} day${duration.days !== 1 ? 's' : ''}`);
|
||||
if (duration.hours > 0) parts.push(`${duration.hours} hour${duration.hours !== 1 ? 's' : ''}`);
|
||||
|
||||
if (parts.length === 0) {
|
||||
const minutes = Math.round(totalHours * 60);
|
||||
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
return parts.join(' and ');
|
||||
}
|
||||
|
||||
return parts.slice(0, -1).join(', ') + ', and ' + parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function breakDownDuration(totalHours: number): TimeDuration {
|
||||
const hoursInDay = 24;
|
||||
const hoursInWeek = hoursInDay * 7;
|
||||
const hoursInMonth = hoursInDay * 30.44; // Average month length
|
||||
const hoursInYear = hoursInDay * 365.25; // Account for leap years
|
||||
|
||||
let remaining = totalHours;
|
||||
|
||||
const years = Math.floor(remaining / hoursInYear);
|
||||
remaining -= years * hoursInYear;
|
||||
|
||||
const months = Math.floor(remaining / hoursInMonth);
|
||||
remaining -= months * hoursInMonth;
|
||||
|
||||
const weeks = Math.floor(remaining / hoursInWeek);
|
||||
remaining -= weeks * hoursInWeek;
|
||||
|
||||
const days = Math.floor(remaining / hoursInDay);
|
||||
remaining -= days * hoursInDay;
|
||||
|
||||
const hours = Math.floor(remaining);
|
||||
remaining -= hours;
|
||||
|
||||
const minutes = Math.round(remaining * 60);
|
||||
|
||||
return {
|
||||
years,
|
||||
months,
|
||||
weeks,
|
||||
days,
|
||||
hours,
|
||||
minutes
|
||||
};
|
||||
}
|
||||
|
||||
export function getLargestTimeUnit(totalHours: number): { value: number; unit: string } {
|
||||
const duration = breakDownDuration(totalHours);
|
||||
|
||||
if (duration.years > 0) return { value: duration.years, unit: 'years' };
|
||||
if (duration.months > 0) return { value: duration.months, unit: 'months' };
|
||||
if (duration.weeks > 0) return { value: duration.weeks, unit: 'weeks' };
|
||||
if (duration.days > 0) return { value: duration.days, unit: 'days' };
|
||||
if (duration.hours > 0) return { value: duration.hours, unit: 'hours' };
|
||||
|
||||
return { value: Math.round(totalHours * 60), unit: 'minutes' };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Utility functions for formatting time durations in human-readable format
|
||||
|
||||
/**
|
||||
* Formats a duration in hours to human-readable format
|
||||
* @param totalHours Total duration in hours
|
||||
* @returns Formatted string (e.g., "2.5 days", "1 month", "3.2 years")
|
||||
*/
|
||||
export const formatDuration = (totalHours: number): string => {
|
||||
if (totalHours < 1) {
|
||||
const minutes = Math.round(totalHours * 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
if (totalHours < 24) {
|
||||
return `${Math.round(totalHours * 10) / 10}h`;
|
||||
}
|
||||
|
||||
const days = totalHours / 24;
|
||||
if (days < 7) {
|
||||
return `${Math.round(days * 10) / 10} days`;
|
||||
}
|
||||
|
||||
const weeks = days / 7;
|
||||
if (weeks < 4) {
|
||||
return `${Math.round(weeks * 10) / 10} weeks`;
|
||||
}
|
||||
|
||||
const months = days / 30.44; // Average month length
|
||||
if (months < 12) {
|
||||
return `${Math.round(months * 10) / 10} months`;
|
||||
}
|
||||
|
||||
const years = months / 12;
|
||||
return `${Math.round(years * 10) / 10} years`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a duration in hours to a compact format
|
||||
* @param totalHours Total duration in hours
|
||||
* @returns Compact formatted string (e.g., "2.5d", "1mo", "3.2y")
|
||||
*/
|
||||
export const formatDurationCompact = (totalHours: number): string => {
|
||||
if (totalHours < 1) {
|
||||
const minutes = Math.round(totalHours * 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
if (totalHours < 24) {
|
||||
return `${Math.round(totalHours * 10) / 10}h`;
|
||||
}
|
||||
|
||||
const days = totalHours / 24;
|
||||
if (days < 7) {
|
||||
return `${Math.round(days * 10) / 10}d`;
|
||||
}
|
||||
|
||||
const weeks = days / 7;
|
||||
if (weeks < 4) {
|
||||
return `${Math.round(weeks * 10) / 10}w`;
|
||||
}
|
||||
|
||||
const months = days / 30.44; // Average month length
|
||||
if (months < 12) {
|
||||
return `${Math.round(months * 10) / 10}mo`;
|
||||
}
|
||||
|
||||
const years = months / 12;
|
||||
return `${Math.round(years * 10) / 10}y`;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
interface WeeklyBarChartProps {
|
||||
data: number[];
|
||||
title?: string;
|
||||
type?: 'activities' | 'contributions';
|
||||
fallbackData?: number[];
|
||||
}
|
||||
|
||||
export const WeeklyBarChart = (props: WeeklyBarChartProps) => {
|
||||
const weeklyData = () => props.data || props.fallbackData || [12, 19, 8, 15, 22, 18, 25];
|
||||
const chartType = () => props.type || 'activities';
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<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-3 md:gap-4">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const activity = weeklyData()[index];
|
||||
const maxActivity = Math.max(...weeklyData());
|
||||
// Use dynamic scale based on actual data
|
||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
||||
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 finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 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-4 md:max-w-5 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;`}
|
||||
title={`${day}: ${activity} ${chartType()}`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
<span>Total: {weeklyData().reduce((a, b) => a + b, 0)} {chartType()}</span>
|
||||
<span>Avg: {Math.round(weeklyData().reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user