mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-04 12:32:58 +00:00
feat: major feature updates and cleanup
- Add Redis architecture implementation - Update browser extension functionality - Clean up deprecated files and documentation - Enhance backend handlers for auth, messages, search - Add new configuration options and settings - Update Docker and deployment configurations
This commit is contained in:
+471
-267
@@ -1,10 +1,11 @@
|
||||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { 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();
|
||||
@@ -79,23 +80,34 @@ export const Notes = () => {
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/notes`, {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
let notesData: any[] = [];
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load notes');
|
||||
// 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 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)
|
||||
? note.tags.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const content = note.content || '';
|
||||
@@ -109,7 +121,7 @@ export const Notes = () => {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
tags,
|
||||
pinned: Boolean(note.pinned ?? note.is_pinned),
|
||||
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}`),
|
||||
@@ -127,7 +139,44 @@ export const Notes = () => {
|
||||
setNotes(adaptedNotes);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notes:', error);
|
||||
setNotes([]);
|
||||
// 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);
|
||||
}
|
||||
@@ -172,19 +221,53 @@ export const Notes = () => {
|
||||
|
||||
const handleAddNote = async (noteData: any) => {
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
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);
|
||||
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);
|
||||
}
|
||||
@@ -192,20 +275,52 @@ export const Notes = () => {
|
||||
|
||||
const handleEditNote = async (noteData: any) => {
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
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);
|
||||
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);
|
||||
}
|
||||
@@ -213,10 +328,32 @@ export const Notes = () => {
|
||||
|
||||
const togglePin = async (noteId: number) => {
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
setNotes(prev => prev.map(note =>
|
||||
note.id === noteId ? { ...note, pinned: !note.pinned } : note
|
||||
));
|
||||
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);
|
||||
}
|
||||
@@ -224,8 +361,25 @@ export const Notes = () => {
|
||||
|
||||
const deleteNote = async (noteId: number) => {
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
setNotes(prev => prev.filter(note => note.id !== noteId));
|
||||
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);
|
||||
}
|
||||
@@ -329,7 +483,7 @@ export const Notes = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="fixed inset-0 bg-background flex flex-col">
|
||||
<style>
|
||||
{`
|
||||
.note-checkbox {
|
||||
@@ -355,232 +509,282 @@ export const Notes = () => {
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-[#fafafa]">Notes</h1>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
Add Note
|
||||
</Button>
|
||||
|
||||
{/* Header - Signal/Discord style */}
|
||||
<div class="bg-[#2c2c2e] border-b border-[#404040] px-4 py-3 flex-shrink-0">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-white">Notes</h1>
|
||||
<p class="text-sm text-[#b9b9b9]">Your personal notes</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
class="bg-[#5865f2] hover:bg-[#4752c4] text-white border-0"
|
||||
>
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchTagFilterBar
|
||||
searchPlaceholder="Search notes..."
|
||||
searchValue={searchTerm()}
|
||||
onSearchChange={(value) => setSearchTerm(value)}
|
||||
tagOptions={allTags()}
|
||||
selectedTag={selectedTags()[0] || ''}
|
||||
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
|
||||
onReset={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Show when={copiedContent()}>
|
||||
<div class="bg-primary/15 text-primary px-3 py-1 rounded-md text-sm">
|
||||
Content copied!
|
||||
{/* Main Content Area */}
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar - Discord style */}
|
||||
<div class="w-64 bg-[#202225] border-r border-[#404040] flex-shrink-0 overflow-y-auto">
|
||||
<div class="p-4">
|
||||
<h3 class="text-xs font-semibold text-[#8e9297] uppercase tracking-wide mb-3">Search & Filter</h3>
|
||||
<SearchTagFilterBar
|
||||
searchPlaceholder="Search notes..."
|
||||
searchValue={searchTerm()}
|
||||
onSearchChange={(value) => setSearchTerm(value)}
|
||||
tagOptions={allTags()}
|
||||
selectedTag={selectedTags()[0] || ''}
|
||||
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
|
||||
onReset={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mt-6">
|
||||
<h3 class="text-xs font-semibold text-[#8e9297] uppercase tracking-wide mb-3">Quick Stats</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#b9b9b9]">Total Notes</span>
|
||||
<span class="text-sm font-medium text-white">{filteredNotes().length}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#b9b9b9]">Pinned</span>
|
||||
<span class="text-sm font-medium text-white">{filteredNotes().filter(n => n.pinned).length}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#b9b9b9]">Tags</span>
|
||||
<span class="text-sm font-medium text-white">{allTags().length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{isLoading() ? (
|
||||
<div class="space-y-4">
|
||||
{[...Array(3)].map(() => (
|
||||
<Card class="p-6">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-6 bg-[#262626] rounded mb-2"></div>
|
||||
<div class="h-4 bg-[#262626] rounded w-3/4"></div>
|
||||
{/* Notes List - Signal style chat */}
|
||||
<div class="flex-1 bg-[#36393f] overflow-y-auto">
|
||||
<div class="p-4 space-y-2">
|
||||
<Show when={copiedContent()}>
|
||||
<div class="bg-green-500/20 border border-green-500/50 text-green-400 px-3 py-2 rounded-lg text-sm mb-4">
|
||||
Content copied!
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{filteredNotes().map((note) => (
|
||||
<Card
|
||||
data-note-id={note.id}
|
||||
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:bg-[#1a1a1a] ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
|
||||
onClick={() => viewNote(note)}
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold text-[#fafafa]">{note.title}</h3>
|
||||
{note.pinned && <IconPin class="size-4 text-primary" />}
|
||||
{note.isMarkdown && <span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">MD</span>}
|
||||
{note.isHtml && <span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">HTML</span>}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyNoteContent(note);
|
||||
}}
|
||||
class="text-white hover:text-white/80 p-1"
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
exportNote(note);
|
||||
}}
|
||||
class="text-white hover:text-white/80 p-1"
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditNote(note);
|
||||
}}
|
||||
class="text-white hover:text-white/80 p-1"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(note.id);
|
||||
}}
|
||||
class="text-primary hover:text-primary/80 p-1"
|
||||
{...{title: note.pinned ? "Unpin note" : "Pin note"}}
|
||||
>
|
||||
<IconPin size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNote(note.id);
|
||||
}}
|
||||
class="text-destructive hover:text-destructive/80 p-1"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-[#a3a3a3] text-sm mb-3">
|
||||
<div class="prose prose-invert max-w-none">
|
||||
<Show
|
||||
when={expandedNotes().has(note.id)}
|
||||
fallback={
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
'-webkit-line-clamp': '3',
|
||||
'-webkit-box-orient': 'vertical',
|
||||
'max-height': '4.5em',
|
||||
'line-height': '1.5em'
|
||||
}}
|
||||
innerHTML={
|
||||
note.isHtml
|
||||
? note.content
|
||||
: note.isMarkdown
|
||||
? renderMarkdownPreviewHtml(note.content)
|
||||
: renderPlainTextPreviewHtml(note.content)
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
innerHTML={
|
||||
note.isHtml
|
||||
? note.content
|
||||
: note.isMarkdown
|
||||
? note.content.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-2">$1</h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1</h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1</h3>')
|
||||
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1</h4>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1</code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1</blockquote>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\n\n+/g, '</p><p class="mb-2">')
|
||||
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
console.log('Show more clicked for note:', note.title);
|
||||
toggleNoteExpansion(note.id);
|
||||
}}
|
||||
class="mt-2 text-xs text-primary hover:text-primary/80 font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<Show when={note.attachments && note.attachments.length > 0}>
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<IconPaperclip class="size-4 text-[#a3a3a3]" />
|
||||
<span class="text-xs text-[#a3a3a3]">Attachments ({note.attachments?.length || 0})</span>
|
||||
</Show>
|
||||
|
||||
{isLoading() ? (
|
||||
<div class="space-y-3">
|
||||
{[...Array(3)].map(() => (
|
||||
<div class="bg-[#2c2c2e] rounded-lg p-4 animate-pulse">
|
||||
<div class="h-4 bg-[#404040] rounded mb-2"></div>
|
||||
<div class="h-3 bg-[#404040] rounded w-3/4"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={note.attachments || []}>
|
||||
{(attachment) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1 bg-[#262626] rounded-md text-xs">
|
||||
<span class="text-[#a3a3a3]">{attachment.name}</span>
|
||||
<span class="text-[#666]">({attachment.size})</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<For each={note.tags}>
|
||||
{(tag) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTag(tag);
|
||||
}}
|
||||
class="px-2 py-1 bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground text-xs rounded-md transition-colors cursor-pointer"
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredNotes().length > 0 ? (
|
||||
filteredNotes().map((note) => (
|
||||
<div
|
||||
data-note-id={note.id}
|
||||
class={`bg-[#2c2c2e] hover:bg-[#35363c] rounded-lg p-4 cursor-pointer transition-all border border-transparent ${note.pinned ? 'border-l-4 border-l-[#5865f2]' : ''}`}
|
||||
onClick={() => viewNote(note)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<p class="text-[#a3a3a3] text-xs">
|
||||
Updated: {note.updatedAt && !isNaN(new Date(note.updatedAt).getTime()) ? new Date(note.updatedAt).toLocaleDateString() : 'Invalid Date'}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{filteredNotes().length === 0 && (
|
||||
<Card class="p-12 text-center">
|
||||
<p class="text-muted-foreground">
|
||||
{searchTerm() || selectedTags().length > 0
|
||||
? 'No notes found matching your search or filters.'
|
||||
: 'No notes yet. Add your first note!'}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-white font-medium">{note.title}</h3>
|
||||
{note.pinned && <IconPin class="size-4 text-[#faa61a]" />}
|
||||
{note.isMarkdown && <span class="text-xs px-2 py-1 bg-[#5865f2]/20 text-[#5865f2] rounded">MD</span>}
|
||||
{note.isHtml && <span class="text-xs px-2 py-1 bg-[#5865f2]/20 text-[#5865f2] rounded">HTML</span>}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyNoteContent(note);
|
||||
}}
|
||||
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
|
||||
>
|
||||
<IconCopy size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
exportNote(note);
|
||||
}}
|
||||
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
|
||||
>
|
||||
<IconDownload size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditNote(note);
|
||||
}}
|
||||
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(note.id);
|
||||
}}
|
||||
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
|
||||
{...{title: note.pinned ? "Unpin note" : "Pin note"}}
|
||||
>
|
||||
<IconPin size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNote(note.id);
|
||||
}}
|
||||
class="text-[#ed4245] hover:text-[#ff6b6b] p-1 h-6 w-6"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note Preview */}
|
||||
<div class="text-[#dcddde] text-sm">
|
||||
<div class="prose prose-invert max-w-none">
|
||||
<Show
|
||||
when={expandedNotes().has(note.id)}
|
||||
fallback={
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
'-webkit-line-clamp': '2',
|
||||
'-webkit-box-orient': 'vertical',
|
||||
'max-height': '3em',
|
||||
'line-height': '1.5em'
|
||||
}}
|
||||
innerHTML={
|
||||
note.isHtml
|
||||
? note.content
|
||||
: note.isMarkdown
|
||||
? renderMarkdownPreviewHtml(note.content)
|
||||
: renderPlainTextPreviewHtml(note.content)
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
innerHTML={
|
||||
note.isHtml
|
||||
? note.content
|
||||
: note.isMarkdown
|
||||
? note.content.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-2">$1</h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1</h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1</h3>')
|
||||
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1</h4>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#404040] px-1 py-0.5 rounded text-xs">$1</code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#404040] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#404040] pl-3 italic text-[#b9b9b9] mb-2">$1</blockquote>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-[#5865f2] hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\n\n+/g, '</p><p class="mb-2">')
|
||||
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-[#5865f2] hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
console.log('Show more clicked for note:', note.title);
|
||||
toggleNoteExpansion(note.id);
|
||||
}}
|
||||
class="mt-2 text-xs text-[#5865f2] hover:text-[#7289da] font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div class="flex flex-wrap gap-1 mt-3">
|
||||
<For each={note.tags}>
|
||||
{(tag) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTag(tag);
|
||||
}}
|
||||
class="px-2 py-1 bg-[#404040] hover:bg-[#4a4b4e] text-[#b9b9b9] hover:text-white text-xs rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<Show when={note.attachments && note.attachments.length > 0}>
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<IconPaperclip class="size-3 text-[#b9b9b9]" />
|
||||
<span class="text-xs text-[#b9b9b9]">Attachments ({note.attachments?.length || 0})</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={note.attachments || []}>
|
||||
{(attachment) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1 bg-[#404040] rounded-md text-xs">
|
||||
<span class="text-[#dcddde]">{attachment.name}</span>
|
||||
<span class="text-[#8e9297]">({attachment.size})</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-between items-center mt-3 pt-3 border-t border-[#404040]">
|
||||
<p class="text-xs text-[#8e9297]">
|
||||
Updated: {note.updatedAt && !isNaN(new Date(note.updatedAt).getTime()) ? new Date(note.updatedAt).toLocaleDateString() : 'Invalid Date'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div class="bg-[#2c2c2e] rounded-lg p-8 text-center">
|
||||
<div class="text-6xl mb-4">📝</div>
|
||||
<p class="text-[#b9b9b9] text-lg font-medium mb-2">
|
||||
{searchTerm() || selectedTags().length > 0
|
||||
? 'No notes found matching your search or filters.'
|
||||
: 'No notes yet. Add your first note!'}
|
||||
</p>
|
||||
<p class="text-[#8e9297] text-sm">
|
||||
{searchTerm() || selectedTags().length > 0
|
||||
? 'Try adjusting your search terms or filters.'
|
||||
: 'Click the "Add Note" button to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Note Modal */}
|
||||
<NoteModal
|
||||
|
||||
Reference in New Issue
Block a user