Pulse/frontend-modern/src/components/AI/GuestNotes.tsx
rcourtman 721f973271 AI features checkpoint: Host selection, memory sparklines, UI refinements
- Extended AI context selection to host rows in HostsOverview
- Added resourceId prop to StackedMemoryBar for sparkline support
- Relocated guest URL editing from GuestRow name click
- Added GuestNotes component with URL field in AI sidebar
- Refined host routing in AI service backend
- Minor animation and styling improvements
2025-12-07 12:25:26 +00:00

818 lines
42 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Component, createSignal, createEffect, For, Show, createMemo } from 'solid-js';
import { notificationStore } from '@/stores/notifications';
import { apiFetch } from '@/utils/apiClient';
interface Note {
id: string;
category: string;
title: string;
content: string;
created_at: string;
updated_at: string;
}
interface GuestKnowledge {
guest_id: string;
guest_name: string;
guest_type: string;
notes: Note[];
updated_at: string;
}
interface GuestNotesProps {
guestId: string;
guestName?: string;
guestType?: string;
customUrl?: string;
onCustomUrlUpdate?: (guestId: string, url: string) => void;
}
const CATEGORY_LABELS: Record<string, string> = {
service: 'Service',
path: 'Path',
config: 'Config',
credential: 'Credential',
learning: 'Learning',
};
const CATEGORY_ICONS: Record<string, string> = {
service: '⚙️',
path: '📁',
config: '📋',
credential: '🔐',
learning: '💡',
};
const CATEGORY_COLORS: Record<string, string> = {
service: 'bg-blue-500/20 border-blue-500/30 text-blue-300',
path: 'bg-amber-500/20 border-amber-500/30 text-amber-300',
config: 'bg-purple-500/20 border-purple-500/30 text-purple-300',
credential: 'bg-red-500/20 border-red-500/30 text-red-300',
learning: 'bg-green-500/20 border-green-500/30 text-green-300',
};
const CATEGORY_OPTIONS = ['service', 'path', 'config', 'credential', 'learning'];
// Quick templates for common note types
const TEMPLATES = [
{ category: 'credential', title: 'Admin Password', placeholder: 'Enter admin password...' },
{ category: 'credential', title: 'SSH Key', placeholder: 'Paste SSH private key or fingerprint...' },
{ category: 'credential', title: 'API Key', placeholder: 'Enter API key...' },
{ category: 'path', title: 'Config Directory', placeholder: '/path/to/config' },
{ category: 'path', title: 'Data Directory', placeholder: '/path/to/data' },
{ category: 'path', title: 'Log Location', placeholder: '/var/log/service.log' },
{ category: 'service', title: 'Web Interface', placeholder: 'http://localhost:8080' },
{ category: 'service', title: 'Database', placeholder: 'PostgreSQL on port 5432' },
{ category: 'config', title: 'Port Number', placeholder: '8080' },
{ category: 'config', title: 'Environment', placeholder: 'production' },
];
// Format relative time
const formatRelativeTime = (dateStr: string): string => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
export const GuestNotes: Component<GuestNotesProps> = (props) => {
const [knowledge, setKnowledge] = createSignal<GuestKnowledge | null>(null);
const [isLoading, setIsLoading] = createSignal(false);
const [isExpanded, setIsExpanded] = createSignal(false);
const [showAddForm, setShowAddForm] = createSignal(false);
const [showTemplates, setShowTemplates] = createSignal(false);
const [showActions, setShowActions] = createSignal(false);
const [editingNote, setEditingNote] = createSignal<Note | null>(null);
const [searchQuery, setSearchQuery] = createSignal('');
const [filterCategory, setFilterCategory] = createSignal<string>('');
const [showCredentials, setShowCredentials] = createSignal<Set<string>>(new Set());
const [deleteConfirmId, setDeleteConfirmId] = createSignal<string | null>(null);
const [clearConfirm, setClearConfirm] = createSignal(false);
const [isImporting, setIsImporting] = createSignal(false);
// Form state
const [category, setCategory] = createSignal('learning');
const [title, setTitle] = createSignal('');
const [content, setContent] = createSignal('');
// Guest URL state
const [guestUrl, setGuestUrl] = createSignal(props.customUrl || '');
const [isEditingUrl, setIsEditingUrl] = createSignal(false);
const [isSavingUrl, setIsSavingUrl] = createSignal(false);
// Sync URL from props
createEffect(() => {
setGuestUrl(props.customUrl || '');
});
// File input ref for import
let fileInputRef: HTMLInputElement | undefined;
// Fetch knowledge when guestId changes
createEffect(() => {
const guestId = props.guestId;
if (guestId) {
loadKnowledge(guestId);
}
});
const loadKnowledge = async (guestId: string) => {
setIsLoading(true);
try {
// Fetch knowledge and metadata in parallel
const [knowledgeResponse, metadataResponse] = await Promise.all([
apiFetch(`/api/ai/knowledge?guest_id=${encodeURIComponent(guestId)}`),
apiFetch(`/api/guests/metadata/${encodeURIComponent(guestId)}`),
]);
if (knowledgeResponse.ok) {
const data = await knowledgeResponse.json();
setKnowledge(data);
}
// Load customUrl from metadata if not provided via props
if (metadataResponse.ok) {
const metadata = await metadataResponse.json();
if (metadata.customUrl && !props.customUrl) {
setGuestUrl(metadata.customUrl);
}
}
} catch (error) {
console.error('Failed to load guest knowledge:', error);
} finally {
setIsLoading(false);
}
};
const saveGuestUrl = async () => {
const url = guestUrl().trim();
setIsSavingUrl(true);
try {
const response = await apiFetch(`/api/guests/metadata/${encodeURIComponent(props.guestId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customUrl: url }),
});
if (response.ok) {
notificationStore.success(url ? 'Guest URL saved' : 'Guest URL cleared');
setIsEditingUrl(false);
props.onCustomUrlUpdate?.(props.guestId, url);
} else {
notificationStore.error('Failed to save guest URL');
}
} catch (error) {
console.error('Failed to save guest URL:', error);
notificationStore.error('Failed to save guest URL');
} finally {
setIsSavingUrl(false);
}
};
const saveNote = async () => {
if (!title().trim() || !content().trim()) return;
try {
const response = await apiFetch('/api/ai/knowledge/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guest_id: props.guestId,
guest_name: props.guestName || props.guestId,
guest_type: props.guestType || 'unknown',
category: category(),
title: title().trim(),
content: content().trim(),
}),
});
if (response.ok) {
notificationStore.success('Note saved');
// Reset form
setTitle('');
setContent('');
setShowAddForm(false);
setShowTemplates(false);
setEditingNote(null);
// Reload knowledge
loadKnowledge(props.guestId);
} else {
notificationStore.error('Failed to save note');
}
} catch (error) {
console.error('Failed to save note:', error);
notificationStore.error('Failed to save note');
}
};
const deleteNote = async (noteId: string) => {
try {
const response = await apiFetch('/api/ai/knowledge/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guest_id: props.guestId,
note_id: noteId,
}),
});
if (response.ok) {
notificationStore.success('Note deleted');
setDeleteConfirmId(null);
loadKnowledge(props.guestId);
} else {
notificationStore.error('Failed to delete note');
}
} catch (error) {
console.error('Failed to delete note:', error);
notificationStore.error('Failed to delete note');
}
};
const exportNotes = async () => {
try {
const response = await apiFetch(`/api/ai/knowledge/export?guest_id=${encodeURIComponent(props.guestId)}`);
if (response.ok) {
const data = await response.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pulse-notes-${props.guestName || props.guestId}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
notificationStore.success('Notes exported');
setShowActions(false);
} else {
notificationStore.error('Failed to export notes');
}
} catch (error) {
console.error('Failed to export notes:', error);
notificationStore.error('Failed to export notes');
}
};
const handleImportFile = async (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
setIsImporting(true);
try {
const text = await file.text();
const data = JSON.parse(text);
// Add merge flag and ensure guest_id matches current
const importData = {
...data,
guest_id: props.guestId,
guest_name: props.guestName || data.guest_name,
guest_type: props.guestType || data.guest_type,
merge: true, // Merge with existing notes
};
const response = await apiFetch('/api/ai/knowledge/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(importData),
});
if (response.ok) {
const result = await response.json();
notificationStore.success(`Imported ${result.imported} of ${result.total} notes`);
loadKnowledge(props.guestId);
setShowActions(false);
} else {
const errorText = await response.text();
notificationStore.error('Import failed: ' + errorText);
}
} catch (error) {
console.error('Failed to import notes:', error);
notificationStore.error('Failed to parse import file');
} finally {
setIsImporting(false);
// Reset file input
if (fileInputRef) {
fileInputRef.value = '';
}
}
};
const clearAllNotes = async () => {
try {
const response = await apiFetch('/api/ai/knowledge/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guest_id: props.guestId,
confirm: true,
}),
});
if (response.ok) {
const result = await response.json();
notificationStore.success(`Cleared ${result.deleted} notes`);
setClearConfirm(false);
setShowActions(false);
loadKnowledge(props.guestId);
} else {
notificationStore.error('Failed to clear notes');
}
} catch (error) {
console.error('Failed to clear notes:', error);
notificationStore.error('Failed to clear notes');
}
};
const startEdit = (note: Note) => {
setEditingNote(note);
setCategory(note.category);
setTitle(note.title);
setContent(note.content);
setShowAddForm(true);
setShowTemplates(false);
};
const useTemplate = (template: { category: string; title: string; placeholder: string }) => {
setCategory(template.category);
setTitle(template.title);
setContent('');
setShowTemplates(false);
setShowAddForm(true);
};
const cancelEdit = () => {
setEditingNote(null);
setTitle('');
setContent('');
setShowAddForm(false);
setShowTemplates(false);
};
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
notificationStore.success(`${label} copied to clipboard`);
} catch {
notificationStore.error('Failed to copy to clipboard');
}
};
const toggleCredentialVisibility = (noteId: string) => {
const current = showCredentials();
const updated = new Set(current);
if (updated.has(noteId)) {
updated.delete(noteId);
} else {
updated.add(noteId);
}
setShowCredentials(updated);
};
const maskCredential = (content: string): string => {
// Mask most of the content, showing only first 2 and last 2 chars
if (content.length <= 6) {
return '••••••';
}
return content.slice(0, 2) + '•'.repeat(Math.min(content.length - 4, 12)) + content.slice(-2);
};
const notes = () => knowledge()?.notes || [];
const hasNotes = () => notes().length > 0;
// Filtered notes based on search and category
const filteredNotes = createMemo(() => {
let result = notes();
// Filter by category
const catFilter = filterCategory();
if (catFilter) {
result = result.filter(n => n.category === catFilter);
}
// Filter by search query
const query = searchQuery().toLowerCase();
if (query) {
result = result.filter(n =>
n.title.toLowerCase().includes(query) ||
n.content.toLowerCase().includes(query) ||
CATEGORY_LABELS[n.category]?.toLowerCase().includes(query)
);
}
// Sort by updated_at descending
return result.sort((a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
});
// Group notes by category for summary
const notesByCategory = createMemo(() => {
const grouped: Record<string, number> = {};
for (const note of notes()) {
grouped[note.category] = (grouped[note.category] || 0) + 1;
}
return grouped;
});
return (
<div class="border-t border-gray-700 pt-2 mt-2">
{/* Hidden file input for import */}
<input
ref={fileInputRef}
type="file"
accept=".json"
class="hidden"
onChange={handleImportFile}
/>
{/* Header with expand/collapse */}
<button
onClick={() => setIsExpanded(!isExpanded())}
class="flex items-center justify-between w-full text-left px-2 py-1 text-sm hover:bg-gray-700/50 rounded transition-colors"
>
<span class="flex items-center gap-2">
<svg class={`w-3 h-3 transition-transform ${isExpanded() ? 'rotate-90' : ''}`} fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-gray-300 font-medium">Saved Notes</span>
<Show when={hasNotes()}>
<span class="text-xs text-gray-500">({notes().length})</span>
</Show>
</span>
<Show when={isLoading()}>
<span class="text-xs text-gray-500">Loading...</span>
</Show>
</button>
{/* Expandable content */}
<Show when={isExpanded()}>
<div class="mt-2 space-y-2 px-2">
{/* Guest URL field */}
<div class="bg-gray-800/50 rounded p-2 border border-gray-700">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-400 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Guest URL
</span>
<Show when={guestUrl() && !isEditingUrl()}>
<a
href={guestUrl()}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
Open
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</Show>
</div>
<Show when={isEditingUrl()} fallback={
<div class="flex items-center gap-2">
<Show when={guestUrl()} fallback={
<span class="text-xs text-gray-500 italic">No URL set</span>
}>
<span class="text-xs text-gray-300 break-all font-mono">{guestUrl()}</span>
</Show>
<button
onClick={() => setIsEditingUrl(true)}
class="text-xs text-blue-400 hover:text-blue-300 ml-auto"
>
{guestUrl() ? 'Edit' : 'Add'}
</button>
</div>
}>
<div class="space-y-2">
<input
type="text"
value={guestUrl()}
onInput={(e) => setGuestUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveGuestUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
setGuestUrl(props.customUrl || '');
setIsEditingUrl(false);
}
}}
placeholder="https://192.168.1.100:8080"
class="w-full bg-gray-700 text-xs text-gray-200 rounded px-2 py-1.5 border border-gray-600 placeholder-gray-500 font-mono"
autofocus
/>
<div class="flex gap-2 justify-end">
<button
onClick={() => {
setGuestUrl(props.customUrl || '');
setIsEditingUrl(false);
}}
class="text-xs px-2 py-1 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
<button
onClick={saveGuestUrl}
disabled={isSavingUrl()}
class="text-xs px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-500 disabled:opacity-50"
>
{isSavingUrl() ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</Show>
</div>
{/* Search and filter bar - only show if there are notes */}
<Show when={hasNotes()}>
<div class="flex gap-2 mb-2">
<input
type="text"
placeholder="Search notes..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.target.value)}
class="flex-1 bg-gray-700 text-xs text-gray-200 rounded px-2 py-1 border border-gray-600 placeholder-gray-500"
/>
<select
value={filterCategory()}
onChange={(e) => setFilterCategory(e.target.value)}
class="bg-gray-700 text-xs text-gray-200 rounded px-2 py-1 border border-gray-600"
>
<option value="">All</option>
<For each={CATEGORY_OPTIONS}>
{(cat) => (
<Show when={notesByCategory()[cat]}>
<option value={cat}>{CATEGORY_ICONS[cat]} {CATEGORY_LABELS[cat]} ({notesByCategory()[cat]})</option>
</Show>
)}
</For>
</select>
</div>
</Show>
{/* Notes list */}
<Show when={hasNotes()} fallback={
<p class="text-xs text-gray-500 italic">No saved notes yet. Add notes to remember passwords, paths, and configs.</p>
}>
<Show when={filteredNotes().length > 0} fallback={
<p class="text-xs text-gray-500 italic">No notes match your search.</p>
}>
<div class="space-y-1.5 max-h-48 overflow-y-auto">
<For each={filteredNotes()}>
{(note) => (
<div class={`group rounded border px-2 py-1.5 text-xs ${CATEGORY_COLORS[note.category] || 'bg-gray-800/50 border-gray-700'}`}>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-sm" title={CATEGORY_LABELS[note.category]}>{CATEGORY_ICONS[note.category]}</span>
<span class="text-gray-200 font-medium">{note.title}</span>
<span class="text-gray-500 text-[10px]" title={new Date(note.updated_at).toLocaleString()}>
{formatRelativeTime(note.updated_at)}
</span>
</div>
<div class="flex items-center gap-1 mt-0.5">
{/* Content - mask if credential and not revealed */}
<Show when={note.category === 'credential' && !showCredentials().has(note.id)}
fallback={
<p class="text-gray-300 break-words font-mono text-[11px]">{note.content}</p>
}>
<p class="text-gray-400 break-words font-mono text-[11px]">{maskCredential(note.content)}</p>
</Show>
</div>
</div>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{/* Show/hide for credentials */}
<Show when={note.category === 'credential'}>
<button
onClick={() => toggleCredentialVisibility(note.id)}
class="text-gray-400 hover:text-yellow-400 p-0.5"
title={showCredentials().has(note.id) ? 'Hide' : 'Show'}
>
<Show when={showCredentials().has(note.id)} fallback={
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
}>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
</Show>
</button>
</Show>
{/* Copy button */}
<button
onClick={() => copyToClipboard(note.content, note.title)}
class="text-gray-400 hover:text-green-400 p-0.5"
title="Copy content"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
{/* Edit button */}
<button
onClick={() => startEdit(note)}
class="text-gray-400 hover:text-blue-400 p-0.5"
title="Edit"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</button>
{/* Delete button with confirmation */}
<Show when={deleteConfirmId() === note.id} fallback={
<button
onClick={() => setDeleteConfirmId(note.id)}
class="text-gray-400 hover:text-red-400 p-0.5"
title="Delete"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
}>
<div class="flex items-center gap-1 bg-red-900/50 rounded px-1">
<span class="text-red-300 text-[10px]">Delete?</span>
<button
onClick={() => deleteNote(note.id)}
class="text-red-400 hover:text-red-300 p-0.5 font-bold"
title="Confirm delete"
>
</button>
<button
onClick={() => setDeleteConfirmId(null)}
class="text-gray-400 hover:text-gray-300 p-0.5"
title="Cancel"
>
</button>
</div>
</Show>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
{/* Template picker */}
<Show when={showTemplates()}>
<div class="bg-gray-800/80 rounded p-2 border border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-300">Quick Templates</span>
<button onClick={() => setShowTemplates(false)} class="text-gray-400 hover:text-gray-200 text-xs"></button>
</div>
<div class="grid grid-cols-2 gap-1">
<For each={TEMPLATES}>
{(template) => (
<button
onClick={() => useTemplate(template)}
class={`text-left px-2 py-1 rounded text-[10px] border ${CATEGORY_COLORS[template.category]} hover:opacity-80 transition-opacity`}
>
{CATEGORY_ICONS[template.category]} {template.title}
</button>
)}
</For>
</div>
</div>
</Show>
{/* Actions menu (Export/Import/Clear) */}
<Show when={showActions()}>
<div class="bg-gray-800/80 rounded p-2 border border-gray-700 space-y-1">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-300">Actions</span>
<button onClick={() => { setShowActions(false); setClearConfirm(false); }} class="text-gray-400 hover:text-gray-200 text-xs"></button>
</div>
<button
onClick={exportNotes}
disabled={!hasNotes()}
class="w-full text-left px-2 py-1.5 rounded text-xs bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
Export Notes (JSON)
</button>
<button
onClick={() => fileInputRef?.click()}
disabled={isImporting()}
class="w-full text-left px-2 py-1.5 rounded text-xs bg-green-600/20 hover:bg-green-600/30 text-green-300 disabled:opacity-50 flex items-center gap-2"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
{isImporting() ? 'Importing...' : 'Import Notes (Merge)'}
</button>
<Show when={clearConfirm()} fallback={
<button
onClick={() => setClearConfirm(true)}
disabled={!hasNotes()}
class="w-full text-left px-2 py-1.5 rounded text-xs bg-red-600/20 hover:bg-red-600/30 text-red-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
Clear All Notes
</button>
}>
<div class="bg-red-900/50 rounded p-2 flex items-center justify-between">
<span class="text-red-300 text-[10px]">Delete all {notes().length} notes?</span>
<div class="flex gap-1">
<button onClick={clearAllNotes} class="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-white rounded text-[10px]">Yes, clear all</button>
<button onClick={() => setClearConfirm(false)} class="px-2 py-0.5 bg-gray-600 hover:bg-gray-500 text-white rounded text-[10px]">Cancel</button>
</div>
</div>
</Show>
</div>
</Show>
{/* Add/Edit form */}
<Show when={showAddForm()}>
<div class="bg-gray-800/80 rounded p-2 space-y-2 border border-gray-700">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-300">
{editingNote() ? 'Edit Note' : 'Add Note'}
</span>
<Show when={editingNote()}>
<span class="text-[10px] text-gray-500">ID: {editingNote()?.id}</span>
</Show>
</div>
<div class="flex gap-2">
<select
value={category()}
onChange={(e) => setCategory(e.target.value)}
class="bg-gray-700 text-xs text-gray-200 rounded px-2 py-1.5 border border-gray-600"
>
<For each={CATEGORY_OPTIONS}>
{(cat) => <option value={cat}>{CATEGORY_ICONS[cat]} {CATEGORY_LABELS[cat]}</option>}
</For>
</select>
<input
type="text"
placeholder="Title (e.g., 'Admin Password')"
value={title()}
onInput={(e) => setTitle(e.target.value)}
class="flex-1 bg-gray-700 text-xs text-gray-200 rounded px-2 py-1.5 border border-gray-600 placeholder-gray-500"
/>
</div>
<textarea
placeholder={category() === 'credential'
? "Enter credential value (stored encrypted)..."
: "Content..."}
value={content()}
onInput={(e) => setContent(e.target.value)}
class="w-full bg-gray-700 text-xs text-gray-200 rounded px-2 py-1.5 border border-gray-600 resize-none placeholder-gray-500 font-mono"
rows={2}
/>
<Show when={category() === 'credential'}>
<p class="text-[10px] text-amber-400 flex items-center gap-1">
<span>🔐</span> Credentials are encrypted at rest and masked in the UI
</p>
</Show>
<div class="flex gap-2 justify-end">
<button
onClick={cancelEdit}
class="text-xs px-2 py-1 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
<button
onClick={saveNote}
disabled={!title().trim() || !content().trim()}
class="text-xs px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingNote() ? 'Update' : 'Save'}
</button>
</div>
</div>
</Show>
{/* Action buttons row */}
<Show when={!showAddForm() && !showTemplates() && !showActions()}>
<div class="flex items-center gap-2 pt-1">
<button
onClick={() => setShowAddForm(true)}
class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
<span>+</span> Add note
</button>
<button
onClick={() => setShowTemplates(true)}
class="text-xs text-purple-400 hover:text-purple-300 flex items-center gap-1"
>
<span>📝</span> Templates
</button>
<button
onClick={() => setShowActions(true)}
class="text-xs text-gray-400 hover:text-gray-300 flex items-center gap-1"
>
<span></span> Actions
</button>
</div>
</Show>
</div>
</Show>
</div>
);
};