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 98087 additions and 2583 deletions
@@ -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: '[email protected]',
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>
);
}