Configure Docker publishing with correct GitHub username

This commit is contained in:
Tomas Dvorak
2026-02-27 17:34:20 +01:00
parent 4c812e376d
commit 0a80ecd9f7
138 changed files with 12130 additions and 7831 deletions
+10 -68
View File
@@ -7,7 +7,9 @@ import { VideoUploadModal } from '@/components/ui/VideoUploadModal';
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs';
import { getMockBookmarks, getMockVideos } from '@/lib/mockData';
import { getApiV1BaseUrl } from '@/lib/api-url';
const API_BASE_URL = getApiV1BaseUrl();
interface BookmarkTag {
id: number;
@@ -113,39 +115,7 @@ export const Bookmarks = () => {
// We no longer show inline HTML content previews, only the bookmark cards themselves
onMount(async () => {
// Check if we're in demo mode and load mock data directly
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
if (isDemoMode) {
console.log('Demo mode detected, loading mock bookmarks');
const mockBookmarks = getMockBookmarks();
const adaptedBookmarks: Bookmark[] = mockBookmarks.map((bookmark, index) => ({
id: index + 1,
title: bookmark.title,
url: bookmark.url,
description: bookmark.description,
tags: bookmark.tags.map((tag) => tag.name),
created_at: bookmark.createdAt,
isImportant: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
favicon: bookmark.favicon,
screenshot: bookmark.screenshot,
screenshot_medium: bookmark.screenshot,
}));
setBookmarks(adaptedBookmarks);
setIsLoading(false);
// Load mock video bookmarks
const mockVideos = getMockVideos();
setVideoBookmarks(mockVideos);
setIsLoadingVideos(false);
return;
}
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api/v1';
// Load regular bookmarks
const bookmarksResponse = await fetch(`${API_BASE_URL}/bookmarks`, {
headers: {
@@ -176,38 +146,18 @@ export const Bookmarks = () => {
const videosData = await videosResponse.json();
setVideoBookmarks(Array.isArray(videosData) ? videosData : []);
} else {
// If video endpoint fails, load mock videos as fallback
const mockVideos = getMockVideos();
setVideoBookmarks(mockVideos);
setVideoBookmarks([]);
}
} catch (videoError) {
console.warn('Failed to load video bookmarks, using mock data:', videoError);
const mockVideos = getMockVideos();
setVideoBookmarks(mockVideos);
console.warn('Failed to load video bookmarks:', videoError);
setVideoBookmarks([]);
}
setIsLoadingVideos(false);
} catch (error) {
console.error('Failed to load bookmarks:', error);
// Fallback to mock data if API fails
const mockBookmarks = getMockBookmarks();
const adaptedBookmarks: Bookmark[] = mockBookmarks.map((bookmark, index) => ({
id: index + 1,
title: bookmark.title,
url: bookmark.url,
description: bookmark.description,
tags: bookmark.tags.map((tag) => tag.name),
created_at: bookmark.createdAt,
isImportant: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
favicon: bookmark.favicon,
screenshot: bookmark.screenshot,
screenshot_medium: bookmark.screenshot,
}));
setBookmarks(adaptedBookmarks);
// Also load mock videos as fallback
const mockVideos = getMockVideos();
setVideoBookmarks(mockVideos);
setBookmarks([]);
setVideoBookmarks([]);
setIsLoadingVideos(false);
} finally {
setIsLoading(false);
@@ -392,7 +342,7 @@ export const Bookmarks = () => {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
'Authorization': `Bearer ${localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''}`
},
body: JSON.stringify({ video_id: video.video_id })
});
@@ -400,7 +350,7 @@ export const Bookmarks = () => {
if (response.ok) {
console.log('Video added:', video);
} else {
console.log('Video added (demo mode):', video);
console.warn('Video save endpoint returned non-OK status');
}
setShowVideoModal(false);
} catch (error) {
@@ -414,14 +364,6 @@ export const Bookmarks = () => {
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-foreground">Bookmarks</h1>
<Show when={localStorage.getItem('demoMode') === 'true' || window.location.search.includes('demo=true')}>
<div class="flex items-center gap-2 mt-2">
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded-full">
Demo Mode
</span>
<span class="text-sm text-muted-foreground">Showing sample bookmarks</span>
</div>
</Show>
</div>
<Show when={activeTab() === 'bookmarks'}>
<Button onClick={() => setShowAddModal(true)}>
+104 -85
View File
@@ -1,5 +1,6 @@
import { createSignal, createEffect, onMount, For, Show } from 'solid-js'
import { DateRangePicker } from '@/components/ui/DateRangePicker';
import { ModalPortal } from '@/components/ui/ModalPortal';
import {
IconCalendar,
IconClock,
@@ -86,8 +87,8 @@ export function Calendar() {
try {
const token = localStorage.getItem('token');
if (isDemoModeEnabled() || !token) {
// Use mock data in demo mode or when not authenticated
if (isDemoModeEnabled()) {
// Use mock data in demo mode
const mockEvents = getMockCalendarEvents();
const today = new Date();
@@ -131,6 +132,14 @@ export function Calendar() {
return;
}
if (!token) {
setMappedEvents([]);
setTodayEvents([]);
setUpcomingEvents([]);
setDeadlines([]);
return;
}
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
@@ -159,46 +168,52 @@ export function Calendar() {
}
} catch (error) {
console.error('Failed to fetch calendar data:', error)
// Fallback to mock data if API fails
const mockEvents = getMockCalendarEvents();
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekFromNow = new Date();
weekFromNow.setDate(weekFromNow.getDate() + 7);
// Map mock events to calendar events and store for calendar grid
const mappedEvents: CalendarEvent[] = mockEvents.map(event => ({
id: parseInt(event.id),
title: event.title,
description: event.description,
start_time: event.start,
end_time: event.end,
type: event.type === 'personal' ? 'reminder' : event.type as 'task' | 'meeting' | 'deadline' | 'reminder' | 'habit',
priority: 'medium' as 'low' | 'medium' | 'high' | 'urgent',
location: event.location,
is_completed: false,
is_all_day: event.allDay
}));
setMappedEvents(mappedEvents);
const todayEvents = mappedEvents.filter(event => {
const eventDate = new Date(event.start_time);
return eventDate.toDateString() === today.toDateString();
});
const upcomingEvents = mappedEvents.filter(event => {
const eventDate = new Date(event.start_time);
return eventDate >= today && eventDate <= weekFromNow;
});
const deadlines = mappedEvents.filter(event =>
event.type === 'deadline' && new Date(event.start_time) >= today
);
setTodayEvents(todayEvents);
setUpcomingEvents(upcomingEvents);
setDeadlines(deadlines);
if (isDemoModeEnabled()) {
const mockEvents = getMockCalendarEvents();
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekFromNow = new Date();
weekFromNow.setDate(weekFromNow.getDate() + 7);
const mappedEvents: CalendarEvent[] = mockEvents.map(event => ({
id: parseInt(event.id),
title: event.title,
description: event.description,
start_time: event.start,
end_time: event.end,
type: event.type === 'personal' ? 'reminder' : event.type as 'task' | 'meeting' | 'deadline' | 'reminder' | 'habit',
priority: 'medium' as 'low' | 'medium' | 'high' | 'urgent',
location: event.location,
is_completed: false,
is_all_day: event.allDay
}));
setMappedEvents(mappedEvents);
const todayEvents = mappedEvents.filter(event => {
const eventDate = new Date(event.start_time);
return eventDate.toDateString() === today.toDateString();
});
const upcomingEvents = mappedEvents.filter(event => {
const eventDate = new Date(event.start_time);
return eventDate >= today && eventDate <= weekFromNow;
});
const deadlines = mappedEvents.filter(event =>
event.type === 'deadline' && new Date(event.start_time) >= today
);
setTodayEvents(todayEvents);
setUpcomingEvents(upcomingEvents);
setDeadlines(deadlines);
return;
}
setMappedEvents([]);
setTodayEvents([]);
setUpcomingEvents([]);
setDeadlines([]);
}
}
@@ -775,25 +790,26 @@ export function Calendar() {
{/* Event Creation Modal */}
<Show when={showEventModal()}>
<div
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) {
setShowEventModal(false);
}
}}
>
<div class="bg-card rounded-lg border border-border p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">New Event</h3>
<button
onClick={() => setShowEventModal(false)}
class="p-1 hover:bg-accent rounded-lg transition-colors"
>
<IconX class="size-4" />
</button>
</div>
<ModalPortal>
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
// Close modal only when clicking the backdrop, not the modal content
if (e.target === e.currentTarget) {
setShowEventModal(false);
}
}}
>
<div class="bg-card rounded-lg border border-border p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">New Event</h3>
<button
onClick={() => setShowEventModal(false)}
class="p-1 hover:bg-accent rounded-lg transition-colors"
>
<IconX class="size-4" />
</button>
</div>
<div class="space-y-4">
<div>
@@ -936,34 +952,36 @@ export function Calendar() {
</div>
</div>
</div>
</div>
</div>
</div>
</ModalPortal>
</Show>
{/* Task Detail Modal */}
<Show when={showTaskDetailModal() && selectedTask()}>
<div
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);
setSelectedTask(null);
}
}}
>
<div class="bg-card rounded-lg border border-border p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">Task Details</h3>
<button
onClick={() => {
setShowTaskDetailModal(false);
setSelectedTask(null);
}}
class="p-1 hover:bg-accent rounded-lg transition-colors"
>
<IconX class="size-4" />
</button>
</div>
<ModalPortal>
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowTaskDetailModal(false);
setSelectedTask(null);
}
}}
>
<div class="bg-card rounded-lg border border-border p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">Task Details</h3>
<button
onClick={() => {
setShowTaskDetailModal(false);
setSelectedTask(null);
}}
class="p-1 hover:bg-accent rounded-lg transition-colors"
>
<IconX class="size-4" />
</button>
</div>
<Show when={selectedTask()}>
{(task) => (
@@ -1106,8 +1124,9 @@ export function Calendar() {
</div>
)}
</Show>
</div>
</div>
</div>
</ModalPortal>
</Show>
</div>
)
+167 -137
View File
@@ -1,4 +1,4 @@
import { createResource, createSignal, For, Show, onMount } from 'solid-js'
import { createEffect, createResource, createSignal, For, Show, onMount } from 'solid-js'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Card } from '@/components/ui/Card'
@@ -35,6 +35,18 @@ interface ChatSession {
include_notes: boolean
}
const getToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''
const getProviderFromModel = (modelId: string): string => {
const value = modelId.toLowerCase()
if (value.includes('mistral')) return 'mistral'
if (value.includes('grok')) return 'grok'
if (value.includes('deepseek')) return 'deepseek'
if (value.includes('ollama')) return 'ollama'
if (value.includes('openrouter')) return 'openrouter'
return 'longcat'
}
const Chat = () => {
const [activeView, setActiveView] = createSignal<'chat' | 'ai-tools'>('chat')
const [aiTool, setAiTool] = createSignal<'summarizer' | 'tasks' | 'content'>('summarizer')
@@ -118,7 +130,7 @@ const Chat = () => {
})
}
// Add all available providers (even if disabled) for demo purposes
// Keep disabled providers visible so users can enable them in settings
if (providers.length > 0) {
providers.forEach((provider: any) => {
if (!models.find(m => m.id === provider.id)) {
@@ -160,50 +172,26 @@ const Chat = () => {
return descriptions[provider] || 'AI provider'
}
const [sessions] = createResource(async () => {
const fetchSessions = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions`)
const token = getToken()
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions`, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
if (!response.ok) throw new Error('Failed to fetch sessions')
return response.json() as Promise<ChatSession[]>
} catch (error) {
console.error('Failed to fetch sessions:', error)
// Return mock sessions for demo mode
return Promise.resolve([
{
id: 1,
title: 'Getting Started',
message_count: 2,
last_message_at: new Date().toISOString(),
created_at: new Date().toISOString(),
include_bookmarks: true,
include_tasks: true,
include_files: true,
include_notes: true
},
{
id: 2,
title: 'Project Planning',
message_count: 5,
last_message_at: new Date(Date.now() - 86400000).toISOString(),
created_at: new Date(Date.now() - 172800000).toISOString(),
include_bookmarks: true,
include_tasks: true,
include_files: false,
include_notes: true
}
] as ChatSession[])
return [] as ChatSession[]
}
})
}
const [currentSessionId, setCurrentSessionId] = createSignal<string | null>('1')
const [messages, setMessages] = createSignal<ChatMessage[]>([
{
id: 1,
content: 'Hello! I\'m your AI assistant. How can I help you today?',
role: 'assistant',
created_at: new Date().toISOString()
}
])
const [sessions, { refetch: refetchSessions }] = createResource(fetchSessions)
const [currentSessionId, setCurrentSessionId] = createSignal<string | null>(null)
const [messages, setMessages] = createSignal<ChatMessage[]>([])
const [inputMessage, setInputMessage] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
const [showSettings, setShowSettings] = createSignal(false)
@@ -226,6 +214,47 @@ const Chat = () => {
{ id: 'content', label: 'Content Generation', icon: Sparkles, description: 'Generate content using AI assistance' }
]
const loadSessionMessages = async (sessionId: string) => {
try {
const token = getToken()
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions/${sessionId}/messages`, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
if (!response.ok) {
throw new Error('Failed to fetch messages')
}
const data = await response.json()
const parsedMessages: ChatMessage[] = (Array.isArray(data) ? data : []).map((message: any) => ({
id: Number(message.id || Date.now()),
content: message.content || '',
role: message.role === 'user' ? 'user' : 'assistant',
created_at: message.created_at || new Date().toISOString(),
token_count: message.token_count,
context_items: Array.isArray(message.context_items) ? message.context_items : [],
}))
setMessages(parsedMessages)
} catch (error) {
console.error('Failed to load session messages:', error)
setMessages([])
}
}
createEffect(() => {
const loadedSessions = sessions()
if (!loadedSessions || loadedSessions.length === 0 || currentSessionId()) {
return
}
const firstSessionId = String(loadedSessions[0].id)
setCurrentSessionId(firstSessionId)
void loadSessionMessages(firstSessionId)
})
const handleSendMessage = async () => {
const message = inputMessage().trim()
if (!message || isLoading()) return
@@ -238,21 +267,69 @@ const Chat = () => {
created_at: new Date().toISOString()
}
setMessages(prev => [...prev, userMessage])
setMessages((prev) => [...prev, userMessage])
setInputMessage('')
setIsLoading(true)
// Simulate AI response (in production, this would call the AI API)
setTimeout(() => {
try {
const token = getToken()
const payload: Record<string, any> = {
message,
context: {
bookmarks: contextSettings().bookmarks,
tasks: contextSettings().tasks,
files: contextSettings().files,
notes: contextSettings().notes,
},
provider: getProviderFromModel(selectedModel()),
}
if (currentSessionId()) {
payload.session_id = currentSessionId()
}
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error('Failed to send message')
}
const data = await response.json()
const sessionId = String(data.session_id || currentSessionId() || '')
if (sessionId) {
setCurrentSessionId(sessionId)
}
const aiResponse: ChatMessage = {
id: Date.now() + 1,
content: `I received your message: "${message}". This is a demo response. In production, I would provide a helpful response based on the selected AI model (${selectedModel()}) and your context settings.`,
content: data.message || 'No response received.',
role: 'assistant',
created_at: new Date().toISOString()
created_at: data.timestamp || new Date().toISOString(),
}
setMessages(prev => [...prev, aiResponse])
setMessages((prev) => [...prev, aiResponse])
await refetchSessions()
} catch (error) {
console.error('Failed to send message:', error)
setMessages((prev) => [
...prev,
{
id: Date.now() + 1,
content: 'Message failed. Please check your AI settings and try again.',
role: 'assistant',
created_at: new Date().toISOString(),
},
])
} finally {
setIsLoading(false)
}, 1000)
}
}
return (
@@ -541,24 +618,8 @@ const Chat = () => {
<div class="p-6">
<Button
onClick={() => {
// Create new session
const newSession = {
id: Date.now().toString(),
title: 'New Chat',
message_count: 0,
created_at: new Date().toISOString(),
include_bookmarks: true,
include_tasks: true,
include_files: true,
include_notes: true
}
setCurrentSessionId(newSession.id)
setMessages([{
id: 1,
content: 'Hello! I\'m your AI assistant. How can I help you today?',
role: 'assistant',
created_at: new Date().toISOString()
}])
setCurrentSessionId(null)
setMessages([])
}}
class="w-full mb-6 h-11"
>
@@ -575,16 +636,9 @@ const Chat = () => {
: 'hover:bg-muted border-transparent hover:shadow-sm'
}`}
onClick={() => {
setCurrentSessionId(session.id.toString())
// Load messages for this session (mock for now)
setMessages([
{
id: 1,
content: `This is the ${session.title} session. How can I help you?`,
role: 'assistant',
created_at: new Date().toISOString()
}
])
const sessionId = session.id.toString()
setCurrentSessionId(sessionId)
void loadSessionMessages(sessionId)
}}
>
<div class="flex items-center justify-between">
@@ -597,10 +651,29 @@ const Chat = () => {
<Button
variant="ghost"
size="sm"
onClick={(e: MouseEvent) => {
onClick={async (e: MouseEvent) => {
e.stopPropagation()
// Delete session logic here
console.log('Delete session:', session.id)
try {
const token = getToken()
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions/${session.id}`, {
method: 'DELETE',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
if (!response.ok) {
throw new Error('Failed to delete session')
}
if (currentSessionId() === String(session.id)) {
setCurrentSessionId(null)
setMessages([])
}
await refetchSessions()
} catch (error) {
console.error('Delete session failed:', error)
}
}}
class="opacity-0 group-hover:opacity-100 transition-opacity"
>
@@ -863,16 +936,11 @@ const Chat = () => {
/>
</div>
<div class="flex gap-2">
<Button onClick={() => {
// Simulate summarization in demo mode
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
if (isDemoMode) {
alert('Summary generated! (Demo Mode)\n\nThis would use the selected AI model to summarize your content.');
}
}}>
Summarize
<Button
onClick={() => setActiveView('chat')}
disabled={!inputMessage().trim()}
>
Summarize In Chat
</Button>
<Button variant="outline" onClick={() => setInputMessage('')}>
Clear
@@ -886,51 +954,18 @@ const Chat = () => {
<Card class="p-6">
<h4 class="text-lg font-semibold mb-4">Task Suggestions</h4>
<p class="text-muted-foreground mb-4">
Get AI-powered task suggestions based on your current projects and deadlines.
AI task suggestions are generated from your real workspace context.
</p>
<div class="space-y-4">
<div class="p-4 bg-muted/30 rounded-lg">
<h5 class="font-medium mb-2">Suggested Tasks:</h5>
<ul class="space-y-2 text-sm">
<li class="flex items-center gap-2">
<CheckSquare class="h-4 w-4 text-primary" />
<span>Review and update project documentation</span>
</li>
<li class="flex items-center gap-2">
<CheckSquare class="h-4 w-4 text-primary" />
<span>Follow up with team members on pending items</span>
</li>
<li class="flex items-center gap-2">
<CheckSquare class="h-4 w-4 text-primary" />
<span>Prepare for upcoming meeting</span>
</li>
<li class="flex items-center gap-2">
<CheckSquare class="h-4 w-4 text-primary" />
<span>Review code quality and performance</span>
</li>
<li class="flex items-center gap-2">
<CheckSquare class="h-4 w-4 text-primary" />
<span>Update dependencies and security patches</span>
</li>
</ul>
<h5 class="font-medium mb-2">No suggestions yet</h5>
<p class="text-sm text-muted-foreground">
Start a chat and ask for task suggestions to generate items from your current data.
</p>
</div>
<div class="flex gap-2">
<Button onClick={() => {
// Simulate getting more suggestions
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
if (isDemoMode) {
alert('More tasks generated! (Demo Mode)\n\nThis would use the selected AI model to analyze your current work and suggest relevant tasks.');
}
}}>
Get More Suggestions
</Button>
<Button variant="outline" onClick={() => {
// Add selected tasks to actual task list
alert('Tasks would be added to your task list. (Demo Mode)');
}}>
Add Selected Tasks
<Button onClick={() => setActiveView('chat')}>
Open Chat
</Button>
</div>
</div>
@@ -983,16 +1018,11 @@ const Chat = () => {
</div>
</div>
<div class="flex gap-2">
<Button onClick={() => {
// Simulate content generation
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
if (isDemoMode) {
alert('Content generated! (Demo Mode)\n\nThis would use the selected AI model to generate your content.');
}
}}>
Generate Content
<Button
onClick={() => setActiveView('chat')}
disabled={!inputMessage().trim()}
>
Generate In Chat
</Button>
<Button variant="outline" onClick={() => setInputMessage('')}>
Clear
File diff suppressed because it is too large Load Diff
+32 -126
View File
@@ -5,7 +5,7 @@ import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { FileUpload } from '@/components/ui/FileUpload';
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
import { getMockFiles } from '@/lib/mockData';
import { getApiV1BaseUrl } from '@/lib/api-url';
import {
IconUpload,
IconEye,
@@ -15,6 +15,8 @@ import {
IconShare
} from '@tabler/icons-solidjs';
const API_BASE_URL = getApiV1BaseUrl();
interface FileItem {
id: number;
name: string;
@@ -48,137 +50,41 @@ export const Files = () => {
const [selectedFile, setSelectedFile] = createSignal<FileItem | null>(null);
const [copiedLink, setCopiedLink] = createSignal(false);
// 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');
};
onMount(async () => {
try {
if (isDemoMode()) {
// Use mock data in demo mode
const mockFiles = getMockFiles();
const mappedFiles: FileItem[] = mockFiles.map(file => ({
id: parseInt(file.id),
name: file.name,
size: file.size,
type: file.type,
uploadedAt: file.uploadedAt,
description: file.description,
tags: file.tags.map(tag => tag.name),
associations: file.associations?.map(assoc => ({
id: assoc.id,
type: assoc.type as 'task' | 'bookmark' | 'note' | 'project',
title: assoc.title
})),
url: file.url,
isLink: file.isLink,
preview: file.preview,
downloadUrl: file.downloadUrl,
viewUrl: file.viewUrl,
shareUrl: file.shareUrl
}));
setFiles(mappedFiles);
setIsLoading(false);
return;
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/files`, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!response.ok) {
throw new Error('Failed to load files');
}
// TODO: Replace with actual API call
// const response = await fetch('/api/v1/files');
// const data = await response.json();
// Mock data for now
setFiles([
{
id: 1,
name: 'project-plan.pdf',
size: 2048576,
type: 'application/pdf',
uploadedAt: '2024-01-15T10:30:00Z',
description: 'Q1 2024 project roadmap and milestones',
tags: ['planning', 'q1-2024'],
downloadUrl: '/files/download/1',
viewUrl: '/files/view/1',
shareUrl: '/files/share/1'
},
{
id: 2,
name: 'meeting-notes.docx',
size: 524288,
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
uploadedAt: '2024-01-14T15:45:00Z',
description: 'Team sync meeting notes',
tags: ['meetings', 'team'],
downloadUrl: '/files/download/2',
viewUrl: '/files/view/2',
shareUrl: '/files/share/2'
},
{
id: 3,
name: 'screenshot.png',
size: 1024000,
type: 'image/png',
uploadedAt: '2024-01-13T09:20:00Z',
description: 'UI design mockup',
tags: ['design', 'ui'],
preview: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
associations: [
{ id: '1', type: 'project', title: 'Website Redesign' },
{ id: '2', type: 'task', title: 'Create mockups' }
],
downloadUrl: '/files/download/3',
viewUrl: '/files/view/3',
shareUrl: '/files/share/3'
},
{
id: 4,
name: 'app.js',
size: 256000,
type: 'text/javascript',
uploadedAt: '2024-01-12T14:15:00Z',
description: 'Main application logic',
tags: ['javascript', 'frontend'],
preview: 'console.log("Hello World");\n\nfunction main() {\n // Main application logic\n return true;\n}',
associations: [
{ id: '3', type: 'project', title: 'Frontend App' }
],
downloadUrl: '/files/download/4',
viewUrl: '/files/view/4',
shareUrl: '/files/share/4'
},
{
id: 5,
name: 'database.sql',
size: 512000,
type: 'application/sql',
uploadedAt: '2024-01-11T11:30:00Z',
description: 'Database schema',
tags: ['database', 'sql'],
preview: 'CREATE TABLE users (\n id INT PRIMARY KEY,\n name VARCHAR(255) NOT NULL\n);',
associations: [
{ id: '4', type: 'project', title: 'Backend API' }
],
downloadUrl: '/files/download/5',
viewUrl: '/files/view/5',
shareUrl: '/files/share/5'
},
{
id: 6,
name: 'presentation.pptx',
size: 3072000,
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
uploadedAt: '2024-01-10T16:45:00Z',
description: 'Q4 review presentation',
tags: ['presentation', 'q4'],
downloadUrl: '/files/download/6',
viewUrl: '/files/view/6',
shareUrl: '/files/share/6'
}
]);
const filesData = await response.json();
const mappedFiles: FileItem[] = (Array.isArray(filesData) ? filesData : []).map((file: any, index) => ({
id: Number(file.id || index + 1),
name: file.original_name || file.file_name || `File ${index + 1}`,
size: Number(file.file_size || file.size || 0),
type: file.mime_type || file.type || 'application/octet-stream',
uploadedAt: file.created_at || file.uploadedAt || new Date().toISOString(),
description: file.description,
tags: Array.isArray(file.tags)
? file.tags.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name)).filter(Boolean)
: [],
url: file.url,
isLink: Boolean(file.is_link),
preview: file.preview,
downloadUrl: file.download_url,
viewUrl: file.view_url,
shareUrl: file.share_url
}));
setFiles(mappedFiles);
} catch (error) {
console.error('Failed to load files:', error);
setFiles([]);
} finally {
setIsLoading(false);
}
+140 -186
View File
@@ -2,6 +2,7 @@ import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { GitHubActivity } from '@/components/ui/GitHubActivity';
import { getApiV1BaseUrl } from '@/lib/api-url';
import {
IconBrandGithub,
IconTrendingUp,
@@ -50,6 +51,8 @@ interface GitHubStats {
repos: GitHubRepo[];
}
const API_BASE_URL = getApiV1BaseUrl();
export const GitHub = () => {
const [githubStats, setGithubStats] = createSignal<GitHubStats>({
totalRepos: 0,
@@ -65,21 +68,35 @@ export const GitHub = () => {
const [username, setUsername] = createSignal('');
const [isConnected, setIsConnected] = createSignal(false);
const weeklyTotal = () => weeklyActivity().reduce((a, b) => a + b, 0);
onMount(() => {
// Check if user is authenticated and has GitHub connected
checkGitHubConnection();
});
const resetGitHubData = () => {
setWeeklyActivity([0, 0, 0, 0, 0, 0, 0]);
setGithubStats({
totalRepos: 0,
totalStars: 0,
totalForks: 0,
totalWatchers: 0,
languages: [],
recentActivity: [],
repos: []
});
};
const checkGitHubConnection = async () => {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
if (!token) {
loadMockData();
resetGitHubData();
return;
}
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, {
const response = await fetch(`${API_BASE_URL}/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -92,24 +109,24 @@ export const GitHub = () => {
setUsername(userData.user.username);
await fetchGitHubStats();
} else {
loadMockData();
resetGitHubData();
}
} else {
loadMockData();
resetGitHubData();
}
} catch (error) {
console.error('Failed to check GitHub connection:', error);
loadMockData();
resetGitHubData();
}
};
const fetchGitHubStats = async () => {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
if (!token) {
throw new Error('No authentication token');
}
const response = await fetch(`${import.meta.env.VITE_API_URL}/github/repos`, {
const response = await fetch(`${API_BASE_URL}/github/repos`, {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -142,8 +159,7 @@ export const GitHub = () => {
} catch (error) {
console.error('Failed to fetch GitHub stats:', error);
// Fallback to mock data
loadMockData();
resetGitHubData();
}
};
@@ -177,81 +193,6 @@ export const GitHub = () => {
}));
};
const loadMockData = () => {
// Load mock data for demonstration
const mockRepos: GitHubRepo[] = [
{
id: 1,
name: 'trackeep',
full_name: 'demo/trackeep',
description: 'A comprehensive productivity and bookmark management system',
html_url: 'https://github.com/demo/trackeep',
stargazers_count: 156,
forks_count: 42,
watchers_count: 28,
language: 'TypeScript',
updated_at: '2024-01-28T10:30:00Z',
created_at: '2023-06-15T14:20:00Z',
size: 2456,
open_issues_count: 3,
default_branch: 'main'
},
{
id: 2,
name: 'solid-components',
full_name: 'demo/solid-components',
description: 'Reusable SolidJS components for modern web applications',
html_url: 'https://github.com/demo/solid-components',
stargazers_count: 89,
forks_count: 23,
watchers_count: 15,
language: 'TypeScript',
updated_at: '2024-01-27T16:45:00Z',
created_at: '2023-08-22T09:15:00Z',
size: 1234,
open_issues_count: 1,
default_branch: 'main'
}
];
const languages = [
{ name: 'TypeScript', count: 2, color: '#3178c6' },
{ name: 'Go', count: 1, color: '#00ADD8' }
];
const recentActivity = [
{
type: 'push',
repo: 'trackeep',
date: '2024-01-28',
message: 'feat: add GitHub integration'
}
];
// Generate mock weekly activity data
const mockWeeklyActivity = [
Math.floor(Math.random() * 20) + 5, // Monday
Math.floor(Math.random() * 25) + 8, // Tuesday
Math.floor(Math.random() * 22) + 6, // Wednesday
Math.floor(Math.random() * 18) + 4, // Thursday
Math.floor(Math.random() * 15) + 3, // Friday
Math.floor(Math.random() * 12) + 2, // Saturday
Math.floor(Math.random() * 10) + 1 // Sunday
];
setWeeklyActivity(mockWeeklyActivity);
setGithubStats({
totalRepos: mockRepos.length,
totalStars: mockRepos.reduce((sum, repo) => sum + repo.stargazers_count, 0),
totalForks: mockRepos.reduce((sum, repo) => sum + repo.forks_count, 0),
totalWatchers: mockRepos.reduce((sum, repo) => sum + repo.watchers_count, 0),
languages,
recentActivity,
repos: mockRepos
});
};
const connectGitHub = () => {
// Redirect to centralized OAuth service
window.location.href = 'https://oauth.tdvorak.dev/auth/github?redirect_uri=' + encodeURIComponent(window.location.origin + '/api/v1/auth/oauth/callback');
@@ -263,7 +204,7 @@ export const GitHub = () => {
// For now, we'll just clear the local state
setIsConnected(false);
setUsername('');
loadMockData();
resetGitHubData();
} catch (error) {
console.error('Failed to disconnect GitHub:', error);
}
@@ -412,20 +353,24 @@ export const GitHub = () => {
{/* Languages - Right Column (smaller) */}
<Card class="p-6 lg:col-span-1">
<h3 class="text-lg font-semibold text-foreground mb-4">Languages</h3>
<div class="space-y-3">
{githubStats().languages.map((language) => (
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full flex-shrink-0"
style={`background-color: ${language.color}`}
></div>
<span class="text-sm text-foreground truncate">{language.name}</span>
{githubStats().languages.length === 0 ? (
<p class="text-sm text-muted-foreground">No language data yet.</p>
) : (
<div class="space-y-3">
{githubStats().languages.map((language) => (
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full flex-shrink-0"
style={`background-color: ${language.color}`}
></div>
<span class="text-sm text-foreground truncate">{language.name}</span>
</div>
<span class="text-sm text-muted-foreground flex-shrink-0">{language.count} repos</span>
</div>
<span class="text-sm text-muted-foreground flex-shrink-0">{language.count} repos</span>
</div>
))}
</div>
))}
</div>
)}
</Card>
</div>
@@ -436,48 +381,49 @@ export const GitHub = () => {
<h3 class="text-lg font-semibold text-foreground">Weekly Activity</h3>
</div>
<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>
{weeklyTotal() === 0 ? (
<div class="h-32 md:h-36 border border-dashed border-border rounded-lg flex items-center justify-center">
<p class="text-sm text-muted-foreground">No weekly GitHub activity yet.</p>
</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 weeklyActivityData = weeklyActivity() || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
const activity = weeklyActivityData[index];
const maxActivity = Math.max(...weeklyActivityData);
// 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);
) : (
<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 weeklyActivityData = weeklyActivity();
const activity = weeklyActivityData[index];
const maxActivity = Math.max(...weeklyActivityData, 1);
const heightPercent = (activity / maxActivity) * 85;
const finalHeightPercent = activity > 0 ? Math.max(heightPercent, 6) : 0;
return (
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8 h-full">
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center justify-end h-full">
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">
{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} contributions`}
></div>
return (
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8 h-full">
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center justify-end h-full">
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">
{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%);`}
title={`${day}: ${activity} contributions`}
></div>
</div>
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
</div>
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
</div>
);
})}
);
})}
</div>
</div>
</div>
)}
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
<span>Total: {weeklyActivity().reduce((a, b) => a + b, 0)} contributions</span>
<span>Avg: {Math.round(weeklyActivity().reduce((a, b) => a + b, 0) / 7)} per day</span>
<span>Total: {weeklyTotal()} contributions</span>
<span>Avg: {Math.round(weeklyTotal() / 7)} per day</span>
</div>
</div>
</Card>
@@ -486,67 +432,75 @@ export const GitHub = () => {
{/* Recent Activity */}
<Card class="p-6">
<h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3>
<div class="space-y-3">
{githubStats().recentActivity.map((activity) => (
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
<div class="flex items-center gap-3">
<div class="bg-primary/10 p-2 rounded-lg">
<IconTrendingUp class="size-4 text-primary" />
</div>
<div>
<p class="text-sm text-foreground">{activity.message}</p>
<p class="text-xs text-muted-foreground">{activity.repo} {activity.date}</p>
{githubStats().recentActivity.length === 0 ? (
<p class="text-sm text-muted-foreground">No recent GitHub events yet.</p>
) : (
<div class="space-y-3">
{githubStats().recentActivity.map((activity) => (
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
<div class="flex items-center gap-3">
<div class="bg-primary/10 p-2 rounded-lg">
<IconTrendingUp class="size-4 text-primary" />
</div>
<div>
<p class="text-sm text-foreground">{activity.message}</p>
<p class="text-xs text-muted-foreground">{activity.repo} {activity.date}</p>
</div>
</div>
<span class="text-xs text-muted-foreground capitalize">{activity.type.replace('_', ' ')}</span>
</div>
<span class="text-xs text-muted-foreground capitalize">{activity.type.replace('_', ' ')}</span>
</div>
))}
</div>
))}
</div>
)}
</Card>
{/* Repositories */}
<Card class="p-6">
<h3 class="text-lg font-semibold text-foreground mb-4">Repositories</h3>
<div class="space-y-4">
{githubStats().repos.map((repo) => (
<div class="border border-border rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h4 class="text-lg font-medium text-foreground">{repo.name}</h4>
{repo.language && (
<span
class="text-xs px-2 py-1 rounded-full"
style={`background-color: ${getLanguageColor()}20; color: ${getLanguageColor()}`}
>
{repo.language}
</span>
)}
</div>
<p class="text-sm text-muted-foreground mb-3">{repo.description}</p>
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<div class="flex items-center gap-1">
<IconStar class="size-3" />
<span>{repo.stargazers_count}</span>
</div>
<div class="flex items-center gap-1">
<IconGitFork class="size-3" />
<span>{repo.forks_count}</span>
</div>
<div class="flex items-center gap-1">
<IconEye class="size-3" />
<span>{repo.watchers_count}</span>
</div>
<span>Updated {formatDate(repo.updated_at)}</span>
{githubStats().repos.length === 0 ? (
<p class="text-sm text-muted-foreground">No repositories available yet.</p>
) : (
<div class="space-y-4">
{githubStats().repos.map((repo) => (
<div class="border border-border rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h4 class="text-lg font-medium text-foreground">{repo.name}</h4>
{repo.language && (
<span
class="text-xs px-2 py-1 rounded-full"
style={`background-color: ${getLanguageColor()}20; color: ${getLanguageColor()}`}
>
{repo.language}
</span>
)}
</div>
<p class="text-sm text-muted-foreground mb-3">{repo.description}</p>
<div class="flex items-center gap-4 text-xs text-muted-foreground">
<div class="flex items-center gap-1">
<IconStar class="size-3" />
<span>{repo.stargazers_count}</span>
</div>
<div class="flex items-center gap-1">
<IconGitFork class="size-3" />
<span>{repo.forks_count}</span>
</div>
<div class="flex items-center gap-1">
<IconEye class="size-3" />
<span>{repo.watchers_count}</span>
</div>
<span>Updated {formatDate(repo.updated_at)}</span>
</div>
</div>
<Button variant="ghost" size="sm">
<IconExternalLink class="size-4" />
</Button>
</div>
<Button variant="ghost" size="sm">
<IconExternalLink class="size-4" />
</Button>
</div>
</div>
))}
</div>
))}
</div>
)}
</Card>
</div>
);
+4 -9
View File
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LearningPathPreviewModal } from '@/components/ui/LearningPathPreviewModal';
import { getMockLearningPaths } from '@/lib/mockData';
import { isDemoMode } from '@/lib/demo-mode';
import { getApiV1BaseUrl } from '@/lib/api-url';
import {
IconClock,
IconUsers,
@@ -25,6 +27,8 @@ import {
IconBook
} from '@tabler/icons-solidjs';
const API_BASE_URL = getApiV1BaseUrl();
interface LearningPath {
id: number;
title: string;
@@ -73,13 +77,6 @@ export const LearningPaths = () => {
const [isPreviewOpen, setIsPreviewOpen] = createSignal(false);
const [selectedPath, setSelectedPath] = createSignal<LearningPath | null>(null);
// 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');
};
const fetchData = async () => {
try {
if (isDemoMode()) {
@@ -118,7 +115,6 @@ export const LearningPaths = () => {
}
// Fetch categories
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
const categoriesResponse = await fetch(`${API_BASE_URL}/learning-paths/categories`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -214,7 +210,6 @@ export const LearningPaths = () => {
return;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
const response = await fetch(`${API_BASE_URL}/learning-paths/${pathId}/enroll`, {
method: 'POST',
headers: {
+60 -11
View File
@@ -1,16 +1,27 @@
import { createSignal, onMount } from 'solid-js';
import { useAuth, type LoginRequest, type RegisterRequest } from '@/lib/auth';
import { isEnvDemoMode } from '@/lib/demo-mode';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { useNavigate } from '@solidjs/router';
const API_BASE_URL = getApiV1BaseUrl();
interface LoginFormData {
email: string;
password: string;
username: string;
fullName: string;
}
export const Login = () => {
const { login, register } = useAuth();
const navigate = useNavigate();
const [isLogin, setIsLogin] = createSignal(true);
const [formData, setFormData] = createSignal<LoginRequest | RegisterRequest>({
const [isLogin, setIsLogin] = createSignal(false);
const [formData, setFormData] = createSignal<LoginFormData>({
email: '',
password: '',
...(isLogin() ? {} : { username: '', fullName: '' }),
username: '',
fullName: '',
});
const [error, setError] = createSignal('');
const [noAccountsExist, setNoAccountsExist] = createSignal(false);
@@ -24,13 +35,14 @@ export const Login = () => {
setFormData({
email: 'demo@trackeep.com',
password: 'demo123',
...(isLogin() ? {} : { username: 'demo', fullName: 'Demo User' }),
username: 'demo',
fullName: 'Demo User',
});
return;
}
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'}/auth/check-users`, {
const response = await fetch(`${API_BASE_URL}/auth/check-users`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -45,12 +57,24 @@ export const Login = () => {
setNoAccountsExist(false);
// Force to login mode
setIsLogin(true);
setFormData({
email: '',
password: '',
username: '',
fullName: '',
});
} else {
// No users exist - allow registration for first user (admin)
setRegistrationDisabled(false);
setNoAccountsExist(true);
// Force to registration mode
setIsLogin(false);
setFormData({
email: '',
password: '',
username: '',
fullName: '',
});
}
}
} catch (err) {
@@ -65,9 +89,19 @@ export const Login = () => {
try {
if (isLogin()) {
await login(formData() as LoginRequest);
const loginPayload: LoginRequest = {
email: formData().email,
password: formData().password,
};
await login(loginPayload);
} else {
await register(formData() as RegisterRequest);
const registerPayload: RegisterRequest = {
email: formData().email,
password: formData().password,
username: formData().username,
fullName: formData().fullName,
};
await register(registerPayload);
}
// Navigate to app after successful login/registration
navigate('/app');
@@ -88,13 +122,21 @@ export const Login = () => {
setError('Registration is disabled. Please contact your administrator to create an account.');
return;
}
// If there are no users, force registration only (no sign in yet)
if (noAccountsExist()) {
setIsLogin(false);
setError('No accounts exist yet. Create the first administrator account first.');
return;
}
setIsLogin(!isLogin());
setError('');
setFormData({
email: '',
password: '',
...(isLogin() ? { username: '', fullName: '' } : {}),
username: '',
fullName: '',
});
};
@@ -102,6 +144,13 @@ export const Login = () => {
<div class="min-h-screen bg-[#18181b] flex items-center justify-center px-4">
<div class="max-w-md w-full bg-[#141415] border border-[#262626] rounded-lg p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center p-3 rounded-xl border border-[#262626] bg-[#0f0f10] mb-4">
<img
src="/trackeep.svg"
alt="Trackeep Logo"
class="w-11 h-11 app-logo-mono"
/>
</div>
<h1 class="text-3xl font-bold text-[#fafafa] mb-2">Trackeep</h1>
<p class="text-[#a3a3a3]">
{isEnvDemoMode() ? 'Demo Mode' : (isLogin() ? 'Welcome back' : 'Create your account')}
@@ -189,7 +238,7 @@ export const Login = () => {
id="username"
type="text"
required
value={(formData() as RegisterRequest).username}
value={formData().username}
onInput={(e) => handleInputChange('username', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="username"
@@ -204,7 +253,7 @@ export const Login = () => {
id="fullName"
type="text"
required
value={(formData() as RegisterRequest).fullName}
value={formData().fullName}
onInput={(e) => handleInputChange('fullName', e.currentTarget.value)}
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
placeholder="Your Name"
@@ -239,7 +288,7 @@ export const Login = () => {
</form>
<div class="mt-6 text-center">
{!registrationDisabled() && (
{!registrationDisabled() && !noAccountsExist() && (
<p class="text-[#a3a3a3]">
{isLogin() ? "Don't have an account?" : 'Already have an account?'}
<button
+229 -129
View File
@@ -1,14 +1,17 @@
import { createSignal, onMount } from 'solid-js';
import { IconPlus, IconDotsVertical, IconEdit, IconTrash, IconShield, IconShieldCheck } from '@tabler/icons-solidjs';
import { createSignal, onCleanup, onMount } from 'solid-js';
import { IconPlus, IconDotsVertical, IconTrash } from '@tabler/icons-solidjs';
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { MemberModal } from '@/components/ui/MemberModal';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { getApiV1BaseUrl } from '@/lib/api-url';
const API_BASE_URL = getApiV1BaseUrl();
interface Member {
id: string;
name: string;
email: string;
role: 'Admin' | 'Member';
role: string;
avatar: string;
joinedAt: string;
}
@@ -16,43 +19,146 @@ interface Member {
export const Members = () => {
const [members, setMembers] = createSignal<Member[]>([]);
const [showAddModal, setShowAddModal] = createSignal(false);
const [showEditModal, setShowEditModal] = createSignal(false);
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
const [editingMember, setEditingMember] = createSignal<Member | null>(null);
const [deletingMember, setDeletingMember] = createSignal<Member | null>(null);
const [workspaceId, setWorkspaceId] = createSignal('');
const [isLoading, setIsLoading] = createSignal(true);
const handleAddMember = (memberData: Omit<Member, 'id' | 'avatar' | 'joinedAt'>) => {
const newMember: Member = {
...memberData,
id: Date.now().toString(),
avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase(),
joinedAt: 'Just now'
};
setMembers(prev => [...prev, newMember]);
setShowAddModal(false);
const getToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
const toRoleLabel = (role: string) => {
if (role === 'owner') return 'Owner';
if (role === 'admin') return 'Admin';
if (role === 'viewer') return 'Viewer';
return 'Member';
};
const handleEditMember = (memberData: Omit<Member, 'id' | 'avatar' | 'joinedAt'>) => {
if (!editingMember()) return;
setMembers(prev =>
prev.map(m =>
m.id === editingMember()!.id
? {
...m,
...memberData,
avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase()
}
: m
)
);
setShowEditModal(false);
setEditingMember(null);
const toInitials = (name: string) => {
return name
.split(' ')
.map((part) => part[0] || '')
.join('')
.slice(0, 2)
.toUpperCase();
};
const openEditModal = (member: Member) => {
setEditingMember(member);
setShowEditModal(true);
const resolveWorkspaceId = async (): Promise<string> => {
const storedWorkspaceId = localStorage.getItem('trackeep_workspace_id') || '';
if (storedWorkspaceId) {
return storedWorkspaceId;
}
const token = getToken();
if (!token) {
return '';
}
const teamsResponse = await fetch(`${API_BASE_URL}/teams`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!teamsResponse.ok) {
return '';
}
const teamsData = await teamsResponse.json();
const teams = Array.isArray(teamsData?.teams) ? teamsData.teams : [];
if (teams.length === 0) {
return '';
}
const firstTeamId = String(teams[0].id);
localStorage.setItem('trackeep_workspace_id', firstTeamId);
localStorage.setItem('trackeep_workspace_name', teams[0].name || 'Trackeep Workspace');
return firstTeamId;
};
const loadMembers = async () => {
setIsLoading(true);
try {
const token = getToken();
if (!token) {
setMembers([]);
setWorkspaceId('');
return;
}
const currentWorkspaceId = await resolveWorkspaceId();
setWorkspaceId(currentWorkspaceId);
if (!currentWorkspaceId) {
setMembers([]);
return;
}
const response = await fetch(`${API_BASE_URL}/teams/${currentWorkspaceId}/members`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch members: ${response.status}`);
}
const data = await response.json();
const membersPayload = Array.isArray(data?.members) ? data.members : [];
const mappedMembers: Member[] = membersPayload.map((member: any, index: number) => {
const user = member.user || {};
const name = user.full_name || user.username || user.email || `User ${index + 1}`;
const email = user.email || '';
return {
id: String(member.user_id || user.id || member.id || index + 1),
name,
email,
role: toRoleLabel(member.role || 'member'),
avatar: toInitials(name),
joinedAt: member.joined_at ? new Date(member.joined_at).toLocaleDateString() : '',
};
});
setMembers(mappedMembers);
} catch (error) {
console.error('Failed to load members:', error);
setMembers([]);
} finally {
setIsLoading(false);
}
};
const handleAddMember = async (memberData: { name: string; email: string; role: 'Admin' | 'Member' }) => {
const token = getToken();
const currentWorkspaceId = workspaceId();
if (!token || !currentWorkspaceId) {
return;
}
try {
const response = await fetch(`${API_BASE_URL}/teams/${currentWorkspaceId}/invite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
email: memberData.email,
role: memberData.role === 'Admin' ? 'admin' : 'member',
}),
});
if (!response.ok) {
throw new Error(`Failed to invite member: ${response.status}`);
}
setShowAddModal(false);
alert('Invitation sent successfully.');
} catch (error) {
console.error('Failed to invite member:', error);
alert('Failed to invite member.');
}
};
const openDeleteModal = (member: Member) => {
@@ -60,58 +166,56 @@ export const Members = () => {
setShowDeleteModal(true);
};
const handleDeleteMember = () => {
if (!deletingMember()) return;
setMembers(prev => prev.filter(m => m.id !== deletingMember()!.id));
setShowDeleteModal(false);
setDeletingMember(null);
};
const handleDeleteMember = async () => {
const member = deletingMember();
const token = getToken();
const currentWorkspaceId = workspaceId();
if (!member || !token || !currentWorkspaceId) {
return;
}
const handleToggleRole = (member: Member) => {
const newRole = member.role === 'Admin' ? 'Member' : 'Admin';
setMembers(prev =>
prev.map(m =>
m.id === member.id ? { ...m, role: newRole } : m
)
);
try {
const response = await fetch(`${API_BASE_URL}/teams/${currentWorkspaceId}/members/${member.id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to remove member: ${response.status}`);
}
setMembers((prev) => prev.filter((entry) => entry.id !== member.id));
setShowDeleteModal(false);
setDeletingMember(null);
} catch (error) {
console.error('Failed to remove member:', error);
alert('Failed to remove member.');
}
};
onMount(() => {
// Mock data
setMembers([
{
id: '1',
name: 'John Doe',
email: 'john@example.com',
role: 'Admin',
avatar: 'JD',
joinedAt: '2 weeks ago'
},
{
id: '2',
name: 'Jane Smith',
email: 'jane@example.com',
role: 'Member',
avatar: 'JS',
joinedAt: '1 month ago'
},
{
id: '3',
name: 'Bob Johnson',
email: 'bob@example.com',
role: 'Member',
avatar: 'BJ',
joinedAt: '3 months ago'
}
]);
void loadMembers();
const onWorkspaceChanged = () => {
void loadMembers();
};
window.addEventListener('trackeep:workspace-changed', onWorkspaceChanged);
onCleanup(() => window.removeEventListener('trackeep:workspace-changed', onWorkspaceChanged));
});
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-foreground">Members</h1>
<button type="button" class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4" onClick={() => setShowAddModal(true)}>
<button
type="button"
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
onClick={() => setShowAddModal(true)}
disabled={!workspaceId()}
>
<IconPlus class="size-4" />
Add Member
</button>
@@ -128,72 +232,68 @@ export const Members = () => {
</tr>
</thead>
<tbody class="[&_tr:last-child]:border-0">
{members().map((member) => (
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
<td class="p-2 align-middle">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
{member.avatar}
</div>
<div>
<div class="font-medium">{member.name}</div>
<div class="text-sm text-muted-foreground">{member.email}</div>
</div>
</div>
</td>
<td class="p-2 align-middle">
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{member.role}
</span>
</td>
<td class="p-2 align-middle text-muted-foreground">
{member.joinedAt}
</td>
<td class="p-2 align-middle">
<div class="flex items-center justify-end">
<DropdownMenu
trigger={
<button type="button" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9">
<IconDotsVertical class="size-4" />
</button>
}
>
<DropdownMenuItem onClick={() => openEditModal(member)} icon={IconEdit}>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleToggleRole(member)} icon={member.role === 'Admin' ? IconShieldCheck : IconShield}>
{member.role === 'Admin' ? 'Make Member' : 'Make Admin'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openDeleteModal(member)} icon={IconTrash} variant="destructive">
Remove
</DropdownMenuItem>
</DropdownMenu>
</div>
{isLoading() ? (
<tr class="border-b">
<td class="p-4 text-muted-foreground" colSpan={4}>
Loading members...
</td>
</tr>
))}
) : members().length === 0 ? (
<tr class="border-b">
<td class="p-4 text-muted-foreground" colSpan={4}>
No members yet.
</td>
</tr>
) : (
members().map((member) => (
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
<td class="p-2 align-middle">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
{member.avatar}
</div>
<div>
<div class="font-medium">{member.name}</div>
<div class="text-sm text-muted-foreground">{member.email}</div>
</div>
</div>
</td>
<td class="p-2 align-middle">
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{member.role}
</span>
</td>
<td class="p-2 align-middle text-muted-foreground">
{member.joinedAt || 'Unknown'}
</td>
<td class="p-2 align-middle">
<div class="flex items-center justify-end">
<DropdownMenu
trigger={
<button type="button" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9">
<IconDotsVertical class="size-4" />
</button>
}
>
<DropdownMenuItem onClick={() => openDeleteModal(member)} icon={IconTrash} variant="destructive">
Remove
</DropdownMenuItem>
</DropdownMenu>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Modals */}
<MemberModal
isOpen={showAddModal()}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddMember}
/>
<MemberModal
isOpen={showEditModal()}
onClose={() => {
setShowEditModal(false);
setEditingMember(null);
}}
onSubmit={handleEditMember}
member={editingMember()}
isEdit={true}
/>
<ConfirmModal
isOpen={showDeleteModal()}
onClose={() => {
+623
View File
@@ -0,0 +1,623 @@
.messages-shell {
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 70%, hsl(var(--muted) / 0.18) 100%);
}
.messages-shell-list .messages-main {
display: none;
}
.messages-shell-conversation .messages-sidebar {
display: none;
}
.messages-sidebar {
border-inline: 1px solid hsl(var(--border));
background: hsl(var(--card));
display: flex;
flex-direction: column;
min-height: 0;
width: min(100%, 980px);
margin: 0 auto;
}
.messages-sidebar-header {
padding: 0.9rem;
border-bottom: 1px solid hsl(var(--border));
display: grid;
gap: 0.6rem;
}
.messages-title-row,
.messages-sidebar-actions,
.messages-status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.messages-title-wrap {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.messages-title {
font-size: 1.02rem;
font-weight: 650;
}
.messages-status-row {
font-size: 0.7rem;
color: hsl(var(--muted-foreground));
letter-spacing: 0.02em;
}
.messages-sidebar-list {
flex: 1;
overflow-y: auto;
padding: 0.55rem;
display: grid;
gap: 0.35rem;
align-content: start;
}
.messages-list-empty {
border: 1px dashed hsl(var(--border));
border-radius: 0.72rem;
background: hsl(var(--muted) / 0.3);
padding: 1rem;
text-align: center;
font-size: 0.82rem;
color: hsl(var(--muted-foreground));
}
.conversation-item {
border: 1px solid transparent;
border-radius: 0.72rem;
padding: 0.58rem 0.65rem;
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
transition: background-color 120ms ease, border-color 120ms ease;
}
.conversation-item:hover {
background: hsl(var(--muted) / 0.6);
}
.conversation-item-active {
background: hsl(var(--primary) / 0.14);
border-color: hsl(var(--primary) / 0.45);
}
.conversation-item-main {
min-width: 0;
}
.conversation-item-name {
font-size: 0.84rem;
font-weight: 620;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-item-preview {
font-size: 0.72rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-item-unread {
min-width: 1.2rem;
height: 1.2rem;
border-radius: 999px;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.68rem;
font-weight: 650;
padding: 0 0.25rem;
}
.messages-main {
width: min(100%, 1180px);
margin: 0 auto;
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
border-inline: 1px solid hsl(var(--border));
background: hsl(var(--card));
}
.messages-main-header {
border-bottom: 1px solid hsl(var(--border));
padding: 0.85rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.messages-header-main {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.messages-back-button {
flex-shrink: 0;
}
.messages-header-meta {
min-width: 0;
}
.messages-header-title {
font-size: 1rem;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.messages-header-subtitle {
color: hsl(var(--muted-foreground));
font-size: 0.72rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.messages-header-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.messages-main-empty {
display: grid;
place-items: center;
color: hsl(var(--muted-foreground));
font-size: 0.85rem;
padding: 1.5rem;
}
.messages-call-strip,
.messages-transcript-preview {
padding: 0.55rem 1rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.messages-timeline {
padding: 1rem;
overflow-y: auto;
display: grid;
gap: 0.8rem;
}
.message-row {
display: flex;
}
.message-row-me {
justify-content: flex-end;
}
.message-row-them {
justify-content: flex-start;
}
.message-bubble {
max-width: min(76%, 900px);
border-radius: 0.95rem;
border: 1px solid hsl(var(--border));
padding: 0.68rem 0.74rem;
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.08);
}
.message-bubble-me {
background: hsl(var(--primary) / 0.17);
border-color: hsl(var(--primary) / 0.48);
}
.message-bubble-them {
background: hsl(var(--card));
}
.message-meta {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.72rem;
color: hsl(var(--muted-foreground));
margin-bottom: 0.35rem;
}
.message-avatar {
width: 1.4rem;
height: 1.4rem;
border-radius: 999px;
overflow: hidden;
background: hsl(var(--muted));
color: hsl(var(--foreground));
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
font-weight: 700;
}
.message-time {
margin-left: auto;
}
.message-edited {
font-size: 0.63rem;
}
.message-body {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.32rem;
font-size: 0.9rem;
}
.message-sensitive-banner {
margin-top: 0.55rem;
border-radius: 0.52rem;
padding: 0.42rem 0.5rem;
border: 1px solid hsl(var(--warning) / 0.4);
background: hsl(var(--warning) / 0.12);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.73rem;
}
.message-attachments {
margin-top: 0.6rem;
display: grid;
gap: 0.4rem;
}
.message-attachment-link,
.message-voice-note {
border: 1px solid hsl(var(--border));
border-radius: 0.55rem;
padding: 0.45rem 0.55rem;
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.75rem;
text-decoration: none;
}
.message-attachment-link:hover {
background: hsl(var(--muted) / 0.55);
}
.message-voice-note {
flex-direction: column;
align-items: flex-start;
}
.message-reference-wrap {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.message-reference-pill {
border-radius: 999px;
border: 1px solid hsl(var(--border));
padding: 0.14rem 0.45rem;
font-size: 0.68rem;
color: hsl(var(--muted-foreground));
text-decoration: none;
}
.message-suggestions {
margin-top: 0.6rem;
display: grid;
gap: 0.45rem;
}
.message-suggestion-card {
border: 1px solid hsl(var(--border));
border-radius: 0.55rem;
padding: 0.45rem;
background: hsl(var(--muted) / 0.35);
}
.message-suggestion-title {
font-size: 0.72rem;
text-transform: capitalize;
margin-bottom: 0.35rem;
}
.message-suggestion-actions {
display: flex;
gap: 0.4rem;
}
.message-reaction-panel {
margin-top: 0.62rem;
display: grid;
gap: 0.35rem;
}
.message-reaction-add-row {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.reaction-add-btn {
width: 1.6rem;
height: 1.6rem;
border-radius: 0.4rem;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
}
.reaction-add-btn:hover {
border-color: hsl(var(--border));
background: hsl(var(--muted) / 0.55);
color: hsl(var(--foreground));
}
.message-reaction-summary {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.reaction-pill {
border-radius: 999px;
border: 1px solid hsl(var(--border));
padding: 0.15rem 0.45rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.68rem;
background: hsl(var(--card));
}
.reaction-pill-me {
border-color: hsl(var(--primary) / 0.55);
background: hsl(var(--primary) / 0.16);
}
.messages-composer {
position: relative;
border-top: 1px solid hsl(var(--border));
padding: 0.72rem 1rem 0.9rem;
display: grid;
gap: 0.5rem;
background: hsl(var(--card));
}
.messages-composer-drag {
background: hsl(var(--primary) / 0.08);
}
.messages-typing-line {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.73rem;
color: hsl(var(--muted-foreground));
}
.typing-dots {
display: inline-flex;
align-items: center;
gap: 0.22rem;
}
.typing-dots span {
width: 0.28rem;
height: 0.28rem;
border-radius: 999px;
background: hsl(var(--primary));
animation: typingBounce 1.1s infinite ease-in-out;
}
.typing-dots span:nth-child(2) {
animation-delay: 0.14s;
}
.typing-dots span:nth-child(3) {
animation-delay: 0.28s;
}
@keyframes typingBounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.4;
}
40% {
transform: translateY(-2px);
opacity: 1;
}
}
.composer-chip-wrap {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.composer-chip {
border-radius: 999px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.45);
padding: 0.18rem 0.36rem;
display: inline-flex;
align-items: center;
gap: 0.28rem;
font-size: 0.68rem;
}
.composer-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
}
.messages-recording-line {
color: hsl(var(--destructive));
font-size: 0.73rem;
font-weight: 600;
}
.messages-composer-row {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
gap: 0.45rem;
align-items: end;
}
.messages-composer-input-wrap {
position: relative;
}
.messages-composer-textarea {
min-height: 2.6rem;
max-height: 9rem;
width: 100%;
resize: none;
border: 1px solid hsl(var(--border));
border-radius: 0.62rem;
background: hsl(var(--background));
padding: 0.58rem 0.64rem;
font-size: 0.88rem;
line-height: 1.25rem;
}
.messages-composer-textarea:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15);
}
.mention-menu {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + 0.4rem);
border: 1px solid hsl(var(--border));
border-radius: 0.65rem;
background: hsl(var(--card));
box-shadow: 0 8px 26px hsl(0 0% 0% / 0.24);
max-height: 16rem;
overflow-y: auto;
z-index: 20;
}
.mention-menu-empty {
padding: 0.55rem 0.65rem;
color: hsl(var(--muted-foreground));
font-size: 0.74rem;
}
.mention-option {
width: 100%;
text-align: left;
border: none;
background: transparent;
padding: 0.52rem 0.62rem;
display: flex;
align-items: center;
gap: 0.45rem;
}
.mention-option-active,
.mention-option:hover {
background: hsl(var(--muted) / 0.65);
}
.mention-option-copy {
min-width: 0;
}
.mention-option-title {
font-size: 0.79rem;
font-weight: 620;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mention-option-sub {
font-size: 0.68rem;
color: hsl(var(--muted-foreground));
}
.messages-composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.messages-inline-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
color: hsl(var(--muted-foreground));
}
@media (max-width: 980px) {
.messages-sidebar,
.messages-main {
width: 100%;
border-inline: none;
}
.message-bubble {
max-width: 86%;
}
}
@media (max-width: 760px) {
.messages-main {
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
}
.messages-composer-row {
grid-template-columns: auto auto 1fr;
}
.messages-composer-row > button:last-child {
grid-column: 3;
justify-self: end;
}
}
File diff suppressed because it is too large Load Diff
+47 -89
View File
@@ -5,7 +5,9 @@ import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { NoteModal } from '@/components/ui/NoteModal';
import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
import { IconPin, IconTrash, IconEdit, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
import { getMockNotes } from '@/lib/mockData';
import { getApiV1BaseUrl } from '@/lib/api-url';
const API_BASE_URL = getApiV1BaseUrl();
interface Note {
id: number;
@@ -26,43 +28,6 @@ interface Note {
isHtml?: boolean;
}
const normalizeMockDate = (dateStr: string): string => {
const directDate = new Date(dateStr);
if (!isNaN(directDate.getTime())) {
return directDate.toISOString();
}
const match = dateStr.match(/(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago/i);
if (!match) {
return new Date().toISOString();
}
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
const date = new Date();
switch (unit) {
case 'day':
case 'days':
date.setDate(date.getDate() - value);
break;
case 'week':
case 'weeks':
date.setDate(date.getDate() - value * 7);
break;
case 'month':
case 'months':
date.setMonth(date.getMonth() - value);
break;
case 'year':
case 'years':
date.setFullYear(date.getFullYear() - value);
break;
}
return date.toISOString();
};
const renderMarkdownPreviewHtml = (content: string, maxBlocks = 4): string => {
const html = content
.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-1">$1<\/h1>')
@@ -112,64 +77,57 @@ export const Notes = () => {
const [copiedContent, setCopiedContent] = createSignal(false);
const [expandedNotes, setExpandedNotes] = createSignal<Set<number>>(new Set());
// 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');
};
onMount(async () => {
try {
if (isDemoMode()) {
// Use mock data in demo mode
const mockNotes = getMockNotes();
const adaptedNotes = mockNotes.map((note, index) => ({
id: index + 1,
title: note.title,
content: note.content,
createdAt: normalizeMockDate(note.createdAt),
updatedAt: normalizeMockDate(note.updatedAt),
tags: note.tags.map(tag => tag.name),
pinned: note.tags.some(tag => tag.name === 'important' || tag.name === 'pinned'),
attachments: note.attachments?.map((att, index) => ({
id: `att_${index}`,
name: att.name,
type: att.type,
size: att.size,
url: `/attachments/${att.name}`
})) || [],
isMarkdown: note.content.includes('#') || note.content.includes('*'),
isHtml: note.content.includes('<') && note.content.includes('>')
}));
setNotes(adaptedNotes);
setIsLoading(false);
return;
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes`, {
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!response.ok) {
throw new Error('Failed to load notes');
}
// Load mock notes data
const mockNotes = getMockNotes();
const adaptedNotes = mockNotes.map((note, index) => ({
id: index + 1,
title: note.title,
content: note.content,
createdAt: normalizeMockDate(note.createdAt),
updatedAt: normalizeMockDate(note.updatedAt),
tags: note.tags.map(tag => tag.name),
pinned: note.tags.some(tag => tag.name === 'important' || tag.name === 'pinned'),
attachments: note.attachments?.map((att, index) => ({
id: `att_${index}`,
name: att.name,
type: att.type,
size: att.size,
url: `/attachments/${att.name}`
})) || [],
isMarkdown: note.content.includes('#') || note.content.includes('*'),
isHtml: note.content.includes('<') && note.content.includes('>')
}));
const notesData = await response.json();
const adaptedNotes: Note[] = (Array.isArray(notesData) ? notesData : []).map((note: any, index) => {
const tags = Array.isArray(note.tags)
? note.tags
.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name))
.filter(Boolean)
: [];
const content = note.content || '';
const createdAt = note.created_at || note.createdAt || new Date().toISOString();
const updatedAt = note.updated_at || note.updatedAt || createdAt;
return {
id: Number(note.id || index + 1),
title: note.title || 'Untitled note',
content,
createdAt,
updatedAt,
tags,
pinned: Boolean(note.pinned ?? note.is_pinned),
attachments: Array.isArray(note.attachments)
? note.attachments.map((att: any, attachmentIndex: number) => ({
id: String(att.id || `att_${attachmentIndex}`),
name: att.name || 'attachment',
type: att.type || 'file',
size: att.size || '',
url: att.url,
}))
: [],
isMarkdown: content.includes('#') || content.includes('*'),
isHtml: content.includes('<') && content.includes('>'),
};
});
setNotes(adaptedNotes);
} catch (error) {
console.error('Failed to load notes:', error);
setNotes([]);
} finally {
setIsLoading(false);
}
+13 -54
View File
@@ -1,4 +1,4 @@
import { createSignal, onMount, Show } from 'solid-js';
import { createEffect, createSignal, onMount, Show } from 'solid-js';
import { IconTrash, IconRestore, IconFileText, IconFileTypePpt, IconFileTypeDocx, IconClock, IconSettings, IconAlertTriangle } from '@tabler/icons-solidjs';
interface RemovedItem {
@@ -28,6 +28,10 @@ export const RemovedStuff = () => {
const [showSettings, setShowSettings] = createSignal(false);
const [selectedItems, setSelectedItems] = createSignal<string[]>([]);
createEffect(() => {
localStorage.setItem('removedItems', JSON.stringify(removedItems()));
});
onMount(() => {
// Load auto-remove settings from localStorage
const savedSettings = localStorage.getItem('autoRemoveSettings');
@@ -35,60 +39,15 @@ export const RemovedStuff = () => {
setAutoRemoveSettings(JSON.parse(savedSettings));
}
// Enhanced mock data with more realistic items
const mockItems: RemovedItem[] = [
{
id: '1',
name: 'Old Document',
type: 'docx',
removedAt: '2 days ago',
removedBy: 'John Doe',
size: '2.5 MB',
path: '/documents/old-document.docx',
daysInTrash: 2
},
{
id: '2',
name: 'Deleted Presentation',
type: 'pptx',
removedAt: '1 week ago',
removedBy: 'Jane Smith',
size: '15.3 MB',
path: '/presentations/deleted-presentation.pptx',
daysInTrash: 7
},
{
id: '3',
name: 'Removed Note',
type: 'note',
removedAt: '2 weeks ago',
removedBy: 'Admin',
size: '156 KB',
path: '/notes/removed-note.md',
daysInTrash: 14
},
{
id: '4',
name: 'Old Backup File',
type: 'zip',
removedAt: '3 weeks ago',
removedBy: 'System',
size: '125.7 MB',
path: '/backups/old-backup.zip',
daysInTrash: 21
},
{
id: '5',
name: 'Temporary Files',
type: 'folder',
removedAt: '1 month ago',
removedBy: 'John Doe',
size: '8.2 MB',
path: '/temp/temporary-files',
daysInTrash: 30
const savedItems = localStorage.getItem('removedItems');
if (savedItems) {
try {
const parsedItems = JSON.parse(savedItems);
setRemovedItems(Array.isArray(parsedItems) ? parsedItems : []);
} catch {
setRemovedItems([]);
}
];
setRemovedItems(mockItems);
}
// Check for auto-remove on mount
checkAutoRemove();
+172 -172
View File
@@ -18,9 +18,10 @@ import {
IconClock
} from '@tabler/icons-solidjs';
import { ActivityFeed } from '@/components/ui/ActivityFeed';
import { getMockStats, getMockActivities } from '@/lib/mockData';
import { formatDuration } from '@/lib/timeFormat';
import { isDemoMode } from '@/lib/demo-mode';
import { getApiV1BaseUrl } from '@/lib/api-url';
const API_BASE_URL = getApiV1BaseUrl();
interface ActivityData {
date: string;
@@ -92,74 +93,100 @@ export const Stats = () => {
const handleRefresh = () => {
setRefreshKey(prev => prev + 1);
void loadStats();
};
const loadStats = async () => {
try {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
};
const [statsRes, filesRes, tasksRes] = await Promise.allSettled([
fetch(`${API_BASE_URL}/dashboard/stats`, { headers }),
fetch(`${API_BASE_URL}/files`, { headers }),
fetch(`${API_BASE_URL}/tasks`, { headers })
]);
const statsData = statsRes.status === 'fulfilled' && statsRes.value.ok ? await statsRes.value.json() : null;
const filesData: Array<any> = filesRes.status === 'fulfilled' && filesRes.value.ok ? await filesRes.value.json() : [];
const tasksData: Array<any> = tasksRes.status === 'fulfilled' && tasksRes.value.ok ? await tasksRes.value.json() : [];
const completedTasks = tasksData.filter((task) => task.status === 'completed').length;
const activeTasks = tasksData.filter((task) => task.status !== 'completed').length;
const totalSizeBytes = filesData.reduce((acc: number, file: any) => acc + Number(file.file_size || 0), 0);
const storageUsedMb = totalSizeBytes / (1024 * 1024);
const storageTotalMb = 50 * 1024;
setStats({
totalBookmarks: Number(statsData?.totalBookmarks || 0),
totalDocuments: filesData.length,
totalTasks: Number(statsData?.totalTasks || tasksData.length || 0),
totalNotes: Number(statsData?.totalNotes || 0),
completedTasks,
activeTasks,
storageUsed: `${storageUsedMb.toFixed(2)} MB`,
storageTotal: `${storageTotalMb} MB`,
weeklyActivity: [0, 0, 0, 0, 0, 0, 0],
monthlyGrowth: {
bookmarks: 0,
documents: 0,
tasks: 0,
notes: 0
},
topCategories: [],
recentActivity: [
{ type: 'Bookmarks', count: Number(statsData?.totalBookmarks || 0), change: 0 },
{ type: 'Documents', count: filesData.length, change: 0 },
{ type: 'Tasks', count: Number(statsData?.totalTasks || tasksData.length || 0), change: 0 },
{ type: 'Notes', count: Number(statsData?.totalNotes || 0), change: 0 }
],
contributionGraph: []
});
} catch (error) {
console.error('Failed to load stats data:', error);
setStats({
totalBookmarks: 0,
totalDocuments: 0,
totalTasks: 0,
totalNotes: 0,
completedTasks: 0,
activeTasks: 0,
storageUsed: '0 MB',
storageTotal: '51200 MB',
weeklyActivity: [0, 0, 0, 0, 0, 0, 0],
monthlyGrowth: {
bookmarks: 0,
documents: 0,
tasks: 0,
notes: 0
},
topCategories: [],
recentActivity: [],
contributionGraph: []
});
}
};
onMount(() => {
// Use mock data from our mockData file
const mockStats = getMockStats();
const mockActivities = getMockActivities();
// Generate mock contribution graph data
const generateContributionGraph = () => {
const graph: ActivityData[] = [];
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
for (let d = new Date(oneYearAgo); d <= today; d.setDate(d.getDate() + 1)) {
const count = Math.floor(Math.random() * 10);
const level = count === 0 ? 0 : Math.ceil(count / 2);
graph.push({
date: new Date(d).toISOString().split('T')[0],
count,
level
});
}
return graph;
};
// Create test data with varied values to verify height calculations
const testWeeklyActivity = [8, 22, 15, 31, 18, 25, 12]; // Fixed test values
// Use demo mode data if available, otherwise use test data
const weeklyActivityData = isDemoMode() ? mockStats.weeklyActivity : testWeeklyActivity;
// Set stats using mock data
setStats({
totalBookmarks: mockStats.totalBookmarks,
totalDocuments: mockStats.totalDocuments,
totalTasks: mockStats.totalTasks,
totalNotes: mockStats.totalNotes,
completedTasks: mockStats.completedTasks,
activeTasks: mockStats.activeTasks,
storageUsed: mockStats.totalSize,
storageTotal: '50 GB',
weeklyActivity: weeklyActivityData, // Use demo mode or test data
monthlyGrowth: mockStats.monthlyGrowth,
topCategories: [
{ name: 'Work', count: 45, color: 'hsl(var(--primary))' },
{ name: 'Personal', count: 32, color: 'hsl(var(--primary))' },
{ name: 'Learning', count: 28, color: 'hsl(var(--primary))' }
],
recentActivity: [
{ type: 'Bookmarks', count: mockActivities.filter(a => a.type === 'bookmark').length, change: 8 },
{ type: 'Documents', count: mockActivities.filter(a => a.type === 'document').length, change: -2 },
{ type: 'Tasks', count: mockActivities.filter(a => a.type === 'task').length, change: 3 },
{ type: 'Notes', count: mockActivities.filter(a => a.type === 'note').length, change: 12 }
],
contributionGraph: generateContributionGraph()
});
void loadStats();
});
const storagePercentage = () => {
const used = parseFloat(stats().storageUsed);
const total = parseFloat(stats().storageTotal);
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) {
return 0;
}
return Math.round((used / total) * 100);
};
const taskCompletionRate = () => {
if (stats().totalTasks <= 0) {
return 0;
}
return Math.round((stats().completedTasks / stats().totalTasks) * 100);
};
return (
@@ -169,15 +196,6 @@ export const Stats = () => {
<h1 class="text-2xl font-bold">Statistics & Activity</h1>
<p class="text-muted-foreground mt-2">Track your productivity, growth, and activity over time</p>
</div>
{/* Demo Mode Indicator */}
<Show when={isDemoMode()}>
<div class="bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-3">
<p class="text-yellow-800 dark:text-yellow-200 text-sm font-medium">
Demo Mode Active - Showing sample data
</p>
</div>
</Show>
</div>
<div class="flex justify-between items-start">
@@ -289,7 +307,7 @@ export const Stats = () => {
<div class="flex items-center gap-2">
<IconUsers class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">12</p>
<p class="text-lg font-semibold text-foreground">0</p>
<p class="text-xs text-muted-foreground">Collaborators</p>
</div>
</div>
@@ -299,7 +317,7 @@ export const Stats = () => {
<div class="flex items-center gap-2">
<IconChartLine class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">{stats().averageProductivity || 78}%</p>
<p class="text-lg font-semibold text-foreground">{stats().averageProductivity ?? 0}%</p>
<p class="text-xs text-muted-foreground">Productivity</p>
</div>
</div>
@@ -309,7 +327,7 @@ export const Stats = () => {
<div class="flex items-center gap-2">
<IconCalendar class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">156</p>
<p class="text-lg font-semibold text-foreground">0</p>
<p class="text-xs text-muted-foreground">Days Active</p>
</div>
</div>
@@ -319,7 +337,7 @@ export const Stats = () => {
<div class="flex items-center gap-2">
<IconSettings class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">{stats().recentProjects?.length || 4}</p>
<p class="text-lg font-semibold text-foreground">{stats().recentProjects?.length || 0}</p>
<p class="text-xs text-muted-foreground">Projects</p>
</div>
</div>
@@ -329,7 +347,7 @@ export const Stats = () => {
<div class="flex items-center gap-2">
<IconFolder class="size-4 text-primary" />
<div>
<p class="text-lg font-semibold text-foreground">{stats().storageUsed || 12.94} GB</p>
<p class="text-lg font-semibold text-foreground">{stats().storageUsed}</p>
<p class="text-xs text-muted-foreground">Storage Used</p>
</div>
</div>
@@ -424,44 +442,49 @@ export const Stats = () => {
<h3 class="text-lg font-semibold">Weekly Activity</h3>
</div>
<div class="space-y-4">
<div class="relative h-32 sm:h-36 md:h-40 lg:h-44 px-4 sm: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-1 sm:gap-2">
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
const weeklyActivity = stats().weeklyActivity;
const activity = weeklyActivity[index];
const maxActivity = Math.max(...weeklyActivity);
// Dynamic scale: use the highest value as the scale, with minimum of 25 for better visualization
const scaleMax = Math.max(maxActivity, 25);
// Calculate height percentage (use 85% of available height to leave room for labels)
const heightPercent = (activity / scaleMax) * 85;
// Ensure minimum height for visibility
const finalHeightPercent = Math.max(heightPercent, 5);
<Show
when={stats().weeklyActivity.reduce((a, b) => a + b, 0) > 0}
fallback={
<div class="h-32 sm:h-36 md:h-40 lg:h-44 border border-dashed border-border rounded-lg flex items-center justify-center">
<p class="text-sm text-muted-foreground">No weekly activity yet.</p>
</div>
}
>
<div class="relative h-32 sm:h-36 md:h-40 lg:h-44 px-4 sm: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-1 sm:gap-2">
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
const weeklyActivity = stats().weeklyActivity;
const activity = weeklyActivity[index];
const maxActivity = Math.max(...weeklyActivity, 1);
const heightPercent = (activity / maxActivity) * 85;
const finalHeightPercent = activity > 0 ? Math.max(heightPercent, 6) : 0;
return (
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 h-full">
<div class="relative w-full max-w-2 sm:max-w-3 md:max-w-4 flex flex-col items-center justify-end h-full">
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10 bg-background px-1 rounded shadow-sm">
{activity}
</span>
<div
class="w-full max-w-2 sm: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}%; min-height: 4px;`}
title={`${day}: ${activity} activities (${finalHeightPercent.toFixed(1)}%)`}
></div>
return (
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 h-full">
<div class="relative w-full max-w-2 sm:max-w-3 md:max-w-4 flex flex-col items-center justify-end h-full">
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10 bg-background px-1 rounded shadow-sm">
{activity}
</span>
<div
class="w-full max-w-2 sm: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}%;`}
title={`${day}: ${activity} activities`}
></div>
</div>
<span class="text-xs text-muted-foreground font-medium mt-1 hidden sm:block">{day}</span>
<span class="text-xs text-muted-foreground font-medium mt-1 sm:hidden">{day.charAt(0)}</span>
</div>
<span class="text-xs text-muted-foreground font-medium mt-1 hidden sm:block">{day}</span>
<span class="text-xs text-muted-foreground font-medium mt-1 sm:hidden">{day.charAt(0)}</span>
</div>
);
})}
);
})}
</div>
</div>
</div>
</Show>
<div class="flex flex-col sm:flex-row sm:justify-between text-xs text-muted-foreground pt-2 border-t border-border gap-1 sm:gap-0">
<span>Total: {stats().weeklyActivity.reduce((a, b) => a + b, 0)} activities</span>
@@ -476,28 +499,33 @@ export const Stats = () => {
<IconUsers class="size-5 text-primary" />
<h3 class="text-lg font-semibold">Top Categories</h3>
</div>
<div class="space-y-3">
{stats().topCategories.map((category) => (
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full"
style={`background-color: ${category.color}`}
></div>
<span class="text-sm">{category.name}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 bg-muted rounded-full h-2">
<Show
when={stats().topCategories.length > 0}
fallback={<p class="text-sm text-muted-foreground">No category activity yet.</p>}
>
<div class="space-y-3">
{stats().topCategories.map((category) => (
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="bg-primary h-2 rounded-full transition-all duration-500"
style={`width: ${(category.count / Math.max(...stats().topCategories.map(c => c.count))) * 100}%`}
class="w-3 h-3 rounded-full"
style={`background-color: ${category.color}`}
></div>
<span class="text-sm">{category.name}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 bg-muted rounded-full h-2">
<div
class="bg-primary h-2 rounded-full transition-all duration-500"
style={`width: ${(category.count / Math.max(...stats().topCategories.map(c => c.count))) * 100}%`}
></div>
</div>
<span class="text-sm text-muted-foreground w-8 text-right">{category.count}</span>
</div>
<span class="text-sm text-muted-foreground w-8 text-right">{category.count}</span>
</div>
</div>
))}
</div>
))}
</div>
</Show>
</div>
{/* Activity Section - Responsive Layout */}
@@ -534,60 +562,32 @@ export const Stats = () => {
{/* Activity Breakdown */}
<div class="border rounded-lg p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-4">Activity Breakdown</h3>
<div class="space-y-3">
{stats().recentActivity.map((activity) => (
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">{activity.type}</span>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{activity.count}</span>
<Show when={activity.change !== 0}>
<span class={`text-xs text-muted-foreground`}>
{activity.change > 0 ? '+' : ''}{activity.change}
</span>
</Show>
<Show
when={stats().recentActivity.length > 0}
fallback={<p class="text-sm text-muted-foreground">No activity breakdown yet.</p>}
>
<div class="space-y-3">
{stats().recentActivity.map((activity) => (
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">{activity.type}</span>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{activity.count}</span>
<Show when={activity.change !== 0}>
<span class={`text-xs text-muted-foreground`}>
{activity.change > 0 ? '+' : ''}{activity.change}
</span>
</Show>
</div>
</div>
</div>
))}
<div class="border-t pt-3 mt-3">
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Commits</span>
<span class="text-sm font-medium">89</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Pull Requests</span>
<span class="text-sm font-medium">12</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Stars</span>
<span class="text-sm font-medium">45</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Forks</span>
<span class="text-sm font-medium">12</span>
</div>
))}
</div>
</div>
</Show>
</div>
{/* Active Repositories */}
<div class="border rounded-lg p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-4">Active Repositories</h3>
<div class="space-y-3">
{[
{ name: 'trackeep', language: 'TypeScript', activity: '2h ago' },
{ name: 'solid-components', language: 'TypeScript', activity: '5h ago' },
{ name: 'go-api', language: 'Go', activity: '1d ago' },
{ name: 'ml-models', language: 'Python', activity: '2d ago' }
].map((repo) => (
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p class="text-sm font-medium">{repo.name}</p>
<p class="text-xs text-muted-foreground">{repo.language}</p>
</div>
<span class="text-xs text-muted-foreground">{repo.activity}</span>
</div>
))}
</div>
<p class="text-sm text-muted-foreground">No repository activity yet.</p>
</div>
{/* Activity Settings */}
+39 -44
View File
@@ -1,10 +1,12 @@
import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { TaskModal } from '@/components/ui/TaskModal';
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
import { getMockTasks } from '@/lib/mockData';
import { getApiV1BaseUrl } from '@/lib/api-url';
const API_BASE_URL = getApiV1BaseUrl();
interface Task {
id: number;
@@ -24,10 +26,10 @@ export const Tasks = () => {
const [editingTask, setEditingTask] = createSignal<Task | null>(null);
const [filter, setFilter] = createSignal<'all' | 'active' | 'completed'>('all');
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedPriority, setSelectedPriority] = createSignal('');
onMount(async () => {
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/tasks`, {
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
@@ -40,18 +42,7 @@ export const Tasks = () => {
setTasks(data);
} catch (error) {
console.error('Failed to load tasks:', error);
// Fallback to mock data if API fails
const mockTasks = getMockTasks();
const adaptedTasks = mockTasks.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
}));
setTasks(adaptedTasks);
setTasks([]);
} finally {
setIsLoading(false);
}
@@ -63,13 +54,15 @@ export const Tasks = () => {
const matchesSearch = !term ||
task.title.toLowerCase().includes(term) ||
(task.description && task.description.toLowerCase().includes(term));
const matchesPriority = !selectedPriority() || task.priority === selectedPriority();
const matchesFilter =
(filter() === 'active' && !task.completed) ||
(filter() === 'completed' && task.completed) ||
filter() === 'all';
return matchesSearch && matchesFilter;
return matchesSearch && matchesFilter && matchesPriority;
});
return filtered.sort((a, b) => {
@@ -81,7 +74,6 @@ export const Tasks = () => {
const handleAddTask = async (task: Omit<Task, 'id'>) => {
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/tasks`, {
method: 'POST',
headers: {
@@ -108,7 +100,6 @@ export const Tasks = () => {
if (!editingTask()) return;
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/tasks/${editingTask()!.id}`, {
method: 'PUT',
headers: {
@@ -150,7 +141,6 @@ export const Tasks = () => {
const deleteTask = async (taskId: number) => {
if (confirm('Are you sure you want to delete this task?')) {
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
method: 'DELETE',
headers: {
@@ -191,6 +181,9 @@ export const Tasks = () => {
return { total, completed, active };
};
const hasSearchOrPriorityFilters = () =>
Boolean(searchTerm().trim()) || Boolean(selectedPriority());
return (
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
@@ -232,30 +225,30 @@ export const Tasks = () => {
</Card>
</div>
<div class="flex flex-col sm:flex-row gap-4 mb-6">
<div class="flex-1">
<Input
type="text"
placeholder="Search tasks..."
value={searchTerm()}
onInput={(e) => {
const target = e.currentTarget as HTMLInputElement;
if (target) setSearchTerm(target.value);
}}
class="w-full"
/>
</div>
<div class="flex space-x-2">
{(['all', 'active', 'completed'] as const).map((filterOption) => (
<Button
variant={filter() === filterOption ? 'default' : 'outline'}
onClick={() => setFilter(filterOption)}
class="capitalize"
>
{filterOption}
</Button>
))}
</div>
<SearchTagFilterBar
searchPlaceholder="Search tasks..."
searchValue={searchTerm()}
onSearchChange={(value) => setSearchTerm(value)}
tagOptions={['high', 'medium', 'low']}
selectedTag={selectedPriority()}
onTagChange={(value) => setSelectedPriority(value)}
onReset={() => {
setSearchTerm('');
setSelectedPriority('');
}}
allOptionLabel="All Priorities"
/>
<div class="flex flex-wrap gap-2 -mt-3 mb-6">
{(['all', 'active', 'completed'] as const).map((filterOption) => (
<Button
variant={filter() === filterOption ? 'default' : 'outline'}
onClick={() => setFilter(filterOption)}
class="capitalize"
>
{filterOption}
</Button>
))}
</div>
{isLoading() ? (
@@ -337,7 +330,9 @@ export const Tasks = () => {
{filteredTasks().length === 0 && (
<Card class="p-12 text-center">
<p class="text-[#a3a3a3]">
{filter() === 'completed' ? 'No completed tasks yet.' :
{hasSearchOrPriorityFilters()
? 'No tasks found matching your search or filters.'
: filter() === 'completed' ? 'No completed tasks yet.' :
filter() === 'active' ? 'No active tasks. Great job!' :
'No tasks yet. Add your first task!'}
</p>
+54 -59
View File
@@ -3,8 +3,10 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { VideoPreviewModal } from '@/components/ui/VideoPreviewModal';
import { ModalPortal } from '@/components/ui/ModalPortal';
import { getMockVideos } from '@/lib/mockData';
import { getAuthHeaders } from '@/lib/auth';
import { isDemoMode } from '@/lib/demo-mode';
import {
IconAlertCircle
} from '@tabler/icons-solidjs';
@@ -158,20 +160,6 @@ export const Youtube = () => {
);
};
// Check if we're in demo mode (for display purposes only)
const isDemoMode = () => {
const demoMode = localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
console.log('YouTube page - Demo mode check:', {
localStorage: localStorage.getItem('demoMode'),
title: document.title,
search: window.location.search,
result: demoMode
});
return demoMode;
};
// Extract video ID from YouTube URL
const extractVideoId = (url: string): string | null => {
const regex = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/;
@@ -343,38 +331,43 @@ export const Youtube = () => {
console.warn('Backend API failed for featured channels:', backendError);
}
// Final fallback to demo mode
console.log('All API methods failed, using demo mode for featured channels');
const mockVideos = getMockVideos();
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).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'
}));
setPredefinedVideos(videos);
if (isDemoMode()) {
console.log('All API methods failed, using demo data for featured channels');
const mockVideos = getMockVideos();
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).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'
}));
setPredefinedVideos(videos);
} else {
setPredefinedVideos([]);
setPredefinedError('No predefined videos available yet.');
}
} catch (err) {
console.error('Error in loadPredefinedVideos:', err);
setPredefinedError(err instanceof Error ? err.message : 'Failed to load predefined channel videos');
// Fallback to demo mode
const mockVideos = getMockVideos();
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).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'
}));
setPredefinedVideos(videos);
if (isDemoMode()) {
const mockVideos = getMockVideos();
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).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'
}));
setPredefinedVideos(videos);
} else {
setPredefinedVideos([]);
setPredefinedError(err instanceof Error ? err.message : 'Failed to load predefined channel videos');
}
} finally {
setIsLoadingPredefined(false);
}
@@ -993,20 +986,21 @@ export const Youtube = () => {
{/* Channel Editor Modal */}
<Show when={showChannelEditor()}>
<div
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);
setEditingChannel(null);
setNewChannelName('');
setNewChannelId('');
setNewChannelDescription('');
}
}}
>
<div class="bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<div class="p-6">
<ModalPortal>
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowChannelEditor(false);
setEditingChannel(null);
setNewChannelName('');
setNewChannelId('');
setNewChannelDescription('');
}
}}
>
<div class="bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold">Manage Featured Channels</h2>
<Button
@@ -1175,9 +1169,10 @@ export const Youtube = () => {
)}
</div>
</div>
</div>
</div>
</div>
</div>
</ModalPortal>
</Show>
</div>
</div>