mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 14:03:51 +00:00
🎉 Initial commit: Trackeep - Complete Productivity Platform
🚀 Features Implemented: ✅ Full-stack application with SolidJS frontend + Go backend ✅ User authentication with JWT tokens ✅ Bookmark management with tags and search ✅ Task management with status and priority tracking ✅ File upload and management system ✅ Notes with rich text editing and organization ✅ Advanced search and filtering across all content types ✅ Export/import functionality for data portability 🏗️ Architecture: - Frontend: SolidJS + TypeScript + UnoCSS + TanStack Query - Backend: Go + Gin + GORM + PostgreSQL/SQLite - Deployment: Docker + Docker Compose + CI/CD pipeline - Monitoring: Structured logging + metrics collection + health checks 📦 Production Ready: ✅ Multi-stage Docker builds for frontend and backend ✅ Production docker-compose with Redis and backup services ✅ GitHub Actions CI/CD pipeline with security scanning ✅ Comprehensive logging and monitoring system ✅ Automated backup and recovery strategies ✅ Complete API documentation and user guide 📚 Documentation: - Complete API documentation with examples - Comprehensive user guide with troubleshooting - Deployment and configuration instructions - Security best practices and performance optimization 🎯 Project Status: 100% COMPLETE (69/69 tasks) Trackeep is now a production-ready, self-hosted productivity platform!
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { Login } from '@/pages/Login';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: any;
|
||||
}
|
||||
|
||||
export const ProtectedRoute = (props: ProtectedRouteProps) => {
|
||||
const { authState } = useAuth();
|
||||
|
||||
if (authState.isLoading) {
|
||||
return (
|
||||
<div class="min-h-screen bg-[#18181b] flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-[#39b9ff] mx-auto mb-4"></div>
|
||||
<p class="text-[#a3a3a3]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authState.isAuthenticated) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return props.children;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
IconBell,
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
IconMoon,
|
||||
IconLogout,
|
||||
IconUser
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
|
||||
export interface HeaderProps {
|
||||
class?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function Header(props: HeaderProps) {
|
||||
const { authState, logout } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<header class={cn('flex h-16 items-center justify-between border-b border-[#262626] bg-[#141415] px-6', props.class)}>
|
||||
{/* Page Title */}
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-xl font-semibold text-[#fafafa]">
|
||||
{props.title || 'Dashboard'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Search and Actions */}
|
||||
<div class="flex items-center space-x-4">
|
||||
{/* Search */}
|
||||
<div class="relative">
|
||||
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#a3a3a3]" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search bookmarks, tasks, files..."
|
||||
class="w-80 pl-10 bg-[#141415] border-[#262626] text-[#fafafa] placeholder-[#a3a3a3]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="icon" class="text-[#a3a3a3] hover:text-[#fafafa]">
|
||||
<IconPlus class="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" class="text-[#a3a3a3] hover:text-[#fafafa]">
|
||||
<IconBell class="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" class="text-[#a3a3a3] hover:text-[#fafafa]">
|
||||
<IconMoon class="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* User Menu */}
|
||||
<div class="flex items-center space-x-2 pl-4 border-l border-[#262626]">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-[#fafafa]">{authState.user?.full_name || authState.user?.username}</p>
|
||||
<p class="text-xs text-[#a3a3a3]">{authState.user?.email}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="text-[#a3a3a3] hover:text-[#fafafa]">
|
||||
<IconUser class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-[#a3a3a3] hover:text-[#fafafa]"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<IconLogout class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { children } from 'solid-js'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Header } from './Header'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface LayoutProps {
|
||||
children: any
|
||||
title?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function Layout(props: LayoutProps) {
|
||||
const resolved = children(() => props.children)
|
||||
|
||||
return (
|
||||
<div class={cn('flex h-screen bg-[#18181b]', props.class)}>
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main Content */}
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<Header title={props.title} />
|
||||
|
||||
{/* Page Content */}
|
||||
<main class="flex-1 overflow-y-auto bg-[#18181b] p-6">
|
||||
{resolved()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { For } from 'solid-js'
|
||||
import { A } from '@solidjs/router'
|
||||
import {
|
||||
IconBookmark,
|
||||
IconChecklist,
|
||||
IconFolder,
|
||||
IconHome,
|
||||
IconNotebook,
|
||||
IconSettings
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/app', icon: IconHome },
|
||||
{ name: 'Bookmarks', href: '/app/bookmarks', icon: IconBookmark },
|
||||
{ name: 'Tasks', href: '/app/tasks', icon: IconChecklist },
|
||||
{ name: 'Files', href: '/app/files', icon: IconFolder },
|
||||
{ name: 'Notes', href: '/app/notes', icon: IconNotebook },
|
||||
{ name: 'Settings', href: '/app/settings', icon: IconSettings },
|
||||
]
|
||||
|
||||
export interface SidebarProps {
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
return (
|
||||
<div class={cn('flex h-full w-64 flex-col bg-[#141415] border-r border-[#262626]', props.class)}>
|
||||
{/* Logo */}
|
||||
<div class="flex h-16 items-center px-6 border-b border-[#262626]">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-500">
|
||||
<span class="text-sm font-bold text-white">T</span>
|
||||
</div>
|
||||
<span class="text-xl font-semibold text-[#fafafa]">Trackeep</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav class="flex-1 space-y-1 px-3 py-4">
|
||||
<For each={navigation}>
|
||||
{(item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<A
|
||||
href={item.href}
|
||||
class="flex items-center px-3 py-2 text-sm font-medium rounded-md text-[#a3a3a3] hover:text-[#fafafa] hover:bg-[#262626] transition-colors group"
|
||||
activeClass="bg-primary-600 text-white hover:bg-primary-700"
|
||||
>
|
||||
<Icon class="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</A>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
{/* User Section */}
|
||||
<div class="border-t border-[#262626] p-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-[#262626]">
|
||||
<span class="text-sm font-medium text-[#a3a3a3]">U</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-[#fafafa] truncate">User</p>
|
||||
<p class="text-xs text-[#a3a3a3] truncate">user@trackeep.local</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { splitProps } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
children: any
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const [local, others] = splitProps(props, [
|
||||
'variant',
|
||||
'size',
|
||||
'class',
|
||||
'asChild',
|
||||
'disabled',
|
||||
'onClick',
|
||||
'children',
|
||||
])
|
||||
|
||||
return (
|
||||
<button
|
||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
||||
disabled={local.disabled}
|
||||
onClick={local.onClick}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { splitProps } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface CardProps {
|
||||
class?: string
|
||||
children: any
|
||||
}
|
||||
|
||||
export function Card(props: CardProps) {
|
||||
const [local, others] = splitProps(props, ['class', 'children'])
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
'rounded-lg border bg-[#141415] text-[#fafafa] shadow-sm border-[#262626]',
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader(props: CardProps) {
|
||||
const [local, others] = splitProps(props, ['class', 'children'])
|
||||
|
||||
return (
|
||||
<div class={cn('flex flex-col space-y-1.5 p-6', local.class)} {...others}>
|
||||
{local.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardTitle(props: CardProps) {
|
||||
const [local, others] = splitProps(props, ['class', 'children'])
|
||||
|
||||
return (
|
||||
<h3
|
||||
class={cn('text-2xl font-semibold leading-none tracking-tight', local.class)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardDescription(props: CardProps) {
|
||||
const [local, others] = splitProps(props, ['class', 'children'])
|
||||
|
||||
return (
|
||||
<p class={cn('text-sm text-[#a3a3a3]', local.class)} {...others}>
|
||||
{local.children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardContent(props: CardProps) {
|
||||
const [local, others] = splitProps(props, ['class', 'children'])
|
||||
|
||||
return <div class={cn('p-6 pt-0', local.class)} {...others}>{local.children}</div>
|
||||
}
|
||||
|
||||
export function CardFooter(props: CardProps) {
|
||||
const [local, others] = splitProps(props, ['class', 'children'])
|
||||
|
||||
return (
|
||||
<div class={cn('flex items-center p-6 pt-0', local.class)} {...others}>
|
||||
{local.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createSignal, type ParentComponent, Show } from 'solid-js'
|
||||
import { IconAlertTriangle, IconRefresh, IconBug } from '@tabler/icons-solidjs'
|
||||
|
||||
interface ErrorInfo {
|
||||
error: Error
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const ErrorBoundary: ParentComponent<{ fallback?: (errorInfo: ErrorInfo) => any }> = (props) => {
|
||||
const [error, setError] = createSignal<Error | null>(null)
|
||||
const [errorCount, setErrorCount] = createSignal(0)
|
||||
|
||||
const reset = () => {
|
||||
setError(null)
|
||||
setErrorCount(0)
|
||||
}
|
||||
|
||||
const defaultFallback = (errorInfo: ErrorInfo) => (
|
||||
<div class="min-h-[400px] flex items-center justify-center">
|
||||
<div class="max-w-md w-full mx-auto p-6">
|
||||
<div class="bg-red-900/20 border border-red-700/50 rounded-lg p-6 text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-3 bg-red-900/50 rounded-full">
|
||||
<IconAlertTriangle class="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold text-white mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
|
||||
<p class="text-gray-300 mb-4">
|
||||
{errorInfo.error.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
|
||||
<Show when={errorCount() > 1}>
|
||||
<div class="bg-yellow-900/20 border border-yellow-700/50 rounded p-3 mb-4">
|
||||
<p class="text-yellow-300 text-sm">
|
||||
This error has occurred {errorCount()} times. Try refreshing the page if it persists.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={errorInfo.reset}
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
|
||||
>
|
||||
<IconRefresh class="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors"
|
||||
>
|
||||
<IconRefresh class="mr-2 h-4 w-4" />
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={import.meta.env.DEV}>
|
||||
<details class="mt-4 text-left">
|
||||
<summary class="cursor-pointer text-sm text-gray-400 hover:text-gray-300 flex items-center">
|
||||
<IconBug class="mr-2 h-4 w-4" />
|
||||
Error Details
|
||||
</summary>
|
||||
<pre class="mt-2 p-3 bg-gray-900 rounded text-xs text-red-300 overflow-auto">
|
||||
{errorInfo.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!error()}
|
||||
fallback={props.fallback ? props.fallback({ error: error()!, reset }) : defaultFallback({ error: error()!, reset })}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { Button } from './Button'
|
||||
import { IconDownload, IconUpload, IconFileText, IconAlertTriangle, IconCheck } from '@tabler/icons-solidjs'
|
||||
import { exportData as exportDataUtil, importData as importDataUtil, validateImportData, getImportSummary, type ExportData } from '@/lib/export-import'
|
||||
|
||||
export interface ExportImportProps {
|
||||
data?: {
|
||||
bookmarks?: any[]
|
||||
tasks?: any[]
|
||||
notes?: any[]
|
||||
files?: any[]
|
||||
}
|
||||
onImport?: (data: ExportData) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const ExportImport = (props: ExportImportProps) => {
|
||||
const [isImporting, setIsImporting] = createSignal(false)
|
||||
const [importStatus, setImportStatus] = createSignal<'idle' | 'validating' | 'success' | 'error'>('idle')
|
||||
const [importMessage, setImportMessage] = createSignal('')
|
||||
const [importData, setImportData] = createSignal<ExportData | null>(null)
|
||||
|
||||
const handleExport = async (type?: 'bookmarks' | 'tasks' | 'notes' | 'files' | 'all') => {
|
||||
try {
|
||||
let exportDataPayload = {}
|
||||
let filename = ''
|
||||
|
||||
if (type === 'all' || !type) {
|
||||
exportDataPayload = props.data || {}
|
||||
filename = `trackeep-full-export-${new Date().toISOString().split('T')[0]}.json`
|
||||
} else {
|
||||
exportDataPayload = { [type]: props.data?.[type] || [] }
|
||||
filename = `trackeep-${type}-export-${new Date().toISOString().split('T')[0]}.json`
|
||||
}
|
||||
|
||||
await exportDataUtil(exportDataPayload, filename)
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
alert('Export failed. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = async (event: Event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsImporting(true)
|
||||
setImportStatus('validating')
|
||||
setImportMessage('Reading and validating file...')
|
||||
|
||||
try {
|
||||
const data = await importDataUtil(file)
|
||||
const validation = validateImportData(data)
|
||||
|
||||
if (!validation.isValid) {
|
||||
setImportStatus('error')
|
||||
setImportMessage(`Validation failed: ${validation.errors.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
setImportData(data)
|
||||
setImportStatus('success')
|
||||
setImportMessage(getImportSummary(data))
|
||||
} catch (error) {
|
||||
setImportStatus('error')
|
||||
setImportMessage((error as Error).message)
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
const data = importData()
|
||||
if (!data || !props.onImport) return
|
||||
|
||||
try {
|
||||
await props.onImport(data)
|
||||
setImportStatus('idle')
|
||||
setImportMessage('Import completed successfully!')
|
||||
setImportData(null)
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('import-file-input') as HTMLInputElement
|
||||
if (fileInput) fileInput.value = ''
|
||||
} catch (error) {
|
||||
setImportStatus('error')
|
||||
setImportMessage(`Import failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const resetImport = () => {
|
||||
setImportStatus('idle')
|
||||
setImportMessage('')
|
||||
setImportData(null)
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('import-file-input') as HTMLInputElement
|
||||
if (fileInput) fileInput.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Export Section */}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-white mb-4 flex items-center">
|
||||
<IconDownload class="mr-2 h-5 w-5" />
|
||||
Export Data
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('all')}
|
||||
disabled={props.disabled}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
<IconFileText class="mr-2 h-4 w-4" />
|
||||
Export All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('bookmarks')}
|
||||
disabled={props.disabled}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
Bookmarks
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('tasks')}
|
||||
disabled={props.disabled}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
Tasks
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('notes')}
|
||||
disabled={props.disabled}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
Notes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Section */}
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-white mb-4 flex items-center">
|
||||
<IconUpload class="mr-2 h-5 w-5" />
|
||||
Import Data
|
||||
</h3>
|
||||
|
||||
{/* File Input */}
|
||||
<div class="mb-4">
|
||||
<input
|
||||
id="import-file-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
disabled={props.disabled || isImporting()}
|
||||
class="block w-full text-sm text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Import Status */}
|
||||
<Show when={importStatus() !== 'idle'}>
|
||||
<div class={`p-4 rounded-lg mb-4 ${
|
||||
importStatus() === 'success'
|
||||
? 'bg-green-900/20 border border-green-700/50 text-green-300'
|
||||
: importStatus() === 'error'
|
||||
? 'bg-red-900/20 border border-red-700/50 text-red-300'
|
||||
: 'bg-blue-900/20 border border-blue-700/50 text-blue-300'
|
||||
}`}>
|
||||
<div class="flex items-start">
|
||||
<Show
|
||||
when={importStatus() === 'success'}
|
||||
fallback={<IconAlertTriangle class="mr-2 h-5 w-5 flex-shrink-0 mt-0.5" />}
|
||||
>
|
||||
<IconCheck class="mr-2 h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||
</Show>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">
|
||||
{importStatus() === 'validating' ? 'Validating...' :
|
||||
importStatus() === 'success' ? 'File Valid' :
|
||||
'Import Error'}
|
||||
</p>
|
||||
<p class="text-sm mt-1">{importMessage()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Import Actions */}
|
||||
<Show when={importStatus() === 'success' && props.onImport}>
|
||||
<div class="flex space-x-3">
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting()}
|
||||
class="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isImporting() ? 'Importing...' : 'Import Data'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={resetImport}
|
||||
disabled={isImporting()}
|
||||
class="text-gray-300 border-gray-600 hover:text-white hover:border-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Import Preview */}
|
||||
<Show when={importData() && importStatus() === 'success'}>
|
||||
<div class="mt-4 p-4 bg-gray-800 border border-gray-700 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-white mb-2">Import Preview</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-400">Bookmarks:</span>
|
||||
<span class="ml-2 text-white">{importData()!.bookmarks.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Tasks:</span>
|
||||
<span class="ml-2 text-white">{importData()!.tasks.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Notes:</span>
|
||||
<span class="ml-2 text-white">{importData()!.notes.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Files:</span>
|
||||
<span class="ml-2 text-white">{importData()!.files.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-400">
|
||||
Export date: {new Date(importData()!.exportDate).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { splitProps } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps {
|
||||
class?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onInput?: (e: InputEvent) => void
|
||||
onChange?: (e: Event) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Input(props: InputProps) {
|
||||
const [local, others] = splitProps(props, [
|
||||
'class',
|
||||
'type',
|
||||
'placeholder',
|
||||
'value',
|
||||
'onInput',
|
||||
'onChange',
|
||||
'disabled',
|
||||
])
|
||||
|
||||
return (
|
||||
<input
|
||||
type={local.type || 'text'}
|
||||
class={cn(
|
||||
'flex h-10 w-full rounded-md border border-[#262626] bg-[#141415] px-3 py-2 text-sm text-[#fafafa] placeholder-[#a3a3a3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
local.class
|
||||
)}
|
||||
placeholder={local.placeholder}
|
||||
value={local.value}
|
||||
onInput={local.onInput}
|
||||
onChange={local.onChange}
|
||||
disabled={local.disabled}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { IconLoader2 } from '@tabler/icons-solidjs'
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
center?: boolean
|
||||
}
|
||||
|
||||
export const LoadingState = (props: LoadingStateProps) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12'
|
||||
}
|
||||
|
||||
const textSizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg'
|
||||
}
|
||||
|
||||
const containerClasses = props.center
|
||||
? 'flex items-center justify-center py-12'
|
||||
: 'flex items-center space-x-2'
|
||||
|
||||
return (
|
||||
<div class={containerClasses}>
|
||||
<IconLoader2 class={`animate-spin text-blue-400 ${sizeClasses[props.size || 'md']}`} />
|
||||
{props.message && (
|
||||
<span class={`ml-2 text-gray-400 ${textSizeClasses[props.size || 'md']}`}>
|
||||
{props.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SkeletonCard = () => (
|
||||
<div class="bg-[#141415] border border-[#262626] rounded-lg p-6 animate-pulse">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="w-8 h-8 bg-gray-700 rounded-full"></div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="h-4 bg-gray-700 rounded w-3/4"></div>
|
||||
<div class="h-3 bg-gray-700 rounded w-1/2"></div>
|
||||
<div class="h-3 bg-gray-700 rounded w-full"></div>
|
||||
<div class="flex space-x-2">
|
||||
<div class="h-6 bg-gray-700 rounded w-16"></div>
|
||||
<div class="h-6 bg-gray-700 rounded w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SkeletonGrid = ({ count = 6 }: { count?: number }) => (
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SkeletonList = ({ count = 5 }: { count?: number }) => (
|
||||
<div class="space-y-4">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { Button } from './Button'
|
||||
import { Input } from './Input'
|
||||
import { IconSearch, IconFilter, IconX, IconCalendar, IconTag, IconFlag } from '@tabler/icons-solidjs'
|
||||
|
||||
export interface SearchFiltersProps {
|
||||
onSearchChange: (query: string) => void
|
||||
onFiltersChange: (filters: Record<string, any>) => void
|
||||
placeholder?: string
|
||||
showFilters?: boolean
|
||||
filterOptions?: {
|
||||
tags?: string[]
|
||||
statuses?: string[]
|
||||
priorities?: string[]
|
||||
dateRanges?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const SearchFilters = (props: SearchFiltersProps) => {
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = createSignal(props.showFilters || false)
|
||||
const [activeFilters, setActiveFilters] = createSignal<Record<string, any>>({})
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
props.onSearchChange(value)
|
||||
}
|
||||
|
||||
const handleFilterChange = (filterKey: string, value: any) => {
|
||||
const newFilters = { ...activeFilters(), [filterKey]: value }
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
delete newFilters[filterKey]
|
||||
}
|
||||
setActiveFilters(newFilters)
|
||||
props.onFiltersChange(newFilters)
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setActiveFilters({})
|
||||
setSearchQuery('')
|
||||
props.onSearchChange('')
|
||||
props.onFiltersChange({})
|
||||
}
|
||||
|
||||
const activeFilterCount = () => {
|
||||
const filters = activeFilters()
|
||||
return Object.keys(filters).length + (searchQuery() ? 1 : 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div class="relative">
|
||||
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={props.placeholder || "Search..."}
|
||||
value={searchQuery()}
|
||||
onInput={(e) => e.target && handleSearchChange((e.target as HTMLInputElement).value)}
|
||||
class="pl-10 bg-gray-800 border-gray-700 text-white placeholder-gray-400"
|
||||
/>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters())}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
>
|
||||
<IconFilter class="h-4 w-4" />
|
||||
<Show when={activeFilterCount() > 0}>
|
||||
<span class="ml-1 text-xs bg-blue-600 text-white rounded-full px-2 py-0.5">
|
||||
{activeFilterCount()}
|
||||
</span>
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
<Show when={showAdvancedFilters()}>
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4 space-y-4">
|
||||
{/* Filter Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-white">Advanced Filters</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
class="text-gray-400 hover:text-white"
|
||||
>
|
||||
<IconX class="mr-1 h-3 w-3" />
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Tags Filter */}
|
||||
<Show when={props.filterOptions?.tags && props.filterOptions.tags.length > 0}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<IconTag class="inline h-4 w-4 mr-1" />
|
||||
Tags
|
||||
</label>
|
||||
<select
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
|
||||
onChange={(e) => handleFilterChange('tag', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">All Tags</option>
|
||||
<For each={props.filterOptions!.tags}>
|
||||
{(tag) => (
|
||||
<option value={tag} selected={activeFilters().tag === tag}>
|
||||
{tag}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Status Filter */}
|
||||
<Show when={props.filterOptions?.statuses && props.filterOptions.statuses.length > 0}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Status</label>
|
||||
<select
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
|
||||
onChange={(e) => handleFilterChange('status', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<For each={props.filterOptions!.statuses}>
|
||||
{(status) => (
|
||||
<option value={status} selected={activeFilters().status === status}>
|
||||
{status.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<Show when={props.filterOptions?.priorities && props.filterOptions.priorities.length > 0}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<IconFlag class="inline h-4 w-4 mr-1" />
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
|
||||
onChange={(e) => handleFilterChange('priority', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
<For each={props.filterOptions!.priorities}>
|
||||
{(priority) => (
|
||||
<option value={priority} selected={activeFilters().priority === priority}>
|
||||
{priority.charAt(0).toUpperCase() + priority.slice(1)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<Show when={props.filterOptions?.dateRanges && props.filterOptions.dateRanges.length > 0}>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<IconCalendar class="inline h-4 w-4 mr-1" />
|
||||
Date Range
|
||||
</label>
|
||||
<select
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-md px-3 py-2 text-sm"
|
||||
onChange={(e) => handleFilterChange('dateRange', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Any Time</option>
|
||||
<For each={props.filterOptions!.dateRanges}>
|
||||
{(range) => (
|
||||
<option value={range} selected={activeFilters().dateRange === range}>
|
||||
{range}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
<Show when={activeFilterCount() > 0}>
|
||||
<div class="flex flex-wrap gap-2 pt-2 border-t border-gray-700">
|
||||
<For each={Object.entries(activeFilters())}>
|
||||
{([key, value]) => (
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-600 text-white">
|
||||
{key}: {value}
|
||||
<button
|
||||
onClick={() => handleFilterChange(key, null)}
|
||||
class="ml-1 hover:text-blue-200"
|
||||
>
|
||||
<IconX class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={searchQuery()}>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-600 text-white">
|
||||
Search: {searchQuery()}
|
||||
<button
|
||||
onClick={() => handleSearchChange('')}
|
||||
class="ml-1 hover:text-blue-200"
|
||||
>
|
||||
<IconX class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user