first test

This commit is contained in:
Tomas Dvorak
2026-02-08 14:14:55 +01:00
parent 18aa702174
commit d27cf14110
372 changed files with 98089 additions and 2585 deletions
+185 -7
View File
@@ -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;
}
+224 -11
View File
@@ -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>
)
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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>
);
};
+17 -12
View File
@@ -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;
};
+232
View File
@@ -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>
);
};
+467
View File
@@ -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>
);
};
+553
View File
@@ -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>
</>
)
}
+95 -62
View File
@@ -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)}
/>
</>
)
}
+162 -13
View File
@@ -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>
)
+236 -43
View File
@@ -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>
);
};
+299
View File
@@ -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>
</>
);
};
+26 -10
View File
@@ -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>
)
}
+5 -3
View File
@@ -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>
</>
);
};
+287
View File
@@ -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>
);
};
+87
View File
@@ -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>
)
}
+11 -11
View File
@@ -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>
);
};
+7 -1
View File
@@ -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>
)
}
+13 -13
View File
@@ -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>
)
+128
View File
@@ -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>
</>
);
};
+107
View File
@@ -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(`![${file.name}](${mockUrl})`);
} else if (mode() === 'html') {
insertHtml(`<img src="${mockUrl}" alt="${file.name}" />`);
} else {
insertText(`![${file.name}](${mockUrl})`);
}
} 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>
);
};
+43
View File
@@ -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 }
+136
View File
@@ -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>
);
};
+156
View File
@@ -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>
</>
);
};
+18
View File
@@ -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 }
+163
View File
@@ -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>
);
};
+137
View File
@@ -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>
)
}
+181
View File
@@ -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>
</>
);
};
+117
View File
@@ -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>
)
}
+42
View File
@@ -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 }
+24
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+40
View File
@@ -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;
}
+43
View File
@@ -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
}
+112
View File
@@ -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
}
+784 -54
View File
@@ -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;
}
+1 -1
View File
@@ -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')
+1 -1
View File
@@ -2,7 +2,7 @@ import { createQuery, useQueryClient, createMutation } from '@tanstack/solid-que
import { getAuthHeaders } from './auth';
// API base URL
const API_BASE_URL = 'http://localhost:8080/api/v1';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
// Retry configuration
const DEFAULT_RETRY_CONFIG = {
+269 -5
View File
@@ -1,5 +1,21 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
// Check if we're in demo mode
const isDemoMode = () => {
return localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
};
// Helper function to get auth headers
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
};
};
// Generic API client
class ApiClient {
private baseURL: string;
@@ -12,11 +28,16 @@ class ApiClient {
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// If in demo mode, use mock data
if (isDemoMode()) {
return this.getMockResponse(endpoint, options);
}
const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...getAuthHeaders(),
...options.headers,
},
...options,
@@ -26,17 +47,186 @@ class ApiClient {
const response = await fetch(url, config);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
// If backend fails, fall back to demo mode
console.warn(`API endpoint ${endpoint} failed, falling back to demo mode`);
return this.getMockResponse(endpoint, options);
}
return await response.json();
} catch (error) {
console.error('API request failed:', error);
throw error;
console.warn(`API request failed for ${endpoint}, falling back to demo mode:`, error);
return this.getMockResponse(endpoint, options);
}
}
private async getMockResponse<T>(endpoint: string, options: RequestInit): Promise<T> {
// Import mock data dynamically to avoid circular dependencies
const {
getMockStats,
getMockDocuments,
getMockBookmarks,
getMockTasks,
getMockNotes,
getMockTimeEntries,
getMockLearningPaths,
getMockVideos
} = await import('./mockData');
const method = options.method || 'GET';
// Dashboard stats
if (endpoint.includes('/dashboard/stats')) {
return getMockStats() as T;
}
// Documents/Files
if (endpoint.includes('/documents') || endpoint.includes('/files')) {
if (method === 'GET') {
return getMockDocuments() as T;
}
}
// Bookmarks
if (endpoint.includes('/bookmarks')) {
if (method === 'GET') {
return getMockBookmarks() as T;
}
}
// Tasks
if (endpoint.includes('/tasks')) {
if (method === 'GET') {
return getMockTasks() as T;
}
}
// Notes
if (endpoint.includes('/notes')) {
if (method === 'GET') {
return getMockNotes() as T;
}
}
// Time entries
if (endpoint.includes('/time-entries')) {
if (method === 'GET') {
const mockEntries = getMockTimeEntries();
// Convert mock entries to TimeEntry format
const timeEntries = mockEntries.map(entry => ({
id: parseInt(entry.id.replace('time_', '')),
user_id: 1,
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
start_time: `${entry.date}T${entry.startTime}:00Z`,
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
duration: entry.duration,
description: entry.description,
tags: entry.tags,
billable: entry.billable,
hourly_rate: entry.hourlyRate,
is_running: false,
source: 'demo',
created_at: `${entry.date}T${entry.startTime}:00Z`,
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
}));
return { time_entries: timeEntries } as T;
}
if (method === 'POST') {
const mockEntries = getMockTimeEntries();
const entry = mockEntries[0];
// Convert mock entry to TimeEntry format
const timeEntry = {
id: parseInt(entry.id.replace('time_', '')),
user_id: 1,
task_id: entry.taskId ? parseInt(entry.taskId.replace('task_', '')) : undefined,
start_time: `${entry.date}T${entry.startTime}:00Z`,
end_time: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : undefined,
duration: entry.duration,
description: entry.description,
tags: entry.tags,
billable: entry.billable,
hourly_rate: entry.hourlyRate,
is_running: false,
source: 'demo',
created_at: `${entry.date}T${entry.startTime}:00Z`,
updated_at: entry.endTime ? `${entry.date}T${entry.endTime}:00Z` : `${entry.date}T${entry.startTime}:00Z`
};
return { time_entry: timeEntry } as T;
}
}
// Auth endpoints
if (endpoint.includes('/auth/login-totp')) {
return {
token: 'demo-token',
user: { id: 1, email: 'demo@trackeep.com', name: 'Demo User' }
} as T;
}
// GitHub repos
if (endpoint.includes('/github/repos')) {
return {
repositories: [
{ id: 1, name: 'trackeep', full_name: 'tdvorak/trackeep', stars: 245, forks: 43, watchers: 65, language: 'Go' },
{ id: 2, name: 'frontend', full_name: 'tdvorak/frontend', stars: 89, forks: 12, watchers: 23, language: 'TypeScript' },
{ id: 3, name: 'mobile-app', full_name: 'tdvorak/mobile-app', stars: 34, forks: 8, watchers: 15, language: 'TypeScript' }
],
totalStars: 368,
totalForks: 63,
totalWatchers: 103
} as T;
}
// Learning paths
if (endpoint.includes('/learning-paths/categories')) {
return {
categories: ['Web Development', 'DevOps', 'Programming', 'Design', 'Business', 'Data Science']
} as T;
}
if (endpoint.includes('/learning-paths')) {
return getMockLearningPaths() as T;
}
// Chat sessions
if (endpoint.includes('/chat/sessions')) {
return {
sessions: [
{ id: '1', title: 'Project Planning', created_at: '2024-01-15T10:00:00Z', updated_at: '2024-01-15T11:30:00Z' },
{ id: '2', title: 'Technical Discussion', created_at: '2024-01-14T14:00:00Z', updated_at: '2024-01-14T15:45:00Z' }
]
} as T;
}
// AI providers
if (endpoint.includes('/ai/providers')) {
return {
providers: [
{ id: 'longcat', name: 'LongCat AI', enabled: true, models: ['LongCat-Flash-Chat', 'LongCat-Flash-Thinking'] },
{ id: 'mistral', name: 'Mistral AI', enabled: false, models: ['mistral-small-latest', 'mistral-large-latest'] },
{ id: 'openai', name: 'OpenAI', enabled: false, models: ['gpt-4', 'gpt-3.5-turbo'] }
]
} as T;
}
// YouTube endpoints
if (endpoint.includes('/youtube/video-details')) {
return getMockVideos()[0] as T;
}
if (endpoint.includes('/youtube/predefined-channels')) {
return {
channels: [
{ id: 'UC8butISFwT-Wy7pm24E6Icg', name: 'NetworkChuck', latestVideos: getMockVideos().slice(0, 2) },
{ id: 'UCWv7vHwRQdGJtU2i9hJ8X7A', name: 'Fireship', latestVideos: getMockVideos().slice(1, 3) },
{ id: 'UCsXVk37bltHxD1rDPgtNG6A', name: 'Beyond Fireship', latestVideos: getMockVideos().slice(0, 1) }
]
} as T;
}
// Default empty response
return {} as T;
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
@@ -65,6 +255,9 @@ class ApiClient {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': getAuthHeaders().Authorization || '',
},
body: formData,
});
@@ -134,6 +327,36 @@ export interface File {
updated_at: string;
}
export interface TimeEntry {
id: number;
user_id: number;
task_id?: number;
bookmark_id?: number;
note_id?: number;
start_time: string;
end_time?: string;
duration?: number;
description: string;
tags: string[];
billable: boolean;
hourly_rate?: number;
is_running: boolean;
source: string;
created_at: string;
updated_at: string;
task?: Task;
bookmark?: Bookmark;
note?: Note;
}
export interface TimeStats {
total_time_seconds: number;
total_entries: number;
running_entries: number;
billable_time_seconds: number;
total_billable_amount: number;
}
// API Functions
export const bookmarksApi = {
getAll: () => api.get<Bookmark[]>('/bookmarks'),
@@ -191,4 +414,45 @@ export const filesApi = {
download: (id: number) => `${API_BASE_URL}/files/${id}/download`,
};
export const timeEntriesApi = {
getAll: (startDate?: string, endDate?: string, isRunning?: boolean) => {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (isRunning !== undefined) params.append('is_running', isRunning.toString());
const query = params.toString() ? `?${params.toString()}` : '';
return api.get<{ time_entries: TimeEntry[] }>(`/time-entries${query}`);
},
getById: (id: number) => api.get<{ time_entry: TimeEntry }>(`/time-entries/${id}`),
create: (timeEntry: {
task_id?: number;
bookmark_id?: number;
note_id?: number;
description: string;
tags?: string[];
billable?: boolean;
hourly_rate?: number;
source?: string;
}) => api.post<{ time_entry: TimeEntry }>('/time-entries', timeEntry),
update: (id: number, timeEntry: {
description?: string;
tags?: string[];
billable?: boolean;
hourly_rate?: number;
end_time?: string;
}) => api.put<{ time_entry: TimeEntry }>(`/time-entries/${id}`, timeEntry),
stop: (id: number) => api.post<{ time_entry: TimeEntry }>(`/time-entries/${id}/stop`),
delete: (id: number) => api.delete<{ message: string }>(`/time-entries/${id}`),
getStats: () => api.get<{ stats: TimeStats }>('/time-entries/stats'),
};
import {
demoBookmarksApi,
demoTasksApi,
demoNotesApi,
demoFilesApi,
demoTimeEntriesApi
} from './demo-api';
export default api;
export { demoBookmarksApi, demoTasksApi, demoNotesApi, demoFilesApi, demoTimeEntriesApi };
+147 -21
View File
@@ -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}` }),
+83
View File
@@ -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 [];
}
}
+320
View File
@@ -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;
+577
View File
@@ -0,0 +1,577 @@
// Demo mode API interceptor to provide mock data instead of making real API calls
// Check if demo mode is enabled via environment variable
export const isEnvDemoMode = (): boolean => {
const result = import.meta.env.VITE_DEMO_MODE === 'true';
console.log('[Demo Mode] isEnvDemoMode:', result, 'VITE_DEMO_MODE:', import.meta.env.VITE_DEMO_MODE);
return result;
};
// Check if demo mode is active (environment variable only)
export const isDemoMode = (): boolean => {
// Only check environment variable - no localStorage persistence
return isEnvDemoMode();
};
// Clear demo mode from localStorage
export const clearDemoMode = (): void => {
localStorage.removeItem('demoMode');
// Only clear demo tokens, not legitimate user tokens
const token = localStorage.getItem('token');
// Only clear if they look like demo tokens (contain 'demo-token')
if (token && token.includes('demo-token')) {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('trackeep_token');
localStorage.removeItem('trackeep_user');
}
};
// Set demo mode (no-op - environment variable only)
export const setDemoMode = (): void => {
// Demo mode is controlled by environment variable only
// No localStorage persistence
};
// Mock data generators
const generateMockStats = () => ({
total_tasks: 42,
completed_tasks: 28,
total_bookmarks: 156,
total_notes: 89,
total_files: 234,
total_time_tracked: 125000, // seconds
recent_activity: [
{ type: 'task', action: 'completed', title: 'Complete project documentation', timestamp: new Date(Date.now() - 3600000).toISOString() },
{ type: 'bookmark', action: 'added', title: 'SolidJS Documentation', timestamp: new Date(Date.now() - 7200000).toISOString() },
{ type: 'note', action: 'created', title: 'Meeting notes - Q1 planning', timestamp: new Date(Date.now() - 10800000).toISOString() },
{ type: 'file', action: 'uploaded', title: 'project-roadmap.pdf', timestamp: new Date(Date.now() - 14400000).toISOString() },
]
});
const generateMockGitHubRepos = () => [
{
id: 1,
name: 'trackeep',
full_name: 'tdvorak/trackeep',
description: 'Your Self-Hosted Productivity Hub',
private: false,
stargazers_count: 245,
forks_count: 65,
watchers_count: 43,
language: 'Go',
updated_at: new Date().toISOString(),
html_url: 'https://github.com/tdvorak/trackeep'
},
{
id: 2,
name: 'solidjs-components',
full_name: 'tdvorak/solidjs-components',
description: 'Reusable SolidJS components library',
private: false,
stargazers_count: 89,
forks_count: 12,
watchers_count: 8,
language: 'TypeScript',
updated_at: new Date(Date.now() - 86400000).toISOString(),
html_url: 'https://github.com/tdvorak/solidjs-components'
}
];
const generateMockTimeEntries = () => [
{
id: 1,
description: 'Working on Trackeep frontend',
start_time: new Date(Date.now() - 7200000).toISOString(),
end_time: new Date(Date.now() - 3600000).toISOString(),
duration: 3600,
billable: true,
hourly_rate: 75,
task_id: 1,
created_at: new Date(Date.now() - 3600000).toISOString()
},
{
id: 2,
description: 'Meeting with team',
start_time: new Date(Date.now() - 14400000).toISOString(),
end_time: new Date(Date.now() - 12600000).toISOString(),
duration: 1800,
billable: false,
hourly_rate: 0,
task_id: null,
created_at: new Date(Date.now() - 12600000).toISOString()
}
];
const generateMockYouTubeVideos = () => [
{
id: 1,
video_id: 'dQw4w9WgXcQ',
title: 'Never Gonna Give You Up',
description: 'Classic music video',
channel_name: 'Rick Astley',
thumbnail_url: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
duration: '3:33',
published_at: '2009-10-25T06:57:33Z',
created_at: new Date().toISOString()
}
];
const generateMockLearningPaths = () => [
{
id: 1,
title: 'SolidJS Mastery',
description: 'Complete guide to SolidJS framework',
category: 'frontend',
difficulty: 'intermediate',
estimated_hours: 20,
progress: 65,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
},
{
id: 2,
title: 'Go Backend Development',
description: 'Build scalable backend with Go',
category: 'backend',
difficulty: 'advanced',
estimated_hours: 40,
progress: 30,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
];
const generateMockChatSessions = () => [
{
id: 1,
title: 'Project Planning Discussion',
created_at: new Date(Date.now() - 86400000).toISOString(),
updated_at: new Date(Date.now() - 3600000).toISOString(),
message_count: 15
},
{
id: 2,
title: 'Code Review Help',
created_at: new Date(Date.now() - 172800000).toISOString(),
updated_at: new Date(Date.now() - 7200000).toISOString(),
message_count: 8
}
];
const generateMockAIProviders = () => [
{
id: 'longcat',
name: 'LongCat AI',
description: 'Fast and efficient AI models',
models: ['LongCat-Flash-Chat', 'LongCat-Flash-Thinking'],
enabled: true,
api_key_configured: true
},
{
id: 'mistral',
name: 'Mistral AI',
description: 'European AI models',
models: ['mistral-small-latest', 'mistral-large-latest'],
enabled: false,
api_key_configured: false
}
];
// Demo mode fetch interceptor
export const demoFetch = async (url: string, options?: RequestInit): Promise<Response> => {
if (!isDemoMode()) {
return fetch(url, options);
}
// Parse URL to determine which mock data to return
let path: string;
try {
// Handle relative URLs by providing a base URL
const urlObj = new URL(url, window.location.origin);
path = urlObj.pathname;
} catch (error) {
// If URL construction fails, treat the url as the path directly
path = url;
console.warn('[Demo Mode] URL construction failed, using url as path:', url);
}
console.log(`[Demo Mode] Intercepting request to: ${path}`);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 300));
// Return mock data based on the endpoint
if (path.includes('/api/v1/auth/login') || path.includes('/api/v1/auth/login-totp')) {
// Handle demo login
return new Response(JSON.stringify({
token: 'demo-token-' + Date.now(),
user: {
id: 1,
email: 'demo@trackeep.com',
username: 'demo',
full_name: 'Demo User',
theme: 'dark',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/dashboard/stats')) {
return new Response(JSON.stringify(generateMockStats()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/tasks') && (!options?.method || options.method === 'GET')) {
const { getMockTasks } = await import('./mockData');
const mockTasks = getMockTasks().map((task, index) => ({
id: index + 1,
title: task.title,
description: task.description,
completed: task.status === 'completed',
priority: task.priority,
createdAt: task.createdAt,
dueDate: task.dueDate,
}));
return new Response(JSON.stringify(mockTasks), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/github/repos')) {
return new Response(JSON.stringify(generateMockGitHubRepos()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/time-entries')) {
return new Response(JSON.stringify(generateMockTimeEntries()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/video-bookmarks')) {
if (options?.method === 'GET') {
// Return empty bookmarks for demo
return new Response(JSON.stringify({ bookmarks: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (options?.method === 'POST') {
// Simulate creating a bookmark
return new Response(JSON.stringify({
id: Date.now(),
message: 'Bookmark created successfully (demo mode)'
}), {
status: 201,
headers: { 'Content-Type': 'application/json' }
});
}
}
if (path.includes('/api/v1/youtube/search')) {
const { getMockVideos } = await import('./mockData');
const body = options?.body && typeof options.body === 'string' ? JSON.parse(options.body) : {};
const query = body.query || '';
const mockVideos = getMockVideos().filter(video =>
video.title.toLowerCase().includes(query.toLowerCase()) ||
video.description.toLowerCase().includes(query.toLowerCase()) ||
video.channel.toLowerCase().includes(query.toLowerCase())
).slice(0, 10).map((video) => ({
id: video.id,
title: video.title,
channel_title: video.channel,
duration: video.duration,
published_at: video.publishedAt,
view_count: Math.floor(Math.random() * 100000) + 1000
}));
return new Response(JSON.stringify({ videos: mockVideos }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/youtube/channel-videos')) {
const { getMockVideos } = await import('./mockData');
const mockVideos = getMockVideos().slice(0, 5).map((video) => ({
video_id: video.id,
title: video.title,
channel: video.channel,
length: video.duration,
views: Math.floor(Math.random() * 100000) + 1000,
published_date: video.publishedAt,
published_text: new Date(video.publishedAt).toLocaleDateString()
}));
return new Response(JSON.stringify({ videos: mockVideos }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/youtube/video-details')) {
return new Response(JSON.stringify(generateMockYouTubeVideos()[0]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/youtube/predefined-channels')) {
return new Response(JSON.stringify(generateMockYouTubeVideos()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/learning-paths/categories')) {
return new Response(JSON.stringify(['frontend', 'backend', 'fullstack', 'devops', 'mobile', 'design']), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/learning-paths')) {
return new Response(JSON.stringify(generateMockLearningPaths()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/chat/sessions')) {
return new Response(JSON.stringify(generateMockChatSessions()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/ai/providers')) {
return new Response(JSON.stringify(generateMockAIProviders()), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// Handle update checking endpoints
if (path.includes('/api/updates/check')) {
return new Response(JSON.stringify({
updateAvailable: false,
currentVersion: '1.0.0-demo',
latestVersion: '1.0.0-demo'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/updates/progress')) {
return new Response(JSON.stringify({
progress: 0,
downloading: false,
installing: false,
completed: false,
error: null
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// For POST requests that create data
if (options?.method === 'POST') {
if (path.includes('/api/v1/time-entries')) {
const newEntry = { ...JSON.parse(options.body as string), id: Date.now() };
return new Response(JSON.stringify(newEntry), {
status: 201,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/tasks')) {
const body = options.body ? JSON.parse(options.body as string) : {};
const newTask = {
id: Date.now(),
title: body.title || 'Untitled task',
description: body.description || '',
completed: body.completed ?? false,
priority: body.priority || 'medium',
createdAt: body.createdAt || new Date().toISOString(),
dueDate: body.dueDate || '',
};
return new Response(JSON.stringify(newTask), {
status: 201,
headers: { 'Content-Type': 'application/json' }
});
}
}
if (options?.method === 'PUT' && path.includes('/api/v1/tasks')) {
const body = options.body ? JSON.parse(options.body as string) : {};
const pathParts = path.split('/');
const idFromPath = parseInt(pathParts[pathParts.length - 1] || '0', 10);
const updatedTask = {
id: idFromPath || body.id || Date.now(),
title: body.title || 'Untitled task',
description: body.description || '',
completed: body.completed ?? false,
priority: body.priority || 'medium',
createdAt: body.createdAt || new Date().toISOString(),
dueDate: body.dueDate || '',
};
return new Response(JSON.stringify(updatedTask), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (options?.method === 'DELETE' && path.includes('/api/v1/tasks')) {
return new Response(JSON.stringify({ message: 'Task deleted (demo mode)' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// Handle search API endpoints
if (path.includes('/api/v1/search/web') || path.includes('/res/v1/web/search')) {
let queryParam: string;
try {
const urlObj = new URL(url, window.location.origin);
queryParam = urlObj.searchParams.get('q') ||
(options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
} catch {
queryParam = (options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
}
if (!queryParam) {
return new Response(JSON.stringify({ error: 'Query parameter required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const mockSearchResults = [
{
title: `${queryParam} - Demo Search Result 1`,
url: `https://demo.example.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `This is a demo search result for "${queryParam}" showing how the search functionality works in demo mode.`,
published_date: new Date().toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'web',
subtype: 'search'
},
{
title: `${queryParam} - Demo Search Result 2`,
url: `https://demo-search.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `Another demo search result for "${queryParam}" demonstrating the search interface in demo mode.`,
published_date: new Date(Date.now() - 86400000).toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'web',
subtype: 'search'
},
{
title: `Learn more about ${queryParam}`,
url: `https://demo-learning.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `Educational content about ${queryParam} for demo purposes. This shows how search results can include learning resources.`,
published_date: new Date(Date.now() - 172800000).toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'web',
subtype: 'educational'
}
];
return new Response(JSON.stringify({
web: { results: mockSearchResults },
query: { original: queryParam, display: queryParam },
mixed: { results: mockSearchResults }
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
if (path.includes('/api/v1/search/news') || path.includes('/res/v1/news/search')) {
let queryParam: string;
try {
const urlObj = new URL(url, window.location.origin);
queryParam = urlObj.searchParams.get('q') ||
(options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
} catch {
queryParam = (options?.body && typeof options.body === 'string' ? JSON.parse(options.body).query : '');
}
if (!queryParam) {
return new Response(JSON.stringify({ error: 'Query parameter required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const mockNewsResults = [
{
title: `Breaking News: ${queryParam} Update`,
url: `https://demo-news.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `Latest news about ${queryParam} - this is a demo news search result showing how news search works in demo mode.`,
published_date: new Date().toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'news',
subtype: 'article'
},
{
title: `${queryParam} - Industry Report`,
url: `https://demo-industry.com/${queryParam.toLowerCase().replace(/\s+/g, '-')}`,
description: `Industry analysis and reports about ${queryParam}. This demo result shows how news search can include industry content.`,
published_date: new Date(Date.now() - 86400000).toISOString().split('T')[0],
language: 'English',
family_friendly: true,
type: 'news',
subtype: 'report'
}
];
return new Response(JSON.stringify({
news: { results: mockNewsResults },
query: { original: queryParam, display: queryParam },
mixed: { results: mockNewsResults }
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// Default fallback - return a successful empty response
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
// Override global fetch for demo mode
export const initializeDemoMode = () => {
if (isDemoMode()) {
// Store original fetch to restore later if needed
const originalFetch = window.fetch;
window.fetch = demoFetch as typeof fetch;
console.log('[Demo Mode] API interceptor initialized');
return originalFetch;
}
return null;
};
File diff suppressed because it is too large Load Diff
+144
View File
@@ -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' };
}
+69
View File
@@ -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`;
};
+60
View File
@@ -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>
);
};
+305
View File
@@ -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>
);
};
+719
View File
@@ -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
+202
View File
@@ -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>
);
};
+404
View File
@@ -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>
);
};
+276
View File
@@ -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>
);
};
+516
View File
@@ -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>
);
};
+68
View File
@@ -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>
);
};
-220
View File
@@ -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>
)
}
+577 -219
View File
@@ -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>
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+652
View File
@@ -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>
);
};
File diff suppressed because it is too large Load Diff
+477 -222
View File
@@ -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>
)
}
);
};
+553
View File
@@ -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>
);
};
+496
View File
@@ -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>
);
};
+196 -98
View File
@@ -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>
);
+211
View File
@@ -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>
);
};

Some files were not shown because too many files have changed in this diff Show More