mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 04:22:57 +00:00
Configure Docker publishing with correct GitHub username
This commit is contained in:
@@ -27,11 +27,13 @@ export const AuthenticationWarning = () => {
|
||||
<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 class="inline-flex items-center justify-center p-2.5 rounded-xl border border-border bg-muted/40">
|
||||
<img
|
||||
src="/trackeep.svg"
|
||||
alt="Trackeep Logo"
|
||||
class="w-9 h-9 app-logo-mono"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { AuthenticationWarning } from '@/components/AuthenticationWarning';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { Show } from 'solid-js';
|
||||
|
||||
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();
|
||||
|
||||
console.log('[ProtectedRoute] Render:', {
|
||||
isDemoMode: isDemoMode(),
|
||||
isAuthenticated: authState.isAuthenticated,
|
||||
isLoading: authState.isLoading
|
||||
});
|
||||
|
||||
// If not authenticated, show authentication warning (no loading state)
|
||||
if (!authState.isAuthenticated) {
|
||||
console.log('[ProtectedRoute] Rendering authentication warning');
|
||||
return <AuthenticationWarning />;
|
||||
}
|
||||
|
||||
console.log('[ProtectedRoute] Rendering children');
|
||||
return props.children;
|
||||
return (
|
||||
<Show when={!isDemoMode()} fallback={props.children}>
|
||||
<Show
|
||||
when={!authState.isLoading}
|
||||
fallback={
|
||||
<div class="min-h-screen bg-background flex items-center justify-center px-4 py-8">
|
||||
<div class="text-center">
|
||||
<div class="inline-block w-8 h-8 border-2 border-primary border-r-transparent rounded-full animate-spin mb-3"></div>
|
||||
<p class="text-sm text-muted-foreground">Checking authentication...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={authState.isAuthenticated} fallback={<AuthenticationWarning />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type TimeEntry
|
||||
} from '../lib/api';
|
||||
import { TagPicker } from '@/components/ui/TagPicker';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
interface TimerProps {
|
||||
onTimeEntryCreated?: (timeEntry: TimeEntry) => void;
|
||||
@@ -38,13 +39,6 @@ export const Timer = (props: TimerProps) => {
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { IconX, IconSend, IconUser, IconChevronDown } from '@tabler/icons-solidjs'
|
||||
import longcatIcon from '@/assets/longcat-color.svg'
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal'
|
||||
|
||||
interface FloatingAIProps {
|
||||
onToggleChat: () => void
|
||||
@@ -79,8 +80,9 @@ export function FloatingAI(props: FloatingAIProps) {
|
||||
|
||||
{/* AI Chat Modal */}
|
||||
<Show when={props.isChatOpen}>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0 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;">
|
||||
<ModalPortal>
|
||||
<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-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">
|
||||
@@ -177,8 +179,9 @@ export function FloatingAI(props: FloatingAIProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -56,6 +56,18 @@ export function Header(props: HeaderProps) {
|
||||
<div class="flex justify-between px-6 pt-4 pb-4">
|
||||
{/* Left side */}
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
href="/app"
|
||||
class="hidden sm:inline-flex items-center gap-2 rounded-md px-2 py-1.5 mr-2 hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<img
|
||||
src="/trackeep.svg"
|
||||
alt="Trackeep Logo"
|
||||
class="w-6 h-6 app-logo-mono"
|
||||
/>
|
||||
<span class="text-sm font-semibold tracking-tight text-foreground">Trackeep</span>
|
||||
</a>
|
||||
|
||||
{/* Menu button */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -14,9 +14,16 @@ export interface LayoutProps {
|
||||
export function Layout(props: LayoutProps) {
|
||||
const resolved = children(() => props.children)
|
||||
const [isChatOpen, setIsChatOpen] = createSignal(false)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
|
||||
onMount(() => {
|
||||
const savedSidebarState = localStorage.getItem('trackeep_sidebar_open')
|
||||
if (savedSidebarState !== null) {
|
||||
setIsSidebarOpen(savedSidebarState === 'true')
|
||||
} else {
|
||||
setIsSidebarOpen(window.innerWidth >= 768)
|
||||
}
|
||||
|
||||
// Initialize dark mode from localStorage or system preference
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
@@ -143,11 +150,14 @@ export function Layout(props: LayoutProps) {
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(!isSidebarOpen())
|
||||
const nextValue = !isSidebarOpen()
|
||||
setIsSidebarOpen(nextValue)
|
||||
localStorage.setItem('trackeep_sidebar_open', String(nextValue))
|
||||
}
|
||||
|
||||
const closeSidebar = () => {
|
||||
setIsSidebarOpen(false)
|
||||
localStorage.setItem('trackeep_sidebar_open', 'false')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -157,7 +167,7 @@ export function Layout(props: LayoutProps) {
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{isSidebarOpen() && (
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
class="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={closeSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, createSignal, onMount, Show } from 'solid-js'
|
||||
import { For, createSignal, onCleanup, onMount, Show } from 'solid-js'
|
||||
import { A, useLocation } from '@solidjs/router'
|
||||
import {
|
||||
IconBookmark,
|
||||
@@ -21,10 +21,15 @@ import {
|
||||
IconMessageCircle,
|
||||
IconLogout,
|
||||
IconBuilding,
|
||||
IconPlus,
|
||||
IconX
|
||||
IconPlus
|
||||
} from '@tabler/icons-solidjs'
|
||||
import { UpdateChecker } from '../ui/UpdateChecker'
|
||||
import { Input } from '../ui/Input'
|
||||
import { Button } from '../ui/Button'
|
||||
import { Switch } from '../ui/Switch'
|
||||
import { ModalPortal } from '../ui/ModalPortal'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Home', href: '/app', icon: IconHome },
|
||||
@@ -43,11 +48,23 @@ const navigation = [
|
||||
{ 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 },
|
||||
]
|
||||
const API_BASE_URL = getApiV1BaseUrl()
|
||||
const DEFAULT_WORKSPACE_NAME = 'Trackeep Workspace'
|
||||
|
||||
interface WorkspaceOption {
|
||||
id: string
|
||||
name: string
|
||||
icon: typeof IconFileText
|
||||
}
|
||||
|
||||
const getWorkspaceIcon = (name: string) => {
|
||||
const lower = name.toLowerCase()
|
||||
if (lower.includes('team')) return IconUsers
|
||||
if (lower.includes('personal')) return IconBuilding
|
||||
return IconFileText
|
||||
}
|
||||
|
||||
const getAuthToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''
|
||||
|
||||
export interface SidebarProps {
|
||||
class?: string
|
||||
@@ -57,8 +74,35 @@ export interface SidebarProps {
|
||||
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const location = useLocation()
|
||||
const { logout } = useAuth()
|
||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = createSignal(false)
|
||||
const [selectedWorkspace, setSelectedWorkspace] = createSignal(mockWorkspaces[0])
|
||||
const [workspaces, setWorkspaces] = createSignal<WorkspaceOption[]>([])
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = createSignal<string>('')
|
||||
const [isCreateWorkspaceModalOpen, setIsCreateWorkspaceModalOpen] = createSignal(false)
|
||||
const [workspaceName, setWorkspaceName] = createSignal('')
|
||||
const [workspaceDescription, setWorkspaceDescription] = createSignal('')
|
||||
const [workspaceIsPublic, setWorkspaceIsPublic] = createSignal(false)
|
||||
const [isCreatingWorkspace, setIsCreatingWorkspace] = createSignal(false)
|
||||
const [createWorkspaceError, setCreateWorkspaceError] = createSignal('')
|
||||
|
||||
const selectedWorkspace = () => {
|
||||
const list = workspaces()
|
||||
const found = list.find((workspace) => workspace.id === selectedWorkspaceId())
|
||||
return found || list[0] || { id: 'default', name: DEFAULT_WORKSPACE_NAME, icon: IconFileText }
|
||||
}
|
||||
|
||||
const persistSelectedWorkspace = (workspace: WorkspaceOption) => {
|
||||
localStorage.setItem('trackeep_workspace_id', workspace.id)
|
||||
localStorage.setItem('trackeep_workspace_name', workspace.name)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('trackeep:workspace-changed', {
|
||||
detail: {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const currentPath = location.pathname
|
||||
@@ -66,17 +110,206 @@ export function Sidebar(props: SidebarProps) {
|
||||
return currentPath === href
|
||||
}
|
||||
|
||||
const handleWorkspaceSelect = (workspace: typeof mockWorkspaces[0]) => {
|
||||
setSelectedWorkspace(workspace)
|
||||
const handleWorkspaceSelect = (workspace: WorkspaceOption) => {
|
||||
setSelectedWorkspaceId(workspace.id)
|
||||
persistSelectedWorkspace(workspace)
|
||||
setIsWorkspaceDropdownOpen(false)
|
||||
}
|
||||
|
||||
const resetCreateWorkspaceForm = () => {
|
||||
setWorkspaceName('')
|
||||
setWorkspaceDescription('')
|
||||
setWorkspaceIsPublic(false)
|
||||
setCreateWorkspaceError('')
|
||||
}
|
||||
|
||||
const openCreateWorkspaceModal = () => {
|
||||
setIsWorkspaceDropdownOpen(false)
|
||||
resetCreateWorkspaceForm()
|
||||
setIsCreateWorkspaceModalOpen(true)
|
||||
}
|
||||
|
||||
const closeCreateWorkspaceModal = () => {
|
||||
if (isCreatingWorkspace()) return
|
||||
setIsCreateWorkspaceModalOpen(false)
|
||||
resetCreateWorkspaceForm()
|
||||
}
|
||||
|
||||
const toggleWorkspaceDropdown = () => {
|
||||
setIsWorkspaceDropdownOpen(!isWorkspaceDropdownOpen())
|
||||
}
|
||||
|
||||
const normalizeWorkspace = (team: { id?: number | string; name?: string }): WorkspaceOption => {
|
||||
const name = team.name?.trim() || DEFAULT_WORKSPACE_NAME
|
||||
return {
|
||||
id: String(team.id ?? `workspace-${Date.now()}`),
|
||||
name,
|
||||
icon: getWorkspaceIcon(name),
|
||||
}
|
||||
}
|
||||
|
||||
const createDefaultWorkspace = async (token: string): Promise<WorkspaceOption | null> => {
|
||||
const response = await fetch(`${API_BASE_URL}/teams`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
description: 'Default workspace',
|
||||
is_public: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (!data?.team) {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizeWorkspace(data.team)
|
||||
}
|
||||
|
||||
const loadWorkspaces = async () => {
|
||||
const token = getAuthToken()
|
||||
if (!token) {
|
||||
const fallbackWorkspace = {
|
||||
id: 'local-default',
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
icon: IconFileText,
|
||||
}
|
||||
setWorkspaces([fallbackWorkspace])
|
||||
setSelectedWorkspaceId(fallbackWorkspace.id)
|
||||
persistSelectedWorkspace(fallbackWorkspace)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/teams`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
let mappedWorkspaces: WorkspaceOption[] = []
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const teams = Array.isArray(data?.teams) ? data.teams : []
|
||||
mappedWorkspaces = teams.map(normalizeWorkspace)
|
||||
}
|
||||
|
||||
if (mappedWorkspaces.length === 0) {
|
||||
const created = await createDefaultWorkspace(token)
|
||||
if (created) {
|
||||
mappedWorkspaces = [created]
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedWorkspaces.length === 0) {
|
||||
mappedWorkspaces = [
|
||||
{
|
||||
id: 'local-default',
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
icon: IconFileText,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
setWorkspaces(mappedWorkspaces)
|
||||
|
||||
const persistedWorkspaceId = localStorage.getItem('trackeep_workspace_id') || ''
|
||||
const initialSelection =
|
||||
mappedWorkspaces.find((workspace) => workspace.id === persistedWorkspaceId) || mappedWorkspaces[0]
|
||||
|
||||
setSelectedWorkspaceId(initialSelection.id)
|
||||
persistSelectedWorkspace(initialSelection)
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspaces:', error)
|
||||
const fallbackWorkspace = {
|
||||
id: 'local-default',
|
||||
name: DEFAULT_WORKSPACE_NAME,
|
||||
icon: IconFileText,
|
||||
}
|
||||
setWorkspaces([fallbackWorkspace])
|
||||
setSelectedWorkspaceId(fallbackWorkspace.id)
|
||||
persistSelectedWorkspace(fallbackWorkspace)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateWorkspace = async () => {
|
||||
const trimmed = workspaceName().trim()
|
||||
const description = workspaceDescription().trim()
|
||||
const isPublic = workspaceIsPublic()
|
||||
|
||||
if (!trimmed) {
|
||||
setCreateWorkspaceError('Workspace name is required.')
|
||||
return
|
||||
}
|
||||
|
||||
setCreateWorkspaceError('')
|
||||
setIsCreatingWorkspace(true)
|
||||
const token = getAuthToken()
|
||||
if (!token) {
|
||||
const localWorkspace = {
|
||||
id: `local-${Date.now()}`,
|
||||
name: trimmed,
|
||||
icon: getWorkspaceIcon(trimmed),
|
||||
}
|
||||
setWorkspaces((prev) => [localWorkspace, ...prev])
|
||||
handleWorkspaceSelect(localWorkspace)
|
||||
setIsCreateWorkspaceModalOpen(false)
|
||||
resetCreateWorkspaceForm()
|
||||
setIsCreatingWorkspace(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/teams`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: trimmed,
|
||||
description,
|
||||
is_public: isPublic,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Failed to create workspace: ${response.status}`
|
||||
try {
|
||||
const data = await response.json()
|
||||
message = data?.error || data?.message || message
|
||||
} catch {
|
||||
// Keep fallback message
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const createdWorkspace = normalizeWorkspace(data.team)
|
||||
setWorkspaces((prev) => [createdWorkspace, ...prev])
|
||||
handleWorkspaceSelect(createdWorkspace)
|
||||
setIsCreateWorkspaceModalOpen(false)
|
||||
resetCreateWorkspaceForm()
|
||||
} catch (error) {
|
||||
console.error('Failed to create workspace:', error)
|
||||
setCreateWorkspaceError(error instanceof Error ? error.message : 'Failed to create workspace.')
|
||||
} finally {
|
||||
setIsCreatingWorkspace(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
onMount(() => {
|
||||
void loadWorkspaces()
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
@@ -85,28 +318,34 @@ export function Sidebar(props: SidebarProps) {
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
onCleanup(() => document.removeEventListener('click', handleClickOutside))
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Close Button - Above sidebar */}
|
||||
<Show when={props.isOpen}>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="fixed top-4 right-4 z-50 md:hidden 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>
|
||||
</Show>
|
||||
|
||||
<div class={`fixed inset-y-0 left-0 z-50 w-280px border-r border-r-border flex-shrink-0 bg-card transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 ${
|
||||
props.isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}>
|
||||
<div class="flex h-full">
|
||||
<div
|
||||
class={`fixed inset-y-0 left-0 z-50 border-r border-r-border bg-card transition-all duration-300 ease-in-out overflow-hidden md:relative md:inset-y-auto md:left-auto md:transform-none ${
|
||||
props.isOpen ? 'w-[280px] translate-x-0' : 'w-[280px] -translate-x-full md:w-0 md:translate-x-0 md:pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div class="w-[280px] h-full flex">
|
||||
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
|
||||
<div class="px-4 pt-4">
|
||||
<A
|
||||
href="/app"
|
||||
class="flex items-center gap-3 rounded-lg px-2 py-2 hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<img
|
||||
src="/trackeep.svg"
|
||||
alt="Trackeep Logo"
|
||||
class="w-7 h-7 app-logo-mono"
|
||||
/>
|
||||
<span class="font-semibold tracking-tight text-foreground">Trackeep</span>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
{/* Organization Selector */}
|
||||
<div class="p-4 pb-0 min-w-0 max-w-full" id="workspace-selector">
|
||||
<div class="p-4 pb-0 pt-3 min-w-0 max-w-full" id="workspace-selector">
|
||||
<div role="group" class="w-full relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -133,7 +372,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
<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}>
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -156,6 +395,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
<div class="border-t border-border mt-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateWorkspaceModal}
|
||||
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" />
|
||||
@@ -262,9 +502,8 @@ export function Sidebar(props: SidebarProps) {
|
||||
}}></div>
|
||||
</A>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Handle logout logic here
|
||||
localStorage.removeItem('auth_token')
|
||||
onClick={async () => {
|
||||
await logout()
|
||||
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"
|
||||
@@ -279,6 +518,87 @@ export function Sidebar(props: SidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isCreateWorkspaceModalOpen()}>
|
||||
<ModalPortal>
|
||||
<>
|
||||
<div
|
||||
class="fixed inset-0 z-[90] bg-black/50"
|
||||
onClick={closeCreateWorkspaceModal}
|
||||
/>
|
||||
<div class="fixed top-1/2 left-1/2 z-[100] w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4">
|
||||
<div class="rounded-lg border border-border bg-card shadow-xl">
|
||||
<div class="border-b border-border p-5">
|
||||
<h3 class="text-lg font-semibold text-foreground">Create Workspace</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Add a new workspace for your team or projects.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="space-y-4 p-5"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-foreground">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Workspace name"
|
||||
value={workspaceName()}
|
||||
onInput={(event) => setWorkspaceName((event.currentTarget as HTMLInputElement).value)}
|
||||
required
|
||||
disabled={isCreatingWorkspace()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-foreground" for="workspace-description">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="workspace-description"
|
||||
rows={3}
|
||||
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Optional description"
|
||||
value={workspaceDescription()}
|
||||
onInput={(event) => setWorkspaceDescription((event.currentTarget as HTMLTextAreaElement).value)}
|
||||
disabled={isCreatingWorkspace()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Public workspace</p>
|
||||
<p class="text-xs text-muted-foreground">Allow all members to discover this workspace.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={workspaceIsPublic()}
|
||||
onCheckedChange={setWorkspaceIsPublic}
|
||||
disabled={isCreatingWorkspace()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={createWorkspaceError()}>
|
||||
<p class="text-sm text-destructive">{createWorkspaceError()}</p>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeCreateWorkspaceModal}
|
||||
disabled={isCreatingWorkspace()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleCreateWorkspace()} disabled={isCreatingWorkspace()}>
|
||||
{isCreatingWorkspace() ? 'Creating...' : 'Create Workspace'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
IconClock,
|
||||
IconExternalLink
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
@@ -27,6 +30,7 @@ interface ActivityItem {
|
||||
language?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
displayTimestamp?: string;
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
@@ -40,6 +44,21 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
const [filter, setFilter] = createSignal<'all' | 'trackeep' | 'github'>('all');
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
const normalizeActivityType = (type: string): ActivityItem['type'] => {
|
||||
if (type === 'bookmark' || type === 'task' || type === 'note' || type === 'file') {
|
||||
return type;
|
||||
}
|
||||
return 'task';
|
||||
};
|
||||
|
||||
const formatTimestamp = (value: string): string => {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'bookmark': return IconBookmark;
|
||||
@@ -57,79 +76,37 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
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
|
||||
|
||||
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/dashboard/stats`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch activities: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const recentActivities: Array<{ id?: number; type?: string; title?: string; timestamp?: string }> = Array.isArray(data.recentActivity)
|
||||
? data.recentActivity
|
||||
: [];
|
||||
|
||||
recentActivities.forEach((activity, index) => {
|
||||
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
|
||||
}
|
||||
id: String(activity.id ?? `activity-${index}`),
|
||||
type: normalizeActivityType(activity.type || ''),
|
||||
title: activity.title || 'Activity',
|
||||
description: activity.type || 'trackeep',
|
||||
timestamp: new Date().toISOString(),
|
||||
displayTimestamp: activity.timestamp || '',
|
||||
source: 'trackeep',
|
||||
});
|
||||
});
|
||||
|
||||
// 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) =>
|
||||
@@ -149,6 +126,7 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
setActivities(limitedActivities);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch activities:', error);
|
||||
setActivities([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -179,7 +157,10 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
{props.showFilter && (
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
onClick={() => {
|
||||
setFilter('all');
|
||||
fetchActivities();
|
||||
}}
|
||||
class={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
filter() === 'all'
|
||||
? 'bg-[#262626] text-[#fafafa]'
|
||||
@@ -189,7 +170,10 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('trackeep')}
|
||||
onClick={() => {
|
||||
setFilter('trackeep');
|
||||
fetchActivities();
|
||||
}}
|
||||
class={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
filter() === 'trackeep'
|
||||
? 'bg-[#262626] text-[#fafafa]'
|
||||
@@ -199,7 +183,10 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
Trackeep
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('github')}
|
||||
onClick={() => {
|
||||
setFilter('github');
|
||||
fetchActivities();
|
||||
}}
|
||||
class={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
filter() === 'github'
|
||||
? 'bg-[#262626] text-[#fafafa]'
|
||||
@@ -220,68 +207,70 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{activities().length > 0 && (
|
||||
<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>{activity.displayTimestamp || formatTimestamp(activity.timestamp)}</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>
|
||||
{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>
|
||||
);
|
||||
}}
|
||||
</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-[#a3a3a3]">No activity yet</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>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface BookmarkModalProps {
|
||||
@@ -52,92 +53,94 @@ export const BookmarkModal = (props: BookmarkModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-[60] mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-[60]" 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-[70] ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: min(500px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 sm: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>
|
||||
{/* 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-[70] ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: min(500px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 sm: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-4 sm:p-6 space-y-4">
|
||||
<div class="relative">
|
||||
{/* Content */}
|
||||
<div class="p-4 sm: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="url"
|
||||
placeholder="URL *"
|
||||
value={newBookmark().url}
|
||||
type="text"
|
||||
placeholder="Title (optional)"
|
||||
value={newBookmark().title}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setNewBookmark(prev => ({ ...prev, url: target.value }));
|
||||
if (target) setNewBookmark(prev => ({ ...prev, title: 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}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div class="flex flex-col sm:flex-row justify-end gap-2 p-4 sm:p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={props.onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!newBookmark().url.trim()}>
|
||||
Save Bookmark
|
||||
</Button>
|
||||
{/* Footer */}
|
||||
<div class="flex flex-col sm:flex-row justify-end gap-2 p-4 sm: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>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -386,10 +386,6 @@
|
||||
inset: -5px;
|
||||
}
|
||||
|
||||
.-translate-y-1\/2 {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* Z-index utilities */
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX, IconAlertTriangle } from '@tabler/icons-solidjs';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
@@ -45,45 +46,47 @@ export const ConfirmModal = (props: ConfirmModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
<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>
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Bookmark {
|
||||
@@ -71,79 +72,81 @@ export const EditBookmarkModal = (props: EditBookmarkModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* 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>
|
||||
{/* 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}
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX, IconDownload, IconExternalLink, IconEye, IconFile, IconCode, IconFileText, IconAlertTriangle, IconMusic, IconFileDescription, IconChartBar, IconChartLine } from '@tabler/icons-solidjs';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -168,12 +170,7 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
||||
};
|
||||
|
||||
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) {
|
||||
if (isDemoMode()) {
|
||||
// Simulate download in demo mode
|
||||
alert(`Download simulated for: ${props.file.name}\n\nIn production, this would download the actual file.`);
|
||||
return;
|
||||
@@ -190,31 +187,32 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
<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;">
|
||||
@@ -251,7 +249,8 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ModalPortal } from './ModalPortal';
|
||||
import './FileUpload.css';
|
||||
|
||||
export interface FileUploadProps {
|
||||
@@ -191,17 +192,26 @@ export const FileUpload = (props: FileUploadProps) => {
|
||||
props.onClose?.();
|
||||
};
|
||||
|
||||
if (!props.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"relative w-full rounded-20 bg-bg-white-0 focus:outline-none 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 max-w-[440px] shadow-custom-md",
|
||||
props.class
|
||||
)}
|
||||
role="dialog"
|
||||
aria-labelledby="file-upload-title"
|
||||
aria-describedby="file-upload-description"
|
||||
data-state={props.isOpen ? 'open' : 'closed'}
|
||||
>
|
||||
<ModalPortal>
|
||||
<>
|
||||
<div class="fixed inset-0 z-[80] bg-black/50" onClick={handleClose} />
|
||||
<div class="fixed top-1/2 left-1/2 z-[90] w-[min(440px,90vw)] max-h-[85vh] -translate-x-1/2 -translate-y-1/2 overflow-y-auto">
|
||||
<div
|
||||
class={cn(
|
||||
"relative w-full rounded-20 bg-bg-white-0 focus:outline-none 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 max-w-[440px] shadow-custom-md",
|
||||
props.class
|
||||
)}
|
||||
role="dialog"
|
||||
aria-labelledby="file-upload-title"
|
||||
aria-describedby="file-upload-description"
|
||||
data-state="open"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="relative flex items-start gap-3.5 py-4 pl-5 pr-14 before:absolute before:inset-x-0 before:bottom-0 before:border-b before:border-stroke-soft-200">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-bg-white-0 ring-1 ring-inset ring-stroke-soft-200">
|
||||
@@ -366,6 +376,9 @@ export const FileUpload = (props: FileUploadProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import {
|
||||
IconX,
|
||||
IconUpload,
|
||||
@@ -153,15 +154,16 @@ export const FileUploadModal = (props: FileUploadModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0"
|
||||
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()}
|
||||
<ModalPortal>
|
||||
<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>
|
||||
@@ -382,8 +384,9 @@ export const FileUploadModal = (props: FileUploadModalProps) => {
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,141 +51,20 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
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);
|
||||
const setEmptyData = () => {
|
||||
setActivities([]);
|
||||
setRecentEvents(props.customEvents || []);
|
||||
setStats({
|
||||
totalContributions,
|
||||
currentStreak,
|
||||
longestStreak: Math.max(longestStreak, tempStreak)
|
||||
totalContributions: 0,
|
||||
currentStreak: 0,
|
||||
longestStreak: 0
|
||||
});
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
setEmptyData();
|
||||
});
|
||||
|
||||
const getMonthLabels = () => {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const today = new Date();
|
||||
@@ -325,75 +204,84 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Month labels - Show all months with responsive spacing */}
|
||||
<div class="flex justify-between mb-3 px-6 sm:px-8 text-xs sm:text-sm font-medium overflow-x-auto">
|
||||
<div class="flex gap-2 sm:gap-3 min-w-max">
|
||||
{getMonthLabels().map((month) => (
|
||||
<span class="text-foreground/80 hover:text-foreground transition-colors cursor-default whitespace-nowrap">
|
||||
{month}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
<Show
|
||||
when={activities().length > 0}
|
||||
fallback={
|
||||
<div class="h-44 border border-dashed border-border rounded-lg flex items-center justify-center">
|
||||
<p class="text-sm text-muted-foreground">No GitHub contribution data yet.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Month labels - Show all months with responsive spacing */}
|
||||
<div class="flex justify-between mb-3 px-6 sm:px-8 text-xs sm:text-sm font-medium overflow-x-auto">
|
||||
<div class="flex gap-2 sm:gap-3 min-w-max">
|
||||
{getMonthLabels().map((month) => (
|
||||
<span class="text-foreground/80 hover:text-foreground transition-colors cursor-default whitespace-nowrap">
|
||||
{month}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly columns - Responsive with proper overflow handling */}
|
||||
<div class="flex gap-1 overflow-x-auto overflow-y-hidden min-w-0 pb-2">
|
||||
{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];
|
||||
{/* 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 pb-2">
|
||||
{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.5 h-2.5 sm:w-3 sm:h-3 rounded-sm flex-shrink-0 transition-all"
|
||||
style={`background-color: ${getActivityColor(0)}`}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activity) {
|
||||
return (
|
||||
<div
|
||||
class="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-sm flex-shrink-0 transition-all"
|
||||
style={`background-color: ${getActivityColor(0)}`}
|
||||
class="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-sm hover:ring-1 hover:ring-primary cursor-pointer transition-all flex-shrink-0 hover:scale-110"
|
||||
style={`background-color: ${getActivityColor(activity.level)}`}
|
||||
title={`${activity.date}: ${activity.count} contributions`}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-sm hover:ring-1 hover:ring-primary cursor-pointer transition-all flex-shrink-0 hover:scale-110"
|
||||
style={`background-color: ${getActivityColor(activity.level)}`}
|
||||
title={`${activity.date}: ${activity.count} contributions`}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
})}
|
||||
</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.5 h-2.5 sm:w-3 sm:h-3 rounded-sm"
|
||||
style={`background-color: ${getActivityColor(level)}`}
|
||||
></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.5 h-2.5 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>
|
||||
<span class="text-xs text-muted-foreground">More</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
@@ -407,52 +295,56 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<Show
|
||||
when={recentEvents().length > 0}
|
||||
fallback={<p class="text-sm text-muted-foreground">No GitHub events yet.</p>}
|
||||
>
|
||||
<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={() => {
|
||||
if (event.link) {
|
||||
window.location.href = event.link;
|
||||
}
|
||||
}}
|
||||
class="hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<IconExternalLink class="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface LearningPathFormData {
|
||||
@@ -100,8 +101,9 @@ export const LearningPathModal = (props: LearningPathModalProps) => {
|
||||
if (!props.isOpen) return null;
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0">
|
||||
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
||||
<ModalPortal>
|
||||
<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]">
|
||||
@@ -264,7 +266,8 @@ export const LearningPathModal = (props: LearningPathModalProps) => {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX, IconClock, IconUsers, IconStar, IconBook, IconVideo, IconFileText, IconCode, IconCheck } from '@tabler/icons-solidjs';
|
||||
|
||||
interface LearningPath {
|
||||
@@ -82,8 +83,9 @@ export const LearningPathPreviewModal = (props: LearningPathPreviewModalProps) =
|
||||
if (!props.isOpen || !props.learningPath) return null;
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0">
|
||||
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-4xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
||||
<ModalPortal>
|
||||
<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 */}
|
||||
@@ -241,7 +243,8 @@ export const LearningPathPreviewModal = (props: LearningPathPreviewModalProps) =
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Member {
|
||||
@@ -55,74 +56,76 @@ export const MemberModal = (props: MemberModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* 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"
|
||||
{/* 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"
|
||||
>
|
||||
<option value="Member">Member</option>
|
||||
<option value="Admin">Admin</option>
|
||||
</select>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Portal } from 'solid-js/web'
|
||||
|
||||
interface ModalPortalProps {
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const ModalPortal = (props: ModalPortalProps) => {
|
||||
return <Portal mount={document.body}>{props.children}</Portal>
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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 { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX, IconTag } from '@tabler/icons-solidjs';
|
||||
|
||||
interface NoteModalProps {
|
||||
@@ -34,74 +35,76 @@ export const NoteModal = (props: NoteModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* 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"
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
<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}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,12 +11,13 @@ interface SearchTagFilterBarProps {
|
||||
selectedTag: string;
|
||||
onTagChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
allOptionLabel?: string;
|
||||
}
|
||||
|
||||
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">
|
||||
<div class="mb-6 space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-[17fr_3fr] gap-3 items-stretch sm:items-center">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={props.searchPlaceholder}
|
||||
@@ -25,14 +26,14 @@ export const SearchTagFilterBar = (props: SearchTagFilterBarProps) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) props.onSearchChange(target.value);
|
||||
}}
|
||||
class="flex-1"
|
||||
class="w-full min-w-0"
|
||||
/>
|
||||
<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"
|
||||
class="flex h-10 w-full min-w-0 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>
|
||||
<option value="">{props.allOptionLabel || 'All Tags'}</option>
|
||||
<For each={props.tagOptions}>
|
||||
{(tag) => <option value={tag}>{tag}</option>}
|
||||
</For>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { DatePicker } from '@/components/ui/DatePicker';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Task {
|
||||
@@ -78,79 +79,81 @@ export const TaskModal = (props: TaskModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-[60]" 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"
|
||||
{/* 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-[70] w-full max-w-lg mx-4 ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`}>
|
||||
{/* 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"
|
||||
>
|
||||
<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"
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconLoader2
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { updateStore } from '../../stores/updateStore';
|
||||
import { ModalPortal } from './ModalPortal';
|
||||
|
||||
interface UpdateCheckerProps {
|
||||
class?: string;
|
||||
@@ -115,9 +116,10 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
|
||||
{/* Update Modal */}
|
||||
<Show when={showUpdateModal() && updateStore.updateInfo()}>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 mt-0">
|
||||
<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">
|
||||
<ModalPortal>
|
||||
<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>
|
||||
@@ -243,9 +245,10 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX, IconUpload } from '@tabler/icons-solidjs';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface UploadModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -39,13 +44,8 @@ export const UploadModal = (props: UploadModalProps) => {
|
||||
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) {
|
||||
if (isDemoMode()) {
|
||||
// Simulate upload in demo mode
|
||||
console.log('Demo mode: Simulating upload for files:', files.map(f => f.name));
|
||||
// Simulate upload delay
|
||||
@@ -62,7 +62,7 @@ export const UploadModal = (props: UploadModalProps) => {
|
||||
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`, {
|
||||
const response = await fetch(`${API_BASE_URL}/files/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
@@ -86,26 +86,27 @@ export const UploadModal = (props: UploadModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-[60] mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-[60]" 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-[70] ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: min(600px, 90vw); max-height: min(80vh, 600px); 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>
|
||||
{/* 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-[70] ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: min(600px, 90vw); max-height: min(80vh, 600px); 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-4 sm:p-6 space-y-4">
|
||||
@@ -175,7 +176,8 @@ export const UploadModal = (props: UploadModalProps) => {
|
||||
Upload {uploadedFiles().length} {uploadedFiles().length === 1 ? 'File' : 'Files'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { createEffect, createMemo, createSignal } from 'solid-js';
|
||||
import {
|
||||
IconUser,
|
||||
IconSettings,
|
||||
@@ -7,21 +7,69 @@ import {
|
||||
IconChevronDown
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { DropdownMenu, DropdownMenuItem } from './DropdownMenu';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
interface UserProfile {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
role: string;
|
||||
joinDate: string;
|
||||
interface DashboardStats {
|
||||
totalBookmarks: number;
|
||||
totalTasks: number;
|
||||
}
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
export const UserProfileDropdown = () => {
|
||||
const [userProfile] = createSignal<UserProfile>({
|
||||
name: 'Admin User',
|
||||
email: 'admin@trackeep.com',
|
||||
role: 'Administrator',
|
||||
joinDate: '2024-01-01'
|
||||
const { logout, authState } = useAuth();
|
||||
const [dashboardStats, setDashboardStats] = createSignal<DashboardStats>({
|
||||
totalBookmarks: 0,
|
||||
totalTasks: 0,
|
||||
});
|
||||
|
||||
const displayName = createMemo(() => {
|
||||
const user = authState.user;
|
||||
if (!user) return 'User';
|
||||
return user.full_name?.trim() || user.username?.trim() || user.email?.split('@')[0] || 'User';
|
||||
});
|
||||
|
||||
const displayEmail = createMemo(() => authState.user?.email || 'unknown@trackeep.com');
|
||||
|
||||
const loadDashboardStats = async () => {
|
||||
if (!authState.isAuthenticated) {
|
||||
setDashboardStats({ totalBookmarks: 0, totalTasks: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
setDashboardStats({ totalBookmarks: 0, totalTasks: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dashboard/stats`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load dashboard stats: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setDashboardStats({
|
||||
totalBookmarks: Number(data?.totalBookmarks || 0),
|
||||
totalTasks: Number(data?.totalTasks || 0),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load user stats:', error);
|
||||
setDashboardStats({ totalBookmarks: 0, totalTasks: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (authState.isAuthenticated) {
|
||||
void loadDashboardStats();
|
||||
return;
|
||||
}
|
||||
setDashboardStats({ totalBookmarks: 0, totalTasks: 0 });
|
||||
});
|
||||
|
||||
const handleProfileClick = () => {
|
||||
@@ -36,20 +84,22 @@ export const UserProfileDropdown = () => {
|
||||
window.location.href = '/app/stats';
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = async () => {
|
||||
if (confirm('Are you sure you want to logout?')) {
|
||||
// In real app, this would clear auth tokens and redirect to login
|
||||
await logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
return initials || 'U';
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -57,7 +107,7 @@ export const UserProfileDropdown = () => {
|
||||
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)}
|
||||
{getInitials(displayName())}
|
||||
</div>
|
||||
<IconChevronDown class="size-3 opacity-50" />
|
||||
</button>
|
||||
@@ -67,11 +117,11 @@ export const UserProfileDropdown = () => {
|
||||
<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)}
|
||||
{getInitials(displayName())}
|
||||
</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>
|
||||
<p class="text-sm font-medium truncate">{displayName()}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">{displayEmail()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,11 +130,11 @@ export const UserProfileDropdown = () => {
|
||||
<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="font-medium text-primary">{dashboardStats().totalBookmarks}</p>
|
||||
<p class="text-muted-foreground">Bookmarks</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="font-medium text-primary">42</p>
|
||||
<p class="font-medium text-primary">{dashboardStats().totalTasks}</p>
|
||||
<p class="text-muted-foreground">Tasks</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX, IconExternalLink } from '@tabler/icons-solidjs';
|
||||
|
||||
interface VideoPreviewModalProps {
|
||||
@@ -13,62 +14,64 @@ export const VideoPreviewModal = (props: VideoPreviewModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* 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"
|
||||
{/* 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"
|
||||
>
|
||||
<IconExternalLink class="size-4" />
|
||||
Open on YouTube
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { IconX } from '@tabler/icons-solidjs';
|
||||
|
||||
interface VideoUploadModalProps {
|
||||
@@ -46,26 +47,27 @@ export const VideoUploadModal = (props: VideoUploadModalProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-[60] mt-0" onClick={props.onClose} />
|
||||
)}
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{props.isOpen && (
|
||||
<div class="fixed inset-0 bg-black/50 z-[60]" 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-[70] ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: min(500px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 sm: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>
|
||||
{/* 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-[70] ${
|
||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||
}`} style="width: min(500px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||
{/* Header */}
|
||||
<div class="flex items-center justify-between p-4 sm: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-4 sm:p-6 space-y-4">
|
||||
@@ -119,7 +121,8 @@ export const VideoUploadModal = (props: VideoUploadModalProps) => {
|
||||
Add Video
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { For, Show, createEffect } from 'solid-js';
|
||||
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
||||
|
||||
@@ -65,23 +66,24 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<Show when={props.isOpen && props.note}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 z-50"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
</Show>
|
||||
<ModalPortal>
|
||||
<>
|
||||
{/* 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 && (
|
||||
<>
|
||||
{/* 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">
|
||||
@@ -239,10 +241,11 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
</ModalPortal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -890,3 +890,12 @@ button.hover\:text-primary\/80:hover,
|
||||
button.hover\:text-primary\/80:hover svg {
|
||||
color: hsl(var(--primary) / 0.8) !important;
|
||||
}
|
||||
|
||||
/* Shared monochrome logo styling for consistent contrast */
|
||||
.app-logo-mono {
|
||||
filter: brightness(0);
|
||||
}
|
||||
|
||||
[data-kb-theme="dark"] .app-logo-mono {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const DEFAULT_API_ORIGIN = 'http://localhost:8080';
|
||||
|
||||
const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
|
||||
|
||||
const trimApiSuffix = (value: string): string => value.replace(/\/api\/v1$/, '');
|
||||
|
||||
export const getApiOrigin = (): string => {
|
||||
const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim();
|
||||
if (!raw) {
|
||||
return DEFAULT_API_ORIGIN;
|
||||
}
|
||||
|
||||
const normalized = trimTrailingSlash(raw);
|
||||
return trimApiSuffix(normalized);
|
||||
};
|
||||
|
||||
export const getApiV1BaseUrl = (): string => {
|
||||
const origin = getApiOrigin();
|
||||
return `${origin}/api/v1`;
|
||||
};
|
||||
+10
-10
@@ -1,10 +1,11 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
// Check if we're in demo mode
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
// Demo mode is controlled by environment only.
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
return isEnvDemoMode();
|
||||
};
|
||||
|
||||
// Helper function to get auth headers
|
||||
@@ -47,15 +48,14 @@ class ApiClient {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
// If backend fails, fall back to demo mode
|
||||
console.warn(`API endpoint ${endpoint} failed, falling back to demo mode`);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
const message = await response.text();
|
||||
throw new Error(message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn(`API request failed for ${endpoint}, falling back to demo mode:`, error);
|
||||
return this.getMockResponse(endpoint, options);
|
||||
console.error(`API request failed for ${endpoint}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createContext, useContext, type ParentComponent, onMount } from 'solid-js';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
// Check if we're in demo mode (same logic as api.ts)
|
||||
// Demo mode is controlled by environment only.
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
return isEnvDemoMode();
|
||||
};
|
||||
|
||||
// Types
|
||||
@@ -44,7 +44,7 @@ export interface AuthResponse {
|
||||
}
|
||||
|
||||
// API base URL
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
// Create auth context
|
||||
const AuthContext = createContext<AuthContextType>();
|
||||
@@ -67,7 +67,7 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false, // Start with false to avoid loading spinner in ProtectedRoute
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
@@ -400,18 +400,12 @@ export const useAuth = () => {
|
||||
|
||||
// Helper function to get auth headers for API requests
|
||||
export const getAuthHeaders = () => {
|
||||
// Check if we're in demo mode first
|
||||
const isDemo = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
|
||||
const isDemo = isDemoMode();
|
||||
let token = null;
|
||||
|
||||
if (isDemo) {
|
||||
// In demo mode, use a mock token
|
||||
token = 'demo-token-' + Date.now();
|
||||
token = localStorage.getItem('token') || localStorage.getItem('trackeep_token') || ('demo-token-' + Date.now());
|
||||
} else {
|
||||
// In normal mode, get token from localStorage
|
||||
token = localStorage.getItem('token') || localStorage.getItem('trackeep_token');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Brave Search API integration
|
||||
const BACKEND_API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
const BACKEND_API_URL = getApiV1BaseUrl();
|
||||
const BRAVE_API_KEY = import.meta.env.VITE_BRAVE_API_KEY || 'BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT';
|
||||
|
||||
// Use the variable to avoid unused warning
|
||||
@@ -7,10 +10,7 @@ console.log('Brave API key available:', !!BRAVE_API_KEY);
|
||||
|
||||
// Helper function to get auth headers
|
||||
const getAuthHeaders = () => {
|
||||
// Check if we're in demo mode
|
||||
const isDemo = import.meta.env.VITE_DEMO_MODE === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
const isDemo = isDemoMode();
|
||||
|
||||
let token = null;
|
||||
|
||||
|
||||
@@ -11,13 +11,7 @@ import {
|
||||
getMockLearningPaths,
|
||||
getMockStats
|
||||
} from './mockData';
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
import { isDemoMode } from './demo-mode';
|
||||
|
||||
// Demo mode API client that falls back to mock data
|
||||
export class DemoModeApiClient {
|
||||
|
||||
@@ -106,6 +106,15 @@ export interface VaultItem {
|
||||
target_conversation_id?: number | null;
|
||||
}
|
||||
|
||||
export interface UserFile {
|
||||
id: number;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
created_at: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface WsEvent {
|
||||
type: string;
|
||||
conversation_id?: number;
|
||||
@@ -186,6 +195,11 @@ export const messagesApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
revealSensitiveMessage: (messageId: number) =>
|
||||
apiRequest<{ message_id: number; plaintext: string }>(`/messages/${messageId}/reveal-sensitive`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
listVaultItems: () => apiRequest<{ items: VaultItem[] }>('/password-vault/items'),
|
||||
createVaultItem: (payload: any) => apiRequest<any>('/password-vault/items', {
|
||||
method: 'POST',
|
||||
@@ -206,6 +220,28 @@ export const messagesApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
listUserFiles: async (query: string = '', limit: number = 20): Promise<UserFile[]> => {
|
||||
const token = getToken();
|
||||
const params = new URLSearchParams();
|
||||
if (query.trim()) {
|
||||
params.set('q', query.trim());
|
||||
}
|
||||
if (Number.isFinite(limit) && limit > 0) {
|
||||
params.set('limit', String(Math.min(100, Math.floor(limit))));
|
||||
}
|
||||
|
||||
const suffix = params.toString() ? `?${params.toString()}` : '';
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/files${suffix}`, {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Request failed (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
export async function uploadChatFile(file: File): Promise<{ id: number; original_name: string; mime_type: string }> {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { VideoUploadModal } from '@/components/ui/VideoUploadModal';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs';
|
||||
import { getMockBookmarks, getMockVideos } from '@/lib/mockData';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface BookmarkTag {
|
||||
id: number;
|
||||
@@ -113,39 +115,7 @@ export const Bookmarks = () => {
|
||||
// We no longer show inline HTML content previews, only the bookmark cards themselves
|
||||
|
||||
onMount(async () => {
|
||||
// Check if we're in demo mode and load mock data directly
|
||||
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
|
||||
if (isDemoMode) {
|
||||
console.log('Demo mode detected, loading mock bookmarks');
|
||||
const mockBookmarks = getMockBookmarks();
|
||||
const adaptedBookmarks: Bookmark[] = mockBookmarks.map((bookmark, index) => ({
|
||||
id: index + 1,
|
||||
title: bookmark.title,
|
||||
url: bookmark.url,
|
||||
description: bookmark.description,
|
||||
tags: bookmark.tags.map((tag) => tag.name),
|
||||
created_at: bookmark.createdAt,
|
||||
isImportant: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
|
||||
favicon: bookmark.favicon,
|
||||
screenshot: bookmark.screenshot,
|
||||
screenshot_medium: bookmark.screenshot,
|
||||
}));
|
||||
setBookmarks(adaptedBookmarks);
|
||||
setIsLoading(false);
|
||||
|
||||
// Load mock video bookmarks
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
setIsLoadingVideos(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api/v1';
|
||||
|
||||
// Load regular bookmarks
|
||||
const bookmarksResponse = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
headers: {
|
||||
@@ -176,38 +146,18 @@ export const Bookmarks = () => {
|
||||
const videosData = await videosResponse.json();
|
||||
setVideoBookmarks(Array.isArray(videosData) ? videosData : []);
|
||||
} else {
|
||||
// If video endpoint fails, load mock videos as fallback
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
setVideoBookmarks([]);
|
||||
}
|
||||
} catch (videoError) {
|
||||
console.warn('Failed to load video bookmarks, using mock data:', videoError);
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
console.warn('Failed to load video bookmarks:', videoError);
|
||||
setVideoBookmarks([]);
|
||||
}
|
||||
|
||||
setIsLoadingVideos(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks:', error);
|
||||
// Fallback to mock data if API fails
|
||||
const mockBookmarks = getMockBookmarks();
|
||||
const adaptedBookmarks: Bookmark[] = mockBookmarks.map((bookmark, index) => ({
|
||||
id: index + 1,
|
||||
title: bookmark.title,
|
||||
url: bookmark.url,
|
||||
description: bookmark.description,
|
||||
tags: bookmark.tags.map((tag) => tag.name),
|
||||
created_at: bookmark.createdAt,
|
||||
isImportant: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
|
||||
favicon: bookmark.favicon,
|
||||
screenshot: bookmark.screenshot,
|
||||
screenshot_medium: bookmark.screenshot,
|
||||
}));
|
||||
setBookmarks(adaptedBookmarks);
|
||||
|
||||
// Also load mock videos as fallback
|
||||
const mockVideos = getMockVideos();
|
||||
setVideoBookmarks(mockVideos);
|
||||
setBookmarks([]);
|
||||
setVideoBookmarks([]);
|
||||
setIsLoadingVideos(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -392,7 +342,7 @@ export const Bookmarks = () => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
'Authorization': `Bearer ${localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''}`
|
||||
},
|
||||
body: JSON.stringify({ video_id: video.video_id })
|
||||
});
|
||||
@@ -400,7 +350,7 @@ export const Bookmarks = () => {
|
||||
if (response.ok) {
|
||||
console.log('Video added:', video);
|
||||
} else {
|
||||
console.log('Video added (demo mode):', video);
|
||||
console.warn('Video save endpoint returned non-OK status');
|
||||
}
|
||||
setShowVideoModal(false);
|
||||
} catch (error) {
|
||||
@@ -414,14 +364,6 @@ export const Bookmarks = () => {
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">Bookmarks</h1>
|
||||
<Show when={localStorage.getItem('demoMode') === 'true' || window.location.search.includes('demo=true')}>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded-full">
|
||||
Demo Mode
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">Showing sample bookmarks</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={activeTab() === 'bookmarks'}>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
|
||||
+104
-85
@@ -1,5 +1,6 @@
|
||||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js'
|
||||
import { DateRangePicker } from '@/components/ui/DateRangePicker';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
@@ -86,8 +87,8 @@ export function Calendar() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (isDemoModeEnabled() || !token) {
|
||||
// Use mock data in demo mode or when not authenticated
|
||||
if (isDemoModeEnabled()) {
|
||||
// Use mock data in demo mode
|
||||
const mockEvents = getMockCalendarEvents();
|
||||
|
||||
const today = new Date();
|
||||
@@ -131,6 +132,14 @@ export function Calendar() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setMappedEvents([]);
|
||||
setTodayEvents([]);
|
||||
setUpcomingEvents([]);
|
||||
setDeadlines([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
@@ -159,46 +168,52 @@ export function Calendar() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch calendar data:', error)
|
||||
// Fallback to mock data if API fails
|
||||
const mockEvents = getMockCalendarEvents();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const weekFromNow = new Date();
|
||||
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
||||
|
||||
// Map mock events to calendar events and store for calendar grid
|
||||
const mappedEvents: CalendarEvent[] = mockEvents.map(event => ({
|
||||
id: parseInt(event.id),
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start_time: event.start,
|
||||
end_time: event.end,
|
||||
type: event.type === 'personal' ? 'reminder' : event.type as 'task' | 'meeting' | 'deadline' | 'reminder' | 'habit',
|
||||
priority: 'medium' as 'low' | 'medium' | 'high' | 'urgent',
|
||||
location: event.location,
|
||||
is_completed: false,
|
||||
is_all_day: event.allDay
|
||||
}));
|
||||
|
||||
setMappedEvents(mappedEvents);
|
||||
|
||||
const todayEvents = mappedEvents.filter(event => {
|
||||
const eventDate = new Date(event.start_time);
|
||||
return eventDate.toDateString() === today.toDateString();
|
||||
});
|
||||
|
||||
const upcomingEvents = mappedEvents.filter(event => {
|
||||
const eventDate = new Date(event.start_time);
|
||||
return eventDate >= today && eventDate <= weekFromNow;
|
||||
});
|
||||
|
||||
const deadlines = mappedEvents.filter(event =>
|
||||
event.type === 'deadline' && new Date(event.start_time) >= today
|
||||
);
|
||||
|
||||
setTodayEvents(todayEvents);
|
||||
setUpcomingEvents(upcomingEvents);
|
||||
setDeadlines(deadlines);
|
||||
if (isDemoModeEnabled()) {
|
||||
const mockEvents = getMockCalendarEvents();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const weekFromNow = new Date();
|
||||
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
||||
|
||||
const mappedEvents: CalendarEvent[] = mockEvents.map(event => ({
|
||||
id: parseInt(event.id),
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start_time: event.start,
|
||||
end_time: event.end,
|
||||
type: event.type === 'personal' ? 'reminder' : event.type as 'task' | 'meeting' | 'deadline' | 'reminder' | 'habit',
|
||||
priority: 'medium' as 'low' | 'medium' | 'high' | 'urgent',
|
||||
location: event.location,
|
||||
is_completed: false,
|
||||
is_all_day: event.allDay
|
||||
}));
|
||||
|
||||
setMappedEvents(mappedEvents);
|
||||
|
||||
const todayEvents = mappedEvents.filter(event => {
|
||||
const eventDate = new Date(event.start_time);
|
||||
return eventDate.toDateString() === today.toDateString();
|
||||
});
|
||||
|
||||
const upcomingEvents = mappedEvents.filter(event => {
|
||||
const eventDate = new Date(event.start_time);
|
||||
return eventDate >= today && eventDate <= weekFromNow;
|
||||
});
|
||||
|
||||
const deadlines = mappedEvents.filter(event =>
|
||||
event.type === 'deadline' && new Date(event.start_time) >= today
|
||||
);
|
||||
|
||||
setTodayEvents(todayEvents);
|
||||
setUpcomingEvents(upcomingEvents);
|
||||
setDeadlines(deadlines);
|
||||
return;
|
||||
}
|
||||
|
||||
setMappedEvents([]);
|
||||
setTodayEvents([]);
|
||||
setUpcomingEvents([]);
|
||||
setDeadlines([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,25 +790,26 @@ export function Calendar() {
|
||||
|
||||
{/* Event Creation Modal */}
|
||||
<Show when={showEventModal()}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0"
|
||||
onClick={(e) => {
|
||||
// Close modal only when clicking the backdrop, not the modal content
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowEventModal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="bg-card rounded-lg border border-border p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">New Event</h3>
|
||||
<button
|
||||
onClick={() => setShowEventModal(false)}
|
||||
class="p-1 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ModalPortal>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={(e) => {
|
||||
// Close modal only when clicking the backdrop, not the modal content
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowEventModal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="bg-card rounded-lg border border-border p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">New Event</h3>
|
||||
<button
|
||||
onClick={() => setShowEventModal(false)}
|
||||
class="p-1 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
@@ -936,34 +952,36 @@ export function Calendar() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
<Show when={showTaskDetailModal() && selectedTask()}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowTaskDetailModal(false);
|
||||
setSelectedTask(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="bg-card rounded-lg border border-border p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Task Details</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTaskDetailModal(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
class="p-1 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ModalPortal>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowTaskDetailModal(false);
|
||||
setSelectedTask(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="bg-card rounded-lg border border-border p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Task Details</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTaskDetailModal(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
class="p-1 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<IconX class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={selectedTask()}>
|
||||
{(task) => (
|
||||
@@ -1106,8 +1124,9 @@ export function Calendar() {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
+167
-137
@@ -1,4 +1,4 @@
|
||||
import { createResource, createSignal, For, Show, onMount } from 'solid-js'
|
||||
import { createEffect, createResource, createSignal, For, Show, onMount } from 'solid-js'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
@@ -35,6 +35,18 @@ interface ChatSession {
|
||||
include_notes: boolean
|
||||
}
|
||||
|
||||
const getToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''
|
||||
|
||||
const getProviderFromModel = (modelId: string): string => {
|
||||
const value = modelId.toLowerCase()
|
||||
if (value.includes('mistral')) return 'mistral'
|
||||
if (value.includes('grok')) return 'grok'
|
||||
if (value.includes('deepseek')) return 'deepseek'
|
||||
if (value.includes('ollama')) return 'ollama'
|
||||
if (value.includes('openrouter')) return 'openrouter'
|
||||
return 'longcat'
|
||||
}
|
||||
|
||||
const Chat = () => {
|
||||
const [activeView, setActiveView] = createSignal<'chat' | 'ai-tools'>('chat')
|
||||
const [aiTool, setAiTool] = createSignal<'summarizer' | 'tasks' | 'content'>('summarizer')
|
||||
@@ -118,7 +130,7 @@ const Chat = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Add all available providers (even if disabled) for demo purposes
|
||||
// Keep disabled providers visible so users can enable them in settings
|
||||
if (providers.length > 0) {
|
||||
providers.forEach((provider: any) => {
|
||||
if (!models.find(m => m.id === provider.id)) {
|
||||
@@ -160,50 +172,26 @@ const Chat = () => {
|
||||
return descriptions[provider] || 'AI provider'
|
||||
}
|
||||
|
||||
const [sessions] = createResource(async () => {
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions`)
|
||||
const token = getToken()
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions`, {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to fetch sessions')
|
||||
return response.json() as Promise<ChatSession[]>
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sessions:', error)
|
||||
// Return mock sessions for demo mode
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Getting Started',
|
||||
message_count: 2,
|
||||
last_message_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
include_bookmarks: true,
|
||||
include_tasks: true,
|
||||
include_files: true,
|
||||
include_notes: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Project Planning',
|
||||
message_count: 5,
|
||||
last_message_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
created_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
include_bookmarks: true,
|
||||
include_tasks: true,
|
||||
include_files: false,
|
||||
include_notes: true
|
||||
}
|
||||
] as ChatSession[])
|
||||
return [] as ChatSession[]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const [currentSessionId, setCurrentSessionId] = createSignal<string | null>('1')
|
||||
const [messages, setMessages] = createSignal<ChatMessage[]>([
|
||||
{
|
||||
id: 1,
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
const [sessions, { refetch: refetchSessions }] = createResource(fetchSessions)
|
||||
|
||||
const [currentSessionId, setCurrentSessionId] = createSignal<string | null>(null)
|
||||
const [messages, setMessages] = createSignal<ChatMessage[]>([])
|
||||
const [inputMessage, setInputMessage] = createSignal('')
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [showSettings, setShowSettings] = createSignal(false)
|
||||
@@ -226,6 +214,47 @@ const Chat = () => {
|
||||
{ id: 'content', label: 'Content Generation', icon: Sparkles, description: 'Generate content using AI assistance' }
|
||||
]
|
||||
|
||||
const loadSessionMessages = async (sessionId: string) => {
|
||||
try {
|
||||
const token = getToken()
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions/${sessionId}/messages`, {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch messages')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const parsedMessages: ChatMessage[] = (Array.isArray(data) ? data : []).map((message: any) => ({
|
||||
id: Number(message.id || Date.now()),
|
||||
content: message.content || '',
|
||||
role: message.role === 'user' ? 'user' : 'assistant',
|
||||
created_at: message.created_at || new Date().toISOString(),
|
||||
token_count: message.token_count,
|
||||
context_items: Array.isArray(message.context_items) ? message.context_items : [],
|
||||
}))
|
||||
|
||||
setMessages(parsedMessages)
|
||||
} catch (error) {
|
||||
console.error('Failed to load session messages:', error)
|
||||
setMessages([])
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const loadedSessions = sessions()
|
||||
if (!loadedSessions || loadedSessions.length === 0 || currentSessionId()) {
|
||||
return
|
||||
}
|
||||
|
||||
const firstSessionId = String(loadedSessions[0].id)
|
||||
setCurrentSessionId(firstSessionId)
|
||||
void loadSessionMessages(firstSessionId)
|
||||
})
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const message = inputMessage().trim()
|
||||
if (!message || isLoading()) return
|
||||
@@ -238,21 +267,69 @@ const Chat = () => {
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setInputMessage('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Simulate AI response (in production, this would call the AI API)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const token = getToken()
|
||||
const payload: Record<string, any> = {
|
||||
message,
|
||||
context: {
|
||||
bookmarks: contextSettings().bookmarks,
|
||||
tasks: contextSettings().tasks,
|
||||
files: contextSettings().files,
|
||||
notes: contextSettings().notes,
|
||||
},
|
||||
provider: getProviderFromModel(selectedModel()),
|
||||
}
|
||||
|
||||
if (currentSessionId()) {
|
||||
payload.session_id = currentSessionId()
|
||||
}
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send message')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const sessionId = String(data.session_id || currentSessionId() || '')
|
||||
if (sessionId) {
|
||||
setCurrentSessionId(sessionId)
|
||||
}
|
||||
|
||||
const aiResponse: ChatMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: `I received your message: "${message}". This is a demo response. In production, I would provide a helpful response based on the selected AI model (${selectedModel()}) and your context settings.`,
|
||||
content: data.message || 'No response received.',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
created_at: data.timestamp || new Date().toISOString(),
|
||||
}
|
||||
setMessages(prev => [...prev, aiResponse])
|
||||
|
||||
setMessages((prev) => [...prev, aiResponse])
|
||||
await refetchSessions()
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now() + 1,
|
||||
content: 'Message failed. Please check your AI settings and try again.',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -541,24 +618,8 @@ const Chat = () => {
|
||||
<div class="p-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Create new session
|
||||
const newSession = {
|
||||
id: Date.now().toString(),
|
||||
title: 'New Chat',
|
||||
message_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
include_bookmarks: true,
|
||||
include_tasks: true,
|
||||
include_files: true,
|
||||
include_notes: true
|
||||
}
|
||||
setCurrentSessionId(newSession.id)
|
||||
setMessages([{
|
||||
id: 1,
|
||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}])
|
||||
setCurrentSessionId(null)
|
||||
setMessages([])
|
||||
}}
|
||||
class="w-full mb-6 h-11"
|
||||
>
|
||||
@@ -575,16 +636,9 @@ const Chat = () => {
|
||||
: 'hover:bg-muted border-transparent hover:shadow-sm'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCurrentSessionId(session.id.toString())
|
||||
// Load messages for this session (mock for now)
|
||||
setMessages([
|
||||
{
|
||||
id: 1,
|
||||
content: `This is the ${session.title} session. How can I help you?`,
|
||||
role: 'assistant',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
const sessionId = session.id.toString()
|
||||
setCurrentSessionId(sessionId)
|
||||
void loadSessionMessages(sessionId)
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -597,10 +651,29 @@ const Chat = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e: MouseEvent) => {
|
||||
onClick={async (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// Delete session logic here
|
||||
console.log('Delete session:', session.id)
|
||||
try {
|
||||
const token = getToken()
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions/${session.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete session')
|
||||
}
|
||||
|
||||
if (currentSessionId() === String(session.id)) {
|
||||
setCurrentSessionId(null)
|
||||
setMessages([])
|
||||
}
|
||||
await refetchSessions()
|
||||
} catch (error) {
|
||||
console.error('Delete session failed:', error)
|
||||
}
|
||||
}}
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
@@ -863,16 +936,11 @@ const Chat = () => {
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button onClick={() => {
|
||||
// Simulate summarization in demo mode
|
||||
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
if (isDemoMode) {
|
||||
alert('Summary generated! (Demo Mode)\n\nThis would use the selected AI model to summarize your content.');
|
||||
}
|
||||
}}>
|
||||
Summarize
|
||||
<Button
|
||||
onClick={() => setActiveView('chat')}
|
||||
disabled={!inputMessage().trim()}
|
||||
>
|
||||
Summarize In Chat
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setInputMessage('')}>
|
||||
Clear
|
||||
@@ -886,51 +954,18 @@ const Chat = () => {
|
||||
<Card class="p-6">
|
||||
<h4 class="text-lg font-semibold mb-4">Task Suggestions</h4>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
Get AI-powered task suggestions based on your current projects and deadlines.
|
||||
AI task suggestions are generated from your real workspace context.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-muted/30 rounded-lg">
|
||||
<h5 class="font-medium mb-2">Suggested Tasks:</h5>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li class="flex items-center gap-2">
|
||||
<CheckSquare class="h-4 w-4 text-primary" />
|
||||
<span>Review and update project documentation</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<CheckSquare class="h-4 w-4 text-primary" />
|
||||
<span>Follow up with team members on pending items</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<CheckSquare class="h-4 w-4 text-primary" />
|
||||
<span>Prepare for upcoming meeting</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<CheckSquare class="h-4 w-4 text-primary" />
|
||||
<span>Review code quality and performance</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<CheckSquare class="h-4 w-4 text-primary" />
|
||||
<span>Update dependencies and security patches</span>
|
||||
</li>
|
||||
</ul>
|
||||
<h5 class="font-medium mb-2">No suggestions yet</h5>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Start a chat and ask for task suggestions to generate items from your current data.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button onClick={() => {
|
||||
// Simulate getting more suggestions
|
||||
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
if (isDemoMode) {
|
||||
alert('More tasks generated! (Demo Mode)\n\nThis would use the selected AI model to analyze your current work and suggest relevant tasks.');
|
||||
}
|
||||
}}>
|
||||
Get More Suggestions
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
// Add selected tasks to actual task list
|
||||
alert('Tasks would be added to your task list. (Demo Mode)');
|
||||
}}>
|
||||
Add Selected Tasks
|
||||
<Button onClick={() => setActiveView('chat')}>
|
||||
Open Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -983,16 +1018,11 @@ const Chat = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button onClick={() => {
|
||||
// Simulate content generation
|
||||
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
if (isDemoMode) {
|
||||
alert('Content generated! (Demo Mode)\n\nThis would use the selected AI model to generate your content.');
|
||||
}
|
||||
}}>
|
||||
Generate Content
|
||||
<Button
|
||||
onClick={() => setActiveView('chat')}
|
||||
disabled={!inputMessage().trim()}
|
||||
>
|
||||
Generate In Chat
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setInputMessage('')}>
|
||||
Clear
|
||||
|
||||
+576
-394
File diff suppressed because it is too large
Load Diff
+32
-126
@@ -5,7 +5,7 @@ import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { FileUpload } from '@/components/ui/FileUpload';
|
||||
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
||||
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
|
||||
import { getMockFiles } from '@/lib/mockData';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import {
|
||||
IconUpload,
|
||||
IconEye,
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
IconShare
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface FileItem {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -48,137 +50,41 @@ export const Files = () => {
|
||||
const [selectedFile, setSelectedFile] = createSignal<FileItem | null>(null);
|
||||
const [copiedLink, setCopiedLink] = createSignal(false);
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
// Use mock data in demo mode
|
||||
const mockFiles = getMockFiles();
|
||||
const mappedFiles: FileItem[] = mockFiles.map(file => ({
|
||||
id: parseInt(file.id),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
uploadedAt: file.uploadedAt,
|
||||
description: file.description,
|
||||
tags: file.tags.map(tag => tag.name),
|
||||
associations: file.associations?.map(assoc => ({
|
||||
id: assoc.id,
|
||||
type: assoc.type as 'task' | 'bookmark' | 'note' | 'project',
|
||||
title: assoc.title
|
||||
})),
|
||||
url: file.url,
|
||||
isLink: file.isLink,
|
||||
preview: file.preview,
|
||||
downloadUrl: file.downloadUrl,
|
||||
viewUrl: file.viewUrl,
|
||||
shareUrl: file.shareUrl
|
||||
}));
|
||||
setFiles(mappedFiles);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/files`, {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load files');
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call
|
||||
// const response = await fetch('/api/v1/files');
|
||||
// const data = await response.json();
|
||||
|
||||
// Mock data for now
|
||||
setFiles([
|
||||
{
|
||||
id: 1,
|
||||
name: 'project-plan.pdf',
|
||||
size: 2048576,
|
||||
type: 'application/pdf',
|
||||
uploadedAt: '2024-01-15T10:30:00Z',
|
||||
description: 'Q1 2024 project roadmap and milestones',
|
||||
tags: ['planning', 'q1-2024'],
|
||||
downloadUrl: '/files/download/1',
|
||||
viewUrl: '/files/view/1',
|
||||
shareUrl: '/files/share/1'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'meeting-notes.docx',
|
||||
size: 524288,
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
uploadedAt: '2024-01-14T15:45:00Z',
|
||||
description: 'Team sync meeting notes',
|
||||
tags: ['meetings', 'team'],
|
||||
downloadUrl: '/files/download/2',
|
||||
viewUrl: '/files/view/2',
|
||||
shareUrl: '/files/share/2'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'screenshot.png',
|
||||
size: 1024000,
|
||||
type: 'image/png',
|
||||
uploadedAt: '2024-01-13T09:20:00Z',
|
||||
description: 'UI design mockup',
|
||||
tags: ['design', 'ui'],
|
||||
preview: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
associations: [
|
||||
{ id: '1', type: 'project', title: 'Website Redesign' },
|
||||
{ id: '2', type: 'task', title: 'Create mockups' }
|
||||
],
|
||||
downloadUrl: '/files/download/3',
|
||||
viewUrl: '/files/view/3',
|
||||
shareUrl: '/files/share/3'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'app.js',
|
||||
size: 256000,
|
||||
type: 'text/javascript',
|
||||
uploadedAt: '2024-01-12T14:15:00Z',
|
||||
description: 'Main application logic',
|
||||
tags: ['javascript', 'frontend'],
|
||||
preview: 'console.log("Hello World");\n\nfunction main() {\n // Main application logic\n return true;\n}',
|
||||
associations: [
|
||||
{ id: '3', type: 'project', title: 'Frontend App' }
|
||||
],
|
||||
downloadUrl: '/files/download/4',
|
||||
viewUrl: '/files/view/4',
|
||||
shareUrl: '/files/share/4'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'database.sql',
|
||||
size: 512000,
|
||||
type: 'application/sql',
|
||||
uploadedAt: '2024-01-11T11:30:00Z',
|
||||
description: 'Database schema',
|
||||
tags: ['database', 'sql'],
|
||||
preview: 'CREATE TABLE users (\n id INT PRIMARY KEY,\n name VARCHAR(255) NOT NULL\n);',
|
||||
associations: [
|
||||
{ id: '4', type: 'project', title: 'Backend API' }
|
||||
],
|
||||
downloadUrl: '/files/download/5',
|
||||
viewUrl: '/files/view/5',
|
||||
shareUrl: '/files/share/5'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'presentation.pptx',
|
||||
size: 3072000,
|
||||
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
uploadedAt: '2024-01-10T16:45:00Z',
|
||||
description: 'Q4 review presentation',
|
||||
tags: ['presentation', 'q4'],
|
||||
downloadUrl: '/files/download/6',
|
||||
viewUrl: '/files/view/6',
|
||||
shareUrl: '/files/share/6'
|
||||
}
|
||||
]);
|
||||
const filesData = await response.json();
|
||||
const mappedFiles: FileItem[] = (Array.isArray(filesData) ? filesData : []).map((file: any, index) => ({
|
||||
id: Number(file.id || index + 1),
|
||||
name: file.original_name || file.file_name || `File ${index + 1}`,
|
||||
size: Number(file.file_size || file.size || 0),
|
||||
type: file.mime_type || file.type || 'application/octet-stream',
|
||||
uploadedAt: file.created_at || file.uploadedAt || new Date().toISOString(),
|
||||
description: file.description,
|
||||
tags: Array.isArray(file.tags)
|
||||
? file.tags.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name)).filter(Boolean)
|
||||
: [],
|
||||
url: file.url,
|
||||
isLink: Boolean(file.is_link),
|
||||
preview: file.preview,
|
||||
downloadUrl: file.download_url,
|
||||
viewUrl: file.view_url,
|
||||
shareUrl: file.share_url
|
||||
}));
|
||||
setFiles(mappedFiles);
|
||||
} catch (error) {
|
||||
console.error('Failed to load files:', error);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
+140
-186
@@ -2,6 +2,7 @@ import { createSignal, onMount } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { GitHubActivity } from '@/components/ui/GitHubActivity';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconTrendingUp,
|
||||
@@ -50,6 +51,8 @@ interface GitHubStats {
|
||||
repos: GitHubRepo[];
|
||||
}
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
export const GitHub = () => {
|
||||
const [githubStats, setGithubStats] = createSignal<GitHubStats>({
|
||||
totalRepos: 0,
|
||||
@@ -65,21 +68,35 @@ export const GitHub = () => {
|
||||
|
||||
const [username, setUsername] = createSignal('');
|
||||
const [isConnected, setIsConnected] = createSignal(false);
|
||||
const weeklyTotal = () => weeklyActivity().reduce((a, b) => a + b, 0);
|
||||
|
||||
onMount(() => {
|
||||
// Check if user is authenticated and has GitHub connected
|
||||
checkGitHubConnection();
|
||||
});
|
||||
|
||||
const resetGitHubData = () => {
|
||||
setWeeklyActivity([0, 0, 0, 0, 0, 0, 0]);
|
||||
setGithubStats({
|
||||
totalRepos: 0,
|
||||
totalStars: 0,
|
||||
totalForks: 0,
|
||||
totalWatchers: 0,
|
||||
languages: [],
|
||||
recentActivity: [],
|
||||
repos: []
|
||||
});
|
||||
};
|
||||
|
||||
const checkGitHubConnection = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
loadMockData();
|
||||
resetGitHubData();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
@@ -92,24 +109,24 @@ export const GitHub = () => {
|
||||
setUsername(userData.user.username);
|
||||
await fetchGitHubStats();
|
||||
} else {
|
||||
loadMockData();
|
||||
resetGitHubData();
|
||||
}
|
||||
} else {
|
||||
loadMockData();
|
||||
resetGitHubData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check GitHub connection:', error);
|
||||
loadMockData();
|
||||
resetGitHubData();
|
||||
}
|
||||
};
|
||||
const fetchGitHubStats = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('No authentication token');
|
||||
}
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/github/repos`, {
|
||||
const response = await fetch(`${API_BASE_URL}/github/repos`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
@@ -142,8 +159,7 @@ export const GitHub = () => {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub stats:', error);
|
||||
// Fallback to mock data
|
||||
loadMockData();
|
||||
resetGitHubData();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -177,81 +193,6 @@ export const GitHub = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const loadMockData = () => {
|
||||
// Load mock data for demonstration
|
||||
const mockRepos: GitHubRepo[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'trackeep',
|
||||
full_name: 'demo/trackeep',
|
||||
description: 'A comprehensive productivity and bookmark management system',
|
||||
html_url: 'https://github.com/demo/trackeep',
|
||||
stargazers_count: 156,
|
||||
forks_count: 42,
|
||||
watchers_count: 28,
|
||||
language: 'TypeScript',
|
||||
updated_at: '2024-01-28T10:30:00Z',
|
||||
created_at: '2023-06-15T14:20:00Z',
|
||||
size: 2456,
|
||||
open_issues_count: 3,
|
||||
default_branch: 'main'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'solid-components',
|
||||
full_name: 'demo/solid-components',
|
||||
description: 'Reusable SolidJS components for modern web applications',
|
||||
html_url: 'https://github.com/demo/solid-components',
|
||||
stargazers_count: 89,
|
||||
forks_count: 23,
|
||||
watchers_count: 15,
|
||||
language: 'TypeScript',
|
||||
updated_at: '2024-01-27T16:45:00Z',
|
||||
created_at: '2023-08-22T09:15:00Z',
|
||||
size: 1234,
|
||||
open_issues_count: 1,
|
||||
default_branch: 'main'
|
||||
}
|
||||
];
|
||||
|
||||
const languages = [
|
||||
{ name: 'TypeScript', count: 2, color: '#3178c6' },
|
||||
{ name: 'Go', count: 1, color: '#00ADD8' }
|
||||
];
|
||||
|
||||
const recentActivity = [
|
||||
{
|
||||
type: 'push',
|
||||
repo: 'trackeep',
|
||||
date: '2024-01-28',
|
||||
message: 'feat: add GitHub integration'
|
||||
}
|
||||
];
|
||||
|
||||
// Generate mock weekly activity data
|
||||
const mockWeeklyActivity = [
|
||||
Math.floor(Math.random() * 20) + 5, // Monday
|
||||
Math.floor(Math.random() * 25) + 8, // Tuesday
|
||||
Math.floor(Math.random() * 22) + 6, // Wednesday
|
||||
Math.floor(Math.random() * 18) + 4, // Thursday
|
||||
Math.floor(Math.random() * 15) + 3, // Friday
|
||||
Math.floor(Math.random() * 12) + 2, // Saturday
|
||||
Math.floor(Math.random() * 10) + 1 // Sunday
|
||||
];
|
||||
|
||||
setWeeklyActivity(mockWeeklyActivity);
|
||||
|
||||
setGithubStats({
|
||||
totalRepos: mockRepos.length,
|
||||
totalStars: mockRepos.reduce((sum, repo) => sum + repo.stargazers_count, 0),
|
||||
totalForks: mockRepos.reduce((sum, repo) => sum + repo.forks_count, 0),
|
||||
totalWatchers: mockRepos.reduce((sum, repo) => sum + repo.watchers_count, 0),
|
||||
languages,
|
||||
recentActivity,
|
||||
repos: mockRepos
|
||||
});
|
||||
};
|
||||
|
||||
const connectGitHub = () => {
|
||||
// Redirect to centralized OAuth service
|
||||
window.location.href = 'https://oauth.tdvorak.dev/auth/github?redirect_uri=' + encodeURIComponent(window.location.origin + '/api/v1/auth/oauth/callback');
|
||||
@@ -263,7 +204,7 @@ export const GitHub = () => {
|
||||
// For now, we'll just clear the local state
|
||||
setIsConnected(false);
|
||||
setUsername('');
|
||||
loadMockData();
|
||||
resetGitHubData();
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect GitHub:', error);
|
||||
}
|
||||
@@ -412,20 +353,24 @@ export const GitHub = () => {
|
||||
{/* Languages - Right Column (smaller) */}
|
||||
<Card class="p-6 lg:col-span-1">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Languages</h3>
|
||||
<div class="space-y-3">
|
||||
{githubStats().languages.map((language) => (
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={`background-color: ${language.color}`}
|
||||
></div>
|
||||
<span class="text-sm text-foreground truncate">{language.name}</span>
|
||||
{githubStats().languages.length === 0 ? (
|
||||
<p class="text-sm text-muted-foreground">No language data yet.</p>
|
||||
) : (
|
||||
<div class="space-y-3">
|
||||
{githubStats().languages.map((language) => (
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={`background-color: ${language.color}`}
|
||||
></div>
|
||||
<span class="text-sm text-foreground truncate">{language.name}</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground flex-shrink-0">{language.count} repos</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground flex-shrink-0">{language.count} repos</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -436,48 +381,49 @@ export const GitHub = () => {
|
||||
<h3 class="text-lg font-semibold text-foreground">Weekly Activity</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
{weeklyTotal() === 0 ? (
|
||||
<div class="h-32 md:h-36 border border-dashed border-border rounded-lg flex items-center justify-center">
|
||||
<p class="text-sm text-muted-foreground">No weekly GitHub activity yet.</p>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivityData = weeklyActivity() || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
|
||||
const activity = weeklyActivityData[index];
|
||||
const maxActivity = Math.max(...weeklyActivityData);
|
||||
// Use dynamic scale based on actual data
|
||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
||||
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
|
||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||
) : (
|
||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivityData = weeklyActivity();
|
||||
const activity = weeklyActivityData[index];
|
||||
const maxActivity = Math.max(...weeklyActivityData, 1);
|
||||
const heightPercent = (activity / maxActivity) * 85;
|
||||
const finalHeightPercent = activity > 0 ? Math.max(heightPercent, 6) : 0;
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8 h-full">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center justify-end h-full">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
|
||||
title={`${day}: ${activity} contributions`}
|
||||
></div>
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8 h-full">
|
||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center justify-end h-full">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%);`}
|
||||
title={`${day}: ${activity} contributions`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
<span>Total: {weeklyActivity().reduce((a, b) => a + b, 0)} contributions</span>
|
||||
<span>Avg: {Math.round(weeklyActivity().reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||
<span>Total: {weeklyTotal()} contributions</span>
|
||||
<span>Avg: {Math.round(weeklyTotal() / 7)} per day</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -486,67 +432,75 @@ export const GitHub = () => {
|
||||
{/* Recent Activity */}
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3>
|
||||
<div class="space-y-3">
|
||||
{githubStats().recentActivity.map((activity) => (
|
||||
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-2 rounded-lg">
|
||||
<IconTrendingUp class="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-foreground">{activity.message}</p>
|
||||
<p class="text-xs text-muted-foreground">{activity.repo} • {activity.date}</p>
|
||||
{githubStats().recentActivity.length === 0 ? (
|
||||
<p class="text-sm text-muted-foreground">No recent GitHub events yet.</p>
|
||||
) : (
|
||||
<div class="space-y-3">
|
||||
{githubStats().recentActivity.map((activity) => (
|
||||
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/10 p-2 rounded-lg">
|
||||
<IconTrendingUp class="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-foreground">{activity.message}</p>
|
||||
<p class="text-xs text-muted-foreground">{activity.repo} • {activity.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground capitalize">{activity.type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground capitalize">{activity.type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Repositories */}
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Repositories</h3>
|
||||
<div class="space-y-4">
|
||||
{githubStats().repos.map((repo) => (
|
||||
<div class="border border-border rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="text-lg font-medium text-foreground">{repo.name}</h4>
|
||||
{repo.language && (
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
style={`background-color: ${getLanguageColor()}20; color: ${getLanguageColor()}`}
|
||||
>
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-3">{repo.description}</p>
|
||||
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<IconStar class="size-3" />
|
||||
<span>{repo.stargazers_count}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconGitFork class="size-3" />
|
||||
<span>{repo.forks_count}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconEye class="size-3" />
|
||||
<span>{repo.watchers_count}</span>
|
||||
</div>
|
||||
<span>Updated {formatDate(repo.updated_at)}</span>
|
||||
{githubStats().repos.length === 0 ? (
|
||||
<p class="text-sm text-muted-foreground">No repositories available yet.</p>
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{githubStats().repos.map((repo) => (
|
||||
<div class="border border-border rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="text-lg font-medium text-foreground">{repo.name}</h4>
|
||||
{repo.language && (
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
style={`background-color: ${getLanguageColor()}20; color: ${getLanguageColor()}`}
|
||||
>
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-3">{repo.description}</p>
|
||||
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-1">
|
||||
<IconStar class="size-3" />
|
||||
<span>{repo.stargazers_count}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconGitFork class="size-3" />
|
||||
<span>{repo.forks_count}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconEye class="size-3" />
|
||||
<span>{repo.watchers_count}</span>
|
||||
</div>
|
||||
<span>Updated {formatDate(repo.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
<IconExternalLink class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
<IconExternalLink class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { LearningPathPreviewModal } from '@/components/ui/LearningPathPreviewModal';
|
||||
import { getMockLearningPaths } from '@/lib/mockData';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import {
|
||||
IconClock,
|
||||
IconUsers,
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
IconBook
|
||||
} from '@tabler/icons-solidjs';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface LearningPath {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -73,13 +77,6 @@ export const LearningPaths = () => {
|
||||
const [isPreviewOpen, setIsPreviewOpen] = createSignal(false);
|
||||
const [selectedPath, setSelectedPath] = createSignal<LearningPath | null>(null);
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
@@ -118,7 +115,6 @@ export const LearningPaths = () => {
|
||||
}
|
||||
|
||||
// Fetch categories
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||
const categoriesResponse = await fetch(`${API_BASE_URL}/learning-paths/categories`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
@@ -214,7 +210,6 @@ export const LearningPaths = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/learning-paths/${pathId}/enroll`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { useAuth, type LoginRequest, type RegisterRequest } from '@/lib/auth';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface LoginFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
fullName: string;
|
||||
}
|
||||
|
||||
export const Login = () => {
|
||||
const { login, register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isLogin, setIsLogin] = createSignal(true);
|
||||
const [formData, setFormData] = createSignal<LoginRequest | RegisterRequest>({
|
||||
const [isLogin, setIsLogin] = createSignal(false);
|
||||
const [formData, setFormData] = createSignal<LoginFormData>({
|
||||
email: '',
|
||||
password: '',
|
||||
...(isLogin() ? {} : { username: '', fullName: '' }),
|
||||
username: '',
|
||||
fullName: '',
|
||||
});
|
||||
const [error, setError] = createSignal('');
|
||||
const [noAccountsExist, setNoAccountsExist] = createSignal(false);
|
||||
@@ -24,13 +35,14 @@ export const Login = () => {
|
||||
setFormData({
|
||||
email: 'demo@trackeep.com',
|
||||
password: 'demo123',
|
||||
...(isLogin() ? {} : { username: 'demo', fullName: 'Demo User' }),
|
||||
username: 'demo',
|
||||
fullName: 'Demo User',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'}/auth/check-users`, {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/check-users`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -45,12 +57,24 @@ export const Login = () => {
|
||||
setNoAccountsExist(false);
|
||||
// Force to login mode
|
||||
setIsLogin(true);
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
username: '',
|
||||
fullName: '',
|
||||
});
|
||||
} else {
|
||||
// No users exist - allow registration for first user (admin)
|
||||
setRegistrationDisabled(false);
|
||||
setNoAccountsExist(true);
|
||||
// Force to registration mode
|
||||
setIsLogin(false);
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
username: '',
|
||||
fullName: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -65,9 +89,19 @@ export const Login = () => {
|
||||
|
||||
try {
|
||||
if (isLogin()) {
|
||||
await login(formData() as LoginRequest);
|
||||
const loginPayload: LoginRequest = {
|
||||
email: formData().email,
|
||||
password: formData().password,
|
||||
};
|
||||
await login(loginPayload);
|
||||
} else {
|
||||
await register(formData() as RegisterRequest);
|
||||
const registerPayload: RegisterRequest = {
|
||||
email: formData().email,
|
||||
password: formData().password,
|
||||
username: formData().username,
|
||||
fullName: formData().fullName,
|
||||
};
|
||||
await register(registerPayload);
|
||||
}
|
||||
// Navigate to app after successful login/registration
|
||||
navigate('/app');
|
||||
@@ -88,13 +122,21 @@ export const Login = () => {
|
||||
setError('Registration is disabled. Please contact your administrator to create an account.');
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no users, force registration only (no sign in yet)
|
||||
if (noAccountsExist()) {
|
||||
setIsLogin(false);
|
||||
setError('No accounts exist yet. Create the first administrator account first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLogin(!isLogin());
|
||||
setError('');
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
...(isLogin() ? { username: '', fullName: '' } : {}),
|
||||
username: '',
|
||||
fullName: '',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -102,6 +144,13 @@ export const Login = () => {
|
||||
<div class="min-h-screen bg-[#18181b] flex items-center justify-center px-4">
|
||||
<div class="max-w-md w-full bg-[#141415] border border-[#262626] rounded-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center p-3 rounded-xl border border-[#262626] bg-[#0f0f10] mb-4">
|
||||
<img
|
||||
src="/trackeep.svg"
|
||||
alt="Trackeep Logo"
|
||||
class="w-11 h-11 app-logo-mono"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-[#fafafa] mb-2">Trackeep</h1>
|
||||
<p class="text-[#a3a3a3]">
|
||||
{isEnvDemoMode() ? 'Demo Mode' : (isLogin() ? 'Welcome back' : 'Create your account')}
|
||||
@@ -189,7 +238,7 @@ export const Login = () => {
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={(formData() as RegisterRequest).username}
|
||||
value={formData().username}
|
||||
onInput={(e) => handleInputChange('username', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="username"
|
||||
@@ -204,7 +253,7 @@ export const Login = () => {
|
||||
id="fullName"
|
||||
type="text"
|
||||
required
|
||||
value={(formData() as RegisterRequest).fullName}
|
||||
value={formData().fullName}
|
||||
onInput={(e) => handleInputChange('fullName', e.currentTarget.value)}
|
||||
class="w-full px-3 py-2 bg-[#18181b] border border-[#262626] rounded-md text-[#fafafa] placeholder-[#a3a3a3] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:border-transparent"
|
||||
placeholder="Your Name"
|
||||
@@ -239,7 +288,7 @@ export const Login = () => {
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
{!registrationDisabled() && (
|
||||
{!registrationDisabled() && !noAccountsExist() && (
|
||||
<p class="text-[#a3a3a3]">
|
||||
{isLogin() ? "Don't have an account?" : 'Already have an account?'}
|
||||
<button
|
||||
|
||||
+229
-129
@@ -1,14 +1,17 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { IconPlus, IconDotsVertical, IconEdit, IconTrash, IconShield, IconShieldCheck } from '@tabler/icons-solidjs';
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { IconPlus, IconDotsVertical, IconTrash } from '@tabler/icons-solidjs';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||
import { MemberModal } from '@/components/ui/MemberModal';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'Admin' | 'Member';
|
||||
role: string;
|
||||
avatar: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
@@ -16,43 +19,146 @@ interface Member {
|
||||
export const Members = () => {
|
||||
const [members, setMembers] = createSignal<Member[]>([]);
|
||||
const [showAddModal, setShowAddModal] = createSignal(false);
|
||||
const [showEditModal, setShowEditModal] = createSignal(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = createSignal(false);
|
||||
const [editingMember, setEditingMember] = createSignal<Member | null>(null);
|
||||
const [deletingMember, setDeletingMember] = createSignal<Member | null>(null);
|
||||
const [workspaceId, setWorkspaceId] = createSignal('');
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
|
||||
const handleAddMember = (memberData: Omit<Member, 'id' | 'avatar' | 'joinedAt'>) => {
|
||||
const newMember: Member = {
|
||||
...memberData,
|
||||
id: Date.now().toString(),
|
||||
avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase(),
|
||||
joinedAt: 'Just now'
|
||||
};
|
||||
setMembers(prev => [...prev, newMember]);
|
||||
setShowAddModal(false);
|
||||
const getToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||
|
||||
const toRoleLabel = (role: string) => {
|
||||
if (role === 'owner') return 'Owner';
|
||||
if (role === 'admin') return 'Admin';
|
||||
if (role === 'viewer') return 'Viewer';
|
||||
return 'Member';
|
||||
};
|
||||
|
||||
const handleEditMember = (memberData: Omit<Member, 'id' | 'avatar' | 'joinedAt'>) => {
|
||||
if (!editingMember()) return;
|
||||
|
||||
setMembers(prev =>
|
||||
prev.map(m =>
|
||||
m.id === editingMember()!.id
|
||||
? {
|
||||
...m,
|
||||
...memberData,
|
||||
avatar: memberData.name.split(' ').map(n => n[0]).join('').toUpperCase()
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
setShowEditModal(false);
|
||||
setEditingMember(null);
|
||||
const toInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0] || '')
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
const openEditModal = (member: Member) => {
|
||||
setEditingMember(member);
|
||||
setShowEditModal(true);
|
||||
const resolveWorkspaceId = async (): Promise<string> => {
|
||||
const storedWorkspaceId = localStorage.getItem('trackeep_workspace_id') || '';
|
||||
if (storedWorkspaceId) {
|
||||
return storedWorkspaceId;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const teamsResponse = await fetch(`${API_BASE_URL}/teams`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamsResponse.ok) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const teamsData = await teamsResponse.json();
|
||||
const teams = Array.isArray(teamsData?.teams) ? teamsData.teams : [];
|
||||
if (teams.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const firstTeamId = String(teams[0].id);
|
||||
localStorage.setItem('trackeep_workspace_id', firstTeamId);
|
||||
localStorage.setItem('trackeep_workspace_name', teams[0].name || 'Trackeep Workspace');
|
||||
return firstTeamId;
|
||||
};
|
||||
|
||||
const loadMembers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setMembers([]);
|
||||
setWorkspaceId('');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentWorkspaceId = await resolveWorkspaceId();
|
||||
setWorkspaceId(currentWorkspaceId);
|
||||
|
||||
if (!currentWorkspaceId) {
|
||||
setMembers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/teams/${currentWorkspaceId}/members`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch members: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const membersPayload = Array.isArray(data?.members) ? data.members : [];
|
||||
|
||||
const mappedMembers: Member[] = membersPayload.map((member: any, index: number) => {
|
||||
const user = member.user || {};
|
||||
const name = user.full_name || user.username || user.email || `User ${index + 1}`;
|
||||
const email = user.email || '';
|
||||
return {
|
||||
id: String(member.user_id || user.id || member.id || index + 1),
|
||||
name,
|
||||
email,
|
||||
role: toRoleLabel(member.role || 'member'),
|
||||
avatar: toInitials(name),
|
||||
joinedAt: member.joined_at ? new Date(member.joined_at).toLocaleDateString() : '',
|
||||
};
|
||||
});
|
||||
|
||||
setMembers(mappedMembers);
|
||||
} catch (error) {
|
||||
console.error('Failed to load members:', error);
|
||||
setMembers([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (memberData: { name: string; email: string; role: 'Admin' | 'Member' }) => {
|
||||
const token = getToken();
|
||||
const currentWorkspaceId = workspaceId();
|
||||
if (!token || !currentWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/teams/${currentWorkspaceId}/invite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: memberData.email,
|
||||
role: memberData.role === 'Admin' ? 'admin' : 'member',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to invite member: ${response.status}`);
|
||||
}
|
||||
|
||||
setShowAddModal(false);
|
||||
alert('Invitation sent successfully.');
|
||||
} catch (error) {
|
||||
console.error('Failed to invite member:', error);
|
||||
alert('Failed to invite member.');
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteModal = (member: Member) => {
|
||||
@@ -60,58 +166,56 @@ export const Members = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteMember = () => {
|
||||
if (!deletingMember()) return;
|
||||
|
||||
setMembers(prev => prev.filter(m => m.id !== deletingMember()!.id));
|
||||
setShowDeleteModal(false);
|
||||
setDeletingMember(null);
|
||||
};
|
||||
const handleDeleteMember = async () => {
|
||||
const member = deletingMember();
|
||||
const token = getToken();
|
||||
const currentWorkspaceId = workspaceId();
|
||||
if (!member || !token || !currentWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleToggleRole = (member: Member) => {
|
||||
const newRole = member.role === 'Admin' ? 'Member' : 'Admin';
|
||||
setMembers(prev =>
|
||||
prev.map(m =>
|
||||
m.id === member.id ? { ...m, role: newRole } : m
|
||||
)
|
||||
);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/teams/${currentWorkspaceId}/members/${member.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to remove member: ${response.status}`);
|
||||
}
|
||||
|
||||
setMembers((prev) => prev.filter((entry) => entry.id !== member.id));
|
||||
setShowDeleteModal(false);
|
||||
setDeletingMember(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove member:', error);
|
||||
alert('Failed to remove member.');
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Mock data
|
||||
setMembers([
|
||||
{
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'Admin',
|
||||
avatar: 'JD',
|
||||
joinedAt: '2 weeks ago'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
role: 'Member',
|
||||
avatar: 'JS',
|
||||
joinedAt: '1 month ago'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Bob Johnson',
|
||||
email: 'bob@example.com',
|
||||
role: 'Member',
|
||||
avatar: 'BJ',
|
||||
joinedAt: '3 months ago'
|
||||
}
|
||||
]);
|
||||
void loadMembers();
|
||||
|
||||
const onWorkspaceChanged = () => {
|
||||
void loadMembers();
|
||||
};
|
||||
|
||||
window.addEventListener('trackeep:workspace-changed', onWorkspaceChanged);
|
||||
onCleanup(() => window.removeEventListener('trackeep:workspace-changed', onWorkspaceChanged));
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-foreground">Members</h1>
|
||||
<button type="button" class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4" onClick={() => setShowAddModal(true)}>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-auto items-center gap-2 py-2 px-4"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={!workspaceId()}
|
||||
>
|
||||
<IconPlus class="size-4" />
|
||||
Add Member
|
||||
</button>
|
||||
@@ -128,72 +232,68 @@ export const Members = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
{members().map((member) => (
|
||||
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
|
||||
<td class="p-2 align-middle">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
|
||||
{member.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">{member.name}</div>
|
||||
<div class="text-sm text-muted-foreground">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 align-middle">
|
||||
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
{member.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2 align-middle text-muted-foreground">
|
||||
{member.joinedAt}
|
||||
</td>
|
||||
<td class="p-2 align-middle">
|
||||
<div class="flex items-center justify-end">
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<button type="button" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9">
|
||||
<IconDotsVertical class="size-4" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => openEditModal(member)} icon={IconEdit}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleToggleRole(member)} icon={member.role === 'Admin' ? IconShieldCheck : IconShield}>
|
||||
{member.role === 'Admin' ? 'Make Member' : 'Make Admin'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openDeleteModal(member)} icon={IconTrash} variant="destructive">
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isLoading() ? (
|
||||
<tr class="border-b">
|
||||
<td class="p-4 text-muted-foreground" colSpan={4}>
|
||||
Loading members...
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : members().length === 0 ? (
|
||||
<tr class="border-b">
|
||||
<td class="p-4 text-muted-foreground" colSpan={4}>
|
||||
No members yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
members().map((member) => (
|
||||
<tr class="border-b transition-colors data-[state=selected]:bg-muted">
|
||||
<td class="p-2 align-middle">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
|
||||
{member.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">{member.name}</div>
|
||||
<div class="text-sm text-muted-foreground">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 align-middle">
|
||||
<span class="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
{member.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2 align-middle text-muted-foreground">
|
||||
{member.joinedAt || 'Unknown'}
|
||||
</td>
|
||||
<td class="p-2 align-middle">
|
||||
<div class="flex items-center justify-end">
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<button type="button" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9">
|
||||
<IconDotsVertical class="size-4" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => openDeleteModal(member)} icon={IconTrash} variant="destructive">
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<MemberModal
|
||||
isOpen={showAddModal()}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSubmit={handleAddMember}
|
||||
/>
|
||||
|
||||
<MemberModal
|
||||
isOpen={showEditModal()}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingMember(null);
|
||||
}}
|
||||
onSubmit={handleEditMember}
|
||||
member={editingMember()}
|
||||
isEdit={true}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal()}
|
||||
onClose={() => {
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
.messages-shell {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 70%, hsl(var(--muted) / 0.18) 100%);
|
||||
}
|
||||
|
||||
.messages-shell-list .messages-main {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages-shell-conversation .messages-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages-sidebar {
|
||||
border-inline: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: min(100%, 980px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.messages-sidebar-header {
|
||||
padding: 0.9rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.messages-title-row,
|
||||
.messages-sidebar-actions,
|
||||
.messages-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.messages-title-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.messages-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.messages-status-row {
|
||||
font-size: 0.7rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.messages-sidebar-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.55rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.messages-list-empty {
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 0.72rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.72rem;
|
||||
padding: 0.58rem 0.65rem;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: hsl(var(--muted) / 0.6);
|
||||
}
|
||||
|
||||
.conversation-item-active {
|
||||
background: hsl(var(--primary) / 0.14);
|
||||
border-color: hsl(var(--primary) / 0.45);
|
||||
}
|
||||
|
||||
.conversation-item-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-item-name {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 620;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-item-preview {
|
||||
font-size: 0.72rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-item-unread {
|
||||
min-width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.messages-main {
|
||||
width: min(100%, 1180px);
|
||||
margin: 0 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||
border-inline: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
|
||||
.messages-main-header {
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.messages-header-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages-back-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.messages-header-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages-header-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.messages-header-subtitle {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.messages-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.messages-main-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.85rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.messages-call-strip,
|
||||
.messages-transcript-preview {
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.messages-timeline {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message-row-me {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-row-them {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: min(76%, 900px);
|
||||
border-radius: 0.95rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
padding: 0.68rem 0.74rem;
|
||||
box-shadow: 0 1px 2px hsl(0 0% 0% / 0.08);
|
||||
}
|
||||
|
||||
.message-bubble-me {
|
||||
background: hsl(var(--primary) / 0.17);
|
||||
border-color: hsl(var(--primary) / 0.48);
|
||||
}
|
||||
|
||||
.message-bubble-them {
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message-edited {
|
||||
font-size: 0.63rem;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.32rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.message-sensitive-banner {
|
||||
margin-top: 0.55rem;
|
||||
border-radius: 0.52rem;
|
||||
padding: 0.42rem 0.5rem;
|
||||
border: 1px solid hsl(var(--warning) / 0.4);
|
||||
background: hsl(var(--warning) / 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.message-attachments {
|
||||
margin-top: 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.message-attachment-link,
|
||||
.message-voice-note {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.message-attachment-link:hover {
|
||||
background: hsl(var(--muted) / 0.55);
|
||||
}
|
||||
|
||||
.message-voice-note {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-reference-wrap {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.message-reference-pill {
|
||||
border-radius: 999px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
padding: 0.14rem 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.message-suggestions {
|
||||
margin-top: 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.message-suggestion-card {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.45rem;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
|
||||
.message-suggestion-title {
|
||||
font-size: 0.72rem;
|
||||
text-transform: capitalize;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.message-suggestion-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.message-reaction-panel {
|
||||
margin-top: 0.62rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.message-reaction-add-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.reaction-add-btn {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.reaction-add-btn:hover {
|
||||
border-color: hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.55);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.message-reaction-summary {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.reaction-pill {
|
||||
border-radius: 999px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
padding: 0.15rem 0.45rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.68rem;
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
|
||||
.reaction-pill-me {
|
||||
border-color: hsl(var(--primary) / 0.55);
|
||||
background: hsl(var(--primary) / 0.16);
|
||||
}
|
||||
|
||||
.messages-composer {
|
||||
position: relative;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
padding: 0.72rem 1rem 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
|
||||
.messages-composer-drag {
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.messages-typing-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.73rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.typing-dots span {
|
||||
width: 0.28rem;
|
||||
height: 0.28rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary));
|
||||
animation: typingBounce 1.1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(2) {
|
||||
animation-delay: 0.14s;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(3) {
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-2px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.composer-chip-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.composer-chip {
|
||||
border-radius: 999px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
padding: 0.18rem 0.36rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.composer-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.messages-recording-line {
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 0.73rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.messages-composer-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
gap: 0.45rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.messages-composer-input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.messages-composer-textarea {
|
||||
min-height: 2.6rem;
|
||||
max-height: 9rem;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.62rem;
|
||||
background: hsl(var(--background));
|
||||
padding: 0.58rem 0.64rem;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.messages-composer-textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15);
|
||||
}
|
||||
|
||||
.mention-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(100% + 0.4rem);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.65rem;
|
||||
background: hsl(var(--card));
|
||||
box-shadow: 0 8px 26px hsl(0 0% 0% / 0.24);
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.mention-menu-empty {
|
||||
padding: 0.55rem 0.65rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.mention-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.52rem 0.62rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.mention-option-active,
|
||||
.mention-option:hover {
|
||||
background: hsl(var(--muted) / 0.65);
|
||||
}
|
||||
|
||||
.mention-option-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mention-option-title {
|
||||
font-size: 0.79rem;
|
||||
font-weight: 620;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mention-option-sub {
|
||||
font-size: 0.68rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.messages-composer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.messages-inline-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.messages-sidebar,
|
||||
.messages-main {
|
||||
width: 100%;
|
||||
border-inline: none;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 86%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.messages-main {
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.messages-composer-row {
|
||||
grid-template-columns: auto auto 1fr;
|
||||
}
|
||||
|
||||
.messages-composer-row > button:last-child {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
+851
-407
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@ import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { NoteModal } from '@/components/ui/NoteModal';
|
||||
import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
|
||||
import { IconPin, IconTrash, IconEdit, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
||||
import { getMockNotes } from '@/lib/mockData';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface Note {
|
||||
id: number;
|
||||
@@ -26,43 +28,6 @@ interface Note {
|
||||
isHtml?: boolean;
|
||||
}
|
||||
|
||||
const normalizeMockDate = (dateStr: string): string => {
|
||||
const directDate = new Date(dateStr);
|
||||
if (!isNaN(directDate.getTime())) {
|
||||
return directDate.toISOString();
|
||||
}
|
||||
|
||||
const match = dateStr.match(/(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago/i);
|
||||
if (!match) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2].toLowerCase();
|
||||
const date = new Date();
|
||||
|
||||
switch (unit) {
|
||||
case 'day':
|
||||
case 'days':
|
||||
date.setDate(date.getDate() - value);
|
||||
break;
|
||||
case 'week':
|
||||
case 'weeks':
|
||||
date.setDate(date.getDate() - value * 7);
|
||||
break;
|
||||
case 'month':
|
||||
case 'months':
|
||||
date.setMonth(date.getMonth() - value);
|
||||
break;
|
||||
case 'year':
|
||||
case 'years':
|
||||
date.setFullYear(date.getFullYear() - value);
|
||||
break;
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const renderMarkdownPreviewHtml = (content: string, maxBlocks = 4): string => {
|
||||
const html = content
|
||||
.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-1">$1<\/h1>')
|
||||
@@ -112,64 +77,57 @@ export const Notes = () => {
|
||||
const [copiedContent, setCopiedContent] = createSignal(false);
|
||||
const [expandedNotes, setExpandedNotes] = createSignal<Set<number>>(new Set());
|
||||
|
||||
// Check if we're in demo mode
|
||||
const isDemoMode = () => {
|
||||
return localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
if (isDemoMode()) {
|
||||
// Use mock data in demo mode
|
||||
const mockNotes = getMockNotes();
|
||||
const adaptedNotes = mockNotes.map((note, index) => ({
|
||||
id: index + 1,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
createdAt: normalizeMockDate(note.createdAt),
|
||||
updatedAt: normalizeMockDate(note.updatedAt),
|
||||
tags: note.tags.map(tag => tag.name),
|
||||
pinned: note.tags.some(tag => tag.name === 'important' || tag.name === 'pinned'),
|
||||
attachments: note.attachments?.map((att, index) => ({
|
||||
id: `att_${index}`,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
size: att.size,
|
||||
url: `/attachments/${att.name}`
|
||||
})) || [],
|
||||
isMarkdown: note.content.includes('#') || note.content.includes('*'),
|
||||
isHtml: note.content.includes('<') && note.content.includes('>')
|
||||
}));
|
||||
setNotes(adaptedNotes);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/notes`, {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load notes');
|
||||
}
|
||||
|
||||
// Load mock notes data
|
||||
const mockNotes = getMockNotes();
|
||||
const adaptedNotes = mockNotes.map((note, index) => ({
|
||||
id: index + 1,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
createdAt: normalizeMockDate(note.createdAt),
|
||||
updatedAt: normalizeMockDate(note.updatedAt),
|
||||
tags: note.tags.map(tag => tag.name),
|
||||
pinned: note.tags.some(tag => tag.name === 'important' || tag.name === 'pinned'),
|
||||
attachments: note.attachments?.map((att, index) => ({
|
||||
id: `att_${index}`,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
size: att.size,
|
||||
url: `/attachments/${att.name}`
|
||||
})) || [],
|
||||
isMarkdown: note.content.includes('#') || note.content.includes('*'),
|
||||
isHtml: note.content.includes('<') && note.content.includes('>')
|
||||
}));
|
||||
const notesData = await response.json();
|
||||
const adaptedNotes: Note[] = (Array.isArray(notesData) ? notesData : []).map((note: any, index) => {
|
||||
const tags = Array.isArray(note.tags)
|
||||
? note.tags
|
||||
.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const content = note.content || '';
|
||||
const createdAt = note.created_at || note.createdAt || new Date().toISOString();
|
||||
const updatedAt = note.updated_at || note.updatedAt || createdAt;
|
||||
|
||||
return {
|
||||
id: Number(note.id || index + 1),
|
||||
title: note.title || 'Untitled note',
|
||||
content,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
tags,
|
||||
pinned: Boolean(note.pinned ?? note.is_pinned),
|
||||
attachments: Array.isArray(note.attachments)
|
||||
? note.attachments.map((att: any, attachmentIndex: number) => ({
|
||||
id: String(att.id || `att_${attachmentIndex}`),
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'file',
|
||||
size: att.size || '',
|
||||
url: att.url,
|
||||
}))
|
||||
: [],
|
||||
isMarkdown: content.includes('#') || content.includes('*'),
|
||||
isHtml: content.includes('<') && content.includes('>'),
|
||||
};
|
||||
});
|
||||
|
||||
setNotes(adaptedNotes);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notes:', error);
|
||||
setNotes([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, onMount, Show } from 'solid-js';
|
||||
import { createEffect, createSignal, onMount, Show } from 'solid-js';
|
||||
import { IconTrash, IconRestore, IconFileText, IconFileTypePpt, IconFileTypeDocx, IconClock, IconSettings, IconAlertTriangle } from '@tabler/icons-solidjs';
|
||||
|
||||
interface RemovedItem {
|
||||
@@ -28,6 +28,10 @@ export const RemovedStuff = () => {
|
||||
const [showSettings, setShowSettings] = createSignal(false);
|
||||
const [selectedItems, setSelectedItems] = createSignal<string[]>([]);
|
||||
|
||||
createEffect(() => {
|
||||
localStorage.setItem('removedItems', JSON.stringify(removedItems()));
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Load auto-remove settings from localStorage
|
||||
const savedSettings = localStorage.getItem('autoRemoveSettings');
|
||||
@@ -35,60 +39,15 @@ export const RemovedStuff = () => {
|
||||
setAutoRemoveSettings(JSON.parse(savedSettings));
|
||||
}
|
||||
|
||||
// Enhanced mock data with more realistic items
|
||||
const mockItems: RemovedItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Old Document',
|
||||
type: 'docx',
|
||||
removedAt: '2 days ago',
|
||||
removedBy: 'John Doe',
|
||||
size: '2.5 MB',
|
||||
path: '/documents/old-document.docx',
|
||||
daysInTrash: 2
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Deleted Presentation',
|
||||
type: 'pptx',
|
||||
removedAt: '1 week ago',
|
||||
removedBy: 'Jane Smith',
|
||||
size: '15.3 MB',
|
||||
path: '/presentations/deleted-presentation.pptx',
|
||||
daysInTrash: 7
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Removed Note',
|
||||
type: 'note',
|
||||
removedAt: '2 weeks ago',
|
||||
removedBy: 'Admin',
|
||||
size: '156 KB',
|
||||
path: '/notes/removed-note.md',
|
||||
daysInTrash: 14
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Old Backup File',
|
||||
type: 'zip',
|
||||
removedAt: '3 weeks ago',
|
||||
removedBy: 'System',
|
||||
size: '125.7 MB',
|
||||
path: '/backups/old-backup.zip',
|
||||
daysInTrash: 21
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Temporary Files',
|
||||
type: 'folder',
|
||||
removedAt: '1 month ago',
|
||||
removedBy: 'John Doe',
|
||||
size: '8.2 MB',
|
||||
path: '/temp/temporary-files',
|
||||
daysInTrash: 30
|
||||
const savedItems = localStorage.getItem('removedItems');
|
||||
if (savedItems) {
|
||||
try {
|
||||
const parsedItems = JSON.parse(savedItems);
|
||||
setRemovedItems(Array.isArray(parsedItems) ? parsedItems : []);
|
||||
} catch {
|
||||
setRemovedItems([]);
|
||||
}
|
||||
];
|
||||
setRemovedItems(mockItems);
|
||||
}
|
||||
|
||||
// Check for auto-remove on mount
|
||||
checkAutoRemove();
|
||||
|
||||
+172
-172
@@ -18,9 +18,10 @@ import {
|
||||
IconClock
|
||||
} from '@tabler/icons-solidjs';
|
||||
import { ActivityFeed } from '@/components/ui/ActivityFeed';
|
||||
import { getMockStats, getMockActivities } from '@/lib/mockData';
|
||||
import { formatDuration } from '@/lib/timeFormat';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface ActivityData {
|
||||
date: string;
|
||||
@@ -92,74 +93,100 @@ export const Stats = () => {
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
void loadStats();
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
};
|
||||
|
||||
const [statsRes, filesRes, tasksRes] = await Promise.allSettled([
|
||||
fetch(`${API_BASE_URL}/dashboard/stats`, { headers }),
|
||||
fetch(`${API_BASE_URL}/files`, { headers }),
|
||||
fetch(`${API_BASE_URL}/tasks`, { headers })
|
||||
]);
|
||||
|
||||
const statsData = statsRes.status === 'fulfilled' && statsRes.value.ok ? await statsRes.value.json() : null;
|
||||
const filesData: Array<any> = filesRes.status === 'fulfilled' && filesRes.value.ok ? await filesRes.value.json() : [];
|
||||
const tasksData: Array<any> = tasksRes.status === 'fulfilled' && tasksRes.value.ok ? await tasksRes.value.json() : [];
|
||||
|
||||
const completedTasks = tasksData.filter((task) => task.status === 'completed').length;
|
||||
const activeTasks = tasksData.filter((task) => task.status !== 'completed').length;
|
||||
const totalSizeBytes = filesData.reduce((acc: number, file: any) => acc + Number(file.file_size || 0), 0);
|
||||
const storageUsedMb = totalSizeBytes / (1024 * 1024);
|
||||
const storageTotalMb = 50 * 1024;
|
||||
|
||||
setStats({
|
||||
totalBookmarks: Number(statsData?.totalBookmarks || 0),
|
||||
totalDocuments: filesData.length,
|
||||
totalTasks: Number(statsData?.totalTasks || tasksData.length || 0),
|
||||
totalNotes: Number(statsData?.totalNotes || 0),
|
||||
completedTasks,
|
||||
activeTasks,
|
||||
storageUsed: `${storageUsedMb.toFixed(2)} MB`,
|
||||
storageTotal: `${storageTotalMb} MB`,
|
||||
weeklyActivity: [0, 0, 0, 0, 0, 0, 0],
|
||||
monthlyGrowth: {
|
||||
bookmarks: 0,
|
||||
documents: 0,
|
||||
tasks: 0,
|
||||
notes: 0
|
||||
},
|
||||
topCategories: [],
|
||||
recentActivity: [
|
||||
{ type: 'Bookmarks', count: Number(statsData?.totalBookmarks || 0), change: 0 },
|
||||
{ type: 'Documents', count: filesData.length, change: 0 },
|
||||
{ type: 'Tasks', count: Number(statsData?.totalTasks || tasksData.length || 0), change: 0 },
|
||||
{ type: 'Notes', count: Number(statsData?.totalNotes || 0), change: 0 }
|
||||
],
|
||||
contributionGraph: []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats data:', error);
|
||||
setStats({
|
||||
totalBookmarks: 0,
|
||||
totalDocuments: 0,
|
||||
totalTasks: 0,
|
||||
totalNotes: 0,
|
||||
completedTasks: 0,
|
||||
activeTasks: 0,
|
||||
storageUsed: '0 MB',
|
||||
storageTotal: '51200 MB',
|
||||
weeklyActivity: [0, 0, 0, 0, 0, 0, 0],
|
||||
monthlyGrowth: {
|
||||
bookmarks: 0,
|
||||
documents: 0,
|
||||
tasks: 0,
|
||||
notes: 0
|
||||
},
|
||||
topCategories: [],
|
||||
recentActivity: [],
|
||||
contributionGraph: []
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Use mock data from our mockData file
|
||||
const mockStats = getMockStats();
|
||||
const mockActivities = getMockActivities();
|
||||
|
||||
// Generate mock contribution graph data
|
||||
const generateContributionGraph = () => {
|
||||
const graph: ActivityData[] = [];
|
||||
const today = new Date();
|
||||
const oneYearAgo = new Date(today);
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
|
||||
for (let d = new Date(oneYearAgo); d <= today; d.setDate(d.getDate() + 1)) {
|
||||
const count = Math.floor(Math.random() * 10);
|
||||
const level = count === 0 ? 0 : Math.ceil(count / 2);
|
||||
|
||||
graph.push({
|
||||
date: new Date(d).toISOString().split('T')[0],
|
||||
count,
|
||||
level
|
||||
});
|
||||
}
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
// Create test data with varied values to verify height calculations
|
||||
const testWeeklyActivity = [8, 22, 15, 31, 18, 25, 12]; // Fixed test values
|
||||
|
||||
// Use demo mode data if available, otherwise use test data
|
||||
const weeklyActivityData = isDemoMode() ? mockStats.weeklyActivity : testWeeklyActivity;
|
||||
|
||||
// Set stats using mock data
|
||||
setStats({
|
||||
totalBookmarks: mockStats.totalBookmarks,
|
||||
totalDocuments: mockStats.totalDocuments,
|
||||
totalTasks: mockStats.totalTasks,
|
||||
totalNotes: mockStats.totalNotes,
|
||||
completedTasks: mockStats.completedTasks,
|
||||
activeTasks: mockStats.activeTasks,
|
||||
storageUsed: mockStats.totalSize,
|
||||
storageTotal: '50 GB',
|
||||
weeklyActivity: weeklyActivityData, // Use demo mode or test data
|
||||
monthlyGrowth: mockStats.monthlyGrowth,
|
||||
topCategories: [
|
||||
{ name: 'Work', count: 45, color: 'hsl(var(--primary))' },
|
||||
{ name: 'Personal', count: 32, color: 'hsl(var(--primary))' },
|
||||
{ name: 'Learning', count: 28, color: 'hsl(var(--primary))' }
|
||||
],
|
||||
recentActivity: [
|
||||
{ type: 'Bookmarks', count: mockActivities.filter(a => a.type === 'bookmark').length, change: 8 },
|
||||
{ type: 'Documents', count: mockActivities.filter(a => a.type === 'document').length, change: -2 },
|
||||
{ type: 'Tasks', count: mockActivities.filter(a => a.type === 'task').length, change: 3 },
|
||||
{ type: 'Notes', count: mockActivities.filter(a => a.type === 'note').length, change: 12 }
|
||||
],
|
||||
contributionGraph: generateContributionGraph()
|
||||
});
|
||||
void loadStats();
|
||||
});
|
||||
|
||||
const storagePercentage = () => {
|
||||
const used = parseFloat(stats().storageUsed);
|
||||
const total = parseFloat(stats().storageTotal);
|
||||
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((used / total) * 100);
|
||||
};
|
||||
|
||||
const taskCompletionRate = () => {
|
||||
if (stats().totalTasks <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((stats().completedTasks / stats().totalTasks) * 100);
|
||||
};
|
||||
return (
|
||||
@@ -169,15 +196,6 @@ export const Stats = () => {
|
||||
<h1 class="text-2xl font-bold">Statistics & Activity</h1>
|
||||
<p class="text-muted-foreground mt-2">Track your productivity, growth, and activity over time</p>
|
||||
</div>
|
||||
|
||||
{/* Demo Mode Indicator */}
|
||||
<Show when={isDemoMode()}>
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-3">
|
||||
<p class="text-yellow-800 dark:text-yellow-200 text-sm font-medium">
|
||||
Demo Mode Active - Showing sample data
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-start">
|
||||
@@ -289,7 +307,7 @@ export const Stats = () => {
|
||||
<div class="flex items-center gap-2">
|
||||
<IconUsers class="size-4 text-primary" />
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-foreground">12</p>
|
||||
<p class="text-lg font-semibold text-foreground">0</p>
|
||||
<p class="text-xs text-muted-foreground">Collaborators</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,7 +317,7 @@ export const Stats = () => {
|
||||
<div class="flex items-center gap-2">
|
||||
<IconChartLine class="size-4 text-primary" />
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-foreground">{stats().averageProductivity || 78}%</p>
|
||||
<p class="text-lg font-semibold text-foreground">{stats().averageProductivity ?? 0}%</p>
|
||||
<p class="text-xs text-muted-foreground">Productivity</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,7 +327,7 @@ export const Stats = () => {
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCalendar class="size-4 text-primary" />
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-foreground">156</p>
|
||||
<p class="text-lg font-semibold text-foreground">0</p>
|
||||
<p class="text-xs text-muted-foreground">Days Active</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,7 +337,7 @@ export const Stats = () => {
|
||||
<div class="flex items-center gap-2">
|
||||
<IconSettings class="size-4 text-primary" />
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-foreground">{stats().recentProjects?.length || 4}</p>
|
||||
<p class="text-lg font-semibold text-foreground">{stats().recentProjects?.length || 0}</p>
|
||||
<p class="text-xs text-muted-foreground">Projects</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,7 +347,7 @@ export const Stats = () => {
|
||||
<div class="flex items-center gap-2">
|
||||
<IconFolder class="size-4 text-primary" />
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-foreground">{stats().storageUsed || 12.94} GB</p>
|
||||
<p class="text-lg font-semibold text-foreground">{stats().storageUsed}</p>
|
||||
<p class="text-xs text-muted-foreground">Storage Used</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -424,44 +442,49 @@ export const Stats = () => {
|
||||
<h3 class="text-lg font-semibold">Weekly Activity</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="relative h-32 sm:h-36 md:h-40 lg:h-44 px-4 sm:px-6 weekly-activity-chart">
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-1 sm:gap-2">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivity = stats().weeklyActivity;
|
||||
const activity = weeklyActivity[index];
|
||||
const maxActivity = Math.max(...weeklyActivity);
|
||||
// Dynamic scale: use the highest value as the scale, with minimum of 25 for better visualization
|
||||
const scaleMax = Math.max(maxActivity, 25);
|
||||
// Calculate height percentage (use 85% of available height to leave room for labels)
|
||||
const heightPercent = (activity / scaleMax) * 85;
|
||||
// Ensure minimum height for visibility
|
||||
const finalHeightPercent = Math.max(heightPercent, 5);
|
||||
<Show
|
||||
when={stats().weeklyActivity.reduce((a, b) => a + b, 0) > 0}
|
||||
fallback={
|
||||
<div class="h-32 sm:h-36 md:h-40 lg:h-44 border border-dashed border-border rounded-lg flex items-center justify-center">
|
||||
<p class="text-sm text-muted-foreground">No weekly activity yet.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative h-32 sm:h-36 md:h-40 lg:h-44 px-4 sm:px-6 weekly-activity-chart">
|
||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||
<div class="border-t border-border/60"></div>
|
||||
<div class="border-t border-border/40"></div>
|
||||
<div class="border-t border-border/30"></div>
|
||||
<div class="border-t border-border/20"></div>
|
||||
</div>
|
||||
<div class="relative flex items-end justify-between h-full gap-1 sm:gap-2">
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||
const weeklyActivity = stats().weeklyActivity;
|
||||
const activity = weeklyActivity[index];
|
||||
const maxActivity = Math.max(...weeklyActivity, 1);
|
||||
const heightPercent = (activity / maxActivity) * 85;
|
||||
const finalHeightPercent = activity > 0 ? Math.max(heightPercent, 6) : 0;
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 h-full">
|
||||
<div class="relative w-full max-w-2 sm:max-w-3 md:max-w-4 flex flex-col items-center justify-end h-full">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10 bg-background px-1 rounded shadow-sm">
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-2 sm:max-w-3 md:max-w-4 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%; min-height: 4px;`}
|
||||
title={`${day}: ${activity} activities (${finalHeightPercent.toFixed(1)}%)`}
|
||||
></div>
|
||||
return (
|
||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 h-full">
|
||||
<div class="relative w-full max-w-2 sm:max-w-3 md:max-w-4 flex flex-col items-center justify-end h-full">
|
||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10 bg-background px-1 rounded shadow-sm">
|
||||
{activity}
|
||||
</span>
|
||||
<div
|
||||
class="w-full max-w-2 sm:max-w-3 md:max-w-4 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||
style={`height: ${finalHeightPercent}%;`}
|
||||
title={`${day}: ${activity} activities`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1 hidden sm:block">{day}</span>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1 sm:hidden">{day.charAt(0)}</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1 hidden sm:block">{day}</span>
|
||||
<span class="text-xs text-muted-foreground font-medium mt-1 sm:hidden">{day.charAt(0)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between text-xs text-muted-foreground pt-2 border-t border-border gap-1 sm:gap-0">
|
||||
<span>Total: {stats().weeklyActivity.reduce((a, b) => a + b, 0)} activities</span>
|
||||
@@ -476,28 +499,33 @@ export const Stats = () => {
|
||||
<IconUsers class="size-5 text-primary" />
|
||||
<h3 class="text-lg font-semibold">Top Categories</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{stats().topCategories.map((category) => (
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style={`background-color: ${category.color}`}
|
||||
></div>
|
||||
<span class="text-sm">{category.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-24 bg-muted rounded-full h-2">
|
||||
<Show
|
||||
when={stats().topCategories.length > 0}
|
||||
fallback={<p class="text-sm text-muted-foreground">No category activity yet.</p>}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
{stats().topCategories.map((category) => (
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="bg-primary h-2 rounded-full transition-all duration-500"
|
||||
style={`width: ${(category.count / Math.max(...stats().topCategories.map(c => c.count))) * 100}%`}
|
||||
class="w-3 h-3 rounded-full"
|
||||
style={`background-color: ${category.color}`}
|
||||
></div>
|
||||
<span class="text-sm">{category.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-24 bg-muted rounded-full h-2">
|
||||
<div
|
||||
class="bg-primary h-2 rounded-full transition-all duration-500"
|
||||
style={`width: ${(category.count / Math.max(...stats().topCategories.map(c => c.count))) * 100}%`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground w-8 text-right">{category.count}</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground w-8 text-right">{category.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Activity Section - Responsive Layout */}
|
||||
@@ -534,60 +562,32 @@ export const Stats = () => {
|
||||
{/* Activity Breakdown */}
|
||||
<div class="border rounded-lg p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Activity Breakdown</h3>
|
||||
<div class="space-y-3">
|
||||
{stats().recentActivity.map((activity) => (
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">{activity.type}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{activity.count}</span>
|
||||
<Show when={activity.change !== 0}>
|
||||
<span class={`text-xs text-muted-foreground`}>
|
||||
{activity.change > 0 ? '+' : ''}{activity.change}
|
||||
</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={stats().recentActivity.length > 0}
|
||||
fallback={<p class="text-sm text-muted-foreground">No activity breakdown yet.</p>}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
{stats().recentActivity.map((activity) => (
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">{activity.type}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{activity.count}</span>
|
||||
<Show when={activity.change !== 0}>
|
||||
<span class={`text-xs text-muted-foreground`}>
|
||||
{activity.change > 0 ? '+' : ''}{activity.change}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Commits</span>
|
||||
<span class="text-sm font-medium">89</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Pull Requests</span>
|
||||
<span class="text-sm font-medium">12</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Stars</span>
|
||||
<span class="text-sm font-medium">45</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Forks</span>
|
||||
<span class="text-sm font-medium">12</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Active Repositories */}
|
||||
<div class="border rounded-lg p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Active Repositories</h3>
|
||||
<div class="space-y-3">
|
||||
{[
|
||||
{ name: 'trackeep', language: 'TypeScript', activity: '2h ago' },
|
||||
{ name: 'solid-components', language: 'TypeScript', activity: '5h ago' },
|
||||
{ name: 'go-api', language: 'Go', activity: '1d ago' },
|
||||
{ name: 'ml-models', language: 'Python', activity: '2d ago' }
|
||||
].map((repo) => (
|
||||
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{repo.name}</p>
|
||||
<p class="text-xs text-muted-foreground">{repo.language}</p>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">{repo.activity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">No repository activity yet.</p>
|
||||
</div>
|
||||
|
||||
{/* Activity Settings */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { TaskModal } from '@/components/ui/TaskModal';
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-solidjs';
|
||||
import { getMockTasks } from '@/lib/mockData';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -24,10 +26,10 @@ export const Tasks = () => {
|
||||
const [editingTask, setEditingTask] = createSignal<Task | null>(null);
|
||||
const [filter, setFilter] = createSignal<'all' | 'active' | 'completed'>('all');
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [selectedPriority, setSelectedPriority] = createSignal('');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/tasks`, {
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
@@ -40,18 +42,7 @@ export const Tasks = () => {
|
||||
setTasks(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
// Fallback to mock data if API fails
|
||||
const mockTasks = getMockTasks();
|
||||
const adaptedTasks = mockTasks.map((task, index) => ({
|
||||
id: index + 1,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
completed: task.status === 'completed',
|
||||
priority: task.priority,
|
||||
createdAt: task.createdAt,
|
||||
dueDate: task.dueDate
|
||||
}));
|
||||
setTasks(adaptedTasks);
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -63,13 +54,15 @@ export const Tasks = () => {
|
||||
const matchesSearch = !term ||
|
||||
task.title.toLowerCase().includes(term) ||
|
||||
(task.description && task.description.toLowerCase().includes(term));
|
||||
|
||||
const matchesPriority = !selectedPriority() || task.priority === selectedPriority();
|
||||
|
||||
const matchesFilter =
|
||||
(filter() === 'active' && !task.completed) ||
|
||||
(filter() === 'completed' && task.completed) ||
|
||||
filter() === 'all';
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
return matchesSearch && matchesFilter && matchesPriority;
|
||||
});
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
@@ -81,7 +74,6 @@ export const Tasks = () => {
|
||||
|
||||
const handleAddTask = async (task: Omit<Task, 'id'>) => {
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -108,7 +100,6 @@ export const Tasks = () => {
|
||||
if (!editingTask()) return;
|
||||
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/tasks/${editingTask()!.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -150,7 +141,6 @@ export const Tasks = () => {
|
||||
const deleteTask = async (taskId: number) => {
|
||||
if (confirm('Are you sure you want to delete this task?')) {
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@@ -191,6 +181,9 @@ export const Tasks = () => {
|
||||
return { total, completed, active };
|
||||
};
|
||||
|
||||
const hasSearchOrPriorityFilters = () =>
|
||||
Boolean(searchTerm().trim()) || Boolean(selectedPriority());
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -232,30 +225,30 @@ export const Tasks = () => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div class="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={searchTerm()}
|
||||
onInput={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
if (target) setSearchTerm(target.value);
|
||||
}}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
{(['all', 'active', 'completed'] as const).map((filterOption) => (
|
||||
<Button
|
||||
variant={filter() === filterOption ? 'default' : 'outline'}
|
||||
onClick={() => setFilter(filterOption)}
|
||||
class="capitalize"
|
||||
>
|
||||
{filterOption}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<SearchTagFilterBar
|
||||
searchPlaceholder="Search tasks..."
|
||||
searchValue={searchTerm()}
|
||||
onSearchChange={(value) => setSearchTerm(value)}
|
||||
tagOptions={['high', 'medium', 'low']}
|
||||
selectedTag={selectedPriority()}
|
||||
onTagChange={(value) => setSelectedPriority(value)}
|
||||
onReset={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedPriority('');
|
||||
}}
|
||||
allOptionLabel="All Priorities"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap gap-2 -mt-3 mb-6">
|
||||
{(['all', 'active', 'completed'] as const).map((filterOption) => (
|
||||
<Button
|
||||
variant={filter() === filterOption ? 'default' : 'outline'}
|
||||
onClick={() => setFilter(filterOption)}
|
||||
class="capitalize"
|
||||
>
|
||||
{filterOption}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading() ? (
|
||||
@@ -337,7 +330,9 @@ export const Tasks = () => {
|
||||
{filteredTasks().length === 0 && (
|
||||
<Card class="p-12 text-center">
|
||||
<p class="text-[#a3a3a3]">
|
||||
{filter() === 'completed' ? 'No completed tasks yet.' :
|
||||
{hasSearchOrPriorityFilters()
|
||||
? 'No tasks found matching your search or filters.'
|
||||
: filter() === 'completed' ? 'No completed tasks yet.' :
|
||||
filter() === 'active' ? 'No active tasks. Great job!' :
|
||||
'No tasks yet. Add your first task!'}
|
||||
</p>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { VideoPreviewModal } from '@/components/ui/VideoPreviewModal';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { getMockVideos } from '@/lib/mockData';
|
||||
import { getAuthHeaders } from '@/lib/auth';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import {
|
||||
IconAlertCircle
|
||||
} from '@tabler/icons-solidjs';
|
||||
@@ -158,20 +160,6 @@ export const Youtube = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Check if we're in demo mode (for display purposes only)
|
||||
const isDemoMode = () => {
|
||||
const demoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true');
|
||||
console.log('YouTube page - Demo mode check:', {
|
||||
localStorage: localStorage.getItem('demoMode'),
|
||||
title: document.title,
|
||||
search: window.location.search,
|
||||
result: demoMode
|
||||
});
|
||||
return demoMode;
|
||||
};
|
||||
|
||||
// Extract video ID from YouTube URL
|
||||
const extractVideoId = (url: string): string | null => {
|
||||
const regex = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/;
|
||||
@@ -343,38 +331,43 @@ export const Youtube = () => {
|
||||
console.warn('Backend API failed for featured channels:', backendError);
|
||||
}
|
||||
|
||||
// Final fallback to demo mode
|
||||
console.log('All API methods failed, using demo mode for featured channels');
|
||||
const mockVideos = getMockVideos();
|
||||
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).map((video) => ({
|
||||
video_id: video.id,
|
||||
channel_name: video.channel,
|
||||
url: video.url,
|
||||
title: video.title,
|
||||
duration: video.duration,
|
||||
published_at: video.publishedAt,
|
||||
view_count: '1000',
|
||||
category: video.category || 'General'
|
||||
}));
|
||||
|
||||
setPredefinedVideos(videos);
|
||||
if (isDemoMode()) {
|
||||
console.log('All API methods failed, using demo data for featured channels');
|
||||
const mockVideos = getMockVideos();
|
||||
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).map((video) => ({
|
||||
video_id: video.id,
|
||||
channel_name: video.channel,
|
||||
url: video.url,
|
||||
title: video.title,
|
||||
duration: video.duration,
|
||||
published_at: video.publishedAt,
|
||||
view_count: '1000',
|
||||
category: video.category || 'General'
|
||||
}));
|
||||
setPredefinedVideos(videos);
|
||||
} else {
|
||||
setPredefinedVideos([]);
|
||||
setPredefinedError('No predefined videos available yet.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in loadPredefinedVideos:', err);
|
||||
setPredefinedError(err instanceof Error ? err.message : 'Failed to load predefined channel videos');
|
||||
// Fallback to demo mode
|
||||
const mockVideos = getMockVideos();
|
||||
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).map((video) => ({
|
||||
video_id: video.id,
|
||||
channel_name: video.channel,
|
||||
url: video.url,
|
||||
title: video.title,
|
||||
duration: video.duration,
|
||||
published_at: video.publishedAt,
|
||||
view_count: '1000',
|
||||
category: video.category || 'General'
|
||||
}));
|
||||
|
||||
setPredefinedVideos(videos);
|
||||
if (isDemoMode()) {
|
||||
const mockVideos = getMockVideos();
|
||||
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).map((video) => ({
|
||||
video_id: video.id,
|
||||
channel_name: video.channel,
|
||||
url: video.url,
|
||||
title: video.title,
|
||||
duration: video.duration,
|
||||
published_at: video.publishedAt,
|
||||
view_count: '1000',
|
||||
category: video.category || 'General'
|
||||
}));
|
||||
setPredefinedVideos(videos);
|
||||
} else {
|
||||
setPredefinedVideos([]);
|
||||
setPredefinedError(err instanceof Error ? err.message : 'Failed to load predefined channel videos');
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingPredefined(false);
|
||||
}
|
||||
@@ -993,20 +986,21 @@ export const Youtube = () => {
|
||||
|
||||
{/* Channel Editor Modal */}
|
||||
<Show when={showChannelEditor()}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowChannelEditor(false);
|
||||
setEditingChannel(null);
|
||||
setNewChannelName('');
|
||||
setNewChannelId('');
|
||||
setNewChannelDescription('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<ModalPortal>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowChannelEditor(false);
|
||||
setEditingChannel(null);
|
||||
setNewChannelName('');
|
||||
setNewChannelId('');
|
||||
setNewChannelDescription('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold">Manage Featured Channels</h2>
|
||||
<Button
|
||||
@@ -1175,9 +1169,10 @@ export const Youtube = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Update service for handling application updates
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
releaseNotes: string;
|
||||
@@ -25,14 +27,6 @@ export interface UpdateCheckResponse {
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
|
||||
// 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') ||
|
||||
import.meta.env.VITE_DEMO_MODE === 'true';
|
||||
};
|
||||
|
||||
export const updateService = {
|
||||
// Check for available updates
|
||||
async checkForUpdates(): Promise<UpdateCheckResponse> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { updateService, type UpdateInfo, type UpdateStatus } from '../services/updateService';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
// Global update state store
|
||||
const [updateAvailable, setUpdateAvailable] = createSignal(false);
|
||||
@@ -61,12 +62,7 @@ const installUpdate = async () => {
|
||||
await updateService.installUpdate(updateInfo()!.version);
|
||||
|
||||
// Start polling for progress or simulation in demo mode
|
||||
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||
document.title.includes('Demo Mode') ||
|
||||
window.location.search.includes('demo=true') ||
|
||||
import.meta.env.VITE_DEMO_MODE === 'true';
|
||||
|
||||
if (isDemoMode) {
|
||||
if (isDemoMode()) {
|
||||
pollCleanup = updateService.simulateUpdateProgress((progress: UpdateStatus) => {
|
||||
setUpdateStatus(progress);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user