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:
@@ -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" />
|
||||
<button type="button"
|
||||
(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">
|
||||
<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>
|
||||
@@ -127,27 +127,28 @@ import { NoteCreationService } from '../../services/note-creation.service';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<li *ngFor="let n of filtered()"
|
||||
<ul class="notes-list">
|
||||
<li *ngFor="let n of filtered()"
|
||||
class="note-row cursor-pointer"
|
||||
[class.active]="selectedId() === n.id"
|
||||
[ngClass]="getListItemClasses()"
|
||||
class="hover:bg-surface1 dark:hover:bg-card cursor-pointer transition-colors"
|
||||
(click)="openNote.emit(n.id)">
|
||||
<!-- Compact View -->
|
||||
<div *ngIf="state.viewMode() === 'compact'" class="px-3 py-1.5">
|
||||
<div class="text-xs font-semibold truncate">{{ n.title }}</div>
|
||||
<div *ngIf="state.viewMode() === 'compact'" class="note-inner">
|
||||
<div class="title text-xs truncate">{{ n.title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Comfortable View (default) -->
|
||||
<div *ngIf="state.viewMode() === 'comfortable'" class="p-3">
|
||||
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
|
||||
<div class="text-xs text-muted truncate">{{ n.filePath }}</div>
|
||||
<div *ngIf="state.viewMode() === 'comfortable'" class="note-inner">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed View -->
|
||||
<div *ngIf="state.viewMode() === 'detailed'" class="p-3 space-y-1.5">
|
||||
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
|
||||
<div class="text-xs text-muted truncate">{{ n.filePath }}</div>
|
||||
<div class="text-xs text-muted/70">
|
||||
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner space-y-1.5">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
<div class="excerpt text-xs">
|
||||
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
|
||||
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
|
||||
</div>
|
||||
@@ -162,13 +163,65 @@ import { NoteCreationService } from '../../services/note-creation.service';
|
||||
display: block;
|
||||
height: 100%;
|
||||
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 {
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
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%;
|
||||
contain: content;
|
||||
}
|
||||
@@ -186,6 +239,98 @@ import { NoteCreationService } from '../../services/note-creation.service';
|
||||
width: 100%;
|
||||
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 {
|
||||
@@ -194,6 +339,7 @@ export class NotesListComponent {
|
||||
query = input<string>('');
|
||||
tagFilter = input<string | null>(null);
|
||||
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
|
||||
selectedId = input<string | null>(null);
|
||||
|
||||
@Output() openNote = new EventEmitter<string>();
|
||||
@Output() queryChange = new EventEmitter<string>();
|
||||
|
||||
@@ -48,14 +48,16 @@ import { takeUntil } from 'rxjs/operators';
|
||||
(scrolledIndexChange)="onScroll($event)"
|
||||
appScrollableOverlay>
|
||||
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<ul class="notes-list">
|
||||
<!-- Virtual items -->
|
||||
<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.selected]="note.id === selectedNoteId()"
|
||||
class="note-row cursor-pointer"
|
||||
[class.active]="(selectedId() ?? selectedNoteId()) === note.id"
|
||||
(click)="selectNote(note)">
|
||||
<div class="text-sm font-semibold truncate">{{ note.title }}</div>
|
||||
<div class="text-xs text-muted truncate">{{ note.filePath }}</div>
|
||||
<div class="note-inner">
|
||||
<div class="title text-sm truncate">{{ note.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ note.filePath }}</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
@@ -85,37 +87,97 @@ import { takeUntil } from 'rxjs/operators';
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--list-panel-bg);
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.notes-list-container {
|
||||
min-height: 400px;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
/* 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%);
|
||||
--list-panel-bg: color-mix(in oklab, var(--card) 92%, black 8%);
|
||||
}
|
||||
|
||||
.note-item.selected {
|
||||
background-color: var(--surface1);
|
||||
border-left: 3px solid var(--primary);
|
||||
:host-context(html.dark) {
|
||||
--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-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 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ul {
|
||||
/* Notes list and rows (match non-virtual list) */
|
||||
.notes-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: 2px 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
.note-row {
|
||||
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 {
|
||||
@@ -130,6 +192,7 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
query = input<string>('');
|
||||
tagFilter = input<string | null>(null);
|
||||
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
|
||||
selectedId = input<string | null>(null);
|
||||
|
||||
// Outputs
|
||||
@Output() openNote = new EventEmitter<string>();
|
||||
|
||||
@@ -301,6 +301,160 @@
|
||||
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 */
|
||||
@media (min-width: 640px) {
|
||||
.parameters-page {
|
||||
|
||||
@@ -112,6 +112,79 @@
|
||||
</div>
|
||||
</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) -->
|
||||
<section class="parameters-section">
|
||||
<h2 class="section-title">
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { Component, inject, signal, effect } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ThemeService, ThemeMode, ThemeId, Language } from '../../core/services/theme.service';
|
||||
import { ToastService } from '../../shared/toast/toast.service';
|
||||
import { FolderFilterService, FolderFilterConfig } from '../../services/folder-filter.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-parameters',
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './parameters.page.html',
|
||||
styleUrls: ['./parameters.page.css']
|
||||
})
|
||||
export class ParametersPage {
|
||||
private themeService = inject(ThemeService);
|
||||
private toastService = inject(ToastService);
|
||||
private folderFilterService = inject(FolderFilterService);
|
||||
|
||||
// Reactive prefs
|
||||
prefs = signal(this.themeService.prefsValue);
|
||||
folderFilterConfig = signal<FolderFilterConfig>(this.folderFilterService.getConfig());
|
||||
newExcludedFolder = '';
|
||||
|
||||
modes: ThemeMode[] = ['system', 'light', 'dark'];
|
||||
themes: ThemeId[] = ['light', 'dark', 'obsidian', 'nord', 'notion', 'github', 'discord', 'monokai'];
|
||||
@@ -77,4 +82,50 @@ export class ParametersPage {
|
||||
};
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { RouterModule } from '@angular/router';
|
||||
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
|
||||
@@ -49,10 +49,13 @@ import { VaultService } from '../../../services/vault.service';
|
||||
|
||||
<!-- Quick Links accordion -->
|
||||
<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">
|
||||
<span class="flex items-center gap-2">⚡ <span>Quick Links</span></span>
|
||||
<span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span>
|
||||
<span>⚡</span>
|
||||
<span>Quick Links</span>
|
||||
</span>
|
||||
</button>
|
||||
<div *ngIf="open.quick" class="pt-1">
|
||||
<app-quick-links (quickLinkSelected)="onQuickLink($event)"></app-quick-links>
|
||||
@@ -61,13 +64,21 @@ import { VaultService } from '../../../services/vault.service';
|
||||
|
||||
<!-- Folders accordion -->
|
||||
<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"
|
||||
(click)="toggleFoldersSection()">
|
||||
<span class="flex items-center gap-2">📁 <span>Folders</span></span>
|
||||
<span class="text-xs text-muted">{{ open.folders ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card">
|
||||
<button class="flex items-center gap-2 flex-1 text-left" (click)="toggleFoldersSection()">
|
||||
<span class="text-xs text-muted">{{ open.folders ? '▾' : '▸' }}</span>
|
||||
<span>📁</span>
|
||||
<span>Folders</span>
|
||||
</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">
|
||||
<app-file-explorer
|
||||
#foldersExplorer
|
||||
[nodes]="effectiveFileTree"
|
||||
[selectedNoteId]="selectedNoteId"
|
||||
[foldersOnly]="true"
|
||||
@@ -80,10 +91,13 @@ import { VaultService } from '../../../services/vault.service';
|
||||
|
||||
<!-- Tags accordion -->
|
||||
<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">
|
||||
<span class="flex items-center gap-2">🏷️ <span>Tags</span></span>
|
||||
<span class="text-xs text-muted">{{ open.tags ? '▾' : '▸' }}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted">{{ open.tags ? '▾' : '▸' }}</span>
|
||||
<span>🏷️</span>
|
||||
<span>Tags</span>
|
||||
</span>
|
||||
</button>
|
||||
<div *ngIf="open.tags" class="px-2 py-2">
|
||||
<ul class="space-y-0.5 text-sm">
|
||||
@@ -100,10 +114,13 @@ import { VaultService } from '../../../services/vault.service';
|
||||
|
||||
<!-- Trash accordion -->
|
||||
<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()">
|
||||
<span class="flex items-center gap-2">🗑️ <span>Trash</span></span>
|
||||
<span class="text-xs text-muted">{{ open.trash ? '▾' : '▸' }}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted">{{ open.trash ? '▾' : '▸' }}</span>
|
||||
<span>🗑️</span>
|
||||
<span>Trash</span>
|
||||
</span>
|
||||
</button>
|
||||
<div *ngIf="open.trash" class="px-1 py-2">
|
||||
<ng-container *ngIf="trashHasContent(); else emptyTrash">
|
||||
@@ -166,6 +183,7 @@ export class NimbusSidebarComponent {
|
||||
env = environment;
|
||||
open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||||
private vault = inject(VaultService);
|
||||
@ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent;
|
||||
|
||||
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 {
|
||||
const next = !this.open.trash;
|
||||
this.open.trash = next;
|
||||
|
||||
@@ -179,6 +179,7 @@ import { AboutPanelComponent } from '../../features/about/about-panel.component'
|
||||
[tagFilter]="tagFilter"
|
||||
[quickLinkFilter]="quickLinkFilter"
|
||||
[query]="listQuery"
|
||||
[selectedId]="selectedNoteId"
|
||||
(openNote)="onOpenNote($event)"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
(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>
|
||||
</div>
|
||||
<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 [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>
|
||||
@@ -288,7 +289,7 @@ import { AboutPanelComponent } from '../../features/about/about-panel.component'
|
||||
|
||||
@if (mobileNav.activeTab() === 'list') {
|
||||
<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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { CommonModule } from '@angular/common';
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-semibold text-white"
|
||||
[ngClass]="bgClass"
|
||||
[ngStyle]="mergedStyle"
|
||||
aria-label="count"
|
||||
>
|
||||
{{ count }}
|
||||
@@ -17,7 +18,7 @@ import { CommonModule } from '@angular/common';
|
||||
})
|
||||
export class BadgeCountComponent {
|
||||
@Input() count = 0;
|
||||
@Input() color: 'slate'|'rose'|'amber'|'indigo'|'emerald'|'stone'|'zinc'|'green'|'purple' = 'slate';
|
||||
@Input() color: string = 'slate';
|
||||
|
||||
get bgClass() {
|
||||
return {
|
||||
@@ -32,4 +33,30 @@ export class BadgeCountComponent {
|
||||
'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>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user