feat: enhance notes list UI with improved visual hierarchy

- Added new folder creation endpoint with support for path or parent/name parameters
- Updated notes list styling with consistent row cards and active state indicators
- Improved theme-aware color variables for better light/dark mode contrast
- Added visual depth with subtle gradient overlays and active item highlighting
- Implemented consistent styling between virtual and standard note list views
- Enhanced new note button styling for better visibility
This commit is contained in:
Bruno Charest 2025-10-24 13:08:13 -04:00
parent 08a2d05dad
commit 83603e2d97
19 changed files with 1230 additions and 104 deletions

View File

@ -27,6 +27,8 @@ export function setupRenameFolderEndpoint(app, vaultDir, broadcastVaultEvent, me
if (!oldPath || typeof oldPath !== 'string') { if (!oldPath || typeof oldPath !== 'string') {
return res.status(400).json({ error: 'Missing or invalid oldPath' }); return res.status(400).json({ error: 'Missing or invalid oldPath' });
} }
if (!newName || typeof newName !== 'string') { if (!newName || typeof newName !== 'string') {
return res.status(400).json({ error: 'Missing or invalid newName' }); return res.status(400).json({ error: 'Missing or invalid newName' });
} }
@ -202,6 +204,59 @@ export function setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, me
}); });
} }
// ============================================================================
// ENDPOINT 7: /api/folders (POST) - Create a folder (supports { path } or { parentPath, newFolderName })
// ============================================================================
export function setupCreateFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) {
app.post('/api/folders', express.json(), async (req, res) => {
try {
const body = req.body || {};
let rel = '';
if (typeof body.path === 'string' && body.path.trim()) {
rel = body.path.trim();
} else if (typeof body.parentPath === 'string' && typeof body.newFolderName === 'string') {
const parent = body.parentPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
const name = body.newFolderName.trim();
if (!name) {
return res.status(400).json({ error: 'New folder name cannot be empty' });
}
rel = parent ? `${parent}/${name}` : name;
} else {
return res.status(400).json({ error: 'Missing path or (parentPath, newFolderName)' });
}
const sanitizedRel = String(rel).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!sanitizedRel) {
return res.status(400).json({ error: 'Invalid folder path' });
}
const abs = path.join(vaultDir, sanitizedRel);
const vaultAbs = path.resolve(vaultDir);
const absResolved = path.resolve(abs);
if (!absResolved.startsWith(vaultAbs)) {
return res.status(400).json({ error: 'Path escapes vault root' });
}
try {
await fs.promises.mkdir(absResolved, { recursive: true });
} catch (mkErr) {
console.error('[POST /api/folders] mkdir failed:', mkErr);
return res.status(500).json({ error: 'Failed to create folder' });
}
if (metadataCache) metadataCache.clear();
if (broadcastVaultEvent) {
broadcastVaultEvent({ event: 'folder-create', path: sanitizedRel, timestamp: Date.now() });
}
return res.json({ success: true, path: sanitizedRel });
} catch (error) {
console.error('[POST /api/folders] Unexpected error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
});
}
// ============================================================================ // ============================================================================
// ENDPOINT 1: /api/vault/metadata - with cache read-through and monitoring // ENDPOINT 1: /api/vault/metadata - with cache read-through and monitoring
// ============================================================================ // ============================================================================

View File

@ -32,7 +32,8 @@ import {
setupDeferredIndexing, setupDeferredIndexing,
setupCreateNoteEndpoint, setupCreateNoteEndpoint,
setupRenameFolderEndpoint, setupRenameFolderEndpoint,
setupDeleteFolderEndpoint setupDeleteFolderEndpoint,
setupCreateFolderEndpoint
} from './index-phase3-patch.mjs'; } from './index-phase3-patch.mjs';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -1532,6 +1533,9 @@ setupRenameFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
// Setup delete folder endpoint (must be before catch-all) // Setup delete folder endpoint (must be before catch-all)
setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
// Setup create folder endpoint (must be before catch-all)
setupCreateFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
app.get('/', sendIndex); app.get('/', sendIndex);
app.use((req, res) => { app.use((req, res) => {
if (req.path.startsWith('/api/')) { if (req.path.startsWith('/api/')) {

View File

@ -51,7 +51,7 @@ import { NoteCreationService } from '../../services/note-creation.service';
class="flex-1 rounded border border-border dark:border-border bg-card dark:bg-main px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" /> class="flex-1 rounded border border-border dark:border-border bg-card dark:bg-main px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
<button type="button" <button type="button"
(click)="openNewNoteDialog()" (click)="openNewNoteDialog()"
class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors" class="new-note-btn inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors"
title="Créer une nouvelle note"> title="Créer une nouvelle note">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="hidden sm:inline">Nouvelle note</span> <span class="hidden sm:inline">Nouvelle note</span>
@ -127,27 +127,28 @@ import { NoteCreationService } from '../../services/note-creation.service';
</div> </div>
</div> </div>
<ul class="divide-y divide-gray-100 dark:divide-gray-800"> <ul class="notes-list">
<li *ngFor="let n of filtered()" <li *ngFor="let n of filtered()"
class="note-row cursor-pointer"
[class.active]="selectedId() === n.id"
[ngClass]="getListItemClasses()" [ngClass]="getListItemClasses()"
class="hover:bg-surface1 dark:hover:bg-card cursor-pointer transition-colors"
(click)="openNote.emit(n.id)"> (click)="openNote.emit(n.id)">
<!-- Compact View --> <!-- Compact View -->
<div *ngIf="state.viewMode() === 'compact'" class="px-3 py-1.5"> <div *ngIf="state.viewMode() === 'compact'" class="note-inner">
<div class="text-xs font-semibold truncate">{{ n.title }}</div> <div class="title text-xs truncate">{{ n.title }}</div>
</div> </div>
<!-- Comfortable View (default) --> <!-- Comfortable View (default) -->
<div *ngIf="state.viewMode() === 'comfortable'" class="p-3"> <div *ngIf="state.viewMode() === 'comfortable'" class="note-inner">
<div class="text-sm font-semibold truncate">{{ n.title }}</div> <div class="title text-sm truncate">{{ n.title }}</div>
<div class="text-xs text-muted truncate">{{ n.filePath }}</div> <div class="meta text-xs truncate">{{ n.filePath }}</div>
</div> </div>
<!-- Detailed View --> <!-- Detailed View -->
<div *ngIf="state.viewMode() === 'detailed'" class="p-3 space-y-1.5"> <div *ngIf="state.viewMode() === 'detailed'" class="note-inner space-y-1.5">
<div class="text-sm font-semibold truncate">{{ n.title }}</div> <div class="title text-sm truncate">{{ n.title }}</div>
<div class="text-xs text-muted truncate">{{ n.filePath }}</div> <div class="meta text-xs truncate">{{ n.filePath }}</div>
<div class="text-xs text-muted/70"> <div class="excerpt text-xs">
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span> <span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span> <span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
</div> </div>
@ -162,13 +163,65 @@ import { NoteCreationService } from '../../services/note-creation.service';
display: block; display: block;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
/* Darken the list background a bit to separate from sidebar and note view */
background: var(--list-panel-bg);
position: relative;
z-index: 0; /* allow external overlays (e.g., sidebar context menu) to appear above */
}
/* Subtle vertical depth overlay */
:host::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to bottom,
color-mix(in oklab, var(--card) 100%, transparent 0%) 0%,
color-mix(in oklab, var(--card) 96%, black 0%) 35%,
color-mix(in oklab, var(--card) 92%, black 0%) 100%
);
opacity: 0.6;
}
/* Theming variables per color scheme */
:host {
--row-radius: 8px;
--row-pad-v: 12px;
--row-pad-h: 16px;
--row-gap: 2px;
--active-line: var(--primary, #3b82f6);
--meta-color: var(--text-muted);
--row-bg: color-mix(in oklab, var(--card) 97%, black 0%);
--row-bg-hover: color-mix(in oklab, var(--card) 100%, white 6%);
--row-shadow-active: 0 2px 10px color-mix(in oklab, var(--active-line) 18%, transparent 82%);
/* Slightly darker base for the panel for better column separation */
--list-panel-bg: color-mix(in oklab, var(--card) 92%, black 8%);
}
:host-context(html.dark) {
/* In dark themes, lighten rows a bit on hover */
--row-bg: color-mix(in oklab, var(--card) 94%, white 0%);
--row-bg-hover: color-mix(in oklab, var(--card) 90%, white 10%);
/* List panel still darker than note view for separation */
--list-panel-bg: color-mix(in oklab, var(--card) 86%, black 14%);
}
:host-context(html:not(.dark)) {
/* In light themes, darken rows subtly */
--row-bg: color-mix(in oklab, var(--card) 94%, black 6%);
--row-bg-hover: color-mix(in oklab, var(--card) 90%, black 10%);
/* Subtle tint for separation in light mode */
--list-panel-bg: color-mix(in oklab, var(--card) 96%, black 4%);
} }
.list-scroll { .list-scroll {
overscroll-behavior: contain; overscroll-behavior: contain;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scroll-behavior: smooth; scroll-behavior: smooth;
scrollbar-gutter: stable both-edges; /* Reserve scrollbar space only on the end edge to avoid large left/right gaps */
scrollbar-gutter: stable;
padding-inline: 0;
max-height: 100%; max-height: 100%;
contain: content; contain: content;
} }
@ -186,6 +239,98 @@ import { NoteCreationService } from '../../services/note-creation.service';
width: 100%; width: 100%;
gap: 0.5rem; gap: 0.5rem;
} }
/* Notes list and rows */
.notes-list {
margin: 0;
padding: 0; /* no side padding so cards can be edge-to-edge */
list-style: none;
}
.note-row {
position: relative;
margin: var(--row-gap) 0; /* flush to left/right edges */
border-radius: var(--row-radius);
background: var(--row-bg);
transition: all 0.2s ease-in-out;
}
.note-row:hover {
background: var(--row-bg-hover);
}
.note-row.active::before,
.note-row.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--active-line);
border-radius: 2px;
}
.note-row.active::before { top: 0; }
.note-row.active::after { bottom: 0; }
.note-row.active {
box-shadow: var(--row-shadow-active);
}
.note-inner {
padding: var(--row-pad-v) var(--row-pad-h);
}
.title {
color: var(--text-main, #111);
font-weight: 500;
}
:host-context(html.dark) .title {
color: var(--text-main, #e5e7eb);
}
.note-row.active .title {
font-weight: 600;
}
.meta {
color: var(--meta-color, #6b7280);
opacity: 0.8;
}
:host-context(html.dark) .meta {
color: var(--meta-color, #94a3b8);
opacity: 0.9;
}
.excerpt {
color: var(--meta-color);
opacity: 0.75;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
/* Ensure the gradient overlay sits beneath any external overlays */
:host::before { z-index: 0; }
/* Improve New Note button contrast across themes */
.new-note-btn {
color: var(--primary-foreground, #fff);
border: 1px solid color-mix(in oklab, var(--primary) 30%, black 70%);
text-shadow: 0 0 0 transparent;
}
:host-context(html:not(.dark)) .new-note-btn {
/* In light themes, ensure minimum contrast */
border-color: color-mix(in oklab, var(--primary) 45%, black 55%);
}
:host-context(html.dark) .new-note-btn {
/* In dark themes, keep readable text color if theme var is too dim */
color: var(--primary-foreground, #fafafa);
border-color: color-mix(in oklab, var(--primary) 35%, white 65%);
}
`] `]
}) })
export class NotesListComponent { export class NotesListComponent {
@ -194,6 +339,7 @@ export class NotesListComponent {
query = input<string>(''); query = input<string>('');
tagFilter = input<string | null>(null); tagFilter = input<string | null>(null);
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null); quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
selectedId = input<string | null>(null);
@Output() openNote = new EventEmitter<string>(); @Output() openNote = new EventEmitter<string>();
@Output() queryChange = new EventEmitter<string>(); @Output() queryChange = new EventEmitter<string>();

View File

@ -48,14 +48,16 @@ import { takeUntil } from 'rxjs/operators';
(scrolledIndexChange)="onScroll($event)" (scrolledIndexChange)="onScroll($event)"
appScrollableOverlay> appScrollableOverlay>
<ul class="divide-y divide-gray-100 dark:divide-gray-800"> <ul class="notes-list">
<!-- Virtual items --> <!-- Virtual items -->
<li *cdkVirtualFor="let note of paginatedNotes(); trackBy: trackByFn" <li *cdkVirtualFor="let note of paginatedNotes(); trackBy: trackByFn"
class="p-3 hover:bg-surface1 dark:hover:bg-card cursor-pointer transition-colors min-h-[60px] flex flex-col justify-center" class="note-row cursor-pointer"
[class.selected]="note.id === selectedNoteId()" [class.active]="(selectedId() ?? selectedNoteId()) === note.id"
(click)="selectNote(note)"> (click)="selectNote(note)">
<div class="text-sm font-semibold truncate">{{ note.title }}</div> <div class="note-inner">
<div class="text-xs text-muted truncate">{{ note.filePath }}</div> <div class="title text-sm truncate">{{ note.title }}</div>
<div class="meta text-xs truncate">{{ note.filePath }}</div>
</div>
</li> </li>
<!-- Loading indicator --> <!-- Loading indicator -->
@ -85,37 +87,97 @@ import { takeUntil } from 'rxjs/operators';
display: block; display: block;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
background: var(--list-panel-bg);
position: relative;
z-index: 0;
} }
.notes-list-container { /* Subtle vertical depth overlay */
min-height: 400px; :host::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to bottom,
color-mix(in oklab, var(--card) 100%, transparent 0%) 0%,
color-mix(in oklab, var(--card) 96%, black 0%) 35%,
color-mix(in oklab, var(--card) 92%, black 0%) 100%
);
opacity: 0.6;
} }
.note-item { /* Theming variables per color scheme */
min-height: 60px; :host {
display: flex; --row-radius: 8px;
flex-direction: column; --row-pad-v: 12px;
justify-content: center; --row-pad-h: 16px;
--row-gap: 2px;
--active-line: var(--primary, #3b82f6);
--meta-color: var(--text-muted);
--row-bg: color-mix(in oklab, var(--card) 97%, black 0%);
--row-bg-hover: color-mix(in oklab, var(--card) 100%, white 6%);
--row-shadow-active: 0 2px 10px color-mix(in oklab, var(--active-line) 18%, transparent 82%);
--list-panel-bg: color-mix(in oklab, var(--card) 92%, black 8%);
} }
.note-item.selected { :host-context(html.dark) {
background-color: var(--surface1); --row-bg: color-mix(in oklab, var(--card) 94%, white 0%);
border-left: 3px solid var(--primary); --row-bg-hover: color-mix(in oklab, var(--card) 90%, white 10%);
--list-panel-bg: color-mix(in oklab, var(--card) 86%, black 14%);
}
:host-context(html:not(.dark)) {
--row-bg: color-mix(in oklab, var(--card) 94%, black 6%);
--row-bg-hover: color-mix(in oklab, var(--card) 90%, black 10%);
--list-panel-bg: color-mix(in oklab, var(--card) 96%, black 4%);
} }
cdk-virtual-scroll-viewport { cdk-virtual-scroll-viewport {
height: 100%; height: 100%;
} }
ul { /* Notes list and rows (match non-virtual list) */
.notes-list {
margin: 0; margin: 0;
padding: 0; padding: 2px 0;
list-style: none; list-style: none;
} }
li { .note-row {
margin: 0; position: relative;
margin: var(--row-gap) 1px;
border-radius: var(--row-radius);
background: var(--row-bg);
transition: all 0.2s ease-in-out;
min-height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
} }
.note-row:hover { background: var(--row-bg-hover); }
.note-row.active::before,
.note-row.active::after {
content: "";
position: absolute;
left: 0; right: 0; height: 2px;
background: var(--active-line);
border-radius: 2px;
}
.note-row.active::before { top: 0; }
.note-row.active::after { bottom: 0; }
.note-row.active { box-shadow: var(--row-shadow-active); }
.note-inner { padding: var(--row-pad-v) var(--row-pad-h); }
.title { color: var(--text-main, #111); font-weight: 500; }
:host-context(html.dark) .title { color: var(--text-main, #e5e7eb); }
.note-row.active .title { font-weight: 600; }
.meta { color: var(--meta-color, #6b7280); opacity: 0.8; }
:host-context(html.dark) .meta { color: var(--meta-color, #94a3b8); opacity: 0.9; }
.excerpt { color: var(--meta-color); opacity: 0.75; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; text-overflow: ellipsis; }
:host::before { z-index: 0; }
`] `]
}) })
export class PaginatedNotesListComponent implements OnInit, OnDestroy { export class PaginatedNotesListComponent implements OnInit, OnDestroy {
@ -130,6 +192,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
query = input<string>(''); query = input<string>('');
tagFilter = input<string | null>(null); tagFilter = input<string | null>(null);
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null); quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
selectedId = input<string | null>(null);
// Outputs // Outputs
@Output() openNote = new EventEmitter<string>(); @Output() openNote = new EventEmitter<string>();

View File

@ -301,6 +301,160 @@
opacity: 0.7; opacity: 0.7;
} }
/* Chips editor */
.chips-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
background: var(--bg-muted);
border: 1px solid var(--border);
}
.chip-text {
font-size: 0.8125rem;
color: var(--text-main);
}
.chip-remove {
appearance: none;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
line-height: 1;
padding: 0 0.2rem;
}
.chip-remove:hover {
color: var(--text-main);
}
.chip-input-wrapper {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.chip-input {
min-width: 18rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--bg-main);
color: var(--text-main);
}
.add-btn {
white-space: nowrap;
}
/* Toggle Switch */
.toggle-switch {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toggle-input {
display: none;
}
.toggle-label {
display: inline-flex;
align-items: center;
cursor: pointer;
position: relative;
width: 2.5rem;
height: 1.25rem;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--surface-1, #e5e7eb);
border-radius: 9999px;
transition: background-color 0.3s ease;
border: 1px solid var(--border);
}
.toggle-slider::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 1rem;
height: 1rem;
background-color: white;
border-radius: 50%;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.toggle-input:checked + .toggle-label .toggle-slider {
background-color: var(--primary, #3b82f6);
border-color: var(--primary, #3b82f6);
}
.toggle-input:checked + .toggle-label .toggle-slider::after {
transform: translateX(1.25rem);
}
:host-context(.dark) .toggle-slider {
background-color: var(--surface-2, #1e293b);
border-color: var(--border, #334155);
}
:host-context(.dark) .toggle-slider::after {
background-color: #e2e8f0;
}
/* Reset Button */
.reset-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-muted);
color: var(--text-main);
cursor: pointer;
transition: all 0.2s ease;
}
.reset-button:hover {
background: var(--surface-1);
border-color: var(--primary);
transform: translateY(-1px);
}
.reset-button:active {
transform: translateY(0);
}
:host-context(.dark) .reset-button {
background: var(--card);
border-color: var(--border);
}
:host-context(.dark) .reset-button:hover {
background: var(--surface-1);
}
/* Responsive */ /* Responsive */
@media (min-width: 640px) { @media (min-width: 640px) {
.parameters-page { .parameters-page {

View File

@ -112,6 +112,79 @@
</div> </div>
</section> </section>
<!-- Folder Filtering Section -->
<section class="parameters-section">
<h2 class="section-title">
<svg class="section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="3"/>
</svg>
Folder Filtering
</h2>
<div class="grid-layout">
<!-- Hidden Folders Filter -->
<div class="setting-group">
<label class="setting-label">Exclude Hidden Folders</label>
<div class="toggle-switch">
<input
type="checkbox"
id="excludeHidden"
[checked]="folderFilterConfig().excludeHiddenFolders"
(change)="toggleHiddenFolders()"
class="toggle-input" />
<label for="excludeHidden" class="toggle-label">
<span class="toggle-slider"></span>
</label>
</div>
<p class="setting-hint">Hide folders starting with a dot (e.g., .obsidian, .trash, .git)</p>
</div>
<!-- Dynamic Excluded Folders -->
<div class="setting-group">
<label class="setting-label">Excluded folders</label>
<div class="chips-container">
<span
class="chip"
*ngFor="let p of folderFilterConfig().customExclusions"
>
<span class="chip-text">{{ p }}</span>
<button type="button" class="chip-remove" (click)="removeExcludedFolder(p)" aria-label="Remove">
×
</button>
</span>
<div class="chip-input-wrapper">
<input
type="text"
[(ngModel)]="newExcludedFolder"
(keydown.enter)="addExcludedFolder()"
placeholder="Add folder (e.g., .obsidian, attachments, old_notes)"
class="chip-input"
/>
<button type="button" class="add-btn btn-colored-xs btn-neutral" (click)="addExcludedFolder()">+ Add folder</button>
</div>
</div>
<p class="setting-hint">Folders listed here will be hidden from the sidebar. Paths are matched recursively.</p>
</div>
<!-- Reset Button -->
<div class="setting-group full-width">
<button
(click)="resetFolderFilters()"
class="reset-button btn-colored-xs btn-neutral">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M3 21v-5h5"/>
</svg>
Reset to Defaults
</button>
<p class="setting-hint">Restore default folder filtering settings</p>
</div>
</div>
</section>
<!-- Vault Stats Section (Placeholder) --> <!-- Vault Stats Section (Placeholder) -->
<section class="parameters-section"> <section class="parameters-section">
<h2 class="section-title"> <h2 class="section-title">

View File

@ -1,21 +1,26 @@
import { Component, inject, signal, effect } from '@angular/core'; import { Component, inject, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ThemeService, ThemeMode, ThemeId, Language } from '../../core/services/theme.service'; import { ThemeService, ThemeMode, ThemeId, Language } from '../../core/services/theme.service';
import { ToastService } from '../../shared/toast/toast.service'; import { ToastService } from '../../shared/toast/toast.service';
import { FolderFilterService, FolderFilterConfig } from '../../services/folder-filter.service';
@Component({ @Component({
standalone: true, standalone: true,
selector: 'app-parameters', selector: 'app-parameters',
imports: [CommonModule], imports: [CommonModule, FormsModule],
templateUrl: './parameters.page.html', templateUrl: './parameters.page.html',
styleUrls: ['./parameters.page.css'] styleUrls: ['./parameters.page.css']
}) })
export class ParametersPage { export class ParametersPage {
private themeService = inject(ThemeService); private themeService = inject(ThemeService);
private toastService = inject(ToastService); private toastService = inject(ToastService);
private folderFilterService = inject(FolderFilterService);
// Reactive prefs // Reactive prefs
prefs = signal(this.themeService.prefsValue); prefs = signal(this.themeService.prefsValue);
folderFilterConfig = signal<FolderFilterConfig>(this.folderFilterService.getConfig());
newExcludedFolder = '';
modes: ThemeMode[] = ['system', 'light', 'dark']; modes: ThemeMode[] = ['system', 'light', 'dark'];
themes: ThemeId[] = ['light', 'dark', 'obsidian', 'nord', 'notion', 'github', 'discord', 'monokai']; themes: ThemeId[] = ['light', 'dark', 'obsidian', 'nord', 'notion', 'github', 'discord', 'monokai'];
@ -77,4 +82,50 @@ export class ParametersPage {
}; };
return previews[themeId]; return previews[themeId];
} }
// Folder Filter Methods
toggleHiddenFolders(): void {
const config = this.folderFilterConfig();
this.folderFilterService.updateConfig({
...config,
excludeHiddenFolders: !config.excludeHiddenFolders
});
this.folderFilterConfig.set(this.folderFilterService.getConfig());
this.showToast('Hidden folders filter updated');
}
toggleAttachments(): void {
const config = this.folderFilterConfig();
this.folderFilterService.updateConfig({
...config,
excludeAttachments: !config.excludeAttachments
});
this.folderFilterConfig.set(this.folderFilterService.getConfig());
this.showToast('Attachments filter updated');
}
// Dynamic excluded folders list management
addExcludedFolder(): void {
const raw = (this.newExcludedFolder || '').trim();
if (!raw) return;
// Normalize path (remove leading/trailing slashes)
const normalized = raw.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalized) return;
this.folderFilterService.addCustomExclusion(normalized);
this.folderFilterConfig.set(this.folderFilterService.getConfig());
this.newExcludedFolder = '';
this.showToast('Folder added to exclusions');
}
removeExcludedFolder(path: string): void {
this.folderFilterService.removeCustomExclusion(path);
this.folderFilterConfig.set(this.folderFilterService.getConfig());
this.showToast('Folder removed from exclusions');
}
resetFolderFilters(): void {
this.folderFilterService.resetToDefaults();
this.folderFilterConfig.set(this.folderFilterService.getConfig());
this.showToast('Folder filters reset to defaults');
}
} }

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; import { Component, EventEmitter, Input, Output, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component'; import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
@ -49,10 +49,13 @@ import { VaultService } from '../../../services/vault.service';
<!-- Quick Links accordion --> <!-- Quick Links accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card" <button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="open.quick = !open.quick"> (click)="open.quick = !open.quick">
<span class="flex items-center gap-2"> <span>Quick Links</span></span> <span class="flex items-center gap-2">
<span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span> <span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span>
<span></span>
<span>Quick Links</span>
</span>
</button> </button>
<div *ngIf="open.quick" class="pt-1"> <div *ngIf="open.quick" class="pt-1">
<app-quick-links (quickLinkSelected)="onQuickLink($event)"></app-quick-links> <app-quick-links (quickLinkSelected)="onQuickLink($event)"></app-quick-links>
@ -61,13 +64,21 @@ import { VaultService } from '../../../services/vault.service';
<!-- Folders accordion --> <!-- Folders accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card" <div class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card">
(click)="toggleFoldersSection()"> <button class="flex items-center gap-2 flex-1 text-left" (click)="toggleFoldersSection()">
<span class="flex items-center gap-2">📁 <span>Folders</span></span>
<span class="text-xs text-muted">{{ open.folders ? '▾' : '▸' }}</span> <span class="text-xs text-muted">{{ open.folders ? '▾' : '▸' }}</span>
<span>📁</span>
<span>Folders</span>
</button> </button>
<button *ngIf="open.folders" (click)="onCreateFolderAtRoot()" title="Create Folder" class="flex items-center gap-1 p-2 rounded hover:bg-surface1 dark:hover:bg-card">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -ml-1" viewBox="0 0 20 20" fill="currentColor">
    <path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 010 2h-3v3a1 1 0 01-2 0v-3H6a1 1 0 010-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
  </svg>
</button>
</div>
<div *ngIf="open.folders" class="px-1 py-1"> <div *ngIf="open.folders" class="px-1 py-1">
<app-file-explorer <app-file-explorer
#foldersExplorer
[nodes]="effectiveFileTree" [nodes]="effectiveFileTree"
[selectedNoteId]="selectedNoteId" [selectedNoteId]="selectedNoteId"
[foldersOnly]="true" [foldersOnly]="true"
@ -80,10 +91,13 @@ import { VaultService } from '../../../services/vault.service';
<!-- Tags accordion --> <!-- Tags accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card" <button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="open.tags = !open.tags"> (click)="open.tags = !open.tags">
<span class="flex items-center gap-2">🏷 <span>Tags</span></span> <span class="flex items-center gap-2">
<span class="text-xs text-muted">{{ open.tags ? '▾' : '▸' }}</span> <span class="text-xs text-muted">{{ open.tags ? '▾' : '▸' }}</span>
<span>🏷</span>
<span>Tags</span>
</span>
</button> </button>
<div *ngIf="open.tags" class="px-2 py-2"> <div *ngIf="open.tags" class="px-2 py-2">
<ul class="space-y-0.5 text-sm"> <ul class="space-y-0.5 text-sm">
@ -100,10 +114,13 @@ import { VaultService } from '../../../services/vault.service';
<!-- Trash accordion --> <!-- Trash accordion -->
<section class="border-b border-border dark:border-gray-800"> <section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card" <button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="toggleTrashSection()"> (click)="toggleTrashSection()">
<span class="flex items-center gap-2">🗑 <span>Trash</span></span> <span class="flex items-center gap-2">
<span class="text-xs text-muted">{{ open.trash ? '▾' : '▸' }}</span> <span class="text-xs text-muted">{{ open.trash ? '▾' : '▸' }}</span>
<span>🗑</span>
<span>Trash</span>
</span>
</button> </button>
<div *ngIf="open.trash" class="px-1 py-2"> <div *ngIf="open.trash" class="px-1 py-2">
<ng-container *ngIf="trashHasContent(); else emptyTrash"> <ng-container *ngIf="trashHasContent(); else emptyTrash">
@ -166,6 +183,7 @@ export class NimbusSidebarComponent {
env = environment; env = environment;
open = { quick: true, folders: false, tags: false, trash: false, tests: false }; open = { quick: true, folders: false, tags: false, trash: false, tests: false };
private vault = inject(VaultService); private vault = inject(VaultService);
@ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent;
onQuickLink(id: string) { this.quickLinkSelected.emit(id); } onQuickLink(id: string) { this.quickLinkSelected.emit(id); }
@ -190,6 +208,16 @@ export class NimbusSidebarComponent {
} }
} }
onCreateFolderAtRoot(): void {
// If not yet rendered, open the section first, then defer action
if (!this.open.folders) {
this.open.folders = true;
setTimeout(() => this.foldersExplorer?.openCreateAtRoot(), 0);
} else {
this.foldersExplorer?.openCreateAtRoot();
}
}
toggleTrashSection(): void { toggleTrashSection(): void {
const next = !this.open.trash; const next = !this.open.trash;
this.open.trash = next; this.open.trash = next;

View File

@ -179,6 +179,7 @@ import { AboutPanelComponent } from '../../features/about/about-panel.component'
[tagFilter]="tagFilter" [tagFilter]="tagFilter"
[quickLinkFilter]="quickLinkFilter" [quickLinkFilter]="quickLinkFilter"
[query]="listQuery" [query]="listQuery"
[selectedId]="selectedNoteId"
(openNote)="onOpenNote($event)" (openNote)="onOpenNote($event)"
(queryChange)="onQueryChange($event)" (queryChange)="onQueryChange($event)"
(clearQuickLinkFilter)="onClearQuickLinkFilter()" (clearQuickLinkFilter)="onClearQuickLinkFilter()"
@ -257,7 +258,7 @@ import { AboutPanelComponent } from '../../features/about/about-panel.component'
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" [quickLinkFilter]="quickLinkFilter" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer> <app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" [quickLinkFilter]="quickLinkFilter" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer>
</div> </div>
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay> <div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay>
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list> <app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list>
</div> </div>
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto px-3 py-4" appScrollableOverlay> <div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground> <app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
@ -288,7 +289,7 @@ import { AboutPanelComponent } from '../../features/about/about-panel.component'
@if (mobileNav.activeTab() === 'list') { @if (mobileNav.activeTab() === 'list') {
<div class="h-full flex flex-col overflow-hidden animate-fadeIn"> <div class="h-full flex flex-col overflow-hidden animate-fadeIn">
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" (queryChange)="onQueryChange($event)" (openNote)="onNoteSelectedMobile($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list> <app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onNoteSelectedMobile($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list>
</div> </div>
} }

View File

@ -0,0 +1,145 @@
import { Injectable, signal, computed } from '@angular/core';
/**
* Folder Filter Service
* Manages dynamic folder filtering to exclude certain folders from display
* in the Folders section of the application.
*/
export interface FolderFilterConfig {
excludeHiddenFolders: boolean; // Exclude folders starting with . (e.g., .obsidian, .trash)
excludeAttachments: boolean; // Exclude 'attachments' folder
customExclusions: string[]; // Custom folder paths to exclude
}
@Injectable({
providedIn: 'root'
})
export class FolderFilterService {
// Default filter configuration
private readonly DEFAULT_CONFIG: FolderFilterConfig = {
excludeHiddenFolders: true,
excludeAttachments: true,
customExclusions: []
};
// Reactive state
private config = signal<FolderFilterConfig>(this.loadConfig());
// Computed derived state
readonly isHiddenFoldersExcluded = computed(() => this.config().excludeHiddenFolders);
readonly isAttachmentsExcluded = computed(() => this.config().excludeAttachments);
readonly customExclusions = computed(() => this.config().customExclusions);
constructor() {
// Initialize from localStorage
this.loadConfigFromStorage();
}
/**
* Load configuration from localStorage or use defaults
*/
private loadConfig(): FolderFilterConfig {
try {
const stored = localStorage.getItem('folderFilterConfig');
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load folder filter config:', e);
}
return { ...this.DEFAULT_CONFIG };
}
/**
* Load configuration from storage on initialization
*/
private loadConfigFromStorage(): void {
const stored = this.loadConfig();
this.config.set(stored);
}
/**
* Update the filter configuration
*/
updateConfig(newConfig: Partial<FolderFilterConfig>): void {
const updated = { ...this.config(), ...newConfig };
this.config.set(updated);
this.persistConfig(updated);
}
/**
* Persist configuration to localStorage
*/
private persistConfig(config: FolderFilterConfig): void {
try {
localStorage.setItem('folderFilterConfig', JSON.stringify(config));
} catch (e) {
console.warn('Failed to persist folder filter config:', e);
}
}
/**
* Check if a folder should be filtered out (hidden)
*/
shouldFilterFolder(folderPath: string): boolean {
if (!folderPath) return false;
const normalizedPath = folderPath.replace(/\\/g, '/').toLowerCase();
const folderName = normalizedPath.split('/').pop() || '';
// Check hidden folders (starting with .)
if (this.config().excludeHiddenFolders && folderName.startsWith('.')) {
return true;
}
// Check attachments folder
if (this.config().excludeAttachments && folderName === 'attachments') {
return true;
}
// Check custom exclusions
for (const exclusion of this.config().customExclusions) {
const normalizedExclusion = exclusion.replace(/\\/g, '/').toLowerCase();
if (normalizedPath === normalizedExclusion || normalizedPath.startsWith(normalizedExclusion + '/')) {
return true;
}
}
return false;
}
/**
* Add a custom folder exclusion
*/
addCustomExclusion(folderPath: string): void {
const exclusions = [...this.config().customExclusions];
if (!exclusions.includes(folderPath)) {
exclusions.push(folderPath);
this.updateConfig({ customExclusions: exclusions });
}
}
/**
* Remove a custom folder exclusion
*/
removeCustomExclusion(folderPath: string): void {
const exclusions = this.config().customExclusions.filter(e => e !== folderPath);
this.updateConfig({ customExclusions: exclusions });
}
/**
* Reset to default configuration
*/
resetToDefaults(): void {
this.config.set({ ...this.DEFAULT_CONFIG });
this.persistConfig(this.DEFAULT_CONFIG);
}
/**
* Get current configuration
*/
getConfig(): FolderFilterConfig {
return { ...this.config() };
}
}

View File

@ -0,0 +1,66 @@
import { Injectable, signal } from '@angular/core';
/**
* Notes List Focus Service
* Manages the focus/selection state of the Notes-list component
* Allows resetting focus when folders are deleted or other operations occur
*/
@Injectable({
providedIn: 'root'
})
export class NotesListFocusService {
// Track the currently selected folder path
private selectedFolderPath = signal<string | null>(null);
// Signal to trigger focus reset
private resetFocusTrigger = signal<number>(0);
constructor() {}
/**
* Set the currently selected folder path
*/
setSelectedFolder(folderPath: string | null): void {
this.selectedFolderPath.set(folderPath);
}
/**
* Get the currently selected folder path
*/
getSelectedFolder(): string | null {
return this.selectedFolderPath();
}
/**
* Reset the focus/selection
* This should be called when a folder is deleted or when we need to clear the selection
*/
resetFocus(): void {
this.selectedFolderPath.set(null);
// Increment trigger to notify subscribers
this.resetFocusTrigger.set(this.resetFocusTrigger() + 1);
}
/**
* Get the reset focus trigger signal
* Components can subscribe to this to know when to reset their focus
*/
getResetFocusTrigger() {
return this.resetFocusTrigger;
}
/**
* Check if a specific folder is currently selected
*/
isFolderSelected(folderPath: string): boolean {
return this.selectedFolderPath() === folderPath;
}
/**
* Clear all focus state
*/
clear(): void {
this.selectedFolderPath.set(null);
}
}

View File

@ -9,6 +9,7 @@ import { CommonModule } from '@angular/common';
<span <span
class="inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold text-white" class="inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold text-white"
[ngClass]="bgClass" [ngClass]="bgClass"
[ngStyle]="mergedStyle"
aria-label="count" aria-label="count"
> >
{{ count }} {{ count }}
@ -17,7 +18,7 @@ import { CommonModule } from '@angular/common';
}) })
export class BadgeCountComponent { export class BadgeCountComponent {
@Input() count = 0; @Input() count = 0;
@Input() color: 'slate'|'rose'|'amber'|'indigo'|'emerald'|'stone'|'zinc'|'green'|'purple' = 'slate'; @Input() color: string = 'slate';
get bgClass() { get bgClass() {
return { return {
@ -32,4 +33,30 @@ export class BadgeCountComponent {
'bg-purple-500': this.color === 'purple', 'bg-purple-500': this.color === 'purple',
}; };
} }
get bgStyle() {
const isPredefined = ['slate','rose','amber','indigo','emerald','stone','zinc','green','purple'].includes(this.color);
if (isPredefined) return {};
// Assume custom CSS color string (e.g., #RRGGBB, rgb(), hsl())
return { backgroundColor: this.color } as Record<string, string>;
}
get fgStyle() {
const isPredefined = ['slate','rose','amber','indigo','emerald','stone','zinc','green','purple'].includes(this.color);
if (isPredefined) return {};
const hexMatch = /^#([0-9a-fA-F]{6})$/.exec(this.color);
if (!hexMatch) return {};
const hex = hexMatch[1];
const r = parseInt(hex.slice(0,2), 16) / 255;
const g = parseInt(hex.slice(2,4), 16) / 255;
const b = parseInt(hex.slice(4,6), 16) / 255;
// Relative luminance approximation
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const color = lum > 0.6 ? '#0f172a' : '#ffffff';
return { color } as Record<string, string>;
}
get mergedStyle() {
return { ...this.bgStyle, ...this.fgStyle } as Record<string, string>;
}
} }

View File

@ -29,7 +29,7 @@ type CtxAction =
imports: [CommonModule], imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
styles: [` styles: [`
:host { position: fixed; inset: 0; pointer-events: none; } :host { position: fixed; inset: 0; pointer-events: none; z-index: 9999; }
.ctx { .ctx {
pointer-events: auto; pointer-events: auto;
min-width: 14rem; min-width: 14rem;
@ -44,6 +44,7 @@ type CtxAction =
background: var(--card, #ffffff); background: var(--card, #ffffff);
border: 1px solid var(--border, #e5e7eb); border: 1px solid var(--border, #e5e7eb);
color: var(--fg, #111827); color: var(--fg, #111827);
z-index: 10000;
} }
.item { .item {
display: block; width: 100%; display: block; width: 100%;
@ -94,7 +95,7 @@ type CtxAction =
template: ` template: `
<ng-container *ngIf="visible"> <ng-container *ngIf="visible">
<!-- Backdrop pour capter les clics extérieurs --> <!-- Backdrop pour capter les clics extérieurs -->
<div class="fixed inset-0" (click)="close()" aria-hidden="true"></div> <div class="fixed inset-0" (click)="close()" aria-hidden="true" style="z-index: 9998;"></div>
<!-- Menu --> <!-- Menu -->
<div <div
@ -176,7 +177,10 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['visible'] && this.visible) { if (changes['visible'] && this.visible) {
// Laisse le DOM peindre le menu, puis calcule les bornes // Immediately set to click position to avoid flashing at 0,0
this.left = this.x;
this.top = this.y;
// Then reposition for anti-overflow
queueMicrotask(() => this.reposition()); queueMicrotask(() => this.reposition());
} }
if ((changes['x'] || changes['y']) && this.visible) { if ((changes['x'] || changes['y']) && this.visible) {

View File

@ -1,9 +1,11 @@
import { Component, ChangeDetectionStrategy, input, output, inject, signal } from '@angular/core'; import { Component, ChangeDetectionStrategy, input, output, inject, signal, effect, computed } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { VaultNode, VaultFile, VaultFolder } from '../../types'; import { VaultNode, VaultFile, VaultFolder } from '../../types';
import { VaultService } from '../../services/vault.service'; import { VaultService } from '../../services/vault.service';
import { NoteCreationService } from '../../app/services/note-creation.service'; import { NoteCreationService } from '../../app/services/note-creation.service';
import { NotesListFocusService } from '../../app/services/notes-list-focus.service';
import { FolderFilterService } from '../../app/services/folder-filter.service';
import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component'; import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
import { ContextMenuComponent } from '../context-menu/context-menu.component'; import { ContextMenuComponent } from '../context-menu/context-menu.component';
@ -11,7 +13,7 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
selector: 'app-file-explorer', selector: 'app-file-explorer',
template: ` template: `
<ul class="space-y-0.5"> <ul class="space-y-0.5">
@for(node of nodes(); track node.path) { @for(node of filteredNodes(); track node.path) {
<li> <li>
@if (isFolder(node) && node.name !== '.trash') { @if (isFolder(node) && node.name !== '.trash') {
@let folder = node; @let folder = node;
@ -20,13 +22,14 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
(click)="onFolderClick(folder)" (click)="onFolderClick(folder)"
(contextmenu)="openContextMenu($event, folder)" (contextmenu)="openContextMenu($event, folder)"
class="flex items-center cursor-pointer px-3 py-2 rounded-lg my-0.5 text-sm hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition" class="flex items-center cursor-pointer px-3 py-2 rounded-lg my-0.5 text-sm hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition"
[ngStyle]="getFolderGradientStyle(folder.path)"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform flex-shrink-0" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform flex-shrink-0" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
<span class="font-semibold truncate">{{ folder.name }}</span> <span class="font-semibold truncate">{{ folder.name }}</span>
<app-badge-count class="ml-auto" [count]="folderCount(folder.path)" color="slate"></app-badge-count> <app-badge-count class="ml-auto" [count]="folderCount(folder.path)" [color]="getFolderBadgeColor(folder.path)"></app-badge-count>
</div> </div>
@if (folder.isOpen) { @if (folder.isOpen) {
<div class="pl-5"> <div class="pl-5">
@ -107,6 +110,43 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
</div> </div>
</div> </div>
</div> </div>
<!-- Create Subfolder Modal -->
<div *ngIf="createModalVisible()" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg p-6 w-full max-w-md mx-4">
<h3 class="text-lg font-semibold text-[var(--fg)] mb-4">Create a new folder</h3>
<div class="mb-1">
<label class="block text-sm font-medium text-[var(--text-main)] mb-2">
Folder name
</label>
<input
type="text"
[(ngModel)]="createInputValue"
class="w-full px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] text-[var(--fg)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
placeholder="Folder name"
(keydown.enter)="confirmCreate()"
id="createInput" #createInput
/>
</div>
<div *ngIf="createError()" class="text-sm text-red-500 mt-1">{{ createError() }}</div>
<div class="flex justify-end gap-3 mt-4">
<button
(click)="cancelCreate()"
class="px-4 py-2 text-[var(--text-main)] hover:bg-[var(--surface-1)] rounded-md transition-colors"
>
Cancel
</button>
<button
(click)="confirmCreate()"
class="px-4 py-2 bg-[var(--primary)] text-white rounded-md hover:bg-[var(--brand-700)] transition-colors"
>
Create
</button>
</div>
</div>
</div>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, BadgeCountComponent, ContextMenuComponent], imports: [CommonModule, FormsModule, BadgeCountComponent, ContextMenuComponent],
@ -131,11 +171,51 @@ export class FileExplorerComponent {
renameInputValue = ''; renameInputValue = '';
renameTarget: VaultFolder | null = null; renameTarget: VaultFolder | null = null;
// Create subfolder modal state
createModalVisible = signal(false);
createInputValue = '';
createParentPath: string | null = null;
createError = signal('');
// Folder colors storage // Folder colors storage
private folderColors = new Map<string, string>(); private folderColors = new Map<string, string>();
private vaultService = inject(VaultService); private vaultService = inject(VaultService);
private noteCreation = inject(NoteCreationService); private noteCreation = inject(NoteCreationService);
private notesListFocus = inject(NotesListFocusService);
private folderFilter = inject(FolderFilterService);
// Computed filtered nodes based on folder filter settings
filteredNodes = computed(() => {
const allNodes = this.nodes();
return this.filterNodes(allNodes);
});
/**
* Recursively filter nodes based on folder filter settings
*/
private filterNodes(nodes: VaultNode[]): VaultNode[] {
return nodes.filter(node => {
if (node.type === 'folder') {
// Check if folder should be filtered out
if (this.folderFilter.shouldFilterFolder(node.path)) {
return false;
}
// Recursively filter children
const folder = node as VaultFolder;
if (folder.children && folder.children.length > 0) {
folder.children = this.filterNodes(folder.children);
}
}
return true;
}).map(node => {
if (node.type === 'folder') {
const folder = node as VaultFolder;
return { ...folder };
}
return node;
});
}
folderCount(path: string): number { folderCount(path: string): number {
const quickLink = this.quickLinkFilter(); const quickLink = this.quickLinkFilter();
@ -169,6 +249,42 @@ export class FileExplorerComponent {
} }
} }
/**
* Get the color for a folder badge, synchronized with folder color
*/
getFolderBadgeColor(folderPath: string): string {
const folderColor = this.getFolderColor(folderPath);
// If folder has a custom color, use it for the badge
if (folderColor && folderColor !== 'currentColor') {
return folderColor;
}
// Default badge color
return 'slate';
}
/**
* Compute a subtle right-to-center horizontal gradient for folder rows
* using the custom folder color. Returns an inline style object or null.
*/
getFolderGradientStyle(folderPath: string): Record<string, string> | null {
const color = this.getFolderColor(folderPath);
if (!color || color === 'currentColor') return null;
// Use color with transparency; fall back directly if color is not hex
// For hex like #RRGGBB convert to rgba with ~12% -> 0% fade
const hexMatch = /^#([0-9a-fA-F]{6})$/.exec(color);
let gradientColor = color;
if (hexMatch) {
const hex = hexMatch[1];
const r = parseInt(hex.slice(0,2), 16);
const g = parseInt(hex.slice(2,4), 16);
const b = parseInt(hex.slice(4,6), 16);
gradientColor = `rgba(${r}, ${g}, ${b}, 0.14)`; // ~14% alpha
}
return {
backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)`
} as Record<string, string>;
}
// ======================================== // ========================================
// FOLDER COLOR MANAGEMENT // FOLDER COLOR MANAGEMENT
// ======================================== // ========================================
@ -269,7 +385,7 @@ export class FileExplorerComponent {
switch (action) { switch (action) {
case 'create-subfolder': case 'create-subfolder':
this.createSubfolder(); this.openCreateModal(this.ctxTarget?.path || null);
break; break;
case 'rename': case 'rename':
this.openRenameModal(); this.openRenameModal();
@ -295,17 +411,58 @@ export class FileExplorerComponent {
onContextMenuColor(color: string) { onContextMenuColor(color: string) {
if (!this.ctxTarget) return; if (!this.ctxTarget) return;
this.setFolderColor(this.ctxTarget.path, color); this.setFolderColor(this.ctxTarget.path, color);
this.showNotification(`Folder color updated to ${color}`, 'success'); // Close context menu after color selection
this.ctxVisible.set(false);
this.showNotification(`Folder color updated`, 'success');
} }
// Action implementations // Action implementations
private createSubfolder() { private openCreateModal(parentPath: string | null) {
if (!this.ctxTarget) return; this.createParentPath = parentPath ?? null;
const name = prompt('Enter subfolder name:'); this.createInputValue = '';
if (!name) return; this.createError.set('');
const newPath = `${this.ctxTarget.path}/${name}`; this.createModalVisible.set(true);
// TODO: Implement actual folder creation via VaultService setTimeout(() => {
this.showNotification(`Creating subfolder: ${newPath}`, 'info'); try { (document.querySelector('#createInput') as HTMLInputElement)?.focus(); } catch {}
}, 0);
}
private async confirmCreate() {
const name = (this.createInputValue || '').trim();
if (!name) { this.createError.set('Please enter a folder name'); return; }
const payload = this.createParentPath != null
? { parentPath: this.createParentPath, newFolderName: name }
: { path: name };
try {
const res = await fetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Failed to create folder');
this.showNotification(`Folder "${name}" created successfully`, 'success');
this.cancelCreate();
this.ctxVisible.set(false);
this.vaultService.refreshFoldersTree(true);
} catch (err: any) {
console.error('Create folder error:', err);
this.createError.set(err?.message || 'Failed to create folder');
}
}
private cancelCreate() {
this.createModalVisible.set(false);
this.createInputValue = '';
this.createParentPath = null;
this.createError.set('');
}
openCreateAtRoot() {
this.openCreateModal(null);
} }
private openRenameModal() { private openRenameModal() {
@ -438,6 +595,9 @@ export class FileExplorerComponent {
this.removeFolderColorsRecursively(target.path); this.removeFolderColorsRecursively(target.path);
this.persistFolderColors(); this.persistFolderColors();
// Reset focus in Notes-list when folder is deleted
this.notesListFocus.resetFocus();
this.showNotification(`Folder deleted: ${target.name}`, 'success'); this.showNotification(`Folder deleted: ${target.name}`, 'success');
this.ctxVisible.set(false); this.ctxVisible.set(false);
@ -478,6 +638,28 @@ export class FileExplorerComponent {
constructor() { constructor() {
this.loadFolderColors(); this.loadFolderColors();
// Close context menu when clicking outside
effect(() => {
if (this.ctxVisible()) {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// Check if click is outside the context menu
if (!target.closest('app-context-menu') && !target.closest('[role="menu"]')) {
this.ctxVisible.set(false);
}
};
// Add listener on next tick to avoid immediate closure
setTimeout(() => {
document.addEventListener('click', handleClickOutside, true);
}, 0);
return () => {
document.removeEventListener('click', handleClickOutside, true);
};
}
});
} }
ngOnInit() { ngOnInit() {

View File

@ -114,12 +114,12 @@ html:not(.dark)[data-theme="light"] {
--border: #e5e7eb; --border: #e5e7eb;
/* Brand colors */ /* Brand colors */
--primary: #3b82f6; --primary: #666666;
--brand: #3a68d1; --brand: #666666;
--brand-700: #2f56ab; --brand-700: #555555;
--brand-800: #254487; --brand-800: #444444;
--secondary: #8b5cf6; --secondary: #888888;
--accent: #22c55e; --accent: #999999;
/* Status colors */ /* Status colors */
--success: #22c55e; --success: #22c55e;
@ -153,6 +153,84 @@ html:not(.dark)[data-theme="light"] {
--md-table-row-alt: color-mix(in oklab, var(--surface-1) 96%, white 0%); --md-table-row-alt: color-mix(in oklab, var(--surface-1) 96%, white 0%);
} }
/* ============================================================================
THÈME PURE WHITE - DARK (theme id is "light")
============================================================================ */
html.dark[data-theme="light"] {
color-scheme: dark;
/* Fonts — Pure White */
--font-ui: Arial, "Helvetica Neue", Helvetica, "Segoe UI", sans-serif;
/* Backgrounds - Dark version for readability */
--bg: #0f0f0f;
--bg-main: #6d6c6c;
--bg-muted: #262626;
--card: #6d6c6c;
--card-bg: #6d6c6c;
--elevated: #262626;
--sidebar-bg: #0f0f0f;
--surface-1: #0f0f0f;
--surface-2: #6d6c6c;
/* Text */
--fg: #cccccc;
--text-main: #cccccc;
--text-muted: #aaaaaa;
--muted: #b3b1b1;
/* Borders */
--border: #333333;
/* Brand colors - Changed to grays */
--primary: #afafaf;
--brand: #888888;
--brand-700: #666666;
--brand-800: #555555;
--secondary: #777777;
--accent: #555555;
/* Status colors */
--success: #4ade80;
--warning: #fbbf24;
--danger: #f87171;
--info: #60a5fa;
/* UI elements */
--chip-bg: #333333;
--link: #cccccc;
--link-hover: #ffffff;
--ring: #888888;
/* Shadows */
--shadow-color: rgba(0, 0, 0, 0.5);
--scrollbar-thumb: rgba(136, 136, 136, 0.6);
/* Editor */
--editor-bg: #1a1a1a;
--editor-fg: #ffffff;
--editor-selection: rgba(255, 255, 255, 0.2);
--editor-gutter-bg: #0f0f0f;
--editor-gutter-fg: #cccccc;
--editor-cursor: #ffffff;
/* Markdown overrides */
--md-h1: #ffffff;
--md-h2: #cccccc;
--md-h3: #aaaaaa;
--md-quote-bar: #cccccc;
--md-quote-bg: color-mix(in oklab, #1a1a1a 92%, black 0%);
--md-table-head-bg: color-mix(in oklab, #262626 90%, black 0%);
--md-table-row-alt: color-mix(in oklab, #0f0f0f 85%, black 0%);
--md-pre-bg: #0f0f0f;
--md-pre-border: #333333;
--md-syntax-1: #cccccc;
--md-syntax-2: #aaaaaa;
--md-syntax-3: #888888;
--md-syntax-4: #666666;
--md-syntax-5: #999999;
}
/* ============================================================================ /* ============================================================================
THÈME DARK (baseline dark) THÈME DARK (baseline dark)
============================================================================ */ ============================================================================ */
@ -238,14 +316,14 @@ html:not(.dark)[data-theme="blue"] {
/* Backgrounds */ /* Backgrounds */
--bg: #ffffff; --bg: #ffffff;
--bg-main: #f7faff; --bg-main: #fbfdff; /* overall app background (lightest after card) */
--bg-muted: #eef2ff; --bg-muted: #f2f6ff; /* subtle blue tint for muted areas */
--card: #ffffff; --card: #ffffff; /* view note / cards: lightest */
--card-bg: #ffffff; --card-bg: #ffffff;
--elevated: #ffffff; --elevated: #ffffff;
--sidebar-bg: #f1f5ff; --sidebar-bg: #eef4ff; /* sidebar: slightly darker */
--surface-1: #f1f5ff; --surface-1: #f3f7ff; /* notes list container: intermediate */
--surface-2: #e6edff; --surface-2: #e9f1ff; /* secondary surfaces */
/* Text */ /* Text */
--fg: #0f172a; --fg: #0f172a;
@ -254,15 +332,15 @@ html:not(.dark)[data-theme="blue"] {
--muted: #64748b; --muted: #64748b;
/* Borders */ /* Borders */
--border: #dbeafe; --border: #e3eeff;
/* Brand colors */ /* Brand colors */
--primary: #2563eb; --primary: #6BA7F7; /* softer, brighter blue */
--brand: #2563eb; --brand: #6BA7F7;
--brand-700: #1d4ed8; --brand-700: #4A90E2; /* hover/focus */
--brand-800: #1e40af; --brand-800: #2F6BD6; /* active */
--secondary: #7c3aed; --secondary: #8FB7FF; /* badges/labels */
--accent: #06b6d4; --accent: #5FC8E8; /* info accents */
/* Status */ /* Status */
--success: #16a34a; --success: #16a34a;
@ -271,40 +349,40 @@ html:not(.dark)[data-theme="blue"] {
--info: #0ea5e9; --info: #0ea5e9;
/* UI */ /* UI */
--chip-bg: #e2e8f0; --chip-bg: #eaf2ff;
--link: #1d4ed8; --link: #4A90E2;
--link-hover: #1e40af; --link-hover: #2F6BD6;
--ring: #2563eb; --ring: #6BA7F7;
/* Shadows */ /* Shadows */
--shadow-color: rgba(15, 23, 42, 0.08); --shadow-color: rgba(15, 23, 42, 0.08);
--scrollbar-thumb: rgba(59, 130, 246, 0.35); --scrollbar-thumb: rgba(125, 155, 195, 0.35); /* light gray-blue */
/* Editor */ /* Editor */
--editor-bg: #ffffff; --editor-bg: #ffffff;
--editor-fg: #0f172a; --editor-fg: #0f172a;
--editor-selection: rgba(37, 99, 235, 0.2); --editor-selection: color-mix(in srgb, var(--primary) 22%, transparent);
--editor-gutter-bg: #f1f5ff; --editor-gutter-bg: #f1f5ff;
--editor-gutter-fg: #64748b; --editor-gutter-fg: #64748b;
--editor-cursor: #0f172a; --editor-cursor: #0f172a;
/* Markdown overrides */ /* Markdown overrides */
--md-h1: #0f172a; --md-h1: #0f172a;
--md-h2: #1d4ed8; --md-h2: #4A90E2;
--md-h3: #2563eb; --md-h3: #6BA7F7;
--md-h4: #7c3aed; --md-h4: #5FC8E8;
--md-h5: #475569; --md-h5: #475569;
--md-h6: #64748b; --md-h6: #64748b;
--md-quote-bar: #2563eb; --md-quote-bar: #4A90E2;
--md-quote-bg: color-mix(in oklab, #e6edff 92%, white 0%); --md-quote-bg: color-mix(in oklab, #e9f1ff 92%, white 0%);
--md-table-head-bg: color-mix(in oklab, #e6edff 90%, white 0%); --md-table-head-bg: color-mix(in oklab, #e9f1ff 90%, white 0%);
--md-table-row-alt: color-mix(in oklab, #f1f5ff 96%, white 0%); --md-table-row-alt: color-mix(in oklab, #f3f7ff 96%, white 0%);
--md-pre-bg: #f1f5ff; --md-pre-bg: #f1f5ff;
--md-pre-border: #dbeafe; --md-pre-border: #dbeafe;
--md-syntax-1: #ef4444; --md-syntax-1: #ef4444;
--md-syntax-2: #7c3aed; --md-syntax-2: #4A90E2;
--md-syntax-3: #16a34a; --md-syntax-3: #16a34a;
--md-syntax-4: #2563eb; --md-syntax-4: #6BA7F7;
--md-syntax-5: #0f172a; --md-syntax-5: #0f172a;
} }

View File

@ -0,0 +1,17 @@
---
titre: Nouvelle note 3
auteur: Bruno Charest
creation_date: 2025-10-24T15:44:07.120Z
modification_date: 2025-10-24T11:44:07-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -0,0 +1,15 @@
---
titre: "Nouvelle note 3"
auteur: "Bruno Charest"
creation_date: "2025-10-24T15:44:07.120Z"
modification_date: "2025-10-24T15:44:07.120Z"
status: "en-cours"
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -0,0 +1,17 @@
---
titre: Nouvelle note 2
auteur: Bruno Charest
creation_date: 2025-10-24T12:24:03.706Z
modification_date: 2025-10-24T08:24:04-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -1,8 +1,8 @@
--- ---
titre: "Nouvelle note 2" titre: "Nouvelle note 2"
auteur: "Bruno Charest" auteur: "Bruno Charest"
creation_date: "2025-10-24T11:57:19.077Z" creation_date: "2025-10-24T12:24:03.706Z"
modification_date: "2025-10-24T11:57:19.077Z" modification_date: "2025-10-24T12:24:03.706Z"
status: "en-cours" status: "en-cours"
publish: false publish: false
favoris: false favoris: false