feat(ui): enhance frontend architecture and improve user experience
CI/CD Pipeline / Test (push) Successful in 21m56s
CI/CD Pipeline / Security Scan (push) Successful in 10m54s
CI/CD Pipeline / Build and Push Images (push) Failing after 2m12s

Refactor the frontend to use a more consistent design system and improve the overall user interface and experience.

- Implement a consistent use of `Card` components across various pages (Dashboard, Settings, Notes, etc.).
- Improve layout responsiveness and spacing in several modules.
- Enhance the Tasks page with drag-and-drop status updates and a Kanban-style view.
- Update the Calendar view with better color coding for task priorities and types.
- Refactor the Bookmarks page to use a grid layout with improved card previews.
- Update the Nginx configuration to handle SPA routing and health checks more effectively.
- Standardize import paths using `@/` aliases.
- Fix minor bugs in message sending and loading states.
- Update Docker configuration to build from source and use a specific backend port.
This commit is contained in:
Tomas Dvorak
2026-05-20 16:36:48 +02:00
parent 1e377a01b0
commit 67dc5cc737
18 changed files with 503 additions and 681 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
FROM node:22-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci --only=production RUN npm install
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build
+3 -4
View File
@@ -1,14 +1,13 @@
icon: https://github.com/Dvorinka/Trackeep/raw/main/trackeepfavi_bg.png
services: services:
trackeep: trackeep:
image: ghcr.io/dvorinka/trackeep:latest build:
context: .
dockerfile: Dockerfile
ports: ports:
- "${HOST_PORT:-8080}:8080" - "${HOST_PORT:-8080}:8080"
env_file: env_file:
- .env - .env
environment: environment:
- BACKEND_PORT=8080
- DB_HOST=postgres - DB_HOST=postgres
- DB_PORT=5432 - DB_PORT=5432
- GIN_MODE=release - GIN_MODE=release
+2 -5
View File
@@ -6,7 +6,7 @@
set -e set -e
# Backend configuration # Backend configuration
export BACKEND_PORT=${BACKEND_PORT:-8080} export BACKEND_PORT=8081
export DB_HOST=${DB_HOST:-postgres} export DB_HOST=${DB_HOST:-postgres}
export DB_PORT=${DB_PORT:-5432} export DB_PORT=${DB_PORT:-5432}
export DB_NAME=${DB_NAME:-trackeep} export DB_NAME=${DB_NAME:-trackeep}
@@ -23,7 +23,7 @@ echo "Starting Trackeep backend on port ${BACKEND_PORT}..."
# Wait for backend to be ready # Wait for backend to be ready
echo "Waiting for backend to be ready..." echo "Waiting for backend to be ready..."
for i in $(seq 1 30); do for i in $(seq 1 30); do
if wget --no-verbose --tries=1 --spider http://localhost:${BACKEND_PORT}/health 2>/dev/null; then if wget --no-verbose --tries=1 --spider http://localhost:8081/health 2>/dev/null; then
echo "Backend is ready!" echo "Backend is ready!"
break break
fi fi
@@ -31,9 +31,6 @@ for i in $(seq 1 30); do
sleep 2 sleep 2
done done
# Update nginx config to proxy to localhost backend
sed -i "s|http://trackeep-backend:8080/|http://localhost:${BACKEND_PORT}/|g" /etc/nginx/nginx.conf
# Start nginx # Start nginx
echo "Starting nginx..." echo "Starting nginx..."
nginx -g "daemon off;" nginx -g "daemon off;"
-1
View File
@@ -9,7 +9,6 @@
<meta name="theme-color" content="#39b9ff" /> <meta name="theme-color" content="#39b9ff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trackeep - Your Self-Hosted Productivity & Knowledge Hub</title> <title>Trackeep - Your Self-Hosted Productivity & Knowledge Hub</title>
<link rel="stylesheet" crossorigin href="/assets/index-LnCEqXC_.css">
<script> <script>
// Runtime environment variable injection // Runtime environment variable injection
window.ENV = { window.ENV = {
+27 -2
View File
@@ -39,7 +39,7 @@ http {
image/svg+xml; image/svg+xml;
server { server {
listen 80; listen 8080;
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
@@ -50,14 +50,39 @@ http {
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Explicit root files (prevent SPA fallback)
location = /manifest.json {
try_files $uri =404;
}
location = /trackeep.svg {
try_files $uri =404;
}
location = /trackeepfavi_bg.png {
try_files $uri =404;
}
location = /trackeepfavi.png {
try_files $uri =404;
}
# Handle client-side routing # Handle client-side routing
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Health check proxy to backend
location /health {
proxy_pass http://localhost:8081/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# API proxy to backend (internal localhost) # API proxy to backend (internal localhost)
location /api/ { location /api/ {
proxy_pass http://localhost:8080/; proxy_pass http://localhost:8081;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
+2 -2
View File
@@ -182,10 +182,10 @@ export function Layout(props: LayoutProps) {
{/* Main Content */} {/* Main Content */}
<div class="flex-1 min-h-0 flex flex-col"> <div class="flex-1 min-h-0 flex flex-col">
{/* Header */} {/* Header */}
<Header title={props.title} onMenuClick={toggleSidebar} /> {!props.fullBleed && <Header title={props.title} onMenuClick={toggleSidebar} />}
{/* Page Content */} {/* Page Content */}
<main class="flex-1 overflow-auto max-w-screen"> <main class={`flex-1 ${props.fullBleed ? 'overflow-hidden' : 'overflow-auto w-full'}`}>
<div class={props.fullBleed ? "h-full" : "p-2 max-w-7xl mx-auto"}> <div class={props.fullBleed ? "h-full" : "p-2 max-w-7xl mx-auto"}>
{resolved()} {resolved()}
</div> </div>
+1 -1
View File
@@ -15,7 +15,7 @@ import {
IconClock, IconClock,
IconChecklist IconChecklist
} from '@tabler/icons-solidjs'; } from '@tabler/icons-solidjs';
import { ColorSwitcher } from './ColorSwitcher'; import { ColorSwitcher } from '@/pages/settings/ColorSwitcher';
import { useHaptics } from '@/lib/haptics'; import { useHaptics } from '@/lib/haptics';
interface ProjectStats { interface ProjectStats {
+5 -108
View File
@@ -9,7 +9,6 @@ import {
FileText as FileTextIcon, FileText as FileTextIcon,
Sparkles, Sparkles,
ChevronDown, ChevronDown,
Settings,
Trash, Trash,
User User
} from 'lucide-solid' } from 'lucide-solid'
@@ -386,109 +385,8 @@ const Chat = () => {
} }
return ( return (
<div class="mt-4 pb-32 max-w-7xl mx-auto"> <div class="mt-4 pb-32 max-w-7xl mx-auto flex flex-col md:flex-row gap-4">
<div class="bg-background rounded-lg border shadow-sm"> <div class="w-72 flex-shrink-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
{/* Header with Model Selection */}
<div class="p-6 border-b bg-card/95 backdrop-blur-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="flex items-center gap-3">
<div>
<h2 class="font-semibold text-lg">AI Assistant</h2>
<p class="text-sm text-muted-foreground">Your intelligent workspace companion</p>
</div>
</div>
<div class="flex items-center gap-1 p-1 bg-muted rounded-lg">
<button
onClick={() => setActiveView('chat')}
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeView() === 'chat'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Chat
</button>
<button
onClick={() => setActiveView('ai-tools')}
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeView() === 'ai-tools'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
AI Tools
</button>
</div>
</div>
{/* Settings Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowSettings(!showSettings())}
class="flex items-center gap-2 px-3 py-2 hover:bg-muted rounded-lg text-sm transition-colors"
>
<Settings class="h-4 w-4" />
Settings
</Button>
{/* Enhanced AI Model Picker */}
<div class="relative">
<button
onClick={() => setShowModelPicker(!showModelPicker())}
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg text-sm transition-colors"
>
<span>{getAIModels().find(m => m.id === selectedModel())?.name || 'Select Model'}</span>
<ChevronDown class={`h-4 w-4 transition-transform ${showModelPicker() ? 'rotate-180' : ''}`} />
</button>
<Show when={showModelPicker()}>
<div class="absolute right-0 mt-2 w-80 bg-background border rounded-lg shadow-lg z-50 p-2 max-h-96 overflow-y-auto">
<div class="p-2 border-b mb-2">
<h4 class="text-sm font-semibold text-foreground">Select AI Model</h4>
<p class="text-xs text-muted-foreground">Choose the best model for your needs</p>
</div>
<For each={getAIModels()}>
{model => (
<button
onClick={() => {
setSelectedModel(model.id)
setShowModelPicker(false)
}}
class={`w-full text-left p-3 rounded-lg transition-colors ${
selectedModel() === model.id
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-muted'
}`}
>
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-sm">{model.name}</div>
<div class="text-xs text-muted-foreground mt-1">{model.description}</div>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
{model.provider}
</span>
<span class="text-xs px-2 py-1 bg-muted text-muted-foreground rounded-full">
{model.category}
</span>
</div>
</div>
{selectedModel() === model.id && (
<div class="w-2 h-2 bg-primary rounded-full"></div>
)}
</div>
</button>
)}
</For>
</div>
</Show>
</div>
</div>
</div>
<Show when={showSettings()}> <Show when={showSettings()}>
<div class="p-6 border-b bg-muted/30"> <div class="p-6 border-b bg-muted/30">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -741,7 +639,7 @@ const Chat = () => {
</div> </div>
{/* Chat Area */} {/* Chat Area */}
<div class="flex-1 flex flex-col min-w-0 ml-80"> <div class="flex-1 flex flex-col min-w-0 bg-background rounded-lg border shadow-sm overflow-hidden">
<div class="hidden md:flex items-center justify-between p-6 border-b bg-card/95 backdrop-blur-sm"> <div class="hidden md:flex items-center justify-between p-6 border-b bg-card/95 backdrop-blur-sm">
<div class="flex items-center gap-6"> <div class="flex items-center gap-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -811,9 +709,9 @@ const Chat = () => {
</div> </div>
{/* Main Content Area */} {/* Main Content Area */}
<div class="flex flex-col"> <div class="flex-1 flex flex-col min-h-0">
<Show when={activeView() === 'chat'}> <Show when={activeView() === 'chat'}>
<div class="flex-1 overflow-y-auto h-[calc(100vh-320px)]"> <div class="flex-1 overflow-y-auto min-h-0">
<div class="space-y-6 max-w-5xl mx-auto p-6"> <div class="space-y-6 max-w-5xl mx-auto p-6">
<For each={messages()}> <For each={messages()}>
{message => ( {message => (
@@ -1111,7 +1009,6 @@ const Chat = () => {
</div> </div>
</Show> </Show>
</div> </div>
<div class="clear-both"></div>
</div> </div>
</div> </div>
) )
@@ -606,13 +606,16 @@
@media (max-width: 767px) { @media (max-width: 767px) {
.messages-shell { .messages-shell {
display: block; display: flex;
flex-direction: column;
} }
.messages-sidebar, .messages-sidebar,
.messages-main { .messages-main {
width: 100%; width: 100%;
max-width: none; max-width: none;
height: 100%;
min-height: 0;
} }
.messages-shell-list .messages-main { .messages-shell-list .messages-main {
@@ -622,6 +625,10 @@
.messages-shell-conversation .messages-sidebar { .messages-shell-conversation .messages-sidebar {
display: none; display: none;
} }
.messages-composer {
padding: 0.75rem;
}
} }
.messages-composer-drag { .messages-composer-drag {
@@ -830,9 +837,9 @@
/* Responsive design */ /* Responsive design */
@media (max-width: 980px) { @media (max-width: 980px) {
.messages-sidebar, .messages-sidebar {
.messages-main { width: 16rem;
width: 100%; min-width: 16rem;
border-inline: none; border-inline: none;
border-radius: 0; border-radius: 0;
} }
@@ -848,11 +855,11 @@
} }
.messages-composer-row { .messages-composer-row {
grid-template-columns: auto auto 1fr; grid-template-columns: repeat(3, auto) 1fr auto;
} }
.messages-composer-row > button:last-child { .messages-composer-row > button:last-child {
grid-column: 3; grid-column: 5;
justify-self: end; justify-self: end;
} }
@@ -1768,6 +1768,7 @@ export const Messages = () => {
if (!selectedConversationId()) return; if (!selectedConversationId()) return;
const body = inputText().trim(); const body = inputText().trim();
if (!body && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0) return; if (!body && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0) return;
if (sendingMessage()) return;
try { try {
const localFiles = [...selectedFiles()]; const localFiles = [...selectedFiles()];
@@ -2751,6 +2752,7 @@ export const Messages = () => {
}} }}
disabled={ disabled={
sendingMessage() || sendingMessage() ||
uploadProgress() !== null ||
(!inputText().trim() && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0) (!inputText().trim() && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0)
} }
> >
+44 -35
View File
@@ -482,114 +482,122 @@ export const Bookmarks = () => {
))} ))}
</div> </div>
) : ( ) : (
<div class="space-y-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredBookmarks().map((bookmark) => { {filteredBookmarks().map((bookmark) => {
const faviconUrl = getFaviconUrl(bookmark); const faviconUrl = getFaviconUrl(bookmark);
const screenshotUrl = getScreenshotUrl(bookmark); const screenshotUrl = getScreenshotUrl(bookmark);
return ( return (
<Card class="p-6 hover:bg-accent transition-colors group"> <Card class="p-4 hover:bg-accent/50 transition-colors group flex flex-col h-full">
<div class="flex justify-between items-start gap-4">
{/* Left side: preview image + favicon + title + URL + tags */}
<div class="flex-1 min-w-0">
{screenshotUrl && ( {screenshotUrl && (
<div class="mb-3 rounded-md overflow-hidden border border-border bg-muted/40"> <div class="mb-3 rounded-lg overflow-hidden border border-border/50 bg-muted/30 -mx-4 -mt-4">
<a href={bookmark.url} target="_blank" rel="noopener noreferrer">
<img <img
src={screenshotUrl} src={screenshotUrl}
alt="Website preview" alt="Website preview"
class="w-full h-32 sm:h-40 object-cover" class="w-full h-28 object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy" loading="lazy"
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none'; e.currentTarget.style.display = 'none';
}} }}
/> />
</a>
</div> </div>
)} )}
<div class="flex items-center gap-3 mb-2"> <div class="flex items-start gap-3 mb-3">
<div class="flex-shrink-0 w-8 h-8 bg-muted rounded-md flex items-center justify-center overflow-hidden"> <div class="flex-shrink-0 w-9 h-9 bg-muted rounded-lg flex items-center justify-center overflow-hidden border border-border/50">
{faviconUrl ? ( {faviconUrl ? (
<img <img
src={faviconUrl} src={faviconUrl}
alt="" alt=""
class="w-6 h-6 object-contain" class="w-5 h-5 object-contain"
onError={(e) => { onError={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
img.style.display = 'none'; img.style.display = 'none';
const span = document.createElement('span'); const span = document.createElement('span');
span.className = 'text-xs text-muted-foreground font-medium'; span.className = 'text-xs text-muted-foreground font-bold';
span.textContent = getBookmarkInitial(bookmark.title); span.textContent = getBookmarkInitial(bookmark.title);
img.parentElement!.appendChild(span); img.parentElement!.appendChild(span);
}} }}
/> />
) : ( ) : (
<span class="text-xs text-muted-foreground font-medium"> <span class="text-xs text-muted-foreground font-bold">
{getBookmarkInitial(bookmark.title)} {getBookmarkInitial(bookmark.title)}
</span> </span>
)} )}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground truncate"> <h3 class="text-sm font-semibold text-foreground leading-tight">
<a <a
href={bookmark.url} href={bookmark.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1" class="text-foreground hover:text-primary transition-colors"
> >
{bookmark.title} {bookmark.title}
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current group-hover:text-white" />
</a> </a>
</h3> </h3>
<p class="text-muted-foreground text-sm truncate">{bookmark.url}</p> <p class="text-muted-foreground text-xs truncate mt-0.5">{bookmark.url}</p>
</div> </div>
</div> </div>
{bookmark.description && ( {bookmark.description && (
<p class="text-foreground text-sm mb-3 line-clamp-2">{bookmark.description}</p> <p class="text-foreground/80 text-xs mb-3 line-clamp-2 flex-grow">{bookmark.description}</p>
)} )}
<div class="flex flex-wrap gap-2 mt-1"> <div class="flex flex-wrap gap-1.5 mt-auto">
{(bookmark.tags || []).map((tag) => ( {(bookmark.tags || []).slice(0, 4).map((tag) => (
<button <button
onClick={() => handleTagClick(tag)} onClick={() => handleTagClick(tag)}
class={`px-2 py-1 text-xs rounded-md border transition-colors cursor-pointer class={`px-2 py-0.5 text-[10px] rounded-md border transition-colors cursor-pointer
${selectedTag() === tag ${selectedTag() === tag
? 'bg-primary text-primary-foreground border-primary' ? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/80 text-muted-foreground border-transparent group-hover:bg-accent group-hover:text-accent-foreground group-hover:border-border' : 'bg-muted/60 text-muted-foreground border-transparent hover:bg-accent hover:text-accent-foreground'
}`} }`}
title={`Click to filter by ${tag}`} title={`Click to filter by ${tag}`}
> >
{tag} {tag}
</button> </button>
))} ))}
</div> {(bookmark.tags || []).length > 4 && (
<span class="px-2 py-0.5 text-[10px] text-muted-foreground">+{(bookmark.tags || []).length - 4}</span>
)}
</div> </div>
{/* Right side: optional date above important star + menu */} <div class="flex items-center justify-between mt-3 pt-3 border-t border-border/50">
<div class="flex flex-col items-end gap-2 ml-2"> {bookmark.created_at && !isNaN(new Date(bookmark.created_at).getTime()) ? (
{bookmark.created_at && !isNaN(new Date(bookmark.created_at).getTime()) && ( <span class="text-muted-foreground text-[10px]">
<div class="text-muted-foreground text-xs">
{new Date(bookmark.created_at).toLocaleDateString()} {new Date(bookmark.created_at).toLocaleDateString()}
</div> </span>
) : (
<span />
)} )}
<div class="flex items-center gap-2"> <div class="flex items-center gap-1">
<button <button
onClick={() => toggleImportant(bookmark.id)} onClick={() => toggleImportant(bookmark.id)}
class={`flex-shrink-0 p-1 rounded hover:bg-accent/50 transition-colors ${ class="p-1.5 rounded-md hover:bg-accent transition-colors"
bookmark.isImportant ? 'order-first' : ''
}`}
title={bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'} title={bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'}
> >
<IconStar <IconStar
class={`size-4 ${ class={`size-3.5 ${
bookmark.isImportant bookmark.isImportant
? 'text-primary fill-primary' ? 'text-primary fill-primary'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
}`} }`}
/> />
</button> </button>
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
class="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
title="Open in new tab"
>
<IconExternalLink class="size-3.5" />
</a>
<DropdownMenu <DropdownMenu
trigger={ trigger={
<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-8 w-8"> <button class="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground">
<IconDotsVertical class="size-4" /> <IconDotsVertical class="size-3.5" />
</button> </button>
} }
> >
@@ -612,17 +620,18 @@ export const Bookmarks = () => {
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
</div>
</Card> </Card>
); );
})} })}
{filteredBookmarks().length === 0 && ( {filteredBookmarks().length === 0 && (
<div class="col-span-full">
<Card class="p-12 text-center"> <Card class="p-12 text-center">
<p class="text-muted-foreground"> <p class="text-muted-foreground">
{searchTerm() ? 'No bookmarks found matching your search.' : 'No bookmarks yet. Add your first bookmark!'} {searchTerm() ? 'No bookmarks found matching your search.' : 'No bookmarks yet. Add your first bookmark!'}
</p> </p>
</Card> </Card>
</div>
)} )}
</div> </div>
)} )}
+70 -151
View File
@@ -5,7 +5,7 @@ import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { NoteModal } from '@/components/ui/NoteModal'; import { NoteModal } from '@/components/ui/NoteModal';
import { ViewNoteModal } from '@/components/ui/ViewNoteModal'; import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer'; import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
import { IconPin, IconTrash, IconEdit, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs'; import { IconPin, IconTrash, IconEdit, IconCopy } from '@tabler/icons-solidjs';
import { getMockNotes } from '@/lib/mockData'; import { getMockNotes } from '@/lib/mockData';
import { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode'; import { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode';
import { getApiV1BaseUrl } from '@/lib/api-url'; import { getApiV1BaseUrl } from '@/lib/api-url';
@@ -103,7 +103,6 @@ export const Notes = () => {
const [editingNote, setEditingNote] = createSignal<Note | null>(null); const [editingNote, setEditingNote] = createSignal<Note | null>(null);
const [viewingNote, setViewingNote] = createSignal<Note | null>(null); const [viewingNote, setViewingNote] = createSignal<Note | null>(null);
const [copiedContent, setCopiedContent] = createSignal(false); const [copiedContent, setCopiedContent] = createSignal(false);
const [expandedNotes, setExpandedNotes] = createSignal<Set<number>>(new Set());
onMount(async () => { onMount(async () => {
try { try {
@@ -400,18 +399,6 @@ export const Notes = () => {
} }
}; };
const toggleNoteExpansion = (noteId: number) => {
setExpandedNotes(prev => {
const newSet = new Set(prev);
if (newSet.has(noteId)) {
newSet.delete(noteId);
} else {
newSet.add(noteId);
}
return newSet;
});
};
const exportNote = (note: Note) => { const exportNote = (note: Note) => {
const content = note.isMarkdown ? `# ${note.title}\n\n${note.content}` : note.content; const content = note.isMarkdown ? `# ${note.title}\n\n${note.content}` : note.content;
const blob = new Blob([content], { type: 'text/plain' }); const blob = new Blob([content], { type: 'text/plain' });
@@ -527,21 +514,6 @@ export const Notes = () => {
}} }}
/> />
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card class="p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Total Notes</p>
<p class="text-xl font-semibold text-foreground">{filteredNotes().length}</p>
</Card>
<Card class="p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Pinned</p>
<p class="text-xl font-semibold text-foreground">{filteredNotes().filter((note) => note.pinned).length}</p>
</Card>
<Card class="p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Tags</p>
<p class="text-xl font-semibold text-foreground">{allTags().length}</p>
</Card>
</div>
<Show when={loadError()}> <Show when={loadError()}>
<Card class="border-destructive/30 bg-destructive/5 p-4"> <Card class="border-destructive/30 bg-destructive/5 p-4">
<p class="text-sm font-medium text-foreground">Notes could not be loaded</p> <p class="text-sm font-medium text-foreground">Notes could not be loaded</p>
@@ -555,160 +527,106 @@ export const Notes = () => {
</div> </div>
</Show> </Show>
<Show when={isLoading()}> {isLoading() ? (
<div class="space-y-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map(() => ( {[...Array(6)].map(() => (
<Card class="p-6"> <Card class="p-5 h-40">
<div class="animate-pulse"> <div class="animate-pulse space-y-3">
<div class="h-6 bg-muted rounded mb-2"></div> <div class="h-5 bg-muted rounded w-2/3"></div>
<div class="h-4 bg-muted rounded w-3/4"></div> <div class="h-3 bg-muted rounded w-full"></div>
<div class="h-3 bg-muted rounded w-4/5"></div>
</div> </div>
</Card> </Card>
))} ))}
</div> </div>
</Show> ) : (
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Show when={!isLoading()}>
<div class="space-y-4">
<For each={filteredNotes()}> <For each={filteredNotes()}>
{(note) => ( {(note) => (
<Card <div
data-note-id={note.id} class={`group relative bg-card rounded-xl border border-border p-5 cursor-pointer hover:shadow-lg hover:border-primary/20 transition-all ${note.pinned ? 'ring-1 ring-primary/20' : ''}`}
class={`p-6 cursor-pointer transition-colors hover:bg-accent/50 ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
onClick={() => viewNote(note)} onClick={() => viewNote(note)}
> >
<div class="flex justify-between items-start mb-3 gap-3">
<div class="flex items-center gap-2 min-w-0">
<h3 class="text-lg font-semibold text-foreground truncate">{note.title}</h3>
<Show when={note.pinned}> <Show when={note.pinned}>
<IconPin class="size-4 text-primary" /> <div class="absolute top-3 right-3">
</Show> <IconPin class="size-3.5 text-primary" />
<Show when={note.isMarkdown}>
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">MD</span>
</Show>
<Show when={note.isHtml}>
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">HTML</span>
</Show>
</div>
<div class="flex gap-1 shrink-0">
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
copyNoteContent(note);
}}
class="text-muted-foreground hover:text-foreground p-1"
>
<IconCopy size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
exportNote(note);
}}
class="text-muted-foreground hover:text-foreground p-1"
>
<IconDownload size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditNote(note);
}}
class="text-muted-foreground hover:text-foreground p-1"
>
<IconEdit size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
togglePin(note.id);
}}
class="text-primary hover:text-primary/80 p-1"
{...{ title: note.pinned ? 'Unpin note' : 'Pin note' }}
>
<IconPin size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
deleteNote(note.id);
}}
class="text-destructive hover:text-destructive/80 p-1"
>
<IconTrash size={16} />
</Button>
</div>
</div> </div>
</Show>
<div class="text-muted-foreground text-sm mb-3"> <h3 class={`text-base font-semibold text-foreground mb-2 pr-5 ${note.pinned ? 'text-primary' : ''}`}>
<div class={expandedNotes().has(note.id) ? '' : 'max-h-72 overflow-hidden'}> {note.title}
</h3>
<div class="text-muted-foreground text-sm line-clamp-3 mb-4">
<NoteContentRenderer <NoteContentRenderer
content={note.content} content={note.content}
kind={getNoteKind(note)} kind={getNoteKind(note)}
preview={!expandedNotes().has(note.id)} preview={true}
maxBlocks={4} maxBlocks={3}
onToggleTask={(taskIndex, nextChecked) => updateNoteCheckbox(note.id, taskIndex, nextChecked)} onToggleTask={(taskIndex, nextChecked) => updateNoteCheckbox(note.id, taskIndex, nextChecked)}
/> />
</div> </div>
<button
onClick={(e) => {
e.stopPropagation();
toggleNoteExpansion(note.id);
}}
class="mt-2 text-xs text-primary hover:text-primary/80 font-medium cursor-pointer transition-colors"
>
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
</button>
</div>
<Show when={note.attachments && note.attachments.length > 0}> <div class="flex flex-wrap gap-1.5 mb-3">
<div class="mb-3"> <For each={note.tags.slice(0, 4)}>
<div class="flex items-center gap-2 mb-2">
<IconPaperclip class="size-4 text-muted-foreground" />
<span class="text-xs text-muted-foreground">Attachments ({note.attachments?.length || 0})</span>
</div>
<div class="flex flex-wrap gap-2">
<For each={note.attachments || []}>
{(attachment) => (
<div class="flex items-center gap-2 px-2 py-1 bg-muted rounded-md text-xs">
<span class="text-foreground">{attachment.name}</span>
<span class="text-muted-foreground">({attachment.size})</span>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex flex-wrap gap-2 mb-3">
<For each={note.tags}>
{(tag) => ( {(tag) => (
<button <span
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleTag(tag); toggleTag(tag);
}} }}
class="px-2 py-1 bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground text-xs rounded-md transition-colors cursor-pointer" class="px-2 py-0.5 bg-muted/60 text-muted-foreground text-[10px] rounded-full cursor-pointer hover:bg-muted hover:text-foreground transition-colors"
> >
{tag} {tag}
</button> </span>
)} )}
</For> </For>
<Show when={note.tags.length > 4}>
<span class="px-2 py-0.5 text-muted-foreground text-[10px]">+{note.tags.length - 4}</span>
</Show>
</div> </div>
<p class="text-muted-foreground text-xs"> <div class="flex items-center justify-between">
Updated: {formatDisplayDate(note.updatedAt)} <span class="text-[10px] text-muted-foreground">
</p> {formatDisplayDate(note.updatedAt)}
</Card> </span>
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); copyNoteContent(note); }}
class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
title="Copy"
>
<IconCopy class="size-3.5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); startEditNote(note); }}
class="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
title="Edit"
>
<IconEdit class="size-3.5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); togglePin(note.id); }}
class="p-1.5 rounded-md hover:bg-muted text-primary hover:text-primary/80"
title={note.pinned ? 'Unpin' : 'Pin'}
>
<IconPin class="size-3.5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); deleteNote(note.id); }}
class="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Delete"
>
<IconTrash class="size-3.5" />
</button>
</div>
</div>
</div>
)} )}
</For> </For>
<Show when={filteredNotes().length === 0}> <Show when={filteredNotes().length === 0}>
<div class="col-span-full">
<Card class="p-12 text-center"> <Card class="p-12 text-center">
<p class="text-muted-foreground"> <p class="text-muted-foreground">
{searchTerm() || selectedTags().length > 0 {searchTerm() || selectedTags().length > 0
@@ -716,9 +634,10 @@ export const Notes = () => {
: 'No notes yet. Add your first note!'} : 'No notes yet. Add your first note!'}
</p> </p>
</Card> </Card>
</Show>
</div> </div>
</Show> </Show>
</div>
)}
{/* Add Note Modal */} {/* Add Note Modal */}
<NoteModal <NoteModal
+86 -109
View File
@@ -31,6 +31,7 @@ import {
} from '@tabler/icons-solidjs'; } from '@tabler/icons-solidjs';
import { BrowserSearch } from '@/components/search/BrowserSearch'; import { BrowserSearch } from '@/components/search/BrowserSearch';
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu'; import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
import { Card } from '@/components/ui/Card';
import { FilePreviewModal } from '@/components/ui/FilePreviewModal'; import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
import { ActivityFeed } from '@/components/ui/ActivityFeed'; import { ActivityFeed } from '@/components/ui/ActivityFeed';
import { UploadModal } from '@/components/ui/UploadModal'; import { UploadModal } from '@/components/ui/UploadModal';
@@ -525,129 +526,105 @@ export const Dashboard = () => {
return ( return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto"> <div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
{/* Stats Overview */} {/* Stats Overview */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
<div class="border rounded-lg p-4"> <Card class="p-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg"> <div class="bg-muted flex items-center justify-center p-2.5 rounded-xl">
<IconFileText class="size-5 text-primary" /> <IconFileText class="size-5 text-primary" />
</div> </div>
<div> <div>
<p class="text-2xl font-light">{stats().totalDocuments}</p> <p class="text-2xl font-bold text-foreground">{stats().totalDocuments}</p>
<p class="text-sm text-muted-foreground">Documents</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconBookmark class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{stats().totalBookmarks}</p>
<p class="text-sm text-muted-foreground">Bookmarks</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconChecklist class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{stats().totalTasks}</p>
<p class="text-sm text-muted-foreground">Tasks</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconNotebook class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{stats().totalNotes}</p>
<p class="text-sm text-muted-foreground">Notes</p>
</div>
</div>
</div>
</div>
{/* Enhanced Stats Row */}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-8">
<div class="border rounded-lg p-4">
<div class="flex flex-col items-center text-center gap-2">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconVideo class="size-5 text-primary" />
</div>
<div>
<p class="text-xl font-bold text-foreground">{stats().totalVideos}</p>
<p class="text-xs text-muted-foreground font-medium">Videos</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex flex-col items-center text-center gap-2">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconSchool class="size-5 text-primary" />
</div>
<div>
<p class="text-xl font-bold text-foreground">{stats().totalLearningPaths}</p>
<p class="text-xs text-muted-foreground font-medium">Learning</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex flex-col items-center text-center gap-2">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconClock class="size-5 text-primary" />
</div>
<div>
<p class="text-xl font-bold text-foreground">{formatDuration(stats().totalTimeTracked)}</p>
<p class="text-xs text-muted-foreground font-medium">Time</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex flex-col items-center text-center gap-2">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconTrendingUp class="size-5 text-primary" />
</div>
<div>
<p class="text-xl font-bold text-foreground">{stats().averageProductivity}%</p>
<p class="text-xs text-muted-foreground font-medium">Productivity</p>
</div>
</div>
</div>
<div class="border rounded-lg p-4">
<div class="flex flex-col items-center text-center gap-2">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconFolder class="size-5 text-primary" />
</div>
<div>
<p class="text-xl font-bold text-foreground">{stats().totalDocuments}</p>
<p class="text-xs text-muted-foreground font-medium">Documents</p> <p class="text-xs text-muted-foreground font-medium">Documents</p>
</div> </div>
</div> </div>
</div> </Card>
<div class="border rounded-lg p-4"> <Card class="p-4">
<div class="flex flex-col items-center text-center gap-2"> <div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg"> <div class="bg-muted flex items-center justify-center p-2.5 rounded-xl">
<IconActivity class="size-5 text-primary" /> <IconBookmark class="size-5 text-primary" />
</div> </div>
<div> <div>
<p class="text-xl font-bold text-foreground">{stats().totalNotes}</p> <p class="text-2xl font-bold text-foreground">{stats().totalBookmarks}</p>
<p class="text-xs text-muted-foreground font-medium">Bookmarks</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2.5 rounded-xl">
<IconChecklist class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-foreground">{stats().totalTasks}</p>
<p class="text-xs text-muted-foreground font-medium">Tasks</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2.5 rounded-xl">
<IconNotebook class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-bold text-foreground">{stats().totalNotes}</p>
<p class="text-xs text-muted-foreground font-medium">Notes</p> <p class="text-xs text-muted-foreground font-medium">Notes</p>
</div> </div>
</div> </div>
</Card>
</div> </div>
{/* Secondary Stats */}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
<Card class="p-3">
<div class="flex items-center gap-2.5">
<div class="bg-muted flex items-center justify-center p-2 rounded-xl">
<IconVideo class="size-4 text-primary" />
</div>
<div>
<p class="text-lg font-bold text-foreground">{stats().totalVideos}</p>
<p class="text-[10px] text-muted-foreground font-medium">Videos</p>
</div>
</div>
</Card>
<Card class="p-3">
<div class="flex items-center gap-2.5">
<div class="bg-muted flex items-center justify-center p-2 rounded-xl">
<IconSchool class="size-4 text-primary" />
</div>
<div>
<p class="text-lg font-bold text-foreground">{stats().totalLearningPaths}</p>
<p class="text-[10px] text-muted-foreground font-medium">Learning</p>
</div>
</div>
</Card>
<Card class="p-3">
<div class="flex items-center gap-2.5">
<div class="bg-muted flex items-center justify-center p-2 rounded-xl">
<IconClock class="size-4 text-primary" />
</div>
<div>
<p class="text-lg font-bold text-foreground">{formatDuration(stats().totalTimeTracked)}</p>
<p class="text-[10px] text-muted-foreground font-medium">Tracked</p>
</div>
</div>
</Card>
<Card class="p-3">
<div class="flex items-center gap-2.5">
<div class="bg-muted flex items-center justify-center p-2 rounded-xl">
<IconTrendingUp class="size-4 text-primary" />
</div>
<div>
<p class="text-lg font-bold text-foreground">{stats().averageProductivity}%</p>
<p class="text-[10px] text-muted-foreground font-medium">Productivity</p>
</div>
</div>
</Card>
</div> </div>
{/* Recent Achievements and Deadlines */} {/* Recent Achievements and Deadlines */}
+11 -11
View File
@@ -328,22 +328,22 @@ export function Calendar() {
const getPriorityColor = (priority: string) => { const getPriorityColor = (priority: string) => {
switch (priority) { switch (priority) {
case 'urgent': return 'text-primary' case 'urgent': return 'text-red-500'
case 'high': return 'text-primary' case 'high': return 'text-orange-500'
case 'medium': return 'text-primary' case 'medium': return 'text-yellow-500'
case 'low': return 'text-primary' case 'low': return 'text-green-500'
default: return 'text-primary' default: return 'text-muted-foreground'
} }
} }
const getTypeColor = (type: string) => { const getTypeColor = (type: string) => {
switch (type) { switch (type) {
case 'task': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary' case 'task': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'meeting': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary' case 'meeting': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
case 'deadline': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary' case 'deadline': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
case 'reminder': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary' case 'reminder': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
case 'habit': return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary' case 'habit': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
default: return 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary' default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
} }
} }
+134 -146
View File
@@ -1,7 +1,6 @@
import { createSignal, onMount } from 'solid-js'; import { createSignal, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { TaskModal } from '@/components/ui/TaskModal'; import { TaskModal } from '@/components/ui/TaskModal';
import { IconEdit, IconTrash } from '@tabler/icons-solidjs'; import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
import { getApiV1BaseUrl } from '@/lib/api-url'; import { getApiV1BaseUrl } from '@/lib/api-url';
@@ -25,12 +24,53 @@ export const Tasks = () => {
const [showAddModal, setShowAddModal] = createSignal(false); const [showAddModal, setShowAddModal] = createSignal(false);
const [showEditModal, setShowEditModal] = createSignal(false); const [showEditModal, setShowEditModal] = createSignal(false);
const [editingTask, setEditingTask] = createSignal<Task | null>(null); const [editingTask, setEditingTask] = createSignal<Task | null>(null);
const [filter, setFilter] = createSignal<'all' | 'active' | 'completed'>('all');
const [searchTerm, setSearchTerm] = createSignal(''); const [searchTerm, setSearchTerm] = createSignal('');
const [selectedPriority, setSelectedPriority] = createSignal(''); const [selectedPriority, setSelectedPriority] = createSignal('');
const [draggedTaskId, setDraggedTaskId] = createSignal<number | null>(null);
const [dragOverColumn, setDragOverColumn] = createSignal<string | null>(null);
const [taskStatuses, setTaskStatuses] = createSignal<Record<number, 'todo' | 'inProgress' | 'done'>>({});
const haptics = useHaptics(); const haptics = useHaptics();
const getTaskColumn = (task: Task) => {
if (task.completed) return 'done';
return taskStatuses()[task.id] || 'todo';
};
const setTaskColumn = async (taskId: number, column: 'todo' | 'inProgress' | 'done') => {
const task = tasks().find(t => t.id === taskId);
if (!task) return;
const shouldBeCompleted = column === 'done';
if (column === 'done') {
setTaskStatuses(prev => { const n = { ...prev }; delete n[taskId]; return n; });
} else {
setTaskStatuses(prev => ({ ...prev, [taskId]: column }));
}
if (task.completed !== shouldBeCompleted) {
try {
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
body: JSON.stringify({ ...task, completed: shouldBeCompleted }),
});
if (response.ok) {
const updated = await response.json();
setTasks(prev => prev.map(t => t.id === taskId ? updated : t));
}
} catch (error) {
console.error('Failed to update task status:', error);
}
} else {
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, completed: shouldBeCompleted } : t));
}
};
onMount(async () => { onMount(async () => {
try { try {
const response = await fetch(`${API_BASE_URL}/tasks`, { const response = await fetch(`${API_BASE_URL}/tasks`, {
@@ -51,30 +91,29 @@ export const Tasks = () => {
} }
}); });
const filteredTasks = () => { const searchedTasks = () => {
const term = searchTerm().toLowerCase(); const term = searchTerm().toLowerCase();
const filtered = tasks().filter(task => { return tasks().filter(task => {
const matchesSearch = !term || const matchesSearch = !term ||
task.title.toLowerCase().includes(term) || task.title.toLowerCase().includes(term) ||
(task.description && task.description.toLowerCase().includes(term)); (task.description && task.description.toLowerCase().includes(term));
const matchesPriority = !selectedPriority() || task.priority === selectedPriority(); const matchesPriority = !selectedPriority() || task.priority === selectedPriority();
return matchesSearch && matchesPriority;
const matchesFilter = }).sort((a, b) => {
(filter() === 'active' && !task.completed) ||
(filter() === 'completed' && task.completed) ||
filter() === 'all';
return matchesSearch && matchesFilter && matchesPriority;
});
return filtered.sort((a, b) => {
const priorityOrder = { high: 0, medium: 1, low: 2 }; const priorityOrder = { high: 0, medium: 1, low: 2 };
if (a.completed !== b.completed) return a.completed ? 1 : -1;
return priorityOrder[a.priority] - priorityOrder[b.priority]; return priorityOrder[a.priority] - priorityOrder[b.priority];
}); });
}; };
const columnTasks = (column: 'todo' | 'inProgress' | 'done') =>
searchedTasks().filter(t => getTaskColumn(t) === column);
const columnCounts = () => ({
todo: columnTasks('todo').length,
inProgress: columnTasks('inProgress').length,
done: columnTasks('done').length,
});
const handleAddTask = async (task: Omit<Task, 'id'>) => { const handleAddTask = async (task: Omit<Task, 'id'>) => {
try { try {
const response = await fetch(`${API_BASE_URL}/tasks`, { const response = await fetch(`${API_BASE_URL}/tasks`, {
@@ -134,19 +173,6 @@ export const Tasks = () => {
} }
}; };
const toggleTaskComplete = async (taskId: number) => {
try {
// TODO: Replace with actual API call
setTasks(prev => prev.map(task =>
task.id === taskId ? { ...task, completed: !task.completed } : task
));
haptics.completion(); // Completion feedback for toggling task
} catch (error) {
haptics.error(); // Error feedback
console.error('Failed to update task:', error);
}
};
const deleteTask = async (taskId: number) => { const deleteTask = async (taskId: number) => {
if (confirm('Are you sure you want to delete this task?')) { if (confirm('Are you sure you want to delete this task?')) {
try { try {
@@ -185,20 +211,13 @@ export const Tasks = () => {
} }
}; };
const taskStats = () => {
const total = tasks().length;
const completed = tasks().filter(t => t.completed).length;
const active = total - completed;
return { total, completed, active };
};
const hasSearchOrPriorityFilters = () =>
Boolean(searchTerm().trim()) || Boolean(selectedPriority());
return ( return (
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
<div class="flex justify-between items-center"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h1 class="text-3xl font-bold text-[#fafafa]">Tasks</h1> <div>
<h1 class="text-3xl font-bold text-foreground">Tasks</h1>
<p class="text-muted-foreground text-sm mt-1">{columnCounts().todo} todo · {columnCounts().inProgress} in progress · {columnCounts().done} done</p>
</div>
<Button onClick={() => setShowAddModal(true)} haptic="impact"> <Button onClick={() => setShowAddModal(true)} haptic="impact">
Add Task Add Task
</Button> </Button>
@@ -221,138 +240,107 @@ export const Tasks = () => {
isEdit={true} isEdit={true}
/> />
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div class="flex flex-col sm:flex-row gap-3">
<Card class="p-4 text-center"> <input
<p class="text-2xl font-bold text-[#fafafa]">{taskStats().total}</p> type="text"
<p class="text-[#a3a3a3] text-sm">Total Tasks</p> placeholder="Search tasks..."
</Card> value={searchTerm()}
<Card class="p-4 text-center"> onInput={(e) => setSearchTerm(e.currentTarget.value)}
<p class="text-2xl font-bold text-[#fafafa]">{taskStats().active}</p> class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
<p class="text-[#a3a3a3] text-sm">Active</p>
</Card>
<Card class="p-4 text-center">
<p class="text-2xl font-bold text-blue-400">{taskStats().completed}</p>
<p class="text-[#a3a3a3] text-sm">Completed</p>
</Card>
</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"
/> />
<select
<div class="flex flex-wrap gap-2 -mt-3 mb-6"> value={selectedPriority()}
{(['all', 'active', 'completed'] as const).map((filterOption) => ( onChange={(e) => setSelectedPriority(e.currentTarget.value)}
<Button class="px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
variant={filter() === filterOption ? 'default' : 'outline'}
onClick={() => setFilter(filterOption)}
class="capitalize"
haptic="selection"
> >
{filterOption} <option value="">All priorities</option>
</Button> <option value="high">High</option>
))} <option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div> </div>
{isLoading() ? ( {isLoading() ? (
<div class="space-y-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{[...Array(3)].map(() => ( {[...Array(3)].map(() => (
<Card class="p-6"> <Card class="p-4 h-48">
<div class="animate-pulse"> <div class="animate-pulse space-y-3">
<div class="h-6 bg-[#262626] rounded mb-2"></div> <div class="h-5 bg-muted rounded w-1/2"></div>
<div class="h-4 bg-[#262626] rounded w-3/4"></div> <div class="h-20 bg-muted rounded"></div>
</div> </div>
</Card> </Card>
))} ))}
</div> </div>
) : ( ) : (
<div class="space-y-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
{filteredTasks().map((task) => ( {([
{ key: 'todo' as const, label: 'To Do', color: 'border-t-4 border-t-muted-foreground' },
{ key: 'inProgress' as const, label: 'In Progress', color: 'border-t-4 border-t-primary' },
{ key: 'done' as const, label: 'Done', color: 'border-t-4 border-t-emerald-500' },
]).map((col) => {
const items = columnTasks(col.key);
const isDropTarget = dragOverColumn() === col.key;
return (
<div <div
class={`cursor-pointer transition-all ${task.completed ? 'opacity-60' : ''}`} class={`flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4 min-h-[12rem] transition-all ${col.color} ${isDropTarget ? 'ring-2 ring-primary/30 bg-primary/5' : ''}`}
onClick={() => toggleTaskComplete(task.id)} onDragOver={(e) => { e.preventDefault(); setDragOverColumn(col.key); }}
onDragLeave={() => setDragOverColumn(null)}
onDrop={(e) => { e.preventDefault(); setDragOverColumn(null); const id = draggedTaskId(); if (id !== null) setTaskColumn(id, col.key); setDraggedTaskId(null); }}
> >
<Card class={`p-6 hover:bg-[#141415]`}>
<div class="flex items-start space-x-3">
<input
type="checkbox"
checked={task.completed}
onChange={(e) => {
e.stopPropagation();
toggleTaskComplete(task.id);
}}
class="mt-1 w-4 h-4 text-[#39b9ff] bg-[#141415] border-[#262626] rounded focus:ring-[#39b9ff]"
/>
<div class="flex-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class={`text-lg font-semibold text-[#fafafa] ${task.completed ? 'line-through' : ''}`}> <h2 class="font-semibold text-foreground">{col.label}</h2>
{task.title} <span class="text-xs font-medium px-2 py-0.5 rounded-full bg-muted text-muted-foreground">{items.length}</span>
</h3> </div>
<div class="flex items-center space-x-2"> <div class="flex flex-col gap-2">
<span class={`px-2 py-1 text-xs rounded-md ${getPriorityColor(task.priority)}`}> {items.map((task: Task) => (
{task.priority} <div
</span> draggable={true}
<Button onDragStart={() => { setDraggedTaskId(task.id); haptics.impact(); }}
variant="ghost" onDragEnd={() => setDraggedTaskId(null)}
size="sm" class={`group bg-background border border-border rounded-lg p-3 cursor-grab active:cursor-grabbing hover:shadow-md hover:border-primary/20 transition-all ${draggedTaskId() === task.id ? 'opacity-40' : ''}`}
onClick={(e) => {
e.stopPropagation();
editTask(task);
}}
class="text-blue-400 hover:text-blue-300"
haptic="impact"
> >
<IconEdit class="w-4 h-4" /> <div class="flex items-start justify-between gap-2">
</Button> <h3 class="text-sm font-medium text-foreground leading-snug flex-1">{task.title}</h3>
<Button <div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
variant="ghost" <button
size="sm" onClick={() => editTask(task)}
onClick={(e) => { class="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
e.stopPropagation();
deleteTask(task.id);
}}
class="text-red-400 hover:text-red-300"
haptic="warning"
> >
<IconTrash class="w-4 h-4" /> <IconEdit class="w-3.5 h-3.5" />
</Button> </button>
<button
onClick={() => deleteTask(task.id)}
class="p-1 rounded hover:bg-muted text-muted-foreground hover:text-destructive"
>
<IconTrash class="w-3.5 h-3.5" />
</button>
</div> </div>
</div> </div>
{task.description && ( {task.description && (
<p class="text-[#a3a3a3] text-sm mt-1">{task.description}</p> <p class="text-xs text-muted-foreground mt-1 line-clamp-2">{task.description}</p>
)} )}
<div class="flex items-center gap-2 mt-2">
<span class={`text-[10px] px-1.5 py-0.5 rounded font-medium ${getPriorityColor(task.priority)}`}>
{task.priority}
</span>
{task.dueDate && ( {task.dueDate && (
<p class="text-[#a3a3a3] text-xs mt-2"> <span class="text-[10px] text-muted-foreground">
Due: {new Date(task.dueDate).toLocaleDateString()} {new Date(task.dueDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</p> </span>
)} )}
</div> </div>
</div> </div>
</Card>
</div>
))} ))}
{items.length === 0 && (
{filteredTasks().length === 0 && ( <div class="text-center py-8 text-xs text-muted-foreground border-2 border-dashed border-border rounded-lg">
<Card class="p-12 text-center"> Drop tasks here
<p class="text-[#a3a3a3]"> </div>
{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>
</Card>
)} )}
</div> </div>
</div>
);
})}
</div>
)} )}
</div> </div>
); );
@@ -1,8 +1,8 @@
import { createSignal, createEffect, Show, For } from 'solid-js'; import { createSignal, createEffect, Show, For } from 'solid-js';
import { Card } from '../components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '../components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '../components/ui/Input'; import { Input } from '@/components/ui/Input';
import { toast } from '../components/ui/Toast'; import { toast } from '@/components/ui/Toast';
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid'; import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
import { getApiV1BaseUrl } from '@/lib/api-url'; import { getApiV1BaseUrl } from '@/lib/api-url';
@@ -478,7 +478,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
<label class="block text-sm font-medium text-gray-700 mb-2">Key Name</label> <label class="block text-sm font-medium text-gray-700 mb-2">Key Name</label>
<Input <Input
value={newKeyName()} value={newKeyName()}
onInput={(e) => setNewKeyName((e.target as HTMLInputElement).value)} onInput={(e: InputEvent) => setNewKeyName((e.target as HTMLInputElement).value)}
placeholder="e.g., Chrome Extension, Laptop Backup" placeholder="e.g., Chrome Extension, Laptop Backup"
class="w-full" class="w-full"
/> />
+10 -7
View File
@@ -4,6 +4,7 @@ import { useAuth } from '@/lib/auth';
import { IconUser, IconLock, IconKey, IconBrain, IconMail, IconSend, IconShield, IconDownload } from '@tabler/icons-solidjs'; import { IconUser, IconLock, IconKey, IconBrain, IconMail, IconSend, IconShield, IconDownload } from '@tabler/icons-solidjs';
import { TwoFactorAuth } from '@/components/TwoFactorAuth'; import { TwoFactorAuth } from '@/components/TwoFactorAuth';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { AIProviderIcon } from '@/components/AIProviderIcon'; import { AIProviderIcon } from '@/components/AIProviderIcon';
import { useHaptics } from '@/lib/haptics'; import { useHaptics } from '@/lib/haptics';
import { getApiV1BaseUrl } from '@/lib/api-url'; import { getApiV1BaseUrl } from '@/lib/api-url';
@@ -412,7 +413,7 @@ export const Settings = () => {
{/* Tab Navigation */} {/* Tab Navigation */}
<div class="border-b border-border mb-6"> <div class="border-b border-border mb-6">
<nav class="flex space-x-1"> <nav class="flex space-x-1 overflow-x-auto scrollbar-hide">
<For each={tabs}> <For each={tabs}>
{(tab) => ( {(tab) => (
<button <button
@@ -420,7 +421,7 @@ export const Settings = () => {
setActiveTab(tab.id); setActiveTab(tab.id);
haptics.selection(); haptics.selection();
}} }}
class={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${ class={`flex items-center gap-2 px-3 sm:px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab() === tab.id activeTab() === tab.id
? 'border-primary text-primary' ? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
@@ -440,9 +441,11 @@ export const Settings = () => {
<Show when={activeTab() === 'account'}> <Show when={activeTab() === 'account'}>
<div class="space-y-6"> <div class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="border rounded-lg p-6"> <Card class="p-6">
<h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2"> <h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2">
<IconUser class="size-5" /> <div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconUser class="size-4 text-primary" />
</div>
Profile Settings Profile Settings
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
@@ -546,9 +549,9 @@ export const Settings = () => {
{isLoading() ? 'Updating...' : 'Update Profile'} {isLoading() ? 'Updating...' : 'Update Profile'}
</button> </button>
</div> </div>
</div> </Card>
<div class="border rounded-lg p-6"> <Card class="p-6">
<h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2"> <h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2">
<IconLock class="size-5" /> <IconLock class="size-5" />
Change Password Change Password
@@ -611,7 +614,7 @@ export const Settings = () => {
{isLoading() ? 'Changing...' : 'Change Password'} {isLoading() ? 'Changing...' : 'Change Password'}
</button> </button>
</div> </div>
</div> </Card>
</div> </div>
</div> </div>
</Show> </Show>
+1 -1
View File
@@ -24,7 +24,7 @@
"render:video:poster": "npm --workspace video run render:poster", "render:video:poster": "npm --workspace video run render:poster",
"install:all": "npm install && cd frontend && npm install && cd ../mobile && npm install && cd ../desktop && npm install", "install:all": "npm install && cd frontend && npm install && cd ../mobile && npm install && cd ../desktop && npm install",
"clean": "rm -rf dist node_modules frontend/node_modules mobile/node_modules desktop/node_modules backend/vendor desktop/dist desktop/src-tauri/target", "clean": "rm -rf dist node_modules frontend/node_modules mobile/node_modules desktop/node_modules backend/vendor desktop/dist desktop/src-tauri/target",
"postinstall": "patch-package" "postinstall": "patch-package || true"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^8.2.2", "concurrently": "^8.2.2",