import { createSignal, createEffect, onMount, For, Show } from 'solid-js'; import { Button } from '@/components/ui/Button'; 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 { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode'; import { getApiV1BaseUrl } from '@/lib/api-url'; const API_BASE_URL = getApiV1BaseUrl(); interface Note { id: number; title: string; content: string; createdAt: string; updatedAt: string; tags: string[]; pinned: boolean; attachments?: Array<{ id: string; name: string; type: string; size: string; url?: string; }>; isMarkdown?: boolean; isHtml?: boolean; } const renderMarkdownPreviewHtml = (content: string, maxBlocks = 4): string => { const html = content .replace(/^# (.*$)/gim, '
$1<\/code>')
.replace(/```(.*?)\n([\s\S]*?)```/g, '$2<\/code><\/pre>')
.replace(/^- \[ \] (.*$)/gim, '$1')
.replace(/^- \[x\] (.*$)/gim, '$1')
.replace(/^- (.*$)/gim, '$1<\/li>')
.replace(/^\d+\. (.*$)/gim, ' $1<\/li>')
.replace(/> (.*$)/gim, '$1<\/blockquote>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1<\/a>')
.replace(/\n\n+/g, '<\/p>');
const parts = html.split('<\/p>
');
const limited = parts.slice(0, maxBlocks).join('<\/p>
');
return limited;
};
const renderPlainTextPreviewHtml = (content: string): string => {
return content
.replace(/(https?:\/\/[^\s]+)/g, '$1<\/a>')
.replace(/\*\*(.*?)\*\*/g, '$1<\/strong>')
.replace(/\*(.*?)\*/g, '$1<\/em>')
.replace(/^- \[ \] (.*$)/gim, '$1')
.replace(/^- \[x\] (.*$)/gim, '$1')
.split('\n')
.slice(0, 6)
.map((line) => (line ? line : '
'))
.join('\n');
};
export const Notes = () => {
const [notes, setNotes] = createSignal([]);
const [isLoading, setIsLoading] = createSignal(true);
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedTags, setSelectedTags] = createSignal([]);
const [showAddModal, setShowAddModal] = createSignal(false);
const [showEditModal, setShowEditModal] = createSignal(false);
const [showViewModal, setShowViewModal] = createSignal(false);
const [editingNote, setEditingNote] = createSignal(null);
const [viewingNote, setViewingNote] = createSignal(null);
const [copiedContent, setCopiedContent] = createSignal(false);
const [expandedNotes, setExpandedNotes] = createSignal>(new Set());
onMount(async () => {
try {
let notesData: any[] = [];
// Check if we should use demo mode or real API
if (isDemoMode() && !shouldUseRealBackend()) {
console.log('[Notes] Loading demo notes data');
// Load mock notes data for demo mode
const mockNotesData = getMockNotes();
notesData = mockNotesData;
} else {
console.log('[Notes] Loading notes from real API');
// Load from real API
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');
}
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 ?? tags.includes('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);
// Fallback to demo data if API fails
if (!isDemoMode()) {
console.log('[Notes] API failed, falling back to demo data');
const mockNotesData = getMockNotes();
const adaptedNotes: Note[] = mockNotesData.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 ?? tags.includes('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);
} else {
setNotes([]);
}
} finally {
setIsLoading(false);
}
});
const filteredNotes = () => {
const term = searchTerm().toLowerCase();
const tags = selectedTags();
return notes().filter(note => {
const matchesSearch = note.title.toLowerCase().includes(term) ||
note.content.toLowerCase().includes(term) ||
note.tags.some(tag => tag.toLowerCase().includes(term));
const matchesTags = tags.length === 0 ||
tags.every(tag => note.tags.includes(tag));
return matchesSearch && matchesTags;
}).sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
};
const allTags = () => {
const tagSet = new Set();
notes().forEach(note => {
note.tags.forEach(tag => tagSet.add(tag));
});
return Array.from(tagSet).sort();
};
const toggleTag = (tag: string) => {
const currentTags = selectedTags();
if (currentTags.includes(tag)) {
setSelectedTags([]);
} else {
setSelectedTags([tag]);
}
};
const handleAddNote = async (noteData: any) => {
try {
if (isDemoMode() && !shouldUseRealBackend()) {
// Demo mode: Add note locally
const note: Note = {
id: Date.now(),
title: noteData.title,
content: noteData.content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: noteData.tags,
pinned: false
};
setNotes(prev => [note, ...prev]);
setShowAddModal(false);
} else {
// Real API: Create note via API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(noteData),
});
if (!response.ok) {
throw new Error('Failed to create note');
}
const newNote = await response.json();
const adaptedNote: Note = {
id: newNote.id,
title: newNote.title,
content: newNote.content,
createdAt: newNote.created_at || newNote.createdAt,
updatedAt: newNote.updated_at || newNote.updatedAt,
tags: Array.isArray(newNote.tags) ? newNote.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.name) : [],
pinned: Boolean(newNote.pinned ?? newNote.is_pinned),
attachments: [],
isMarkdown: newNote.content.includes('#') || newNote.content.includes('*'),
isHtml: newNote.content.includes('<') && newNote.content.includes('>'),
};
setNotes(prev => [adaptedNote, ...prev]);
setShowAddModal(false);
}
} catch (error) {
console.error('Failed to add note:', error);
}
};
const handleEditNote = async (noteData: any) => {
try {
if (isDemoMode() && !shouldUseRealBackend()) {
// Demo mode: Update note locally
setNotes(prev => prev.map(note =>
note.id === noteData.id
? {
...note,
title: noteData.title,
content: noteData.content,
tags: noteData.tags,
updatedAt: new Date().toISOString()
}
: note
));
setShowEditModal(false);
setEditingNote(null);
} else {
// Real API: Update note via API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes/${noteData.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(noteData),
});
if (!response.ok) {
throw new Error('Failed to update note');
}
const updatedNote = await response.json();
setNotes(prev => prev.map(note =>
note.id === noteData.id
? {
...note,
title: updatedNote.title || noteData.title,
content: updatedNote.content || noteData.content,
tags: Array.isArray(updatedNote.tags) ? updatedNote.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.name) : noteData.tags,
updatedAt: updatedNote.updated_at || updatedNote.updatedAt || new Date().toISOString()
}
: note
));
setShowEditModal(false);
setEditingNote(null);
}
} catch (error) {
console.error('Failed to update note:', error);
}
};
const togglePin = async (noteId: number) => {
try {
if (isDemoMode() && !shouldUseRealBackend()) {
// Demo mode: Toggle pin locally
setNotes(prev => prev.map(note =>
note.id === noteId ? { ...note, pinned: !note.pinned } : note
));
} else {
// Real API: Toggle pin via API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const note = notes().find(n => n.id === noteId);
const response = await fetch(`${API_BASE_URL}/notes/${noteId}/pin`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ pinned: !note?.pinned }),
});
if (!response.ok) {
throw new Error('Failed to toggle pin');
}
setNotes(prev => prev.map(note =>
note.id === noteId ? { ...note, pinned: !note.pinned } : note
));
}
} catch (error) {
console.error('Failed to toggle pin:', error);
}
};
const deleteNote = async (noteId: number) => {
try {
if (isDemoMode() && !shouldUseRealBackend()) {
// Demo mode: Delete note locally
setNotes(prev => prev.filter(note => note.id !== noteId));
} else {
// Real API: Delete note via API
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const response = await fetch(`${API_BASE_URL}/notes/${noteId}`, {
method: 'DELETE',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!response.ok) {
throw new Error('Failed to delete note');
}
setNotes(prev => prev.filter(note => note.id !== noteId));
}
} catch (error) {
console.error('Failed to delete note:', error);
}
};
const startEditNote = (note: Note) => {
setEditingNote(note);
setShowEditModal(true);
};
const viewNote = (note: Note) => {
console.log('Viewing note:', note.title);
setViewingNote(note);
setShowViewModal(true);
};
const copyNoteContent = async (note: Note) => {
try {
await navigator.clipboard.writeText(note.content);
setCopiedContent(true);
setTimeout(() => setCopiedContent(false), 2000);
} catch (error) {
console.error('Failed to copy content:', error);
}
};
const toggleNoteExpansion = (noteId: number) => {
setExpandedNotes(prev => {
const newSet = new Set(prev);
if (newSet.has(noteId)) {
newSet.delete(noteId);
} else {
newSet.add(noteId);
}
return newSet;
});
};
const exportNote = (note: Note) => {
const content = note.isMarkdown ? `# ${note.title}\n\n${note.content}` : note.content;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${note.title.replace(/\s+/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
};
// Add this function to handle checkbox changes
const updateNoteCheckbox = (noteId: number, checkboxIndex: number, isChecked: boolean) => {
setNotes(prev => prev.map(note => {
if (note.id === noteId) {
const lines = note.content.split('\n');
let checkboxCount = 0;
const updatedLines = lines.map(line => {
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
const checkedMatch = line.match(/^- \[x\] (.*)$/);
if (uncheckedMatch || checkedMatch) {
if (checkboxCount === checkboxIndex) {
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
return isChecked ? `- [x] ${text}` : `- [ ] ${text}`;
}
checkboxCount++;
}
return line;
});
return {
...note,
content: updatedLines.join('\n'),
updatedAt: new Date().toISOString()
};
}
return note;
}));
};
// Handler for updating note content from ViewNoteModal
const handleUpdateNoteContent = (noteId: number, content: string) => {
setNotes(prev => prev.map(note =>
note.id === noteId
? { ...note, content, updatedAt: new Date().toISOString() }
: note
));
};
// Make the function available globally for checkbox onchange handlers
createEffect(() => {
(window as any).updateNoteContent = (checkbox: HTMLInputElement) => {
const noteElement = checkbox.closest('[data-note-id]');
if (noteElement) {
const noteId = parseInt(noteElement.getAttribute('data-note-id') || '0');
const checkboxElements = noteElement.querySelectorAll('input[type="checkbox"]');
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
updateNoteCheckbox(noteId, checkboxIndex, checkbox.checked);
}
};
});
return (
{/* Header - Signal/Discord style */}
Notes
Your personal notes
{/* Main Content Area */}
{/* Sidebar - Discord style */}
Search & Filter
setSearchTerm(value)}
tagOptions={allTags()}
selectedTag={selectedTags()[0] || ''}
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
onReset={() => {
setSearchTerm('');
setSelectedTags([]);
}}
/>
Quick Stats
Total Notes
{filteredNotes().length}
Pinned
{filteredNotes().filter(n => n.pinned).length}
Tags
{allTags().length}
{/* Notes List - Signal style chat */}
Content copied!
{isLoading() ? (
{[...Array(3)].map(() => (
))}
) : (
<>
{filteredNotes().length > 0 ? (
filteredNotes().map((note) => (
viewNote(note)}
>
{note.title}
{note.pinned && }
{note.isMarkdown && MD}
{note.isHtml && HTML}
{/* Note Preview */}
}
>
$1')
.replace(/^## (.*$)/gim, '$1
')
.replace(/^### (.*$)/gim, '$1
')
.replace(/^#### (.*$)/gim, '$1
')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`(.*?)`/g, '$1')
.replace(/```(.*?)\n([\s\S]*?)```/g, '$2
')
.replace(/^- \[ \] (.*$)/gim, '$1')
.replace(/^- \[x\] (.*$)/gim, '$1')
.replace(/^- (.*$)/gim, '$1 ')
.replace(/^\d+\. (.*$)/gim, '$1 ')
.replace(/> (.*$)/gim, '$1
')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/\n\n+/g, '')
: note.content.replace(/(https?:\/\/[^\s]+)/g, '$1')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/^- \[ \] (.*$)/gim, '
$1')
.replace(/^- \[x\] (.*$)/gim, '$1')
.split('\n').map((line) => line ? `${line}
` : '
').join('')
}
/>
{/* Tags */}
{(tag) => (
)}
{/* Attachments */}
0}>
Attachments ({note.attachments?.length || 0})
{(attachment) => (
{attachment.name}
({attachment.size})
)}
Updated: {note.updatedAt && !isNaN(new Date(note.updatedAt).getTime()) ? new Date(note.updatedAt).toLocaleDateString() : 'Invalid Date'}
))
) : (
📝
{searchTerm() || selectedTags().length > 0
? 'No notes found matching your search or filters.'
: 'No notes yet. Add your first note!'}
{searchTerm() || selectedTags().length > 0
? 'Try adjusting your search terms or filters.'
: 'Click the "Add Note" button to get started.'}
)}
>
)}
{/* Add Note Modal */}
setShowAddModal(false)}
onSubmit={handleAddNote}
availableTags={allTags()}
/>
{/* Edit Note Modal */}
{
setShowEditModal(false);
setEditingNote(null);
}}
onSubmit={handleEditNote}
note={editingNote()}
availableTags={allTags()}
/>
{/* View Note Modal */}
{
setShowViewModal(false);
setViewingNote(null);
}}
note={viewingNote()}
onEdit={startEditNote}
onTogglePin={togglePin}
onDelete={deleteNote}
onCopyContent={copyNoteContent}
onExportNote={exportNote}
onUpdateNote={handleUpdateNoteContent}
/>
);
};