first test
@@ -1,8 +1,54 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Improve white mode styling */
|
||||
:root {
|
||||
/* Better contrast for light mode */
|
||||
--background: 250 250 250;
|
||||
--foreground: 15 23 42;
|
||||
--card: 255 255 255;
|
||||
--card-foreground: 15 23 42;
|
||||
--popover: 255 255 255;
|
||||
--popover-foreground: 15 23 42;
|
||||
--primary-foreground: 0 0 0;
|
||||
--secondary: 241 245 249;
|
||||
--secondary-foreground: 71 85 105;
|
||||
--muted: 241 245 249;
|
||||
--muted-foreground: 100 116 139;
|
||||
--accent: 241 245 249;
|
||||
--accent-foreground: 71 85 105;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 226 232 240;
|
||||
--input: 226 232 240;
|
||||
--ring: 217 70.2% 91.2%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
[data-kb-theme="dark"] {
|
||||
--background: 26 26 26;
|
||||
--foreground: 250 250 250;
|
||||
--card: 32 32 32;
|
||||
--card-foreground: 250 250 250;
|
||||
--popover: 32 32 32;
|
||||
--popover-foreground: 250 250 250;
|
||||
--primary-foreground: 250 250 250;
|
||||
--secondary: 39 39 42;
|
||||
--secondary-foreground: 250 250 250;
|
||||
--muted: 39 39 42;
|
||||
--muted-foreground: 163 163 163;
|
||||
--accent: 39 39 42;
|
||||
--accent-foreground: 250 250 250;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 250 250 250;
|
||||
--border: 39 39 42;
|
||||
--input: 39 39 42;
|
||||
--ring: 217 70.2% 91.2%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -11,11 +57,13 @@
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
filter: drop-shadow(0 0 2em hsl(var(--primary) / 0.3));
|
||||
}
|
||||
|
||||
.logo.solid:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
filter: drop-shadow(0 0 2em hsl(var(--primary) / 0.3));
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -23,5 +71,135 @@
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Better light mode text contrast */
|
||||
[data-kb-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Improve button styling in light mode */
|
||||
[data-kb-theme="light"] button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Improve input styling in light mode */
|
||||
[data-kb-theme="light"] input,
|
||||
[data-kb-theme="light"] textarea,
|
||||
[data-kb-theme="light"] select {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Better border visibility in light mode */
|
||||
[data-kb-theme="light"] .border {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Enhanced bookmark preview styles */
|
||||
.page-preview {
|
||||
max-width: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.page-preview .preview-header {
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.page-preview .favicon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.page-preview .preview-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.page-preview .preview-url {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.page-preview .preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-preview .preview-meta {
|
||||
background: hsl(var(--muted));
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.page-preview .preview-meta p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.page-preview .preview-meta strong {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-preview .preview-actions {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.page-preview .visit-site {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
text-decoration: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.page-preview .visit-site:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.page-preview.error {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-preview.error .error-message {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-preview.error .error-message h3 {
|
||||
color: hsl(var(--destructive));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.page-preview.error .error-message p {
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,68 @@ import { Router, Route } from '@solidjs/router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { ToastContainer } from '@/components/ui/Toast'
|
||||
import { Dashboard } from '@/pages/Dashboard'
|
||||
import { Bookmarks } from '@/pages/Bookmarks'
|
||||
import { Tasks } from '@/pages/Tasks'
|
||||
import { Files } from '@/pages/Files'
|
||||
import { Notes } from '@/pages/Notes'
|
||||
import { AIChat } from '@/pages/AIChat'
|
||||
import { Settings } from '@/pages/Settings'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { Youtube } from '@/pages/Youtube'
|
||||
import { Members } from '@/pages/Members'
|
||||
import { RemovedStuff } from '@/pages/RemovedStuff'
|
||||
import { AdminSettings } from '@/pages/AdminSettings'
|
||||
import { ColorSwitcher } from '@/pages/ColorSwitcher'
|
||||
import { AdminDashboard } from '@/pages/AdminDashboard'
|
||||
import { Stats } from '@/pages/Stats'
|
||||
import { Profile } from '@/pages/Profile'
|
||||
import { LearningPaths } from '@/pages/LearningPaths'
|
||||
import { GitHub } from '@/pages/GitHub'
|
||||
import { TimeTracking } from '@/pages/TimeTracking'
|
||||
import { Calendar } from '@/pages/Calendar'
|
||||
import { AuthCallback } from '@/pages/AuthCallback'
|
||||
import { AuthProvider } from '@/lib/auth'
|
||||
import { Search } from '@/pages/Search'
|
||||
import { Analytics } from '@/pages/Analytics'
|
||||
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
|
||||
import { onMount } from 'solid-js'
|
||||
|
||||
// Initialize dark mode immediately before anything else
|
||||
const initializeDarkMode = () => {
|
||||
// Check if user has a saved theme preference
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const user = localStorage.getItem('user') || localStorage.getItem('trackeep_user');
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
const userData = JSON.parse(user);
|
||||
// Prefer user's saved theme from profile, fallback to localStorage
|
||||
const userTheme = userData.theme || savedTheme;
|
||||
if (userTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to localStorage or dark mode if user data is invalid
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
}
|
||||
}
|
||||
} else if (savedTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
// Default to dark mode
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize dark mode immediately
|
||||
initializeDarkMode();
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
@@ -22,23 +76,182 @@ const queryClient = new QueryClient({
|
||||
})
|
||||
|
||||
function App() {
|
||||
// Initialize demo mode API interceptor and cleanup old demo data
|
||||
onMount(() => {
|
||||
// Clear demo mode if it's disabled in environment
|
||||
if (!isEnvDemoMode()) {
|
||||
clearDemoMode();
|
||||
}
|
||||
|
||||
initializeDemoMode();
|
||||
// Ensure dark mode is set after component mount
|
||||
initializeDarkMode();
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Route path="/" component={Login} />
|
||||
<Route path="/" component={() => {
|
||||
// Always show login page, demo mode will be handled there
|
||||
return <Login />;
|
||||
}} />
|
||||
<Route path="/login" component={Login} />
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Route path="/app" component={Dashboard} />
|
||||
<Route path="/app/bookmarks" component={Bookmarks} />
|
||||
<Route path="/app/tasks" component={Tasks} />
|
||||
<Route path="/app/files" component={Files} />
|
||||
<Route path="/app/notes" component={Notes} />
|
||||
<Route path="/app/settings" component={Settings} />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
<Route path="/auth/callback" component={AuthCallback} />
|
||||
<Route path="/app" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Dashboard">
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/bookmarks" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Bookmarks">
|
||||
<Bookmarks />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/tasks" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Tasks">
|
||||
<Tasks />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/time-tracking" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Time Tracking">
|
||||
<TimeTracking />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/calendar" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Calendar">
|
||||
<Calendar />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/search" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Enhanced Search">
|
||||
<Search />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/files" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Files">
|
||||
<Files />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/notes" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Notes">
|
||||
<Notes />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/youtube" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="YouTube">
|
||||
<Youtube />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/chat" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="AI Chat" fullBleed>
|
||||
<AIChat />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/members" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Members">
|
||||
<Members />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/removed-stuff" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Removed Stuff">
|
||||
<RemovedStuff />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/admin-settings" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Admin Settings">
|
||||
<AdminSettings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/settings" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Settings">
|
||||
<Settings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/color-switcher" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Color Switcher">
|
||||
<ColorSwitcher />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/stats" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Statistics">
|
||||
<Stats />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/analytics" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Analytics">
|
||||
<Analytics />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/profile" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Profile">
|
||||
<Profile />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/learning-paths" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Learning Paths">
|
||||
<LearningPaths />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/github" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="GitHub">
|
||||
<GitHub />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
<Route path="/app/activity" component={() => {
|
||||
// Redirect to stats since we're combining activity and stats
|
||||
window.location.href = '/app/stats';
|
||||
return null;
|
||||
}} />
|
||||
<Route path="/admin" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Admin Dashboard">
|
||||
<AdminDashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
</Router>
|
||||
<ToastContainer />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path fill="#fff" d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>
|
||||
|
After Width: | Height: | Size: 832 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>
|
||||
|
After Width: | Height: | Size: 655 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>
|
||||
|
After Width: | Height: | Size: 906 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,95 @@
|
||||
import { createMemo, Show } from 'solid-js';
|
||||
|
||||
interface AIProviderIconProps {
|
||||
providerId: string;
|
||||
size?: string;
|
||||
class?: string;
|
||||
white?: boolean;
|
||||
}
|
||||
|
||||
const inlineSVGs: Record<string, string> = {
|
||||
openrouter: '<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>',
|
||||
ollama: '<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.38-1.416.507-2.3.381a3.822 3.822 0 01-1.416-.507c-.727-.442-1.133-1.134-1.133-2.022 0-.705.375-1.413 1.005-1.94.646-.542 1.51-.855 2.446-.855z"></path></svg>',
|
||||
grok: '<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>',
|
||||
};
|
||||
|
||||
const iconPaths: Record<string, string> = {
|
||||
mistral: '/assets/mistral-color.svg',
|
||||
longcat: '/assets/longcat-color.svg',
|
||||
deepseek: '/assets/deepseek-color.svg',
|
||||
};
|
||||
|
||||
const fallbackIcons: Record<string, string> = {
|
||||
mistral: '🇪🇺',
|
||||
longcat: '🐱',
|
||||
grok: '🐦',
|
||||
deepseek: '🔍',
|
||||
ollama: '🦙',
|
||||
openrouter: '🌀',
|
||||
};
|
||||
|
||||
export function AIProviderIcon(props: AIProviderIconProps) {
|
||||
const inlineSVG = createMemo(() => inlineSVGs[props.providerId]);
|
||||
const iconPath = createMemo(() => iconPaths[props.providerId]);
|
||||
const fallbackIcon = createMemo(() => fallbackIcons[props.providerId] || '🤖');
|
||||
|
||||
// Use inline SVG if available (for openrouter, ollama, grok)
|
||||
if (inlineSVG()) {
|
||||
return (
|
||||
<div
|
||||
class={props.class}
|
||||
style={{
|
||||
width: props.size || "1.5rem",
|
||||
height: props.size || "1.5rem",
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
color: props.white ? "white" : "currentColor"
|
||||
}}
|
||||
innerHTML={inlineSVG()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Use image for other providers
|
||||
return (
|
||||
<Show when={iconPath()} fallback={
|
||||
<span class={props.class} style={{
|
||||
"font-size": props.size || "1rem",
|
||||
color: props.white ? "white" : "currentColor"
|
||||
}}>
|
||||
{fallbackIcon()}
|
||||
</span>
|
||||
}>
|
||||
<img
|
||||
src={iconPath()}
|
||||
alt={`${props.providerId} icon`}
|
||||
class={props.class}
|
||||
style={{
|
||||
width: props.size || "1.5rem",
|
||||
height: props.size || "1.5rem",
|
||||
"object-fit": "contain",
|
||||
filter: props.white ? "brightness(0) invert(1)" : "none"
|
||||
}}
|
||||
onError={(e) => {
|
||||
// Fallback to emoji if SVG fails to load
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
if (target.nextElementSibling) {
|
||||
(target.nextElementSibling as HTMLElement).style.display = 'inline';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
class={props.class}
|
||||
style={{
|
||||
"font-size": props.size || "1.5rem",
|
||||
display: "none",
|
||||
color: props.white ? "white" : "currentColor"
|
||||
}}
|
||||
>
|
||||
{fallbackIcon()}
|
||||
</span>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
export const AuthenticationWarning = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = () => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleRegister = () => {
|
||||
navigate('/login?register=true');
|
||||
};
|
||||
|
||||
const handleDemoMode = () => {
|
||||
if (isDemoMode()) {
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-background flex items-center justify-center px-4 py-8 dark">
|
||||
<div class="w-full max-w-md">
|
||||
<Card class="p-8 border-border">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mb-6">
|
||||
<div class="inline-flex items-center justify-center mb-4">
|
||||
<img
|
||||
src="/trackeepfavi_bg.png"
|
||||
alt="Trackeep Logo"
|
||||
class="w-12 h-12 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold tracking-tight mb-2 text-foreground">Authentication Required</h1>
|
||||
<p class="text-muted-foreground">Please sign in to access Trackeep</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5">
|
||||
<svg fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-amber-800 dark:text-amber-200 mb-1">Authentication Required</h3>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||
You need to be authenticated to access this page. Please sign in or create an account to continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
class="w-full"
|
||||
size="lg"
|
||||
onClick={handleLogin}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
class="w-full"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleRegister}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
|
||||
{isDemoMode() && (
|
||||
<Button
|
||||
class="w-full"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={handleDemoMode}
|
||||
>
|
||||
🎭 Try Demo Mode
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,32 @@
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { Login } from '@/pages/Login';
|
||||
import { AuthenticationWarning } from '@/components/AuthenticationWarning';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: any;
|
||||
}
|
||||
|
||||
export const ProtectedRoute = (props: ProtectedRouteProps) => {
|
||||
// In demo mode, show UI immediately without any checks
|
||||
if (isDemoMode()) {
|
||||
console.log('[ProtectedRoute] Demo mode active - showing UI immediately');
|
||||
return props.children;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
console.log('[ProtectedRoute] Render:', {
|
||||
isDemoMode: isDemoMode(),
|
||||
isAuthenticated: authState.isAuthenticated,
|
||||
isLoading: authState.isLoading
|
||||
});
|
||||
|
||||
// If not authenticated, show authentication warning (no loading state)
|
||||
if (!authState.isAuthenticated) {
|
||||
return <Login />;
|
||||
console.log('[ProtectedRoute] Rendering authentication warning');
|
||||
return <AuthenticationWarning />;
|
||||
}
|
||||
|
||||
console.log('[ProtectedRoute] Rendering children');
|
||||
return props.children;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { createSignal, onMount, createEffect, For } from 'solid-js';
|
||||
import {
|
||||
IconClock,
|
||||
IconCalendar,
|
||||
IconCurrencyDollar,
|
||||
IconTrash,
|
||||
IconSquare
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { timeEntriesApi, demoTimeEntriesApi, type TimeEntry } from '../lib/api';
|
||||
import { isDemoMode } from '../lib/demo-mode';
|
||||
|
||||
interface TimeEntriesListProps {
|
||||
class?: string;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export const TimeEntriesList = (props: TimeEntriesListProps) => {
|
||||
const [timeEntries, setTimeEntries] = createSignal<TimeEntry[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
// Use appropriate API based on demo mode
|
||||
const getApi = () => isDemoMode() ? demoTimeEntriesApi : timeEntriesApi;
|
||||
|
||||
const loadTimeEntries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await getApi().getAll();
|
||||
|
||||
// Handle different response formats
|
||||
let entries: TimeEntry[] = [];
|
||||
if (response && response.time_entries) {
|
||||
entries = response.time_entries;
|
||||
} else if (response && Array.isArray(response)) {
|
||||
entries = response;
|
||||
} else {
|
||||
console.warn('Unexpected response format:', response);
|
||||
entries = [];
|
||||
}
|
||||
|
||||
setTimeEntries(entries);
|
||||
} catch (err) {
|
||||
console.error('Failed to load time entries:', err);
|
||||
setError('Failed to load time entries');
|
||||
setTimeEntries([]); // Ensure empty array on error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
loadTimeEntries();
|
||||
});
|
||||
|
||||
// Refresh when refreshTrigger changes
|
||||
createEffect(() => {
|
||||
if (props.refreshTrigger !== undefined) {
|
||||
loadTimeEntries();
|
||||
}
|
||||
});
|
||||
|
||||
const stopTimeEntry = async (id: number) => {
|
||||
try {
|
||||
await getApi().stop(id);
|
||||
loadTimeEntries();
|
||||
} catch (err) {
|
||||
console.error('Failed to stop time entry:', err);
|
||||
setError('Failed to stop time entry');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTimeEntry = async (id: number) => {
|
||||
try {
|
||||
await getApi().delete(id);
|
||||
loadTimeEntries();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete time entry:', err);
|
||||
setError('Failed to delete time entry');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={`border rounded-lg p-6 bg-card ${props.class || ''}`}>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-foreground">Time Entries</h2>
|
||||
<button
|
||||
onClick={loadTimeEntries}
|
||||
class="px-3 py-1 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading() && (
|
||||
<div class="text-center py-8">
|
||||
<div class="text-muted-foreground">Loading time entries...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error() && (
|
||||
<div class="text-center py-8">
|
||||
<div class="text-destructive">{error()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading() && !error() && (timeEntries() || []).length === 0 && (
|
||||
<div class="text-center py-8">
|
||||
<IconClock class="size-12 mx-auto text-muted-foreground mb-4" />
|
||||
<div class="text-muted-foreground">No time entries yet</div>
|
||||
<div class="text-sm text-muted-foreground mt-2">
|
||||
Start tracking your time to see entries here
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading() && !error() && (timeEntries() || []).length > 0 && (
|
||||
<div class="space-y-3">
|
||||
<For each={timeEntries() || []}>
|
||||
{(entry) => (
|
||||
<div class="border border-border/50 rounded-lg p-4 hover:bg-accent/30 transition-all duration-200 hover:shadow-sm">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="font-semibold text-foreground text-base">
|
||||
{entry.description}
|
||||
</h3>
|
||||
{entry.is_running && (
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-800">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-1.5 animate-pulse"></span>
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
{entry.billable && (
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
|
||||
<IconCurrencyDollar class="size-3 mr-1" />
|
||||
Billable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 text-sm text-muted-foreground mb-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<IconCalendar class="size-4 opacity-70" />
|
||||
<span>{formatDate(entry.start_time)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<IconClock class="size-4 opacity-70" />
|
||||
<span class="font-medium">{entry.duration ? formatDuration(entry.duration) : 'In progress'}</span>
|
||||
</div>
|
||||
{entry.hourly_rate && entry.billable && entry.duration && (
|
||||
<div class="flex items-center gap-1.5">
|
||||
<IconCurrencyDollar class="size-4 opacity-70" />
|
||||
<span class="font-semibold text-green-600 dark:text-green-400">
|
||||
${(entry.duration / 3600 * entry.hourly_rate).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2 mb-3 text-muted-foreground">
|
||||
<For each={entry.tags}>
|
||||
{(tag) => (
|
||||
<span class="inline-flex items-center gap-2 px-2.5 py-1 rounded-lg bg-muted text-xs">
|
||||
<span class="size-1.5 rounded-full bg-primary"></span>
|
||||
<span class="font-medium text-foreground">{tag}</span>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Associated Items */}
|
||||
{(entry.task || entry.bookmark || entry.note) && (
|
||||
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2 border border-border/50">
|
||||
<span class="font-medium mb-1 block">Linked items:</span>
|
||||
<div class="space-y-1">
|
||||
{entry.task && <div class="flex items-center gap-1"><span class="w-1 h-1 bg-muted-foreground rounded-full"></span>Task: {entry.task.title}</div>}
|
||||
{entry.bookmark && <div class="flex items-center gap-1"><span class="w-1 h-1 bg-muted-foreground rounded-full"></span>Bookmark: {entry.bookmark.title}</div>}
|
||||
{entry.note && <div class="flex items-center gap-1"><span class="w-1 h-1 bg-muted-foreground rounded-full"></span>Note: {entry.note.title}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex items-center gap-1 ml-4">
|
||||
{entry.is_running && (
|
||||
<button
|
||||
onClick={() => stopTimeEntry(entry.id)}
|
||||
class="p-2 rounded-md text-primary hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
title="Stop timer"
|
||||
>
|
||||
<IconSquare class="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteTimeEntry(entry.id)}
|
||||
class="p-2 rounded-md text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
title="Delete entry"
|
||||
>
|
||||
<IconTrash class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,467 @@
|
||||
import { createSignal, createEffect, onCleanup, onMount, Show } from 'solid-js';
|
||||
import {
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconSquare,
|
||||
IconClock,
|
||||
IconCurrencyDollar,
|
||||
IconLink
|
||||
} from '@tabler/icons-solidjs';
|
||||
import {
|
||||
timeEntriesApi,
|
||||
demoTimeEntriesApi,
|
||||
bookmarksApi,
|
||||
tasksApi,
|
||||
notesApi,
|
||||
type TimeEntry
|
||||
} from '../lib/api';
|
||||
import { TagPicker } from '@/components/ui/TagPicker';
|
||||
|
||||
interface TimerProps {
|
||||
onTimeEntryCreated?: (timeEntry: TimeEntry) => void;
|
||||
onTimerUpdate?: (entry: TimeEntry | null, elapsedSeconds: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Timer = (props: TimerProps) => {
|
||||
const [isRunning, setIsRunning] = createSignal(false);
|
||||
const [startTime, setStartTime] = createSignal<Date | null>(null);
|
||||
const [elapsedTime, setElapsedTime] = createSignal(0);
|
||||
const [description, setDescription] = createSignal('');
|
||||
const [selectedTaskId, setSelectedTaskId] = createSignal<number | undefined>();
|
||||
const [selectedBookmarkId, setSelectedBookmarkId] = createSignal<number | undefined>();
|
||||
const [selectedNoteId, setSelectedNoteId] = createSignal<number | undefined>();
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
const [billable, setBillable] = createSignal(false);
|
||||
const [hourlyRate, setHourlyRate] = createSignal('');
|
||||
const [currentTimeEntry, setCurrentTimeEntry] = createSignal<TimeEntry | null>(null);
|
||||
const [showSettings, setShowSettings] = createSignal(false);
|
||||
const [availableTags, setAvailableTags] = createSignal<string[]>([]);
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
// Use appropriate API based on demo mode
|
||||
const getApi = () => isDemoMode() ? demoTimeEntriesApi : timeEntriesApi;
|
||||
|
||||
let intervalRef: number | null = null;
|
||||
|
||||
// Update elapsed time every second when running
|
||||
createEffect(() => {
|
||||
if (isRunning() && startTime()) {
|
||||
intervalRef = setInterval(() => {
|
||||
const start = startTime();
|
||||
if (start) {
|
||||
const elapsed = Math.floor((new Date().getTime() - start.getTime()) / 1000);
|
||||
setElapsedTime(elapsed);
|
||||
// Send real-time updates to parent
|
||||
props.onTimerUpdate?.(currentTimeEntry(), elapsed);
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
if (intervalRef) {
|
||||
clearInterval(intervalRef);
|
||||
intervalRef = null;
|
||||
}
|
||||
// Send update when timer stops
|
||||
props.onTimerUpdate?.(null, 0);
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (intervalRef) {
|
||||
clearInterval(intervalRef);
|
||||
intervalRef = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Format time display
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Load available tags from bookmarks, tasks, and notes
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [bookmarksResponse, tasksResponse, notesResponse] = await Promise.all([
|
||||
bookmarksApi.getAll(),
|
||||
tasksApi.getAll(),
|
||||
notesApi.getAll()
|
||||
]);
|
||||
|
||||
const tagSet = new Set<string>();
|
||||
|
||||
const collectTags = (items: any[]) => {
|
||||
items.forEach((item) => {
|
||||
const rawTags = item?.tags || [];
|
||||
(rawTags as any[]).forEach((tag) => {
|
||||
if (!tag) return;
|
||||
if (typeof tag === 'string') {
|
||||
tagSet.add(tag);
|
||||
} else if (typeof tag.name === 'string') {
|
||||
tagSet.add(tag.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
collectTags(bookmarksResponse as any[]);
|
||||
collectTags(tasksResponse as any[]);
|
||||
collectTags(notesResponse as any[]);
|
||||
|
||||
setAvailableTags(Array.from(tagSet).sort());
|
||||
} catch (error) {
|
||||
console.error('Failed to load available tags for timer:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const allAvailableTags = () => {
|
||||
const set = new Set<string>(availableTags());
|
||||
tags().forEach((tag) => set.add(tag));
|
||||
return Array.from(set).sort();
|
||||
};
|
||||
|
||||
// Start timer
|
||||
const startTimer = async () => {
|
||||
try {
|
||||
// Allow starting timer without description - use default if empty
|
||||
const finalDescription = description().trim() || 'Untitled';
|
||||
|
||||
const response = await getApi().create({
|
||||
description: finalDescription,
|
||||
task_id: selectedTaskId(),
|
||||
bookmark_id: selectedBookmarkId(),
|
||||
note_id: selectedNoteId(),
|
||||
tags: tags(),
|
||||
billable: billable(),
|
||||
hourly_rate: hourlyRate() ? parseFloat(hourlyRate()) : undefined,
|
||||
source: 'manual'
|
||||
});
|
||||
|
||||
const newTimeEntry = response.time_entry;
|
||||
|
||||
setIsRunning(true);
|
||||
setStartTime(new Date());
|
||||
setElapsedTime(0);
|
||||
setCurrentTimeEntry(newTimeEntry);
|
||||
|
||||
props.onTimeEntryCreated?.(newTimeEntry);
|
||||
} catch (error) {
|
||||
console.error('Failed to start timer:', error);
|
||||
// Remove browser alert - just log the error
|
||||
}
|
||||
};
|
||||
|
||||
// Pause timer (local UI-only pause, backend entry keeps running until stopped)
|
||||
const pauseTimer = () => {
|
||||
const start = startTime();
|
||||
if (!start) return;
|
||||
|
||||
const now = new Date();
|
||||
const totalElapsed = Math.floor((now.getTime() - start.getTime()) / 1000);
|
||||
setElapsedTime(totalElapsed);
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
// Resume timer from paused state
|
||||
const resumeTimer = () => {
|
||||
const currentElapsed = elapsedTime();
|
||||
const now = new Date();
|
||||
const resumeStart = new Date(now.getTime() - currentElapsed * 1000);
|
||||
setStartTime(resumeStart);
|
||||
setIsRunning(true);
|
||||
};
|
||||
|
||||
// Stop timer
|
||||
const stopTimer = async () => {
|
||||
const entry = currentTimeEntry();
|
||||
if (!entry) return;
|
||||
|
||||
try {
|
||||
await getApi().stop(entry.id);
|
||||
|
||||
setIsRunning(false);
|
||||
setStartTime(null);
|
||||
setCurrentTimeEntry(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop timer:', error);
|
||||
// Remove browser alert - just log the error
|
||||
}
|
||||
};
|
||||
|
||||
// Discard timer
|
||||
const discardTimer = async () => {
|
||||
const entry = currentTimeEntry();
|
||||
if (!entry) return;
|
||||
|
||||
try {
|
||||
await getApi().delete(entry.id);
|
||||
|
||||
setIsRunning(false);
|
||||
setStartTime(null);
|
||||
setElapsedTime(0);
|
||||
setCurrentTimeEntry(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to discard timer:', error);
|
||||
// Remove browser alert - just log the error
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate current billable amount for running timer
|
||||
const getCurrentBillableAmount = (): number => {
|
||||
if (!billable() || !hourlyRate() || !isRunning()) {
|
||||
return 0;
|
||||
}
|
||||
const rate = parseFloat(hourlyRate());
|
||||
if (isNaN(rate) || rate <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (elapsedTime() / 3600) * rate;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number): string => {
|
||||
return `$${amount.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const hasActiveEntry = () => currentTimeEntry() !== null;
|
||||
|
||||
return (
|
||||
<div class={`border rounded-lg p-6 bg-card ${props.className || ''}`}>
|
||||
<div class="space-y-4">
|
||||
{/* Timer Display */}
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-mono font-bold text-foreground mb-2" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;">
|
||||
{formatTime(elapsedTime())}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mb-2">
|
||||
{hasActiveEntry() ? (isRunning() ? 'Running' : 'Paused') : 'Stopped'}
|
||||
</div>
|
||||
|
||||
{/* Real-time Billable Amount Display */}
|
||||
{hasActiveEntry() && billable() && hourlyRate() && (
|
||||
<div class="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div class="flex items-center justify-center gap-2 text-green-800 dark:text-green-200">
|
||||
<IconCurrencyDollar class="size-4" />
|
||||
<span class="text-sm font-medium">
|
||||
Current earnings: <strong>{formatAmount(getCurrentBillableAmount())}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
{hourlyRate()} USD/hour
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description Input */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="What are you working on? (optional)"
|
||||
value={description()}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isRunning()}
|
||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary bg-background text-foreground disabled:opacity-50"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
You can start the timer without entering a description
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div class="space-y-2">
|
||||
<TagPicker
|
||||
availableTags={allAvailableTags()}
|
||||
selectedTags={tags()}
|
||||
onTagsChange={(next) => {
|
||||
if (!hasActiveEntry()) {
|
||||
setTags(next);
|
||||
}
|
||||
}}
|
||||
placeholder="Add tags..."
|
||||
allowNew={true}
|
||||
/>
|
||||
<Show when={hasActiveEntry()}>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tags can be adjusted before starting the timer. Stop and edit the time entry if you need to change them later.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div class="flex gap-2">
|
||||
{!hasActiveEntry() ? (
|
||||
<button
|
||||
onClick={startTimer}
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<IconPlayerPlay class="size-4" />
|
||||
Start
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={isRunning() ? pauseTimer : resumeTimer}
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{isRunning() ? (
|
||||
<>
|
||||
<IconPlayerPause class="size-4" />
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconPlayerPlay class="size-4" />
|
||||
Resume
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={stopTimer}
|
||||
class="flex items-center justify-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90 transition-colors"
|
||||
>
|
||||
<IconSquare class="size-4" />
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
onClick={discardTimer}
|
||||
class="flex items-center justify-center gap-2 px-4 py-2 bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<IconSquare class="size-4" />
|
||||
Discard
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Billable Settings - Always Visible but Optional */}
|
||||
<div class="border-t border-border pt-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={billable()}
|
||||
onChange={(e) => setBillable(e.target.checked)}
|
||||
disabled={isRunning()}
|
||||
class="rounded border-border accent-primary"
|
||||
/>
|
||||
<span class="text-sm text-foreground">Mark as billable</span>
|
||||
</label>
|
||||
{billable() && (
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCurrencyDollar class="size-4 text-muted-foreground" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Hourly rate ($)"
|
||||
value={hourlyRate()}
|
||||
onChange={(e) => setHourlyRate(e.target.value)}
|
||||
disabled={isRunning()}
|
||||
class="w-28 px-3 py-1 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary bg-background text-foreground disabled:opacity-50 text-sm"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">USD/hour</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Optional: Mark time entries as billable for client invoicing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings Toggle for Advanced Options */}
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings())}
|
||||
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<IconClock class="size-4" />
|
||||
{showSettings() ? 'Hide' : 'Show'} Advanced Options
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
{showSettings() && (
|
||||
<div class="border-t border-border pt-4 space-y-4">
|
||||
{/* Associated Items */}
|
||||
<div class="text-sm">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<IconLink class="size-4 text-primary" />
|
||||
<span class="text-foreground font-medium">Link to existing items</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
Optional: Connect this time entry to tasks, bookmarks, or notes for better organization
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-foreground mb-1 text-xs font-medium">Related Task</label>
|
||||
<select
|
||||
value={selectedTaskId() || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedTaskId(value ? parseInt(value) : undefined);
|
||||
}}
|
||||
disabled={isRunning()}
|
||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary bg-background text-foreground disabled:opacity-50 text-sm"
|
||||
>
|
||||
<option value="">No task selected</option>
|
||||
<option value="1">Complete project documentation</option>
|
||||
<option value="2">Review pull requests</option>
|
||||
<option value="3">Setup CI/CD pipeline</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-foreground mb-1 text-xs font-medium">Related Bookmark</label>
|
||||
<select
|
||||
value={selectedBookmarkId() || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedBookmarkId(value ? parseInt(value) : undefined);
|
||||
}}
|
||||
disabled={isRunning()}
|
||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary bg-background text-foreground disabled:opacity-50 text-sm"
|
||||
>
|
||||
<option value="">No bookmark selected</option>
|
||||
<option value="1">SolidJS Documentation</option>
|
||||
<option value="2">TypeScript Handbook</option>
|
||||
<option value="3">Go Programming Language</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-foreground mb-1 text-xs font-medium">Related Note</label>
|
||||
<select
|
||||
value={selectedNoteId() || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedNoteId(value ? parseInt(value) : undefined);
|
||||
}}
|
||||
disabled={isRunning()}
|
||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary bg-background text-foreground disabled:opacity-50 text-sm"
|
||||
>
|
||||
<option value="">No note selected</option>
|
||||
<option value="1">Meeting notes - Q4 Planning</option>
|
||||
<option value="2">Project brainstorming</option>
|
||||
<option value="3">API documentation notes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,553 @@
|
||||
import { createSignal, onMount, Show } from 'solid-js';
|
||||
import { Button } from './ui/Button';
|
||||
import { Card } from './ui/Card';
|
||||
|
||||
interface TOTPSetupResponse {
|
||||
secret: string;
|
||||
qr_code: string;
|
||||
backup_codes: string[];
|
||||
}
|
||||
|
||||
interface TOTPStatus {
|
||||
enabled: boolean;
|
||||
setup: boolean;
|
||||
}
|
||||
|
||||
export function TwoFactorAuth() {
|
||||
const [totpStatus, setTotpStatus] = createSignal<TOTPStatus | null>(null);
|
||||
const [setupData, setSetupData] = createSignal<TOTPSetupResponse | null>(null);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [success, setSuccess] = createSignal<string | null>(null);
|
||||
|
||||
// Form states
|
||||
const [setupPassword, setSetupPassword] = createSignal('');
|
||||
const [verifyCode, setVerifyCode] = createSignal('');
|
||||
const [enableCode, setEnableCode] = createSignal('');
|
||||
const [disableCode, setDisableCode] = createSignal('');
|
||||
const [disablePassword, setDisablePassword] = createSignal('');
|
||||
const [backupCodeVerify, setBackupCodeVerify] = createSignal('');
|
||||
const [regenerateCode, setRegenerateCode] = createSignal('');
|
||||
|
||||
// UI states
|
||||
const [showSetup, setShowSetup] = createSignal(false);
|
||||
const [backupCodes, setBackupCodes] = createSignal<string[]>([]);
|
||||
|
||||
const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
};
|
||||
|
||||
const fetchTOTPStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/status`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTotpStatus(data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch 2FA status');
|
||||
}
|
||||
};
|
||||
|
||||
const setupTOTP = async () => {
|
||||
if (!setupPassword()) {
|
||||
setError('Password is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/setup`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
password: setupPassword(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSetupData(data);
|
||||
setBackupCodes(data.backup_codes);
|
||||
setShowSetup(true);
|
||||
setSuccess('TOTP setup initiated. Please scan the QR code and save your backup codes.');
|
||||
setSetupPassword('');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Failed to setup TOTP');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyTOTPCode = async () => {
|
||||
if (!verifyCode()) {
|
||||
setError('Verification code is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
code: verifyCode(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess('TOTP code verified successfully!');
|
||||
setVerifyCode('');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Invalid verification code');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enableTOTP = async () => {
|
||||
if (!enableCode()) {
|
||||
setError('Enable code is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/enable`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
code: enableCode(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess('Two-Factor Authentication enabled successfully!');
|
||||
setEnableCode('');
|
||||
setShowSetup(false);
|
||||
setSetupData(null);
|
||||
await fetchTOTPStatus();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Failed to enable TOTP');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableTOTP = async () => {
|
||||
if (!disableCode() || !disablePassword()) {
|
||||
setError('Both code and password are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/disable`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
code: disableCode(),
|
||||
password: disablePassword(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess('Two-Factor Authentication disabled successfully!');
|
||||
setDisableCode('');
|
||||
setDisablePassword('');
|
||||
await fetchTOTPStatus();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Failed to disable TOTP');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyBackupCode = async () => {
|
||||
if (!backupCodeVerify()) {
|
||||
setError('Backup code is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/backup-codes/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
code: backupCodeVerify(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSuccess(`Backup code verified! ${data.remaining_codes} codes remaining.`);
|
||||
setBackupCodeVerify('');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Invalid backup code');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateBackupCodes = async () => {
|
||||
if (!regenerateCode()) {
|
||||
setError('Current TOTP code is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/2fa/backup-codes/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
code: regenerateCode(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBackupCodes(data.backup_codes);
|
||||
setSuccess('Backup codes regenerated successfully!');
|
||||
setRegenerateCode('');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Failed to regenerate backup codes');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
fetchTOTPStatus();
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-white">Two-Factor Authentication</h2>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class={`w-3 h-3 rounded-full ${totpStatus()?.enabled ? 'bg-primary' : 'bg-muted'}`}></div>
|
||||
<span class="text-gray-300">
|
||||
{totpStatus()?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error and Success Messages */}
|
||||
<Show when={error()}>
|
||||
<div class="bg-destructive/15 border border-destructive/20 text-destructive px-4 py-3 rounded-lg">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={success()}>
|
||||
<div class="bg-primary/15 border border-primary/20 text-primary px-4 py-3 rounded-lg">
|
||||
{success()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Current Status */}
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Current Status</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-300">2FA Status:</span>
|
||||
<span class={`font-medium ${totpStatus()?.enabled ? 'text-primary' : 'text-muted-foreground'}`}>
|
||||
{totpStatus()?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-300">Setup Status:</span>
|
||||
<span class={`font-medium ${totpStatus()?.setup ? 'text-blue-400' : 'text-gray-400'}`}>
|
||||
{totpStatus()?.setup ? 'Configured' : 'Not Configured'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Setup TOTP */}
|
||||
<Show when={!totpStatus()?.enabled}>
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Setup Two-Factor Authentication</h3>
|
||||
<p class="text-gray-300 mb-4">
|
||||
Enable 2FA to add an extra layer of security to your account. You'll need a TOTP app like Google Authenticator or Authy.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupPassword()}
|
||||
onInput={(e) => setSetupPassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={setupTOTP}
|
||||
disabled={loading()}
|
||||
class="w-full"
|
||||
>
|
||||
{loading() ? 'Setting up...' : 'Setup 2FA'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* TOTP Setup Process */}
|
||||
<Show when={showSetup() && setupData()}>
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Complete 2FA Setup</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
{/* QR Code */}
|
||||
<div class="text-center">
|
||||
<h4 class="text-md font-medium text-gray-300 mb-3">Scan QR Code</h4>
|
||||
<img
|
||||
src={setupData()!.qr_code}
|
||||
alt="TOTP QR Code"
|
||||
class="mx-auto border-2 border-gray-600 rounded-lg"
|
||||
/>
|
||||
<p class="text-sm text-gray-400 mt-2">
|
||||
Or manually enter this secret in your TOTP app:
|
||||
</p>
|
||||
<code class="block bg-gray-800 px-3 py-2 rounded text-blue-400 break-all">
|
||||
{setupData()!.secret}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Backup Codes */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-300 mb-3">Backup Codes</h4>
|
||||
<p class="text-sm text-gray-400 mb-3">
|
||||
Save these backup codes in a secure location. You can use them to access your account if you lose your TOTP device.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{backupCodes().map((code) => (
|
||||
<code class="bg-gray-800 px-3 py-2 rounded text-gray-300 text-sm">
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-300 mb-3">Verify Setup</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Enter 6-digit code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode()}
|
||||
onInput={(e) => setVerifyCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))}
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="000000"
|
||||
maxlength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<Button
|
||||
onClick={verifyTOTPCode}
|
||||
disabled={loading() || verifyCode().length !== 6}
|
||||
class="flex-1"
|
||||
>
|
||||
{loading() ? 'Verifying...' : 'Verify Code'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={enableTOTP}
|
||||
disabled={loading() || verifyCode().length !== 6}
|
||||
variant="papra"
|
||||
class="flex-1"
|
||||
>
|
||||
{loading() ? 'Enabling...' : 'Enable 2FA'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Disable 2FA */}
|
||||
<Show when={totpStatus()?.enabled}>
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Disable Two-Factor Authentication</h3>
|
||||
<p class="text-gray-300 mb-4">
|
||||
Disabling 2FA will make your account less secure. You'll need to provide your current TOTP code and password.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
TOTP Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={disableCode()}
|
||||
onInput={(e) => setDisableCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))}
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="000000"
|
||||
maxlength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={disablePassword()}
|
||||
onInput={(e) => setDisablePassword(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={disableTOTP}
|
||||
disabled={loading()}
|
||||
variant="destructive"
|
||||
class="w-full"
|
||||
>
|
||||
{loading() ? 'Disabling...' : 'Disable 2FA'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Backup Code Management */}
|
||||
<Show when={totpStatus()?.enabled}>
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Backup Code Management</h3>
|
||||
|
||||
<div class="space-y-6">
|
||||
{/* Verify Backup Code */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-300 mb-3">Verify Backup Code</h4>
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={backupCodeVerify()}
|
||||
onInput={(e) => setBackupCodeVerify(e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter backup code"
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={verifyBackupCode}
|
||||
disabled={loading()}
|
||||
class="w-full"
|
||||
>
|
||||
{loading() ? 'Verifying...' : 'Verify Backup Code'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regenerate Backup Codes */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-300 mb-3">Regenerate Backup Codes</h4>
|
||||
<p class="text-sm text-gray-400 mb-3">
|
||||
This will invalidate all existing backup codes and generate new ones.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={regenerateCode()}
|
||||
onInput={(e) => setRegenerateCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))}
|
||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Current TOTP code"
|
||||
maxlength={6}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={regenerateBackupCodes}
|
||||
disabled={loading()}
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
>
|
||||
{loading() ? 'Regenerating...' : 'Regenerate Backup Codes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show New Backup Codes */}
|
||||
<Show when={backupCodes().length > 0}>
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-300 mb-3">New Backup Codes</h4>
|
||||
<p class="text-sm text-gray-400 mb-3">
|
||||
Save these new backup codes in a secure location:
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{backupCodes().map((code) => (
|
||||
<code class="bg-gray-800 px-3 py-2 rounded text-gray-300 text-sm">
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { IconSend, IconX, IconBrain, IconUser, IconChevronDown } from '@tabler/icons-solidjs'
|
||||
import { AIProviderIcon } from '../AIProviderIcon'
|
||||
|
||||
interface AIChatPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
provider: string
|
||||
category: string
|
||||
iconId?: string
|
||||
}
|
||||
|
||||
export function AIChatPanel(props: AIChatPanelProps) {
|
||||
const [messages, setMessages] = createSignal<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}
|
||||
])
|
||||
const [inputValue, setInputValue] = createSignal('')
|
||||
const [selectedModel, setSelectedModel] = createSignal('longcat-flash-chat')
|
||||
const [showModelPicker, setShowModelPicker] = createSignal(false)
|
||||
|
||||
const aiModels: AIModel[] = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||
{ id: 'mistral-standard', name: 'Mistral Standard', description: 'Mistral default model', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||
]
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const value = inputValue().trim()
|
||||
if (!value) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: value,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'I understand your question. Let me help you with that...',
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Chat Panel */}
|
||||
<div class={`fixed right-0 top-0 h-full bg-card border-l border-border shadow-xl transition-transform duration-300 z-50 ${
|
||||
props.isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`} style="width: min(420px, 100vw); max-width: 100vw;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 border-b border-border">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10">
|
||||
<IconBrain class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">AI Assistant</h3>
|
||||
<p class="text-xs text-muted-foreground">Always here to help</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4 text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<div class={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{message.role === 'assistant' && (
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-muted flex-shrink-0">
|
||||
<IconBrain class="size-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div class={`max-w-[280px] rounded-2xl p-3 ${
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground rounded-br-sm'
|
||||
: 'bg-muted rounded-bl-sm'
|
||||
}`}>
|
||||
<p class="text-sm leading-relaxed">{message.content}</p>
|
||||
<p class="text-xs opacity-70 mt-2">
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
{message.role === 'user' && (
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary flex-shrink-0">
|
||||
<IconUser class="size-4 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div class="p-4 border-t border-border bg-card">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue()}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
class="flex-1 h-10 w-full rounded-full border border-input bg-transparent px-4 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue().trim()}
|
||||
class="inline-flex items-center justify-center rounded-full text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-10 w-10"
|
||||
>
|
||||
<IconSend class="size-4 text-primary-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model Picker at Bottom */}
|
||||
<div class="mt-3 pt-3 border-t border-border">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setShowModelPicker(!showModelPicker())}
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-muted/80 rounded-full text-xs transition-colors"
|
||||
>
|
||||
<Show when={aiModels.find(m => m.id === selectedModel())?.iconId}>
|
||||
<AIProviderIcon
|
||||
providerId={aiModels.find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||
size="1rem"
|
||||
class="rounded-full"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!aiModels.find(m => m.id === selectedModel())?.iconId}>
|
||||
<div class="w-4 h-4 rounded-full bg-gradient-to-r from-blue-500 to-purple-500"></div>
|
||||
</Show>
|
||||
<span class="text-muted-foreground">
|
||||
{aiModels.find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||
</span>
|
||||
<IconChevronDown class={`size-3 transition-transform ${showModelPicker() ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<Show when={showModelPicker()}>
|
||||
<div class="absolute bottom-full left-0 mb-2 w-64 bg-background border rounded-lg shadow-lg z-50 p-1 max-h-48 overflow-y-auto">
|
||||
<For each={aiModels}>
|
||||
{model => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedModel(model.id)
|
||||
setShowModelPicker(false)
|
||||
}}
|
||||
class={`w-full text-left p-2 rounded text-xs transition-colors ${
|
||||
selectedModel() === model.id
|
||||
? 'bg-primary/10 border border-primary/20'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={model.iconId}>
|
||||
<AIProviderIcon
|
||||
providerId={model.iconId!}
|
||||
size="0.75rem"
|
||||
class="rounded-full flex-shrink-0"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!model.iconId}>
|
||||
<div class={`w-3 h-3 rounded-full flex-shrink-0 ${
|
||||
model.provider === 'LongCat' ? 'bg-gradient-to-r from-orange-500 to-red-500' :
|
||||
model.provider === 'OpenAI' ? 'bg-gradient-to-r from-green-500 to-emerald-500' :
|
||||
'bg-gradient-to-r from-purple-500 to-pink-500'
|
||||
}`}></div>
|
||||
</Show>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{model.name}</div>
|
||||
<div class="text-muted-foreground text-xs truncate">{model.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{aiModels.find(m => m.id === selectedModel())?.provider || 'LongCat'}</span>
|
||||
<a href="/app/settings" class="text-primary hover:underline">
|
||||
AI settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { IconX, IconSend, IconUser, IconChevronDown } from '@tabler/icons-solidjs'
|
||||
import longcatIcon from '@/assets/longcat-color.svg'
|
||||
|
||||
interface FloatingAIProps {
|
||||
onToggleChat: () => void
|
||||
isChatOpen: boolean
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export function FloatingAI(props: FloatingAIProps) {
|
||||
const [messages, setMessages] = createSignal<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
timestamp: new Date()
|
||||
}
|
||||
])
|
||||
const [inputValue, setInputValue] = createSignal('')
|
||||
const [selectedModel, setSelectedModel] = createSignal('longcat-flash-chat')
|
||||
const [showModelSelector, setShowModelSelector] = createSignal(false)
|
||||
|
||||
const aiModels = [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash', description: 'Fast and efficient' },
|
||||
{ id: 'gpt-4', name: 'GPT-4', description: 'Most capable' },
|
||||
{ id: 'claude-3', name: 'Claude 3', description: 'Balanced performance' }
|
||||
]
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const value = inputValue().trim()
|
||||
if (!value) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: value,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'I understand your question. Let me help you with that...',
|
||||
timestamp: new Date()
|
||||
}
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating AI Button */}
|
||||
<button
|
||||
onClick={props.onToggleChat}
|
||||
class="fixed bottom-6 right-8 z-40 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-all duration-200 hover:scale-110 w-14 h-14"
|
||||
title="AI Assistant"
|
||||
>
|
||||
<img src={longcatIcon} alt="AI Assistant" class="size-6" />
|
||||
</button>
|
||||
|
||||
{/* AI Chat Modal */}
|
||||
<Show when={props.isChatOpen}>
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-card border border-border rounded-lg shadow-xl max-w-md w-full max-h-[600px] flex flex-col" style="width: 420px;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 border-b border-border bg-gradient-to-r from-primary/10 to-primary/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center p-3 rounded-lg bg-primary/20">
|
||||
<img src={longcatIcon} alt="AI Assistant" class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">AI Assistant</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs text-muted-foreground">Always here to help</p>
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setShowModelSelector(!showModelSelector())}
|
||||
class="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{aiModels.find(m => m.id === selectedModel())?.name || 'LongCat Flash'}
|
||||
<IconChevronDown class="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Model Selector Dropdown */}
|
||||
<Show when={showModelSelector()}>
|
||||
<div class="absolute bottom-full left-0 mb-2 w-48 bg-popover border border-border rounded-md shadow-lg z-10">
|
||||
{aiModels.map((model) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedModel(model.id)
|
||||
setShowModelSelector(false)
|
||||
}}
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
<div class="font-medium">{model.name}</div>
|
||||
<div class="text-xs text-muted-foreground">{model.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onToggleChat}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4 text-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-background to-muted/20" style="max-height: 400px;">
|
||||
{messages().map((message) => (
|
||||
<div class={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'} animate-in slide-in-from-bottom-2 duration-200`}>
|
||||
{message.role === 'assistant' && (
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<img src={longcatIcon} alt="AI Assistant" class="size-4" />
|
||||
</div>
|
||||
)}
|
||||
<div class={`max-w-[300px] rounded-lg p-3 shadow-sm ${
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground ml-auto'
|
||||
: 'bg-muted border border-border'
|
||||
}`}>
|
||||
<p class="text-sm leading-relaxed">{message.content}</p>
|
||||
<p class="text-xs opacity-70 mt-2">
|
||||
{message.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
{message.role === 'user' && (
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary flex-shrink-0">
|
||||
<IconUser class="size-4 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div class="p-4 border-t border-border bg-muted/30">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue()}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
class="flex-1 h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue().trim()}
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 hover:shadow-md h-10 px-4"
|
||||
>
|
||||
<IconSend class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import {
|
||||
IconBell,
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
IconUpload,
|
||||
IconMoon,
|
||||
IconLogout,
|
||||
IconUser
|
||||
IconSettings,
|
||||
IconMenu2
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { QuickSearch } from '@/components/search/QuickSearch'
|
||||
import { ColorSwitcherDropdown } from '@/components/ui/ColorSwitcherDropdown'
|
||||
import { UploadModal } from '@/components/ui/UploadModal'
|
||||
import { UserProfileDropdown } from '@/components/ui/UserProfileDropdown'
|
||||
import { createSignal } from 'solid-js'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
|
||||
export interface HeaderProps {
|
||||
@@ -16,70 +17,102 @@ export interface HeaderProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function Header(props: HeaderProps) {
|
||||
const { authState, logout } = useAuth();
|
||||
export function Header(_props: HeaderProps) {
|
||||
const [showUploadModal, setShowUploadModal] = createSignal(false);
|
||||
const { authState, updateProfile } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
const handleThemeToggle = async () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-kb-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
// Apply theme immediately
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Update user profile if authenticated
|
||||
if (authState.isAuthenticated && authState.user) {
|
||||
try {
|
||||
await updateProfile({ theme: newTheme });
|
||||
} catch (error) {
|
||||
console.error('Failed to update theme in profile:', error);
|
||||
// Still keep the local theme change even if profile update fails
|
||||
}
|
||||
}
|
||||
|
||||
// Reload the page so all Papra CSS and color schemes re-initialize
|
||||
// and the theme change is fully applied without manual refresh
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
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 class="flex justify-between px-6 pt-4 pb-4">
|
||||
{/* Left side */}
|
||||
<div class="flex items-center">
|
||||
{/* Mobile menu button */}
|
||||
<button type="button" aria-haspopup="dialog" aria-expanded="false" data-closed="" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9 md:hidden mr-2">
|
||||
<IconMenu2 class="size-6" />
|
||||
</button>
|
||||
|
||||
{/* Quick Search */}
|
||||
<QuickSearch />
|
||||
</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>
|
||||
{/* Right side */}
|
||||
<div class="flex items-center gap-2">
|
||||
{/* Drop zone overlay */}
|
||||
<div class="fixed top-0 left-0 w-screen h-screen z-80 bg-background bg-opacity-50 backdrop-blur transition-colors hidden">
|
||||
<div class="flex items-center justify-center h-full text-center flex-col">
|
||||
<IconPlus class="text-6xl text-muted-foreground mx-auto" />
|
||||
<div class="text-xl my-2 font-semibold text-muted-foreground">Drop files here</div>
|
||||
<div class="text-base text-muted-foreground">Drag and drop files here to import them</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-[#a3a3a3] hover:text-[#fafafa]"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<IconLogout class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Import button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
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-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2"
|
||||
>
|
||||
<IconUpload class="size-4" />
|
||||
<span class="hidden sm:inline ml-2">Import a document</span>
|
||||
</button>
|
||||
|
||||
{/* Color switcher dropdown */}
|
||||
<ColorSwitcherDropdown />
|
||||
|
||||
{/* Theme switcher */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleThemeToggle}
|
||||
class="items-center justify-center rounded-md font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-8 px-3 py-1 text-base hidden sm:flex"
|
||||
>
|
||||
<IconMoon class="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Admin link */}
|
||||
<a href="/app/admin-settings" class="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 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2 hidden sm:flex gap-2">
|
||||
<IconSettings class="size-4" />
|
||||
Admin
|
||||
</a>
|
||||
|
||||
{/* User menu */}
|
||||
<UserProfileDropdown />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Upload Modal */}
|
||||
<UploadModal
|
||||
isOpen={showUploadModal()}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,180 @@
|
||||
import { children } from 'solid-js'
|
||||
import { children, createSignal, onMount } from 'solid-js'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Header } from './Header'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AIChatPanel } from './AIChatPanel'
|
||||
import { UpdateNotification } from '../ui/UpdateNotification'
|
||||
import { IconBrain } from '@tabler/icons-solidjs'
|
||||
|
||||
export interface LayoutProps {
|
||||
children: any
|
||||
title?: string
|
||||
class?: string
|
||||
fullBleed?: boolean
|
||||
}
|
||||
|
||||
export function Layout(props: LayoutProps) {
|
||||
const resolved = children(() => props.children)
|
||||
const [isChatOpen, setIsChatOpen] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
// Initialize dark mode from localStorage or system preference
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme')
|
||||
}
|
||||
|
||||
// Initialize color scheme from localStorage
|
||||
const savedColorScheme = localStorage.getItem('colorScheme');
|
||||
const savedCustomColors = localStorage.getItem('customColors');
|
||||
|
||||
if (savedColorScheme === 'custom' && savedCustomColors) {
|
||||
try {
|
||||
const colors = JSON.parse(savedCustomColors);
|
||||
|
||||
// Apply custom colors
|
||||
const hexToHsl = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return '0 0% 100%';
|
||||
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
};
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--primary', hexToHsl(colors.primary));
|
||||
root.style.setProperty('--background', hexToHsl(colors.background));
|
||||
root.style.setProperty('--foreground', hexToHsl(colors.foreground));
|
||||
root.style.setProperty('--muted', hexToHsl(colors.muted));
|
||||
root.style.setProperty('--border', colors.border);
|
||||
|
||||
// Also set as CSS custom properties for direct use
|
||||
root.style.setProperty('--colors-primary', hexToHsl(colors.primary));
|
||||
root.style.setProperty('--colors-background', hexToHsl(colors.background));
|
||||
root.style.setProperty('--colors-foreground', hexToHsl(colors.foreground));
|
||||
root.style.setProperty('--colors-muted', hexToHsl(colors.muted));
|
||||
root.style.setProperty('--colors-border', colors.border);
|
||||
} catch (e) {
|
||||
console.error('Failed to load custom colors:', e);
|
||||
}
|
||||
} else if (savedColorScheme) {
|
||||
// Apply predefined scheme
|
||||
const predefinedSchemes: Record<string, any> = {
|
||||
'default': { primary: '#5ab9ff', background: savedTheme === 'dark' ? '#1a1a1a' : '#ffffff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#262727' : '#f5f5f5', border: '#262626' },
|
||||
'ocean': { primary: '#0077be', background: savedTheme === 'dark' ? '#001f3f' : '#e6f3ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#003366' : '#cce7ff', border: '#004080' },
|
||||
'forest': { primary: '#228b22', background: savedTheme === 'dark' ? '#0d2818' : '#f0f8f0', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#1a431a' : '#d4edd4', border: '#2d5a2d' },
|
||||
'sunset': { primary: '#ff6b35', background: savedTheme === 'dark' ? '#2c1810' : '#fff5f0', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#5c2e00' : '#ffe4d6', border: '#8b4513' },
|
||||
'purple': { primary: '#8b5cf6', background: savedTheme === 'dark' ? '#1a0033' : '#f8f5ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#330066' : '#ede9fe', border: '#4d0099' },
|
||||
'rose': { primary: '#f43f5e', background: savedTheme === 'dark' ? '#2d1111' : '#fff1f2', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#5a1a1a' : '#ffe4e6', border: '#881337' },
|
||||
'amber': { primary: '#f59e0b', background: savedTheme === 'dark' ? '#2d1a00' : '#fffbeb', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#5c4a00' : '#fef3c7', border: '#78350f' },
|
||||
'emerald': { primary: '#10b981', background: savedTheme === 'dark' ? '#022c22' : '#ecfdf5', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#064e3b' : '#d1fae5', border: '#047857' },
|
||||
'cyan': { primary: '#06b6d4', background: savedTheme === 'dark' ? '#022c3a' : '#ecfeff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#164e63' : '#cffafe', border: '#0891b2' },
|
||||
'indigo': { primary: '#6366f1', background: savedTheme === 'dark' ? '#1e1b4b' : '#eef2ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#312e81' : '#e0e7ff', border: '#4338ca' }
|
||||
};
|
||||
|
||||
const scheme = predefinedSchemes[savedColorScheme];
|
||||
if (scheme) {
|
||||
const hexToHsl = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return '0 0% 100%';
|
||||
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
};
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--primary', hexToHsl(scheme.primary));
|
||||
root.style.setProperty('--background', hexToHsl(scheme.background));
|
||||
root.style.setProperty('--foreground', hexToHsl(scheme.foreground));
|
||||
root.style.setProperty('--muted', hexToHsl(scheme.muted));
|
||||
root.style.setProperty('--border', scheme.border);
|
||||
|
||||
// Also set as CSS custom properties for direct use
|
||||
root.style.setProperty('--colors-primary', hexToHsl(scheme.primary));
|
||||
root.style.setProperty('--colors-background', hexToHsl(scheme.background));
|
||||
root.style.setProperty('--colors-foreground', hexToHsl(scheme.foreground));
|
||||
root.style.setProperty('--colors-muted', hexToHsl(scheme.muted));
|
||||
root.style.setProperty('--colors-border', hexToHsl(scheme.border));
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const toggleChat = () => {
|
||||
setIsChatOpen(!isChatOpen())
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={cn('flex h-screen bg-[#18181b]', props.class)}>
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
<div class="min-h-screen font-sans text-sm font-400 bg-background text-foreground">
|
||||
{/* Update Notification - Above everything */}
|
||||
<UpdateNotification />
|
||||
|
||||
{/* Main Content */}
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<Header title={props.title} />
|
||||
<div class="flex flex-row h-screen min-h-0">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Page Content */}
|
||||
<main class="flex-1 overflow-y-auto bg-[#18181b] p-6">
|
||||
{resolved()}
|
||||
</main>
|
||||
{/* Main Content */}
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
{/* Header */}
|
||||
<Header title={props.title} />
|
||||
|
||||
{/* Page Content */}
|
||||
<main class="flex-1 overflow-auto max-w-screen">
|
||||
<div class={props.fullBleed ? "h-full" : "p-2 max-w-7xl mx-auto"}>
|
||||
{resolved()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Floating AI Button */}
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
class="fixed bottom-6 right-8 z-40 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-all duration-200 hover:scale-110 w-14 h-14"
|
||||
title="AI Assistant"
|
||||
>
|
||||
<IconBrain class="size-6 text-primary-foreground" />
|
||||
</button>
|
||||
|
||||
{/* AI Chat Panel */}
|
||||
<AIChatPanel isOpen={isChatOpen()} onClose={() => setIsChatOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,70 +1,263 @@
|
||||
import { For } from 'solid-js'
|
||||
import { A } from '@solidjs/router'
|
||||
import { For, createSignal, onMount, Show } from 'solid-js'
|
||||
import { A, useLocation } from '@solidjs/router'
|
||||
import {
|
||||
IconBookmark,
|
||||
IconChecklist,
|
||||
IconFolder,
|
||||
IconHome,
|
||||
IconNotebook,
|
||||
IconSettings
|
||||
IconSettings,
|
||||
IconVideo,
|
||||
IconFileText,
|
||||
IconChevronDown,
|
||||
IconTrash,
|
||||
IconUsers,
|
||||
IconBrain,
|
||||
IconSchool,
|
||||
IconChartLine,
|
||||
IconBrandGithub,
|
||||
IconClock,
|
||||
IconCalendar,
|
||||
IconLogout,
|
||||
IconBuilding,
|
||||
IconPlus
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { UpdateChecker } from '../ui/UpdateChecker'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/app', icon: IconHome },
|
||||
{ name: 'Home', href: '/app', icon: IconHome },
|
||||
{ name: 'Bookmarks', href: '/app/bookmarks', icon: IconBookmark },
|
||||
{ name: 'Tasks', href: '/app/tasks', icon: IconChecklist },
|
||||
{ name: 'Time Tracking', href: '/app/time-tracking', icon: IconClock },
|
||||
{ name: 'Calendar', href: '/app/calendar', icon: IconCalendar },
|
||||
{ name: 'Files', href: '/app/files', icon: IconFolder },
|
||||
{ name: 'Notes', href: '/app/notes', icon: IconNotebook },
|
||||
{ name: 'Settings', href: '/app/settings', icon: IconSettings },
|
||||
{ name: 'YouTube', href: '/app/youtube', icon: IconVideo },
|
||||
{ name: 'Members', href: '/app/members', icon: IconUsers },
|
||||
{ name: 'Learning', href: '/app/learning-paths', icon: IconSchool },
|
||||
{ name: 'Stats', href: '/app/stats', icon: IconChartLine },
|
||||
{ name: 'GitHub', href: '/app/github', icon: IconBrandGithub },
|
||||
{ name: 'AI Assistant', href: '/app/chat', icon: IconBrain },
|
||||
]
|
||||
|
||||
const mockWorkspaces = [
|
||||
{ id: '1', name: 'Trackeep Workspace', icon: IconFileText },
|
||||
{ id: '2', name: 'Personal Projects', icon: IconBuilding },
|
||||
{ id: '3', name: 'Team Collaboration', icon: IconUsers },
|
||||
]
|
||||
|
||||
export interface SidebarProps {
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
export function Sidebar(_props: SidebarProps) {
|
||||
const location = useLocation()
|
||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = createSignal(false)
|
||||
const [selectedWorkspace, setSelectedWorkspace] = createSignal(mockWorkspaces[0])
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const currentPath = location.pathname
|
||||
if (href === '/app' && currentPath === '/app') return true
|
||||
return currentPath === href
|
||||
}
|
||||
|
||||
const handleWorkspaceSelect = (workspace: typeof mockWorkspaces[0]) => {
|
||||
setSelectedWorkspace(workspace)
|
||||
setIsWorkspaceDropdownOpen(false)
|
||||
}
|
||||
|
||||
const toggleWorkspaceDropdown = () => {
|
||||
setIsWorkspaceDropdownOpen(!isWorkspaceDropdownOpen())
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
onMount(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (!target.closest('#workspace-selector')) {
|
||||
setIsWorkspaceDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
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"
|
||||
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
||||
<div class="flex h-full">
|
||||
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
|
||||
{/* Organization Selector */}
|
||||
<div class="p-4 pb-0 min-w-0 max-w-full" id="workspace-selector">
|
||||
<div role="group" class="w-full relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleWorkspaceDropdown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isWorkspaceDropdownOpen()}
|
||||
class="flex w-full items-center justify-between border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 hover:bg-accent/50 transition rounded-lg h-auto pl-2"
|
||||
>
|
||||
<Icon class="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</A>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
<span class="flex items-center gap-2 min-w-0">
|
||||
<span class="p-1.5 rounded text-lg font-bold flex items-center bg-muted light:border dark:bg-primary/10 transition flex-shrink-0">
|
||||
{(() => {
|
||||
const workspace = selectedWorkspace()
|
||||
return <workspace.icon class="size-5.5" style="color: hsl(var(--primary))" />
|
||||
})()}
|
||||
</span>
|
||||
<span class="truncate text-base font-medium">{selectedWorkspace().name}</span>
|
||||
</span>
|
||||
<div class="size-4 opacity-50 ml-2 flex-shrink-0 transition-transform duration-200" classList={{ "rotate-180": isWorkspaceDropdownOpen() }}>
|
||||
<IconChevronDown class="size-4" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 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>
|
||||
{/* Dropdown Menu */}
|
||||
<Show when={isWorkspaceDropdownOpen()}>
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-60 overflow-auto">
|
||||
<div class="p-1" role="listbox">
|
||||
<For each={mockWorkspaces}>
|
||||
{(workspace) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWorkspaceSelect(workspace)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm rounded-sm hover:bg-accent/50 transition-colors focus:bg-accent/50 focus:outline-none"
|
||||
role="option"
|
||||
classList={{ "bg-accent/30": workspace.id === selectedWorkspace().id }}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = workspace.icon
|
||||
return <Icon class="size-4 text-muted-foreground" />
|
||||
})()}
|
||||
<span class="flex-1 text-left truncate">{workspace.name}</span>
|
||||
<Show when={workspace.id === selectedWorkspace().id}>
|
||||
<div class="w-2 h-2 bg-primary rounded-full"></div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<div class="border-t border-border mt-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm rounded-sm hover:bg-accent/50 transition-colors focus:bg-accent/50 focus:outline-none text-muted-foreground"
|
||||
>
|
||||
<IconPlus class="size-4" />
|
||||
<span>Create Workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav class="flex flex-col gap-0.5 mt-4 px-4">
|
||||
<For each={navigation}>
|
||||
{(item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<A
|
||||
href={item.href}
|
||||
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden"
|
||||
classList={{
|
||||
"bg-[#262626] text-white font-medium shadow-sm": active,
|
||||
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !active
|
||||
}}
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<Icon class={`size-5 transition-colors ${
|
||||
active
|
||||
? 'text-primary'
|
||||
: 'text-[#a3a3a3] group-hover:text-primary'
|
||||
}`} />
|
||||
<div class={`transition-colors ${
|
||||
active
|
||||
? 'text-white font-medium'
|
||||
: 'text-[#a3a3a3] group-hover:text-white'
|
||||
}`}>{item.name}</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200" classList={{
|
||||
"bg-[#262626]": !active
|
||||
}}></div>
|
||||
</A>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{/* Update Checker */}
|
||||
<div class="px-4 mb-2">
|
||||
<UpdateChecker />
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-col gap-0.5 px-4">
|
||||
<A
|
||||
href="/app/removed-stuff"
|
||||
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden"
|
||||
classList={{
|
||||
"bg-[#262626] text-white font-medium shadow-sm": isActive('/app/removed-stuff'),
|
||||
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !isActive('/app/removed-stuff')
|
||||
}}
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<IconTrash class={`size-5 transition-colors ${
|
||||
isActive('/app/removed-stuff')
|
||||
? 'text-white'
|
||||
: 'text-[#a3a3a3] group-hover:text-primary'
|
||||
}`} />
|
||||
<div class={`transition-colors ${
|
||||
isActive('/app/removed-stuff')
|
||||
? 'text-white font-medium'
|
||||
: 'text-[#a3a3a3] group-hover:text-white'
|
||||
}`}>Removed stuff</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200" classList={{
|
||||
"bg-[#262626]": !isActive('/app/removed-stuff')
|
||||
}}></div>
|
||||
</A>
|
||||
<A
|
||||
href="/app/settings"
|
||||
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden"
|
||||
classList={{
|
||||
"bg-[#262626] text-white font-medium shadow-sm": isActive('/app/settings'),
|
||||
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !isActive('/app/settings')
|
||||
}}
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<IconSettings class={`size-5 transition-colors ${
|
||||
isActive('/app/settings')
|
||||
? 'text-white'
|
||||
: 'text-[#a3a3a3] group-hover:text-primary'
|
||||
}`} />
|
||||
<div class={`transition-colors ${
|
||||
isActive('/app/settings')
|
||||
? 'text-white font-medium'
|
||||
: 'text-[#a3a3a3] group-hover:text-white'
|
||||
}`}>Settings</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200" classList={{
|
||||
"bg-[#262626]": !isActive('/app/settings')
|
||||
}}></div>
|
||||
</A>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Handle logout logic here
|
||||
localStorage.removeItem('auth_token')
|
||||
window.location.href = '/login'
|
||||
}}
|
||||
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate w-full relative overflow-hidden hover:bg-destructive/10 hover:text-destructive dark:text-muted-foreground"
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<IconLogout class={`size-5 transition-colors text-[#a3a3a3]`} />
|
||||
<div class="transition-colors">Logout</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200"></div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { IconSearch, IconExternalLink, IconLoader2, IconBookmark } from '@tabler/icons-solidjs';
|
||||
import { type BraveSearchResult } from '@/lib/brave-search';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
export const BrowserSearch = () => {
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [searchResults, setSearchResults] = createSignal<BraveSearchResult[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
const [hasSearched, setHasSearched] = createSignal(false);
|
||||
const [searchType, setSearchType] = createSignal<'web' | 'news'>('web');
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return isEnvDemoMode();
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
const query = searchQuery().trim();
|
||||
if (!query) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setHasSearched(true);
|
||||
|
||||
try {
|
||||
const isDemo = isDemoMode();
|
||||
|
||||
// In demo mode, use the demo mode API interceptor
|
||||
if (isDemo) {
|
||||
console.log('Demo mode detected, using demo API interceptor...');
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL?.replace('/api/v1', '') || 'http://localhost:8080';
|
||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, count: 8 }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Handle demo mode response format
|
||||
const results = data.web?.results || data.news?.results || data.mixed?.results || data.results || [];
|
||||
if (results.length > 0) {
|
||||
setSearchResults(results);
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.warn('Demo API failed, falling back to direct Brave API...');
|
||||
}
|
||||
|
||||
// Try Brave Search API directly (for production mode or as fallback)
|
||||
const { searchBrave } = await import('@/lib/brave-search');
|
||||
const results = await searchBrave(query, 8, searchType());
|
||||
|
||||
if (results && results.length > 0) {
|
||||
setSearchResults(results);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no results from Brave API, try backend as last resort (only in non-demo mode)
|
||||
if (!isDemo) {
|
||||
console.warn('Brave Search returned no results, trying backend...');
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL?.replace('/api/v1', '') || 'http://localhost:8080';
|
||||
const token = localStorage.getItem('token') ||
|
||||
localStorage.getItem('auth_token') ||
|
||||
localStorage.getItem('trackeep_token');
|
||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ query, count: 8 }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.results && data.results.length > 0) {
|
||||
setSearchResults(data.results);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If all APIs fail or return no results, show appropriate message
|
||||
throw new Error('No search results available');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
|
||||
// Only show demo data if all APIs fail
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
const apiFailed = errorMessage.includes('API') || errorMessage.includes('fetch') || errorMessage.includes('No search results');
|
||||
|
||||
if (apiFailed) {
|
||||
console.warn('All search APIs failed, showing demo results:', errorMessage);
|
||||
const mockResults: BraveSearchResult[] = [
|
||||
{
|
||||
title: `${query} - Search Result 1`,
|
||||
url: `https://example.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `This is a mock search result for "${query}" demonstrating the search functionality in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English'
|
||||
},
|
||||
{
|
||||
title: `${query} - Search Result 2`,
|
||||
url: `https://demo-site.com/${query.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Another mock search result for "${query}" showing how the search interface works in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English'
|
||||
}
|
||||
];
|
||||
setSearchResults(mockResults);
|
||||
} else {
|
||||
setError('Search temporarily unavailable. Please try again later.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (e: InputEvent) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) {
|
||||
setSearchQuery(target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const openResult = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const bookmarkResult = async (result: BraveSearchResult) => {
|
||||
// If in demo mode, just show success message
|
||||
if (isDemoMode()) {
|
||||
// In demo mode, just show success without actual API call
|
||||
console.log('Demo mode: Bookmark created for', result.title);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const bookmarkData = {
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
description: result.description,
|
||||
tags: ['web-search', 'browser-search']
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': localStorage.getItem('token') ? `Bearer ${localStorage.getItem('token')}` : '',
|
||||
},
|
||||
body: JSON.stringify(bookmarkData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to create bookmark');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to bookmark search result:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Search Content */}
|
||||
<div class="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-xs text-muted-foreground uppercase tracking-wide">Search type</span>
|
||||
<div class="inline-flex items-center gap-1 rounded-md bg-muted p-1">
|
||||
<Button
|
||||
variant={searchType() === 'web' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
class="h-7 px-3 text-xs"
|
||||
onClick={() => setSearchType('web')}
|
||||
>
|
||||
Web
|
||||
</Button>
|
||||
<Button
|
||||
variant={searchType() === 'news' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
class="h-7 px-3 text-xs"
|
||||
onClick={() => setSearchType('news')}
|
||||
>
|
||||
News
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchType() === 'news' ? 'Search news...' : 'Search the web...'}
|
||||
value={searchQuery()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
class="text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading() || !searchQuery().trim()}
|
||||
size="lg"
|
||||
class="px-8"
|
||||
>
|
||||
{isLoading() ? (
|
||||
<span class="flex items-center gap-2">
|
||||
<IconLoader2 class="w-4 h-4 animate-spin" />
|
||||
Searching...
|
||||
</span>
|
||||
) : (
|
||||
<span class="flex items-center gap-2">
|
||||
<IconSearch class="w-4 h-4" />
|
||||
Search
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Error Message */}
|
||||
<Show when={error()}>
|
||||
<Card class="p-4 border-red-500/20 bg-red-500/5">
|
||||
<p class="text-red-400 text-sm">{error()}</p>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Search Results */}
|
||||
<Show when={hasSearched() && !isLoading()}>
|
||||
<div class="space-y-4">
|
||||
<Show when={searchResults().length > 0}>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Found {searchResults().length} results
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<For each={searchResults()}>
|
||||
{(result) => (
|
||||
<Card class="p-6 hover:bg-accent/50 transition-colors cursor-pointer group">
|
||||
<div class="space-y-2">
|
||||
{/* URL */}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-primary truncate">
|
||||
{result.url}
|
||||
</span>
|
||||
<IconExternalLink class="w-3.5 h-3.5 ml-1 text-primary flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3
|
||||
class="text-lg font-semibold hover:text-primary transition-colors"
|
||||
onClick={() => openResult(result.url)}
|
||||
>
|
||||
{result.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p class="text-muted-foreground text-sm line-clamp-2">
|
||||
{result.description}
|
||||
</p>
|
||||
|
||||
{/* Meta info */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<Show when={result.published_date}>
|
||||
<span>{result.published_date}</span>
|
||||
</Show>
|
||||
<Show when={result.language}>
|
||||
<span>Language: {result.language}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bookmarkResult(result)}
|
||||
class="mt-2"
|
||||
>
|
||||
<IconBookmark class="w-3 h-3 mr-1" />
|
||||
Bookmark
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openResult(result.url)}
|
||||
class="mt-2"
|
||||
>
|
||||
<IconExternalLink class="w-3.5 h-3.5 mr-1" />
|
||||
Visit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={searchResults().length === 0 && !error()}>
|
||||
<Card class="p-12 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<IconSearch class="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No results found</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Try searching with different keywords or check your spelling.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Initial State */}
|
||||
<Show when={!hasSearched()}>
|
||||
<Card class="p-12 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<IconSearch class="w-16 h-16 text-primary mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">Search the web using Brave Search API</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Enter keywords above to search the web and bookmark results.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,781 @@
|
||||
import { createSignal, For, Show, onMount } from 'solid-js';
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
import {
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
IconBookmark,
|
||||
IconChecklist,
|
||||
IconNotebook,
|
||||
IconFolder,
|
||||
IconX,
|
||||
IconStar,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFileText
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SavedSearches } from './SavedSearches';
|
||||
|
||||
interface SearchFilters {
|
||||
query: string;
|
||||
content_type: 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files';
|
||||
tags: string[];
|
||||
date_range: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
author: string;
|
||||
language: string;
|
||||
file_types: string[];
|
||||
is_favorite?: boolean;
|
||||
is_read?: boolean;
|
||||
is_public?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
search_mode: 'fulltext' | 'semantic' | 'hybrid'; // New field
|
||||
threshold: number; // Similarity threshold for semantic search
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: Array<{ id: number; name: string; color: string }>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
url?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
is_favorite?: boolean;
|
||||
is_read?: boolean;
|
||||
is_public?: boolean;
|
||||
author?: string;
|
||||
file_size?: number;
|
||||
mime_type?: string;
|
||||
file_type?: string;
|
||||
progress?: number;
|
||||
highlights?: Record<string, string[]>;
|
||||
score: number;
|
||||
similarity?: number; // Semantic similarity score
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
total: number;
|
||||
query: string;
|
||||
filters: SearchFilters;
|
||||
took: number;
|
||||
suggestions: string[];
|
||||
aggregations: Record<string, number>;
|
||||
}
|
||||
|
||||
export const EnhancedSearch = () => {
|
||||
const [activeTab, setActiveTab] = createSignal<'search' | 'saved'>('search');
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [filters, setFilters] = createSignal<SearchFilters>({
|
||||
query: '',
|
||||
content_type: 'all',
|
||||
tags: [],
|
||||
date_range: { start: '', end: '' },
|
||||
author: '',
|
||||
language: '',
|
||||
file_types: [],
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
search_mode: 'fulltext',
|
||||
threshold: 0.7
|
||||
});
|
||||
const [searchResults, setSearchResults] = createSignal<SearchResult[]>([]);
|
||||
const [total, setTotal] = createSignal(0);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [showFilters, setShowFilters] = createSignal(false);
|
||||
const [aggregations, setAggregations] = createSignal<Record<string, number>>({});
|
||||
const [took, setTook] = createSignal(0);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// API call to search
|
||||
const performSearch = async (resetOffset = true) => {
|
||||
setLoading(true);
|
||||
|
||||
const currentFilters = { ...filters() };
|
||||
currentFilters.query = searchQuery();
|
||||
|
||||
if (resetOffset) {
|
||||
currentFilters.offset = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try multiple token sources for better compatibility
|
||||
const token = localStorage.getItem('token') ||
|
||||
localStorage.getItem('auth_token') ||
|
||||
localStorage.getItem('trackeep_token');
|
||||
let response;
|
||||
|
||||
if (currentFilters.search_mode === 'semantic') {
|
||||
// Use semantic search API
|
||||
response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/semantic`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: currentFilters.query,
|
||||
content_type: currentFilters.content_type,
|
||||
limit: currentFilters.limit,
|
||||
threshold: currentFilters.threshold
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (resetOffset) {
|
||||
setSearchResults(data.results);
|
||||
} else {
|
||||
setSearchResults(prev => [...prev, ...data.results]);
|
||||
}
|
||||
setTotal(data.results.length); // Semantic search doesn't return total count
|
||||
setTook(data.took);
|
||||
}
|
||||
} else {
|
||||
// Use enhanced full-text search API
|
||||
response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/enhanced`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
},
|
||||
body: JSON.stringify(currentFilters)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: SearchResponse = await response.json();
|
||||
if (resetOffset) {
|
||||
setSearchResults(data.results);
|
||||
} else {
|
||||
setSearchResults(prev => [...prev, ...data.results]);
|
||||
}
|
||||
setTotal(data.total);
|
||||
setAggregations(data.aggregations);
|
||||
setTook(data.took);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// If unauthorized, fallback to mock data
|
||||
if (response.status === 401) {
|
||||
console.warn('Search authorization failed, using mock data');
|
||||
const mockResults: SearchResult[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'bookmark',
|
||||
title: `Mock result for "${currentFilters.query}"`,
|
||||
description: 'This is a mock search result due to authorization issues',
|
||||
content: 'Mock content for demonstration purposes',
|
||||
tags: [{ id: 1, name: 'demo', color: '#6b7280' }],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
url: 'https://example.com',
|
||||
score: 0.9
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'note',
|
||||
title: `Another mock result for "${currentFilters.query}"`,
|
||||
description: 'Another mock search result in demo mode',
|
||||
content: 'Additional mock content for search demonstration',
|
||||
tags: [{ id: 2, name: 'mock', color: '#3b82f6' }],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
score: 0.8
|
||||
}
|
||||
];
|
||||
|
||||
if (resetOffset) {
|
||||
setSearchResults(mockResults);
|
||||
} else {
|
||||
setSearchResults(prev => [...prev, ...mockResults]);
|
||||
}
|
||||
setTotal(mockResults.length);
|
||||
setTook(50);
|
||||
return;
|
||||
}
|
||||
throw new Error('Search failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
// Fallback to mock data on any error
|
||||
const mockResults: SearchResult[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'bookmark',
|
||||
title: `Fallback result for "${currentFilters.query}"`,
|
||||
description: 'This is a fallback search result due to API errors',
|
||||
content: 'Fallback content for demonstration purposes',
|
||||
tags: [{ id: 1, name: 'fallback', color: '#ef4444' }],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
url: 'https://example.com',
|
||||
score: 0.7
|
||||
}
|
||||
];
|
||||
|
||||
if (resetOffset) {
|
||||
setSearchResults(mockResults);
|
||||
} else {
|
||||
setSearchResults(prev => [...prev, ...mockResults]);
|
||||
}
|
||||
setTotal(mockResults.length);
|
||||
setTook(100);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout: number;
|
||||
const debouncedSearch = () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => performSearch(), 300);
|
||||
};
|
||||
|
||||
// Handle search input
|
||||
const handleSearchInput = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
// Handle filter changes
|
||||
const updateFilter = (key: keyof SearchFilters, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setTimeout(() => performSearch(), 100);
|
||||
};
|
||||
|
||||
// Add/remove tag filter
|
||||
const toggleTag = (tag: string) => {
|
||||
const currentTags = filters().tags;
|
||||
const newTags = currentTags.includes(tag)
|
||||
? currentTags.filter(t => t !== tag)
|
||||
: [...currentTags, tag];
|
||||
updateFilter('tags', newTags);
|
||||
};
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
query: searchQuery(),
|
||||
content_type: 'all',
|
||||
tags: [],
|
||||
date_range: { start: '', end: '' },
|
||||
author: '',
|
||||
language: '',
|
||||
file_types: [],
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
search_mode: 'fulltext',
|
||||
threshold: 0.7
|
||||
});
|
||||
setTimeout(() => performSearch(), 100);
|
||||
};
|
||||
|
||||
// Load more results
|
||||
const loadMore = () => {
|
||||
setFilters(prev => ({ ...prev, offset: prev.offset + prev.limit }));
|
||||
setTimeout(() => performSearch(false), 100);
|
||||
};
|
||||
|
||||
// Get icon for content type
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'bookmark':
|
||||
return IconBookmark;
|
||||
case 'task':
|
||||
return IconChecklist;
|
||||
case 'note':
|
||||
return IconNotebook;
|
||||
case 'file':
|
||||
return IconFolder;
|
||||
default:
|
||||
return IconFileText;
|
||||
}
|
||||
};
|
||||
|
||||
// Get color for content type
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'bookmark':
|
||||
return 'text-green-400';
|
||||
case 'task':
|
||||
return 'text-yellow-400';
|
||||
case 'note':
|
||||
return 'text-purple-400';
|
||||
case 'file':
|
||||
return 'text-orange-400';
|
||||
default:
|
||||
return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return '';
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
// Get priority color
|
||||
const getPriorityColor = (priority?: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return 'bg-red-500';
|
||||
case 'high':
|
||||
return 'bg-orange-500';
|
||||
case 'medium':
|
||||
return 'bg-yellow-500';
|
||||
case 'low':
|
||||
return 'bg-green-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-500';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-500';
|
||||
case 'pending':
|
||||
return 'bg-yellow-500';
|
||||
case 'cancelled':
|
||||
return 'bg-red-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// Initial search on mount (respect URL query/tag params)
|
||||
onMount(() => {
|
||||
const urlQuery = (searchParams as any).query || '';
|
||||
const urlTag = (searchParams as any).tag || '';
|
||||
|
||||
const initialQuery = urlQuery || urlTag || '';
|
||||
|
||||
if (initialQuery) {
|
||||
setSearchQuery(initialQuery);
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
query: initialQuery,
|
||||
tags: urlTag ? [urlTag] : prev.tags,
|
||||
}));
|
||||
}
|
||||
|
||||
performSearch();
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header with Tabs */}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Enhanced Search</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Search across all your content with powerful filters and AI-powered discovery
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div class="border-b">
|
||||
<nav class="flex space-x-8">
|
||||
<button
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab() === 'search'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => setActiveTab('search')}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconSearch class="size-4" />
|
||||
Search
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab() === 'saved'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => setActiveTab('saved')}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconBookmark class="size-4" />
|
||||
Saved Searches
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<Show when={activeTab() === 'search'}>
|
||||
<div class="space-y-6">
|
||||
{/* Search Input */}
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
<IconSearch class="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground size-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search across all your content..."
|
||||
value={searchQuery()}
|
||||
onInput={(e: any) => handleSearchInput(e.target?.value || '')}
|
||||
class="pl-10 pr-12 h-12 text-lg"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters())}
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
>
|
||||
<IconFilter class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search Stats */}
|
||||
<Show when={total() > 0}>
|
||||
<div class="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>Found {total()} results in {took()}ms</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<For each={Object.entries(aggregations())}>
|
||||
{([type, count]) => (
|
||||
<span>{type}: {count}</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Filters Panel */}
|
||||
<Show when={showFilters()}>
|
||||
<Card class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Filters</h3>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowFilters(false)}>
|
||||
<IconX class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Search Mode */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Search Mode</label>
|
||||
<select
|
||||
value={filters().search_mode}
|
||||
onChange={(e: any) => updateFilter('search_mode', e.target.value)}
|
||||
class="w-full p-2 border rounded-md bg-background"
|
||||
>
|
||||
<option value="fulltext">Full-Text Search</option>
|
||||
<option value="semantic">Semantic Search</option>
|
||||
<option value="hybrid">Hybrid (Coming Soon)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Similarity Threshold (for semantic search) */}
|
||||
<Show when={filters().search_mode === 'semantic'}>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Similarity Threshold: {filters().threshold.toFixed(2)}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1.0"
|
||||
step="0.1"
|
||||
value={filters().threshold}
|
||||
onChange={(e: any) => updateFilter('threshold', parseFloat(e.target.value))}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>More results</span>
|
||||
<span>More precise</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Content Type Filter */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Content Type</label>
|
||||
<select
|
||||
value={filters().content_type}
|
||||
onChange={(e: any) => updateFilter('content_type', e.target.value)}
|
||||
class="w-full p-2 border rounded-md bg-background"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="bookmarks">Bookmarks</option>
|
||||
<option value="tasks">Tasks</option>
|
||||
<option value="notes">Notes</option>
|
||||
<option value="files">Files</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Date Range</label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={filters().date_range.start}
|
||||
onChange={(e: any) => updateFilter('date_range', {
|
||||
...filters().date_range,
|
||||
start: e.target?.value || ''
|
||||
})}
|
||||
placeholder="Start date"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters().date_range.end}
|
||||
onChange={(e: any) => updateFilter('date_range', {
|
||||
...filters().date_range,
|
||||
end: e.target?.value || ''
|
||||
})}
|
||||
placeholder="End date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Filter */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Author</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={filters().author}
|
||||
onChange={(e: any) => updateFilter('author', e.target?.value || '')}
|
||||
placeholder="Filter by author"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Boolean Filters */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Quick Filters</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={filters().is_favorite ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => updateFilter('is_favorite', !filters().is_favorite)}
|
||||
>
|
||||
<IconStar class="size-3 mr-1" />
|
||||
Favorites
|
||||
</Button>
|
||||
<Button
|
||||
variant={filters().is_read ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => updateFilter('is_read', !filters().is_read)}
|
||||
>
|
||||
<IconEye class="size-3 mr-1" />
|
||||
Read
|
||||
</Button>
|
||||
<Button
|
||||
variant={filters().is_public ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => updateFilter('is_public', !filters().is_public)}
|
||||
>
|
||||
<IconEyeOff class="size-3 mr-1" />
|
||||
Public
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Tags */}
|
||||
<Show when={filters().tags.length > 0}>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Active Tags</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={filters().tags}>
|
||||
{(tag) => (
|
||||
<span class="inline-block bg-secondary text-secondary-foreground text-xs px-2 py-1 rounded cursor-pointer hover:bg-secondary/80" onClick={() => toggleTag(tag)}>
|
||||
{tag}
|
||||
<IconX class="inline size-3 ml-1" />
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Search Results */}
|
||||
<div class="space-y-4">
|
||||
<Show when={loading()}>
|
||||
<div class="text-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p class="mt-2 text-muted-foreground">Searching...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && searchResults().length === 0 && searchQuery()}>
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
<IconSearch class="size-12 mx-auto mb-4 opacity-50" />
|
||||
<h3 class="text-lg font-medium mb-2">No results found</h3>
|
||||
<p>Try adjusting your search terms or filters</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && searchResults().length > 0}>
|
||||
<div class="space-y-4">
|
||||
<For each={searchResults()}>
|
||||
{(result) => {
|
||||
const Icon = getIcon(result.type);
|
||||
return (
|
||||
<Card class="p-6 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start gap-4">
|
||||
{/* Type Icon */}
|
||||
<div class={`p-2 rounded-lg bg-muted ${getTypeColor(result.type)}`}>
|
||||
<Icon class="size-5" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold mb-1">{result.title}</h3>
|
||||
<p class="text-muted-foreground mb-2">{result.description}</p>
|
||||
|
||||
{/* Content preview */}
|
||||
<Show when={result.content}>
|
||||
<p class="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||||
{result.content.substring(0, 200)}...
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
{/* Tags */}
|
||||
<Show when={result.tags.length > 0}>
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
<For each={result.tags}>
|
||||
{(tag) => (
|
||||
<span
|
||||
class="inline-block bg-secondary text-secondary-foreground text-xs px-2 py-1 rounded cursor-pointer hover:bg-secondary/80"
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Metadata */}
|
||||
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span class="capitalize">{result.type}</span>
|
||||
<span>Created {formatDate(result.created_at)}</span>
|
||||
<Show when={result.updated_at !== result.created_at}>
|
||||
<span>Updated {formatDate(result.updated_at)}</span>
|
||||
</Show>
|
||||
<Show when={result.author}>
|
||||
<span>By {result.author}</span>
|
||||
</Show>
|
||||
<Show when={result.file_size}>
|
||||
<span>{formatFileSize(result.file_size)}</span>
|
||||
</Show>
|
||||
<Show when={result.score}>
|
||||
<span>Score: {result.score.toFixed(1)}</span>
|
||||
</Show>
|
||||
<Show when={result.similarity !== undefined}>
|
||||
<span>Similarity: {(result.similarity! * 100).toFixed(1)}%</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status/Priority Indicators */}
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<Show when={result.status}>
|
||||
<span class={`inline-block ${getStatusColor(result.status)} text-white text-xs px-2 py-1 rounded`}>
|
||||
{result.status}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={result.priority}>
|
||||
<span class={`inline-block ${getPriorityColor(result.priority)} text-white text-xs px-2 py-1 rounded`}>
|
||||
{result.priority}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={result.is_favorite}>
|
||||
<IconStar class="size-4 text-yellow-500 fill-current" />
|
||||
</Show>
|
||||
<Show when={result.is_read !== undefined}>
|
||||
{result.is_read ? (
|
||||
<IconEye class="size-4 text-green-500" />
|
||||
) : (
|
||||
<IconEyeOff class="size-4 text-gray-400" />
|
||||
)}
|
||||
</Show>
|
||||
<Show when={result.progress !== undefined}>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{result.progress}%
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL for bookmarks */}
|
||||
<Show when={result.url}>
|
||||
<div class="mt-3">
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-blue-500 hover:underline"
|
||||
>
|
||||
{result.url}
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
{/* Load More */}
|
||||
<Show when={searchResults().length < total()}>
|
||||
<div class="text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMore}
|
||||
disabled={loading()}
|
||||
>
|
||||
Load More Results
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'saved'}>
|
||||
<SavedSearches />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createSignal, For, Show, onMount, onCleanup } from 'solid-js';
|
||||
import { IconSearch, IconFileText, IconBookmark, IconChecklist, IconNotebook, IconFolder } from '@tabler/icons-solidjs';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'document' | 'bookmark' | 'task' | 'note' | 'file';
|
||||
url?: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export const QuickSearch = () => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [searchResults, setSearchResults] = createSignal<SearchResult[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
// Mock data for demonstration - replace with actual API calls
|
||||
const mockData: SearchResult[] = [
|
||||
{ id: '1', title: 'Project Documentation', type: 'document', path: '/documents/project-docs' },
|
||||
{ id: '2', title: 'SolidJS Tutorial', type: 'bookmark', url: 'https://solidjs.com/tutorial' },
|
||||
{ id: '3', title: 'Review Pull Request', type: 'task', description: 'Review and merge feature branch' },
|
||||
{ id: '4', title: 'Meeting Notes', type: 'note', path: '/notes/meeting-2024-01-28' },
|
||||
{ id: '5', title: 'Design Assets', type: 'file', path: '/files/design-assets.zip' },
|
||||
{ id: '6', title: 'API Documentation', type: 'document', path: '/documents/api-docs' },
|
||||
{ id: '7', title: 'GitHub Repository', type: 'bookmark', url: 'https://github.com/user/repo' },
|
||||
{ id: '8', title: 'Fix Navigation Bug', type: 'task', description: 'Resolve navigation issue in mobile view' },
|
||||
];
|
||||
|
||||
const performSearch = (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = mockData.filter(item =>
|
||||
item.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.description?.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
setSearchResults(filtered);
|
||||
setSelectedIndex(0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen()) return;
|
||||
|
||||
const results = searchResults();
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev + 1) % results.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev === 0 ? results.length - 1 : prev - 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (results[selectedIndex()]) {
|
||||
handleResultClick(results[selectedIndex()]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (e: InputEvent) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) {
|
||||
const query = target.value;
|
||||
setSearchQuery(query);
|
||||
performSearch(query);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResultClick = (result: SearchResult) => {
|
||||
// Handle navigation or action based on result type
|
||||
if (result.url) {
|
||||
window.open(result.url, '_blank');
|
||||
} else if (result.path) {
|
||||
// Navigate to internal path - you might want to use your router here
|
||||
console.log('Navigate to:', result.path);
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
const getIcon = (type: SearchResult['type']) => {
|
||||
switch (type) {
|
||||
case 'document':
|
||||
return IconFileText;
|
||||
case 'bookmark':
|
||||
return IconBookmark;
|
||||
case 'task':
|
||||
return IconChecklist;
|
||||
case 'note':
|
||||
return IconNotebook;
|
||||
case 'file':
|
||||
return IconFolder;
|
||||
default:
|
||||
return IconFileText;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: SearchResult['type']) => {
|
||||
switch (type) {
|
||||
case 'document':
|
||||
return 'text-blue-400';
|
||||
case 'bookmark':
|
||||
return 'text-green-400';
|
||||
case 'task':
|
||||
return 'text-yellow-400';
|
||||
case 'note':
|
||||
return 'text-purple-400';
|
||||
case 'file':
|
||||
return 'text-orange-400';
|
||||
default:
|
||||
return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
// Global keyboard shortcut (Ctrl/Cmd + K) and Escape to close
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
} else if (e.key === 'Escape' && isOpen()) {
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Search Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
type="button"
|
||||
class="inline-flex items-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 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2 lg:min-w-64 justify-start"
|
||||
>
|
||||
<IconSearch class="size-4 mr-2" />
|
||||
Quick search
|
||||
<span class="ml-auto text-xs text-muted-foreground hidden sm:inline">⌘ K</span>
|
||||
</button>
|
||||
|
||||
{/* Search Modal */}
|
||||
<Show when={isOpen()}>
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/50 backdrop-blur-sm">
|
||||
<div class="w-full max-w-2xl mx-4">
|
||||
<Card class="p-4 shadow-2xl">
|
||||
{/* Search Input */}
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<IconSearch class="size-5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search documents, bookmarks, tasks, notes..."
|
||||
value={searchQuery()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
class="border-0 shadow-none text-base focus-visible:ring-0"
|
||||
ref={(el: HTMLInputElement) => el?.focus()}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span class="text-sm">ESC</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
<Show when={searchResults().length > 0}>
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<For each={searchResults()}>
|
||||
{(result, index) => {
|
||||
const Icon = getIcon(result.type);
|
||||
return (
|
||||
<div
|
||||
class={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
|
||||
index() === selectedIndex() ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => handleResultClick(result)}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
>
|
||||
<Icon class={`size-5 ${getTypeColor(result.type)}`} />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm truncate">{result.title}</div>
|
||||
<div class="text-xs text-muted-foreground truncate">
|
||||
{result.description || result.url || result.path}
|
||||
</div>
|
||||
</div>
|
||||
<span class={`text-xs ${getTypeColor(result.type)} capitalize`}>
|
||||
{result.type}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={searchQuery() && searchResults().length === 0}>
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
<IconSearch class="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No results found for "{searchQuery()}"</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Initial State */}
|
||||
<Show when={!searchQuery()}>
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
<p class="text-sm">Start typing to search across your workspace...</p>
|
||||
<div class="flex justify-center gap-4 mt-4 text-xs">
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
<span>ESC Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,483 @@
|
||||
import { createSignal, For, Show, onMount } from 'solid-js';
|
||||
import {
|
||||
IconBookmark,
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconPlayerPlay,
|
||||
IconBell,
|
||||
IconTag,
|
||||
IconEye,
|
||||
IconX,
|
||||
IconClock
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
interface SavedSearch {
|
||||
id: number;
|
||||
name: string;
|
||||
query: string;
|
||||
filters: Record<string, any>;
|
||||
alert: boolean;
|
||||
last_run?: string;
|
||||
run_count: number;
|
||||
is_public: boolean;
|
||||
description?: string;
|
||||
tags: Array<{ id: number; name: string; color: string }>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SavedSearchFormData {
|
||||
name: string;
|
||||
query: string;
|
||||
filters: Record<string, any>;
|
||||
alert: boolean;
|
||||
is_public: boolean;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const SavedSearches = () => {
|
||||
const [savedSearches, setSavedSearches] = createSignal<SavedSearch[]>([]);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [showCreateModal, setShowCreateModal] = createSignal(false);
|
||||
const [editingSearch, setEditingSearch] = createSignal<SavedSearch | null>(null);
|
||||
const [formData, setFormData] = createSignal<SavedSearchFormData>({
|
||||
name: '',
|
||||
query: '',
|
||||
filters: {},
|
||||
alert: false,
|
||||
is_public: false,
|
||||
description: '',
|
||||
tags: []
|
||||
});
|
||||
|
||||
// Load saved searches
|
||||
const loadSavedSearches = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/saved`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSavedSearches(data.saved_searches || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load saved searches:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load available tags
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/saved/tags`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Tags loaded but not used currently
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create or update saved search
|
||||
const saveSavedSearch = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const isEditing = editingSearch() !== null;
|
||||
const url = isEditing
|
||||
? `/api/v1/search/saved/${editingSearch()!.id}`
|
||||
: '/api/v1/search/saved';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: isEditing ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(formData())
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setShowCreateModal(false);
|
||||
setEditingSearch(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
query: '',
|
||||
filters: {},
|
||||
alert: false,
|
||||
is_public: false,
|
||||
description: '',
|
||||
tags: []
|
||||
});
|
||||
loadSavedSearches();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save saved search:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete saved search
|
||||
const deleteSavedSearch = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this saved search?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/saved/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadSavedSearches();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete saved search:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Run saved search
|
||||
const runSavedSearch = async (id: number) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/search/saved/${id}/run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Navigate to search results or show results in modal
|
||||
console.log('Search results:', data);
|
||||
// For now, just reload to show updated run_count
|
||||
loadSavedSearches();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to run saved search:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Edit saved search
|
||||
const editSavedSearch = (search: SavedSearch) => {
|
||||
setEditingSearch(search);
|
||||
setFormData({
|
||||
name: search.name,
|
||||
query: search.query,
|
||||
filters: search.filters,
|
||||
alert: search.alert,
|
||||
is_public: search.is_public,
|
||||
description: search.description || '',
|
||||
tags: search.tags.map(tag => tag.name)
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
// Handle form input changes
|
||||
const updateFormData = (field: keyof SavedSearchFormData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
// Format relative time
|
||||
const formatRelativeTime = (dateString?: string) => {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||
return formatDate(dateString);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
loadSavedSearches();
|
||||
loadTags();
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
<IconBookmark class="size-8" />
|
||||
Saved Searches
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Save and manage your frequently used searches with alerts
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<IconPlus class="size-4 mr-2" />
|
||||
New Saved Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={loading()}>
|
||||
<div class="text-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p class="mt-2 text-muted-foreground">Loading saved searches...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Saved Searches List */}
|
||||
<Show when={!loading() && savedSearches().length === 0}>
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
<IconBookmark class="size-12 mx-auto mb-4 opacity-50" />
|
||||
<h3 class="text-lg font-medium mb-2">No saved searches yet</h3>
|
||||
<p>Create your first saved search to get started</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && savedSearches().length > 0}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={savedSearches()}>
|
||||
{(search) => (
|
||||
<Card class="p-6 hover:shadow-md transition-shadow">
|
||||
<div class="space-y-4">
|
||||
{/* Header */}
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-lg mb-1">{search.name}</h3>
|
||||
<Show when={search.description}>
|
||||
<p class="text-sm text-muted-foreground mb-2">{search.description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={search.alert}>
|
||||
<IconBell class="size-4 text-blue-500" />
|
||||
</Show>
|
||||
<Show when={search.is_public}>
|
||||
<IconEye class="size-4 text-green-500" />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Query */}
|
||||
<div class="bg-muted p-3 rounded-md">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<IconSearch class="size-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Query</span>
|
||||
</div>
|
||||
<p class="text-sm font-mono">{search.query}</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<Show when={search.tags.length > 0}>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={search.tags}>
|
||||
{(tag) => (
|
||||
<span
|
||||
class="inline-block text-xs px-2 py-1 rounded"
|
||||
style={{
|
||||
'background-color': tag.color + '20',
|
||||
'color': tag.color,
|
||||
'border': `1px solid ${tag.color}40`
|
||||
}}
|
||||
>
|
||||
<IconTag class="inline size-3 mr-1" />
|
||||
{tag.name}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Metadata */}
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconClock class="size-3" />
|
||||
<span>Run {search.run_count} times</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconClock class="size-3" />
|
||||
<span>Last run: {formatRelativeTime(search.last_run)}</span>
|
||||
</div>
|
||||
<div>Created {formatDate(search.created_at)}</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => runSavedSearch(search.id)}
|
||||
class="flex-1"
|
||||
>
|
||||
<IconPlayerPlay class="size-3 mr-1" />
|
||||
Run
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => editSavedSearch(search)}
|
||||
>
|
||||
<IconEdit class="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteSavedSearch(search.id)}
|
||||
>
|
||||
<IconTrash class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Show when={showCreateModal()}>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<Card class="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold">
|
||||
{editingSearch() ? 'Edit Saved Search' : 'Create Saved Search'}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setEditingSearch(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
query: '',
|
||||
filters: {},
|
||||
alert: false,
|
||||
is_public: false,
|
||||
description: '',
|
||||
tags: []
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{/* Name */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Name *</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData().name}
|
||||
onInput={(e: any) => updateFormData('name', e.target?.value || '')}
|
||||
placeholder="Enter a descriptive name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Query */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Search Query *</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData().query}
|
||||
onInput={(e: any) => updateFormData('query', e.target?.value || '')}
|
||||
placeholder="Enter your search query"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Description</label>
|
||||
<textarea
|
||||
class="w-full p-2 border rounded-md bg-background min-h-[80px]"
|
||||
value={formData().description}
|
||||
onInput={(e: any) => updateFormData('description', e.target?.value || '')}
|
||||
placeholder="Optional description of this saved search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Tags</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData().tags.join(', ')}
|
||||
onInput={(e: any) => updateFormData('tags', e.target?.value?.split(',').map((t: string) => t.trim()).filter(Boolean) || [])}
|
||||
placeholder="Enter tags separated by commas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData().alert}
|
||||
onChange={(e: any) => updateFormData('alert', e.target.checked)}
|
||||
class="rounded"
|
||||
/>
|
||||
<span class="text-sm">Enable alerts for new results</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData().is_public}
|
||||
onChange={(e: any) => updateFormData('is_public', e.target.checked)}
|
||||
class="rounded"
|
||||
/>
|
||||
<span class="text-sm">Make this saved search public</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
class="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveSavedSearch}
|
||||
disabled={!formData().name || !formData().query}
|
||||
class="flex-1"
|
||||
>
|
||||
{editingSearch() ? 'Update' : 'Create'} Saved Search
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
import { createSignal, For, onMount, createEffect, Show } from 'solid-js';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
IconBookmark,
|
||||
IconChecklist,
|
||||
IconNotebook,
|
||||
IconFileText,
|
||||
IconGitPullRequest,
|
||||
IconGitCommit,
|
||||
IconStar,
|
||||
IconGitFork,
|
||||
IconClock,
|
||||
IconExternalLink
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: 'bookmark' | 'task' | 'note' | 'file' | 'github_commit' | 'github_pr' | 'github_star' | 'github_fork';
|
||||
title: string;
|
||||
description?: string;
|
||||
timestamp: string;
|
||||
source: 'trackeep' | 'github';
|
||||
metadata?: {
|
||||
repo?: string;
|
||||
url?: string;
|
||||
author?: string;
|
||||
branch?: string;
|
||||
language?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
limit?: number;
|
||||
showFilter?: boolean;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
const [activities, setActivities] = createSignal<ActivityItem[]>([]);
|
||||
const [filter, setFilter] = createSignal<'all' | 'trackeep' | 'github'>('all');
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
// Debounce filter changes to prevent excessive re-renders
|
||||
const debouncedFilter = useDebounce(filter, 300);
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'bookmark': return IconBookmark;
|
||||
case 'task': return IconChecklist;
|
||||
case 'note': return IconNotebook;
|
||||
case 'file': return IconFileText;
|
||||
case 'github_commit': return IconGitCommit;
|
||||
case 'github_pr': return IconGitPullRequest;
|
||||
case 'github_star': return IconStar;
|
||||
case 'github_fork': return IconGitFork;
|
||||
default: return IconClock;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchActivities = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Import mock data for demo mode
|
||||
const { getMockActivities } = await import('@/lib/mockData');
|
||||
|
||||
// Combine and format activities
|
||||
const combinedActivities: ActivityItem[] = [];
|
||||
|
||||
// Add Trackeep activities from mock data
|
||||
const mockActivities = getMockActivities();
|
||||
const now = new Date();
|
||||
|
||||
mockActivities.forEach((activity, index) => {
|
||||
// Create realistic timestamps
|
||||
const timestamp = new Date(now.getTime() - (index * 3600000)); // Each activity 1 hour apart
|
||||
|
||||
combinedActivities.push({
|
||||
id: activity.id,
|
||||
type: activity.type as any,
|
||||
title: activity.title,
|
||||
description: `${activity.action} ${activity.type}`,
|
||||
timestamp: timestamp.toISOString(),
|
||||
source: 'trackeep' as const,
|
||||
metadata: {
|
||||
tags: activity.details?.tags ? Object.keys(activity.details.tags) : undefined
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add some GitHub-style activities
|
||||
const githubActivities = [
|
||||
{
|
||||
id: 'github_1',
|
||||
type: 'github_commit' as const,
|
||||
title: 'Fixed responsive design issues',
|
||||
description: 'Resolved mobile layout problems on dashboard',
|
||||
timestamp: new Date(now.getTime() - 2 * 3600000).toISOString(),
|
||||
source: 'github' as const,
|
||||
metadata: {
|
||||
repo: 'tdvorak/trackeep',
|
||||
url: 'https://github.com/tdvorak/trackeep/commit/abc123',
|
||||
branch: 'main',
|
||||
language: 'Go'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'github_2',
|
||||
type: 'github_pr' as const,
|
||||
title: 'Add AI chat integration',
|
||||
description: 'Implement LongCat AI provider with model switching',
|
||||
timestamp: new Date(now.getTime() - 5 * 3600000).toISOString(),
|
||||
source: 'github' as const,
|
||||
metadata: {
|
||||
repo: 'tdvorak/trackeep',
|
||||
url: 'https://github.com/tdvorak/trackeep/pull/42',
|
||||
branch: 'feature/ai-chat',
|
||||
language: 'TypeScript'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'github_3',
|
||||
type: 'github_star' as const,
|
||||
title: '⭐ trackeep gained new stars',
|
||||
description: 'Repository reached 245 stars',
|
||||
timestamp: new Date(now.getTime() - 8 * 3600000).toISOString(),
|
||||
source: 'github' as const,
|
||||
metadata: {
|
||||
repo: 'tdvorak/trackeep',
|
||||
url: 'https://github.com/tdvorak/trackeep'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
combinedActivities.push(...githubActivities);
|
||||
|
||||
// Sort by timestamp (most recent first)
|
||||
combinedActivities.sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
// Apply filter
|
||||
const filteredActivities = filter() === 'all'
|
||||
? combinedActivities
|
||||
: combinedActivities.filter(a => a.source === filter());
|
||||
|
||||
// Apply limit
|
||||
const limitedActivities = props.limit
|
||||
? filteredActivities.slice(0, props.limit)
|
||||
: filteredActivities;
|
||||
|
||||
setActivities(limitedActivities);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch activities:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
fetchActivities();
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchActivities, 30000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
// Refetch when refreshKey changes
|
||||
createEffect(() => {
|
||||
if (props.refreshKey !== undefined) {
|
||||
fetchActivities();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full space-y-4">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-[#a3a3a3]">({activities().length} items)</span>
|
||||
</div>
|
||||
|
||||
{props.showFilter && (
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
class={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
filter() === 'all'
|
||||
? 'bg-[#262626] text-[#fafafa]'
|
||||
: 'text-[#a3a3a3] hover:text-[#fafafa]'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('trackeep')}
|
||||
class={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
filter() === 'trackeep'
|
||||
? 'bg-[#262626] text-[#fafafa]'
|
||||
: 'text-[#a3a3a3] hover:text-[#fafafa]'
|
||||
}`}
|
||||
>
|
||||
Trackeep
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('github')}
|
||||
class={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
filter() === 'github'
|
||||
? 'bg-[#262626] text-[#fafafa]'
|
||||
: 'text-[#a3a3a3] hover:text-[#fafafa]'
|
||||
}`}
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading() && (
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity List */}
|
||||
<div class="space-y-3 flex-1 min-h-0 overflow-y-auto max-h-96 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
<For each={activities()}>
|
||||
{(activity) => {
|
||||
const Icon = getActivityIcon(activity.type);
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-between p-3 bg-card rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-2 rounded-lg">
|
||||
<Icon class="size-4 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-foreground font-medium">
|
||||
{activity.title}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||
<span>{new Date(activity.timestamp).toISOString().split('T')[0]}</span>
|
||||
<span>•</span>
|
||||
<span class="text-primary">
|
||||
{activity.source === 'github'
|
||||
? (activity.metadata?.repo?.split('/').pop() || 'GitHub')
|
||||
: 'trackeep'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{activity.source === 'github'
|
||||
? activity.type === 'github_commit'
|
||||
? 'pushed'
|
||||
: activity.type === 'github_pr'
|
||||
? 'opened PR'
|
||||
: activity.type === 'github_star'
|
||||
? 'starred'
|
||||
: activity.type === 'github_fork'
|
||||
? 'forked'
|
||||
: 'activity'
|
||||
: activity.description || activity.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activity.metadata?.url && (
|
||||
<a
|
||||
href={activity.metadata.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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 ml-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconExternalLink class="size-4 text-primary" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading() && activities().length === 0 && (
|
||||
<div class="text-center py-8">
|
||||
<IconClock class="size-12 text-[#a3a3a3] mx-auto mb-4" />
|
||||
<p class="text-[#a3a3a3]">No recent activity found</p>
|
||||
<p class="text-sm text-[#a3a3a3] mt-1">
|
||||
{filter() === 'github' ? 'Connect your GitHub account to see activity' : 'Start using Trackeep to see your activity here'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createSignal, createEffect } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagPicker } from '@/components/ui/TagPicker';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface BookmarkModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (bookmark: any) => void;
|
||||
availableTags?: string[];
|
||||
}
|
||||
|
||||
export const BookmarkModal = (props: BookmarkModalProps) => {
|
||||
const [newBookmark, setNewBookmark] = createSignal({
|
||||
title: '',
|
||||
url: '',
|
||||
description: ''
|
||||
});
|
||||
const [faviconPreview, setFaviconPreview] = createSignal('');
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
|
||||
const defaultTags = ['reading', 'article', 'dev', 'tutorial', 'docs', 'tool', 'video', 'personal', 'work'];
|
||||
const availableTags = () => (props.availableTags && props.availableTags.length > 0 ? props.availableTags : defaultTags);
|
||||
|
||||
// Update favicon preview when URL changes
|
||||
createEffect(() => {
|
||||
const url = newBookmark().url;
|
||||
if (url) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`;
|
||||
setFaviconPreview(faviconUrl);
|
||||
} catch {
|
||||
setFaviconPreview('');
|
||||
}
|
||||
} else {
|
||||
setFaviconPreview('');
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
const bookmark = {
|
||||
title: newBookmark().title || newBookmark().url,
|
||||
url: newBookmark().url,
|
||||
description: newBookmark().description,
|
||||
tags: tags()
|
||||
};
|
||||
props.onSubmit(bookmark);
|
||||
setNewBookmark({ title: '', url: '', description: '' });
|
||||
setTags([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 500px; max-width: 90vw;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">Add New Bookmark</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="relative">
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="URL *"
|
||||
value={newBookmark().url}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setNewBookmark(prev => ({ ...prev, url: target.value }));
|
||||
}}
|
||||
required
|
||||
class="pr-12"
|
||||
/>
|
||||
{faviconPreview() && (
|
||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2 w-6 h-6 bg-muted rounded flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
src={faviconPreview()}
|
||||
alt="Site favicon"
|
||||
class="w-4 h-4 object-contain"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Title (optional)"
|
||||
value={newBookmark().title}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setNewBookmark(prev => ({ ...prev, title: target.value }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Description (optional)"
|
||||
value={newBookmark().description}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setNewBookmark(prev => ({ ...prev, description: target.value }));
|
||||
}}
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-muted-foreground">Tags</label>
|
||||
<TagPicker
|
||||
availableTags={availableTags()}
|
||||
selectedTags={tags()}
|
||||
onTagsChange={(next) => setTags(next)}
|
||||
placeholder="Add tags..."
|
||||
allowNew={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!newBookmark().url.trim()}>
|
||||
Save Bookmark
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,34 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { splitProps } from 'solid-js'
|
||||
import { splitProps, Show } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IconLoader2 } from '@tabler/icons-solidjs'
|
||||
|
||||
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',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-papra 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',
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
'border border-input bg-background hover:bg-accent/50 hover:text-accent-foreground shadow-sm',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm',
|
||||
ghost: 'text-primary hover:bg-accent/50 hover:text-primary/80',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
papra: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-md hover:shadow-lg transition-all duration-200',
|
||||
papraOutline: 'border border-border bg-card hover:bg-accent/50 hover:text-accent-foreground shadow-sm hover:shadow-md transition-all duration-200',
|
||||
papraGhost: 'hover:bg-accent/50 hover:text-accent-foreground transition-all duration-200',
|
||||
papraSecondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm hover:shadow-md transition-all duration-200',
|
||||
},
|
||||
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',
|
||||
iconSm: 'h-9 w-9',
|
||||
iconLg: 'h-11 w-11',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -36,7 +43,8 @@ export interface ButtonProps
|
||||
asChild?: boolean
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
loading?: boolean
|
||||
onClick?: (e: MouseEvent) => void
|
||||
children: any
|
||||
}
|
||||
|
||||
@@ -47,18 +55,26 @@ export function Button(props: ButtonProps) {
|
||||
'class',
|
||||
'asChild',
|
||||
'disabled',
|
||||
'loading',
|
||||
'onClick',
|
||||
'children',
|
||||
])
|
||||
|
||||
const isDisabled = () => local.disabled || local.loading
|
||||
|
||||
return (
|
||||
<button
|
||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
||||
disabled={local.disabled}
|
||||
disabled={isDisabled()}
|
||||
onClick={local.onClick}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
<Show when={local.loading}>
|
||||
<IconLoader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
</Show>
|
||||
<Show when={!local.loading}>
|
||||
{local.children}
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,17 +4,19 @@ import { cn } from '@/lib/utils'
|
||||
export interface CardProps {
|
||||
class?: string
|
||||
children: any
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function Card(props: CardProps) {
|
||||
const [local, others] = splitProps(props, ['class', 'children'])
|
||||
const [local, others] = splitProps(props, ['class', 'children', 'onClick'])
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
'rounded-lg border bg-[#141415] text-[#fafafa] shadow-sm border-[#262626]',
|
||||
'card-papra',
|
||||
local.class
|
||||
)}
|
||||
onClick={local.onClick}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
@@ -49,7 +51,7 @@ export function CardDescription(props: CardProps) {
|
||||
const [local, others] = splitProps(props, ['class', 'children'])
|
||||
|
||||
return (
|
||||
<p class={cn('text-sm text-[#a3a3a3]', local.class)} {...others}>
|
||||
<p class={cn('text-sm text-muted-foreground', local.class)} {...others}>
|
||||
{local.children}
|
||||
</p>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { IconPalette, IconCheck, IconChevronDown } from '@tabler/icons-solidjs';
|
||||
|
||||
interface ColorScheme {
|
||||
name: string;
|
||||
primary: string;
|
||||
}
|
||||
|
||||
export const ColorSwitcherDropdown = () => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [currentScheme, setCurrentScheme] = createSignal('default');
|
||||
const [schemes, setSchemes] = createSignal<ColorScheme[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
// Load saved color scheme from localStorage
|
||||
const savedScheme = localStorage.getItem('trackeep-color-scheme');
|
||||
if (savedScheme) {
|
||||
setCurrentScheme(savedScheme);
|
||||
}
|
||||
|
||||
// Predefined color schemes - only changing primary color (removed monochrome)
|
||||
setSchemes([
|
||||
{ name: 'default', primary: '#5ab9ff' },
|
||||
{ name: 'ocean', primary: '#0077be' },
|
||||
{ name: 'forest', primary: '#228b22' },
|
||||
{ name: 'sunset', primary: '#ff6b35' },
|
||||
{ name: 'purple', primary: '#8b5cf6' }
|
||||
]);
|
||||
|
||||
// Apply saved scheme on mount
|
||||
if (savedScheme) {
|
||||
const scheme = schemes().find(s => s.name === savedScheme);
|
||||
if (scheme) {
|
||||
applyScheme(scheme, false); // false = don't close dropdown
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const applyScheme = (scheme: ColorScheme, closeDropdown = true) => {
|
||||
setCurrentScheme(scheme.name);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem('trackeep-color-scheme', scheme.name);
|
||||
|
||||
// Apply only primary color to CSS variables
|
||||
const root = document.documentElement;
|
||||
|
||||
// Convert hex to HSL for CSS variables
|
||||
const hexToHsl = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return '0 0% 100%';
|
||||
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
};
|
||||
|
||||
// Apply only the primary color
|
||||
root.style.setProperty('--primary', hexToHsl(scheme.primary));
|
||||
root.style.setProperty('--colors-primary', hexToHsl(scheme.primary));
|
||||
|
||||
if (closeDropdown) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class="items-center justify-center rounded-md font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-8 px-3 py-1 text-base flex gap-1"
|
||||
>
|
||||
<IconPalette class="size-4" />
|
||||
<IconChevronDown class="text-muted-foreground text-sm" />
|
||||
</button>
|
||||
|
||||
{isOpen() && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
<div class="absolute right-0 top-full mt-1 w-56 bg-card border border-border rounded-md shadow-lg z-50">
|
||||
<div class="p-2">
|
||||
{schemes().map((scheme) => (
|
||||
<button
|
||||
onClick={() => applyScheme(scheme)}
|
||||
class={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm hover:bg-accent hover:text-accent-foreground transition-colors ${
|
||||
currentScheme() === scheme.name ? 'bg-accent/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 rounded border border-border"
|
||||
style={`background-color: ${scheme.primary}`}
|
||||
/>
|
||||
<span class="capitalize">{scheme.name}</span>
|
||||
{currentScheme() === scheme.name && (
|
||||
<IconCheck class="size-4 text-primary ml-auto" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { IconX, IconAlertTriangle } from '@tabler/icons-solidjs';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
type?: 'danger' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export const ConfirmModal = (props: ConfirmModalProps) => {
|
||||
const {
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
type = 'warning'
|
||||
} = props;
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return <IconAlertTriangle class="size-6 text-red-500" />;
|
||||
case 'warning':
|
||||
return <IconAlertTriangle class="size-6 text-yellow-500" />;
|
||||
default:
|
||||
return <IconAlertTriangle class="size-6 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfirmButtonVariant = () => {
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return 'destructive' as const;
|
||||
default:
|
||||
return 'default' as const;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 400px; max-width: 90vw;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
{getIcon()}
|
||||
<h3 class="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6">
|
||||
<p class="text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button variant={getConfirmButtonVariant()} onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,287 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { IconChevronLeft, IconChevronRight, IconCalendar } from '@tabler/icons-solidjs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TimePicker } from './TimePicker';
|
||||
|
||||
export interface DatePickerProps {
|
||||
value?: Date;
|
||||
onChange?: (date: Date | null) => void;
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DatePicker = (props: DatePickerProps) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [selectedDate, setSelectedDate] = createSignal<Date | undefined>(props.value);
|
||||
const [currentMonth, setCurrentMonth] = createSignal(new Date());
|
||||
const [position, setPosition] = createSignal({ top: 0, left: 0, width: 0 });
|
||||
|
||||
let triggerRef: HTMLButtonElement | undefined;
|
||||
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||
};
|
||||
|
||||
const getFirstDayOfMonth = (date: Date) => {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
};
|
||||
|
||||
const generateCalendarDays = () => {
|
||||
const days = [];
|
||||
const daysInMonth = getDaysInMonth(currentMonth());
|
||||
const firstDay = getFirstDayOfMonth(currentMonth());
|
||||
|
||||
// Add empty cells for days before month starts
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
// Add days of the month
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const handleDateSelect = (day: number) => {
|
||||
const newDate = new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day);
|
||||
setSelectedDate(newDate);
|
||||
props.onChange?.(newDate);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
setCurrentMonth(new Date(currentMonth().getFullYear(), currentMonth().getMonth() - 1));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setCurrentMonth(new Date(currentMonth().getFullYear(), currentMonth().getMonth() + 1));
|
||||
};
|
||||
|
||||
const handleToggleModal = () => {
|
||||
if (props.disabled) return;
|
||||
|
||||
if (!isOpen()) {
|
||||
if (!triggerRef) return;
|
||||
|
||||
const rect = triggerRef.getBoundingClientRect();
|
||||
const estimatedHeight = 360; // approximate dropdown height
|
||||
|
||||
let top = rect.bottom + window.scrollY + 4; // default below
|
||||
const viewportBottom = window.scrollY + window.innerHeight;
|
||||
|
||||
// If there isn't enough space below, open above the trigger
|
||||
if (top + estimatedHeight > viewportBottom) {
|
||||
top = rect.top + window.scrollY - estimatedHeight - 4;
|
||||
}
|
||||
|
||||
const minWidth = 260;
|
||||
const width = Math.max(rect.width, minWidth);
|
||||
|
||||
let left = rect.left + window.scrollX;
|
||||
const maxLeft = window.scrollX + window.innerWidth - width - 16; // 16px margin to screen edge
|
||||
if (left > maxLeft) {
|
||||
left = maxLeft;
|
||||
}
|
||||
if (left < window.scrollX + 16) {
|
||||
left = window.scrollX + 16;
|
||||
}
|
||||
|
||||
setPosition({ top, left, width });
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen());
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleModal}
|
||||
disabled={props.disabled}
|
||||
class={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 text-left",
|
||||
props.class
|
||||
)}
|
||||
id={props.id || 'date-picker-button'}
|
||||
ref={triggerRef}
|
||||
>
|
||||
<Show when={selectedDate()} fallback={<span class="text-muted-foreground">{props.placeholder || "Select date"}</span>}>
|
||||
<span>{formatDate(selectedDate()!)}</span>
|
||||
</Show>
|
||||
<IconCalendar class="ml-auto h-4 w-4 opacity-50" />
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<Portal>
|
||||
{/* Close on outside click */}
|
||||
<div
|
||||
class="fixed inset-0 z-[120]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"fixed z-[130] p-3 bg-popover text-popover-foreground border rounded-md shadow-md",
|
||||
"max-w-[calc(100vw-2rem)]"
|
||||
)}
|
||||
style={{
|
||||
top: `${position().top}px`,
|
||||
left: `${position().left}px`,
|
||||
width: `${position().width}px`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium 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 hover:bg-accent hover:text-accent-foreground h-7 w-7"
|
||||
>
|
||||
<IconChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="text-sm font-medium">
|
||||
{months[currentMonth().getMonth()]} {currentMonth().getFullYear()}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium 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 hover:bg-accent hover:text-accent-foreground h-7 w-7"
|
||||
>
|
||||
<IconChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Week days */}
|
||||
<div class="grid grid-cols-7 gap-1 mb-2">
|
||||
<For each={weekDays}>
|
||||
{(day) => (
|
||||
<div class="h-8 text-xs text-muted-foreground font-normal text-center">
|
||||
{day}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Calendar days */}
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<For each={generateCalendarDays()}>
|
||||
{(day) => (
|
||||
<div class="h-8">
|
||||
<Show when={day !== null}>
|
||||
<button
|
||||
onClick={() => handleDateSelect(day!)}
|
||||
class={cn(
|
||||
"block w-full h-8 rounded-md text-center text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
selectedDate() &&
|
||||
selectedDate()!.getDate() === day! &&
|
||||
selectedDate()!.getMonth() === currentMonth().getMonth() &&
|
||||
selectedDate()!.getFullYear() === currentMonth().getFullYear()
|
||||
? "bg-primary text-primary-foreground"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface DateTimePickerProps {
|
||||
value?: Date;
|
||||
onChange?: (date: Date | null) => void;
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
dateOnly?: boolean; // New prop for date-only mode
|
||||
}
|
||||
|
||||
export const DateTimePicker = (props: DateTimePickerProps) => {
|
||||
const [date, setDate] = createSignal<Date | undefined>(props.value);
|
||||
const [time, setTime] = createSignal<string>(
|
||||
props.value ?
|
||||
props.value.toTimeString().slice(0, 5) :
|
||||
'12:00'
|
||||
);
|
||||
|
||||
const handleDateChange = (newDate: Date | null) => {
|
||||
if (newDate) {
|
||||
const [hours, minutes] = time().split(':').map(Number);
|
||||
newDate.setHours(hours, minutes);
|
||||
setDate(newDate);
|
||||
props.onChange?.(newDate);
|
||||
} else {
|
||||
setDate(undefined);
|
||||
props.onChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeChange = (newTime: string) => {
|
||||
setTime(newTime);
|
||||
if (date()) {
|
||||
const [hours, minutes] = newTime.split(':').map(Number);
|
||||
const newDate = new Date(date()!);
|
||||
newDate.setHours(hours, minutes);
|
||||
setDate(newDate);
|
||||
props.onChange?.(newDate);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.dateOnly} fallback={
|
||||
<div class="flex flex-col gap-2">
|
||||
<DatePicker
|
||||
value={date()}
|
||||
onChange={handleDateChange}
|
||||
placeholder={props.placeholder || "Select date"}
|
||||
class="w-full"
|
||||
id={props.id ? `${props.id}-date` : undefined}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
<TimePicker
|
||||
value={time()}
|
||||
onChange={handleTimeChange}
|
||||
disabled={props.disabled}
|
||||
class="w-full"
|
||||
id={props.id ? `${props.id}-time` : undefined}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<DatePicker
|
||||
value={date()}
|
||||
onChange={handleDateChange}
|
||||
placeholder={props.placeholder || "Select date"}
|
||||
class="w-full"
|
||||
id={props.id ? `${props.id}-date` : undefined}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ComponentProps } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DialogProps extends ComponentProps<'div'> {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export interface DialogContentProps extends ComponentProps<'div'> {}
|
||||
|
||||
export interface DialogHeaderProps extends ComponentProps<'div'> {}
|
||||
|
||||
export interface DialogTitleProps extends ComponentProps<'h2'> {}
|
||||
|
||||
export interface DialogDescriptionProps extends ComponentProps<'p'> {}
|
||||
|
||||
export interface DialogFooterProps extends ComponentProps<'div'> {}
|
||||
|
||||
const Dialog = (props: DialogProps) => {
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
props.class
|
||||
)}
|
||||
data-state={props.open ? 'open' : 'closed'}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogContent = (props: DialogContentProps) => {
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogHeader = (props: DialogHeaderProps) => {
|
||||
return (
|
||||
<div
|
||||
class={cn('flex flex-col space-y-1.5 text-center sm:text-left', props.class)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogTitle = (props: DialogTitleProps) => {
|
||||
return (
|
||||
<h2
|
||||
class={cn('text-lg font-semibold leading-none tracking-tight', props.class)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogDescription = (props: DialogDescriptionProps) => {
|
||||
return (
|
||||
<p
|
||||
class={cn('text-sm text-muted-foreground', props.class)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogFooter = (props: DialogFooterProps) => {
|
||||
return (
|
||||
<div
|
||||
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', props.class)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
interface DropdownMenuProps {
|
||||
trigger: any;
|
||||
children: any;
|
||||
}
|
||||
|
||||
interface DropdownMenuItemProps {
|
||||
onClick: () => void;
|
||||
icon: any;
|
||||
children: any;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export const DropdownMenu = (props: DropdownMenuProps) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
return (
|
||||
<div class="relative inline-block text-left">
|
||||
<div onClick={() => setIsOpen(!isOpen())}>
|
||||
{props.trigger}
|
||||
</div>
|
||||
|
||||
{isOpen() && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
<div class="absolute right-0 top-full mt-1 w-48 bg-card border border-border rounded-md shadow-lg z-50">
|
||||
<div class="py-1">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenuItem = (props: DropdownMenuItemProps) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
props.onClick();
|
||||
// Close parent dropdown by triggering a click outside
|
||||
document.dispatchEvent(new MouseEvent('click'));
|
||||
}}
|
||||
class={`w-full text-left px-4 py-2 text-sm hover:bg-accent hover:text-accent-foreground flex items-center gap-2 ${
|
||||
props.variant === 'destructive' ? 'text-destructive hover:text-destructive' : ''
|
||||
}`}
|
||||
>
|
||||
<props.icon class="size-4" />
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TagPicker } from '@/components/ui/TagPicker';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Bookmark {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface EditBookmarkModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (bookmark: Partial<Bookmark>) => void;
|
||||
bookmark: Bookmark | null;
|
||||
availableTags?: string[];
|
||||
}
|
||||
|
||||
export const EditBookmarkModal = (props: EditBookmarkModalProps) => {
|
||||
const [editBookmark, setEditBookmark] = createSignal({
|
||||
title: '',
|
||||
url: '',
|
||||
description: ''
|
||||
});
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
|
||||
const defaultTags = ['reading', 'article', 'dev', 'tutorial', 'docs', 'tool', 'video', 'personal', 'work'];
|
||||
const availableTags = () => (props.availableTags && props.availableTags.length > 0 ? props.availableTags : defaultTags);
|
||||
|
||||
// Update form when bookmark changes
|
||||
onMount(() => {
|
||||
if (props.bookmark) {
|
||||
setEditBookmark({
|
||||
title: props.bookmark.title,
|
||||
url: props.bookmark.url,
|
||||
description: props.bookmark.description || ''
|
||||
});
|
||||
setTags(props.bookmark.tags || []);
|
||||
}
|
||||
});
|
||||
|
||||
// Update form when bookmark prop changes
|
||||
const updateForm = () => {
|
||||
if (props.bookmark) {
|
||||
setEditBookmark({
|
||||
title: props.bookmark.title,
|
||||
url: props.bookmark.url,
|
||||
description: props.bookmark.description || ''
|
||||
});
|
||||
setTags(props.bookmark.tags || []);
|
||||
}
|
||||
};
|
||||
|
||||
// Call updateForm when bookmark changes
|
||||
if (props.bookmark) {
|
||||
updateForm();
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const bookmark = {
|
||||
title: editBookmark().title || editBookmark().url,
|
||||
url: editBookmark().url,
|
||||
description: editBookmark().description,
|
||||
tags: tags()
|
||||
};
|
||||
props.onSubmit(bookmark);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 500px; max-width: 90vw;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">Edit Bookmark</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="URL *"
|
||||
value={editBookmark().url}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setEditBookmark(prev => ({ ...prev, url: target.value }));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={editBookmark().title}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setEditBookmark(prev => ({ ...prev, title: target.value }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Description"
|
||||
value={editBookmark().description}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setEditBookmark(prev => ({ ...prev, description: target.value }));
|
||||
}}
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-muted-foreground">Tags</label>
|
||||
<TagPicker
|
||||
availableTags={availableTags()}
|
||||
selectedTags={tags()}
|
||||
onTagsChange={(next) => setTags(next)}
|
||||
placeholder="Add tags..."
|
||||
allowNew={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!editBookmark().url.trim()}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ParentComponent } from 'solid-js'
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IconLoader2, IconX } from '@tabler/icons-solidjs'
|
||||
|
||||
interface EnhancedCardProps {
|
||||
class?: string
|
||||
loading?: boolean
|
||||
error?: string
|
||||
closable?: boolean
|
||||
onClose?: () => void
|
||||
variant?: 'default' | 'bordered' | 'elevated' | 'ghost'
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export const EnhancedCard: ParentComponent<EnhancedCardProps> = (props) => {
|
||||
const [isVisible, setIsVisible] = createSignal(true)
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false)
|
||||
props.onClose?.()
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-card border border-border shadow-sm',
|
||||
bordered: 'bg-card border-2 border-border',
|
||||
elevated: 'bg-card border border-border shadow-lg',
|
||||
ghost: 'bg-transparent border-0 shadow-none'
|
||||
}
|
||||
|
||||
const paddingClasses = {
|
||||
none: 'p-0',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6'
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={isVisible()}>
|
||||
<div
|
||||
class={cn(
|
||||
'rounded-lg transition-all duration-200 relative',
|
||||
variantClasses[props.variant || 'default'],
|
||||
paddingClasses[props.padding || 'md'],
|
||||
props.class
|
||||
)}
|
||||
>
|
||||
{/* Close button */}
|
||||
<Show when={props.closable}>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
class="absolute top-2 right-2 p-1 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<IconX class="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* Loading state */}
|
||||
<Show when={props.loading}>
|
||||
<div class="absolute inset-0 bg-background/80 backdrop-blur-sm rounded-lg flex items-center justify-center z-10">
|
||||
<IconLoader2 class="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Error state */}
|
||||
<Show when={props.error}>
|
||||
<div class="absolute inset-0 bg-destructive/10 backdrop-blur-sm rounded-lg flex items-center justify-center z-10">
|
||||
<div class="text-center p-4">
|
||||
<p class="text-destructive text-sm font-medium">{props.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Content */}
|
||||
<div class={cn('relative', props.loading && 'opacity-50')}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -18,24 +18,24 @@ export const ErrorBoundary: ParentComponent<{ fallback?: (errorInfo: ErrorInfo)
|
||||
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="bg-destructive/10 border border-destructive/20 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 class="p-3 bg-destructive/20 rounded-full">
|
||||
<IconAlertTriangle class="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold text-white mb-2">
|
||||
<h2 class="text-xl font-semibold text-foreground mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
|
||||
<p class="text-gray-300 mb-4">
|
||||
<p class="text-muted-foreground 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">
|
||||
<div class="bg-warning/10 border border-warning/20 rounded p-3 mb-4">
|
||||
<p class="text-warning-foreground text-sm">
|
||||
This error has occurred {errorCount()} times. Try refreshing the page if it persists.
|
||||
</p>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@ export const ErrorBoundary: ParentComponent<{ fallback?: (errorInfo: ErrorInfo)
|
||||
<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"
|
||||
class="inline-flex items-center px-4 py-2 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md transition-colors"
|
||||
>
|
||||
<IconRefresh class="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
@@ -52,7 +52,7 @@ export const ErrorBoundary: ParentComponent<{ fallback?: (errorInfo: ErrorInfo)
|
||||
|
||||
<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"
|
||||
class="inline-flex items-center px-4 py-2 bg-secondary hover:bg-secondary/80 text-secondary-foreground rounded-md transition-colors"
|
||||
>
|
||||
<IconRefresh class="mr-2 h-4 w-4" />
|
||||
Refresh Page
|
||||
@@ -61,11 +61,11 @@ export const ErrorBoundary: ParentComponent<{ fallback?: (errorInfo: ErrorInfo)
|
||||
|
||||
<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">
|
||||
<summary class="cursor-pointer text-sm text-muted-foreground hover:text-foreground 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">
|
||||
<pre class="mt-2 p-3 bg-muted rounded text-xs text-destructive overflow-auto">
|
||||
{errorInfo.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { IconX, IconDownload, IconExternalLink, IconEye, IconFile, IconCode, IconFileText } from '@tabler/icons-solidjs';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
file: any;
|
||||
}
|
||||
|
||||
export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
||||
const [previewError, setPreviewError] = createSignal(false);
|
||||
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith('image/')) return <IconEye class="size-8" />;
|
||||
if (fileType.startsWith('video/')) return <IconEye class="size-8" />;
|
||||
if (fileType.startsWith('audio/')) return <IconEye class="size-8" />;
|
||||
if (fileType.includes('pdf')) return <IconFileText class="size-8" />;
|
||||
if (fileType.includes('json') || fileType.includes('xml') || fileType.includes('csv')) return <IconCode class="size-8" />;
|
||||
if (fileType.startsWith('text/') || fileType === 'txt' || fileType === 'md') return <IconFileText class="size-8" />;
|
||||
if (fileType === 'docx' || fileType === 'pptx' || fileType === 'xlsx') return <IconFileText class="size-8" />;
|
||||
return <IconFile class="size-8" />;
|
||||
};
|
||||
|
||||
const getFilePreview = (file: any) => {
|
||||
if (previewError()) {
|
||||
return (
|
||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4">⚠️</div>
|
||||
<p class="text-lg font-medium mb-2">Preview Failed</p>
|
||||
<p class="text-muted-foreground mb-4">Unable to load preview for this file</p>
|
||||
<Button onClick={() => window.open(file.downloadUrl || '#', '_blank')}>
|
||||
Download File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
return (
|
||||
<div class="w-full h-full flex items-center justify-center bg-black/5 rounded">
|
||||
<img
|
||||
src={file.preview || file.url || `https://picsum.photos/seed/${file.name}/800/600.jpg`}
|
||||
alt={file.name}
|
||||
class="max-w-full max-h-full object-contain rounded"
|
||||
onError={() => setPreviewError(true)}
|
||||
onLoad={() => setPreviewError(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
return (
|
||||
<div class="w-full h-full flex items-center justify-center bg-black/5 rounded">
|
||||
<video controls class="max-w-full max-h-full rounded" preload="metadata">
|
||||
<source src={file.preview || file.url} type={file.type} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
return (
|
||||
<div class="w-full h-full flex items-center justify-center p-8 bg-muted rounded">
|
||||
<div class="w-full max-w-md">
|
||||
<audio controls class="w-full mb-4" preload="metadata">
|
||||
<source src={file.preview || file.url} type={file.type} />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">🎵</div>
|
||||
<p class="font-medium">{file.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (file.type.startsWith('text/') || file.type.includes('json') || file.type.includes('xml') || file.type.includes('csv') || file.type === 'txt' || file.type === 'md') {
|
||||
return (
|
||||
<div class="w-full h-full bg-muted p-4 rounded overflow-auto">
|
||||
<pre class="text-sm text-foreground whitespace-pre-wrap break-words font-mono">
|
||||
{file.preview || `# Preview of ${file.name}\n\nFile content would be displayed here...\n\nIn a real implementation, this would show the actual file content.\n\nFor text files, this would display the full text content.\nFor code files, this would show syntax-highlighted code.\nFor markdown files, this would render the formatted content.`}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
} else if (file.type.includes('pdf')) {
|
||||
return (
|
||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="text-6xl mb-4">📄</div>
|
||||
<p class="text-lg font-medium mb-2">PDF Document</p>
|
||||
<p class="text-muted-foreground mb-4 truncate">{file.name}</p>
|
||||
<div class="space-y-2">
|
||||
<Button onClick={() => window.open(file.viewUrl || '#', '_blank')} class="w-full">
|
||||
<IconExternalLink class="size-4 mr-2" />
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.open(file.downloadUrl || '#', '_blank')} class="w-full">
|
||||
<IconDownload class="size-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (file.type === 'docx' || file.type === 'pptx' || file.type === 'xlsx') {
|
||||
return (
|
||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="text-6xl mb-4">
|
||||
{file.type === 'docx' ? '📄' : file.type === 'pptx' ? '📊' : '📈'}
|
||||
</div>
|
||||
<p class="text-lg font-medium mb-2">
|
||||
{file.type === 'docx' ? 'Word Document' : file.type === 'pptx' ? 'PowerPoint Presentation' : 'Excel Spreadsheet'}
|
||||
</p>
|
||||
<p class="text-muted-foreground mb-4 truncate">{file.name}</p>
|
||||
{file.preview && (
|
||||
<div class="text-left mb-4 p-4 bg-background rounded max-h-40 overflow-y-auto">
|
||||
<pre class="text-xs text-muted-foreground whitespace-pre-wrap">{file.preview.substring(0, 200)}...</pre>
|
||||
</div>
|
||||
)}
|
||||
<div class="space-y-2">
|
||||
<Button onClick={() => window.open(file.viewUrl || '#', '_blank')} class="w-full">
|
||||
<IconExternalLink class="size-4 mr-2" />
|
||||
Open in New Tab
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.open(file.downloadUrl || '#', '_blank')} class="w-full">
|
||||
<IconDownload class="size-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||
<div class="text-center max-w-md">
|
||||
{getFileIcon(file.type)}
|
||||
<p class="text-lg font-medium mb-2 mt-4">File Preview</p>
|
||||
<p class="text-muted-foreground mb-4 truncate">{file.name}</p>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p>Type: {file.type}</p>
|
||||
<p>Size: {formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
{file.preview && (
|
||||
<div class="text-left mt-4 p-4 bg-background rounded max-h-32 overflow-y-auto">
|
||||
<pre class="text-xs text-muted-foreground whitespace-pre-wrap">{file.preview.substring(0, 150)}...</pre>
|
||||
</div>
|
||||
)}
|
||||
<div class="mt-4 space-y-2">
|
||||
<Button onClick={() => window.open(file.downloadUrl || '#', '_blank')} class="w-full">
|
||||
<IconDownload class="size-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
|
||||
if (isDemoMode) {
|
||||
// Simulate download in demo mode
|
||||
alert(`Download simulated for: ${props.file.name}\n\nIn production, this would download the actual file.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.href = props.file?.downloadUrl || '#';
|
||||
link.download = props.file?.name;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 900px; max-width: 95vw; max-height: 85vh;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold truncate">{props.file?.name}</h3>
|
||||
<span class="text-sm text-muted-foreground flex-shrink-0">
|
||||
{props.file?.size ? formatFileSize(props.file.size) : 'Unknown size'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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 flex-shrink-0"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview Area */}
|
||||
<div class="p-6" style="height: 500px;">
|
||||
<div class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
{props.file && getFilePreview(props.file)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-between items-center p-6 border-t border-border">
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{props.file?.type || 'Unknown file type'}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<IconDownload class="size-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const url = props.file?.viewUrl || props.file?.url;
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
alert('No preview URL available for this file');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconExternalLink class="size-4 mr-2" />
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,389 @@
|
||||
import { createSignal, For, Show, onMount, onCleanup } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
IconX,
|
||||
IconUpload,
|
||||
IconLink,
|
||||
IconTag,
|
||||
IconFileText,
|
||||
IconPhoto,
|
||||
IconVideo,
|
||||
IconMusic,
|
||||
IconFolder
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
interface FileUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUpload: (fileData: any) => void;
|
||||
}
|
||||
|
||||
interface Association {
|
||||
id: string;
|
||||
type: 'task' | 'bookmark' | 'note' | 'project';
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const FileUploadModal = (props: FileUploadModalProps) => {
|
||||
const [selectedFile, setSelectedFile] = createSignal<File | null>(null);
|
||||
const [description, setDescription] = createSignal('');
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
const [tagInput, setTagInput] = createSignal('');
|
||||
const [associations, setAssociations] = createSignal<Association[]>([]);
|
||||
const [linkUrl, setLinkUrl] = createSignal('');
|
||||
const [isLinkMode, setIsLinkMode] = createSignal(false);
|
||||
const [dragActive, setDragActive] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.isOpen) {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
});
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
setSelectedFile(target.files[0]);
|
||||
setIsLinkMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
|
||||
setSelectedFile(e.dataTransfer.files[0]);
|
||||
setIsLinkMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput().trim();
|
||||
if (tag && !tags().includes(tag)) {
|
||||
setTags([...tags(), tag]);
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setTags(tags().filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const addAssociation = (type: Association['type']) => {
|
||||
// Mock association - in real app, this would open a picker
|
||||
const mockAssociation: Association = {
|
||||
id: Date.now().toString(),
|
||||
type,
|
||||
title: `Sample ${type} ${Date.now()}`
|
||||
};
|
||||
setAssociations([...associations(), mockAssociation]);
|
||||
};
|
||||
|
||||
const removeAssociation = (id: string) => {
|
||||
setAssociations(associations().filter(assoc => assoc.id !== id));
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
const fileData = {
|
||||
file: selectedFile(),
|
||||
linkUrl: linkUrl(),
|
||||
description: description(),
|
||||
tags: tags(),
|
||||
associations: associations(),
|
||||
isLinkMode: isLinkMode()
|
||||
};
|
||||
|
||||
props.onUpload(fileData);
|
||||
props.onClose();
|
||||
|
||||
// Reset form
|
||||
setSelectedFile(null);
|
||||
setDescription('');
|
||||
setTags([]);
|
||||
setTagInput('');
|
||||
setAssociations([]);
|
||||
setLinkUrl('');
|
||||
setIsLinkMode(false);
|
||||
};
|
||||
|
||||
const getFileIcon = (file?: File) => {
|
||||
if (!file) return IconFolder;
|
||||
|
||||
if (file.type.startsWith('image/')) return IconPhoto;
|
||||
if (file.type.startsWith('video/')) return IconVideo;
|
||||
if (file.type.startsWith('audio/')) return IconMusic;
|
||||
return IconFileText;
|
||||
};
|
||||
|
||||
const isValidUrl = (url: string) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const canUpload = () => {
|
||||
if (isLinkMode()) {
|
||||
return linkUrl() && isValidUrl(linkUrl());
|
||||
}
|
||||
return selectedFile() !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<div
|
||||
class="bg-card rounded-lg border border-border p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 my-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold">Upload File</h2>
|
||||
<Button variant="ghost" onClick={props.onClose}>
|
||||
<IconX class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Upload Mode Toggle */}
|
||||
<div class="flex gap-2 mb-6">
|
||||
<Button
|
||||
variant={!isLinkMode() ? "default" : "outline"}
|
||||
onClick={() => setIsLinkMode(false)}
|
||||
class="flex-1"
|
||||
>
|
||||
<IconUpload class="size-4 mr-2" />
|
||||
Upload File
|
||||
</Button>
|
||||
<Button
|
||||
variant={isLinkMode() ? "default" : "outline"}
|
||||
onClick={() => setIsLinkMode(true)}
|
||||
class="flex-1"
|
||||
>
|
||||
<IconLink class="size-4 mr-2" />
|
||||
Add Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File Upload Area */}
|
||||
<Show when={!isLinkMode()}>
|
||||
<Card class="p-8 mb-6">
|
||||
<div
|
||||
class={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
dragActive()
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
class="hidden"
|
||||
id="file-input"
|
||||
/>
|
||||
|
||||
<Show
|
||||
when={selectedFile()}
|
||||
fallback={
|
||||
<div>
|
||||
<IconUpload class="size-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p class="text-lg font-medium mb-2">Drop file here or click to browse</p>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Supports all file types
|
||||
</p>
|
||||
<Button onClick={() => document.getElementById('file-input')?.click()}>
|
||||
Choose File
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-4 justify-center">
|
||||
<div class="text-4xl text-primary">
|
||||
{(() => {
|
||||
const IconComponent = getFileIcon(selectedFile()!);
|
||||
return <IconComponent size={48} />;
|
||||
})()}
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<p class="font-medium">{selectedFile()!.name}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{(selectedFile()!.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
class="mt-2"
|
||||
>
|
||||
Change File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Link Input */}
|
||||
<Show when={isLinkMode()}>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">File URL</label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://example.com/file.pdf"
|
||||
value={linkUrl()}
|
||||
onInput={(e: any) => setLinkUrl(e.currentTarget.value)}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Description */}
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Description</label>
|
||||
<textarea
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background resize-none"
|
||||
rows={3}
|
||||
placeholder="Optional description..."
|
||||
value={description()}
|
||||
onInput={(e: any) => setDescription(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Tags</label>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Add tag..."
|
||||
value={tagInput()}
|
||||
onInput={(e: any) => setTagInput(e.currentTarget.value)}
|
||||
onKeyDown={(e: any) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button onClick={addTag} disabled={!tagInput().trim()}>
|
||||
<IconTag class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={tags()}>
|
||||
{(tag) => (
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm">
|
||||
{tag}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeTag(tag)}
|
||||
class="h-4 w-4 p-0 hover:bg-primary/20"
|
||||
>
|
||||
<IconX class="size-3" />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Associations */}
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Link to</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAssociation('task')}
|
||||
>
|
||||
Task
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAssociation('bookmark')}
|
||||
>
|
||||
Bookmark
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAssociation('note')}
|
||||
>
|
||||
Note
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addAssociation('project')}
|
||||
>
|
||||
Project
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<For each={associations()}>
|
||||
{(assoc) => (
|
||||
<div class="flex items-center justify-between p-2 bg-muted rounded-md">
|
||||
<span class="text-sm">
|
||||
<span class="font-medium capitalize">{assoc.type}:</span> {assoc.title}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeAssociation(assoc.id)}
|
||||
class="h-6 w-6 p-0"
|
||||
>
|
||||
<IconX class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex gap-3 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose} class="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={!canUpload()} class="flex-1">
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,461 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconTrendingUp,
|
||||
IconBook,
|
||||
IconFolder,
|
||||
IconExternalLink,
|
||||
IconGitBranch,
|
||||
IconGitMerge,
|
||||
IconGitPullRequest,
|
||||
IconGitCommit
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
interface ActivityData {
|
||||
date: string;
|
||||
count: number;
|
||||
level: number; // 0-5 intensity level
|
||||
}
|
||||
|
||||
interface ActivityEvent {
|
||||
type: 'push' | 'pull_request' | 'merge' | 'issue' | 'bookmark' | 'project' | 'learning' | 'note' | 'commit';
|
||||
title: string;
|
||||
date: string;
|
||||
link?: string;
|
||||
repo?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface GitHubActivityProps {
|
||||
title?: string;
|
||||
showStats?: boolean;
|
||||
showContributionGraph?: boolean;
|
||||
showRecentActivity?: boolean;
|
||||
compact?: boolean;
|
||||
period?: 'year' | 'month' | 'week';
|
||||
customEvents?: ActivityEvent[];
|
||||
hideHeader?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
const [activities, setActivities] = createSignal<ActivityData[]>([]);
|
||||
const [recentEvents, setRecentEvents] = createSignal<ActivityEvent[]>([]);
|
||||
const [selectedPeriod, setSelectedPeriod] = createSignal<'year' | 'month' | 'week'>(props.period || 'year');
|
||||
|
||||
const [stats, setStats] = createSignal({
|
||||
totalContributions: 0,
|
||||
currentStreak: 0,
|
||||
longestStreak: 0
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Always show rich mock data for demonstration
|
||||
generateMockData();
|
||||
return;
|
||||
|
||||
// Original real data loading logic (commented out for demo)
|
||||
/*
|
||||
if (isDemoMode()) {
|
||||
// In demo mode, always show rich mock data
|
||||
generateMockData();
|
||||
return;
|
||||
}
|
||||
|
||||
loadRealData().catch((error) => {
|
||||
console.error('Failed to load GitHub activity analytics, falling back to mock data:', error);
|
||||
generateMockData();
|
||||
});
|
||||
*/
|
||||
});
|
||||
|
||||
const generateMockData = () => {
|
||||
const activityData: ActivityData[] = [];
|
||||
const today = new Date();
|
||||
const oneYearAgo = new Date(today);
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
|
||||
let currentStreak = 0;
|
||||
let longestStreak = 0;
|
||||
let tempStreak = 0;
|
||||
let totalContributions = 0;
|
||||
|
||||
// Generate more realistic activity patterns
|
||||
for (let d = new Date(oneYearAgo); d <= today; d.setDate(d.getDate() + 1)) {
|
||||
const dayOfWeek = d.getDay();
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
const monthsAgo = Math.floor((today.getTime() - d.getTime()) / (30 * 24 * 60 * 60 * 1000));
|
||||
|
||||
// More realistic patterns:
|
||||
// - Higher activity in recent months
|
||||
// - Lower activity on weekends
|
||||
// - Some bursts of activity followed by quiet periods
|
||||
let baseProbability = 0.3; // 30% chance of some activity
|
||||
|
||||
// Increase activity for more recent months
|
||||
if (monthsAgo < 3) baseProbability = 0.7; // Last 3 months: 70% chance
|
||||
else if (monthsAgo < 6) baseProbability = 0.5; // 3-6 months ago: 50% chance
|
||||
else baseProbability = 0.3; // 6+ months ago: 30% chance
|
||||
|
||||
// Reduce activity on weekends
|
||||
if (isWeekend) baseProbability *= 0.6;
|
||||
|
||||
// Add some randomness and bursts
|
||||
const hasActivity = Math.random() < baseProbability;
|
||||
let count = 0;
|
||||
|
||||
if (hasActivity) {
|
||||
// Generate contribution count with some bursts
|
||||
if (Math.random() < 0.1) {
|
||||
// 10% chance of high activity burst
|
||||
count = Math.floor(Math.random() * 15) + 10;
|
||||
} else {
|
||||
// Normal activity
|
||||
count = Math.floor(Math.random() * 8) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const level = count === 0 ? 0 : Math.min(5, Math.ceil(count / 2));
|
||||
|
||||
activityData.push({
|
||||
date: new Date(d).toISOString().split('T')[0],
|
||||
count,
|
||||
level
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
tempStreak++;
|
||||
if (d.toDateString() === today.toDateString()) {
|
||||
currentStreak = tempStreak;
|
||||
}
|
||||
} else {
|
||||
longestStreak = Math.max(longestStreak, tempStreak);
|
||||
tempStreak = 0;
|
||||
}
|
||||
|
||||
totalContributions += count;
|
||||
}
|
||||
|
||||
const defaultEvents: ActivityEvent[] = [
|
||||
{
|
||||
type: 'commit',
|
||||
title: 'feat: Add advanced color scheme management',
|
||||
date: '2024-01-28',
|
||||
link: '/app/activity',
|
||||
repo: 'trackeep',
|
||||
action: 'pushed'
|
||||
},
|
||||
{
|
||||
type: 'pull_request',
|
||||
title: 'Enhance admin settings with toggle buttons',
|
||||
date: '2024-01-27',
|
||||
link: '/app/admin',
|
||||
repo: 'trackeep',
|
||||
action: 'opened'
|
||||
},
|
||||
{
|
||||
type: 'merge',
|
||||
title: 'Merge branch: feature/ai-chat-enhancements',
|
||||
date: '2024-01-26',
|
||||
link: '/app/chat',
|
||||
repo: 'trackeep',
|
||||
action: 'merged'
|
||||
},
|
||||
{
|
||||
type: 'bookmark',
|
||||
title: 'Added bookmark: Advanced React Patterns',
|
||||
date: '2024-01-25',
|
||||
link: '/app/bookmarks'
|
||||
},
|
||||
{
|
||||
type: 'project',
|
||||
title: 'Updated project: Trackeep Dashboard',
|
||||
date: '2024-01-24',
|
||||
link: '/app/projects'
|
||||
}
|
||||
];
|
||||
|
||||
setActivities(activityData);
|
||||
setRecentEvents(props.customEvents || defaultEvents);
|
||||
setStats({
|
||||
totalContributions,
|
||||
currentStreak,
|
||||
longestStreak: Math.max(longestStreak, tempStreak)
|
||||
});
|
||||
};
|
||||
|
||||
const getMonthLabels = () => {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const today = new Date();
|
||||
const labels = [];
|
||||
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setMonth(date.getMonth() - i);
|
||||
labels.push(months[date.getMonth()]);
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
|
||||
const getActivityColor = (level: number) => {
|
||||
// Use project-themed colors instead of Christmas tree colors
|
||||
// Based on the primary theme color with varying intensities
|
||||
const colors = [
|
||||
'hsl(var(--muted) / 0.3)', // Level 0 - no activity (very light muted)
|
||||
'hsl(var(--primary) / 0.2)', // Level 1 - very light primary
|
||||
'hsl(var(--primary) / 0.4)', // Level 2 - light primary
|
||||
'hsl(var(--primary) / 0.6)', // Level 3 - medium primary
|
||||
'hsl(var(--primary) / 0.8)', // Level 4 - strong primary
|
||||
'hsl(var(--primary))' // Level 5 - full primary
|
||||
];
|
||||
return colors[level] || colors[0];
|
||||
};
|
||||
|
||||
const formatContributionCount = (count: number) => {
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
const getEventIcon = (type: ActivityEvent['type']) => {
|
||||
switch (type) {
|
||||
case 'push':
|
||||
case 'commit':
|
||||
return <IconGitBranch class="size-4 text-primary" />;
|
||||
case 'pull_request':
|
||||
return <IconGitPullRequest class="size-4 text-primary" />;
|
||||
case 'merge':
|
||||
return <IconGitMerge class="size-4 text-primary" />;
|
||||
case 'issue':
|
||||
return <IconBook class="size-4 text-primary" />;
|
||||
case 'bookmark':
|
||||
return <IconBook class="size-4 text-primary" />;
|
||||
case 'project':
|
||||
return <IconFolder class="size-4 text-primary" />;
|
||||
case 'learning':
|
||||
return <IconTrendingUp class="size-4 text-primary" />;
|
||||
case 'note':
|
||||
return <IconBook class="size-4 text-primary" />;
|
||||
default:
|
||||
return <IconGitCommit class="size-4 text-primary" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header (can be hidden by parent) */}
|
||||
{!props.hideHeader && (
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">
|
||||
{props.title || 'Activity Overview'}
|
||||
</h2>
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Track your contributions and activity over time
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{(['year', 'month', 'week'] as const).map((period) => (
|
||||
<Button
|
||||
variant={selectedPeriod() === period ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
size="sm"
|
||||
>
|
||||
{period.charAt(0).toUpperCase() + period.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
<Show when={props.showStats !== false}>
|
||||
<div class={`grid ${props.compact ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-3'} gap-4`}>
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
||||
<IconTrendingUp class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-foreground">
|
||||
{formatContributionCount(stats().totalContributions)}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Total contributions</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
||||
<IconCalendar class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-foreground">{stats().currentStreak}</p>
|
||||
<p class="text-sm text-muted-foreground">Current streak</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
||||
<IconCalendar class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-foreground">{stats().longestStreak}</p>
|
||||
<p class="text-sm text-muted-foreground">Longest streak</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Contribution Graph */}
|
||||
<Show when={props.showContributionGraph !== false}>
|
||||
<Card class="p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">
|
||||
{formatContributionCount(stats().totalContributions)} contributions in the last year
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Month labels - More visible and responsive */}
|
||||
<div class="flex justify-between mb-3 px-8 text-sm font-medium">
|
||||
{getMonthLabels().map((month, index) => (
|
||||
<span
|
||||
class="text-foreground/80 hover:text-foreground transition-colors cursor-default"
|
||||
style={index % 2 === 0 ? "" : "visibility: hidden;"}
|
||||
>
|
||||
{month}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Contribution grid - Responsive and prevents overflow */}
|
||||
<div class="overflow-hidden w-full">
|
||||
<div class="flex gap-1 min-w-0">
|
||||
{/* Day labels */}
|
||||
<div class="flex flex-col gap-1 pr-2 flex-shrink-0">
|
||||
{['Mon', 'Wed', 'Fri'].map((day) => (
|
||||
<div class="h-3 flex items-center justify-end">
|
||||
<span class="text-xs text-foreground/70 hover:text-foreground transition-colors cursor-default font-medium">
|
||||
{day}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Weekly columns - Responsive with proper overflow handling */}
|
||||
<div class="flex gap-1 overflow-x-auto overflow-y-hidden min-w-0">
|
||||
{Array.from({ length: 53 }, (_, weekIndex) => (
|
||||
<div class="flex flex-col gap-1 flex-shrink-0">
|
||||
{Array.from({ length: 7 }, (_, dayIndex) => {
|
||||
const activityIndex = weekIndex * 7 + dayIndex;
|
||||
const activity = activities()[activityIndex];
|
||||
|
||||
if (!activity) {
|
||||
return (
|
||||
<div
|
||||
class="w-2 h-2 sm:w-3 sm:h-3 rounded-sm flex-shrink-0"
|
||||
style={`background-color: ${getActivityColor(0)}`}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="w-2 h-2 sm:w-3 sm:h-3 rounded-sm hover:ring-1 hover:ring-primary cursor-pointer transition-all flex-shrink-0"
|
||||
style={`background-color: ${getActivityColor(activity.level)}`}
|
||||
title={`${activity.date}: ${activity.count} contributions`}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<span class="text-xs text-muted-foreground">Less</span>
|
||||
<div class="flex gap-1">
|
||||
{[0, 1, 2, 3, 4].map((level) => (
|
||||
<div
|
||||
class="w-2 h-2 sm:w-3 sm:h-3 rounded-sm"
|
||||
style={`background-color: ${getActivityColor(level)}`}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">More</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Show when={props.showRecentActivity !== false}>
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Recent Activity</h3>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span>Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 max-h-64 overflow-y-auto">
|
||||
<For each={recentEvents()}>
|
||||
{(event) => (
|
||||
<div class="flex items-center justify-between p-3 bg-card rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-2 rounded-lg">
|
||||
{getEventIcon(event.type)}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-foreground font-medium">{event.title}</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||
<span>{event.date}</span>
|
||||
{event.repo && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span class="text-primary">{event.repo}</span>
|
||||
</>
|
||||
)}
|
||||
{event.action && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{event.action}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{event.link && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Navigate to the link in the same tab
|
||||
if (event.link) {
|
||||
window.location.href = event.link;
|
||||
}
|
||||
}}
|
||||
class="hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<IconExternalLink class="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,9 @@ export interface InputProps {
|
||||
value?: string
|
||||
onInput?: (e: InputEvent) => void
|
||||
onChange?: (e: Event) => void
|
||||
onKeyDown?: (e: KeyboardEvent) => void
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export function Input(props: InputProps) {
|
||||
@@ -19,21 +21,25 @@ export function Input(props: InputProps) {
|
||||
'value',
|
||||
'onInput',
|
||||
'onChange',
|
||||
'onKeyDown',
|
||||
'disabled',
|
||||
'required',
|
||||
])
|
||||
|
||||
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',
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring 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}
|
||||
onKeyDown={local.onKeyDown}
|
||||
disabled={local.disabled}
|
||||
required={local.required}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface LearningPathFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
||||
duration: string;
|
||||
thumbnail?: string;
|
||||
is_featured?: boolean;
|
||||
}
|
||||
|
||||
interface LearningPathModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (learningPath: LearningPathFormData) => Promise<void>;
|
||||
learningPath?: LearningPathFormData | null;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export const LearningPathModal = (props: LearningPathModalProps) => {
|
||||
const [learningPathData, setLearningPathData] = createSignal<LearningPathFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
difficulty: 'beginner',
|
||||
duration: '',
|
||||
thumbnail: '',
|
||||
is_featured: false
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
||||
|
||||
// Reset form when modal opens/closes or learningPath changes
|
||||
const resetForm = () => {
|
||||
if (props.learningPath && props.isEdit) {
|
||||
setLearningPathData({
|
||||
title: props.learningPath.title,
|
||||
description: props.learningPath.description,
|
||||
category: props.learningPath.category,
|
||||
difficulty: props.learningPath.difficulty,
|
||||
duration: props.learningPath.duration,
|
||||
thumbnail: props.learningPath.thumbnail || '',
|
||||
is_featured: props.learningPath.is_featured || false
|
||||
});
|
||||
} else {
|
||||
setLearningPathData({
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
difficulty: 'beginner',
|
||||
duration: '',
|
||||
thumbnail: '',
|
||||
is_featured: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form when modal opens/closes
|
||||
if (props.isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!learningPathData().title.trim() || !learningPathData().description.trim()) {
|
||||
// Display inline error instead of alert
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await props.onSubmit(learningPathData());
|
||||
props.onClose();
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Failed to save learning path:', error);
|
||||
// Let the parent handle the error display
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof LearningPathFormData) => {
|
||||
return (e: Event) => {
|
||||
const target = e.currentTarget as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
||||
if (target) {
|
||||
setLearningPathData(prev => ({
|
||||
...prev,
|
||||
[field]: target.type === 'checkbox' ? (target as HTMLInputElement).checked : target.value
|
||||
}));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (!props.isOpen) return null;
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-[#404040]">
|
||||
<h2 class="text-xl font-semibold text-[#fafafa]">
|
||||
{props.isEdit ? 'Edit Learning Path' : 'Create New Learning Path'}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={props.onClose}
|
||||
class="text-[#a3a3a3] hover:text-[#fafafa]"
|
||||
>
|
||||
<IconX class="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} class="p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Title *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={learningPathData().title}
|
||||
onInput={handleInputChange('title')}
|
||||
placeholder="Enter learning path title"
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
value={learningPathData().description}
|
||||
onInput={handleInputChange('description')}
|
||||
placeholder="Describe what students will learn in this path"
|
||||
rows={4}
|
||||
required
|
||||
class="w-full px-3 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category and Difficulty */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
value={learningPathData().category}
|
||||
onChange={handleInputChange('category')}
|
||||
required
|
||||
class="w-full px-3 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
<option value="programming">Programming</option>
|
||||
<option value="web-development">Web Development</option>
|
||||
<option value="mobile-development">Mobile Development</option>
|
||||
<option value="data-science">Data Science</option>
|
||||
<option value="machine-learning">Machine Learning</option>
|
||||
<option value="cybersecurity">Cybersecurity</option>
|
||||
<option value="design">Design</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="marketing">Marketing</option>
|
||||
<option value="photography">Photography</option>
|
||||
<option value="music">Music</option>
|
||||
<option value="writing">Writing</option>
|
||||
<option value="languages">Languages</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Difficulty *
|
||||
</label>
|
||||
<select
|
||||
value={learningPathData().difficulty}
|
||||
onChange={handleInputChange('difficulty')}
|
||||
required
|
||||
class="w-full px-3 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Duration *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={learningPathData().duration}
|
||||
onInput={handleInputChange('duration')}
|
||||
placeholder="e.g., 8 weeks, 3 months"
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Thumbnail URL (optional)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={learningPathData().thumbnail}
|
||||
onInput={handleInputChange('thumbnail')}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Featured */}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="featured"
|
||||
checked={learningPathData().is_featured}
|
||||
onChange={handleInputChange('is_featured')}
|
||||
class="w-4 h-4 text-blue-600 bg-[#262626] border-[#404040] rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label for="featured" class="text-sm font-medium text-[#fafafa]">
|
||||
Featured Learning Path
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-[#404040]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={props.onClose}
|
||||
disabled={isSubmitting()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => handleSubmit(e)}
|
||||
disabled={isSubmitting()}
|
||||
class="min-w-[100px]"
|
||||
>
|
||||
{isSubmitting() ? (
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin"></span>
|
||||
{props.isEdit ? 'Updating...' : 'Creating...'}
|
||||
</span>
|
||||
) : (
|
||||
props.isEdit ? 'Update Learning Path' : 'Create Learning Path'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { IconX, IconClock, IconUsers, IconStar, IconBook, IconVideo, IconFileText, IconCode, IconCheck } from '@tabler/icons-solidjs';
|
||||
|
||||
interface LearningPath {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
difficulty: string;
|
||||
duration: string;
|
||||
thumbnail: string;
|
||||
is_featured: boolean;
|
||||
enrollment_count: number;
|
||||
rating: number;
|
||||
review_count: number;
|
||||
creator: {
|
||||
username: string;
|
||||
full_name: string;
|
||||
};
|
||||
tags: Array<{
|
||||
name: string;
|
||||
color: string;
|
||||
}>;
|
||||
modules?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
resources: Array<{
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface LearningPathPreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
learningPath: LearningPath | null;
|
||||
onEnroll: (pathId: number) => void;
|
||||
}
|
||||
|
||||
export const LearningPathPreviewModal = (props: LearningPathPreviewModalProps) => {
|
||||
const [isEnrolling, setIsEnrolling] = createSignal(false);
|
||||
|
||||
const handleEnroll = async () => {
|
||||
if (!props.learningPath) return;
|
||||
|
||||
setIsEnrolling(true);
|
||||
try {
|
||||
await props.onEnroll(props.learningPath.id);
|
||||
props.onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to enroll:', error);
|
||||
} finally {
|
||||
setIsEnrolling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getResourceIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'video': return <IconVideo class="size-4" />;
|
||||
case 'article': return <IconFileText class="size-4" />;
|
||||
case 'project': return <IconCode class="size-4" />;
|
||||
case 'lab': return <IconBook class="size-4" />;
|
||||
default: return <IconFileText class="size-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'beginner': return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'intermediate': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
|
||||
case 'advanced': return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
if (!props.isOpen || !props.learningPath) return null;
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-4xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
||||
{/* Header */}
|
||||
<div class="relative">
|
||||
{/* Thumbnail */}
|
||||
<div class="h-64 bg-[#262626] relative overflow-hidden">
|
||||
<img
|
||||
src={props.learningPath.thumbnail}
|
||||
alt={props.learningPath.title}
|
||||
class="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.src = `https://placehold.co/600x400/1e293b/ffffff?text=${encodeURIComponent(props.learningPath?.title || 'Learning Path')}`;
|
||||
}}
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent"></div>
|
||||
|
||||
{props.learningPath.is_featured && (
|
||||
<div class="absolute top-4 left-4 bg-blue-500 text-white px-3 py-1 rounded-full text-xs font-semibold z-10">
|
||||
Featured
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={props.onClose}
|
||||
class="absolute top-4 right-4 text-white hover:bg-white/20"
|
||||
>
|
||||
<IconX class="size-5" />
|
||||
</Button>
|
||||
|
||||
<div class="absolute bottom-6 left-6 right-6">
|
||||
<h2 class="text-3xl font-bold text-white mb-2">{props.learningPath.title}</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class={`px-3 py-1 rounded-full text-sm font-medium border ${getDifficultyColor(props.learningPath.difficulty)}`}>
|
||||
{props.learningPath.difficulty}
|
||||
</span>
|
||||
<span class="text-white/80">{props.learningPath.category}</span>
|
||||
<div class="flex items-center gap-1 text-white/80">
|
||||
<IconClock class="size-4" />
|
||||
<span class="text-sm">{props.learningPath.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-6">
|
||||
{/* Description and Stats */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<h3 class="text-xl font-semibold text-white mb-3">About this Learning Path</h3>
|
||||
<p class="text-[#a3a3a3] leading-relaxed">{props.learningPath.description}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Card class="p-4 bg-[#262626] border-[#404040]">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[#a3a3a3] text-sm">Instructor</span>
|
||||
<span class="text-white font-medium">{props.learningPath.creator.full_name}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[#a3a3a3] text-sm">Students</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconUsers class="size-4 text-[#a3a3a3]" />
|
||||
<span class="text-white font-medium">{props.learningPath.enrollment_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[#a3a3a3] text-sm">Rating</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconStar class="size-4 fill-yellow-400 text-yellow-400" />
|
||||
<span class="text-white font-medium">{props.learningPath.rating.toFixed(1)}</span>
|
||||
<span class="text-[#a3a3a3] text-sm">({props.learningPath.review_count})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Enroll Button */}
|
||||
<Button
|
||||
onClick={handleEnroll}
|
||||
disabled={isEnrolling()}
|
||||
class="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isEnrolling() ? (
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
|
||||
Enrolling...
|
||||
</span>
|
||||
) : (
|
||||
'Enroll Now'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{props.learningPath.tags && props.learningPath.tags.length > 0 && (
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white mb-3">Tags</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{props.learningPath.tags.map((tag) => (
|
||||
<span
|
||||
class="px-3 py-1 rounded-full text-sm font-medium"
|
||||
style={`background-color: ${tag.color}20; color: ${tag.color}`}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modules */}
|
||||
{props.learningPath.modules && props.learningPath.modules.length > 0 && (
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Course Content</h3>
|
||||
<div class="space-y-3">
|
||||
{props.learningPath.modules.map((module, index) => (
|
||||
<Card class="p-4 bg-[#262626] border-[#404040] hover:border-[#525252] transition-colors">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-[#404040] rounded-full flex items-center justify-center text-sm font-medium text-white">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="font-semibold text-white">{module.title}</h4>
|
||||
{module.completed && (
|
||||
<IconCheck class="size-4 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
<p class="text-[#a3a3a3] text-sm mb-3">{module.description}</p>
|
||||
|
||||
{module.resources && module.resources.length > 0 && (
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-[#a3a3a3] font-medium">Resources:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{module.resources.map((resource) => (
|
||||
<div class="flex items-center gap-1 px-2 py-1 bg-[#404040] rounded text-xs text-[#a3a3a3]">
|
||||
{getResourceIcon(resource.type)}
|
||||
<span>{resource.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IconLoader2 } from '@tabler/icons-solidjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
class?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const LoadingSpinner = (props: LoadingSpinnerProps) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8'
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={cn('flex items-center gap-2', props.class)}>
|
||||
<IconLoader2 class={cn('animate-spin text-primary', sizeClasses[props.size || 'md'])} />
|
||||
{props.text && (
|
||||
<span class="text-sm text-muted-foreground">{props.text}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FullPageLoader = (props: { text?: string }) => {
|
||||
return (
|
||||
<div class="min-h-[400px] flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<LoadingSpinner size="lg" text={props.text || 'Loading...'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,9 +25,9 @@ export const LoadingState = (props: LoadingStateProps) => {
|
||||
|
||||
return (
|
||||
<div class={containerClasses}>
|
||||
<IconLoader2 class={`animate-spin text-blue-400 ${sizeClasses[props.size || 'md']}`} />
|
||||
<IconLoader2 class={`animate-spin text-primary ${sizeClasses[props.size || 'md']}`} />
|
||||
{props.message && (
|
||||
<span class={`ml-2 text-gray-400 ${textSizeClasses[props.size || 'md']}`}>
|
||||
<span class={`ml-2 text-muted-foreground ${textSizeClasses[props.size || 'md']}`}>
|
||||
{props.message}
|
||||
</span>
|
||||
)}
|
||||
@@ -36,16 +36,16 @@ export const LoadingState = (props: LoadingStateProps) => {
|
||||
}
|
||||
|
||||
export const SkeletonCard = () => (
|
||||
<div class="bg-[#141415] border border-[#262626] rounded-lg p-6 animate-pulse">
|
||||
<div class="bg-card border border-border 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="w-8 h-8 bg-muted 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="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div class="h-3 bg-muted rounded w-1/2"></div>
|
||||
<div class="h-3 bg-muted 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 class="h-6 bg-muted rounded w-16"></div>
|
||||
<div class="h-6 bg-muted rounded w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,16 +54,16 @@ export const SkeletonCard = () => (
|
||||
|
||||
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} />
|
||||
{Array.from({ length: count }, () => (
|
||||
<SkeletonCard />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SkeletonList = ({ count = 5 }: { count?: number }) => (
|
||||
<div class="space-y-4">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
{Array.from({ length: count }, () => (
|
||||
<SkeletonCard />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Member {
|
||||
id?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'Admin' | 'Member';
|
||||
avatar?: string;
|
||||
joinedAt?: string;
|
||||
}
|
||||
|
||||
interface MemberModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (member: Omit<Member, 'id' | 'avatar' | 'joinedAt'>) => void;
|
||||
member?: Member | null;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export const MemberModal = (props: MemberModalProps) => {
|
||||
const [memberData, setMemberData] = createSignal<Omit<Member, 'id' | 'avatar' | 'joinedAt'>>({
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'Member'
|
||||
});
|
||||
|
||||
// Reset form when modal opens/closes or member changes
|
||||
const resetForm = () => {
|
||||
if (props.member && props.isEdit) {
|
||||
setMemberData({
|
||||
name: props.member.name,
|
||||
email: props.member.email,
|
||||
role: props.member.role as 'Admin' | 'Member'
|
||||
});
|
||||
} else {
|
||||
setMemberData({
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'Member'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Call resetForm when member changes
|
||||
if (props.isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!memberData().name.trim() || !memberData().email.trim()) return;
|
||||
props.onSubmit(memberData());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 500px; max-width: 90vw;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{props.isEdit ? 'Edit Member' : 'Add New Member'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Member name *"
|
||||
value={memberData().name}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setMemberData(prev => ({ ...prev, name: target.value }));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email address *"
|
||||
value={memberData().email}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setMemberData(prev => ({ ...prev, email: target.value }));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-foreground">Role</label>
|
||||
<select
|
||||
value={memberData().role}
|
||||
onChange={(e) => setMemberData(prev => ({ ...prev, role: e.target.value as 'Admin' | 'Member' }))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="Member">Member</option>
|
||||
<option value="Admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!memberData().name.trim() || !memberData().email.trim()}>
|
||||
{props.isEdit ? 'Save Changes' : 'Add Member'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { RichTextEditor } from '@/components/ui/RichTextEditor';
|
||||
import { TagPicker } from '@/components/ui/TagPicker';
|
||||
import { IconX, IconTag } from '@tabler/icons-solidjs';
|
||||
|
||||
interface NoteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (note: any) => void;
|
||||
note?: any;
|
||||
availableTags?: string[];
|
||||
}
|
||||
|
||||
export const NoteModal = (props: NoteModalProps) => {
|
||||
const [noteData, setNoteData] = createSignal({
|
||||
title: props.note?.title || '',
|
||||
content: props.note?.content || '',
|
||||
tags: props.note?.tags || []
|
||||
});
|
||||
|
||||
const defaultTags = ['ideas', 'work', 'personal', 'todo', 'meeting', 'project', 'research', 'important'];
|
||||
const availableTags = () => props.availableTags || defaultTags;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const note = {
|
||||
id: props.note?.id,
|
||||
title: noteData().title,
|
||||
content: noteData().content,
|
||||
tags: noteData().tags
|
||||
};
|
||||
props.onSubmit(note);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 600px; max-width: 90vw; max-height: 80vh; overflow-y: auto;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{props.note ? 'Edit Note' : 'Add New Note'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Note title"
|
||||
value={noteData().title}
|
||||
onInput={(e: any) => setNoteData(prev => ({ ...prev, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<IconTag class="size-4" />
|
||||
Content
|
||||
</label>
|
||||
<RichTextEditor
|
||||
value={noteData().content}
|
||||
onChange={(content) => setNoteData(prev => ({ ...prev, content }))}
|
||||
placeholder="Write your note here..."
|
||||
mode="markdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-muted-foreground">Tags</label>
|
||||
<TagPicker
|
||||
availableTags={availableTags()}
|
||||
selectedTags={noteData().tags}
|
||||
onTagsChange={(tags) => setNoteData(prev => ({ ...prev, tags }))}
|
||||
placeholder="Add tags..."
|
||||
allowNew={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!noteData().title.trim()}>
|
||||
{props.note ? 'Update Note' : 'Save Note'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ParentComponent } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ResponsiveGridProps {
|
||||
class?: string
|
||||
cols?: {
|
||||
sm?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
xl?: number
|
||||
'2xl'?: number
|
||||
}
|
||||
gap?: number
|
||||
autoFit?: boolean
|
||||
minItemWidth?: string
|
||||
}
|
||||
|
||||
export const ResponsiveGrid: ParentComponent<ResponsiveGridProps> = (props) => {
|
||||
const getGridClasses = () => {
|
||||
const cols = props.cols || { sm: 1, md: 2, lg: 3, xl: 4 }
|
||||
const gap = props.gap || 4
|
||||
|
||||
let classes = 'grid'
|
||||
|
||||
// Add grid columns for each breakpoint
|
||||
if (cols.sm) classes += ` grid-cols-${cols.sm}`
|
||||
if (cols.md) classes += ` md:grid-cols-${cols.md}`
|
||||
if (cols.lg) classes += ` lg:grid-cols-${cols.lg}`
|
||||
if (cols.xl) classes += ` xl:grid-cols-${cols.xl}`
|
||||
if (cols['2xl']) classes += ` 2xl:grid-cols-${cols['2xl']}`
|
||||
|
||||
// Add gap
|
||||
classes += ` gap-${gap}`
|
||||
|
||||
// Auto-fit functionality
|
||||
if (props.autoFit) {
|
||||
classes += ` grid-cols-[repeat(auto-fit,minmax(${props.minItemWidth || '250px'},1fr))]`
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(getGridClasses(), props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MasonryGridProps {
|
||||
class?: string
|
||||
columnCount?: number
|
||||
gap?: number
|
||||
}
|
||||
|
||||
export const MasonryGrid: ParentComponent<MasonryGridProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class={cn('space-y-4', props.class)}
|
||||
style={{
|
||||
'column-count': props.columnCount?.toString() || '3',
|
||||
'column-gap': `${props.gap || 16}px`
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import {
|
||||
IconBold,
|
||||
IconItalic,
|
||||
IconUnderline,
|
||||
IconStrikethrough,
|
||||
IconHeading,
|
||||
IconList,
|
||||
IconListNumbers,
|
||||
IconQuote,
|
||||
IconCode,
|
||||
IconLink,
|
||||
IconPhoto,
|
||||
IconPaperclip,
|
||||
IconEye
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
mode?: 'wysiwyg' | 'markdown' | 'html';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const RichTextEditor = (props: RichTextEditorProps) => {
|
||||
const [mode, setMode] = createSignal<'wysiwyg' | 'markdown' | 'html'>(props.mode || 'wysiwyg');
|
||||
const [isPreviewMode, setIsPreviewMode] = createSignal(false);
|
||||
|
||||
const insertText = (before: string, after: string = '') => {
|
||||
const textarea = document.querySelector('textarea');
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = textarea.value.substring(start, end);
|
||||
const newText = before + selectedText + after;
|
||||
|
||||
const newValue = textarea.value.substring(0, start) + newText + textarea.value.substring(end);
|
||||
props.onChange(newValue);
|
||||
|
||||
// Restore cursor position
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + before.length, start + before.length + selectedText.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const insertMarkdown = (markdown: string) => {
|
||||
insertText(markdown);
|
||||
};
|
||||
|
||||
const insertHtml = (html: string) => {
|
||||
insertText(html);
|
||||
};
|
||||
|
||||
const handleFileUpload = (type: 'image' | 'file') => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = type === 'image' ? 'image/*' : '*/*';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// In a real app, this would upload the file and return a URL
|
||||
const mockUrl = `/uploads/${file.name}`;
|
||||
if (type === 'image') {
|
||||
if (mode() === 'markdown') {
|
||||
insertMarkdown(``);
|
||||
} else if (mode() === 'html') {
|
||||
insertHtml(`<img src="${mockUrl}" alt="${file.name}" />`);
|
||||
} else {
|
||||
insertText(``);
|
||||
}
|
||||
} else {
|
||||
if (mode() === 'markdown') {
|
||||
insertMarkdown(`[${file.name}](${mockUrl})`);
|
||||
} else if (mode() === 'html') {
|
||||
insertHtml(`<a href="${mockUrl}">${file.name}</a>`);
|
||||
} else {
|
||||
insertText(`[${file.name}](${mockUrl})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const renderPreview = () => {
|
||||
if (mode() === 'markdown') {
|
||||
// Simple markdown to HTML conversion (in real app, use a proper markdown parser)
|
||||
return props.value
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/`(.*)`/gim, '<code>$1</code>')
|
||||
.replace(/\n/gim, '<br>');
|
||||
}
|
||||
|
||||
if (mode() === 'html') {
|
||||
return props.value;
|
||||
}
|
||||
|
||||
// WYSIWYG mode - treat as plain text with basic formatting
|
||||
return props.value
|
||||
.replace(/\n/gim, '<br>')
|
||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>');
|
||||
};
|
||||
|
||||
const toolbarButtons = [
|
||||
{ icon: IconBold, action: () => insertText('**', '**'), title: 'Bold' },
|
||||
{ icon: IconItalic, action: () => insertText('*', '*'), title: 'Italic' },
|
||||
{ icon: IconUnderline, action: () => insertText('<u>', '</u>'), title: 'Underline' },
|
||||
{ icon: IconStrikethrough, action: () => insertText('~~', '~~'), title: 'Strikethrough' },
|
||||
{ icon: IconHeading, action: () => insertText('## ', ''), title: 'Heading' },
|
||||
{ icon: IconList, action: () => insertText('- '), title: 'Bullet List' },
|
||||
{ icon: IconListNumbers, action: () => insertText('1. '), title: 'Numbered List' },
|
||||
{ icon: IconQuote, action: () => insertText('> '), title: 'Quote' },
|
||||
{ icon: IconCode, action: () => insertText('`', '`'), title: 'Code' },
|
||||
{ icon: IconLink, action: () => insertText('[', '](url)'), title: 'Link' },
|
||||
{ icon: IconPhoto, action: () => handleFileUpload('image'), title: 'Insert Image' },
|
||||
{ icon: IconPaperclip, action: () => handleFileUpload('file'), title: 'Attach File' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div class={`border border-border rounded-lg overflow-hidden ${props.class || ''}`}>
|
||||
{/* Toolbar */}
|
||||
<div class="bg-muted border-b border-border p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={toolbarButtons}>
|
||||
{(button) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={button.action}
|
||||
class="p-2 hover:bg-accent rounded transition-colors"
|
||||
title={button.title}
|
||||
>
|
||||
<button.icon class="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{/* Mode Selector */}
|
||||
<select
|
||||
value={mode()}
|
||||
onChange={(e: any) => setMode(e.target.value)}
|
||||
class="text-xs px-2 py-1 border border-border rounded bg-background"
|
||||
>
|
||||
<option value="wysiwyg">WYSIWYG</option>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
|
||||
{/* Preview Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewMode(!isPreviewMode())}
|
||||
class={`p-2 rounded transition-colors ${
|
||||
isPreviewMode() ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'
|
||||
}`}
|
||||
title="Toggle Preview"
|
||||
>
|
||||
<IconEye class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor/Preview Area */}
|
||||
<div class="min-h-[300px]">
|
||||
<Show when={!isPreviewMode()}>
|
||||
<textarea
|
||||
value={props.value}
|
||||
onInput={(e: any) => props.onChange(e.target.value)}
|
||||
placeholder={props.placeholder || 'Start writing...'}
|
||||
class="w-full h-full min-h-[300px] p-4 resize-none focus:outline-none bg-background"
|
||||
style="font-family: inherit;"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={isPreviewMode()}>
|
||||
<div
|
||||
class="p-4 min-h-[300px] prose prose-invert max-w-none"
|
||||
innerHTML={renderPreview()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div class="bg-muted border-t border-border px-4 py-2 text-xs text-muted-foreground">
|
||||
<div class="flex justify-between">
|
||||
<span>Mode: {mode().toUpperCase()}</span>
|
||||
<span>{props.value.length} characters</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { For } from 'solid-js';
|
||||
import { Button } from './Button';
|
||||
import { Input } from './Input';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface SearchTagFilterBarProps {
|
||||
searchPlaceholder: string;
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
tagOptions: string[];
|
||||
selectedTag: string;
|
||||
onTagChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export const SearchTagFilterBar = (props: SearchTagFilterBarProps) => {
|
||||
return (
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div class="flex flex-1 gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={props.searchPlaceholder}
|
||||
value={props.searchValue}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) props.onSearchChange(target.value);
|
||||
}}
|
||||
class="flex-1"
|
||||
/>
|
||||
<select
|
||||
value={props.selectedTag}
|
||||
onChange={(e) => props.onTagChange(e.target.value)}
|
||||
class="flex h-10 w-full sm:w-48 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">All Tags</option>
|
||||
<For each={props.tagOptions}>
|
||||
{(tag) => <option value={tag}>{tag}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
{props.selectedTag && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={props.onReset}
|
||||
class="justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-papra focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent/50 hover:text-accent-foreground shadow-sm h-10 px-4 py-2 flex items-center gap-2"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
Reset Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ComponentProps } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SwitchProps extends ComponentProps<'button'> {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const Switch = (props: SwitchProps) => {
|
||||
const [local, others] = props.checked !== undefined
|
||||
? [{ checked: props.checked, onCheckedChange: props.onCheckedChange }, props]
|
||||
: [props, props]
|
||||
|
||||
const handleClick = () => {
|
||||
if (local.onCheckedChange) {
|
||||
local.onCheckedChange(!local.checked)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={local.checked}
|
||||
class={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
local.checked ? 'data-[state=checked]' : 'data-[state=unchecked]',
|
||||
props.class
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...others}
|
||||
>
|
||||
<span
|
||||
data-state={local.checked ? 'checked' : 'unchecked'}
|
||||
class={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createSignal, For, Show, createEffect } from 'solid-js';
|
||||
import { IconTag, IconX, IconChevronDown } from '@tabler/icons-solidjs';
|
||||
|
||||
interface TagPickerProps {
|
||||
availableTags: string[];
|
||||
selectedTags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
allowNew?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const TagPicker = (props: TagPickerProps) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [inputValue, setInputValue] = createSignal('');
|
||||
const [filteredTags, setFilteredTags] = createSignal<string[]>([]);
|
||||
|
||||
createEffect(() => {
|
||||
const input = inputValue().toLowerCase();
|
||||
const filtered = props.availableTags.filter(tag =>
|
||||
tag.toLowerCase().includes(input) &&
|
||||
!props.selectedTags.includes(tag)
|
||||
);
|
||||
setFilteredTags(filtered);
|
||||
});
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
if (!props.selectedTags.includes(tag)) {
|
||||
props.onTagsChange([...props.selectedTags, tag]);
|
||||
setInputValue('');
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
props.onTagsChange(props.selectedTags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const value = inputValue().trim();
|
||||
if (value) {
|
||||
if (props.allowNew && !props.availableTags.includes(value)) {
|
||||
addTag(value);
|
||||
} else if (filteredTags().length > 0) {
|
||||
addTag(filteredTags()[0]);
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === 'Backspace' && !inputValue() && props.selectedTags.length > 0) {
|
||||
removeTag(props.selectedTags[props.selectedTags.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInputValue(value);
|
||||
if (value && !isOpen()) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={`relative ${props.class || ''}`}>
|
||||
{/* Selected Tags */}
|
||||
<div class="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-background min-h-[42px] cursor-text">
|
||||
<For each={props.selectedTags}>
|
||||
{(tag) => (
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm">
|
||||
<IconTag class="size-3" />
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
class="ml-1 hover:bg-primary/20 rounded p-0.5"
|
||||
>
|
||||
<IconX class="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue()}
|
||||
onInput={(e: any) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={props.selectedTags.length === 0 ? props.placeholder || 'Add tags...' : ''}
|
||||
class="flex-1 min-w-[120px] outline-none bg-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
<Show when={isOpen() && (filteredTags().length > 0 || (props.allowNew && inputValue().trim()))}>
|
||||
<div class="absolute top-full left-0 right-0 z-50 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
{/* New tag option */}
|
||||
<Show when={props.allowNew && inputValue().trim() && !props.availableTags.includes(inputValue().trim())}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addTag(inputValue().trim())}
|
||||
class="w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 text-sm"
|
||||
>
|
||||
<IconTag class="size-3 text-muted-foreground" />
|
||||
Create "{inputValue().trim()}"
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* Filtered tags */}
|
||||
<For each={filteredTags()}>
|
||||
{(tag) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addTag(tag)}
|
||||
class="w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 text-sm"
|
||||
>
|
||||
<IconTag class="size-3 text-muted-foreground" />
|
||||
{tag}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Close dropdown when clicking outside */}
|
||||
<Show when={isOpen()}>
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { DatePicker } from '@/components/ui/DatePicker';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Task {
|
||||
id?: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
completed: boolean;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
createdAt: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (task: Omit<Task, 'id'>) => Promise<void>;
|
||||
task?: Task | null;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export const TaskModal = (props: TaskModalProps) => {
|
||||
const [taskData, setTaskData] = createSignal<Omit<Task, 'id'>>({
|
||||
title: '',
|
||||
description: '',
|
||||
completed: false,
|
||||
priority: 'medium',
|
||||
createdAt: new Date().toISOString(),
|
||||
dueDate: ''
|
||||
});
|
||||
|
||||
const [dueDate, setDueDate] = createSignal<Date | undefined>(
|
||||
props.task?.dueDate ? new Date(props.task.dueDate) : undefined
|
||||
);
|
||||
|
||||
// Reset form when modal opens/closes or task changes
|
||||
const resetForm = () => {
|
||||
if (props.task && props.isEdit) {
|
||||
setTaskData({
|
||||
title: props.task.title,
|
||||
description: props.task.description || '',
|
||||
completed: props.task.completed || false,
|
||||
priority: props.task.priority,
|
||||
createdAt: props.task.createdAt,
|
||||
dueDate: props.task.dueDate || ''
|
||||
});
|
||||
setDueDate(props.task.dueDate ? new Date(props.task.dueDate) : undefined);
|
||||
} else {
|
||||
setTaskData({
|
||||
title: '',
|
||||
description: '',
|
||||
completed: false,
|
||||
priority: 'medium',
|
||||
createdAt: new Date().toISOString(),
|
||||
dueDate: ''
|
||||
});
|
||||
setDueDate(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// Call resetForm when task changes
|
||||
if (props.isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!taskData().title.trim()) return;
|
||||
|
||||
const submissionData = {
|
||||
...taskData(),
|
||||
dueDate: dueDate() ? dueDate()!.toISOString() : ''
|
||||
};
|
||||
|
||||
props.onSubmit(submissionData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 500px; max-width: 90vw;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{props.isEdit ? 'Edit Task' : 'Add New Task'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Task title *"
|
||||
value={taskData().title}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setTaskData(prev => ({ ...prev, title: target.value }));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Description (optional)"
|
||||
value={taskData().description}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setTaskData(prev => ({ ...prev, description: target.value }));
|
||||
}}
|
||||
/>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<select
|
||||
value={taskData().priority}
|
||||
onChange={(e) => setTaskData(prev => ({ ...prev, priority: e.target.value as any }))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="low">Low Priority</option>
|
||||
<option value="medium">Medium Priority</option>
|
||||
<option value="high">High Priority</option>
|
||||
</select>
|
||||
<DatePicker
|
||||
value={dueDate()}
|
||||
onChange={(date) => setDueDate(date || undefined)}
|
||||
placeholder="Due date (optional)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!taskData().title.trim()}>
|
||||
{props.isEdit ? 'Save Changes' : 'Add Task'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ComponentProps } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TextareaProps extends ComponentProps<'textarea'> {}
|
||||
|
||||
const Textarea = (props: TextareaProps) => {
|
||||
return (
|
||||
<textarea
|
||||
class={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TimePickerProps {
|
||||
value?: string;
|
||||
onChange?: (time: string) => void;
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TimePicker = (props: TimePickerProps) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [selectedTime, setSelectedTime] = createSignal<string>(props.value || '12:00');
|
||||
const [position, setPosition] = createSignal({ top: 0, left: 0, width: 0 });
|
||||
|
||||
let triggerRef: HTMLButtonElement | undefined;
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
|
||||
const minutes = ['00', '15', '30', '45'];
|
||||
|
||||
const getCurrentHour = () => selectedTime().split(':')[0];
|
||||
const getCurrentMinute = () => selectedTime().split(':')[1];
|
||||
|
||||
const handleTimeSelect = (hour: string, minute: string) => {
|
||||
const newTime = `${hour}:${minute}`;
|
||||
setSelectedTime(newTime);
|
||||
props.onChange?.(newTime);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleToggleModal = () => {
|
||||
if (props.disabled) return;
|
||||
|
||||
if (!isOpen()) {
|
||||
if (!triggerRef) return;
|
||||
|
||||
const rect = triggerRef.getBoundingClientRect();
|
||||
const estimatedHeight = 280; // approximate dropdown height
|
||||
|
||||
let top = rect.bottom + window.scrollY + 4; // default below
|
||||
const viewportBottom = window.scrollY + window.innerHeight;
|
||||
|
||||
// If there isn't enough space below, open above the trigger
|
||||
if (top + estimatedHeight > viewportBottom) {
|
||||
top = rect.top + window.scrollY - estimatedHeight - 4;
|
||||
}
|
||||
|
||||
const width = 200; // fixed width for time picker
|
||||
|
||||
let left = rect.left + window.scrollX;
|
||||
const maxLeft = window.scrollX + window.innerWidth - width - 16; // 16px margin to screen edge
|
||||
if (left > maxLeft) {
|
||||
left = maxLeft;
|
||||
}
|
||||
if (left < window.scrollX + 16) {
|
||||
left = window.scrollX + 16;
|
||||
}
|
||||
|
||||
setPosition({ top, left, width });
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen());
|
||||
};
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
const [hour, minute] = time.split(':');
|
||||
const hourNum = parseInt(hour);
|
||||
const ampm = hourNum >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
||||
return `${displayHour}:${minute} ${ampm}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleModal}
|
||||
disabled={props.disabled}
|
||||
class={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 text-left",
|
||||
props.class
|
||||
)}
|
||||
id={props.id || 'time-picker-button'}
|
||||
ref={triggerRef}
|
||||
>
|
||||
<Show when={props.value} fallback={<span class="text-muted-foreground">{props.placeholder || "Select time"}</span>}>
|
||||
<span>{formatTime(selectedTime())}</span>
|
||||
</Show>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-auto h-4 w-4 opacity-50">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<Portal>
|
||||
{/* Close on outside click */}
|
||||
<div
|
||||
class="fixed inset-0 z-[120]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"fixed z-[130] p-3 bg-popover text-popover-foreground border rounded-md shadow-md",
|
||||
"max-w-[calc(100vw-2rem)]"
|
||||
)}
|
||||
style={{
|
||||
top: `${position().top}px`,
|
||||
left: `${position().left}px`,
|
||||
width: `${position().width}px`,
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{/* Hours column */}
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted-foreground mb-2 text-center">Hour</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<For each={hours}>
|
||||
{(hour) => (
|
||||
<button
|
||||
onClick={() => handleTimeSelect(hour, getCurrentMinute())}
|
||||
class={cn(
|
||||
"w-full py-1 text-sm rounded hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
getCurrentHour() === hour ? "bg-primary text-primary-foreground" : ""
|
||||
)}
|
||||
>
|
||||
{hour}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Minutes column */}
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted-foreground mb-2 text-center">Minute</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<For each={minutes}>
|
||||
{(minute) => (
|
||||
<button
|
||||
onClick={() => handleTimeSelect(getCurrentHour(), minute)}
|
||||
class={cn(
|
||||
"w-full py-1 text-sm rounded hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
getCurrentMinute() === minute ? "bg-primary text-primary-foreground" : ""
|
||||
)}
|
||||
>
|
||||
{minute}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createSignal, createEffect, onCleanup, Show } from 'solid-js';
|
||||
import { IconX, IconCheck, IconAlertTriangle, IconInfoCircle } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toast: Toast;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastItem = (props: ToastProps) => {
|
||||
const [isVisible, setIsVisible] = createSignal(true);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => props.onClose(props.toast.id), 300);
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (props.toast.type) {
|
||||
case 'success':
|
||||
return <IconCheck class="h-5 w-5 text-green-500" />;
|
||||
case 'error':
|
||||
return <IconAlertTriangle class="h-5 w-5 text-destructive" />;
|
||||
case 'warning':
|
||||
return <IconAlertTriangle class="h-5 w-5 text-warning" />;
|
||||
case 'info':
|
||||
return <IconInfoCircle class="h-5 w-5 text-primary" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
switch (props.toast.type) {
|
||||
case 'success':
|
||||
return 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800';
|
||||
case 'error':
|
||||
return 'bg-destructive/10 border-destructive/20';
|
||||
case 'warning':
|
||||
return 'bg-warning/10 border-warning/20';
|
||||
case 'info':
|
||||
return 'bg-primary/10 border-primary/20';
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const duration = props.toast.duration || 5000;
|
||||
const timer = setTimeout(handleClose, duration);
|
||||
onCleanup(() => clearTimeout(timer));
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`transform transition-all duration-300 ease-in-out ${
|
||||
isVisible() ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
class={`max-w-sm w-full ${getBackgroundColor()} border rounded-lg shadow-lg p-4 mb-4`}
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
{getIcon()}
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{props.toast.title}
|
||||
</p>
|
||||
{props.toast.message && (
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{props.toast.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
class="inline-flex text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary rounded"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<IconX class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
let toastId = 0;
|
||||
const [toasts, setToasts] = createSignal<Toast[]>([]);
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, message?: string, duration?: number) => {
|
||||
const id = `toast-${++toastId}`;
|
||||
setToasts(prev => [...prev, { id, type: 'success', title, message, duration }]);
|
||||
return id;
|
||||
},
|
||||
error: (title: string, message?: string, duration?: number) => {
|
||||
const id = `toast-${++toastId}`;
|
||||
setToasts(prev => [...prev, { id, type: 'error', title, message, duration }]);
|
||||
return id;
|
||||
},
|
||||
warning: (title: string, message?: string, duration?: number) => {
|
||||
const id = `toast-${++toastId}`;
|
||||
setToasts(prev => [...prev, { id, type: 'warning', title, message, duration }]);
|
||||
return id;
|
||||
},
|
||||
info: (title: string, message?: string, duration?: number) => {
|
||||
const id = `toast-${++toastId}`;
|
||||
setToasts(prev => [...prev, { id, type: 'info', title, message, duration }]);
|
||||
return id;
|
||||
},
|
||||
dismiss: (id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
export const ToastContainer = () => {
|
||||
const handleClose = (id: string) => {
|
||||
toast.dismiss(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="fixed top-4 right-4 z-50 space-y-4">
|
||||
{toasts().map(toast => (
|
||||
<ToastItem toast={toast} onClose={handleClose} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import { createSignal, onMount, onCleanup, Show } from 'solid-js';
|
||||
import {
|
||||
IconRefresh,
|
||||
IconCheck,
|
||||
IconAlertTriangle,
|
||||
IconDownload,
|
||||
IconLoader2
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { updateService, type UpdateInfo, type UpdateStatus } from '../../services/updateService';
|
||||
|
||||
interface UpdateCheckerProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
const [updateAvailable, setUpdateAvailable] = createSignal(false);
|
||||
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
|
||||
const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
progress: 0
|
||||
});
|
||||
const [isChecking, setIsChecking] = createSignal(false);
|
||||
const [showUpdateModal, setShowUpdateModal] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [currentVersion, setCurrentVersion] = createSignal('1.0.0');
|
||||
|
||||
let pollCleanup: (() => void) | null = null;
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await updateService.checkForUpdates();
|
||||
setUpdateAvailable(response.updateAvailable);
|
||||
setUpdateInfo(response.updateInfo || null);
|
||||
setCurrentVersion(response.currentVersion);
|
||||
|
||||
if (response.updateAvailable && response.updateInfo) {
|
||||
setUpdateStatus(prev => ({ ...prev, available: true }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check for updates:', err);
|
||||
setError('Failed to check for updates');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const installUpdate = async () => {
|
||||
if (!updateInfo()) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await updateService.installUpdate(updateInfo()!.version);
|
||||
|
||||
// Start polling for progress
|
||||
pollCleanup = updateService.pollUpdateProgress((progress: UpdateStatus) => {
|
||||
setUpdateStatus(progress);
|
||||
|
||||
if (progress.completed) {
|
||||
setShowUpdateModal(false);
|
||||
// Show success notification or trigger reload
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
if (progress.error) {
|
||||
setError(progress.error);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to install update:', err);
|
||||
setError('Failed to install update');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelUpdate = () => {
|
||||
if (pollCleanup) {
|
||||
pollCleanup();
|
||||
pollCleanup = null;
|
||||
}
|
||||
setShowUpdateModal(false);
|
||||
setUpdateStatus({
|
||||
available: updateAvailable(),
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
progress: 0
|
||||
});
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Check for updates on component mount
|
||||
checkForUpdates();
|
||||
|
||||
// Set current version
|
||||
setCurrentVersion(updateService.getCurrentVersion());
|
||||
|
||||
// Check for updates periodically (every 30 minutes)
|
||||
const intervalId = setInterval(checkForUpdates, 30 * 60 * 1000);
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(intervalId);
|
||||
if (pollCleanup) {
|
||||
pollCleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isChecking()) return <IconLoader2 class="size-4 animate-spin" />;
|
||||
if (updateStatus().downloading || updateStatus().installing) return <IconLoader2 class="size-4 animate-spin" />;
|
||||
if (updateStatus().completed) return <IconCheck class="size-4 text-green-500" />;
|
||||
if (updateAvailable()) return <IconDownload class="size-4 text-blue-500" />;
|
||||
if (error()) return <IconAlertTriangle class="size-4 text-red-500" />;
|
||||
return <IconRefresh class="size-4" />;
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isChecking()) return 'Checking...';
|
||||
if (updateStatus().downloading) return `Downloading... ${Math.round(updateStatus().progress)}%`;
|
||||
if (updateStatus().installing) return `Installing... ${Math.round(updateStatus().progress)}%`;
|
||||
if (updateStatus().completed) return 'Update Complete';
|
||||
if (updateAvailable()) return 'Update Available';
|
||||
if (error()) return 'Update Failed';
|
||||
return 'Check Updates';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class={`flex items-center gap-2 ${props.class || ''}`}>
|
||||
<button
|
||||
onClick={() => updateAvailable() ? setShowUpdateModal(true) : checkForUpdates()}
|
||||
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden w-full"
|
||||
classList={{
|
||||
"bg-blue-500/20 text-blue-400": updateAvailable() && !updateStatus().downloading && !updateStatus().installing,
|
||||
"hover:bg-blue-500/30": updateAvailable() && !updateStatus().downloading && !updateStatus().installing,
|
||||
"bg-orange-500/20 text-orange-400": updateStatus().downloading || updateStatus().installing,
|
||||
"hover:bg-orange-500/30": updateStatus().downloading || updateStatus().installing,
|
||||
"bg-green-500/20 text-green-400": updateStatus().completed,
|
||||
"hover:bg-green-500/30": updateStatus().completed,
|
||||
"bg-red-500/20 text-red-400": !!error(),
|
||||
"hover:bg-red-500/30": !!error(),
|
||||
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !updateAvailable() && !updateStatus().downloading && !updateStatus().installing && !updateStatus().completed && !error()
|
||||
}}
|
||||
disabled={isChecking() || updateStatus().downloading || updateStatus().installing}
|
||||
>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
{getStatusIcon()}
|
||||
<div class="transition-colors truncate">
|
||||
{getStatusText()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200"></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Update Modal */}
|
||||
<Show when={showUpdateModal() && updateInfo()}>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-card border border-border rounded-lg shadow-lg max-w-md w-full max-h-[80vh] overflow-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<IconDownload class="size-6 text-blue-500" />
|
||||
<h2 class="text-lg font-semibold">Update Available</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm text-muted-foreground">Current Version</span>
|
||||
<span class="text-sm font-medium">{currentVersion()}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Latest Version</span>
|
||||
<span class="text-sm font-medium text-blue-500">{updateInfo()!.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium mb-2">Release Notes</h3>
|
||||
<div class="text-sm text-muted-foreground whitespace-pre-line bg-muted/30 rounded p-3">
|
||||
{updateInfo()!.releaseNotes}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-muted-foreground">Download Size</span>
|
||||
<span>{updateInfo()!.size}</span>
|
||||
</div>
|
||||
|
||||
<Show when={updateStatus().downloading || updateStatus().installing}>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">
|
||||
{updateStatus().downloading ? 'Downloading' : 'Installing'}
|
||||
</span>
|
||||
<span>{Math.round(updateStatus().progress)}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${updateStatus().progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded p-3">
|
||||
<div class="flex items-center gap-2 text-red-500 text-sm">
|
||||
<IconAlertTriangle class="size-4" />
|
||||
<span>{error()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={updateStatus().completed}>
|
||||
<div class="bg-green-500/10 border border-green-500/20 rounded p-3">
|
||||
<div class="flex items-center gap-2 text-green-500 text-sm">
|
||||
<IconCheck class="size-4" />
|
||||
<span>Update completed successfully! Restarting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<Show when={!updateStatus().downloading && !updateStatus().installing && !updateStatus().completed}>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
class="flex-1 px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={installUpdate}
|
||||
disabled={updateStatus().downloading || updateStatus().installing}
|
||||
class="flex-1 px-4 py-2 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Show when={updateStatus().downloading || updateStatus().installing}>
|
||||
<IconLoader2 class="size-4 animate-spin" />
|
||||
</Show>
|
||||
{updateStatus().downloading || updateStatus().installing ? 'Installing...' : 'Install Update'}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={updateStatus().downloading || updateStatus().installing || error()}>
|
||||
<button
|
||||
onClick={cancelUpdate}
|
||||
class="px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={updateStatus().completed}>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
class="w-full px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
||||
>
|
||||
Reload Application
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { createSignal, onMount, Show, For } from 'solid-js'
|
||||
import {
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconX,
|
||||
IconCheck,
|
||||
IconAlertTriangle,
|
||||
IconLoader2
|
||||
} from '@tabler/icons-solidjs'
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string
|
||||
releaseNotes: string
|
||||
downloadUrl: string
|
||||
mandatory: boolean
|
||||
size: string
|
||||
}
|
||||
|
||||
interface UpdateStatus {
|
||||
available: boolean
|
||||
downloading: boolean
|
||||
installing: boolean
|
||||
completed: boolean
|
||||
error: string | null
|
||||
progress: number
|
||||
}
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null)
|
||||
const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
error: null,
|
||||
progress: 0
|
||||
})
|
||||
const [dismissed, setDismissed] = createSignal(false)
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
checkForUpdates()
|
||||
// Check for updates every 30 minutes
|
||||
const interval = setInterval(checkForUpdates, 30 * 60 * 1000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/updates/check')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.updateAvailable) {
|
||||
setUpdateInfo(data.updateInfo)
|
||||
setUpdateStatus(prev => ({ ...prev, available: true }))
|
||||
setDismissed(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const startUpdate = async () => {
|
||||
if (!updateInfo()) return
|
||||
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
downloading: true,
|
||||
error: null,
|
||||
progress: 0
|
||||
}))
|
||||
|
||||
try {
|
||||
// Start the update process
|
||||
const response = await fetch('/api/updates/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ version: updateInfo()?.version })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start update')
|
||||
}
|
||||
|
||||
// Monitor progress
|
||||
monitorUpdateProgress()
|
||||
} catch (error) {
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
error: error instanceof Error ? error.message : 'Update failed'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const monitorUpdateProgress = async () => {
|
||||
const progressInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/updates/progress')
|
||||
if (response.ok) {
|
||||
const progress = await response.json()
|
||||
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
progress: progress.progress,
|
||||
installing: progress.installing,
|
||||
downloading: progress.downloading
|
||||
}))
|
||||
|
||||
if (progress.completed) {
|
||||
clearInterval(progressInterval)
|
||||
setUpdateStatus(prev => ({ ...prev, completed: true }))
|
||||
|
||||
// Reload page after a short delay to show completion
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
} else if (progress.error) {
|
||||
clearInterval(progressInterval)
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
error: progress.error
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval)
|
||||
setUpdateStatus(prev => ({
|
||||
...prev,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
error: 'Failed to monitor update progress'
|
||||
}))
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
setDismissed(true)
|
||||
if (!updateInfo()?.mandatory) {
|
||||
setUpdateStatus(prev => ({ ...prev, available: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const status = updateStatus()
|
||||
const info = updateInfo()
|
||||
|
||||
return (
|
||||
<Show when={status.available && !dismissed()}>
|
||||
<div class="border-b border-border bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20">
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<Show
|
||||
when={status.completed}
|
||||
fallback={
|
||||
<Show
|
||||
when={status.error}
|
||||
fallback={
|
||||
<Show
|
||||
when={status.downloading || status.installing}
|
||||
fallback={<IconDownload class="size-5 text-blue-600 dark:text-blue-400 animate-pulse" />}
|
||||
>
|
||||
<IconLoader2 class="size-5 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<IconAlertTriangle class="size-5 text-red-600 dark:text-red-400" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<IconCheck class="size-5 text-green-600 dark:text-green-400" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<Show
|
||||
when={status.completed}
|
||||
fallback={
|
||||
<Show
|
||||
when={status.error}
|
||||
fallback={
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
New version {info?.version} available
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p class="text-sm font-medium text-red-900 dark:text-red-100">
|
||||
Update failed
|
||||
</p>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<p class="text-sm font-medium text-green-900 dark:text-green-100">
|
||||
Update completed! Reloading...
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={!status.completed && !status.error}>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||
{info?.size} • {info?.mandatory ? 'Required update' : 'Optional update'}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<Show when={!status.completed && !status.error && !status.downloading}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded())}
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200"
|
||||
>
|
||||
{expanded() ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={startUpdate}
|
||||
class="inline-flex items-center gap-1 px-3 py-1 text-xs font-medium bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<IconDownload class="size-3" />
|
||||
Update Now
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={status.downloading || status.installing}>
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400">
|
||||
{status.installing ? 'Installing...' : `Downloading... ${Math.round(status.progress)}%`}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={status.error}>
|
||||
<button
|
||||
onClick={startUpdate}
|
||||
class="inline-flex items-center gap-1 px-3 py-1 text-xs font-medium bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<IconRefresh class="size-3" />
|
||||
Retry
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={!info?.mandatory}>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Show when={status.downloading || status.installing}>
|
||||
<div class="mt-2">
|
||||
<div class="w-full bg-blue-100 dark:bg-blue-900/30 rounded-full h-1.5">
|
||||
<div
|
||||
class="bg-blue-600 h-1.5 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${status.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Expanded Details */}
|
||||
<Show when={expanded() && info}>
|
||||
<div class="mt-3 p-3 bg-white/50 dark:bg-black/20 rounded-md border border-blue-200 dark:border-blue-800">
|
||||
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
What's new in {info?.version}
|
||||
</h4>
|
||||
<div class="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<For each={info?.releaseNotes.split('\n').filter(line => line.trim()) || []}>
|
||||
{(line) => <p>• {line}</p>}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Error Message */}
|
||||
<Show when={status.error}>
|
||||
<div class="mt-2 p-2 bg-red-50 dark:bg-red-950/20 rounded-md border border-red-200 dark:border-red-800">
|
||||
<p class="text-xs text-red-800 dark:text-red-200">
|
||||
{status.error}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { IconX, IconUpload } from '@tabler/icons-solidjs';
|
||||
|
||||
interface UploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UploadModal = (props: UploadModalProps) => {
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
const [uploadedFiles, setUploadedFiles] = createSignal<File[]>([]);
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
setUploadedFiles(prev => [...prev, ...files]);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const files = Array.from(target.files || []);
|
||||
setUploadedFiles(prev => [...prev, ...files]);
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const files = uploadedFiles();
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
|
||||
try {
|
||||
if (isDemoMode) {
|
||||
// Simulate upload in demo mode
|
||||
console.log('Demo mode: Simulating upload for files:', files.map(f => f.name));
|
||||
// Simulate upload delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Reset and close
|
||||
setUploadedFiles([]);
|
||||
props.onClose();
|
||||
alert('Files uploaded successfully! (Demo Mode)');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload ${file.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset and close
|
||||
setUploadedFiles([]);
|
||||
props.onClose();
|
||||
alert('Files uploaded successfully!');
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to upload files');
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 600px; max-width: 90vw; max-height: 80vh; overflow-y: auto;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">Import Documents</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
class={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
isDragging()
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<IconUpload class="size-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h4 class="text-lg font-medium mb-2">Drop files here</h4>
|
||||
<p class="text-muted-foreground mb-4">or click to browse</p>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
class="hidden"
|
||||
id="file-input"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
Browse Files
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{uploadedFiles().length > 0 && (
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium">Selected Files:</h4>
|
||||
{uploadedFiles().map((file, index) => (
|
||||
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate">{file.name}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFile(index)}
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploadedFiles().length === 0}
|
||||
>
|
||||
Upload {uploadedFiles().length} {uploadedFiles().length === 1 ? 'File' : 'Files'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import {
|
||||
IconUser,
|
||||
IconSettings,
|
||||
IconLogout,
|
||||
IconChartLine,
|
||||
IconChevronDown
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { DropdownMenu, DropdownMenuItem } from './DropdownMenu';
|
||||
|
||||
interface UserProfile {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
role: string;
|
||||
joinDate: string;
|
||||
}
|
||||
|
||||
export const UserProfileDropdown = () => {
|
||||
const [userProfile] = createSignal<UserProfile>({
|
||||
name: 'Admin User',
|
||||
email: 'admin@trackeep.com',
|
||||
role: 'Administrator',
|
||||
joinDate: '2024-01-01'
|
||||
});
|
||||
|
||||
const handleProfileClick = () => {
|
||||
window.location.href = '/app/profile';
|
||||
};
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
window.location.href = '/app/settings';
|
||||
};
|
||||
|
||||
const handleStatsClick = () => {
|
||||
window.location.href = '/app/stats';
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('Are you sure you want to logout?')) {
|
||||
// In real app, this would clear auth tokens and redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(part => part.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<button type="button" class="items-center justify-center rounded-md font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-9 px-3 py-1 text-base flex gap-2">
|
||||
<div class="w-5 h-5 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-xs font-medium">
|
||||
{getInitials(userProfile().name)}
|
||||
</div>
|
||||
<IconChevronDown class="size-3 opacity-50" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{/* User Info Header */}
|
||||
<div class="px-3 py-2 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{getInitials(userProfile().name)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{userProfile().name}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">{userProfile().email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div class="px-3 py-2 border-b border-border">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="text-center">
|
||||
<p class="font-medium text-primary">156</p>
|
||||
<p class="text-muted-foreground">Bookmarks</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="font-medium text-primary">42</p>
|
||||
<p class="text-muted-foreground">Tasks</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<DropdownMenuItem onClick={handleProfileClick} icon={IconUser}>
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleStatsClick} icon={IconChartLine}>
|
||||
Statistics
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSettingsClick} icon={IconSettings}>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
|
||||
<div class="border-t border-border my-1"></div>
|
||||
|
||||
<DropdownMenuItem onClick={handleLogout} icon={IconLogout} variant="destructive">
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { IconX, IconExternalLink } from '@tabler/icons-solidjs';
|
||||
|
||||
interface VideoPreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
video: any;
|
||||
}
|
||||
|
||||
export const VideoPreviewModal = (props: VideoPreviewModalProps) => {
|
||||
const getEmbedUrl = (videoId: string) => {
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 900px; max-width: 90vw; max-height: 80vh;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold truncate pr-4">{props.video?.title}</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video Player */}
|
||||
<div class="p-6">
|
||||
<div class="aspect-video bg-black rounded-lg overflow-hidden">
|
||||
{props.video && (
|
||||
<iframe
|
||||
src={getEmbedUrl(props.video.video_id)}
|
||||
title={props.video.title}
|
||||
class="w-full h-full"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Info */}
|
||||
<div class="px-6 pb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 class="text-lg font-medium mb-1">{props.video?.title}</h4>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Channel: {props.video?.channel_name}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.open(props.video?.url, '_blank')}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<IconExternalLink class="size-4" />
|
||||
Open on YouTube
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface VideoUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (video: any) => void;
|
||||
}
|
||||
|
||||
export const VideoUploadModal = (props: VideoUploadModalProps) => {
|
||||
const [newVideo, setNewVideo] = createSignal({
|
||||
url: '',
|
||||
title: '',
|
||||
description: '',
|
||||
tags: ''
|
||||
});
|
||||
|
||||
const extractVideoId = (url: string) => {
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const videoId = extractVideoId(newVideo().url);
|
||||
if (!videoId) {
|
||||
alert('Please enter a valid YouTube URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const video = {
|
||||
video_id: videoId,
|
||||
url: newVideo().url,
|
||||
title: newVideo().title || `YouTube Video - ${videoId}`,
|
||||
description: newVideo().description,
|
||||
tags: newVideo().tags.split(',').map(tag => tag.trim()).filter(Boolean),
|
||||
channel_name: 'Unknown',
|
||||
duration: 'Unknown',
|
||||
view_count: '0',
|
||||
published_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
props.onSubmit(video);
|
||||
setNewVideo({ url: '', title: '', description: '', tags: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: 500px; max-width: 90vw;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold">Add YouTube Video</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium">YouTube URL</label>
|
||||
<Input
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
value={newVideo().url}
|
||||
onInput={(e) => setNewVideo({ ...newVideo(), url: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium">Title (optional)</label>
|
||||
<Input
|
||||
placeholder="Video title"
|
||||
value={newVideo().title}
|
||||
onInput={(e) => setNewVideo({ ...newVideo(), title: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium">Description (optional)</label>
|
||||
<textarea
|
||||
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Video description"
|
||||
value={newVideo().description}
|
||||
onInput={(e) => setNewVideo({ ...newVideo(), description: (e.target as HTMLTextAreaElement).value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium">Tags (comma-separated)</label>
|
||||
<Input
|
||||
placeholder="tutorial, learning, tech"
|
||||
value={newVideo().tags}
|
||||
onInput={(e) => setNewVideo({ ...newVideo(), tags: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!newVideo().url}
|
||||
>
|
||||
Add Video
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Note {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: string[];
|
||||
pinned: boolean;
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: string;
|
||||
url?: string;
|
||||
}>;
|
||||
isMarkdown?: boolean;
|
||||
isHtml?: boolean;
|
||||
}
|
||||
|
||||
interface ViewNoteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
note: Note | null;
|
||||
onEdit: (note: Note) => void;
|
||||
onTogglePin: (noteId: number) => void;
|
||||
onDelete: (noteId: number) => void;
|
||||
onCopyContent?: (note: Note) => void;
|
||||
onExportNote?: (note: Note) => void;
|
||||
}
|
||||
|
||||
export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
console.log('ViewNoteModal render:', { isOpen: props.isOpen, note: props.note?.title });
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<Show when={props.isOpen && props.note}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 z-50"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Modal */}
|
||||
<Show when={props.isOpen && props.note}>
|
||||
<div
|
||||
class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-2xl transition-all duration-300 z-50"
|
||||
style="width: 800px; max-width: 90vw; max-height: 85vh; overflow-y: auto;"
|
||||
>
|
||||
{props.note && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-xl font-semibold text-[#fafafa]">{props.note.title}</h3>
|
||||
{props.note.pinned && <IconPin class="size-5 text-primary" />}
|
||||
{props.note.isMarkdown && <span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">MD</span>}
|
||||
{props.note.isHtml && <span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">HTML</span>}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => props.onCopyContent?.(props.note!)}
|
||||
class="text-primary hover:text-primary/80 p-1"
|
||||
>
|
||||
<IconCopy size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => props.onExportNote?.(props.note!)}
|
||||
class="text-primary hover:text-primary/80 p-1"
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
props.onEdit(props.note!);
|
||||
props.onClose();
|
||||
}}
|
||||
class="text-primary hover:text-primary/80 p-1"
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
props.onTogglePin(props.note!.id);
|
||||
props.onClose();
|
||||
}}
|
||||
class="text-primary hover:text-primary/80 p-1"
|
||||
>
|
||||
<IconPin size={18} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this note?')) {
|
||||
props.onDelete(props.note!.id);
|
||||
props.onClose();
|
||||
}
|
||||
}}
|
||||
class="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</Button>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
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"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
{/* Tags */}
|
||||
{props.note.tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{props.note.tags.map((tag) => (
|
||||
<span class="px-3 py-1 bg-[#262626] text-[#a3a3a3] text-sm rounded-md">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
{props.note.attachments && props.note.attachments.length > 0 && (
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<IconPaperclip class="size-4 text-[#a3a3a3]" />
|
||||
<span class="text-sm text-[#a3a3a3]">Attachments ({props.note.attachments.length})</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{props.note.attachments.map((attachment) => (
|
||||
<div class="flex items-center justify-between p-3 bg-[#262626] rounded-md">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[#fafafa]">{attachment.name}</span>
|
||||
<span class="text-xs text-[#666]">({attachment.size})</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="text-primary hover:text-primary/80">
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Content */}
|
||||
<div class="prose prose-invert max-w-none">
|
||||
{props.note.isHtml ? (
|
||||
<div
|
||||
class="text-[#fafafa] leading-relaxed"
|
||||
innerHTML={props.note.content}
|
||||
/>
|
||||
) : props.note.isMarkdown ? (
|
||||
<div class="text-[#fafafa] leading-relaxed">
|
||||
{/* Enhanced Markdown rendering with image support */}
|
||||
<For each={props.note.content
|
||||
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mb-4">$1</h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-bold mb-3">$1</h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-bold mb-2">$1</h3>')
|
||||
.replace(/^#### (.*$)/gim, '<h4 class="text-md font-bold mb-2">$1</h4>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-sm">$1</code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-4 rounded mb-4 overflow-x-auto"><code class="text-sm">$2</code></pre>')
|
||||
.replace(/\n\n/g, '</p><p class="mb-4">')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-4 italic text-[#aaa] mb-4">$1</blockquote>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="max-w-full h-auto rounded mb-4" onerror="this.style.display=\'none\'; this.nextElementSibling.style.display=\'block\';" /><div style="display:none;" class="text-[#666] italic mb-4">Image: $1 ($2)</div>')
|
||||
.split('</p><p class="mb-4">')}>
|
||||
{(line) => (
|
||||
<div innerHTML={line.startsWith('<') ? line : `<p class="mb-4">${line}</p>`} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
) : (
|
||||
<div class="text-[#fafafa] whitespace-pre-wrap leading-relaxed">
|
||||
{/* Auto-detect and render URLs and basic formatting */}
|
||||
{props.note.content
|
||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.split('\n').map((line) => (
|
||||
<div innerHTML={line || '<br />'} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div class="pt-4 border-t border-border">
|
||||
<div class="text-sm text-[#a3a3a3] space-y-1">
|
||||
<p>Created: {new Date(props.note.createdAt).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(props.note.updatedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface VirtualListProps<T> {
|
||||
items: T[]
|
||||
itemHeight: number
|
||||
containerHeight: number
|
||||
renderItem: (item: T, index: number) => any
|
||||
overscan?: number
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function VirtualList<T>(props: VirtualListProps<T>) {
|
||||
const [scrollTop, setScrollTop] = createSignal(0)
|
||||
|
||||
const overscan = props.overscan || 5
|
||||
const itemHeight = props.itemHeight
|
||||
|
||||
const visibleRange = () => {
|
||||
const start = Math.floor(scrollTop() / itemHeight)
|
||||
const visibleCount = Math.ceil(props.containerHeight / itemHeight)
|
||||
const end = start + visibleCount
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - overscan),
|
||||
end: Math.min(props.items.length, end + overscan)
|
||||
}
|
||||
}
|
||||
|
||||
const totalHeight = () => props.items.length * itemHeight
|
||||
|
||||
const offsetY = () => visibleRange().start * itemHeight
|
||||
|
||||
const visibleItems = () => {
|
||||
const { start, end } = visibleRange()
|
||||
return props.items.slice(start, end).map((item, index) => ({
|
||||
item,
|
||||
index: start + index
|
||||
}))
|
||||
}
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
setScrollTop(target.scrollTop)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn('overflow-auto', props.class)}
|
||||
style={{ height: `${props.containerHeight}px` }}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div style={{ height: `${totalHeight()}px`, position: 'relative' }}>
|
||||
<div style={{ transform: `translateY(${offsetY()}px)` }}>
|
||||
<For each={visibleItems()}>
|
||||
{({ item, index }) => (
|
||||
<div style={{ height: `${itemHeight}px` }}>
|
||||
{props.renderItem(item, index)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface InfiniteScrollProps<T> {
|
||||
items: T[]
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
onLoadMore: () => void
|
||||
renderItem: (item: T, index: number) => any
|
||||
loader?: any
|
||||
class?: string
|
||||
}
|
||||
|
||||
export function InfiniteScroll<T>(props: InfiniteScrollProps<T>) {
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
const { scrollTop, scrollHeight, clientHeight } = target
|
||||
|
||||
// Load more when user is near the bottom
|
||||
if (
|
||||
!props.loading &&
|
||||
props.hasMore &&
|
||||
scrollHeight - scrollTop - clientHeight < 200
|
||||
) {
|
||||
props.onLoadMore()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn('overflow-auto', props.class)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<For each={props.items}>
|
||||
{(item, index) => props.renderItem(item, index())}
|
||||
</For>
|
||||
|
||||
<Show when={props.loading}>
|
||||
{props.loader || (
|
||||
<div class="p-4 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={!props.hasMore && props.items.length > 0}>
|
||||
<div class="p-4 text-center text-muted-foreground text-sm">
|
||||
No more items to load
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { splitProps } from 'solid-js'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps {
|
||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
class?: string;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
export function Badge(props: BadgeProps) {
|
||||
const [local, others] = splitProps(props, ['variant', 'class']);
|
||||
|
||||
return (
|
||||
<div class={cn(badgeVariants({ variant: local.variant }), local.class)} {...others}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { badgeVariants }
|
||||
@@ -0,0 +1,24 @@
|
||||
import { splitProps } from 'solid-js'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
export interface LabelProps {
|
||||
class?: string;
|
||||
for?: string;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
export function Label(props: LabelProps) {
|
||||
const [local, others] = splitProps(props, ['class']);
|
||||
|
||||
return (
|
||||
<label class={cn(labelVariants(), local.class)} {...others}>
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ChevronDown } from 'lucide-solid';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SelectProps {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
children?: any;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function Select(props: SelectProps) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
export interface SelectTriggerProps {
|
||||
children?: any;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function SelectTrigger(props: SelectTriggerProps) {
|
||||
return (
|
||||
<div class={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
props.class
|
||||
)}>
|
||||
{props.children}
|
||||
<ChevronDown class="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectValueProps {
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export function SelectValue(props: SelectValueProps) {
|
||||
return <span>{props.value || props.placeholder}</span>;
|
||||
}
|
||||
|
||||
export interface SelectContentProps {
|
||||
children?: any;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function SelectContent(props: SelectContentProps) {
|
||||
return (
|
||||
<div class={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
|
||||
props.class
|
||||
)}>
|
||||
<div class="p-1">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectItemProps {
|
||||
value: string;
|
||||
children: any;
|
||||
onClick?: (value: string) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function SelectItem(props: SelectItemProps) {
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent cursor-pointer",
|
||||
props.class
|
||||
)}
|
||||
onClick={() => props.onClick?.(props.value)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TabsProps {
|
||||
defaultValue?: string;
|
||||
children?: any;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function Tabs(props: TabsProps) {
|
||||
const [activeTab, setActiveTab] = createSignal(props.defaultValue || '');
|
||||
|
||||
return (
|
||||
<div class={props.class}>
|
||||
{props.children?.map((child: any) => {
|
||||
if (child?.type === TabsList) {
|
||||
return child({ activeTab, setActiveTab });
|
||||
}
|
||||
if (child?.type === TabsContent) {
|
||||
return child({ activeTab });
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabsListProps {
|
||||
children?: any;
|
||||
activeTab?: () => string;
|
||||
setActiveTab?: (tab: string) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function TabsList(props: TabsListProps) {
|
||||
return (
|
||||
<div class={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
props.class
|
||||
)}>
|
||||
{props.children?.map((child: any) => {
|
||||
if (child?.type === TabsTrigger) {
|
||||
return child({
|
||||
isActive: child.props.value === props.activeTab?.(),
|
||||
onClick: () => props.setActiveTab?.(child.props.value)
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabsTriggerProps {
|
||||
value: string;
|
||||
children: any;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function TabsTrigger(props: TabsTriggerProps) {
|
||||
return (
|
||||
<button
|
||||
class={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
props.isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "hover:bg-accent/50 hover:text-accent-foreground",
|
||||
props.class
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabsContentProps {
|
||||
value: string;
|
||||
children: any;
|
||||
activeTab?: () => string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function TabsContent(props: TabsContentProps) {
|
||||
if (props.value !== props.activeTab?.()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
props.class
|
||||
)}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
declare module "*.svg" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.png" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.jpg" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.jpeg" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.gif" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.webp" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.ico" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.bmp" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createSignal, createEffect, onCleanup } from 'solid-js'
|
||||
|
||||
export function useDebounce<T>(value: () => T, delay: number): () => T {
|
||||
const [debouncedValue, setDebouncedValue] = createSignal<T>(value())
|
||||
let timeoutId: number | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const currentValue = value()
|
||||
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
setDebouncedValue(() => currentValue)
|
||||
}, delay) as unknown as number
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
})
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
export function useDebouncedCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
let timeoutId: number | undefined
|
||||
|
||||
return ((...args: Parameters<T>) => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
callback(...args)
|
||||
}, delay) as unknown as number
|
||||
}) as T
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createSignal, createEffect, onMount } from 'solid-js'
|
||||
import { isServer } from 'solid-js/web'
|
||||
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
options?: {
|
||||
serializer?: {
|
||||
read: (value: string) => T
|
||||
write: (value: T) => string
|
||||
}
|
||||
}
|
||||
) {
|
||||
const serializer = options?.serializer || {
|
||||
read: (v: string) => {
|
||||
try {
|
||||
return JSON.parse(v)
|
||||
} catch {
|
||||
return v as T
|
||||
}
|
||||
},
|
||||
write: (v: T) => JSON.stringify(v),
|
||||
}
|
||||
|
||||
const [storedValue, setStoredValue] = createSignal<T>(initialValue)
|
||||
|
||||
// Initialize on mount
|
||||
onMount(() => {
|
||||
if (isServer) return
|
||||
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
if (item !== null) {
|
||||
setStoredValue(() => serializer.read(item))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (isServer) return
|
||||
|
||||
try {
|
||||
const value = storedValue()
|
||||
if (value === undefined || value === null) {
|
||||
localStorage.removeItem(key)
|
||||
} else {
|
||||
localStorage.setItem(key, serializer.write(value))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error)
|
||||
}
|
||||
})
|
||||
|
||||
return [storedValue, setStoredValue] as const
|
||||
}
|
||||
|
||||
export function useSessionStorage<T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
options?: {
|
||||
serializer?: {
|
||||
read: (value: string) => T
|
||||
write: (value: T) => string
|
||||
}
|
||||
}
|
||||
) {
|
||||
const serializer = options?.serializer || {
|
||||
read: (v: string) => {
|
||||
try {
|
||||
return JSON.parse(v)
|
||||
} catch {
|
||||
return v as T
|
||||
}
|
||||
},
|
||||
write: (v: T) => JSON.stringify(v),
|
||||
}
|
||||
|
||||
const [storedValue, setStoredValue] = createSignal<T>(initialValue)
|
||||
|
||||
// Initialize on mount
|
||||
onMount(() => {
|
||||
if (isServer) return
|
||||
|
||||
try {
|
||||
const item = sessionStorage.getItem(key)
|
||||
if (item !== null) {
|
||||
setStoredValue(() => serializer.read(item))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error reading sessionStorage key "${key}":`, error)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (isServer) return
|
||||
|
||||
try {
|
||||
const value = storedValue()
|
||||
if (value === undefined || value === null) {
|
||||
sessionStorage.removeItem(key)
|
||||
} else {
|
||||
sessionStorage.setItem(key, serializer.write(value))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error setting sessionStorage key "${key}":`, error)
|
||||
}
|
||||
})
|
||||
|
||||
return [storedValue, setStoredValue] as const
|
||||
}
|
||||
@@ -1,68 +1,798 @@
|
||||
/* Complete Papra CSS - 1:1 match */
|
||||
|
||||
/* Animation utilities */
|
||||
.data-\[expanded\]\:animate-in[data-expanded] {
|
||||
animation: una-in;
|
||||
animation-name: una-in;
|
||||
animation-duration: .15s;
|
||||
--una-enter-opacity: initial;
|
||||
--una-enter-scale: initial;
|
||||
--una-enter-rotate: initial;
|
||||
--una-enter-translate-x: initial;
|
||||
--una-enter-translate-y: initial
|
||||
}
|
||||
|
||||
.data-\[closed\]\:animate-out[data-closed] {
|
||||
animation: una-out;
|
||||
animation-name: una-out;
|
||||
animation-duration: .15s;
|
||||
--una-exit-opacity: initial;
|
||||
--una-exit-scale: initial;
|
||||
--una-exit-rotate: initial;
|
||||
--una-exit-translate-x: initial;
|
||||
--una-exit-translate-y: initial
|
||||
}
|
||||
|
||||
/* Complete Inter Font Faces - Exact Papra */
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.bunny.net/inter/files/inter-latin-400-normal.woff2) format("woff2"),url(https://fonts.bunny.net/inter/files/inter-latin-400-normal.woff) format("woff");
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.bunny.net/inter/files/inter-latin-500-normal.woff2) format("woff2"),url(https://fonts.bunny.net/inter/files/inter-latin-500-normal.woff) format("woff");
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.bunny.net/inter/files/inter-latin-600-normal.woff2) format("woff2"),url(https://fonts.bunny.net/inter/files/inter-latin-600-normal.woff) format("woff");
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-stretch: 100%;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.bunny.net/inter/files/inter-latin-700-normal.woff2) format("woff2"),url(https://fonts.bunny.net/inter/files/inter-latin-700-normal.woff) format("woff");
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD
|
||||
}
|
||||
|
||||
/* Exact Papra CSS Variables and Base Styles */
|
||||
:root,:host {
|
||||
--colors-border: hsl(var(--border));
|
||||
--colors-background: hsl(var(--background));
|
||||
--colors-foreground: hsl(var(--foreground));
|
||||
--font-sans: "Inter",ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
|
||||
--radius-lg: .5rem;
|
||||
--spacing: .25rem;
|
||||
--fontWeight-semibold: 600;
|
||||
--radius-xl: .75rem;
|
||||
--fontWeight-bold: 700;
|
||||
--fontWeight-medium: 500;
|
||||
--leading-tight: 1.25;
|
||||
--font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
|
||||
--container-xl: 36rem;
|
||||
--radius-md: .375rem;
|
||||
--container-sm: 24rem;
|
||||
--container-xs: 20rem;
|
||||
--leading-none: 1;
|
||||
--container-md: 28rem;
|
||||
--default-transition-timingFunction: cubic-bezier(.4, 0, .2, 1);
|
||||
--default-transition-duration: .15s;
|
||||
--container-7xl: 80rem;
|
||||
--container-5xl: 64rem;
|
||||
--fontWeight-normal: 400;
|
||||
--fontWeight-light: 300;
|
||||
--colors-primary-DEFAULT: hsl(var(--primary));
|
||||
--radius-DEFAULT: .25rem;
|
||||
--radius-sm: .25rem;
|
||||
--tracking-widest: .1em;
|
||||
--container-lg: 32rem;
|
||||
--colors-card-DEFAULT: hsl(var(--card));
|
||||
--ease-DEFAULT: cubic-bezier(.4, 0, .2, 1);
|
||||
--ease-out: cubic-bezier(0, 0, .2, 1);
|
||||
--tracking-tight: -.025em;
|
||||
--colors-secondary-DEFAULT: hsl(var(--secondary));
|
||||
--ease-linear: linear;
|
||||
--ease-in-out: cubic-bezier(.4, 0, .2, 1);
|
||||
--colors-ring: hsl(var(--ring));
|
||||
--container-2xl: 42rem;
|
||||
--colors-destructive-DEFAULT: hsl(var(--destructive));
|
||||
--colors-muted-foreground: hsl(var(--muted-foreground));
|
||||
--colors-red-500: oklch(63.7% .237 25.331);
|
||||
--colors-red-DEFAULT: oklch(70.4% .191 22.216);
|
||||
--colors-primary-foreground: hsl(var(--primary-foreground));
|
||||
--colors-muted-DEFAULT: hsl(var(--muted));
|
||||
--colors-warning-DEFAULT: hsl(var(--warning));
|
||||
--colors-popover-DEFAULT: hsl(var(--popover));
|
||||
--colors-popover-foreground: hsl(var(--popover-foreground));
|
||||
--colors-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--colors-input: hsl(var(--input));
|
||||
--colors-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--colors-card-foreground: hsl(var(--card-foreground));
|
||||
--colors-accent-DEFAULT: hsl(var(--accent));
|
||||
--text-sm-fontSize: .875rem;
|
||||
--text-sm-lineHeight: 1.25rem;
|
||||
--text-xs-fontSize: .75rem;
|
||||
--text-xs-lineHeight: 1rem;
|
||||
--text-2xl-fontSize: 1.5rem;
|
||||
--text-2xl-lineHeight: 2rem;
|
||||
--text-lg-fontSize: 1.125rem;
|
||||
--text-lg-lineHeight: 1.75rem;
|
||||
--text-xl-fontSize: 1.25rem;
|
||||
--text-xl-lineHeight: 1.75rem;
|
||||
--text-base-fontSize: 1rem;
|
||||
--text-base-lineHeight: 1.5rem;
|
||||
--text-3xl-fontSize: 1.875rem;
|
||||
--text-3xl-lineHeight: 2.25rem;
|
||||
--colors-accent-foreground: hsl(var(--accent-foreground));
|
||||
--text-4xl-fontSize: 2.25rem;
|
||||
--text-4xl-lineHeight: 2.5rem;
|
||||
--text-6xl-fontSize: 3.75rem;
|
||||
--text-6xl-lineHeight: 1;
|
||||
--colors-red-600: oklch(57.7% .245 27.325);
|
||||
--leading-relaxed: 1.625;
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-monoFont-family: var(--font-mono)
|
||||
}
|
||||
|
||||
/* Papra color system - improved light theme */
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 98.5%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 199 89% 67%;
|
||||
--primary-foreground: 0 0% 3.9%;
|
||||
--secondary: 0 0% 95%;
|
||||
--secondary-foreground: 0 0% 8%;
|
||||
--muted: 0 0% 95%;
|
||||
--muted-foreground: 0 0% 42%;
|
||||
--accent: 0 0% 95%;
|
||||
--accent-foreground: 0 0% 8%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 0%;
|
||||
--warning: 31 98% 50%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--border: #e2e8f0;
|
||||
--input: #e2e8f0;
|
||||
--ring: 199 89% 67%;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
/* Light mode enhancements */
|
||||
:root {
|
||||
/* Better shadows for light mode */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
|
||||
/* Light mode specific styles */
|
||||
:root .card {
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
:root .hover\:shadow-md:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
:root .hover\:shadow-lg:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Better button styling in light mode */
|
||||
:root button {
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
:root .bg-primary {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
:root .bg-primary:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Improved input styling in light mode */
|
||||
:root input, :root textarea, :root select {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
:root input:focus, :root textarea:focus, :root select:focus {
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Light mode scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f8fafc;
|
||||
}
|
||||
|
||||
/* Papra dark theme - exact match */
|
||||
[data-kb-theme=dark] {
|
||||
--background: 240 4% 10%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 4% 8%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 4% 8%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 199 89% 67%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--warning: 31 98% 50%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--border: #262626;
|
||||
--input: #262626;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
|
||||
/* Ensure all borders are #262626 in dark mode only */
|
||||
[data-kb-theme=dark] * {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Override all border utilities in dark mode */
|
||||
[data-kb-theme=dark] .border,
|
||||
[data-kb-theme=dark] .border-t,
|
||||
[data-kb-theme=dark] .border-b,
|
||||
[data-kb-theme=dark] .border-l,
|
||||
[data-kb-theme=dark] .border-r,
|
||||
[data-kb-theme=dark] .border-x,
|
||||
[data-kb-theme=dark] .border-y,
|
||||
[data-kb-theme=dark] .border-0,
|
||||
[data-kb-theme=dark] .border-2,
|
||||
[data-kb-theme=dark] .border-4,
|
||||
[data-kb-theme=dark] .border-8,
|
||||
[data-kb-theme=dark] .border-input,
|
||||
[data-kb-theme=dark] .border-border,
|
||||
[data-kb-theme=dark] .border-destructive,
|
||||
[data-kb-theme=dark] .border-primary,
|
||||
[data-kb-theme=dark] .border-secondary,
|
||||
[data-kb-theme=dark] .border-accent,
|
||||
[data-kb-theme=dark] .border-muted,
|
||||
[data-kb-theme=dark] .border-foreground,
|
||||
[data-kb-theme=dark] .border-card,
|
||||
[data-kb-theme=dark] .border-popover {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Ensure borders don't change on hover in dark mode */
|
||||
[data-kb-theme=dark] .hover\:border:hover,
|
||||
[data-kb-theme=dark] .hover\:border-t:hover,
|
||||
[data-kb-theme=dark] .hover\:border-b:hover,
|
||||
[data-kb-theme=dark] .hover\:border-l:hover,
|
||||
[data-kb-theme=dark] .hover\:border-r:hover,
|
||||
[data-kb-theme=dark] .hover\:border-x:hover,
|
||||
[data-kb-theme=dark] .hover\:border-y:hover,
|
||||
[data-kb-theme=dark] .hover\:border-input:hover,
|
||||
[data-kb-theme=dark] .hover\:border-border:hover,
|
||||
[data-kb-theme=dark] .hover\:border-destructive:hover,
|
||||
[data-kb-theme=dark] .hover\:border-primary:hover,
|
||||
[data-kb-theme=dark] .hover\:border-secondary:hover,
|
||||
[data-kb-theme=dark] .hover\:border-accent:hover,
|
||||
[data-kb-theme=dark] .hover\:border-muted:hover,
|
||||
[data-kb-theme=dark] .hover\:border-foreground:hover,
|
||||
[data-kb-theme=dark] .hover\:border-card:hover,
|
||||
[data-kb-theme=dark] .hover\:border-popover:hover {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Focus states in dark mode */
|
||||
[data-kb-theme=dark] .focus\:border:focus,
|
||||
[data-kb-theme=dark] .focus\:border-t:focus,
|
||||
[data-kb-theme=dark] .focus\:border-b:focus,
|
||||
[data-kb-theme=dark] .focus\:border-l:focus,
|
||||
[data-kb-theme=dark] .focus\:border-r:focus,
|
||||
[data-kb-theme=dark] .focus\:border-x:focus,
|
||||
[data-kb-theme=dark] .focus\:border-y:focus,
|
||||
[data-kb-theme=dark] .focus\:border-input:focus,
|
||||
[data-kb-theme=dark] .focus\:border-border:focus,
|
||||
[data-kb-theme=dark] .focus\:border-destructive:focus,
|
||||
[data-kb-theme=dark] .focus\:border-primary:focus,
|
||||
[data-kb-theme=dark] .focus\:border-secondary:focus,
|
||||
[data-kb-theme=dark] .focus\:border-accent:focus,
|
||||
[data-kb-theme=dark] .focus\:border-muted:focus,
|
||||
[data-kb-theme=dark] .focus\:border-foreground:focus,
|
||||
[data-kb-theme=dark] .focus\:border-card:focus,
|
||||
[data-kb-theme=dark] .focus\:border-popover:focus {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Table borders in dark mode */
|
||||
[data-kb-theme=dark] table,
|
||||
[data-kb-theme=dark] table th,
|
||||
[data-kb-theme=dark] table td,
|
||||
[data-kb-theme=dark] table thead,
|
||||
[data-kb-theme=dark] table tbody,
|
||||
[data-kb-theme=dark] table tfoot,
|
||||
[data-kb-theme=dark] table tr {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Form elements in dark mode */
|
||||
[data-kb-theme=dark] input,
|
||||
[data-kb-theme=dark] textarea,
|
||||
[data-kb-theme=dark] select,
|
||||
[data-kb-theme=dark] button {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Dark mode select/dropdown fixes */
|
||||
[data-kb-theme=dark] select {
|
||||
background-color: hsl(var(--background)) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] select option {
|
||||
background-color: hsl(var(--background)) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] select:focus {
|
||||
border-color: hsl(var(--primary)) !important;
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1) !important;
|
||||
}
|
||||
|
||||
/* Dark mode dropdown content fixes */
|
||||
[data-kb-theme=dark] .bg-popover {
|
||||
background-color: hsl(var(--popover)) !important;
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] .text-popover-foreground {
|
||||
color: hsl(var(--popover-foreground)) !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] [role="option"],
|
||||
[data-kb-theme=dark] .select-item {
|
||||
background-color: hsl(var(--popover)) !important;
|
||||
color: hsl(var(--popover-foreground)) !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] [role="option"]:hover,
|
||||
[data-kb-theme=dark] .select-item:hover,
|
||||
[data-kb-theme=dark] [role="option"]:focus,
|
||||
[data-kb-theme=dark] .select-item:focus {
|
||||
background-color: hsl(var(--accent)) !important;
|
||||
color: hsl(var(--accent-foreground)) !important;
|
||||
}
|
||||
|
||||
/* Card and component borders in dark mode */
|
||||
[data-kb-theme=dark] .card,
|
||||
[data-kb-theme=dark] [role="dialog"],
|
||||
[data-kb-theme=dark] [role="menu"],
|
||||
[data-kb-theme=dark] [role="listbox"],
|
||||
[data-kb-theme=dark] [role="option"],
|
||||
[data-kb-theme=dark] .dropdown,
|
||||
[data-kb-theme=dark] .popover,
|
||||
[data-kb-theme=dark] .tooltip {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Papra base styles - exact match */
|
||||
*,:after,:before,::backdrop,::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid
|
||||
}
|
||||
|
||||
html,:host {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
|
||||
font-feature-settings: var(--default-font-featureSettings, normal);
|
||||
font-variation-settings: var(--default-font-variationSettings, normal);
|
||||
-webkit-tap-highlight-color: transparent
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-variation-settings: normal;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
/* Papra utility classes */
|
||||
.w-280px {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
.size-5\.5 {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
/* Papra tabler icons */
|
||||
.i-tabler-chevron-down {
|
||||
--un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m6 9l6 6l6-6'/%3E%3C/svg%3E");
|
||||
-webkit-mask: var(--un-icon) no-repeat;
|
||||
mask: var(--un-icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
color: inherit;
|
||||
width: 1em;
|
||||
height: 1em
|
||||
}
|
||||
|
||||
.i-tabler-file-text {
|
||||
--un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z'/%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M16 13H8M16 17H8M10 9H8'/%3E%3C/svg%3E");
|
||||
-webkit-mask: var(--un-icon) no-repeat;
|
||||
mask: var(--un-icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
color: inherit;
|
||||
width: 1em;
|
||||
height: 1em
|
||||
}
|
||||
|
||||
.i-tabler-home {
|
||||
--un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m3 9l9-7l9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'/%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M9 22V12h6v10'/%3E%3C/svg%3E");
|
||||
-webkit-mask: var(--un-icon) no-repeat;
|
||||
mask: var(--un-icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
color: inherit;
|
||||
width: 1em;
|
||||
height: 1em
|
||||
}
|
||||
|
||||
.i-tabler-tag {
|
||||
--un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M20.5 7.5L16 12l-4.5 4.5L3 21l1.5-8.5L9 8l4.5-4.5z'/%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M20.5 7.5L16 12m0 0l-4.5 4.5L3 21l1.5-8.5L9 8l4.5-4.5L16 12z'/%3E%3C/svg%3E");
|
||||
-webkit-mask: var(--un-icon) no-repeat;
|
||||
mask: var(--un-icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
color: inherit;
|
||||
width: 1em;
|
||||
height: 1em
|
||||
}
|
||||
|
||||
.i-tabler-list-check {
|
||||
--un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m3 7l6 6l4-4'/%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M3 17h18M3 12h18'/%3E%3C/svg%3E");
|
||||
-webkit-mask: var(--un-icon) no-repeat;
|
||||
mask: var(--un-icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
color: inherit;
|
||||
width: 1em;
|
||||
height: 1em
|
||||
}
|
||||
|
||||
.i-tabler-users {
|
||||
--un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M16 7a4 4 0 1 1-8 0a4 4 0 0 1 8 0M12 14a7 7 0 0 0-7 7h14a7 7 0 0 0-7-7'/%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 21v-2a4 4 0 0 1 4-4'/%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M22 21v-2a4 4 0 0 0-3-3.87'/%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M17 8a4 4 0 0 1 0 8'/%3E%3C/svg%3E");
|
||||
-webkit-mask: var(--un-icon) no-repeat;
|
||||
mask: var(--un-icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
color: inherit;
|
||||
width: 1em;
|
||||
height: 1em
|
||||
}
|
||||
|
||||
.i-tabler-trash {
|
||||
--un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14zM10 11v6M14 11v6'/%3E%3C/svg%3E");
|
||||
-webkit-mask: var(--un-icon) no-repeat;
|
||||
mask: var(--un-icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
color: inherit;
|
||||
width: 1em;
|
||||
height: 1em
|
||||
}
|
||||
|
||||
.i-tabler-settings {
|
||||
--un-icon: url("data:image/svg+xml;utf8,%3Csvg viewBox='0 0 24 24' width='1em' height='1em' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.15.08a2 2 0 0 1-2.65-.22l-.13-.13a2 2 0 0 0-2.83 0l-.06.06a2 2 0 0 0 0 2.83l.13.13a2 2 0 0 1 .22 2.65l-.08.15a2 2 0 0 1-1.73 1H2a2 2 0 0 0-2 2v.44a2 2 0 0 0 2 2h.18a2 2 0 0 1 1.73 1l.08.15a2 2 0 0 1-.22 2.65l-.13.13a2 2 0 0 0 0 2.83l.06.06a2 2 0 0 0 2.83 0l.13-.13a2 2 0 0 1 2.65-.22l.15.08a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.15-.08a2 2 0 0 1 2.65.22l.13.13a2 2 0 0 0 2.83 0l.06-.06a2 2 0 0 0 0-2.83l-.13-.13a2 2 0 0 1-.22-2.65l.08-.15a2 2 0 0 1 1.73-1H22a2 2 0 0 0 2-2v-.44a2 2 0 0 0-2-2h-.18a2 2 0 0 1-1.73-1l-.08-.15a2 2 0 0 1 .22-2.65l.13-.13a2 2 0 0 0 0-2.83l-.06-.06a2 2 0 0 0-2.83 0l-.13.13a2 2 0 0 1-2.65.22l-.15-.08a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z'/%3E%3Ccircle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/%3E%3C/svg%3E");
|
||||
-webkit-mask: var(--un-icon) no-repeat;
|
||||
mask: var(--un-icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
color: inherit;
|
||||
width: 1em;
|
||||
height: 1em
|
||||
}
|
||||
|
||||
/* Papra transitions */
|
||||
.transition {
|
||||
transition: all var(--default-transition-duration) var(--default-transition-timingFunction);
|
||||
}
|
||||
|
||||
/* Border styles - maintain #262626 on hover in dark mode */
|
||||
[data-kb-theme=dark] .border {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] .border-input {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] .border-r {
|
||||
border-right-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] .border-r-border {
|
||||
border-right-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Ensure borders don't change on hover in dark mode */
|
||||
[data-kb-theme=dark] .hover\:bg-accent\/50:hover {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] .hover\:bg-accent:hover {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] .hover\:text-accent-foreground:hover {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Button hover states - maintain border color in dark mode */
|
||||
[data-kb-theme=dark] button:hover {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Icon background colors in dark mode */
|
||||
[data-kb-theme=dark] .bg-muted {
|
||||
background-color: #262727 !important;
|
||||
}
|
||||
|
||||
/* Icon containers in dashboard and sidebar in dark mode */
|
||||
[data-kb-theme=dark] .bg-muted.flex.items-center.justify-center.p-2.rounded-lg {
|
||||
background-color: #262727 !important;
|
||||
}
|
||||
|
||||
/* Organization selector icon background in dark mode */
|
||||
[data-kb-theme=dark] .p-1\.5.rounded.text-lg.font-bold.flex.items-center.bg-muted {
|
||||
background-color: #262727 !important;
|
||||
}
|
||||
|
||||
/* Ensure icon backgrounds stay consistent on hover in dark mode */
|
||||
[data-kb-theme=dark] .bg-muted:hover {
|
||||
background-color: #262727 !important;
|
||||
}
|
||||
|
||||
/* Table borders in dark mode */
|
||||
[data-kb-theme=dark] table {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] table th,
|
||||
[data-kb-theme=dark] table td {
|
||||
border-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] table thead tr {
|
||||
border-bottom-color: #262626 !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] table tbody tr {
|
||||
border-bottom-color: #262626 !important;
|
||||
}
|
||||
|
||||
/* Papra sidebar styling */
|
||||
.sidebar-papra {
|
||||
width: 280px;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
flex-shrink: 0;
|
||||
display: none;
|
||||
background-color: hsl(var(--card));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sidebar-papra {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure primary color is properly applied */
|
||||
.text-primary {
|
||||
color: hsl(var(--primary)) !important;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background-color: hsl(var(--primary)) !important;
|
||||
}
|
||||
|
||||
/* Bar chart specific fixes */
|
||||
.weekly-bar {
|
||||
background-color: hsl(var(--primary)) !important;
|
||||
min-height: 4px !important;
|
||||
display: block !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] .weekly-bar {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
/* Additional bar chart fixes */
|
||||
.bg-primary.rounded-t {
|
||||
background-color: hsl(var(--primary)) !important;
|
||||
min-height: 4px !important;
|
||||
}
|
||||
|
||||
[data-kb-theme=dark] .bg-primary.rounded-t {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
/* Force bar chart visibility */
|
||||
.weekly-activity-chart .bg-primary {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
.weekly-activity-chart .weekly-bar {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
/* Direct bar styling */
|
||||
div[class*="bg-primary"][class*="rounded-t"] {
|
||||
background-color: hsl(199 89% 67%) !important;
|
||||
}
|
||||
|
||||
/* Better bar proportions */
|
||||
.weekly-activity-chart .max-w-8 {
|
||||
width: 2rem !important;
|
||||
max-width: 2rem !important;
|
||||
}
|
||||
|
||||
/* Enhanced bar visibility */
|
||||
.weekly-activity-chart .weekly-bar {
|
||||
min-height: 8px !important;
|
||||
width: 2rem !important;
|
||||
max-width: 2rem !important;
|
||||
}
|
||||
|
||||
/* Responsive bar chart */
|
||||
.weekly-activity-chart {
|
||||
min-height: 128px !important; /* h-32 */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.weekly-activity-chart {
|
||||
min-height: 144px !important; /* h-36 */
|
||||
}
|
||||
|
||||
.weekly-activity-chart .max-w-8 {
|
||||
width: 2.5rem !important;
|
||||
max-width: 2.5rem !important;
|
||||
}
|
||||
|
||||
.weekly-activity-chart .weekly-bar {
|
||||
width: 2.5rem !important;
|
||||
max-width: 2.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon color fixes */
|
||||
.text-primary .icon,
|
||||
.text-primary svg,
|
||||
.bg-primary .icon,
|
||||
.bg-primary svg {
|
||||
color: hsl(var(--primary-foreground)) !important;
|
||||
}
|
||||
|
||||
/* Button primary color fixes */
|
||||
.bg-primary:hover {
|
||||
background-color: hsl(var(--primary) / 0.9) !important;
|
||||
}
|
||||
|
||||
/* Ensure icon containers show primary color */
|
||||
.bg-primary\/10 {
|
||||
background-color: hsl(var(--primary) / 0.1) !important;
|
||||
}
|
||||
|
||||
.text-primary\/foreground {
|
||||
color: hsl(var(--primary-foreground)) !important;
|
||||
}
|
||||
|
||||
/* Utility: match Tailwind-style max-h-96 used in activity feed */
|
||||
.max-h-96 {
|
||||
max-height: 29.7rem;
|
||||
}
|
||||
|
||||
/* Utility: slim scrollbars for activity feed list */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.scrollbar-thumb-border::-webkit-scrollbar-thumb {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.scrollbar-track-transparent::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #262626;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #262626 transparent;
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar adjustments */
|
||||
[data-kb-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||
background: #404040;
|
||||
border: 1px solid #262626;
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #555555;
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] * {
|
||||
scrollbar-color: #404040 transparent;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { render } from 'solid-js/web'
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import 'uno.css'
|
||||
import './styles/globals.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createQuery, useQueryClient, createMutation } from '@tanstack/solid-que
|
||||
import { getAuthHeaders } from './auth';
|
||||
|
||||
// API base URL
|
||||
const API_BASE_URL = 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
// Retry configuration
|
||||
const DEFAULT_RETRY_CONFIG = {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
// Helper function to get auth headers
|
||||
const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
};
|
||||
};
|
||||
|
||||
// Generic API client
|
||||
class ApiClient {
|
||||
private baseURL: string;
|
||||
@@ -12,11 +28,16 @@ class ApiClient {
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// If in demo mode, use mock data
|
||||
if (isDemoMode()) {
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders(),
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
@@ -26,17 +47,186 @@ class ApiClient {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
// If backend fails, fall back to demo mode
|
||||
console.warn(`API endpoint ${endpoint} failed, falling back to demo mode`);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
throw error;
|
||||
console.warn(`API request failed for ${endpoint}, falling back to demo mode:`, error);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
}
|
||||
|
||||
private async getMockResponse<T>(endpoint: string, options: RequestInit): Promise<T> {
|
||||
// Import mock data dynamically to avoid circular dependencies
|
||||
const {
|
||||
getMockStats,
|
||||
getMockDocuments,
|
||||
getMockBookmarks,
|
||||
getMockTasks,
|
||||
getMockNotes,
|
||||
getMockTimeEntries,
|
||||
getMockLearningPaths,
|
||||
getMockVideos
|
||||
} = await import('./mockData');
|
||||
|
||||
const method = options.method || 'GET';
|
||||
|
||||
// Dashboard stats
|
||||
if (endpoint.includes('/dashboard/stats')) {
|
||||
return getMockStats() as T;
|
||||
}
|
||||
|
||||
// Documents/Files
|
||||
if (endpoint.includes('/documents') || endpoint.includes('/files')) {
|
||||
if (method === 'GET') {
|
||||
return getMockDocuments() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarks
|
||||
if (endpoint.includes('/bookmarks')) {
|
||||
if (method === 'GET') {
|
||||
return getMockBookmarks() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks
|
||||
if (endpoint.includes('/tasks')) {
|
||||
if (method === 'GET') {
|
||||
return getMockTasks() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (endpoint.includes('/notes')) {
|
||||
if (method === 'GET') {
|
||||
return getMockNotes() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Time entries
|
||||
if (endpoint.includes('/time-entries')) {
|
||||
if (method === 'GET') {
|
||||
const mockEntries = getMockTimeEntries();
|
||||
// Convert mock entries to TimeEntry format
|
||||
const timeEntries = mockEntries.map(entry => ({
|
||||
id: parseInt(entry.id.replace('time_', '')),
|
||||
user_id: 1,
|
||||
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
|
||||
start_time: `${entry.date}T${entry.startTime}:00Z`,
|
||||
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
|
||||
duration: entry.duration,
|
||||
description: entry.description,
|
||||
tags: entry.tags,
|
||||
billable: entry.billable,
|
||||
hourly_rate: entry.hourlyRate,
|
||||
is_running: false,
|
||||
source: 'demo',
|
||||
created_at: `${entry.date}T${entry.startTime}:00Z`,
|
||||
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
|
||||
}));
|
||||
return { time_entries: timeEntries } as T;
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const mockEntries = getMockTimeEntries();
|
||||
const entry = mockEntries[0];
|
||||
// Convert mock entry to TimeEntry format
|
||||
const timeEntry = {
|
||||
id: parseInt(entry.id.replace('time_', '')),
|
||||
user_id: 1,
|
||||
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
|
||||
start_time: `${entry.date}T${entry.startTime}:00Z`,
|
||||
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
|
||||
duration: entry.duration,
|
||||
description: entry.description,
|
||||
tags: entry.tags,
|
||||
billable: entry.billable,
|
||||
hourly_rate: entry.hourlyRate,
|
||||
is_running: false,
|
||||
source: 'demo',
|
||||
created_at: `${entry.date}T${entry.startTime}:00Z`,
|
||||
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
|
||||
};
|
||||
return { time_entry: timeEntry } as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
if (endpoint.includes('/auth/login-totp')) {
|
||||
return {
|
||||
token: 'demo-token',
|
||||
user: { id: 1, email: 'demo@trackeep.com', name: 'Demo User' }
|
||||
} as T;
|
||||
}
|
||||
|
||||
// GitHub repos
|
||||
if (endpoint.includes('/github/repos')) {
|
||||
return {
|
||||
repositories: [
|
||||
{ id: 1, name: 'trackeep', full_name: 'tdvorak/trackeep', stars: 245, forks: 43, watchers: 65, language: 'Go' },
|
||||
{ id: 2, name: 'frontend', full_name: 'tdvorak/frontend', stars: 89, forks: 12, watchers: 23, language: 'TypeScript' },
|
||||
{ id: 3, name: 'mobile-app', full_name: 'tdvorak/mobile-app', stars: 34, forks: 8, watchers: 15, language: 'TypeScript' }
|
||||
],
|
||||
totalStars: 368,
|
||||
totalForks: 63,
|
||||
totalWatchers: 103
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Learning paths
|
||||
if (endpoint.includes('/learning-paths/categories')) {
|
||||
return {
|
||||
categories: ['Web Development', 'DevOps', 'Programming', 'Design', 'Business', 'Data Science']
|
||||
} as T;
|
||||
}
|
||||
|
||||
if (endpoint.includes('/learning-paths')) {
|
||||
return getMockLearningPaths() as T;
|
||||
}
|
||||
|
||||
// Chat sessions
|
||||
if (endpoint.includes('/chat/sessions')) {
|
||||
return {
|
||||
sessions: [
|
||||
{ id: '1', title: 'Project Planning', created_at: '2024-01-15T10:00:00Z', updated_at: '2024-01-15T11:30:00Z' },
|
||||
{ id: '2', title: 'Technical Discussion', created_at: '2024-01-14T14:00:00Z', updated_at: '2024-01-14T15:45:00Z' }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// AI providers
|
||||
if (endpoint.includes('/ai/providers')) {
|
||||
return {
|
||||
providers: [
|
||||
{ id: 'longcat', name: 'LongCat AI', enabled: true, models: ['LongCat-Flash-Chat', 'LongCat-Flash-Thinking'] },
|
||||
{ id: 'mistral', name: 'Mistral AI', enabled: false, models: ['mistral-small-latest', 'mistral-large-latest'] },
|
||||
{ id: 'openai', name: 'OpenAI', enabled: false, models: ['gpt-4', 'gpt-3.5-turbo'] }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// YouTube endpoints
|
||||
if (endpoint.includes('/youtube/video-details')) {
|
||||
return getMockVideos()[0] as T;
|
||||
}
|
||||
|
||||
if (endpoint.includes('/youtube/predefined-channels')) {
|
||||
return {
|
||||
channels: [
|
||||
{ id: 'UC8butISFwT-Wy7pm24E6Icg', name: 'NetworkChuck', latestVideos: getMockVideos().slice(0, 2) },
|
||||
{ id: 'UCWv7vHwRQdGJtU2i9hJ8X7A', name: 'Fireship', latestVideos: getMockVideos().slice(1, 3) },
|
||||
{ id: 'UCsXVk37bltHxD1rDPgtNG6A', name: 'Beyond Fireship', latestVideos: getMockVideos().slice(0, 1) }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Default empty response
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'GET' });
|
||||
}
|
||||
@@ -65,6 +255,9 @@ class ApiClient {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': getAuthHeaders().Authorization || '',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -134,6 +327,36 @@ export interface File {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: number;
|
||||
user_id: number;
|
||||
task_id?: number;
|
||||
bookmark_id?: number;
|
||||
note_id?: number;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
duration?: number;
|
||||
description: string;
|
||||
tags: string[];
|
||||
billable: boolean;
|
||||
hourly_rate?: number;
|
||||
is_running: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
task?: Task;
|
||||
bookmark?: Bookmark;
|
||||
note?: Note;
|
||||
}
|
||||
|
||||
export interface TimeStats {
|
||||
total_time_seconds: number;
|
||||
total_entries: number;
|
||||
running_entries: number;
|
||||
billable_time_seconds: number;
|
||||
total_billable_amount: number;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
export const bookmarksApi = {
|
||||
getAll: () => api.get<Bookmark[]>('/bookmarks'),
|
||||
@@ -191,4 +414,45 @@ export const filesApi = {
|
||||
download: (id: number) => `${API_BASE_URL}/files/${id}/download`,
|
||||
};
|
||||
|
||||
export const timeEntriesApi = {
|
||||
getAll: (startDate?: string, endDate?: string, isRunning?: boolean) => {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
if (isRunning !== undefined) params.append('is_running', isRunning.toString());
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
return api.get<{ time_entries: TimeEntry[] }>(`/time-entries${query}`);
|
||||
},
|
||||
getById: (id: number) => api.get<{ time_entry: TimeEntry }>(`/time-entries/${id}`),
|
||||
create: (timeEntry: {
|
||||
task_id?: number;
|
||||
bookmark_id?: number;
|
||||
note_id?: number;
|
||||
description: string;
|
||||
tags?: string[];
|
||||
billable?: boolean;
|
||||
hourly_rate?: number;
|
||||
source?: string;
|
||||
}) => api.post<{ time_entry: TimeEntry }>('/time-entries', timeEntry),
|
||||
update: (id: number, timeEntry: {
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
billable?: boolean;
|
||||
hourly_rate?: number;
|
||||
end_time?: string;
|
||||
}) => api.put<{ time_entry: TimeEntry }>(`/time-entries/${id}`, timeEntry),
|
||||
stop: (id: number) => api.post<{ time_entry: TimeEntry }>(`/time-entries/${id}/stop`),
|
||||
delete: (id: number) => api.delete<{ message: string }>(`/time-entries/${id}`),
|
||||
getStats: () => api.get<{ stats: TimeStats }>('/time-entries/stats'),
|
||||
};
|
||||
|
||||
import {
|
||||
demoBookmarksApi,
|
||||
demoTasksApi,
|
||||
demoNotesApi,
|
||||
demoFilesApi,
|
||||
demoTimeEntriesApi
|
||||
} from './demo-api';
|
||||
|
||||
export default api;
|
||||
export { demoBookmarksApi, demoTasksApi, demoNotesApi, demoFilesApi, demoTimeEntriesApi };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, type ParentComponent, onMount } from 'solid-js';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import { isDemoMode } from './demo-mode';
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
@@ -37,7 +38,7 @@ export interface AuthResponse {
|
||||
}
|
||||
|
||||
// API base URL
|
||||
const API_BASE_URL = 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
// Create auth context
|
||||
const AuthContext = createContext<AuthContextType>();
|
||||
@@ -49,6 +50,9 @@ export interface AuthContextType {
|
||||
logout: () => void;
|
||||
updateProfile: (data: { fullName?: string; theme?: string }) => Promise<void>;
|
||||
changePassword: (data: { currentPassword: string; newPassword: string }) => Promise<void>;
|
||||
requestPasswordReset: (email: string) => Promise<void>;
|
||||
confirmPasswordReset: (code: string, password: string) => Promise<void>;
|
||||
setAuth: (token: string, user: User) => void;
|
||||
}
|
||||
|
||||
// Auth provider component
|
||||
@@ -57,52 +61,123 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
isLoading: false, // Start with false to avoid loading spinner in ProtectedRoute
|
||||
});
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
onMount(() => {
|
||||
const token = localStorage.getItem('trackeep_token');
|
||||
const userStr = localStorage.getItem('trackeep_user');
|
||||
console.log('[Auth] onMount: Initializing auth state');
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
setAuthState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse user data:', error);
|
||||
clearAuth();
|
||||
// First check if demo mode should be cleared
|
||||
if (!isDemoMode()) {
|
||||
console.log('[Auth] onMount: Demo mode disabled, clearing demo-specific data only');
|
||||
// Only clear demo mode data, not legitimate user auth data
|
||||
localStorage.removeItem('demoMode');
|
||||
|
||||
// Check for existing non-demo auth
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
const userStr = localStorage.getItem('trackeep_user') || localStorage.getItem('user');
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
console.log('[Auth] onMount: Found existing auth, restoring:', user);
|
||||
setAuthState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Apply theme
|
||||
if (user.theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth] onMount: Failed to parse user data:', error);
|
||||
clearAuth();
|
||||
}
|
||||
} else {
|
||||
console.log('[Auth] onMount: No existing auth found, setting isLoading to false');
|
||||
setAuthState('isLoading', false);
|
||||
// Set dark mode by default when not authenticated
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
}
|
||||
} else {
|
||||
setAuthState('isLoading', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Demo mode is enabled - use in-memory auth only
|
||||
console.log('[Auth] onMount: Demo mode enabled, using in-memory auth');
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'demo@trackeep.com',
|
||||
username: 'demo',
|
||||
full_name: 'Demo User',
|
||||
theme: 'dark',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const mockToken = 'demo-token-' + Date.now();
|
||||
|
||||
setAuthState({
|
||||
user: mockUser,
|
||||
token: mockToken,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Apply theme
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
document.title = 'Trackeep - Demo Mode';
|
||||
});
|
||||
|
||||
|
||||
const clearAuth = () => {
|
||||
localStorage.removeItem('trackeep_token');
|
||||
localStorage.removeItem('trackeep_user');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('demoMode');
|
||||
setAuthState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
// Reset to dark mode on logout
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
};
|
||||
|
||||
const setAuth = (token: string, user: User) => {
|
||||
localStorage.setItem('trackeep_token', token);
|
||||
localStorage.setItem('trackeep_user', JSON.stringify(user));
|
||||
console.log('[Auth] setAuth called with:', { token, user });
|
||||
|
||||
// Only store in localStorage if not in demo mode
|
||||
if (!isDemoMode()) {
|
||||
localStorage.setItem('trackeep_token', token);
|
||||
localStorage.setItem('trackeep_user', JSON.stringify(user));
|
||||
// Also set the legacy keys for compatibility
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
console.log('[Auth] setAuth: Updating auth state');
|
||||
setAuthState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
console.log('[Auth] setAuth: Auth state updated');
|
||||
// Apply theme immediately
|
||||
if (user.theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
@@ -188,7 +263,15 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
const updatedUser = result.user;
|
||||
|
||||
localStorage.setItem('trackeep_user', JSON.stringify(updatedUser));
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser)); // Keep legacy key
|
||||
setAuthState('user', updatedUser);
|
||||
|
||||
// Apply theme change immediately
|
||||
if (updatedUser.theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Profile update error:', error);
|
||||
throw error;
|
||||
@@ -216,6 +299,46 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const requestPasswordReset = async (email: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/password-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Password reset request failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password reset request error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPasswordReset = async (code: string, password: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/password-reset/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Password reset confirmation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password reset confirmation error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const authContextValue: AuthContextType = {
|
||||
authState,
|
||||
login,
|
||||
@@ -223,6 +346,9 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
logout,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
requestPasswordReset,
|
||||
confirmPasswordReset,
|
||||
setAuth,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -243,7 +369,7 @@ export const useAuth = () => {
|
||||
|
||||
// Helper function to get auth headers for API requests
|
||||
export const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem('trackeep_token');
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Brave Search API integration
|
||||
const BRAVE_API_KEY = import.meta.env.VITE_BRAVE_API_KEY || 'BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT';
|
||||
const BRAVE_WEB_API_BASE = 'https://api.search.brave.com/res/v1/web/search';
|
||||
const BRAVE_NEWS_API_BASE = 'https://api.search.brave.com/res/v1/news/search';
|
||||
|
||||
export interface BraveSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
published_date?: string;
|
||||
language?: string;
|
||||
family_friendly?: boolean;
|
||||
type?: string;
|
||||
subtype?: string;
|
||||
}
|
||||
|
||||
export interface BraveSearchResponse {
|
||||
web?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
news?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
mixed?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
query?: {
|
||||
original: string;
|
||||
display: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchBrave(query: string, count: number = 10, type: 'web' | 'news' = 'web'): Promise<BraveSearchResult[]> {
|
||||
try {
|
||||
const apiBase = type === 'news' ? BRAVE_NEWS_API_BASE : BRAVE_WEB_API_BASE;
|
||||
const response = await fetch(`${apiBase}?q=${encodeURIComponent(query)}&count=${count}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': BRAVE_API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: BraveSearchResponse = await response.json();
|
||||
|
||||
// Return results from appropriate search type
|
||||
if (type === 'news' && data.news?.results) {
|
||||
return data.news.results;
|
||||
} else if (data.web?.results) {
|
||||
return data.web.results;
|
||||
} else if (data.mixed?.results) {
|
||||
return data.mixed.results;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Brave search error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchWeb(query: string, count: number = 10): Promise<BraveSearchResult[]> {
|
||||
return searchBrave(query, count, 'web');
|
||||
}
|
||||
|
||||
export async function searchNews(query: string, count: number = 10): Promise<BraveSearchResult[]> {
|
||||
return searchBrave(query, count, 'news');
|
||||
}
|
||||
|
||||
export async function getQuickSearchSuggestions(query: string, limit: number = 5): Promise<string[]> {
|
||||
try {
|
||||
const results = await searchBrave(query, limit);
|
||||
return results.map(result => result.title);
|
||||
} catch (error) {
|
||||
console.error('Failed to get search suggestions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Demo mode API wrapper for Trackeep
|
||||
// Provides mock data when backend is not available
|
||||
|
||||
import {
|
||||
getMockDocuments,
|
||||
getMockBookmarks,
|
||||
getMockTasks,
|
||||
getMockNotes,
|
||||
getMockTimeEntries,
|
||||
getMockVideos,
|
||||
getMockLearningPaths,
|
||||
getMockCalendarEvents,
|
||||
getMockActivities,
|
||||
getMockStats,
|
||||
getPopularTags,
|
||||
type MockDocument,
|
||||
type MockBookmark,
|
||||
type MockTask,
|
||||
type MockNote,
|
||||
type MockTimeEntry,
|
||||
type MockVideo,
|
||||
type MockLearningPath,
|
||||
type MockCalendarEvent,
|
||||
type MockActivity
|
||||
} from './mockData';
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
// Demo mode API client that falls back to mock data
|
||||
export class DemoModeApiClient {
|
||||
public baseURL: string;
|
||||
private demoMode: boolean;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
this.demoMode = isDemoMode();
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// If in demo mode, return mock data immediately
|
||||
if (this.demoMode) {
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
// If backend fails, fall back to demo mode
|
||||
console.warn(`API endpoint ${endpoint} failed, falling back to demo mode`);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn(`API request failed for ${endpoint}, falling back to demo mode:`, error);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
}
|
||||
}
|
||||
|
||||
private getMockResponse<T>(endpoint: string, options: RequestInit): T {
|
||||
const method = options.method || 'GET';
|
||||
|
||||
// Dashboard stats
|
||||
if (endpoint.includes('/dashboard/stats')) {
|
||||
return getMockStats() as T;
|
||||
}
|
||||
|
||||
// Documents
|
||||
if (endpoint.includes('/documents') || endpoint.includes('/files')) {
|
||||
if (method === 'GET') {
|
||||
return { documents: getMockDocuments() } as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarks
|
||||
if (endpoint.includes('/bookmarks')) {
|
||||
if (method === 'GET') {
|
||||
return getMockBookmarks() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks
|
||||
if (endpoint.includes('/tasks')) {
|
||||
if (method === 'GET') {
|
||||
return getMockTasks() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (endpoint.includes('/notes')) {
|
||||
if (method === 'GET') {
|
||||
return getMockNotes() as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Time entries
|
||||
if (endpoint.includes('/time-entries')) {
|
||||
if (method === 'GET') {
|
||||
const mockEntries = getMockTimeEntries();
|
||||
// Convert mock entries to TimeEntry format
|
||||
const timeEntries = mockEntries.map(entry => ({
|
||||
id: parseInt(entry.id.replace('time_', '')),
|
||||
user_id: 1,
|
||||
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
|
||||
start_time: `${entry.date}T${entry.startTime}:00Z`,
|
||||
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
|
||||
duration: entry.duration,
|
||||
description: entry.description,
|
||||
tags: entry.tags,
|
||||
billable: entry.billable,
|
||||
hourly_rate: entry.hourlyRate,
|
||||
is_running: false,
|
||||
source: 'demo',
|
||||
created_at: `${entry.date}T${entry.startTime}:00Z`,
|
||||
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
|
||||
}));
|
||||
return { time_entries: timeEntries } as T;
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const mockEntries = getMockTimeEntries();
|
||||
const entry = mockEntries[0];
|
||||
// Convert mock entry to TimeEntry format
|
||||
const timeEntry = {
|
||||
id: parseInt(entry.id.replace('time_', '')),
|
||||
user_id: 1,
|
||||
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
|
||||
start_time: `${entry.date}T${entry.startTime}:00Z`,
|
||||
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
|
||||
duration: entry.duration,
|
||||
description: entry.description,
|
||||
tags: entry.tags,
|
||||
billable: entry.billable,
|
||||
hourly_rate: entry.hourlyRate,
|
||||
is_running: false,
|
||||
source: 'demo',
|
||||
created_at: `${entry.date}T${entry.startTime}:00Z`,
|
||||
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
|
||||
};
|
||||
return { time_entry: timeEntry } as T;
|
||||
}
|
||||
}
|
||||
|
||||
// YouTube
|
||||
if (endpoint.includes('/youtube')) {
|
||||
if (endpoint.includes('predefined-channels')) {
|
||||
return {
|
||||
channels: [
|
||||
{ id: 'UC8butISFwT-Wy7pm24E6Icg', name: 'NetworkChuck' },
|
||||
{ id: 'UCsBjURrPoezyKlLJRzKwBA', name: 'Fireship' },
|
||||
{ id: 'UCsXVk37bltJxDpvrMzOXvQ', name: 'Beyond Fireship' }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
if (endpoint.includes('video-details')) {
|
||||
return getMockVideos()[0] as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Learning paths
|
||||
if (endpoint.includes('/learning-paths')) {
|
||||
if (endpoint.includes('categories')) {
|
||||
return {
|
||||
categories: ['Web Development', 'DevOps', 'Programming', 'Design', 'Business']
|
||||
} as T;
|
||||
}
|
||||
return getMockLearningPaths() as T;
|
||||
}
|
||||
|
||||
// GitHub
|
||||
if (endpoint.includes('/github/repos')) {
|
||||
return {
|
||||
repositories: [
|
||||
{ name: 'trackeep', stars: 245, forks: 43, watchers: 65 },
|
||||
{ name: 'solidjs-app', stars: 123, forks: 21, watchers: 34 }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Chat sessions
|
||||
if (endpoint.includes('/chat/sessions')) {
|
||||
return {
|
||||
sessions: [
|
||||
{ id: '1', title: 'General Chat', created_at: new Date().toISOString() },
|
||||
{ id: '2', title: 'Development Help', created_at: new Date().toISOString() }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// AI providers
|
||||
if (endpoint.includes('/ai/providers')) {
|
||||
return {
|
||||
providers: [
|
||||
{ id: 'longcat', name: 'LongCat AI', enabled: true },
|
||||
{ id: 'mistral', name: 'Mistral AI', enabled: false },
|
||||
{ id: 'openai', name: 'OpenAI', enabled: false }
|
||||
]
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
if (endpoint.includes('/auth/login-totp')) {
|
||||
return {
|
||||
token: 'demo-token',
|
||||
user: { id: 1, email: 'demo@trackeep.com', name: 'Demo User' }
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Default empty response
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'GET' });
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, data?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async put<T>(endpoint: string, data?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async upload<T>(endpoint: string, formData: FormData): Promise<T> {
|
||||
// For demo mode, simulate file upload
|
||||
const file = formData.get('file') as File;
|
||||
return {
|
||||
id: Date.now(),
|
||||
original_name: file?.name || 'demo-file',
|
||||
file_name: `demo-${Date.now()}`,
|
||||
file_size: file?.size || 1024,
|
||||
mime_type: file?.type || 'application/octet-stream',
|
||||
created_at: new Date().toISOString()
|
||||
} as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Create demo mode API client
|
||||
const demoApi = new DemoModeApiClient(import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1');
|
||||
|
||||
// Export demo mode API functions that match the regular API
|
||||
export const demoBookmarksApi = {
|
||||
getAll: () => demoApi.get<any[]>('/bookmarks'),
|
||||
getById: (id: number) => demoApi.get<any>(`/bookmarks/${id}`),
|
||||
create: (bookmark: any) => demoApi.post<any>('/bookmarks', bookmark),
|
||||
update: (id: number, bookmark: any) => demoApi.put<any>(`/bookmarks/${id}`, bookmark),
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/bookmarks/${id}`),
|
||||
};
|
||||
|
||||
export const demoTasksApi = {
|
||||
getAll: () => demoApi.get<any[]>('/tasks'),
|
||||
getById: (id: number) => demoApi.get<any>(`/tasks/${id}`),
|
||||
create: (task: any) => demoApi.post<any>('/tasks', task),
|
||||
update: (id: number, task: any) => demoApi.put<any>(`/tasks/${id}`, task),
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/tasks/${id}`),
|
||||
};
|
||||
|
||||
export const demoNotesApi = {
|
||||
getAll: (_search?: string, _tag?: string) => demoApi.get<any[]>('/notes'),
|
||||
getById: (id: number) => demoApi.get<any>(`/notes/${id}`),
|
||||
create: (note: any) => demoApi.post<any>('/notes', note),
|
||||
update: (id: number, note: any) => demoApi.put<any>(`/notes/${id}`, note),
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/notes/${id}`),
|
||||
getStats: () => demoApi.get<any>('/notes/stats'),
|
||||
};
|
||||
|
||||
export const demoFilesApi = {
|
||||
getAll: () => demoApi.get<any[]>('/files'),
|
||||
getById: (id: number) => demoApi.get<any>(`/files/${id}`),
|
||||
upload: (file: Blob, description?: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (description) formData.append('description', description);
|
||||
return demoApi.upload<any>('/files/upload', formData);
|
||||
},
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/files/${id}`),
|
||||
download: (id: number) => `${demoApi.baseURL}/files/${id}/download`,
|
||||
};
|
||||
|
||||
export const demoTimeEntriesApi = {
|
||||
getAll: (_startDate?: string, _endDate?: string, _isRunning?: boolean) =>
|
||||
demoApi.get<{ time_entries: any[] }>('/time-entries'),
|
||||
getById: (id: number) => demoApi.get<{ time_entry: any }>(`/time-entries/${id}`),
|
||||
create: (timeEntry: any) => demoApi.post<{ time_entry: any }>('/time-entries', timeEntry),
|
||||
update: (id: number, timeEntry: any) => demoApi.put<{ time_entry: any }>(`/time-entries/${id}`, timeEntry),
|
||||
stop: (id: number) => demoApi.post<{ time_entry: any }>(`/time-entries/${id}/stop`),
|
||||
delete: (id: number) => demoApi.delete<{ message: string }>(`/time-entries/${id}`),
|
||||
getStats: () => demoApi.get<{ stats: any }>('/time-entries/stats'),
|
||||
};
|
||||
|
||||
export default demoApi;
|
||||
@@ -0,0 +1,577 @@
|
||||
// Demo mode API interceptor to provide mock data instead of making real API calls
|
||||
|
||||
// Check if demo mode is enabled via environment variable
|
||||
export const isEnvDemoMode = (): boolean => {
|
||||
const result = import.meta.env.VITE_DEMO_MODE === 'true';
|
||||
console.log('[Demo Mode] isEnvDemoMode:', result, 'VITE_DEMO_MODE:', import.meta.env.VITE_DEMO_MODE);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Check if demo mode is active (environment variable only)
|
||||
export const isDemoMode = (): boolean => {
|
||||
// Only check environment variable - no localStorage persistence
|
||||
return isEnvDemoMode();
|
||||
};
|
||||
|
||||
// Clear demo mode from localStorage
|
||||
export const clearDemoMode = (): void => {
|
||||
localStorage.removeItem('demoMode');
|
||||
// Only clear demo tokens, not legitimate user tokens
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// Only clear if they look like demo tokens (contain 'demo-token')
|
||||
if (token && token.includes('demo-token')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('trackeep_token');
|
||||
localStorage.removeItem('trackeep_user');
|
||||
}
|
||||
};
|
||||
|
||||
// Set demo mode (no-op - environment variable only)
|
||||
export const setDemoMode = (): void => {
|
||||
// Demo mode is controlled by environment variable only
|
||||
// No localStorage persistence
|
||||
};
|
||||
|
||||
// Mock data generators
|
||||
const generateMockStats = () => ({
|
||||
total_tasks: 42,
|
||||
completed_tasks: 28,
|
||||
total_bookmarks: 156,
|
||||
total_notes: 89,
|
||||
total_files: 234,
|
||||
total_time_tracked: 125000, // seconds
|
||||
recent_activity: [
|
||||
{ type: 'task', action: 'completed', title: 'Complete project documentation', timestamp: new Date(Date.now() - 3600000).toISOString() },
|
||||
{ type: 'bookmark', action: 'added', title: 'SolidJS Documentation', timestamp: new Date(Date.now() - 7200000).toISOString() },
|
||||
{ type: 'note', action: 'created', title: 'Meeting notes - Q1 planning', timestamp: new Date(Date.now() - 10800000).toISOString() },
|
||||
{ type: 'file', action: 'uploaded', title: 'project-roadmap.pdf', timestamp: new Date(Date.now() - 14400000).toISOString() },
|
||||
]
|
||||
});
|
||||
|
||||
const generateMockGitHubRepos = () => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'trackeep',
|
||||
full_name: 'tdvorak/trackeep',
|
||||
description: 'Your Self-Hosted Productivity Hub',
|
||||
private: false,
|
||||
stargazers_count: 245,
|
||||
forks_count: 65,
|
||||
watchers_count: 43,
|
||||
language: 'Go',
|
||||
updated_at: new Date().toISOString(),
|
||||
html_url: 'https://github.com/tdvorak/trackeep'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'solidjs-components',
|
||||
full_name: 'tdvorak/solidjs-components',
|
||||
description: 'Reusable SolidJS components library',
|
||||
private: false,
|
||||
stargazers_count: 89,
|
||||
forks_count: 12,
|
||||
watchers_count: 8,
|
||||
language: 'TypeScript',
|
||||
updated_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
html_url: 'https://github.com/tdvorak/solidjs-components'
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockTimeEntries = () => [
|
||||
{
|
||||
id: 1,
|
||||
description: 'Working on Trackeep frontend',
|
||||
start_time: new Date(Date.now() - 7200000).toISOString(),
|
||||
end_time: new Date(Date.now() - 3600000).toISOString(),
|
||||
duration: 3600,
|
||||
billable: true,
|
||||
hourly_rate: 75,
|
||||
task_id: 1,
|
||||
created_at: new Date(Date.now() - 3600000).toISOString()
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
description: 'Meeting with team',
|
||||
start_time: new Date(Date.now() - 14400000).toISOString(),
|
||||
end_time: new Date(Date.now() - 12600000).toISOString(),
|
||||
duration: 1800,
|
||||
billable: false,
|
||||
hourly_rate: 0,
|
||||
task_id: null,
|
||||
created_at: new Date(Date.now() - 12600000).toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockYouTubeVideos = () => [
|
||||
{
|
||||
id: 1,
|
||||
video_id: 'dQw4w9WgXcQ',
|
||||
title: 'Never Gonna Give You Up',
|
||||
description: 'Classic music video',
|
||||
channel_name: 'Rick Astley',
|
||||
thumbnail_url: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
|
||||
duration: '3:33',
|
||||
published_at: '2009-10-25T06:57:33Z',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockLearningPaths = () => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'SolidJS Mastery',
|
||||
description: 'Complete guide to SolidJS framework',
|
||||
category: 'frontend',
|
||||
difficulty: 'intermediate',
|
||||
estimated_hours: 20,
|
||||
progress: 65,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Go Backend Development',
|
||||
description: 'Build scalable backend with Go',
|
||||
category: 'backend',
|
||||
difficulty: 'advanced',
|
||||
estimated_hours: 40,
|
||||
progress: 30,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockChatSessions = () => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Project Planning Discussion',
|
||||
created_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
message_count: 15
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Code Review Help',
|
||||
created_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
updated_at: new Date(Date.now() - 7200000).toISOString(),
|
||||
message_count: 8
|
||||
}
|
||||
];
|
||||
|
||||
const generateMockAIProviders = () => [
|
||||
{
|
||||
id: 'longcat',
|
||||
name: 'LongCat AI',
|
||||
description: 'Fast and efficient AI models',
|
||||
models: ['LongCat-Flash-Chat', 'LongCat-Flash-Thinking'],
|
||||
enabled: true,
|
||||
api_key_configured: true
|
||||
},
|
||||
{
|
||||
id: 'mistral',
|
||||
name: 'Mistral AI',
|
||||
description: 'European AI models',
|
||||
models: ['mistral-small-latest', 'mistral-large-latest'],
|
||||
enabled: false,
|
||||
api_key_configured: false
|
||||
}
|
||||
];
|
||||
|
||||
// Demo mode fetch interceptor
|
||||
export const demoFetch = async (url: string, options?: RequestInit): Promise<Response> => {
|
||||
if (!isDemoMode()) {
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
// Parse URL to determine which mock data to return
|
||||
let path: string;
|
||||
try {
|
||||
// Handle relative URLs by providing a base URL
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
path = urlObj.pathname;
|
||||
} catch (error) {
|
||||
// If URL construction fails, treat the url as the path directly
|
||||
path = url;
|
||||
console.warn('[Demo Mode] URL construction failed, using url as path:', url);
|
||||
}
|
||||
|
||||
console.log(`[Demo Mode] Intercepting request to: ${path}`);
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Return mock data based on the endpoint
|
||||
if (path.includes('/api/v1/auth/login') || path.includes('/api/v1/auth/login-totp')) {
|
||||
// Handle demo login
|
||||
return new Response(JSON.stringify({
|
||||
token: 'demo-token-' + Date.now(),
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'demo@trackeep.com',
|
||||
username: 'demo',
|
||||
full_name: 'Demo User',
|
||||
theme: 'dark',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/dashboard/stats')) {
|
||||
return new Response(JSON.stringify(generateMockStats()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/tasks') && (!options?.method || options.method === 'GET')) {
|
||||
const { getMockTasks } = await import('./mockData');
|
||||
const mockTasks = getMockTasks().map((task, index) => ({
|
||||
id: index + 1,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
completed: task.status === 'completed',
|
||||
priority: task.priority,
|
||||
createdAt: task.createdAt,
|
||||
dueDate: task.dueDate,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify(mockTasks), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/github/repos')) {
|
||||
return new Response(JSON.stringify(generateMockGitHubRepos()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/time-entries')) {
|
||||
return new Response(JSON.stringify(generateMockTimeEntries()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/video-bookmarks')) {
|
||||
if (options?.method === 'GET') {
|
||||
// Return empty bookmarks for demo
|
||||
return new Response(JSON.stringify({ bookmarks: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (options?.method === 'POST') {
|
||||
// Simulate creating a bookmark
|
||||
return new Response(JSON.stringify({
|
||||
id: Date.now(),
|
||||
message: 'Bookmark created successfully (demo mode)'
|
||||
}), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/youtube/search')) {
|
||||
const { getMockVideos } = await import('./mockData');
|
||||
const body = options?.body && typeof options.body === 'string' ? JSON.parse(options.body) : {};
|
||||
const query = body.query || '';
|
||||
|
||||
const mockVideos = getMockVideos().filter(video =>
|
||||
video.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
video.description.toLowerCase().includes(query.toLowerCase()) ||
|
||||
video.channel.toLowerCase().includes(query.toLowerCase())
|
||||
).slice(0, 10).map((video) => ({
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
channel_title: video.channel,
|
||||
duration: video.duration,
|
||||
published_at: video.publishedAt,
|
||||
view_count: Math.floor(Math.random() * 100000) + 1000
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ videos: mockVideos }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/youtube/channel-videos')) {
|
||||
const { getMockVideos } = await import('./mockData');
|
||||
const mockVideos = getMockVideos().slice(0, 5).map((video) => ({
|
||||
video_id: video.id,
|
||||
title: video.title,
|
||||
channel: video.channel,
|
||||
length: video.duration,
|
||||
views: Math.floor(Math.random() * 100000) + 1000,
|
||||
published_date: video.publishedAt,
|
||||
published_text: new Date(video.publishedAt).toLocaleDateString()
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ videos: mockVideos }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/youtube/video-details')) {
|
||||
return new Response(JSON.stringify(generateMockYouTubeVideos()[0]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/youtube/predefined-channels')) {
|
||||
return new Response(JSON.stringify(generateMockYouTubeVideos()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/learning-paths/categories')) {
|
||||
return new Response(JSON.stringify(['frontend', 'backend', 'fullstack', 'devops', 'mobile', 'design']), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/learning-paths')) {
|
||||
return new Response(JSON.stringify(generateMockLearningPaths()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/chat/sessions')) {
|
||||
return new Response(JSON.stringify(generateMockChatSessions()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/ai/providers')) {
|
||||
return new Response(JSON.stringify(generateMockAIProviders()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Handle update checking endpoints
|
||||
if (path.includes('/api/updates/check')) {
|
||||
return new Response(JSON.stringify({
|
||||
updateAvailable: false,
|
||||
currentVersion: '1.0.0-demo',
|
||||
latestVersion: '1.0.0-demo'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/updates/progress')) {
|
||||
return new Response(JSON.stringify({
|
||||
progress: 0,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
completed: false,
|
||||
error: null
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// For POST requests that create data
|
||||
if (options?.method === 'POST') {
|
||||
if (path.includes('/api/v1/time-entries')) {
|
||||
const newEntry = { ...JSON.parse(options.body as string), id: Date.now() };
|
||||
return new Response(JSON.stringify(newEntry), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/tasks')) {
|
||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||
const newTask = {
|
||||
id: Date.now(),
|
||||
title: body.title || 'Untitled task',
|
||||
description: body.description || '',
|
||||
completed: body.completed ?? false,
|
||||
priority: body.priority || 'medium',
|
||||
createdAt: body.createdAt || new Date().toISOString(),
|
||||
dueDate: body.dueDate || '',
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(newTask), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.method === 'PUT' && path.includes('/api/v1/tasks')) {
|
||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||
const pathParts = path.split('/');
|
||||
const idFromPath = parseInt(pathParts[pathParts.length - 1] || '0', 10);
|
||||
const updatedTask = {
|
||||
id: idFromPath || body.id || Date.now(),
|
||||
title: body.title || 'Untitled task',
|
||||
description: body.description || '',
|
||||
completed: body.completed ?? false,
|
||||
priority: body.priority || 'medium',
|
||||
createdAt: body.createdAt || new Date().toISOString(),
|
||||
dueDate: body.dueDate || '',
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(updatedTask), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.method === 'DELETE' && path.includes('/api/v1/tasks')) {
|
||||
return new Response(JSON.stringify({ message: 'Task deleted (demo mode)' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Handle search API endpoints
|
||||
if (path.includes('/api/v1/search/web') || path.includes('/res/v1/web/search')) {
|
||||
let queryParam: string;
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
queryParam = urlObj.searchParams.get('q') ||
|
||||
(options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
|
||||
} catch {
|
||||
queryParam = (options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
|
||||
}
|
||||
|
||||
if (!queryParam) {
|
||||
return new Response(JSON.stringify({ error: 'Query parameter required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const mockSearchResults = [
|
||||
{
|
||||
title: `${queryParam} - Demo Search Result 1`,
|
||||
url: `https://demo.example.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `This is a demo search result for "${queryParam}" showing how the search functionality works in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'web',
|
||||
subtype: 'search'
|
||||
},
|
||||
{
|
||||
title: `${queryParam} - Demo Search Result 2`,
|
||||
url: `https://demo-search.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Another demo search result for "${queryParam}" demonstrating the search interface in demo mode.`,
|
||||
published_date: new Date(Date.now() - 86400000).toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'web',
|
||||
subtype: 'search'
|
||||
},
|
||||
{
|
||||
title: `Learn more about ${queryParam}`,
|
||||
url: `https://demo-learning.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Educational content about ${queryParam} for demo purposes. This shows how search results can include learning resources.`,
|
||||
published_date: new Date(Date.now() - 172800000).toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'web',
|
||||
subtype: 'educational'
|
||||
}
|
||||
];
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
web: { results: mockSearchResults },
|
||||
query: { original: queryParam, display: queryParam },
|
||||
mixed: { results: mockSearchResults }
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (path.includes('/api/v1/search/news') || path.includes('/res/v1/news/search')) {
|
||||
let queryParam: string;
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
queryParam = urlObj.searchParams.get('q') ||
|
||||
(options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
|
||||
} catch {
|
||||
queryParam = (options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
|
||||
}
|
||||
|
||||
if (!queryParam) {
|
||||
return new Response(JSON.stringify({ error: 'Query parameter required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const mockNewsResults = [
|
||||
{
|
||||
title: `Breaking News: ${queryParam} Update`,
|
||||
url: `https://demo-news.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Latest news about ${queryParam} - this is a demo news search result showing how news search works in demo mode.`,
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'news',
|
||||
subtype: 'article'
|
||||
},
|
||||
{
|
||||
title: `${queryParam} - Industry Report`,
|
||||
url: `https://demo-industry.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
description: `Industry analysis and reports about ${queryParam}. This demo result shows how news search can include industry content.`,
|
||||
published_date: new Date(Date.now() - 86400000).toISOString().split('T')[0],
|
||||
language: 'English',
|
||||
family_friendly: true,
|
||||
type: 'news',
|
||||
subtype: 'report'
|
||||
}
|
||||
];
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
news: { results: mockNewsResults },
|
||||
query: { original: queryParam, display: queryParam },
|
||||
mixed: { results: mockNewsResults }
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Default fallback - return a successful empty response
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
|
||||
// Override global fetch for demo mode
|
||||
export const initializeDemoMode = () => {
|
||||
if (isDemoMode()) {
|
||||
// Store original fetch to restore later if needed
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = demoFetch as typeof fetch;
|
||||
console.log('[Demo Mode] API interceptor initialized');
|
||||
return originalFetch;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
// Utility functions for formatting time durations
|
||||
|
||||
export interface TimeDuration {
|
||||
years: number;
|
||||
months: number;
|
||||
weeks: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export function formatDuration(totalHours: number): string {
|
||||
if (totalHours < 1) {
|
||||
const minutes = Math.round(totalHours * 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const duration = breakDownDuration(totalHours);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (duration.years > 0) {
|
||||
parts.push(`${duration.years}y`);
|
||||
}
|
||||
if (duration.months > 0) {
|
||||
parts.push(`${duration.months}mo`);
|
||||
}
|
||||
if (duration.weeks > 0) {
|
||||
parts.push(`${duration.weeks}w`);
|
||||
}
|
||||
if (duration.days > 0) {
|
||||
parts.push(`${duration.days}d`);
|
||||
}
|
||||
if (duration.hours > 0) {
|
||||
parts.push(`${duration.hours}h`);
|
||||
}
|
||||
|
||||
// If we have multiple parts, show the most significant 2-3
|
||||
if (parts.length > 3) {
|
||||
return parts.slice(0, 3).join(' ');
|
||||
}
|
||||
|
||||
// If we have just hours and it's less than 24, show just hours
|
||||
if (parts.length === 1 && parts[0].includes('h')) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function formatDurationShort(totalHours: number): string {
|
||||
if (totalHours < 24) {
|
||||
return `${Math.round(totalHours)}h`;
|
||||
}
|
||||
|
||||
const duration = breakDownDuration(totalHours);
|
||||
|
||||
if (duration.years > 0) {
|
||||
return `${duration.years}y ${duration.months}mo`;
|
||||
}
|
||||
if (duration.months > 0) {
|
||||
return `${duration.months}mo ${duration.weeks}w`;
|
||||
}
|
||||
if (duration.weeks > 0) {
|
||||
return `${duration.weeks}w ${duration.days}d`;
|
||||
}
|
||||
if (duration.days > 0) {
|
||||
return `${duration.days}d ${duration.hours}h`;
|
||||
}
|
||||
|
||||
return `${duration.hours}h`;
|
||||
}
|
||||
|
||||
export function formatDurationDetailed(totalHours: number): string {
|
||||
const duration = breakDownDuration(totalHours);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (duration.years > 0) parts.push(`${duration.years} year${duration.years !== 1 ? 's' : ''}`);
|
||||
if (duration.months > 0) parts.push(`${duration.months} month${duration.months !== 1 ? 's' : ''}`);
|
||||
if (duration.weeks > 0) parts.push(`${duration.weeks} week${duration.weeks !== 1 ? 's' : ''}`);
|
||||
if (duration.days > 0) parts.push(`${duration.days} day${duration.days !== 1 ? 's' : ''}`);
|
||||
if (duration.hours > 0) parts.push(`${duration.hours} hour${duration.hours !== 1 ? 's' : ''}`);
|
||||
|
||||
if (parts.length === 0) {
|
||||
const minutes = Math.round(totalHours * 60);
|
||||
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
return parts.join(' and ');
|
||||
}
|
||||
|
||||
return parts.slice(0, -1).join(', ') + ', and ' + parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function breakDownDuration(totalHours: number): TimeDuration {
|
||||
const hoursInDay = 24;
|
||||
const hoursInWeek = hoursInDay * 7;
|
||||
const hoursInMonth = hoursInDay * 30.44; // Average month length
|
||||
const hoursInYear = hoursInDay * 365.25; // Account for leap years
|
||||
|
||||
let remaining = totalHours;
|
||||
|
||||
const years = Math.floor(remaining / hoursInYear);
|
||||
remaining -= years * hoursInYear;
|
||||
|
||||
const months = Math.floor(remaining / hoursInMonth);
|
||||
remaining -= months * hoursInMonth;
|
||||
|
||||
const weeks = Math.floor(remaining / hoursInWeek);
|
||||
remaining -= weeks * hoursInWeek;
|
||||
|
||||
const days = Math.floor(remaining / hoursInDay);
|
||||
remaining -= days * hoursInDay;
|
||||
|
||||
const hours = Math.floor(remaining);
|
||||
remaining -= hours;
|
||||
|
||||
const minutes = Math.round(remaining * 60);
|
||||
|
||||
return {
|
||||
years,
|
||||
months,
|
||||
weeks,
|
||||
days,
|
||||
hours,
|
||||
minutes
|
||||
};
|
||||
}
|
||||
|
||||
export function getLargestTimeUnit(totalHours: number): { value: number; unit: string } {
|
||||
const duration = breakDownDuration(totalHours);
|
||||
|
||||
if (duration.years > 0) return { value: duration.years, unit: 'years' };
|
||||
if (duration.months > 0) return { value: duration.months, unit: 'months' };
|
||||
if (duration.weeks > 0) return { value: duration.weeks, unit: 'weeks' };
|
||||
if (duration.days > 0) return { value: duration.days, unit: 'days' };
|
||||
if (duration.hours > 0) return { value: duration.hours, unit: 'hours' };
|
||||
|
||||
return { value: Math.round(totalHours * 60), unit: 'minutes' };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Utility functions for formatting time durations in human-readable format
|
||||
|
||||
/**
|
||||
* Formats a duration in hours to human-readable format
|
||||
* @param totalHours Total duration in hours
|
||||
* @returns Formatted string (e.g., "2.5 days", "1 month", "3.2 years")
|
||||
*/
|
||||
export const formatDuration = (totalHours: number): string => {
|
||||
if (totalHours < 1) {
|
||||
const minutes = Math.round(totalHours * 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
if (totalHours < 24) {
|
||||
return `${Math.round(totalHours * 10) / 10}h`;
|
||||
}
|
||||
|
||||
const days = totalHours / 24;
|
||||
if (days < 7) {
|
||||
return `${Math.round(days * 10) / 10} days`;
|
||||
}
|
||||
|
||||
const weeks = days / 7;
|
||||
if (weeks < 4) {
|
||||
return `${Math.round(weeks * 10) / 10} weeks`;
|
||||
}
|
||||
|
||||
const months = days / 30.44; // Average month length
|
||||
if (months < 12) {
|
||||
return `${Math.round(months * 10) / 10} months`;
|
||||
}
|
||||
|
||||
const years = months / 12;
|
||||
return `${Math.round(years * 10) / 10} years`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a duration in hours to a compact format
|
||||
* @param totalHours Total duration in hours
|
||||
* @returns Compact formatted string (e.g., "2.5d", "1mo", "3.2y")
|
||||
*/
|
||||
export const formatDurationCompact = (totalHours: number): string => {
|
||||
if (totalHours < 1) {
|
||||
const minutes = Math.round(totalHours * 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
if (totalHours < 24) {
|
||||
return `${Math.round(totalHours * 10) / 10}h`;
|
||||
}
|
||||
|
||||
const days = totalHours / 24;
|
||||
if (days < 7) {
|
||||
return `${Math.round(days * 10) / 10}d`;
|
||||
}
|
||||
|
||||
const weeks = days / 7;
|
||||
if (weeks < 4) {
|
||||
return `${Math.round(weeks * 10) / 10}w`;
|
||||
}
|
||||
|
||||
const months = days / 30.44; // Average month length
|
||||
if (months < 12) {
|
||||
return `${Math.round(months * 10) / 10}mo`;
|
||||
}
|
||||
|
||||
const years = months / 12;
|
||||
return `${Math.round(years * 10) / 10}y`;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
interface WeeklyBarChartProps {
|
||||
data: number[];
|
||||
title?: string;
|
||||
type?: 'activities' | 'contributions';
|
||||
fallbackData?: number[];
|
||||
}
|
||||
|
||||
export const WeeklyBarChart = (props: WeeklyBarChartProps) => {
|
||||
const weeklyData = () => props.data || props.fallbackData || [12, 19, 8, 15, 22, 18, 25];
|
||||
const chartType = () => props.type || 'activities';
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const activity = weeklyData()[index];
|
||||
const maxActivity = Math.max(...weeklyData());
|
||||
// Use dynamic scale based on actual data
|
||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
||||
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
||||
<span
|
||||
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5"
|
||||
>
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
|
||||
title={`${day}: ${activity} ${chartType()}`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
<span>Total: {weeklyData().reduce((a, b) => a + b, 0)} {chartType()}</span>
|
||||
<span>Avg: {Math.round(weeklyData().reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { IconBrain, IconFileText, IconChecklist, IconSparkles, IconRobot, IconSettings } from '@tabler/icons-solidjs';
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||
|
||||
interface AIProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
models: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const AIAssistant = () => {
|
||||
const [activeTab, setActiveTab] = createSignal<'dashboard' | 'summarizer' | 'tasks' | 'content' | 'settings'>('dashboard');
|
||||
const [selectedProvider, setSelectedProvider] = createSignal<string>('');
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('standard');
|
||||
const [enabledProviders, setEnabledProviders] = createSignal<string[]>([]);
|
||||
const [providers, setProviders] = createSignal<AIProvider[]>([]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'AI Dashboard', icon: IconBrain },
|
||||
{ id: 'summarizer', label: 'Content Summarizer', icon: IconFileText },
|
||||
{ id: 'tasks', label: 'Task Suggestions', icon: IconChecklist },
|
||||
{ id: 'content', label: 'Content Generation', icon: IconSparkles },
|
||||
{ id: 'settings', label: 'AI Settings', icon: IconSettings },
|
||||
];
|
||||
|
||||
// Fetch available providers on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/v1/ai/providers`);
|
||||
const data = await response.json();
|
||||
setProviders(data.providers || []);
|
||||
|
||||
// Enable all providers by default
|
||||
const providerIds = (data.providers || []).map((p: AIProvider) => p.id);
|
||||
setEnabledProviders(providerIds);
|
||||
|
||||
// Set default provider if available
|
||||
if (data.providers && data.providers.length > 0) {
|
||||
setSelectedProvider(data.providers[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI providers:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const toggleProvider = (providerId: string) => {
|
||||
const enabled = enabledProviders();
|
||||
if (enabled.includes(providerId)) {
|
||||
// Remove provider if it's currently selected, select another
|
||||
if (selectedProvider() === providerId) {
|
||||
const remaining = enabled.filter(p => p !== providerId);
|
||||
setSelectedProvider(remaining.length > 0 ? remaining[0] : '');
|
||||
}
|
||||
setEnabledProviders(enabled.filter(p => p !== providerId));
|
||||
} else {
|
||||
setEnabledProviders([...enabled, providerId]);
|
||||
// If this is the first provider, select it
|
||||
if (enabled.length === 0) {
|
||||
setSelectedProvider(providerId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<IconRobot class="size-8 text-primary" />
|
||||
AI Assistant
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Leverage AI to enhance your productivity and content management
|
||||
</p>
|
||||
</div>
|
||||
{enabledProviders().length > 0 && (
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-gray-500">Active:</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{enabledProviders().map(providerId => {
|
||||
const provider = providers().find(p => p.id === providerId);
|
||||
return (
|
||||
<div class="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||
<AIProviderIcon
|
||||
providerId={providerId}
|
||||
size="1.25rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">
|
||||
{provider?.name || providerId}
|
||||
</span>
|
||||
{selectedModel() !== 'standard' && selectedProvider() === providerId && (
|
||||
<span class="text-xs text-blue-500">
|
||||
{provider?.models.find(m => m.id === selectedModel())?.name?.split('-')[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
class={`flex items-center gap-2 py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab() === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon class="size-5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="space-y-6">
|
||||
{activeTab() === 'settings' && (
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">AI Provider Settings</h3>
|
||||
<div class="space-y-6">
|
||||
{/* Provider Toggles */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available Providers</h4>
|
||||
<div class="space-y-3">
|
||||
{providers().map((provider) => {
|
||||
const isEnabled = enabledProviders().includes(provider.id);
|
||||
return (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
isEnabled
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={provider.id}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium text-gray-900 dark:text-white">{provider.name}</h5>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{provider.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isEnabled
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
isEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model selection for enabled providers */}
|
||||
{isEnabled && (
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Model:
|
||||
</label>
|
||||
<select
|
||||
value={selectedProvider() === provider.id ? selectedModel() : 'standard'}
|
||||
onChange={(e) => {
|
||||
setSelectedProvider(provider.id);
|
||||
setSelectedModel(e.target.value);
|
||||
}}
|
||||
class="text-sm px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
{provider.models.map((model) => (
|
||||
<option value={model.id}>
|
||||
{model.type} - {model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Model badges */}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{provider.models.map((model) => (
|
||||
<div
|
||||
class={`px-2 py-1 text-xs rounded-full border ${
|
||||
model.id.includes('thinking') || model.id.includes('reasoner')
|
||||
? 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{model.type}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Selection */}
|
||||
{enabledProviders().length > 0 && (
|
||||
<div>
|
||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Current Selection</h4>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={selectedProvider()}
|
||||
size="1.5rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{providers().find(p => p.id === selectedProvider())?.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{providers().find(p => p.id === selectedProvider())?.models.find(m => m.id === selectedModel())?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab() === 'dashboard' && (
|
||||
<Card class="p-6 text-center">
|
||||
<IconBrain class="size-12 text-primary mx-auto" />
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
|
||||
AI Dashboard
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
AI Dashboard component temporarily disabled.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{activeTab() === 'summarizer' && (
|
||||
<Card class="p-6 text-center">
|
||||
<IconFileText class="size-12 text-primary mx-auto" />
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
|
||||
Content Summarizer
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Content Summarizer component temporarily disabled.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{activeTab() === 'tasks' && (
|
||||
<Card class="p-6 text-center">
|
||||
<IconChecklist class="size-12 text-primary mx-auto" />
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
|
||||
Task Suggestions
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
AI-powered task suggestions based on your calendar, deadlines, and habits.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
View and manage suggestions from the AI Dashboard.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{activeTab() === 'content' && (
|
||||
<Card class="p-6 text-center">
|
||||
<IconSparkles class="size-12 text-primary mx-auto" />
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2 mt-4">
|
||||
Content Generation
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Generate blog posts, code, emails, and more with AI assistance.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
Coming soon - Advanced AI content generation tools.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,719 @@
|
||||
import { createSignal, For, Show, onMount } from 'solid-js'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import {
|
||||
MessageCircle,
|
||||
Brain,
|
||||
Cog,
|
||||
Send
|
||||
} from 'lucide-solid'
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
||||
|
||||
interface AIProvider {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
models: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}[];
|
||||
}
|
||||
|
||||
export const AIChat = () => {
|
||||
const [activeView, setActiveView] = createSignal<'chat' | 'settings'>('chat')
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
|
||||
// Chat state
|
||||
const [messages, setMessages] = createSignal<any[]>([
|
||||
{
|
||||
id: 1,
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
const [inputMessage, setInputMessage] = createSignal('')
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
|
||||
// AI Provider state
|
||||
const [selectedProvider, setSelectedProvider] = createSignal<string>('')
|
||||
const [selectedModel, setSelectedModel] = createSignal<string>('standard')
|
||||
const [enabledProviders, setEnabledProviders] = createSignal<string[]>([])
|
||||
const [providers, setProviders] = createSignal<AIProvider[]>([])
|
||||
|
||||
// Per-user AI settings (mirrors /api/v1/auth/ai/settings)
|
||||
const [aiSettings, setAISettings] = createSignal({
|
||||
mistral: { enabled: false, api_key: '', model: '', model_thinking: '' },
|
||||
grok: { enabled: false, api_key: '', base_url: '', model: '', model_thinking: '' },
|
||||
deepseek: { enabled: false, api_key: '', base_url: '', model: '', model_thinking: '' },
|
||||
ollama: { enabled: false, base_url: '', model: '', model_thinking: '' },
|
||||
longcat: { enabled: false, api_key: '', base_url: '', openai_endpoint: '', anthropic_endpoint: '', model: '', model_thinking: '', model_thinking_upgraded: '', format: 'openai' }
|
||||
})
|
||||
const [aiSettingsLoading, setAiSettingsLoading] = createSignal(false)
|
||||
const [aiSettingsMessage, setAiSettingsMessage] = createSignal('')
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const message = inputMessage().trim()
|
||||
if (!message || isLoading()) return
|
||||
|
||||
// Add user message
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
content: message,
|
||||
role: 'user',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
const aiResponse = {
|
||||
id: Date.now() + 1,
|
||||
content: `I received your message: "${message}". This is a demo response from the AI assistant. In production, I would provide a helpful response based on the selected AI provider and model.`,
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
setMessages(prev => [...prev, aiResponse])
|
||||
setIsLoading(false)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
// Check mobile on mount
|
||||
onMount(() => {
|
||||
const checkMobile = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
|
||||
// Fetch AI providers
|
||||
fetchAIProviders()
|
||||
// Load per-user AI provider settings
|
||||
loadAISettings()
|
||||
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
const fetchAIProviders = async () => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
||||
const response = await fetch(`${apiUrl}/api/v1/ai/providers`)
|
||||
const data = await response.json()
|
||||
setProviders(data.providers || [])
|
||||
|
||||
const providerIds = (data.providers || []).map((p: AIProvider) => p.id)
|
||||
setEnabledProviders(providerIds)
|
||||
|
||||
if (data.providers && data.providers.length > 0) {
|
||||
setSelectedProvider(data.providers[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI providers:', error)
|
||||
// Set mock providers for demo mode
|
||||
const mockProviders: AIProvider[] = [
|
||||
{
|
||||
id: 'longcat',
|
||||
name: 'LongCat AI',
|
||||
description: 'Fast and efficient AI models',
|
||||
icon: '🐱',
|
||||
models: [
|
||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', type: 'chat' },
|
||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', type: 'thinking' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'mistral',
|
||||
name: 'Mistral AI',
|
||||
description: 'Advanced language models',
|
||||
icon: '🌊',
|
||||
models: [
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', type: 'chat' },
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', type: 'chat' }
|
||||
]
|
||||
}
|
||||
]
|
||||
setProviders(mockProviders)
|
||||
setEnabledProviders(['longcat'])
|
||||
setSelectedProvider('longcat')
|
||||
}
|
||||
}
|
||||
|
||||
const loadAISettings = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAISettings(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load AI settings:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateAISettings = async () => {
|
||||
setAiSettingsLoading(true)
|
||||
setAiSettingsMessage('')
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(aiSettings())
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setAiSettingsMessage('AI settings updated successfully!')
|
||||
await loadAISettings()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
setAiSettingsMessage(error.error || 'Failed to update AI settings')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update AI settings:', error)
|
||||
setAiSettingsMessage('Failed to update AI settings')
|
||||
} finally {
|
||||
setAiSettingsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleProvider = (providerId: string) => {
|
||||
const enabled = enabledProviders()
|
||||
if (enabled.includes(providerId)) {
|
||||
if (selectedProvider() === providerId) {
|
||||
const remaining = enabled.filter(p => p !== providerId)
|
||||
setSelectedProvider(remaining.length > 0 ? remaining[0] : '')
|
||||
}
|
||||
setEnabledProviders(enabled.filter(p => p !== providerId))
|
||||
} else {
|
||||
setEnabledProviders([...enabled, providerId])
|
||||
if (enabled.length === 0) {
|
||||
setSelectedProvider(providerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div class="h-full w-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<header class="border-b bg-card/95 backdrop-blur-sm z-10">
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen())}
|
||||
class="md:hidden"
|
||||
>
|
||||
<MessageCircle class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* AI Logo */}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<Brain class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1 class="font-semibold text-lg">AI Assistant</h1>
|
||||
<p class="text-sm text-muted-foreground">Your intelligent workspace companion</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Switcher */}
|
||||
<div class="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedModel()}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
class="px-3 py-2 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="standard">Standard Model</option>
|
||||
<option value="advanced">Advanced Model</option>
|
||||
<option value="fast">Fast Model</option>
|
||||
<option value="creative">Creative Model</option>
|
||||
</select>
|
||||
|
||||
{/* View Switcher */}
|
||||
<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('settings')}
|
||||
class={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeView() === 'settings'
|
||||
? 'bg-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<Show when={isSidebarOpen()}>
|
||||
<aside class="w-80 border-r bg-card flex flex-col hidden md:flex">
|
||||
{/* Sidebar Header */}
|
||||
<div class="p-4 border-b">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Chat Sessions</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setActiveView('settings')}
|
||||
>
|
||||
<Cog class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-3">
|
||||
{/* New Chat Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMessages([{
|
||||
id: 1,
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}])
|
||||
setInputMessage('')
|
||||
}}
|
||||
class="w-full justify-start"
|
||||
variant="outline"
|
||||
>
|
||||
<MessageCircle class="h-4 w-4 mr-2" />
|
||||
New Chat
|
||||
</Button>
|
||||
|
||||
{/* Chat Sessions */}
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-muted-foreground font-medium px-3 py-2">
|
||||
Recent Chats
|
||||
</div>
|
||||
{[
|
||||
{ id: '1', title: 'Getting Started', message_count: 2, last_message: '2 hours ago' },
|
||||
{ id: '2', title: 'Project Planning', message_count: 5, last_message: '1 day ago' },
|
||||
{ id: '3', title: 'Technical Discussion', message_count: 3, last_message: '2 days ago' }
|
||||
].map(session => (
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-lg hover:bg-muted transition-colors"
|
||||
onClick={() => {
|
||||
setMessages([{
|
||||
id: 1,
|
||||
content: `This is the ${session.title} session. How can I help you?`,
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}])
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-medium truncate">{session.title}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{session.message_count} messages • {session.last_message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Show>
|
||||
|
||||
{/* Main Content */}
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Chat View */}
|
||||
<Show when={activeView() === 'chat'}>
|
||||
<div class="flex-1 flex flex-col">
|
||||
{/* Messages Area */}
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<For each={messages()}>
|
||||
{message => (
|
||||
<div
|
||||
class={`flex gap-4 ${
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
class={`max-w-[80%] rounded-lg p-4 ${
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
|
||||
}`}>
|
||||
{message.role === 'user' ? (
|
||||
<span class="text-xs">👤</span>
|
||||
) : (
|
||||
<span class="text-xs">🤖</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{isLoading() && (
|
||||
<div class="flex justify-start">
|
||||
<div class="bg-muted rounded-lg p-4 max-w-[80%]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span class="text-xs">🤖</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div class="border-t bg-card/95 backdrop-blur-sm">
|
||||
<div class="p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex gap-4">
|
||||
<Input
|
||||
value={inputMessage()}
|
||||
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder="Type your message..."
|
||||
class="flex-1"
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && inputMessage().trim()) {
|
||||
handleSendMessage()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
disabled={isLoading() || !inputMessage().trim()}
|
||||
onClick={handleSendMessage}
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Settings View */}
|
||||
<Show when={activeView() === 'settings'}>
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold mb-2">AI Settings</h2>
|
||||
<p class="text-muted-foreground">Configure your AI providers and preferences</p>
|
||||
</div>
|
||||
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">AI Provider Settings</h3>
|
||||
<div class="space-y-6">
|
||||
{/* Provider Toggles */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium mb-3">Available Providers</h4>
|
||||
<div class="space-y-3">
|
||||
<For each={providers()}>
|
||||
{(provider) => {
|
||||
const isEnabled = enabledProviders().includes(provider.id)
|
||||
return (
|
||||
<div
|
||||
class={`p-4 border rounded-lg transition-all ${
|
||||
isEnabled
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<AIProviderIcon
|
||||
providerId={provider.id}
|
||||
size="2rem"
|
||||
class="text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h5 class="font-medium">{provider.name}</h5>
|
||||
<p class="text-sm text-muted-foreground">{provider.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isEnabled
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
isEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Model selection */}
|
||||
{isEnabled && (
|
||||
<div class="mt-4 pt-4 border-t border-border">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<label class="text-sm font-medium">
|
||||
Model:
|
||||
</label>
|
||||
<select
|
||||
value={selectedProvider() === provider.id ? selectedModel() : 'standard'}
|
||||
onChange={(e) => {
|
||||
setSelectedProvider(provider.id)
|
||||
setSelectedModel(e.target.value)
|
||||
}}
|
||||
class="text-sm px-2 py-1 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<For each={provider.models}>
|
||||
{(model) => (
|
||||
<option value={model.id}>
|
||||
{model.type} - {model.name}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Settings */}
|
||||
<div>
|
||||
<h4 class="text-md font-medium mb-3">Response Settings</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 border border-border rounded-lg">
|
||||
<label class="block text-sm font-medium mb-2">Response Length</label>
|
||||
<select class="w-full text-sm px-3 py-2 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option value="concise">Concise</option>
|
||||
<option value="balanced" selected>Balanced</option>
|
||||
<option value="detailed">Detailed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border border-border rounded-lg">
|
||||
<label class="block text-sm font-medium mb-2">Response Style</label>
|
||||
<select class="w-full text-sm px-3 py-2 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option value="professional" selected>Professional</option>
|
||||
<option value="casual">Casual</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="creative">Creative</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account-level provider settings (example: LongCat) */}
|
||||
<div class="pt-4 mt-2 border-t border-border space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-md font-medium">Account Provider Settings</h4>
|
||||
<span class="text-xs text-muted-foreground">{aiSettingsMessage()}</span>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 bg-purple-500 rounded-full" />
|
||||
<span class="text-sm font-medium">LongCat AI</span>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiSettings().longcat.enabled}
|
||||
onChange={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, enabled: e.currentTarget.checked }
|
||||
})
|
||||
}}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiSettings().longcat.api_key}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, api_key: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
placeholder="LongCat API key"
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.base_url}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, base_url: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Chat Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.model}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, model: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Thinking Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.model_thinking}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, model_thinking: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Upgraded Thinking</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiSettings().longcat.model_thinking_upgraded}
|
||||
onInput={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, model_thinking_upgraded: e.currentTarget.value }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Format</label>
|
||||
<select
|
||||
value={aiSettings().longcat.format}
|
||||
onChange={(e) => {
|
||||
const settings = aiSettings()
|
||||
setAISettings({
|
||||
...settings,
|
||||
longcat: { ...settings.longcat, format: e.currentTarget.value as 'openai' | 'anthropic' }
|
||||
})
|
||||
}}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="openai">OpenAI Compatible</option>
|
||||
<option value="anthropic">Anthropic Compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<Button
|
||||
onClick={handleUpdateAISettings}
|
||||
disabled={aiSettingsLoading()}
|
||||
>
|
||||
{aiSettingsLoading() ? 'Saving...' : 'Save AI Settings'}
|
||||
</Button>
|
||||
<a
|
||||
href="/app/settings"
|
||||
class="ml-auto text-xs text-primary hover:underline"
|
||||
>
|
||||
Open full AI settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIChat
|
||||
@@ -0,0 +1,202 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ActivityFeed } from '@/components/ui/ActivityFeed';
|
||||
import {
|
||||
IconTrendingUp,
|
||||
IconClock,
|
||||
IconFilter,
|
||||
IconRefresh,
|
||||
IconDownload,
|
||||
IconSettings
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
export const Activity = () => {
|
||||
const [refreshKey, setRefreshKey] = createSignal(0);
|
||||
const [showFilters, setShowFilters] = createSignal(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-[#fafafa]">Activity Dashboard</h1>
|
||||
<p class="text-[#a3a3a3] mt-2">
|
||||
All your Trackeep activity enriched with GitHub data, unified in one place
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters())}
|
||||
>
|
||||
<IconFilter class="size-4 mr-2" />
|
||||
Filters
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<IconRefresh class="size-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconDownload class="size-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card class="p-6 border-l-4 border-l-primary">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-3 rounded-lg">
|
||||
<IconTrendingUp class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-[#fafafa]">247</p>
|
||||
<p class="text-sm text-[#a3a3a3]">Total Activities</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6 border-l-4 border-l-primary">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-3 rounded-lg">
|
||||
<IconTrendingUp class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-[#fafafa]">89</p>
|
||||
<p class="text-sm text-[#a3a3a3]">Trackeep Items</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6 border-l-4 border-l-primary">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-3 rounded-lg">
|
||||
<IconTrendingUp class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-[#fafafa]">158</p>
|
||||
<p class="text-sm text-[#a3a3a3]">GitHub Events</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6 border-l-4 border-l-primary">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-3 rounded-lg">
|
||||
<IconClock class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-[#fafafa]">2h</p>
|
||||
<p class="text-sm text-[#a3a3a3]">Last Activity</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Activity Feed */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Feed */}
|
||||
<div class="lg:col-span-2">
|
||||
<Card class="p-6">
|
||||
<ActivityFeed
|
||||
refreshKey={refreshKey()}
|
||||
limit={20}
|
||||
showFilter={showFilters()}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div class="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-[#fafafa] mb-4">Activity Breakdown</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#a3a3a3]">Bookmarks</span>
|
||||
<span class="text-sm font-medium text-primary">23</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#a3a3a3]">Tasks</span>
|
||||
<span class="text-sm font-medium text-primary">31</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#a3a3a3]">Notes</span>
|
||||
<span class="text-sm font-medium text-primary">18</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#a3a3a3]">Files</span>
|
||||
<span class="text-sm font-medium text-primary">17</span>
|
||||
</div>
|
||||
<div class="border-t border-[#262626] pt-3 mt-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#a3a3a3]">Commits</span>
|
||||
<span class="text-sm font-medium text-primary">89</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#a3a3a3]">Pull Requests</span>
|
||||
<span class="text-sm font-medium text-primary">12</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#a3a3a3]">Stars</span>
|
||||
<span class="text-sm font-medium text-primary">45</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#a3a3a3]">Forks</span>
|
||||
<span class="text-sm font-medium text-primary">12</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recent Repos */}
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-[#fafafa] mb-4">Active Repositories</h3>
|
||||
<div class="space-y-3">
|
||||
{[
|
||||
{ name: 'trackeep', language: 'TypeScript', activity: '2h ago' },
|
||||
{ name: 'solid-components', language: 'TypeScript', activity: '5h ago' },
|
||||
{ name: 'go-api', language: 'Go', activity: '1d ago' },
|
||||
{ name: 'ml-models', language: 'Python', activity: '2d ago' }
|
||||
].map((repo) => (
|
||||
<div class="flex items-center justify-between p-3 bg-[#262626] rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[#fafafa]">{repo.name}</p>
|
||||
<p class="text-xs text-[#a3a3a3]">{repo.language}</p>
|
||||
</div>
|
||||
<span class="text-xs text-[#a3a3a3]">{repo.activity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Settings */}
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-[#fafafa] mb-4">Activity Settings</h3>
|
||||
<div class="space-y-3">
|
||||
<Button variant="outline" size="sm" class="w-full justify-start">
|
||||
<IconSettings class="size-4 mr-2" />
|
||||
Configure Filters
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="w-full justify-start">
|
||||
<IconDownload class="size-4 mr-2" />
|
||||
Export Activity Data
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import {
|
||||
IconUsers,
|
||||
IconFileText,
|
||||
IconBookmark,
|
||||
IconFolder,
|
||||
IconTrendingUp,
|
||||
IconActivity,
|
||||
IconDatabase,
|
||||
IconPalette,
|
||||
IconSettings,
|
||||
IconUpload,
|
||||
IconEdit,
|
||||
IconGitBranch,
|
||||
IconClock,
|
||||
IconChecklist
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { ColorSwitcher } from './ColorSwitcher';
|
||||
|
||||
interface ProjectStats {
|
||||
totalUsers: number;
|
||||
totalDocuments: number;
|
||||
totalBookmarks: number;
|
||||
totalTasks: number;
|
||||
totalNotes: number;
|
||||
totalStorage: string;
|
||||
activeUsers: number;
|
||||
systemUptime: string;
|
||||
apiCallsToday: number;
|
||||
databaseSize: string;
|
||||
serverLoad: number;
|
||||
lastBackup: string;
|
||||
}
|
||||
|
||||
interface SystemActivity {
|
||||
id: string;
|
||||
type: 'user_login' | 'file_upload' | 'bookmark_created' | 'task_completed' | 'system_backup';
|
||||
description: string;
|
||||
timestamp: string;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
interface GitHubActivity {
|
||||
id: string;
|
||||
repo: string;
|
||||
commit: string;
|
||||
author: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
type: 'commit' | 'pull_request' | 'merge';
|
||||
}
|
||||
|
||||
export const AdminDashboard = () => {
|
||||
const [stats, setStats] = createSignal<ProjectStats>({
|
||||
totalUsers: 0,
|
||||
totalDocuments: 0,
|
||||
totalBookmarks: 0,
|
||||
totalTasks: 0,
|
||||
totalNotes: 0,
|
||||
totalStorage: '0 MB',
|
||||
activeUsers: 0,
|
||||
systemUptime: '0 days',
|
||||
apiCallsToday: 0,
|
||||
databaseSize: '0 MB',
|
||||
serverLoad: 0,
|
||||
lastBackup: 'Never'
|
||||
});
|
||||
const [systemActivities, setSystemActivities] = createSignal<SystemActivity[]>([]);
|
||||
const [githubActivities, setGithubActivities] = createSignal<GitHubActivity[]>([]);
|
||||
const [, setIsLoading] = createSignal(true);
|
||||
|
||||
onMount(() => {
|
||||
// Mock admin stats data
|
||||
setStats({
|
||||
totalUsers: 156,
|
||||
totalDocuments: 1247,
|
||||
totalBookmarks: 892,
|
||||
totalTasks: 456,
|
||||
totalNotes: 623,
|
||||
totalStorage: '2.4 GB',
|
||||
activeUsers: 23,
|
||||
systemUptime: '45 days',
|
||||
apiCallsToday: 12456,
|
||||
databaseSize: '847 MB',
|
||||
serverLoad: 35,
|
||||
lastBackup: '2024-01-15 02:30:00'
|
||||
});
|
||||
|
||||
// Mock system activities
|
||||
setSystemActivities([
|
||||
{
|
||||
id: '1',
|
||||
type: 'user_login',
|
||||
description: 'Admin user logged in',
|
||||
timestamp: '2 minutes ago',
|
||||
user: 'admin@trackeep.com'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'file_upload',
|
||||
description: 'User uploaded 3 documents',
|
||||
timestamp: '15 minutes ago',
|
||||
user: 'john.doe@example.com'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'bookmark_created',
|
||||
description: 'New bookmark added to collection',
|
||||
timestamp: '1 hour ago',
|
||||
user: 'jane.smith@example.com'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'system_backup',
|
||||
description: 'Automated backup completed successfully',
|
||||
timestamp: '2 hours ago'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'task_completed',
|
||||
description: 'Project milestone completed',
|
||||
timestamp: '3 hours ago',
|
||||
user: 'mike.wilson@example.com'
|
||||
}
|
||||
]);
|
||||
|
||||
// Mock GitHub activities
|
||||
setGithubActivities([
|
||||
{
|
||||
id: '1',
|
||||
repo: 'trackeep/frontend',
|
||||
commit: 'a1b2c3d',
|
||||
author: 'John Doe',
|
||||
message: 'Add pagination functionality to dashboard',
|
||||
timestamp: '30 minutes ago',
|
||||
type: 'commit'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
repo: 'trackeep/backend',
|
||||
commit: 'e4f5g6h',
|
||||
author: 'Jane Smith',
|
||||
message: 'Fix authentication middleware bug',
|
||||
timestamp: '2 hours ago',
|
||||
type: 'commit'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
repo: 'trackeep/docs',
|
||||
commit: 'i7j8k9l',
|
||||
author: 'Mike Wilson',
|
||||
message: 'Update API documentation',
|
||||
timestamp: '4 hours ago',
|
||||
type: 'merge'
|
||||
}
|
||||
]);
|
||||
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
const handleBackupDatabase = async () => {
|
||||
try {
|
||||
alert('Database backup initiated successfully!');
|
||||
// In real app, this would call the backup API
|
||||
} catch (error) {
|
||||
alert('Failed to backup database');
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageUsers = () => {
|
||||
window.open('/app/members', '_blank');
|
||||
};
|
||||
|
||||
const handleSystemSettings = () => {
|
||||
window.open('/app/admin-settings', '_blank');
|
||||
};
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'user_login': return IconUsers;
|
||||
case 'file_upload': return IconUpload;
|
||||
case 'bookmark_created': return IconBookmark;
|
||||
case 'task_completed': return IconChecklist;
|
||||
case 'system_backup': return IconDatabase;
|
||||
default: return IconActivity;
|
||||
}
|
||||
};
|
||||
|
||||
const getGitHubIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'commit': return IconGitBranch;
|
||||
case 'pull_request': return IconEdit;
|
||||
case 'merge': return IconGitBranch;
|
||||
default: return IconGitBranch;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">Admin Dashboard</h1>
|
||||
<p class="text-muted-foreground mt-2">System overview and management</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconSettings class="size-5 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">Administrator Access</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Stats Grid */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<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">
|
||||
<IconUsers class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-light">{stats().totalUsers}</p>
|
||||
<p class="text-sm text-muted-foreground">Total Users</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">
|
||||
<IconFileText class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-light">{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">
|
||||
<IconFolder class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-light">{stats().totalStorage}</p>
|
||||
<p class="text-sm text-muted-foreground">Storage Used</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary Stats and Activity */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
{/* System Activity */}
|
||||
<div class="lg:col-span-2 border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<IconActivity class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">System Activity</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Active Users</span>
|
||||
<span class="font-medium">{stats().activeUsers}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Tasks Completed</span>
|
||||
<span class="font-medium">{stats().totalTasks}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Notes Created</span>
|
||||
<span class="font-medium">{stats().totalNotes}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">System Uptime</span>
|
||||
<span class="font-medium">{stats().systemUptime}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Database Size</span>
|
||||
<span class="font-medium">847 MB</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">API Calls Today</span>
|
||||
<span class="font-medium">12,456</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<IconTrendingUp class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">Quick Actions</h3>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<button class="w-full text-left inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 px-3" onClick={handleBackupDatabase}>
|
||||
<IconDatabase class="size-4 mr-2" />
|
||||
Backup Database
|
||||
</button>
|
||||
<button class="w-full text-left inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 px-3" onClick={handleManageUsers}>
|
||||
<IconUsers class="size-4 mr-2" />
|
||||
Manage Users
|
||||
</button>
|
||||
<button class="w-full text-left inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 px-3" onClick={handleSystemSettings}>
|
||||
<IconSettings class="size-4 mr-2" />
|
||||
System Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline and GitHub Activity */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* System Activity Timeline */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<IconClock class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">System Activity Timeline</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
{systemActivities().map((activity, index) => {
|
||||
const ActivityIcon = getActivityIcon(activity.type);
|
||||
return (
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="bg-muted flex items-center justify-center p-2 rounded-full">
|
||||
<ActivityIcon class="size-4 text-primary" />
|
||||
</div>
|
||||
{index < systemActivities().length - 1 && (
|
||||
<div class="w-0.5 h-8 bg-muted mt-2"></div>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">{activity.description}</p>
|
||||
<p class="text-xs text-muted-foreground">{activity.timestamp}</p>
|
||||
{activity.user && (
|
||||
<p class="text-xs text-muted-foreground">User: {activity.user}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub Activity */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<IconGitBranch class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">GitHub Activity</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
{githubActivities().map((activity) => {
|
||||
const GitHubIcon = getGitHubIcon(activity.type);
|
||||
return (
|
||||
<div class="border rounded-lg p-3 hover:bg-muted/50 transition-colors">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
||||
<GitHubIcon class="size-4 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-sm font-medium">{activity.repo}</span>
|
||||
<span class="text-xs text-muted-foreground">•</span>
|
||||
<span class="text-xs text-muted-foreground">{activity.timestamp}</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-1">{activity.message}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono bg-muted px-2 py-1 rounded">{activity.commit}</span>
|
||||
<span class="text-xs text-muted-foreground">by {activity.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Switcher Section */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<IconPalette class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">Theme Customization</h3>
|
||||
</div>
|
||||
<ColorSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { IconSettings, IconUsers, IconDatabase, IconShield, IconCheck } from '@tabler/icons-solidjs';
|
||||
|
||||
interface AdminSetting {
|
||||
key: string;
|
||||
label: string;
|
||||
value: any;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
description: string;
|
||||
category: 'user' | 'system' | 'security';
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const AdminSettings = () => {
|
||||
const [settings, setSettings] = createSignal<AdminSetting[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [message, setMessage] = createSignal('');
|
||||
|
||||
onMount(() => {
|
||||
setSettings([
|
||||
{
|
||||
key: 'max_users',
|
||||
label: 'Maximum Users',
|
||||
value: '100',
|
||||
type: 'number',
|
||||
description: 'Maximum number of users allowed in the workspace',
|
||||
category: 'user',
|
||||
icon: 'IconUsers'
|
||||
},
|
||||
{
|
||||
key: 'allow_registration',
|
||||
label: 'Allow Registration',
|
||||
value: true,
|
||||
type: 'boolean',
|
||||
description: 'Allow new users to register',
|
||||
category: 'user',
|
||||
icon: 'IconUsers'
|
||||
},
|
||||
{
|
||||
key: 'maintenance_mode',
|
||||
label: 'Maintenance Mode',
|
||||
value: false,
|
||||
type: 'boolean',
|
||||
description: 'Put the application in maintenance mode',
|
||||
category: 'system',
|
||||
icon: 'IconDatabase'
|
||||
},
|
||||
{
|
||||
key: 'enable_2fa',
|
||||
label: 'Enable 2FA',
|
||||
value: false,
|
||||
type: 'boolean',
|
||||
description: 'Require two-factor authentication for all users',
|
||||
category: 'security',
|
||||
icon: 'IconShield'
|
||||
},
|
||||
{
|
||||
key: 'session_timeout',
|
||||
label: 'Session Timeout (hours)',
|
||||
value: '24',
|
||||
type: 'number',
|
||||
description: 'Hours before user sessions expire',
|
||||
category: 'security',
|
||||
icon: 'IconShield'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
const updateSetting = (key: string, value: any) => {
|
||||
setSettings(prev =>
|
||||
prev.map(setting =>
|
||||
setting.key === key ? { ...setting, value } : setting
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
setMessage('Settings saved successfully!');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} catch (error) {
|
||||
setMessage('Failed to save settings');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-6xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-foreground mb-2 flex items-center gap-3">
|
||||
<IconSettings class="size-8 text-primary" />
|
||||
Admin Settings
|
||||
</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Manage system-wide settings and configurations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={message()}>
|
||||
<div class="p-4 rounded-lg text-sm mb-6 bg-primary/15 text-primary border border-primary/20">
|
||||
{message()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-8">
|
||||
{/* User Settings */}
|
||||
<div class="border rounded-lg p-6 bg-card">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10">
|
||||
<IconUsers class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-foreground">User Settings</h2>
|
||||
<p class="text-sm text-muted-foreground">Manage user-related configurations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<For each={settings().filter(s => s.category === 'user')}>
|
||||
{(setting) => (
|
||||
<div class="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<label class="text-sm font-medium text-foreground">{setting.label}</label>
|
||||
<p class="text-xs text-muted-foreground mt-1">{setting.description}</p>
|
||||
</div>
|
||||
{setting.type === 'boolean' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSetting(setting.key, !setting.value)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
|
||||
setting.value ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
setting.value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
type={setting.type}
|
||||
value={String(setting.value)}
|
||||
onInput={(e) => updateSetting(setting.key, e.currentTarget.value)}
|
||||
class="flex h-10 w-32 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Settings */}
|
||||
<div class="border rounded-lg p-6 bg-card">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10">
|
||||
<IconDatabase class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-foreground">System Settings</h2>
|
||||
<p class="text-sm text-muted-foreground">Manage system configurations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<For each={settings().filter(s => s.category === 'system')}>
|
||||
{(setting) => (
|
||||
<div class="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<label class="text-sm font-medium text-foreground">{setting.label}</label>
|
||||
<p class="text-xs text-muted-foreground mt-1">{setting.description}</p>
|
||||
</div>
|
||||
{setting.type === 'boolean' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSetting(setting.key, !setting.value)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
|
||||
setting.value ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
setting.value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
type={setting.type}
|
||||
value={String(setting.value)}
|
||||
onInput={(e) => updateSetting(setting.key, e.currentTarget.value)}
|
||||
class="flex h-10 w-32 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Settings */}
|
||||
<div class="border rounded-lg p-6 bg-card">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="flex items-center justify-center p-2 rounded-lg bg-primary/10">
|
||||
<IconShield class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-foreground">Security Settings</h2>
|
||||
<p class="text-sm text-muted-foreground">Manage security configurations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<For each={settings().filter(s => s.category === 'security')}>
|
||||
{(setting) => (
|
||||
<div class="flex items-center justify-between p-4 bg-muted/30 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<label class="text-sm font-medium text-foreground">{setting.label}</label>
|
||||
<p class="text-xs text-muted-foreground mt-1">{setting.description}</p>
|
||||
</div>
|
||||
{setting.type === 'boolean' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSetting(setting.key, !setting.value)}
|
||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
|
||||
setting.value ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
setting.value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
type={setting.type}
|
||||
value={String(setting.value)}
|
||||
onInput={(e) => updateSetting(setting.key, e.currentTarget.value)}
|
||||
class="flex h-10 w-32 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div class="flex justify-end mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveSettings}
|
||||
disabled={isLoading()}
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 h-11 px-6 gap-2"
|
||||
>
|
||||
{isLoading() ? (
|
||||
<>
|
||||
<div class="w-4 h-4 border-2 border-primary-foreground/30 border-t-transparent rounded-full animate-spin"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCheck class="size-4" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,516 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import {
|
||||
IconChartLine,
|
||||
IconBookmarks,
|
||||
IconChecklist,
|
||||
IconClock,
|
||||
IconTarget,
|
||||
IconBrain,
|
||||
IconGitBranch,
|
||||
IconBulb,
|
||||
IconAward
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface AnalyticsData {
|
||||
period: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
days: number;
|
||||
};
|
||||
summary: {
|
||||
hours_tracked: number;
|
||||
tasks_completed: number;
|
||||
bookmarks_added: number;
|
||||
notes_created: number;
|
||||
courses_completed: number;
|
||||
github_commits: number;
|
||||
};
|
||||
analytics: Array<{
|
||||
date: string;
|
||||
hours_tracked: number;
|
||||
tasks_completed: number;
|
||||
bookmarks_added: number;
|
||||
notes_created: number;
|
||||
courses_completed: number;
|
||||
github_commits: number;
|
||||
study_streak: number;
|
||||
productivity_score: number;
|
||||
}>;
|
||||
productivity_metrics: Array<{
|
||||
period: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
total_hours: number;
|
||||
billable_hours: number;
|
||||
non_billable_hours: number;
|
||||
tasks_completed: number;
|
||||
average_task_time: number;
|
||||
peak_productivity_hour: number;
|
||||
focus_score: number;
|
||||
efficiency_score: number;
|
||||
}>;
|
||||
learning_analytics: Array<{
|
||||
id: number;
|
||||
course: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
start_date: string;
|
||||
last_accessed: string;
|
||||
time_spent: number;
|
||||
progress: number;
|
||||
modules_completed: number;
|
||||
total_modules: number;
|
||||
average_score: number;
|
||||
streak_days: number;
|
||||
skills_acquired: string[];
|
||||
}>;
|
||||
github_analytics: Array<{
|
||||
date: string;
|
||||
commits: number;
|
||||
pull_requests: number;
|
||||
issues_opened: number;
|
||||
issues_closed: number;
|
||||
reviews: number;
|
||||
contributions: number;
|
||||
languages: Record<string, number>;
|
||||
repositories: string[];
|
||||
}>;
|
||||
goals: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
target_value: number;
|
||||
current_value: number;
|
||||
unit: string;
|
||||
deadline: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
progress: number;
|
||||
is_completed: boolean;
|
||||
milestones: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
target_value: number;
|
||||
current_value: number;
|
||||
deadline: string;
|
||||
status: string;
|
||||
is_completed: boolean;
|
||||
}>;
|
||||
}>;
|
||||
habit_analytics: Array<{
|
||||
habit_name: string;
|
||||
start_date: string;
|
||||
last_completed: string;
|
||||
streak: number;
|
||||
best_streak: number;
|
||||
total_days: number;
|
||||
completion_rate: number;
|
||||
frequency: string;
|
||||
category: string;
|
||||
goal_target: number;
|
||||
goal_achieved: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const Analytics = () => {
|
||||
const [analytics, setAnalytics] = createSignal<AnalyticsData | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [selectedPeriod, setSelectedPeriod] = createSignal('30');
|
||||
|
||||
const fetchAnalytics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/analytics/dashboard?days=${selectedPeriod()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch analytics');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setAnalytics(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
fetchAnalytics();
|
||||
});
|
||||
|
||||
const formatHours = (hours: number) => {
|
||||
const h = Math.floor(hours);
|
||||
const m = Math.round((hours - h) * 60);
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent': return 'text-destructive';
|
||||
case 'high': return 'text-orange-500';
|
||||
case 'medium': return 'text-yellow-500';
|
||||
case 'low': return 'text-muted-foreground';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// Component render
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Analytics & Insights</h1>
|
||||
<p class="text-muted-foreground">Track your productivity and progress</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
value={selectedPeriod()}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
class="px-3 py-2 border rounded-md bg-background"
|
||||
>
|
||||
<option value="7">Last 7 days</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="90">Last 90 days</option>
|
||||
</select>
|
||||
<Button onClick={fetchAnalytics}>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="bg-destructive/15 border border-destructive/20 rounded-md p-4">
|
||||
<p class="text-destructive">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div class="text-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p class="mt-2 text-muted-foreground">Loading analytics...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={analytics()}>
|
||||
<div class="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Hours Tracked</p>
|
||||
<p class="text-2xl font-bold">{formatHours(analytics()!.summary.hours_tracked)}</p>
|
||||
</div>
|
||||
<IconClock class="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Tasks Completed</p>
|
||||
<p class="text-2xl font-bold">{analytics()!.summary.tasks_completed}</p>
|
||||
</div>
|
||||
<IconChecklist class="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Bookmarks Added</p>
|
||||
<p class="text-2xl font-bold">{analytics()!.summary.bookmarks_added}</p>
|
||||
</div>
|
||||
<IconBookmarks class="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">GitHub Commits</p>
|
||||
<p class="text-2xl font-bold">{analytics()!.summary.github_commits}</p>
|
||||
</div>
|
||||
<IconGitBranch class="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Goals Progress */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconTarget class="h-5 w-5" />
|
||||
Active Goals
|
||||
</CardTitle>
|
||||
<CardDescription>Track your goal progress</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<For each={analytics()!.goals.filter(g => g.status === 'active').slice(0, 5)}>
|
||||
{(goal) => (
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium">{goal.title}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{goal.current_value} / {goal.target_value} {goal.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`text-sm font-medium ${getPriorityColor(goal.priority)}`}>
|
||||
{goal.priority}
|
||||
</span>
|
||||
<span class="text-sm font-medium">{Math.round(goal.progress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={`width: ${goal.progress}%`}
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Deadline: {formatDate(goal.deadline)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={analytics()!.goals.filter(g => g.status === 'active').length === 0}>
|
||||
<p class="text-muted-foreground text-center py-4">No active goals</p>
|
||||
</Show>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Habit Tracking */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconAward class="h-5 w-5" />
|
||||
Habit Tracking
|
||||
</CardTitle>
|
||||
<CardDescription>Your daily habits and streaks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<For each={analytics()!.habit_analytics.slice(0, 5)}>
|
||||
{(habit) => (
|
||||
<div class="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 class="font-medium">{habit.habit_name}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{habit.frequency} • {Math.round(habit.completion_rate)}% completion
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="flex items-center gap-1">
|
||||
<IconBulb class="h-4 w-4 text-orange-500" />
|
||||
<span class="font-medium">{habit.streak} day streak</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Best: {habit.best_streak} days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={analytics()!.habit_analytics.length === 0}>
|
||||
<p class="text-muted-foreground text-center py-4">No habits tracked</p>
|
||||
</Show>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Learning Progress */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconBrain class="h-5 w-5" />
|
||||
Learning Progress
|
||||
</CardTitle>
|
||||
<CardDescription>Your course progress and achievements</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={analytics()!.learning_analytics.slice(0, 6)}>
|
||||
{(course) => (
|
||||
<div class="border rounded-lg p-4">
|
||||
<h4 class="font-medium truncate">{course.course.title}</h4>
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
{course.modules_completed}/{course.total_modules} modules
|
||||
</p>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||
<div
|
||||
class="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={`width: ${course.progress}%`}
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{Math.round(course.progress)}% complete</span>
|
||||
<span>{course.streak_days} day streak</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={analytics()!.learning_analytics.length === 0}>
|
||||
<div class="col-span-full text-center py-8">
|
||||
<p class="text-muted-foreground">No courses in progress</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* GitHub Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconGitBranch class="h-5 w-5" />
|
||||
GitHub Activity
|
||||
</CardTitle>
|
||||
<CardDescription>Your contribution summary</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold">{analytics()!.summary.github_commits}</p>
|
||||
<p class="text-sm text-muted-foreground">Commits</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold">
|
||||
{analytics()!.github_analytics.reduce((sum, day) => sum + day.pull_requests, 0)}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Pull Requests</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold">
|
||||
{analytics()!.github_analytics.reduce((sum, day) => sum + day.issues_opened, 0)}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Issues Opened</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold">
|
||||
{analytics()!.github_analytics.reduce((sum, day) => sum + day.reviews, 0)}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Reviews</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<For each={analytics()!.github_analytics.slice(0, 7)}>
|
||||
{(day) => (
|
||||
<div class="flex justify-between items-center p-2 border rounded">
|
||||
<span class="text-sm">{formatDate(day.date)}</span>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<span>{day.commits} commits</span>
|
||||
<span>{day.pull_requests} PRs</span>
|
||||
<span>{day.issues_opened} issues</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Productivity Insights */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<IconChartLine class="h-5 w-5" />
|
||||
Productivity Insights
|
||||
</CardTitle>
|
||||
<CardDescription>Key insights and patterns</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Daily Activity</h4>
|
||||
<div class="space-y-2">
|
||||
<For each={analytics()!.analytics.slice(0, 7)}>
|
||||
{(day) => (
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm">{formatDate(day.date)}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">{formatHours(day.hours_tracked)}</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{day.tasks_completed} tasks
|
||||
</span>
|
||||
<Show when={day.productivity_score > 0}>
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">
|
||||
{Math.round(day.productivity_score)}%
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium mb-3">Key Metrics</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Average Daily Hours</span>
|
||||
<span class="text-sm font-medium">
|
||||
{formatHours(analytics()!.summary.hours_tracked / analytics()!.period.days)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Tasks per Day</span>
|
||||
<span class="text-sm font-medium">
|
||||
{(analytics()!.summary.tasks_completed / analytics()!.period.days).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Study Streak</span>
|
||||
<span class="text-sm font-medium">
|
||||
{Math.max(...analytics()!.analytics.map(a => a.study_streak))} days
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-muted-foreground">Average Productivity</span>
|
||||
<span class="text-sm font-medium">
|
||||
{Math.round(
|
||||
analytics()!.analytics.reduce((sum, a) => sum + a.productivity_score, 0) /
|
||||
analytics()!.analytics.length
|
||||
)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export const AuthCallback = () => {
|
||||
const [status, setStatus] = createSignal<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = createSignal('Processing authentication...');
|
||||
|
||||
onMount(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('token');
|
||||
|
||||
if (token) {
|
||||
// Store the token from Trackeep backend
|
||||
localStorage.setItem('token', token);
|
||||
setStatus('success');
|
||||
setMessage('Authentication successful! Redirecting...');
|
||||
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/app';
|
||||
}, 2000);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage('Authentication failed. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="min-h-screen flex items-center justify-center bg-background">
|
||||
<Card class="p-8 max-w-md w-full">
|
||||
<div class="text-center">
|
||||
{status() === 'loading' && (
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
<p class="text-lg text-foreground">{message()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status() === 'success' && (
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-12 h-12 bg-primary rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg text-primary font-medium">{message()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status() === 'error' && (
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-12 h-12 bg-destructive rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-lg text-destructive font-medium">{message()}</p>
|
||||
<Button onClick={() => window.location.href = '/login'}>
|
||||
Back to Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import {
|
||||
IconBookmark,
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
IconExternalLink,
|
||||
IconTag,
|
||||
IconClock,
|
||||
IconLoader2
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { createSignal, onMount, For } from 'solid-js'
|
||||
import { bookmarksApi, type Bookmark } from '@/lib/api'
|
||||
|
||||
export function Bookmarks() {
|
||||
const [bookmarks, setBookmarks] = createSignal<Bookmark[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
|
||||
const loadBookmarks = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await bookmarksApi.getAll()
|
||||
setBookmarks(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load bookmarks')
|
||||
console.error('Error loading bookmarks:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredBookmarks = () => {
|
||||
const query = searchQuery().toLowerCase()
|
||||
if (!query) return bookmarks()
|
||||
|
||||
return bookmarks().filter(bookmark =>
|
||||
bookmark.title.toLowerCase().includes(query) ||
|
||||
bookmark.description?.toLowerCase().includes(query) ||
|
||||
bookmark.url.toLowerCase().includes(query) ||
|
||||
bookmark.tags.some(tag => tag.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeleteBookmark = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this bookmark?')) return
|
||||
|
||||
try {
|
||||
await bookmarksApi.delete(id)
|
||||
setBookmarks(prev => prev.filter(b => b.id !== id))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete bookmark')
|
||||
console.error('Error deleting bookmark:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadBookmarks()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">Bookmarks</h1>
|
||||
<p class="text-gray-400 mt-2">Manage and organize your saved links</p>
|
||||
</div>
|
||||
<Button>
|
||||
<IconPlus class="mr-2 h-4 w-4" />
|
||||
Add Bookmark
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error() && (
|
||||
<div class="bg-red-900/20 border border-red-700 text-red-400 px-4 py-3 rounded-lg">
|
||||
{error()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="ml-2 text-red-400 hover:text-red-300"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="relative flex-1">
|
||||
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search bookmarks..."
|
||||
class="pl-10 bg-gray-800 border-gray-700 text-white placeholder-gray-400"
|
||||
value={searchQuery()}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement
|
||||
if (target) setSearchQuery(target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<IconTag class="mr-2 h-4 w-4" />
|
||||
All Tags
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconClock class="mr-2 h-4 w-4" />
|
||||
Recent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading() && (
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<IconLoader2 class="h-8 w-8 animate-spin text-primary-500" />
|
||||
<span class="ml-2 text-gray-400">Loading bookmarks...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading() && filteredBookmarks().length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<IconBookmark class="h-12 w-12 text-gray-600 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-300 mb-2">
|
||||
{searchQuery() ? 'No bookmarks found' : 'No bookmarks yet'}
|
||||
</h3>
|
||||
<p class="text-gray-500">
|
||||
{searchQuery()
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Start by adding your first bookmark'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bookmarks Grid */}
|
||||
{!loading() && (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={filteredBookmarks()}>
|
||||
{(bookmark) => (
|
||||
<Card class="hover:shadow-lg transition-shadow">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">🔖</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-lg text-white truncate">
|
||||
{bookmark.title}
|
||||
</CardTitle>
|
||||
<CardDescription class="text-xs text-primary-400 truncate">
|
||||
{bookmark.url}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-gray-400 hover:text-white"
|
||||
onClick={() => window.open(bookmark.url, '_blank')}
|
||||
>
|
||||
<IconExternalLink class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
{bookmark.description && (
|
||||
<p class="text-sm text-gray-300 line-clamp-2">
|
||||
{bookmark.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={bookmark.tags}>
|
||||
{(tag) => (
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-700 text-gray-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-700">
|
||||
<span class="text-xs text-gray-400">
|
||||
{new Date(bookmark.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<div class="flex space-x-1">
|
||||
<Button variant="ghost" size="sm" class="text-gray-400 hover:text-white">
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-gray-400 hover:text-red-400"
|
||||
onClick={() => handleDeleteBookmark(bookmark.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,245 +1,603 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary'
|
||||
import { SkeletonGrid } from '@/components/ui/LoadingState'
|
||||
import {
|
||||
IconBookmark,
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
IconExternalLink,
|
||||
IconTag,
|
||||
IconClock,
|
||||
IconStar,
|
||||
IconStarOff,
|
||||
IconRefresh,
|
||||
IconAlertTriangle
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { bookmarksApi, type Bookmark } from '@/lib/api-client'
|
||||
import { createSignal, onMount, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { BookmarkModal } from '@/components/ui/BookmarkModal';
|
||||
import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo } from '@tabler/icons-solidjs';
|
||||
import { getMockBookmarks, getMockVideos } from '@/lib/mockData';
|
||||
|
||||
export function Bookmarks() {
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
|
||||
const bookmarksQuery = bookmarksApi.useGetAll()
|
||||
const deleteBookmarkMutation = bookmarksApi.useDelete()
|
||||
const updateBookmarkMutation = bookmarksApi.useUpdate()
|
||||
interface BookmarkTag {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface Bookmark {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
// Normalized tags: always string[] for easier filtering/rendering
|
||||
tags: string[];
|
||||
created_at?: string;
|
||||
isImportant?: boolean;
|
||||
favicon?: string;
|
||||
screenshot?: string;
|
||||
screenshot_thumbnail?: string;
|
||||
screenshot_medium?: string;
|
||||
screenshot_large?: string;
|
||||
screenshot_original?: string;
|
||||
}
|
||||
|
||||
export const Bookmarks = () => {
|
||||
const adaptBookmarkFromApi = (raw: any): Bookmark => {
|
||||
const rawTags: BookmarkTag[] | string[] | undefined = raw.tags;
|
||||
let tags: string[] = [];
|
||||
|
||||
if (Array.isArray(rawTags)) {
|
||||
if (rawTags.length > 0 && typeof rawTags[0] === 'string') {
|
||||
tags = rawTags as string[];
|
||||
} else {
|
||||
tags = (rawTags as BookmarkTag[]).map((t) => t.name).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
title: raw.title || raw.url,
|
||||
url: raw.url,
|
||||
description: raw.description,
|
||||
tags,
|
||||
created_at: raw.created_at,
|
||||
isImportant: raw.is_favorite ?? raw.isImportant ?? false,
|
||||
favicon: raw.favicon,
|
||||
screenshot: raw.screenshot,
|
||||
screenshot_thumbnail: raw.screenshot_thumbnail,
|
||||
screenshot_medium: raw.screenshot_medium,
|
||||
screenshot_large: raw.screenshot_large,
|
||||
screenshot_original: raw.screenshot_original,
|
||||
};
|
||||
};
|
||||
|
||||
const getFaviconUrl = (bookmark: Bookmark) => {
|
||||
if (bookmark.favicon) return bookmark.favicon;
|
||||
try {
|
||||
const url = new URL(bookmark.url);
|
||||
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getScreenshotUrl = (bookmark: Bookmark) => {
|
||||
return (
|
||||
bookmark.screenshot_medium ||
|
||||
bookmark.screenshot ||
|
||||
bookmark.screenshot_large ||
|
||||
bookmark.screenshot_thumbnail ||
|
||||
bookmark.screenshot_original ||
|
||||
''
|
||||
);
|
||||
};
|
||||
|
||||
const [bookmarks, setBookmarks] = createSignal<Bookmark[]>([]);
|
||||
const [videoBookmarks, setVideoBookmarks] = createSignal<any[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
const [isLoadingVideos, setIsLoadingVideos] = createSignal(true);
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [selectedTag, setSelectedTag] = createSignal('');
|
||||
const [showAddModal, setShowAddModal] = createSignal(false);
|
||||
const [showEditModal, setShowEditModal] = createSignal(false);
|
||||
const [editingBookmark, setEditingBookmark] = createSignal<Bookmark | null>(null);
|
||||
const [activeTab, setActiveTab] = createSignal<'bookmarks' | 'videos'>('bookmarks');
|
||||
// We no longer show inline HTML content previews, only the bookmark cards themselves
|
||||
|
||||
onMount(async () => {
|
||||
// Check if we're in demo mode and load mock data directly
|
||||
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
|
||||
if (isDemoMode) {
|
||||
console.log('Demo mode detected, loading mock bookmarks');
|
||||
const mockBookmarks = getMockBookmarks();
|
||||
const adaptedBookmarks: Bookmark[] = mockBookmarks.map((bookmark, index) => ({
|
||||
id: index + 1,
|
||||
title: bookmark.title,
|
||||
url: bookmark.url,
|
||||
description: bookmark.description,
|
||||
tags: bookmark.tags.map((tag) => tag.name),
|
||||
created_at: bookmark.createdAt,
|
||||
isImportant: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
|
||||
favicon: bookmark.favicon,
|
||||
screenshot: bookmark.screenshot,
|
||||
screenshot_medium: bookmark.screenshot,
|
||||
}));
|
||||
setBookmarks(adaptedBookmarks);
|
||||
setIsLoading(false);
|
||||
|
||||
// Load mock video bookmarks
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
setIsLoadingVideos(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load bookmarks');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Normalize API response:
|
||||
// - Ensure we always work with an array
|
||||
// - Map Tag objects to simple string[]
|
||||
const normalized: Bookmark[] = (Array.isArray(data) ? data : []).map(adaptBookmarkFromApi);
|
||||
|
||||
setBookmarks(normalized);
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks:', error);
|
||||
// Fallback to mock data if API fails
|
||||
const mockBookmarks = getMockBookmarks();
|
||||
const adaptedBookmarks: Bookmark[] = mockBookmarks.map((bookmark, index) => ({
|
||||
id: index + 1,
|
||||
title: bookmark.title,
|
||||
url: bookmark.url,
|
||||
description: bookmark.description,
|
||||
tags: bookmark.tags.map((tag) => tag.name),
|
||||
created_at: bookmark.createdAt,
|
||||
isImportant: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
|
||||
favicon: bookmark.favicon,
|
||||
screenshot: bookmark.screenshot,
|
||||
screenshot_medium: bookmark.screenshot,
|
||||
}));
|
||||
setBookmarks(adaptedBookmarks);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all unique tags from bookmarks
|
||||
const getAllTags = () => {
|
||||
const tags = new Set<string>();
|
||||
bookmarks().forEach((bookmark) => {
|
||||
(bookmark.tags || []).forEach((tag) => tags.add(tag));
|
||||
});
|
||||
return Array.from(tags).sort();
|
||||
};
|
||||
|
||||
const filteredBookmarks = () => {
|
||||
const query = searchQuery().toLowerCase()
|
||||
if (!query) return bookmarksQuery.data || []
|
||||
const term = searchTerm().toLowerCase();
|
||||
const tag = selectedTag();
|
||||
|
||||
return (bookmarksQuery.data || []).filter(bookmark =>
|
||||
bookmark.title.toLowerCase().includes(query) ||
|
||||
bookmark.description?.toLowerCase().includes(query) ||
|
||||
bookmark.url.toLowerCase().includes(query) ||
|
||||
bookmark.tags.some(tag => tag.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
return bookmarks().filter(bookmark => {
|
||||
const matchesSearch = !term ||
|
||||
bookmark.title.toLowerCase().includes(term) ||
|
||||
bookmark.url.toLowerCase().includes(term) ||
|
||||
bookmark.description?.toLowerCase().includes(term) ||
|
||||
(bookmark.tags || []).some((t) => t.toLowerCase().includes(term));
|
||||
|
||||
const matchesTag = !tag || (bookmark.tags || []).includes(tag);
|
||||
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteBookmark = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this bookmark?')) return
|
||||
|
||||
// We no longer fetch or display full page metadata/content previews here.
|
||||
|
||||
const handleAddBookmark = async (bookmarkData: any) => {
|
||||
try {
|
||||
await deleteBookmarkMutation.mutateAsync(id)
|
||||
} catch (error) {
|
||||
console.error('Error deleting bookmark:', error)
|
||||
// Error is already handled by the mutation's onError callback
|
||||
}
|
||||
}
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
body: JSON.stringify(bookmarkData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to create bookmark');
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
const newBookmark = adaptBookmarkFromApi(raw);
|
||||
setBookmarks(prev => [newBookmark, ...prev]);
|
||||
setShowAddModal(false);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to add bookmark');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleImportant = (bookmarkId: number) => {
|
||||
setBookmarks((prev) =>
|
||||
prev.map((bookmark) =>
|
||||
bookmark.id === bookmarkId
|
||||
? { ...bookmark, isImportant: !bookmark.isImportant }
|
||||
: bookmark
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const deleteBookmark = async (bookmarkId: number) => {
|
||||
if (confirm('Are you sure you want to delete this bookmark?')) {
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks/${bookmarkId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete bookmark');
|
||||
}
|
||||
|
||||
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId));
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to delete bookmark');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const editBookmark = (bookmark: Bookmark) => {
|
||||
setEditingBookmark(bookmark);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
setSelectedTag((current) => (current === tag ? '' : tag));
|
||||
setSearchTerm(''); // Clear search when filtering by tag
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearchTerm('');
|
||||
setSelectedTag('');
|
||||
};
|
||||
|
||||
const handleEditBookmark = async (bookmarkData: Partial<Bookmark>) => {
|
||||
if (!editingBookmark()) return;
|
||||
|
||||
const handleToggleFavorite = async (bookmark: Bookmark) => {
|
||||
try {
|
||||
await updateBookmarkMutation.mutateAsync({
|
||||
id: bookmark.id,
|
||||
data: { is_favorite: !bookmark.is_favorite }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating bookmark:', error)
|
||||
// Error is already handled by the mutation's onError callback
|
||||
}
|
||||
}
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks/${editingBookmark()!.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
body: JSON.stringify(bookmarkData),
|
||||
});
|
||||
|
||||
const handleToggleRead = async (bookmark: Bookmark) => {
|
||||
try {
|
||||
await updateBookmarkMutation.mutateAsync({
|
||||
id: bookmark.id,
|
||||
data: { is_read: !bookmark.is_read }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating bookmark:', error)
|
||||
// Error is already handled by the mutation's onError callback
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update bookmark');
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
const raw = await response.json();
|
||||
const updatedBookmark = adaptBookmarkFromApi(raw);
|
||||
setBookmarks(prev =>
|
||||
prev.map(bookmark =>
|
||||
bookmark.id === updatedBookmark.id ? updatedBookmark : bookmark
|
||||
)
|
||||
);
|
||||
setShowEditModal(false);
|
||||
setEditingBookmark(null);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to update bookmark');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[#fafafa]">Bookmarks</h1>
|
||||
<p class="text-[#a3a3a3]">Save and organize your favorite links</p>
|
||||
</div>
|
||||
<Button class="bg-[#39b9ff] hover:bg-[#2a8fdb]">
|
||||
<IconPlus class="mr-2 h-4 w-4" />
|
||||
Add Bookmark
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => e.target && setSearchQuery((e.target as HTMLInputElement).value)}
|
||||
class="pl-10 bg-[#141415] border-[#262626] text-[#fafafa] placeholder-[#a3a3a3]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={bookmarksQuery.isLoading}>
|
||||
<SkeletonGrid count={6} />
|
||||
</Show>
|
||||
|
||||
{/* Error State */}
|
||||
<Show when={bookmarksQuery.isError}>
|
||||
<div class="bg-red-500/10 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<IconAlertTriangle class="mr-2 h-5 w-5" />
|
||||
<span>Failed to load bookmarks: {bookmarksQuery.error?.message}</span>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">Bookmarks</h1>
|
||||
<Show when={localStorage.getItem('demoMode') === 'true' || window.location.search.includes('demo=true')}>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded-full">
|
||||
Demo Mode
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">Showing sample bookmarks</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => bookmarksQuery.refetch()}
|
||||
class="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<IconRefresh class="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
Add Bookmark
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div class="border-b border-border">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('bookmarks')}
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab() === 'bookmarks'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
Web Bookmarks
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('videos')}
|
||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||
activeTab() === 'videos'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
<IconVideo class="size-4" />
|
||||
Video Bookmarks
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<Show when={activeTab() === 'bookmarks'}>
|
||||
<SearchTagFilterBar
|
||||
searchPlaceholder="Search bookmarks..."
|
||||
searchValue={searchTerm()}
|
||||
onSearchChange={(value) => setSearchTerm(value)}
|
||||
tagOptions={getAllTags()}
|
||||
selectedTag={selectedTag()}
|
||||
onTagChange={(value) => setSelectedTag(value)}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
<BookmarkModal
|
||||
isOpen={showAddModal()}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSubmit={handleAddBookmark}
|
||||
availableTags={getAllTags()}
|
||||
/>
|
||||
|
||||
<EditBookmarkModal
|
||||
isOpen={showEditModal()}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingBookmark(null);
|
||||
}}
|
||||
onSubmit={handleEditBookmark}
|
||||
bookmark={editingBookmark()}
|
||||
availableTags={getAllTags()}
|
||||
/>
|
||||
|
||||
{isLoading() ? (
|
||||
<div class="space-y-4">
|
||||
{[...Array(3)].map(() => (
|
||||
<Card class="p-6">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-6 bg-muted rounded mb-2"></div>
|
||||
<div class="h-4 bg-muted rounded mb-2 w-3/4"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Bookmarks Grid */}
|
||||
<Show when={!bookmarksQuery.isLoading && !bookmarksQuery.isError}>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<For each={filteredBookmarks()}>
|
||||
{(bookmark) => (
|
||||
<Card class="bg-[#141415] border-[#262626] hover:border-[#39b9ff] transition-colors">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{filteredBookmarks().map((bookmark) => {
|
||||
const faviconUrl = getFaviconUrl(bookmark);
|
||||
const screenshotUrl = getScreenshotUrl(bookmark);
|
||||
return (
|
||||
<Card class="p-6 hover:bg-accent transition-colors">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
{/* Left side: preview image + favicon + title + URL + tags */}
|
||||
<div class="flex-1 min-w-0">
|
||||
<CardTitle class="text-[#fafafa] truncate">
|
||||
<a
|
||||
href={bookmark.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-[#39b9ff] transition-colors"
|
||||
{screenshotUrl && (
|
||||
<div class="mb-3 rounded-md overflow-hidden border border-border bg-muted/40">
|
||||
<img
|
||||
src={screenshotUrl}
|
||||
alt="Website preview"
|
||||
class="w-full h-32 sm:h-40 object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-muted rounded-md flex items-center justify-center overflow-hidden">
|
||||
{faviconUrl ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
class="w-6 h-6 object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement!.innerHTML = `<span class=\"text-xs text-muted-foreground font-medium\">${bookmark.title.charAt(0).toUpperCase()}</span>`;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span class="text-xs text-muted-foreground font-medium">
|
||||
{bookmark.title.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground truncate">
|
||||
<a
|
||||
href={bookmark.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{bookmark.title}
|
||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current" />
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm truncate">{bookmark.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bookmark.description && (
|
||||
<p class="text-foreground text-sm mb-3 line-clamp-2">{bookmark.description}</p>
|
||||
)}
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
{(bookmark.tags || []).map((tag) => (
|
||||
<button
|
||||
onClick={() => handleTagClick(tag)}
|
||||
class={`px-2 py-1 text-xs rounded-md border transition-colors cursor-pointer
|
||||
${selectedTag() === tag
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted text-muted-foreground border-transparent hover:bg-primary hover:text-primary-foreground hover:border-primary'
|
||||
}`}
|
||||
title={`Click to filter by ${tag}`}
|
||||
>
|
||||
{bookmark.title}
|
||||
</a>
|
||||
</CardTitle>
|
||||
<CardDescription class="text-[#a3a3a3] text-xs mt-1">
|
||||
{new URL(bookmark.url).hostname}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-[#a3a3a3] hover:text-[#fafafa]"
|
||||
onClick={() => handleToggleFavorite(bookmark)}
|
||||
>
|
||||
<Show when={bookmark.is_favorite} fallback={<IconStarOff class="h-4 w-4" />}>
|
||||
<IconStar class="h-4 w-4 text-yellow-500" />
|
||||
</Show>
|
||||
</Button>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-3">
|
||||
<Show when={bookmark.description}>
|
||||
<p class="text-sm text-[#a3a3a3] line-clamp-2">
|
||||
{bookmark.description}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
{/* Tags */}
|
||||
<Show when={bookmark.tags.length > 0}>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={bookmark.tags}>
|
||||
{(tag) => (
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-[#262626] text-[#a3a3a3]">
|
||||
<IconTag class="mr-1 h-3 w-3" />
|
||||
{tag}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex items-center justify-between pt-2 border-t border-[#262626]">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={`text-xs ${bookmark.is_read ? 'text-[#a3a3a3]' : 'text-[#39b9ff]'}`}
|
||||
onClick={() => handleToggleRead(bookmark)}
|
||||
{/* Right side: optional date above important star + menu */}
|
||||
<div class="flex flex-col items-end gap-2 ml-2">
|
||||
{bookmark.created_at && !isNaN(new Date(bookmark.created_at).getTime()) && (
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{new Date(bookmark.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleImportant(bookmark.id)}
|
||||
class={`flex-shrink-0 p-1 rounded hover:bg-accent/50 transition-colors ${
|
||||
bookmark.isImportant ? 'order-first' : ''
|
||||
}`}
|
||||
title={bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'}
|
||||
>
|
||||
{bookmark.is_read ? 'Read' : 'Unread'}
|
||||
</Button>
|
||||
<span class="text-xs text-[#a3a3a3] flex items-center">
|
||||
<IconClock class="mr-1 h-3 w-3" />
|
||||
{formatDate(bookmark.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-[#a3a3a3] hover:text-[#fafafa]"
|
||||
onClick={() => window.open(bookmark.url, '_blank')}
|
||||
<IconStar
|
||||
class={`size-4 ${
|
||||
bookmark.isImportant
|
||||
? 'text-primary fill-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<DropdownMenu
|
||||
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">
|
||||
<IconDotsVertical class="size-4" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<IconExternalLink class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-[#a3a3a3] hover:text-red-400"
|
||||
onClick={() => handleDeleteBookmark(bookmark.id)}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
<DropdownMenuItem onClick={() => editBookmark(bookmark)} icon={IconEdit}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toggleImportant(bookmark.id)}
|
||||
icon={IconStar}
|
||||
>
|
||||
{bookmark.isImportant ? 'Remove from favorites' : 'Mark as favorite'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteBookmark(bookmark.id)}
|
||||
icon={IconTrash}
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredBookmarks().length === 0 && (
|
||||
<Card class="p-12 text-center">
|
||||
<p class="text-muted-foreground">
|
||||
{searchTerm() ? 'No bookmarks found matching your search.' : 'No bookmarks yet. Add your first bookmark!'}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={filteredBookmarks().length === 0}>
|
||||
<div class="text-center py-12">
|
||||
<IconBookmark class="mx-auto h-12 w-12 text-[#a3a3a3]" />
|
||||
<h3 class="mt-2 text-sm font-medium text-[#fafafa]">No bookmarks found</h3>
|
||||
<p class="mt-1 text-sm text-[#a3a3a3]">
|
||||
{searchQuery() ? 'Try adjusting your search terms' : 'Get started by adding your first bookmark'}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'videos'}>
|
||||
{isLoadingVideos() ? (
|
||||
<div class="space-y-4">
|
||||
{[...Array(3)].map(() => (
|
||||
<Card class="p-6">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-6 bg-muted rounded mb-2"></div>
|
||||
<div class="h-4 bg-muted rounded mb-2 w-3/4"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{videoBookmarks().map((video) => (
|
||||
<Card class="p-6 hover:bg-accent transition-colors">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
class="w-32 h-20 object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">
|
||||
<a
|
||||
href={video.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{video.title}
|
||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current" />
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm mb-2">{video.description}</p>
|
||||
<div class="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{video.channel}</span>
|
||||
<span>•</span>
|
||||
<span>{video.duration}</span>
|
||||
<span>•</span>
|
||||
<span>{video.publishedAt}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{video.tags.map((tag: any) => (
|
||||
<span class="px-2 py-1 text-xs rounded-md bg-muted text-muted-foreground">
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{videoBookmarks().length === 0 && (
|
||||
<Card class="p-12 text-center">
|
||||
<p class="text-muted-foreground">
|
||||
No video bookmarks yet. Save your first YouTube video!
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { IconPalette, IconCheck, IconRepeat, IconSun, IconMoon, IconDownload, IconUpload, IconEye, IconEyeOff } from '@tabler/icons-solidjs';
|
||||
|
||||
interface ColorScheme {
|
||||
name: string;
|
||||
primary: string;
|
||||
background: string;
|
||||
foreground: string;
|
||||
muted: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
export const ColorSwitcher = () => {
|
||||
const [schemes, setSchemes] = createSignal<ColorScheme[]>([]);
|
||||
const [currentScheme, setCurrentScheme] = createSignal('default');
|
||||
const [isDarkMode, setIsDarkMode] = createSignal(false);
|
||||
const [customColors, setCustomColors] = createSignal({
|
||||
primary: '#5ab9ff',
|
||||
background: '#000000',
|
||||
foreground: '#ffffff',
|
||||
muted: '#262727',
|
||||
border: '#262626'
|
||||
});
|
||||
const [showAdvanced, setShowAdvanced] = createSignal(false);
|
||||
const [savedSchemes, setSavedSchemes] = createSignal<ColorScheme[]>([]);
|
||||
const [showPreview, setShowPreview] = createSignal(true);
|
||||
|
||||
onMount(() => {
|
||||
// Check current theme
|
||||
const currentTheme = document.documentElement.getAttribute('data-kb-theme');
|
||||
setIsDarkMode(currentTheme === 'dark');
|
||||
|
||||
// Load saved color scheme from localStorage
|
||||
const savedScheme = localStorage.getItem('colorScheme');
|
||||
const savedColors = localStorage.getItem('customColors');
|
||||
|
||||
if (savedColors && savedScheme === 'custom') {
|
||||
try {
|
||||
const colors = JSON.parse(savedColors);
|
||||
setCustomColors(colors);
|
||||
applyCustomColors();
|
||||
} catch (e) {
|
||||
console.error('Failed to load custom colors:', e);
|
||||
}
|
||||
} else if (savedScheme) {
|
||||
setCurrentScheme(savedScheme);
|
||||
}
|
||||
|
||||
// Predefined color schemes with more options
|
||||
setSchemes([
|
||||
{
|
||||
name: 'default',
|
||||
primary: '#5ab9ff',
|
||||
background: isDarkMode() ? '#1a1a1a' : '#ffffff',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#262727' : '#f5f5f5',
|
||||
border: '#262626'
|
||||
},
|
||||
{
|
||||
name: 'ocean',
|
||||
primary: '#0077be',
|
||||
background: isDarkMode() ? '#001f3f' : '#e6f3ff',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#003366' : '#cce7ff',
|
||||
border: '#004080'
|
||||
},
|
||||
{
|
||||
name: 'forest',
|
||||
primary: '#228b22',
|
||||
background: isDarkMode() ? '#0d2818' : '#f0f8f0',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#1a431a' : '#d4edd4',
|
||||
border: '#2d5a2d'
|
||||
},
|
||||
{
|
||||
name: 'sunset',
|
||||
primary: '#ff6b35',
|
||||
background: isDarkMode() ? '#2c1810' : '#fff5f0',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#5c2e00' : '#ffe4d6',
|
||||
border: '#8b4513'
|
||||
},
|
||||
{
|
||||
name: 'purple',
|
||||
primary: '#8b5cf6',
|
||||
background: isDarkMode() ? '#1a0033' : '#f8f5ff',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#330066' : '#ede9fe',
|
||||
border: '#4d0099'
|
||||
},
|
||||
{
|
||||
name: 'rose',
|
||||
primary: '#f43f5e',
|
||||
background: isDarkMode() ? '#2d1111' : '#fff1f2',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#5a1a1a' : '#ffe4e6',
|
||||
border: '#881337'
|
||||
},
|
||||
{
|
||||
name: 'amber',
|
||||
primary: '#f59e0b',
|
||||
background: isDarkMode() ? '#2d1a00' : '#fffbeb',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#5c4a00' : '#fef3c7',
|
||||
border: '#78350f'
|
||||
},
|
||||
{
|
||||
name: 'emerald',
|
||||
primary: '#10b981',
|
||||
background: isDarkMode() ? '#022c22' : '#ecfdf5',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#064e3b' : '#d1fae5',
|
||||
border: '#047857'
|
||||
},
|
||||
{
|
||||
name: 'cyan',
|
||||
primary: '#06b6d4',
|
||||
background: isDarkMode() ? '#022c3a' : '#ecfeff',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#164e63' : '#cffafe',
|
||||
border: '#0891b2'
|
||||
},
|
||||
{
|
||||
name: 'indigo',
|
||||
primary: '#6366f1',
|
||||
background: isDarkMode() ? '#1e1b4b' : '#eef2ff',
|
||||
foreground: isDarkMode() ? '#ffffff' : '#000000',
|
||||
muted: isDarkMode() ? '#312e81' : '#e0e7ff',
|
||||
border: '#4338ca'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
const newDarkMode = !isDarkMode();
|
||||
setIsDarkMode(newDarkMode);
|
||||
|
||||
if (newDarkMode) {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
|
||||
// Update schemes with new theme
|
||||
updateSchemesForTheme(newDarkMode);
|
||||
};
|
||||
|
||||
const updateSchemesForTheme = (dark: boolean) => {
|
||||
setSchemes([
|
||||
{
|
||||
name: 'default',
|
||||
primary: '#5ab9ff',
|
||||
background: dark ? '#1a1a1a' : '#ffffff',
|
||||
foreground: dark ? '#ffffff' : '#000000',
|
||||
muted: dark ? '#262727' : '#f5f5f5',
|
||||
border: '#262626'
|
||||
},
|
||||
{
|
||||
name: 'ocean',
|
||||
primary: '#0077be',
|
||||
background: dark ? '#001f3f' : '#e6f3ff',
|
||||
foreground: dark ? '#ffffff' : '#000000',
|
||||
muted: dark ? '#003366' : '#cce7ff',
|
||||
border: '#004080'
|
||||
},
|
||||
{
|
||||
name: 'forest',
|
||||
primary: '#228b22',
|
||||
background: dark ? '#0d2818' : '#f0f8f0',
|
||||
foreground: dark ? '#ffffff' : '#000000',
|
||||
muted: dark ? '#1a431a' : '#d4edd4',
|
||||
border: '#2d5a2d'
|
||||
},
|
||||
{
|
||||
name: 'sunset',
|
||||
primary: '#ff6b35',
|
||||
background: dark ? '#2c1810' : '#fff5f0',
|
||||
foreground: dark ? '#ffffff' : '#000000',
|
||||
muted: dark ? '#5c2e00' : '#ffe4d6',
|
||||
border: '#8b4513'
|
||||
},
|
||||
{
|
||||
name: 'purple',
|
||||
primary: '#8b5cf6',
|
||||
background: dark ? '#1a0033' : '#f8f5ff',
|
||||
foreground: dark ? '#ffffff' : '#000000',
|
||||
muted: dark ? '#330066' : '#ede9fe',
|
||||
border: '#4d0099'
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const applyScheme = (scheme: ColorScheme) => {
|
||||
setCurrentScheme(scheme.name);
|
||||
setCustomColors(scheme);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem('colorScheme', scheme.name);
|
||||
localStorage.removeItem('customColors'); // Clear custom colors when applying preset
|
||||
|
||||
// Apply colors to CSS variables with proper HSL conversion
|
||||
const root = document.documentElement;
|
||||
|
||||
// Convert hex to HSL for CSS variables
|
||||
const hexToHsl = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return '0 0% 100%';
|
||||
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
};
|
||||
|
||||
// Apply the colors
|
||||
root.style.setProperty('--primary', hexToHsl(scheme.primary));
|
||||
root.style.setProperty('--background', hexToHsl(scheme.background));
|
||||
root.style.setProperty('--foreground', hexToHsl(scheme.foreground));
|
||||
root.style.setProperty('--muted', hexToHsl(scheme.muted));
|
||||
root.style.setProperty('--border', scheme.border);
|
||||
|
||||
// Also set as CSS custom properties for direct use
|
||||
root.style.setProperty('--colors-primary', hexToHsl(scheme.primary));
|
||||
root.style.setProperty('--colors-background', hexToHsl(scheme.background));
|
||||
root.style.setProperty('--colors-foreground', hexToHsl(scheme.foreground));
|
||||
root.style.setProperty('--colors-muted', hexToHsl(scheme.muted));
|
||||
root.style.setProperty('--colors-border', scheme.border);
|
||||
};
|
||||
|
||||
const applyCustomColors = () => {
|
||||
const root = document.documentElement;
|
||||
const colors = customColors();
|
||||
|
||||
// Save custom colors to localStorage
|
||||
localStorage.setItem('colorScheme', 'custom');
|
||||
localStorage.setItem('customColors', JSON.stringify(colors));
|
||||
|
||||
// Convert hex to HSL for CSS variables
|
||||
const hexToHsl = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return '0 0% 100%';
|
||||
|
||||
let r = parseInt(result[1], 16) / 255;
|
||||
let g = parseInt(result[2], 16) / 255;
|
||||
let b = parseInt(result[3], 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
};
|
||||
|
||||
// Apply the colors
|
||||
root.style.setProperty('--primary', hexToHsl(colors.primary));
|
||||
root.style.setProperty('--background', hexToHsl(colors.background));
|
||||
root.style.setProperty('--foreground', hexToHsl(colors.foreground));
|
||||
root.style.setProperty('--muted', hexToHsl(colors.muted));
|
||||
root.style.setProperty('--border', colors.border);
|
||||
|
||||
// Also set as CSS custom properties for direct use
|
||||
root.style.setProperty('--colors-primary', hexToHsl(colors.primary));
|
||||
root.style.setProperty('--colors-background', hexToHsl(colors.background));
|
||||
root.style.setProperty('--colors-foreground', hexToHsl(colors.foreground));
|
||||
root.style.setProperty('--colors-muted', hexToHsl(colors.muted));
|
||||
root.style.setProperty('--colors-border', colors.border);
|
||||
|
||||
setCurrentScheme('custom');
|
||||
};
|
||||
|
||||
const resetColors = () => {
|
||||
const defaultScheme = schemes().find(s => s.name === 'default');
|
||||
if (defaultScheme) {
|
||||
applyScheme(defaultScheme);
|
||||
}
|
||||
};
|
||||
|
||||
// Advanced functions
|
||||
const exportColorScheme = () => {
|
||||
const scheme = currentScheme() === 'custom' ? { ...customColors(), name: 'custom' } : schemes().find(s => s.name === currentScheme());
|
||||
if (scheme) {
|
||||
const data = JSON.stringify(scheme, null, 2);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${scheme.name}-color-scheme.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const importColorScheme = (event: Event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const scheme = JSON.parse(e.target?.result as string) as ColorScheme;
|
||||
if (scheme.name && scheme.primary && scheme.background && scheme.foreground && scheme.muted && scheme.border) {
|
||||
setCustomColors(scheme);
|
||||
applyCustomColors();
|
||||
} else {
|
||||
alert('Invalid color scheme format');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to import color scheme');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCustomScheme = () => {
|
||||
const schemeName = prompt('Enter a name for your custom scheme:');
|
||||
if (schemeName && customColors()) {
|
||||
const newScheme: ColorScheme = {
|
||||
name: schemeName,
|
||||
...customColors()
|
||||
};
|
||||
const updatedSchemes = [...savedSchemes(), newScheme];
|
||||
setSavedSchemes(updatedSchemes);
|
||||
localStorage.setItem('savedSchemes', JSON.stringify(updatedSchemes));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-foreground mb-6 flex items-center gap-2">
|
||||
<IconPalette class="size-8" />
|
||||
Color Switcher
|
||||
</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
{/* Dark Mode Toggle */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4">Theme Mode</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
{isDarkMode() ? (
|
||||
<IconMoon class="size-6 text-primary" />
|
||||
) : (
|
||||
<IconSun class="size-6 text-primary" />
|
||||
)}
|
||||
<div>
|
||||
<h3 class="font-medium text-foreground">
|
||||
{isDarkMode() ? 'Dark Mode' : 'Light Mode'}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Toggle between dark and light theme
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
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-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
|
||||
>
|
||||
{isDarkMode() ? <IconSun class="size-4 text-primary-foreground" /> : <IconMoon class="size-4 text-primary-foreground" />}
|
||||
Switch to {isDarkMode() ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Predefined Schemes */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4">Color Schemes</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{schemes().map((scheme) => (
|
||||
<div
|
||||
class={`border rounded-lg p-4 cursor-pointer transition-all hover:shadow-md ${
|
||||
currentScheme() === scheme.name ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => applyScheme(scheme)}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-medium text-foreground capitalize">{scheme.name}</h3>
|
||||
{currentScheme() === scheme.name && (
|
||||
<IconCheck class="size-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div class="flex gap-1 mb-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded border"
|
||||
style={`background-color: ${scheme.primary}`}
|
||||
title="Primary"
|
||||
/>
|
||||
<div
|
||||
class="w-8 h-8 rounded border"
|
||||
style={`background-color: ${scheme.background}`}
|
||||
title="Background"
|
||||
/>
|
||||
<div
|
||||
class="w-8 h-8 rounded border"
|
||||
style={`background-color: ${scheme.muted}`}
|
||||
title="Muted"
|
||||
/>
|
||||
<div
|
||||
class="w-8 h-8 rounded border"
|
||||
style={`background-color: ${scheme.border}`}
|
||||
title="Border"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Click to apply this scheme
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Colors */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4">Custom Colors</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Primary Color
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors().primary}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, primary: e.currentTarget.value }))}
|
||||
class="h-10 w-16 rounded border border-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColors().primary}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, primary: e.currentTarget.value }))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Background Color
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors().background}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, background: e.currentTarget.value }))}
|
||||
class="h-10 w-16 rounded border border-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColors().background}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, background: e.currentTarget.value }))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Foreground Color
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors().foreground}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, foreground: e.currentTarget.value }))}
|
||||
class="h-10 w-16 rounded border border-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColors().foreground}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, foreground: e.currentTarget.value }))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Muted Color
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors().muted}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, muted: e.currentTarget.value }))}
|
||||
class="h-10 w-16 rounded border border-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColors().muted}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, muted: e.currentTarget.value }))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||
Border Color
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors().border}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, border: e.currentTarget.value }))}
|
||||
class="h-10 w-16 rounded border border-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColors().border}
|
||||
onInput={(e) => setCustomColors(prev => ({ ...prev, border: e.currentTarget.value }))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyCustomColors}
|
||||
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
|
||||
>
|
||||
<IconPalette class="size-4 text-primary-foreground" />
|
||||
Apply Custom Colors
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetColors}
|
||||
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-auto items-center gap-2 py-2 px-4 border"
|
||||
>
|
||||
<IconRepeat class="size-4 text-foreground" />
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-foreground">Advanced Options</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced())}
|
||||
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors 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-auto items-center gap-2 py-1 px-3"
|
||||
>
|
||||
{showAdvanced() ? 'Hide Advanced' : 'Show Advanced'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={showAdvanced()}>
|
||||
<div class="space-y-4">
|
||||
{/* Export/Import */}
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportColorScheme}
|
||||
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
|
||||
>
|
||||
<IconDownload class="size-4 text-primary-foreground" />
|
||||
Export Scheme
|
||||
</button>
|
||||
<label class="inline-flex justify-center rounded-md text-sm font-medium transition-colors 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-auto items-center gap-2 py-2 px-4 border cursor-pointer">
|
||||
<IconUpload class="size-4 text-foreground" />
|
||||
Import Scheme
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={importColorScheme}
|
||||
class="hidden"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveCustomScheme}
|
||||
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors 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-auto items-center gap-2 py-2 px-4 border"
|
||||
>
|
||||
Save Custom Scheme
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview Toggle */}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-foreground">Show Preview Panel</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview())}
|
||||
class="inline-flex justify-center rounded-md text-sm font-medium transition-colors 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-auto items-center gap-2 py-1 px-3"
|
||||
>
|
||||
{showPreview() ? <IconEye class="size-4 text-foreground" /> : <IconEyeOff class="size-4 text-foreground" />}
|
||||
{showPreview() ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<Show when={showPreview()}>
|
||||
<div class="border rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4">Preview</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-muted rounded-lg">
|
||||
<h3 class="font-medium text-foreground mb-2">Sample Content</h3>
|
||||
<p class="text-muted-foreground mb-3">
|
||||
This is how your content will look with the selected colors.
|
||||
</p>
|
||||
<button class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4">
|
||||
Sample Button
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-medium text-foreground mb-2">Border Example</h3>
|
||||
<p class="text-muted-foreground">
|
||||
This shows how borders will appear with your color scheme.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,255 +1,510 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { FileUploadModal } from '@/components/ui/FileUploadModal';
|
||||
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
||||
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
|
||||
import { getMockFiles } from '@/lib/mockData';
|
||||
import {
|
||||
IconSearch,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
IconEye,
|
||||
IconTrash,
|
||||
IconCalendar,
|
||||
IconLoader2,
|
||||
IconUpload
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { filesApi, type FileItem } from '@/lib/api-client'
|
||||
IconDownload,
|
||||
IconCopy,
|
||||
IconShare
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
const fileIcons = {
|
||||
'document': '📄',
|
||||
'image': '🖼️',
|
||||
'video': '🎥',
|
||||
'audio': '🎵',
|
||||
'archive': '📦',
|
||||
'other': '📁'
|
||||
interface FileItem {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
uploadedAt: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
associations?: Association[];
|
||||
url?: string;
|
||||
isLink?: boolean;
|
||||
preview?: string;
|
||||
downloadUrl?: string;
|
||||
viewUrl?: string;
|
||||
shareUrl?: string;
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
interface Association {
|
||||
id: string;
|
||||
type: 'task' | 'bookmark' | 'note' | 'project';
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function Files() {
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
|
||||
const filesQuery = filesApi.useGetAll()
|
||||
const deleteFileMutation = filesApi.useDelete()
|
||||
const uploadFileMutation = filesApi.useUpload()
|
||||
export const Files = () => {
|
||||
const [files, setFiles] = createSignal<FileItem[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [selectedTags, setSelectedTags] = createSignal<string[]>([]);
|
||||
const [showUploadModal, setShowUploadModal] = createSignal(false);
|
||||
const [showPreviewModal, setShowPreviewModal] = createSignal(false);
|
||||
const [selectedFile, setSelectedFile] = createSignal<FileItem | null>(null);
|
||||
const [copiedLink, setCopiedLink] = createSignal(false);
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
// Use mock data in demo mode
|
||||
const mockFiles = getMockFiles();
|
||||
const mappedFiles: FileItem[] = mockFiles.map(file => ({
|
||||
id: parseInt(file.id),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
uploadedAt: file.uploadedAt,
|
||||
description: file.description,
|
||||
tags: file.tags.map(tag => tag.name),
|
||||
associations: file.associations?.map(assoc => ({
|
||||
id: assoc.id,
|
||||
type: assoc.type as 'task' | 'bookmark' | 'note' | 'project',
|
||||
title: assoc.title
|
||||
})),
|
||||
url: file.url,
|
||||
isLink: file.isLink,
|
||||
preview: file.preview,
|
||||
downloadUrl: file.downloadUrl,
|
||||
viewUrl: file.viewUrl,
|
||||
shareUrl: file.shareUrl
|
||||
}));
|
||||
setFiles(mappedFiles);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call
|
||||
// const response = await fetch('/api/v1/files');
|
||||
// const data = await response.json();
|
||||
|
||||
// Mock data for now
|
||||
setFiles([
|
||||
{
|
||||
id: 1,
|
||||
name: 'project-plan.pdf',
|
||||
size: 2048576,
|
||||
type: 'application/pdf',
|
||||
uploadedAt: '2024-01-15T10:30:00Z',
|
||||
description: 'Q1 2024 project roadmap and milestones',
|
||||
tags: ['planning', 'q1-2024'],
|
||||
downloadUrl: '/files/download/1',
|
||||
viewUrl: '/files/view/1',
|
||||
shareUrl: '/files/share/1'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'meeting-notes.docx',
|
||||
size: 524288,
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
uploadedAt: '2024-01-14T15:45:00Z',
|
||||
description: 'Team sync meeting notes',
|
||||
tags: ['meetings', 'team'],
|
||||
downloadUrl: '/files/download/2',
|
||||
viewUrl: '/files/view/2',
|
||||
shareUrl: '/files/share/2'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'screenshot.png',
|
||||
size: 1024000,
|
||||
type: 'image/png',
|
||||
uploadedAt: '2024-01-13T09:20:00Z',
|
||||
description: 'UI design mockup',
|
||||
tags: ['design', 'ui'],
|
||||
preview: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
associations: [
|
||||
{ id: '1', type: 'project', title: 'Website Redesign' },
|
||||
{ id: '2', type: 'task', title: 'Create mockups' }
|
||||
],
|
||||
downloadUrl: '/files/download/3',
|
||||
viewUrl: '/files/view/3',
|
||||
shareUrl: '/files/share/3'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'app.js',
|
||||
size: 256000,
|
||||
type: 'text/javascript',
|
||||
uploadedAt: '2024-01-12T14:15:00Z',
|
||||
description: 'Main application logic',
|
||||
tags: ['javascript', 'frontend'],
|
||||
preview: 'console.log("Hello World");\n\nfunction main() {\n // Main application logic\n return true;\n}',
|
||||
associations: [
|
||||
{ id: '3', type: 'project', title: 'Frontend App' }
|
||||
],
|
||||
downloadUrl: '/files/download/4',
|
||||
viewUrl: '/files/view/4',
|
||||
shareUrl: '/files/share/4'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'database.sql',
|
||||
size: 512000,
|
||||
type: 'application/sql',
|
||||
uploadedAt: '2024-01-11T11:30:00Z',
|
||||
description: 'Database schema',
|
||||
tags: ['database', 'sql'],
|
||||
preview: 'CREATE TABLE users (\n id INT PRIMARY KEY,\n name VARCHAR(255) NOT NULL\n);',
|
||||
associations: [
|
||||
{ id: '4', type: 'project', title: 'Backend API' }
|
||||
],
|
||||
downloadUrl: '/files/download/5',
|
||||
viewUrl: '/files/view/5',
|
||||
shareUrl: '/files/share/5'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'presentation.pptx',
|
||||
size: 3072000,
|
||||
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
uploadedAt: '2024-01-10T16:45:00Z',
|
||||
description: 'Q4 review presentation',
|
||||
tags: ['presentation', 'q4'],
|
||||
downloadUrl: '/files/download/6',
|
||||
viewUrl: '/files/view/6',
|
||||
shareUrl: '/files/share/6'
|
||||
}
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load files:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredFiles = () => {
|
||||
const query = searchQuery().toLowerCase()
|
||||
if (!query) return filesQuery.data || []
|
||||
const term = searchTerm().toLowerCase();
|
||||
const tags = selectedTags();
|
||||
|
||||
return (filesQuery.data || []).filter(file =>
|
||||
file.original_name.toLowerCase().includes(query) ||
|
||||
file.mime_type.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
return files().filter(file => {
|
||||
const matchesSearch = file.name.toLowerCase().includes(term) ||
|
||||
file.description?.toLowerCase().includes(term) ||
|
||||
file.tags.some(tag => tag.toLowerCase().includes(term));
|
||||
|
||||
const matchesTags = tags.length === 0 ||
|
||||
tags.every(tag => file.tags.includes(tag));
|
||||
|
||||
return matchesSearch && matchesTags;
|
||||
});
|
||||
};
|
||||
|
||||
const getFileType = (mimeType: string): string => {
|
||||
if (mimeType.startsWith('image/')) return 'image'
|
||||
if (mimeType.startsWith('video/')) return 'video'
|
||||
if (mimeType.startsWith('audio/')) return 'audio'
|
||||
if (mimeType.includes('document') || mimeType.includes('pdf') || mimeType.includes('text')) return 'document'
|
||||
if (mimeType.includes('zip') || mimeType.includes('archive')) return 'archive'
|
||||
return 'other'
|
||||
}
|
||||
const allTags = () => {
|
||||
const tagSet = new Set<string>();
|
||||
files().forEach(file => {
|
||||
file.tags.forEach(tag => tagSet.add(tag));
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
};
|
||||
|
||||
const handleFileUpload = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
await uploadFileMutation.mutateAsync(file)
|
||||
target.value = '' // Reset input
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
alert('Failed to upload file')
|
||||
const toggleTag = (tag: string) => {
|
||||
const currentTags = selectedTags();
|
||||
if (currentTags.includes(tag)) {
|
||||
setSelectedTags([]);
|
||||
} else {
|
||||
setSelectedTags([tag]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (fileId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this file?')) return
|
||||
|
||||
|
||||
const handleFileUpload = async (fileData: any) => {
|
||||
try {
|
||||
await deleteFileMutation.mutateAsync(fileId)
|
||||
// Mock upload - in real app, this would be an API call
|
||||
const newFile: FileItem = {
|
||||
id: Date.now(),
|
||||
name: fileData.file?.name || fileData.linkUrl?.split('/').pop() || 'Untitled',
|
||||
size: fileData.file?.size || 0,
|
||||
type: fileData.file?.type || 'application/octet-stream',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
description: fileData.description,
|
||||
tags: fileData.tags,
|
||||
associations: fileData.associations,
|
||||
url: fileData.linkUrl,
|
||||
isLink: fileData.isLinkMode,
|
||||
downloadUrl: fileData.isLinkMode ? fileData.linkUrl : `/files/download/${Date.now()}`,
|
||||
viewUrl: fileData.isLinkMode ? fileData.linkUrl : `/files/view/${Date.now()}`,
|
||||
shareUrl: `/files/share/${Date.now()}`
|
||||
};
|
||||
|
||||
setFiles(prev => [newFile, ...prev]);
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error)
|
||||
alert('Failed to delete file')
|
||||
console.error('Failed to upload file:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewFile = (file: FileItem) => {
|
||||
setSelectedFile(file);
|
||||
setShowPreviewModal(true);
|
||||
};
|
||||
|
||||
const handleCopyLink = async (file: FileItem) => {
|
||||
try {
|
||||
const link = file.isLink ? file.url : file.shareUrl || '#';
|
||||
if (link) {
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopiedLink(true);
|
||||
setTimeout(() => setCopiedLink(false), 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShareFile = (file: FileItem) => {
|
||||
// In a real app, this would open a share dialog or generate a shareable link
|
||||
const shareUrl = file.shareUrl || '#';
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: file.name,
|
||||
text: file.description,
|
||||
url: shareUrl
|
||||
});
|
||||
} else {
|
||||
window.open(shareUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = (file: FileItem) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = `http://localhost:8080/api/v1/files/${file.id}/download`
|
||||
link.download = file.original_name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
if (file.isLink && file.url) {
|
||||
window.open(file.url, '_blank');
|
||||
} else if (file.downloadUrl) {
|
||||
// In a real app, this would trigger an actual download
|
||||
const link = document.createElement('a');
|
||||
link.href = file.downloadUrl;
|
||||
link.download = file.name;
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const deleteFile = async (fileId: number) => {
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
// await fetch(`/api/v1/files/${fileId}`, { method: 'DELETE' });
|
||||
|
||||
setFiles(prev => prev.filter(file => file.id !== fileId));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">Files</h1>
|
||||
<p class="text-gray-400 mt-2">Store and manage your documents and media</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
class="hidden"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploadFileMutation.isPending}
|
||||
/>
|
||||
<label for="file-upload">
|
||||
<Button
|
||||
disabled={uploadFileMutation.isPending}
|
||||
class="cursor-pointer"
|
||||
onClick={() => document.getElementById('file-upload')?.click()}
|
||||
>
|
||||
{uploadFileMutation.isPending ? (
|
||||
<>
|
||||
<IconLoader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconUpload class="mr-2 h-4 w-4" />
|
||||
Upload File
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-foreground">Files</h1>
|
||||
<Button onClick={() => setShowUploadModal(true)}>
|
||||
<IconUpload class="size-4 mr-2" />
|
||||
Upload File
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
<Show when={filesQuery.error}>
|
||||
<div class="bg-red-900 border border-red-700 text-red-200 px-4 py-3 rounded">
|
||||
Failed to load files: {filesQuery.error?.message}
|
||||
<SearchTagFilterBar
|
||||
searchPlaceholder="Search files..."
|
||||
searchValue={searchTerm()}
|
||||
onSearchChange={(value) => setSearchTerm(value)}
|
||||
tagOptions={allTags()}
|
||||
selectedTag={selectedTags()[0] || ''}
|
||||
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
|
||||
onReset={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Show when={copiedLink()}>
|
||||
<div class="bg-primary/15 text-primary px-3 py-1 rounded-md text-sm">
|
||||
Link copied!
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="relative flex-1">
|
||||
<IconSearch class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search files..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
|
||||
class="pl-10 bg-gray-800 border-gray-700 text-white placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
All Types
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
All Tags
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconCalendar class="mr-2 h-4 w-4" />
|
||||
Recent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={filesQuery.isLoading}>
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<IconLoader2 class="h-8 w-8 animate-spin text-blue-400" />
|
||||
<span class="ml-2 text-gray-400">Loading files...</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Files Grid */}
|
||||
<Show when={!filesQuery.isLoading && !filesQuery.error}>
|
||||
{isLoading() ? (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={filteredFiles()}>
|
||||
{(file) => (
|
||||
<Card class="hover:shadow-lg transition-shadow">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">
|
||||
{fileIcons[getFileType(file.mime_type) as keyof typeof fileIcons] || fileIcons.other}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-lg text-white truncate">
|
||||
{file.original_name}
|
||||
</CardTitle>
|
||||
<CardDescription class="text-xs text-gray-400">
|
||||
{formatFileSize(file.file_size)} • {getFileType(file.mime_type).toUpperCase()}
|
||||
</CardDescription>
|
||||
{[...Array(6)].map(() => (
|
||||
<Card class="p-6">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-12 bg-[#262626] rounded mb-4"></div>
|
||||
<div class="h-4 bg-[#262626] rounded mb-2"></div>
|
||||
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={filteredFiles()}>
|
||||
{(file) => {
|
||||
const fileTypeConfig = getFileTypeConfig(file.type, file.name);
|
||||
const IconComponent = fileTypeConfig.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
class="p-6 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => handlePreviewFile(file)}
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class={`text-3xl ${fileTypeConfig.color}`}>
|
||||
<IconComponent size={32} />
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewFile(file);
|
||||
}}
|
||||
class="text-foreground hover:text-foreground/80 p-1"
|
||||
>
|
||||
<IconEye size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyLink(file);
|
||||
}}
|
||||
class="text-foreground hover:text-foreground/80 p-1"
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleShareFile(file);
|
||||
}}
|
||||
class="text-foreground hover:text-foreground/80 p-1"
|
||||
>
|
||||
<IconShare size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteFile(file.id);
|
||||
}}
|
||||
class="text-destructive hover:text-destructive/80 p-1"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
{file.mime_type && (
|
||||
<p class="text-sm text-gray-300 mb-3">
|
||||
{file.mime_type}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-700">
|
||||
<span class="text-xs text-gray-400">
|
||||
{new Date(file.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<div class="flex space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-gray-400 hover:text-white"
|
||||
onClick={() => handleDownloadFile(file)}
|
||||
>
|
||||
<IconDownload class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-gray-400 hover:text-red-400"
|
||||
onClick={() => handleDeleteFile(file.id)}
|
||||
>
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div class="mb-2">
|
||||
<span class={`inline-block px-2 py-1 text-xs rounded-full ${getFileCategoryColor(fileTypeConfig.category)}`}>
|
||||
{fileTypeConfig.displayName}
|
||||
</span>
|
||||
{file.isLink && (
|
||||
<span class="ml-2 inline-block px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Link
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={filteredFiles().length === 0}>
|
||||
<div class="text-center py-12">
|
||||
<div class="mx-auto h-12 w-12 text-gray-400 mb-4 flex items-center justify-center text-2xl">📁</div>
|
||||
<h3 class="text-lg font-medium text-white mb-2">No files found</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
{searchQuery() ? 'Try adjusting your search terms' : 'Upload your first file to get started'}
|
||||
</p>
|
||||
<label for="file-upload">
|
||||
<Button
|
||||
disabled={uploadFileMutation.isPending}
|
||||
class="cursor-pointer"
|
||||
onClick={() => document.getElementById('file-upload')?.click()}
|
||||
>
|
||||
<IconUpload class="mr-2 h-4 w-4" />
|
||||
Upload File
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
<h3 class="text-lg font-semibold text-foreground mb-1 truncate">
|
||||
{file.name}
|
||||
</h3>
|
||||
|
||||
<p class="text-muted-foreground text-sm mb-2">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
|
||||
{file.description && (
|
||||
<p class="text-foreground text-sm mb-3 line-clamp-2">
|
||||
{file.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
<For each={file.tags}>
|
||||
{(tag) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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"
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Associations */}
|
||||
<Show when={file.associations && file.associations.length > 0}>
|
||||
<div class="mb-3">
|
||||
<p class="text-xs text-muted-foreground mb-1">Linked to:</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={file.associations}>
|
||||
{(assoc) => (
|
||||
<span class="px-2 py-1 bg-primary/10 text-primary text-xs rounded-md">
|
||||
{assoc.type}: {assoc.title}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<span>{new Date(file.uploadedAt).toLocaleDateString()}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-foreground hover:text-foreground/80 p-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownloadFile(file);
|
||||
}}
|
||||
>
|
||||
<IconDownload size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{filteredFiles().length === 0 && (
|
||||
<Card class="p-12 text-center">
|
||||
<p class="text-muted-foreground">
|
||||
{searchTerm() || selectedTags().length > 0
|
||||
? 'No files found matching your search or filters.'
|
||||
: 'No files uploaded yet. Upload your first file!'}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File Upload Modal */}
|
||||
<FileUploadModal
|
||||
isOpen={showUploadModal()}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onUpload={handleFileUpload}
|
||||
/>
|
||||
|
||||
{/* File Preview Modal */}
|
||||
<FilePreviewModal
|
||||
isOpen={showPreviewModal()}
|
||||
onClose={() => setShowPreviewModal(false)}
|
||||
file={selectedFile()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { GitHubActivity } from '@/components/ui/GitHubActivity';
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconTrendingUp,
|
||||
IconFolder,
|
||||
IconStar,
|
||||
IconGitFork,
|
||||
IconEye,
|
||||
IconExternalLink,
|
||||
IconRefresh,
|
||||
IconActivity
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
interface GitHubRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string;
|
||||
html_url: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
watchers_count: number;
|
||||
language: string;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
size: number;
|
||||
open_issues_count: number;
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
interface GitHubStats {
|
||||
totalRepos: number;
|
||||
totalStars: number;
|
||||
totalForks: number;
|
||||
totalWatchers: number;
|
||||
languages: Array<{
|
||||
name: string;
|
||||
count: number;
|
||||
color: string;
|
||||
}>;
|
||||
recentActivity: Array<{
|
||||
type: string;
|
||||
repo: string;
|
||||
date: string;
|
||||
message: string;
|
||||
}>;
|
||||
repos: GitHubRepo[];
|
||||
}
|
||||
|
||||
export const GitHub = () => {
|
||||
const [githubStats, setGithubStats] = createSignal<GitHubStats>({
|
||||
totalRepos: 0,
|
||||
totalStars: 0,
|
||||
totalForks: 0,
|
||||
totalWatchers: 0,
|
||||
languages: [],
|
||||
recentActivity: [],
|
||||
repos: []
|
||||
});
|
||||
|
||||
const [weeklyActivity, setWeeklyActivity] = createSignal([0, 0, 0, 0, 0, 0, 0]);
|
||||
|
||||
const [username, setUsername] = createSignal('');
|
||||
const [isConnected, setIsConnected] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
// Check if user is authenticated and has GitHub connected
|
||||
checkGitHubConnection();
|
||||
});
|
||||
|
||||
const checkGitHubConnection = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
loadMockData();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
if (userData.user.github_id) {
|
||||
setIsConnected(true);
|
||||
setUsername(userData.user.username);
|
||||
await fetchGitHubStats();
|
||||
} else {
|
||||
loadMockData();
|
||||
}
|
||||
} else {
|
||||
loadMockData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check GitHub connection:', error);
|
||||
loadMockData();
|
||||
}
|
||||
};
|
||||
const fetchGitHubStats = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('No authentication token');
|
||||
}
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/github/repos`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch GitHub stats');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const repos = data.repos || [];
|
||||
|
||||
// Process real GitHub data
|
||||
const languages = processLanguages(repos);
|
||||
const recentActivity = generateRecentActivity(repos);
|
||||
|
||||
const totalStars = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.stargazers_count, 0);
|
||||
const totalForks = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.forks_count, 0);
|
||||
const totalWatchers = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.watchers_count, 0);
|
||||
|
||||
setGithubStats({
|
||||
totalRepos: repos.length,
|
||||
totalStars,
|
||||
totalForks,
|
||||
totalWatchers,
|
||||
languages,
|
||||
recentActivity,
|
||||
repos
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub stats:', error);
|
||||
// Fallback to mock data
|
||||
loadMockData();
|
||||
}
|
||||
};
|
||||
|
||||
const processLanguages = (repos: GitHubRepo[]) => {
|
||||
const languageMap = new Map<string, number>();
|
||||
|
||||
repos.forEach(repo => {
|
||||
if (repo.language) {
|
||||
languageMap.set(repo.language, (languageMap.get(repo.language) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(languageMap.entries()).map(([name, count]) => ({
|
||||
name,
|
||||
count,
|
||||
color: getLanguageColor()
|
||||
}));
|
||||
};
|
||||
|
||||
const generateRecentActivity = (repos: GitHubRepo[]) => {
|
||||
// Sort repos by updated_at and take recent ones
|
||||
const sortedRepos = repos
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
return sortedRepos.map(repo => ({
|
||||
type: 'push',
|
||||
repo: repo.name,
|
||||
date: formatDate(repo.updated_at),
|
||||
message: `Updated ${repo.name}`
|
||||
}));
|
||||
};
|
||||
|
||||
const loadMockData = () => {
|
||||
// Load mock data for demonstration
|
||||
const mockRepos: GitHubRepo[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'trackeep',
|
||||
full_name: 'demo/trackeep',
|
||||
description: 'A comprehensive productivity and bookmark management system',
|
||||
html_url: 'https://github.com/demo/trackeep',
|
||||
stargazers_count: 156,
|
||||
forks_count: 42,
|
||||
watchers_count: 28,
|
||||
language: 'TypeScript',
|
||||
updated_at: '2024-01-28T10:30:00Z',
|
||||
created_at: '2023-06-15T14:20:00Z',
|
||||
size: 2456,
|
||||
open_issues_count: 3,
|
||||
default_branch: 'main'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'solid-components',
|
||||
full_name: 'demo/solid-components',
|
||||
description: 'Reusable SolidJS components for modern web applications',
|
||||
html_url: 'https://github.com/demo/solid-components',
|
||||
stargazers_count: 89,
|
||||
forks_count: 23,
|
||||
watchers_count: 15,
|
||||
language: 'TypeScript',
|
||||
updated_at: '2024-01-27T16:45:00Z',
|
||||
created_at: '2023-08-22T09:15:00Z',
|
||||
size: 1234,
|
||||
open_issues_count: 1,
|
||||
default_branch: 'main'
|
||||
}
|
||||
];
|
||||
|
||||
const languages = [
|
||||
{ name: 'TypeScript', count: 2, color: '#3178c6' },
|
||||
{ name: 'Go', count: 1, color: '#00ADD8' }
|
||||
];
|
||||
|
||||
const recentActivity = [
|
||||
{
|
||||
type: 'push',
|
||||
repo: 'trackeep',
|
||||
date: '2024-01-28',
|
||||
message: 'feat: add GitHub integration'
|
||||
}
|
||||
];
|
||||
|
||||
// Generate mock weekly activity data
|
||||
const mockWeeklyActivity = [
|
||||
Math.floor(Math.random() * 20) + 5, // Monday
|
||||
Math.floor(Math.random() * 25) + 8, // Tuesday
|
||||
Math.floor(Math.random() * 22) + 6, // Wednesday
|
||||
Math.floor(Math.random() * 18) + 4, // Thursday
|
||||
Math.floor(Math.random() * 15) + 3, // Friday
|
||||
Math.floor(Math.random() * 12) + 2, // Saturday
|
||||
Math.floor(Math.random() * 10) + 1 // Sunday
|
||||
];
|
||||
|
||||
setWeeklyActivity(mockWeeklyActivity);
|
||||
|
||||
setGithubStats({
|
||||
totalRepos: mockRepos.length,
|
||||
totalStars: mockRepos.reduce((sum, repo) => sum + repo.stargazers_count, 0),
|
||||
totalForks: mockRepos.reduce((sum, repo) => sum + repo.forks_count, 0),
|
||||
totalWatchers: mockRepos.reduce((sum, repo) => sum + repo.watchers_count, 0),
|
||||
languages,
|
||||
recentActivity,
|
||||
repos: mockRepos
|
||||
});
|
||||
};
|
||||
|
||||
const connectGitHub = () => {
|
||||
// Redirect to centralized OAuth service
|
||||
window.location.href = 'https://oauth.tdvorak.dev/auth/github?redirect_uri=' + encodeURIComponent(window.location.origin + '/api/v1/auth/oauth/callback');
|
||||
};
|
||||
|
||||
const disconnectGitHub = async () => {
|
||||
try {
|
||||
// In a real implementation, you might want to disconnect the GitHub account
|
||||
// For now, we'll just clear the local state
|
||||
setIsConnected(false);
|
||||
setUsername('');
|
||||
loadMockData();
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect GitHub:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const getLanguageColor = () => {
|
||||
// Use primary color for all languages instead of language-specific colors
|
||||
return 'hsl(var(--primary))';
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6 overflow-x-hidden max-w-full">
|
||||
{/* Header */}
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">GitHub Integration</h1>
|
||||
<p class="text-muted-foreground mt-2">Track your GitHub repositories and activity</p>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
{isConnected() ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchGitHubStats()}>
|
||||
<IconRefresh class="size-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={disconnectGitHub}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={connectGitHub}>
|
||||
<IconBrandGithub class="size-4 mr-2" />
|
||||
Connect GitHub
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
{isConnected() && (
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-2 rounded-lg">
|
||||
<IconBrandGithub class="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Connected as @{username()}</p>
|
||||
<p class="text-xs text-muted-foreground">Syncing data from GitHub API</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Overview - 2-column layout with larger left column */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Column - Main Stats */}
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
||||
<IconFolder class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-foreground">{githubStats().totalRepos}</p>
|
||||
<p class="text-sm text-muted-foreground">Repositories</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
||||
<IconStar class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-foreground">{githubStats().totalStars}</p>
|
||||
<p class="text-sm text-muted-foreground">Total Stars</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
||||
<IconGitFork class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-foreground">{githubStats().totalForks}</p>
|
||||
<p class="text-sm text-muted-foreground">Total Forks</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
||||
<IconEye class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-foreground">{githubStats().totalWatchers}</p>
|
||||
<p class="text-sm text-muted-foreground">Watchers</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Additional Stats */}
|
||||
<div class="space-y-4">
|
||||
{/* Additional GitHub stats can go here */}
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
||||
<IconActivity class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-foreground">{weeklyActivity().reduce((a, b) => a + b, 0)}</p>
|
||||
<p class="text-sm text-muted-foreground">Weekly Activity</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-way Grid: Contribution Graph and Languages - Responsive */}
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Contribution Graph - Left Column (2/3 width on large screens) */}
|
||||
<div class="xl:w-2/3">
|
||||
<GitHubActivity
|
||||
title="Contribution Activity"
|
||||
showStats={false}
|
||||
showContributionGraph={true}
|
||||
showRecentActivity={false}
|
||||
compact={true}
|
||||
period="year"
|
||||
fullWidth={true}
|
||||
hideHeader={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Languages - Right Column (1/3 width on large screens) */}
|
||||
<Card class="p-6 xl:w-1/3">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Languages</h3>
|
||||
<div class="space-y-3">
|
||||
{githubStats().languages.map((language) => (
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={`background-color: ${language.color}`}
|
||||
></div>
|
||||
<span class="text-sm text-foreground truncate">{language.name}</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground flex-shrink-0">{language.count} repos</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Weekly Activity Chart */}
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<IconActivity class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold text-foreground">Weekly Activity</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivityData = weeklyActivity() || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
|
||||
const activity = weeklyActivityData[index];
|
||||
const maxActivity = Math.max(...weeklyActivityData);
|
||||
// Use dynamic scale based on actual data
|
||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
||||
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5">
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
|
||||
title={`${day}: ${activity} contributions`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
<span>Total: {weeklyActivity().reduce((a, b) => a + b, 0)} contributions</span>
|
||||
<span>Avg: {Math.round(weeklyActivity().reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3>
|
||||
<div class="space-y-3">
|
||||
{githubStats().recentActivity.map((activity) => (
|
||||
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-2 rounded-lg">
|
||||
<IconTrendingUp class="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-foreground">{activity.message}</p>
|
||||
<p class="text-xs text-muted-foreground">{activity.repo} • {activity.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground capitalize">{activity.type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Repositories */}
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Repositories</h3>
|
||||
<div class="space-y-4">
|
||||
{githubStats().repos.map((repo) => (
|
||||
<div class="border border-border rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="text-lg font-medium text-foreground">{repo.name}</h4>
|
||||
{repo.language && (
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
style={`background-color: ${getLanguageColor()}20; color: ${getLanguageColor()}`}
|
||||
>
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-3">{repo.description}</p>
|
||||
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<IconStar class="size-3" />
|
||||
<span>{repo.stargazers_count}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconGitFork class="size-3" />
|
||||
<span>{repo.forks_count}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconEye class="size-3" />
|
||||
<span>{repo.watchers_count}</span>
|
||||
</div>
|
||||
<span>Updated {formatDate(repo.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
<IconExternalLink class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,496 @@
|
||||
import { createSignal, onMount, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { LearningPathPreviewModal } from '@/components/ui/LearningPathPreviewModal';
|
||||
import { getMockLearningPaths } from '@/lib/mockData';
|
||||
import {
|
||||
IconClock,
|
||||
IconUsers,
|
||||
IconStar,
|
||||
IconFilter,
|
||||
IconSearch,
|
||||
IconAlertCircle,
|
||||
IconCode,
|
||||
IconCloud,
|
||||
IconPalette,
|
||||
IconBriefcase,
|
||||
IconCamera,
|
||||
IconMusic,
|
||||
IconWriting,
|
||||
IconLanguage,
|
||||
IconDeviceLaptop,
|
||||
IconShield,
|
||||
IconBrain,
|
||||
IconBook
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
interface LearningPath {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
difficulty: string;
|
||||
duration: string;
|
||||
thumbnail: string;
|
||||
is_featured: boolean;
|
||||
enrollment_count: number;
|
||||
rating: number;
|
||||
review_count: number;
|
||||
creator: {
|
||||
username: string;
|
||||
full_name: string;
|
||||
};
|
||||
tags: Array<{
|
||||
name: string;
|
||||
color: string;
|
||||
}>;
|
||||
modules?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
resources: Array<{
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
}>;
|
||||
createdAt?: string;
|
||||
enrolledAt?: string;
|
||||
}
|
||||
|
||||
export const LearningPaths = () => {
|
||||
const [learningPaths, setLearningPaths] = createSignal<LearningPath[]>([]);
|
||||
const [categories, setCategories] = createSignal<string[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [selectedCategory, setSelectedCategory] = createSignal('');
|
||||
const [selectedDifficulty, setSelectedDifficulty] = createSignal('');
|
||||
const [successMessage, setSuccessMessage] = createSignal('');
|
||||
const [errorMessage, setErrorMessage] = createSignal('');
|
||||
const [enrolledPaths, setEnrolledPaths] = createSignal<Set<number>>(new Set());
|
||||
const [isPreviewOpen, setIsPreviewOpen] = createSignal(false);
|
||||
const [selectedPath, setSelectedPath] = createSignal<LearningPath | null>(null);
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
// Use mock data in demo mode
|
||||
const mockLearningPaths = getMockLearningPaths();
|
||||
const mappedPaths: LearningPath[] = mockLearningPaths.map((path, index) => ({
|
||||
id: index + 1,
|
||||
title: path.title,
|
||||
description: path.description,
|
||||
category: path.category,
|
||||
difficulty: path.difficulty,
|
||||
duration: path.estimatedTime,
|
||||
thumbnail: `https://picsum.photos/seed/${path.category.replace(/\s+/g, '-').toLowerCase()}-${index}/400/200.jpg`,
|
||||
is_featured: index < 2, // Make first 2 paths featured
|
||||
enrollment_count: Math.floor(Math.random() * 2000) + 200,
|
||||
rating: 4.0 + Math.random() * 1.0,
|
||||
review_count: Math.floor(Math.random() * 200) + 20,
|
||||
creator: {
|
||||
username: 'instructor',
|
||||
full_name: 'Expert Instructor'
|
||||
},
|
||||
tags: path.tags,
|
||||
modules: path.modules,
|
||||
createdAt: path.createdAt,
|
||||
enrolledAt: path.enrolledAt
|
||||
}));
|
||||
|
||||
setLearningPaths(mappedPaths);
|
||||
|
||||
// Extract unique categories from mock data
|
||||
const uniqueCategories = [...new Set(mockLearningPaths.map(path => path.category))];
|
||||
setCategories(uniqueCategories);
|
||||
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch categories
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const categoriesResponse = await fetch(`${API_BASE_URL}/learning-paths/categories`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (categoriesResponse.ok) {
|
||||
const categoriesData = await categoriesResponse.json();
|
||||
setCategories(categoriesData.categories || []);
|
||||
}
|
||||
|
||||
// Fetch learning paths
|
||||
const params = new URLSearchParams();
|
||||
if (searchTerm()) params.append('search', searchTerm());
|
||||
if (selectedCategory()) params.append('category', selectedCategory());
|
||||
if (selectedDifficulty()) params.append('difficulty', selectedDifficulty());
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/learning-paths?${params}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setLearningPaths(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load learning paths:', error);
|
||||
setErrorMessage('Failed to load learning paths. Please try again.');
|
||||
setTimeout(() => setErrorMessage(''), 3000);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(fetchData);
|
||||
|
||||
const handleSearch = () => {
|
||||
// Refetch with search parameters
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const getDifficultyColor = (_difficulty: string) => {
|
||||
// Use single main project color (blue) for all difficulties
|
||||
return 'bg-blue-500/20 text-blue-400 border border-blue-500/30';
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'programming':
|
||||
case 'web development':
|
||||
return <IconCode class="size-4" />;
|
||||
case 'mobile development':
|
||||
return <IconDeviceLaptop class="size-4" />;
|
||||
case 'data science':
|
||||
case 'machine learning':
|
||||
return <IconBrain class="size-4" />;
|
||||
case 'cybersecurity':
|
||||
return <IconShield class="size-4" />;
|
||||
case 'devops':
|
||||
return <IconCloud class="size-4" />;
|
||||
case 'design':
|
||||
return <IconPalette class="size-4" />;
|
||||
case 'business':
|
||||
return <IconBriefcase class="size-4" />;
|
||||
case 'marketing':
|
||||
return <IconBriefcase class="size-4" />;
|
||||
case 'photography':
|
||||
return <IconCamera class="size-4" />;
|
||||
case 'music':
|
||||
return <IconMusic class="size-4" />;
|
||||
case 'writing':
|
||||
return <IconWriting class="size-4" />;
|
||||
case 'languages':
|
||||
return <IconLanguage class="size-4" />;
|
||||
default:
|
||||
return <IconBook class="size-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (_category: string) => {
|
||||
// Use single main project color (blue) for all categories
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
};
|
||||
|
||||
const handleEnroll = async (pathId: number) => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
// Simulate enrollment in demo mode
|
||||
setEnrolledPaths(prev => new Set(prev).add(pathId));
|
||||
setSuccessMessage('Successfully enrolled in learning path!');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/learning-paths/${pathId}/enroll`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setEnrolledPaths(prev => new Set(prev).add(pathId));
|
||||
setSuccessMessage('Successfully enrolled in learning path!');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
} else {
|
||||
throw new Error('Failed to enroll');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error enrolling in learning path:', error);
|
||||
setErrorMessage('Failed to enroll. Please try again.');
|
||||
setTimeout(() => setErrorMessage(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = (path: LearningPath) => {
|
||||
setSelectedPath(path);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
const stars = [];
|
||||
const fullStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 !== 0;
|
||||
|
||||
for (let i = 0; i < fullStars; i++) {
|
||||
stars.push(<IconStar class="size-4 fill-yellow-400 text-yellow-400" />);
|
||||
}
|
||||
|
||||
if (hasHalfStar) {
|
||||
stars.push(<IconStar class="size-4 fill-yellow-400/50 text-yellow-400" />);
|
||||
}
|
||||
|
||||
const emptyStars = 5 - Math.ceil(rating);
|
||||
for (let i = 0; i < emptyStars; i++) {
|
||||
stars.push(<IconStar class="size-4 text-gray-400" />);
|
||||
}
|
||||
|
||||
return stars;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-[#fafafa]">Learning Paths</h1>
|
||||
</div>
|
||||
|
||||
{/* Success and Error Messages */}
|
||||
<Show when={successMessage()}>
|
||||
<Card class="p-4 border-primary/20 bg-primary/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconAlertCircle class="size-4 text-primary" />
|
||||
<p class="text-primary text-sm">{successMessage()}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
<Card class="p-4 border-destructive/20 bg-destructive/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconAlertCircle class="size-4 text-destructive" />
|
||||
<p class="text-destructive text-sm">{errorMessage()}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div class="bg-[#1a1a1a] rounded-lg p-6 space-y-4">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1 relative">
|
||||
<IconSearch class="absolute left-3 top-1/2 transform -translate-y-1/2 text-[#a3a3a3] size-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search learning paths..."
|
||||
value={searchTerm()}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setSearchTerm(target.value);
|
||||
}}
|
||||
class="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedCategory()}
|
||||
onChange={(e) => {
|
||||
const target = e.currentTarget as HTMLSelectElement;
|
||||
if (target) setSelectedCategory(target.value);
|
||||
}}
|
||||
class="px-4 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories().map(category => (
|
||||
<option value={category}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedDifficulty()}
|
||||
onChange={(e) => {
|
||||
const target = e.currentTarget as HTMLSelectElement;
|
||||
if (target) setSelectedDifficulty(target.value);
|
||||
}}
|
||||
class="px-4 py-2 bg-[#262626] text-[#fafafa] border border-[#404040] rounded-lg focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
</select>
|
||||
|
||||
<Button onClick={handleSearch} class="whitespace-nowrap">
|
||||
<IconFilter class="size-4 mr-2" />
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learning Paths Grid */}
|
||||
{isLoading() ? (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map(() => (
|
||||
<Card class="animate-pulse">
|
||||
<div class="h-48 bg-[#262626] rounded-t-lg"></div>
|
||||
<div class="p-6 space-y-3">
|
||||
<div class="h-6 bg-[#262626] rounded"></div>
|
||||
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
|
||||
<div class="h-4 bg-[#262626] rounded w-1/2"></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{learningPaths().map((path) => (
|
||||
<Card class="overflow-hidden hover:shadow-xl transition-all duration-300 group cursor-pointer bg-[#1a1a1a] border-[#404040]">
|
||||
{/* Thumbnail */}
|
||||
<div class="h-48 bg-[#262626] relative overflow-hidden">
|
||||
{path.is_featured && (
|
||||
<div class="absolute top-4 left-4 bg-blue-500 text-white px-3 py-1 rounded-full text-xs font-semibold z-10">
|
||||
Featured
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={path.thumbnail}
|
||||
alt={path.title}
|
||||
class="w-full h-full object-cover filter grayscale"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.src = `https://placehold.co/600x400/1e293b/ffffff?text=${encodeURIComponent(path.category)}`;
|
||||
}}
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
||||
<div class="absolute bottom-4 left-4 right-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{getCategoryIcon(path.category)}
|
||||
<span class={`px-2 py-1 rounded-full text-xs font-medium border ${getCategoryColor(path.category)}`}>
|
||||
{path.category}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white mb-2 line-clamp-2">{path.title}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`px-2 py-1 rounded-full text-xs font-medium border ${getDifficultyColor(path.difficulty)}`}>
|
||||
{path.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6 space-y-4">
|
||||
<p class="text-[#a3a3a3] text-sm line-clamp-3">{path.description}</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<IconUsers class="size-4 text-[#a3a3a3]" />
|
||||
<span class="text-[#a3a3a3]">{path.enrollment_count}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconClock class="size-4 text-[#a3a3a3]" />
|
||||
<span class="text-[#a3a3a3]">{path.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{path.rating > 0 && (
|
||||
<div class="flex items-center gap-1">
|
||||
{renderStars(path.rating)}
|
||||
<span class="text-[#a3a3a3] text-xs">({path.review_count})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{path.tags && path.tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{path.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-medium"
|
||||
style={`background-color: ${tag.color}20; color: ${tag.color}`}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{path.tags.length > 3 && (
|
||||
<span class="px-2 py-1 rounded-full text-xs bg-[#262626] text-[#a3a3a3]">
|
||||
+{path.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openPreview(path);
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnroll(path.id);
|
||||
}}
|
||||
disabled={enrolledPaths().has(path.id)}
|
||||
class="flex-1"
|
||||
>
|
||||
{enrolledPaths().has(path.id) ? 'Enrolled' : 'Enroll Now'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading() && learningPaths().length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<div class="text-[#a3a3a3] text-lg mb-4">
|
||||
No learning paths found matching your criteria.
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedCategory('');
|
||||
setSelectedDifficulty('');
|
||||
fetchData();
|
||||
}}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LearningPathPreviewModal
|
||||
isOpen={isPreviewOpen()}
|
||||
onClose={() => {
|
||||
setIsPreviewOpen(false);
|
||||
setSelectedPath(null);
|
||||
}}
|
||||
learningPath={selectedPath()}
|
||||
onEnroll={handleEnroll}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { useAuth, type LoginRequest, type RegisterRequest } from '@/lib/auth';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
|
||||
export const Login = () => {
|
||||
const { login, register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isLogin, setIsLogin] = createSignal(true);
|
||||
const [formData, setFormData] = createSignal<LoginRequest | RegisterRequest>({
|
||||
email: '',
|
||||
@@ -10,8 +13,51 @@ export const Login = () => {
|
||||
...(isLogin() ? {} : { username: '', fullName: '' }),
|
||||
});
|
||||
const [error, setError] = createSignal('');
|
||||
const [noAccountsExist, setNoAccountsExist] = createSignal(false);
|
||||
const [registrationDisabled, setRegistrationDisabled] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
// Check if users exist and set appropriate mode
|
||||
onMount(async () => {
|
||||
// Auto-fill demo credentials if in demo mode
|
||||
if (isEnvDemoMode()) {
|
||||
setFormData({
|
||||
email: 'demo@trackeep.com',
|
||||
password: 'demo123',
|
||||
...(isLogin() ? {} : { username: 'demo', fullName: 'Demo User' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'}/auth/check-users`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.hasUsers) {
|
||||
// Users exist - disable registration
|
||||
setRegistrationDisabled(true);
|
||||
setNoAccountsExist(false);
|
||||
// Force to login mode
|
||||
setIsLogin(true);
|
||||
} else {
|
||||
// No users exist - allow registration for first user (admin)
|
||||
setRegistrationDisabled(false);
|
||||
setNoAccountsExist(true);
|
||||
// Force to registration mode
|
||||
setIsLogin(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to check if users exist:', err);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
@@ -23,7 +69,8 @@ export const Login = () => {
|
||||
} else {
|
||||
await register(formData() as RegisterRequest);
|
||||
}
|
||||
// Navigation will be handled by the auth state change
|
||||
// Navigate to app after successful login/registration
|
||||
navigate('/app');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
@@ -36,6 +83,12 @@ export const Login = () => {
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
// Prevent toggling if registration is disabled (users exist)
|
||||
if (registrationDisabled()) {
|
||||
setError('Registration is disabled. Please contact your administrator to create an account.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLogin(!isLogin());
|
||||
setError('');
|
||||
setFormData({
|
||||
@@ -51,111 +104,156 @@ export const Login = () => {
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-[#fafafa] mb-2">Trackeep</h1>
|
||||
<p class="text-[#a3a3a3]">
|
||||
{isLogin() ? 'Welcome back' : 'Create your account'}
|
||||
{isEnvDemoMode() ? 'Demo Mode' : (isLogin() ? 'Welcome back' : 'Create your account')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} class="space-y-6">
|
||||
{error() && (
|
||||
<div class="bg-red-500/10 border border-red-500/50 text-red-400 px-4 py-3 rounded">
|
||||
{error()}
|
||||
{/* Demo Mode - Show only demo button */}
|
||||
{isEnvDemoMode() ? (
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<div class="mb-6 bg-green-500/10 border border-green-500/50 text-green-400 px-4 py-3 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
<span class="font-medium">Demo Mode Active</span>
|
||||
</div>
|
||||
<p class="text-xs">Experience Trackeep with mock data - no login required</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData().email}
|
||||
onInput={(e) => handleInputChange('email', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLogin() && (
|
||||
<>
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={(formData() as RegisterRequest).username}
|
||||
onInput={(e) => handleInputChange('username', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="fullName" class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="fullName"
|
||||
type="text"
|
||||
required
|
||||
value={(formData() as RegisterRequest).fullName}
|
||||
onInput={(e) => handleInputChange('fullName', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={formData().password}
|
||||
onInput={(e) => handleInputChange('password', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading()}
|
||||
class="w-full bg-[#39b9ff] text-white py-2 px-4 rounded-md hover:bg-[#2a8fdb] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:ring-offset-2 focus:ring-offset-[#141415] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading() ? 'Please wait...' : isLogin() ? 'Sign In' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-[#a3a3a3]">
|
||||
{isLogin() ? "Don't have an account?" : 'Already have an account?'}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
class="ml-1 text-[#39b9ff] hover:text-[#2a8fdb] focus:outline-none focus:underline"
|
||||
onClick={() => {
|
||||
// Auto-submit with demo credentials
|
||||
handleSubmit(new Event('submit') as any);
|
||||
}}
|
||||
disabled={loading()}
|
||||
class="w-full bg-green-600 text-white py-3 px-4 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-600 focus:ring-offset-2 focus:ring-offset-[#141415] disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-lg font-medium"
|
||||
>
|
||||
{isLogin() ? 'Sign up' : 'Sign in'}
|
||||
{loading() ? 'Entering Demo...' : 'Enter Demo Mode'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-[#262626]">
|
||||
<div class="text-center text-sm text-[#a3a3a3]">
|
||||
<p>Demo Account:</p>
|
||||
<p>Email: demo@trackeep.com</p>
|
||||
<p>Password: demo123</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Registration disabled message */}
|
||||
{registrationDisabled() && (
|
||||
<div class="mb-6 bg-blue-500/10 border border-blue-500/50 text-blue-400 px-4 py-3 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
<span class="font-medium">Registration Disabled</span>
|
||||
</div>
|
||||
<p class="text-xs">Accounts can only be created by the administrator. Please contact your admin to get an account.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No accounts exist message */}
|
||||
{noAccountsExist() && !isLogin() && (
|
||||
<div class="mb-6 bg-yellow-500/10 border border-yellow-500/50 text-yellow-400 px-4 py-3 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||
<span class="font-medium">Create Admin Account</span>
|
||||
</div>
|
||||
<p class="text-xs">No accounts exist yet. Create the first administrator account to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} class="space-y-6">
|
||||
{error() && (
|
||||
<div class="bg-red-500/10 border border-red-500/50 text-red-400 px-4 py-3 rounded">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData().email}
|
||||
onInput={(e) => handleInputChange('email', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLogin() && (
|
||||
<>
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={(formData() as RegisterRequest).username}
|
||||
onInput={(e) => handleInputChange('username', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="fullName" class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="fullName"
|
||||
type="text"
|
||||
required
|
||||
value={(formData() as RegisterRequest).fullName}
|
||||
onInput={(e) => handleInputChange('fullName', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-[#fafafa] mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={formData().password}
|
||||
onInput={(e) => handleInputChange('password', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading()}
|
||||
class="w-full bg-[#39b9ff] text-white py-2 px-4 rounded-md hover:bg-[#2a8fdb] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:ring-offset-2 focus:ring-offset-[#141415] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading() ? 'Please wait...' : isLogin() ? 'Sign In' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
{!registrationDisabled() && (
|
||||
<p class="text-[#a3a3a3]">
|
||||
{isLogin() ? "Don't have an account?" : 'Already have an account?'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
class="ml-1 text-[#39b9ff] hover:text-[#2a8fdb] focus:outline-none focus:underline"
|
||||
>
|
||||
{isLogin() ? 'Sign up' : 'Sign in'}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { IconPlus, IconDotsVertical, IconEdit, IconTrash, IconShield, IconShieldCheck } from '@tabler/icons-solidjs';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||
import { MemberModal } from '@/components/ui/MemberModal';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'Admin' | 'Member';
|
||||
avatar: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export const Members = () => {
|
||||
const [members, setMembers] = createSignal<Member[]>([]);
|
||||
const [showAddModal, setShowAddModal] = createSignal(false);
|
||||
const [showEditModal, setShowEditModal] = createSignal(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
|
||||
const [editingMember, setEditingMember] = createSignal<Member | null>(null);
|
||||
const [deletingMember, setDeletingMember] = createSignal<Member | null>(null);
|
||||
|
||||
const handleAddMember = (memberData: Omit<Member, 'id' | 'avatar' | 'joinedAt'>) => {
|
||||
const newMember: Member = {
|
||||
...memberData,
|
||||
id: Date.now().toString(),
|
||||
avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase(),
|
||||
joinedAt: 'Just now'
|
||||
};
|
||||
setMembers(prev => [...prev, newMember]);
|
||||
setShowAddModal(false);
|
||||
};
|
||||
|
||||
const handleEditMember = (memberData: Omit<Member, 'id' | 'avatar' | 'joinedAt'>) => {
|
||||
if (!editingMember()) return;
|
||||
|
||||
setMembers(prev =>
|
||||
prev.map(m =>
|
||||
m.id === editingMember()!.id
|
||||
? {
|
||||
...m,
|
||||
...memberData,
|
||||
avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase()
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
setShowEditModal(false);
|
||||
setEditingMember(null);
|
||||
};
|
||||
|
||||
const openEditModal = (member: Member) => {
|
||||
setEditingMember(member);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const openDeleteModal = (member: Member) => {
|
||||
setDeletingMember(member);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteMember = () => {
|
||||
if (!deletingMember()) return;
|
||||
|
||||
setMembers(prev => prev.filter(m => m.id !== deletingMember()!.id));
|
||||
setShowDeleteModal(false);
|
||||
setDeletingMember(null);
|
||||
};
|
||||
|
||||
const handleToggleRole = (member: Member) => {
|
||||
const newRole = member.role === 'Admin' ? 'Member' : 'Admin';
|
||||
setMembers(prev =>
|
||||
prev.map(m =>
|
||||
m.id === member.id ? { ...m, role: newRole } : m
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Mock data
|
||||
setMembers([
|
||||
{
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'Admin',
|
||||
avatar: 'JD',
|
||||
joinedAt: '2 weeks ago'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
role: 'Member',
|
||||
avatar: 'JS',
|
||||
joinedAt: '1 month ago'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Bob Johnson',
|
||||
email: 'bob@example.com',
|
||||
role: 'Member',
|
||||
avatar: 'BJ',
|
||||
joinedAt: '3 months ago'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-foreground">Members</h1>
|
||||
<button type="button" class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4" onClick={() => setShowAddModal(true)}>
|
||||
<IconPlus class="size-4" />
|
||||
Add Member
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-full overflow-auto">
|
||||
<table class="w-full caption-bottom text-sm">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
|
||||
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Member</th>
|
||||
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Role</th>
|
||||
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Joined</th>
|
||||
<th class="h-10 px-2 text-left align-middle font-medium text-muted-foreground text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
{members().map((member) => (
|
||||
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
|
||||
<td class="p-2 align-middle">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
|
||||
{member.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">{member.name}</div>
|
||||
<div class="text-sm text-muted-foreground">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 align-middle">
|
||||
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
{member.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2 align-middle text-muted-foreground">
|
||||
{member.joinedAt}
|
||||
</td>
|
||||
<td class="p-2 align-middle">
|
||||
<div class="flex items-center justify-end">
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<button type="button" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9">
|
||||
<IconDotsVertical class="size-4" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => openEditModal(member)} icon={IconEdit}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleToggleRole(member)} icon={member.role === 'Admin' ? IconShieldCheck : IconShield}>
|
||||
{member.role === 'Admin' ? 'Make Member' : 'Make Admin'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openDeleteModal(member)} icon={IconTrash} variant="destructive">
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<MemberModal
|
||||
isOpen={showAddModal()}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSubmit={handleAddMember}
|
||||
/>
|
||||
|
||||
<MemberModal
|
||||
isOpen={showEditModal()}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingMember(null);
|
||||
}}
|
||||
onSubmit={handleEditMember}
|
||||
member={editingMember()}
|
||||
isEdit={true}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal()}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeletingMember(null);
|
||||
}}
|
||||
onConfirm={handleDeleteMember}
|
||||
title="Remove Member"
|
||||
message={`Are you sure you want to remove ${deletingMember()?.name} from the team?`}
|
||||
confirmText="Remove"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||