feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support
This commit is contained in:
@@ -47,7 +47,6 @@
|
||||
[cellTemplate]="dayCellTemplate"
|
||||
[weekStartsOn]="1"
|
||||
[events]="[]"
|
||||
(pointerup)="onDatePointerUp()"
|
||||
></mwl-calendar-month-view>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +68,8 @@
|
||||
class="day-cell"
|
||||
[ngClass]="getCellClasses(day)"
|
||||
(pointerdown)="onDatePointerDown(day, $event)"
|
||||
(pointerenter)="onDatePointerEnter(day)"
|
||||
(pointerenter)="onDatePointerEnter(day, $event)"
|
||||
(pointerup)="onDatePointerUp(day, $event)"
|
||||
>
|
||||
<span class="day-number">{{ day.date | date: 'd' }}</span>
|
||||
<div class="indicators">
|
||||
|
||||
@@ -125,6 +125,8 @@ export class MarkdownCalendarComponent {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const date = this.normalizeDay(day.date);
|
||||
this.dragAnchor = date;
|
||||
this.selectedRange.set({ start: date, end: date });
|
||||
@@ -138,9 +140,12 @@ export class MarkdownCalendarComponent {
|
||||
this.emitSelectionSummary();
|
||||
this.requestSearchPanel.emit();
|
||||
this.refresh$.next();
|
||||
|
||||
// Capture pointer to enable drag across cells
|
||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
onDatePointerEnter(day: CalendarMonthViewDay<Date>): void {
|
||||
onDatePointerEnter(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
|
||||
if (!this.dragAnchor) {
|
||||
return;
|
||||
}
|
||||
@@ -152,12 +157,13 @@ export class MarkdownCalendarComponent {
|
||||
this.refresh$.next();
|
||||
}
|
||||
|
||||
@HostListener('document:pointerup')
|
||||
finishRangeSelection(): void {
|
||||
@HostListener('document:pointerup', ['$event'])
|
||||
finishRangeSelection(event: PointerEvent): void {
|
||||
this.finalizeSelection();
|
||||
}
|
||||
|
||||
onDatePointerUp(): void {
|
||||
onDatePointerUp(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
|
||||
event.stopPropagation();
|
||||
this.finalizeSelection();
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,19 @@ import { SearchOptions } from '../../core/search/search-parser.types';
|
||||
.*
|
||||
</button>
|
||||
|
||||
<!-- Highlight toggle (Hi) -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleHighlight()"
|
||||
[class.bg-accent]="highlight()"
|
||||
[class.text-white]="highlight()"
|
||||
class="btn-standard-xs"
|
||||
[title]="highlight() ? 'Highlight enabled' : 'Highlight disabled'"
|
||||
aria-label="Toggle highlight"
|
||||
>
|
||||
Hi
|
||||
</button>
|
||||
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
*ngIf="query"
|
||||
@@ -133,6 +146,9 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
@Input() showSearchIcon: boolean = true;
|
||||
@Input() inputClass: string = 'w-full rounded-full border border-border bg-bg-muted/70 py-2.5 pr-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-gray-600 dark:bg-gray-800/80 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-blue-500';
|
||||
@Input() initialQuery: string = '';
|
||||
@Input() caseSensitiveDefault: boolean = false;
|
||||
@Input() regexDefault: boolean = false;
|
||||
@Input() highlightDefault: boolean = true;
|
||||
|
||||
@Output() search = new EventEmitter<{ query: string; options: SearchOptions }>();
|
||||
@Output() queryChange = new EventEmitter<string>();
|
||||
@@ -145,6 +161,7 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query = '';
|
||||
caseSensitive = signal(false);
|
||||
regexMode = signal(false);
|
||||
highlight = signal(true);
|
||||
anchorElement: HTMLElement | null = null;
|
||||
private historyIndex = -1;
|
||||
|
||||
@@ -154,6 +171,9 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
if (this.initialQuery) {
|
||||
this.query = this.initialQuery;
|
||||
}
|
||||
this.caseSensitive.set(!!this.caseSensitiveDefault);
|
||||
this.regexMode.set(!!this.regexDefault);
|
||||
this.highlight.set(this.highlightDefault !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,7 +242,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -274,7 +295,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -292,7 +314,23 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle highlight mode */
|
||||
toggleHighlight(): void {
|
||||
this.highlight.update(v => !v);
|
||||
if (this.query.trim()) {
|
||||
this.search.emit({
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -316,7 +354,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query: '',
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+67
-13
@@ -7,13 +7,15 @@ import {
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
AfterViewInit
|
||||
AfterViewInit,
|
||||
OnInit,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchQueryAssistantComponent } from '../search-query-assistant/search-query-assistant.component';
|
||||
import { inject } from '@angular/core';
|
||||
import { SearchHistoryService } from '../../core/search/search-history.service';
|
||||
import { SearchPreferencesService } from '../../core/search/search-preferences.service';
|
||||
|
||||
/**
|
||||
* Search input with integrated query assistant
|
||||
@@ -51,16 +53,25 @@ import { SearchHistoryService } from '../../core/search/search-history.service';
|
||||
[attr.aria-label]="placeholder"
|
||||
/>
|
||||
|
||||
<button
|
||||
*ngIf="value"
|
||||
(click)="clear()"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<!-- Case (Aa) -->
|
||||
<button type="button" class="btn-standard-xs" [class.bg-accent]="caseSensitive()" [class.text-white]="caseSensitive()" (click)="toggleCase()" title="Case sensitivity">Aa</button>
|
||||
<!-- Regex (.*) -->
|
||||
<button type="button" class="btn-standard-xs font-mono" [class.bg-accent]="regexMode()" [class.text-white]="regexMode()" (click)="toggleRegex()" title="Regex mode">.*</button>
|
||||
<!-- Highlight (Hi) -->
|
||||
<button type="button" class="btn-standard-xs" [class.bg-accent]="highlight()" [class.text-white]="highlight()" (click)="toggleHighlight()" title="Highlight results">Hi</button>
|
||||
<!-- Clear -->
|
||||
<button
|
||||
*ngIf="value"
|
||||
(click)="clear()"
|
||||
class="btn-standard-icon"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-search-query-assistant
|
||||
@@ -81,7 +92,7 @@ import { SearchHistoryService } from '../../core/search/search-history.service';
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
export class SearchInputWithAssistantComponent implements AfterViewInit, OnInit {
|
||||
@Input() placeholder: string = 'Search...';
|
||||
@Input() value: string = '';
|
||||
@Input() context: string = 'default';
|
||||
@@ -91,15 +102,29 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
|
||||
@Output() valueChange = new EventEmitter<string>();
|
||||
@Output() submit = new EventEmitter<string>();
|
||||
@Output() optionsChange = new EventEmitter<{ caseSensitive: boolean; regexMode: boolean; highlight: boolean }>();
|
||||
|
||||
@ViewChild('searchInput') searchInputRef?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('assistant') assistantRef?: SearchQueryAssistantComponent;
|
||||
|
||||
anchorElement: HTMLElement | null = null;
|
||||
private historyService = inject(SearchHistoryService);
|
||||
private prefs = inject(SearchPreferencesService);
|
||||
|
||||
caseSensitive = signal(false);
|
||||
regexMode = signal(false);
|
||||
highlight = signal(true);
|
||||
|
||||
constructor(private hostElement: ElementRef<HTMLElement>) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load header search defaults
|
||||
const p = this.prefs.getPreferences('vault-header');
|
||||
this.caseSensitive.set(!!p.caseSensitive);
|
||||
this.regexMode.set(!!p.regexMode);
|
||||
this.highlight.set(p.highlight !== false);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.anchorElement = this.hostElement.nativeElement;
|
||||
}
|
||||
@@ -150,6 +175,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
this.historyService.add(this.context, this.value);
|
||||
}
|
||||
this.submit.emit(this.value);
|
||||
this.emitOptionsChange();
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.refreshHistoryView();
|
||||
this.assistantRef.close();
|
||||
@@ -194,6 +220,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
this.historyService.add(this.context, query);
|
||||
}
|
||||
this.submit.emit(query);
|
||||
this.emitOptionsChange();
|
||||
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = query;
|
||||
@@ -217,6 +244,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
this.searchInputRef.nativeElement.value = '';
|
||||
this.searchInputRef.nativeElement.focus();
|
||||
}
|
||||
this.emitOptionsChange();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,4 +255,30 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
this.searchInputRef.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private emitOptionsChange(): void {
|
||||
this.optionsChange.emit({
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
});
|
||||
}
|
||||
|
||||
toggleCase(): void {
|
||||
this.caseSensitive.update(v => !v);
|
||||
this.prefs.togglePreference('vault-header', 'caseSensitive');
|
||||
this.emitOptionsChange();
|
||||
}
|
||||
|
||||
toggleRegex(): void {
|
||||
this.regexMode.update(v => !v);
|
||||
this.prefs.togglePreference('vault-header', 'regexMode');
|
||||
this.emitOptionsChange();
|
||||
}
|
||||
|
||||
toggleHighlight(): void {
|
||||
this.highlight.update(v => !v);
|
||||
this.prefs.togglePreference('vault-header', 'highlight');
|
||||
this.emitOptionsChange();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
signal,
|
||||
computed,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
effect,
|
||||
untracked
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { isObservable, Subject } from 'rxjs';
|
||||
import { take, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchBarComponent } from '../search-bar/search-bar.component';
|
||||
import { SearchResultsComponent } from '../search-results/search-results.component';
|
||||
@@ -21,6 +26,8 @@ import { SearchPreferencesService } from '../../core/search/search-preferences.s
|
||||
import { SearchOptions } from '../../core/search/search-parser.types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
import { environment } from '../../core/logging/environment';
|
||||
import { parseSearchQuery } from '../../core/search/search-parser';
|
||||
|
||||
/**
|
||||
* Complete search panel with bar and results
|
||||
@@ -38,13 +45,17 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
<app-search-bar
|
||||
[placeholder]="placeholder"
|
||||
[context]="context"
|
||||
[initialQuery]="initialQuery"
|
||||
[showSearchIcon]="true"
|
||||
[showExamples]="false"
|
||||
[caseSensitiveDefault]="caseSensitive"
|
||||
[regexDefault]="regexMode"
|
||||
[highlightDefault]="highlightEnabled"
|
||||
(search)="onSearch($event)"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Search options toggles -->
|
||||
@if (hasSearched() && results().length > 0) {
|
||||
<div class="flex flex-col gap-3 p-4 border-b border-border dark:border-gray-700 bg-bg-muted dark:bg-gray-800">
|
||||
@@ -94,6 +105,17 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<!-- Explain search terms panel -->
|
||||
@if (explainSearchTerms && currentQuery().trim()) {
|
||||
<div class="p-4 border-b border-border dark:border-gray-700 bg-bg-primary/60 dark:bg-gray-900/60">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-muted mb-2">Match all of:</h4>
|
||||
<ul class="ml-1 space-y-1 text-xs">
|
||||
@for (line of explanationLines(); track $index) {
|
||||
<li class="text-text-muted dark:text-gray-400">{{ line }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Search results -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
@@ -118,6 +140,7 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
[collapseAll]="collapseResults"
|
||||
[showMoreContext]="showMoreContext"
|
||||
[contextLines]="contextLines()"
|
||||
[highlight]="lastOptions.highlight !== false"
|
||||
(noteOpen)="onNoteOpen($event)"
|
||||
/>
|
||||
} @else {
|
||||
@@ -138,11 +161,14 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchPanelComponent implements OnInit {
|
||||
export class SearchPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() placeholder: string = 'Search in vault...';
|
||||
@Input() context: string = 'vault';
|
||||
@Input() initialQuery: string = '';
|
||||
|
||||
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
|
||||
@Output() searchTermChange = new EventEmitter<string>();
|
||||
@Output() optionsChange = new EventEmitter<SearchOptions>();
|
||||
|
||||
private orchestrator = inject(SearchOrchestratorService);
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
@@ -156,30 +182,68 @@ export class SearchPanelComponent implements OnInit {
|
||||
currentQuery = signal('');
|
||||
|
||||
private lastOptions: SearchOptions = {};
|
||||
private searchSubject = new Subject<{ query: string; options?: SearchOptions }>();
|
||||
|
||||
// UI toggles
|
||||
collapseResults = false;
|
||||
showMoreContext = false;
|
||||
explainSearchTerms = false;
|
||||
caseSensitive = false;
|
||||
regexMode = false;
|
||||
highlightEnabled = true;
|
||||
|
||||
// Computed context lines based on showMoreContext
|
||||
contextLines = computed(() => this.showMoreContext ? 5 : 2);
|
||||
|
||||
private syncIndexEffect = effect(() => {
|
||||
const notes = this.vaultService.allNotes();
|
||||
this.logger.info('SearchPanel', 'Detected notes change, rebuilding index', {
|
||||
context: this.context,
|
||||
noteCount: notes.length
|
||||
});
|
||||
this.searchIndex.rebuildIndex(notes);
|
||||
// Build explanation lines from the local parser (UI-only, independent of provider)
|
||||
explanationLines = computed(() => {
|
||||
const q = this.currentQuery().trim();
|
||||
if (!q) return [] as string[];
|
||||
try {
|
||||
const parsed = parseSearchQuery(q, { caseSensitive: false, regexMode: false });
|
||||
const lines: string[] = [];
|
||||
const f = parsed.diagnostics?.filters;
|
||||
if (f) {
|
||||
if (f.file?.length) {
|
||||
f.file.forEach(v => lines.push(`Match file name: "${v}"`));
|
||||
}
|
||||
if (f.path?.length) {
|
||||
f.path.forEach(v => lines.push(`Match path: "${v}"`));
|
||||
}
|
||||
if (f.tag?.length) {
|
||||
f.tag.forEach(v => lines.push(`Match tag: #${v}`));
|
||||
}
|
||||
}
|
||||
// Remaining tokens (rough): use diagnostics.tokens minus operators
|
||||
const tokens = parsed.diagnostics?.tokens ?? [];
|
||||
tokens
|
||||
.filter(t => t.toUpperCase() !== 'AND' && t.toUpperCase() !== 'OR')
|
||||
.filter(t => !/^\w+:/.test(t))
|
||||
.forEach(t => lines.push(`Matches text: "${t.replace(/^"|"$/g,'')}"`));
|
||||
return lines;
|
||||
} catch {
|
||||
return [] as string[];
|
||||
}
|
||||
});
|
||||
|
||||
const query = untracked(() => this.currentQuery());
|
||||
if (query && query.trim()) {
|
||||
this.logger.debug('SearchPanel', 'Re-running search after index rebuild', {
|
||||
query,
|
||||
context: this.context
|
||||
private syncIndexEffect = effect(() => {
|
||||
// Only rebuild index if not using Meilisearch
|
||||
if (!environment.USE_MEILI) {
|
||||
const notes = this.vaultService.allNotes();
|
||||
this.logger.info('SearchPanel', 'Detected notes change, rebuilding index', {
|
||||
context: this.context,
|
||||
noteCount: notes.length
|
||||
});
|
||||
this.executeSearch(query);
|
||||
this.searchIndex.rebuildIndex(notes);
|
||||
|
||||
const query = untracked(() => this.currentQuery());
|
||||
if (query && query.trim()) {
|
||||
this.logger.debug('SearchPanel', 'Re-running search after index rebuild', {
|
||||
query,
|
||||
context: this.context
|
||||
});
|
||||
this.executeSearch(query);
|
||||
}
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
@@ -189,6 +253,40 @@ export class SearchPanelComponent implements OnInit {
|
||||
this.collapseResults = prefs.collapseResults;
|
||||
this.showMoreContext = prefs.showMoreContext;
|
||||
this.explainSearchTerms = prefs.explainSearchTerms;
|
||||
this.caseSensitive = prefs.caseSensitive;
|
||||
this.regexMode = prefs.regexMode;
|
||||
this.highlightEnabled = prefs.highlight;
|
||||
|
||||
// Setup debounced search for live typing (only when using Meilisearch)
|
||||
if (environment.USE_MEILI) {
|
||||
this.searchSubject.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged((prev, curr) => prev.query === curr.query)
|
||||
).subscribe(({ query, options }) => {
|
||||
this.executeSearch(query, options);
|
||||
});
|
||||
}
|
||||
|
||||
// Execute initial query if provided
|
||||
const iq = this.initialQuery?.trim();
|
||||
if (iq && iq.length >= 2) {
|
||||
this.currentQuery.set(iq);
|
||||
this.executeSearch(iq);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.searchSubject.complete();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['initialQuery']) {
|
||||
const iq = (this.initialQuery ?? '').trim();
|
||||
if (iq && iq !== this.currentQuery()) {
|
||||
this.currentQuery.set(iq);
|
||||
this.executeSearch(iq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,33 +316,72 @@ export class SearchPanelComponent implements OnInit {
|
||||
query: trimmed,
|
||||
options: baseOptions,
|
||||
contextLines: this.contextLines(),
|
||||
context: this.context
|
||||
context: this.context,
|
||||
useMeilisearch: environment.USE_MEILI
|
||||
});
|
||||
|
||||
this.isSearching.set(true);
|
||||
|
||||
setTimeout(() => {
|
||||
// With Meilisearch, execute immediately (no setTimeout needed)
|
||||
// Without Meilisearch, use setTimeout to avoid blocking UI
|
||||
const executeNow = () => {
|
||||
try {
|
||||
const searchResults = this.orchestrator.execute(trimmed, {
|
||||
const exec = this.orchestrator.execute(trimmed, {
|
||||
...baseOptions,
|
||||
contextLines: this.contextLines()
|
||||
});
|
||||
this.logger.info('SearchPanel', 'Search completed', {
|
||||
query: trimmed,
|
||||
resultCount: searchResults.length
|
||||
});
|
||||
this.results.set(searchResults);
|
||||
this.hasSearched.set(true);
|
||||
|
||||
if (isObservable(exec)) {
|
||||
exec.pipe(take(1)).subscribe({
|
||||
next: (arr) => {
|
||||
this.logger.info('SearchPanel', 'Search completed (obs)', {
|
||||
query: trimmed,
|
||||
resultCount: arr.length,
|
||||
firstResult: arr.length > 0 ? { noteId: arr[0].noteId, matchCount: arr[0].matches.length } : null
|
||||
});
|
||||
console.log('[SearchPanel] Results received:', arr);
|
||||
this.results.set(arr);
|
||||
this.hasSearched.set(true);
|
||||
this.isSearching.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.logger.error('SearchPanel', 'Search execution error (obs)', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
stack: e instanceof Error ? e.stack : undefined
|
||||
});
|
||||
console.error('[SearchPanel] Search error:', e);
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(true);
|
||||
this.isSearching.set(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const searchResults = exec;
|
||||
this.logger.info('SearchPanel', 'Search completed', {
|
||||
query: trimmed,
|
||||
resultCount: searchResults.length
|
||||
});
|
||||
this.results.set(searchResults);
|
||||
this.hasSearched.set(true);
|
||||
this.isSearching.set(false);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('SearchPanel', 'Search execution error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(true);
|
||||
} finally {
|
||||
this.isSearching.set(false);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
if (environment.USE_MEILI) {
|
||||
// Execute immediately with Meilisearch (backend handles it)
|
||||
executeNow();
|
||||
} else {
|
||||
// Use setTimeout to avoid blocking UI with local search
|
||||
setTimeout(executeNow, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,6 +392,15 @@ export class SearchPanelComponent implements OnInit {
|
||||
|
||||
this.currentQuery.set(query);
|
||||
this.lastOptions = { ...options };
|
||||
// Persist user option toggles
|
||||
this.preferences.updatePreferences(this.context, {
|
||||
caseSensitive: !!options.caseSensitive,
|
||||
regexMode: !!options.regexMode,
|
||||
highlight: options.highlight !== false
|
||||
});
|
||||
// Emit upwards for header to sync highlight to document
|
||||
this.searchTermChange.emit(query);
|
||||
this.optionsChange.emit({ ...options });
|
||||
this.executeSearch(query, options);
|
||||
}
|
||||
|
||||
@@ -264,8 +410,14 @@ export class SearchPanelComponent implements OnInit {
|
||||
onQueryChange(query: string): void {
|
||||
this.currentQuery.set(query);
|
||||
|
||||
// Could implement debounced live search here
|
||||
// For now, only search on Enter
|
||||
// With Meilisearch, enable live search with debounce
|
||||
if (environment.USE_MEILI && query.trim().length >= 2) {
|
||||
this.searchSubject.next({ query, options: this.lastOptions });
|
||||
} else if (!query.trim()) {
|
||||
// Clear results if query is empty
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,6 +446,8 @@ export class SearchPanelComponent implements OnInit {
|
||||
this.preferences.updatePreferences(this.context, {
|
||||
collapseResults: this.collapseResults
|
||||
});
|
||||
// No need to re-run search, just update the preference
|
||||
// The search-results component will react to the input change
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,7 +460,7 @@ export class SearchPanelComponent implements OnInit {
|
||||
|
||||
// Re-run search with new context lines
|
||||
if (this.currentQuery()) {
|
||||
this.executeSearch(this.currentQuery());
|
||||
this.executeSearch(this.currentQuery(), this.lastOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
EventEmitter,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
ChangeDetectionStrategy,
|
||||
inject
|
||||
inject,
|
||||
OnChanges,
|
||||
SimpleChanges
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -197,7 +198,7 @@ type SortOption = 'relevance' | 'name' | 'modified';
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchResultsComponent {
|
||||
export class SearchResultsComponent implements OnChanges {
|
||||
@Input() set results(value: SearchResult[]) {
|
||||
this._results.set(value);
|
||||
this.buildGroups(value);
|
||||
@@ -206,6 +207,7 @@ export class SearchResultsComponent {
|
||||
@Input() collapseAll: boolean = false;
|
||||
@Input() showMoreContext: boolean = false;
|
||||
@Input() contextLines: number = 2;
|
||||
@Input() highlight: boolean = true;
|
||||
|
||||
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
|
||||
|
||||
@@ -216,13 +218,12 @@ export class SearchResultsComponent {
|
||||
private groups = signal<ResultGroup[]>([]);
|
||||
sortBy: SortOption = 'relevance';
|
||||
|
||||
constructor() {
|
||||
// Watch for collapseAll changes
|
||||
effect(() => {
|
||||
if (this.collapseAll !== undefined) {
|
||||
this.applyCollapseAll(this.collapseAll);
|
||||
}
|
||||
});
|
||||
constructor() {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['collapseAll'] && !changes['collapseAll'].firstChange) {
|
||||
this.applyCollapseAll(this.collapseAll);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,6 +348,19 @@ export class SearchResultsComponent {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If highlight is disabled, strip any pre-highlighted HTML and return plain text
|
||||
if (!this.highlight) {
|
||||
return this.highlighter.stripHtml(match.context);
|
||||
}
|
||||
|
||||
// If context already contains highlight markup from Meilisearch
|
||||
// (e.g., <mark> or <em> tags), return it as-is to preserve server-side highlights.
|
||||
// We purposely avoid escaping HTML here because it is produced by our backend with safe tags.
|
||||
const hasPreHighlightedHtml = /<(mark|em)>/i.test(match.context);
|
||||
if (hasPreHighlightedHtml) {
|
||||
return match.context;
|
||||
}
|
||||
|
||||
// If we have ranges, use them for precise highlighting
|
||||
if (match.ranges && match.ranges.length > 0) {
|
||||
return this.highlighter.highlightWithRanges(match.context, match.ranges);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TagsViewComponent } from './tags-view.component';
|
||||
import { TagInfo } from '../../types';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
describe('TagsViewComponent', () => {
|
||||
let component: TagsViewComponent;
|
||||
let fixture: ComponentFixture<TagsViewComponent>;
|
||||
|
||||
const mockTags: TagInfo[] = [
|
||||
{ name: 'AI', count: 6 },
|
||||
{ name: 'angular', count: 1 },
|
||||
{ name: 'debian/server', count: 3 },
|
||||
{ name: 'debian/desktop', count: 2 },
|
||||
{ name: 'automatisation', count: 2 },
|
||||
{ name: 'budget', count: 1 },
|
||||
{ name: 'docker/automation', count: 5 },
|
||||
{ name: 'docker/test', count: 3 },
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TagsViewComponent]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TagsViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
// Set required input
|
||||
fixture.componentRef.setInput('tags', mockTags);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('Filtering', () => {
|
||||
it('should filter tags by search query', () => {
|
||||
component.searchQuery.set('docker');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component['filteredTags']();
|
||||
expect(displayed.length).toBe(2);
|
||||
expect(displayed.every(t => t.name.includes('docker'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
component.searchQuery.set('DOCKER');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component['filteredTags']();
|
||||
expect(displayed.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle accents', () => {
|
||||
component.searchQuery.set('automatisation');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component['filteredTags']();
|
||||
expect(displayed.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sorting', () => {
|
||||
it('should sort alphabetically A-Z', () => {
|
||||
component.sortMode.set('alpha-asc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const sorted = component['sortedTags']();
|
||||
expect(sorted[0].name).toBe('AI');
|
||||
expect(sorted[sorted.length - 1].name).toBe('docker/test');
|
||||
});
|
||||
|
||||
it('should sort alphabetically Z-A', () => {
|
||||
component.sortMode.set('alpha-desc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const sorted = component['sortedTags']();
|
||||
expect(sorted[0].name).toBe('docker/test');
|
||||
expect(sorted[sorted.length - 1].name).toBe('AI');
|
||||
});
|
||||
|
||||
it('should sort by frequency descending', () => {
|
||||
component.sortMode.set('freq-desc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const sorted = component['sortedTags']();
|
||||
expect(sorted[0].name).toBe('AI'); // count: 6
|
||||
expect(sorted[1].name).toBe('docker/automation'); // count: 5
|
||||
});
|
||||
|
||||
it('should sort by frequency ascending', () => {
|
||||
component.sortMode.set('freq-asc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const sorted = component['sortedTags']();
|
||||
const firstCount = sorted[0].count;
|
||||
const lastCount = sorted[sorted.length - 1].count;
|
||||
expect(firstCount).toBeLessThanOrEqual(lastCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grouping', () => {
|
||||
it('should not group when mode is "none"', () => {
|
||||
component.groupMode.set('none');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
expect(groups.length).toBe(1);
|
||||
expect(groups[0].label).toBe('all');
|
||||
expect(groups[0].tags.length).toBe(mockTags.length);
|
||||
});
|
||||
|
||||
it('should group by hierarchy', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
|
||||
// Should have groups: AI, angular, debian, automatisation, budget, docker
|
||||
expect(groups.length).toBeGreaterThan(1);
|
||||
|
||||
const dockerGroup = groups.find(g => g.label === 'docker');
|
||||
expect(dockerGroup).toBeDefined();
|
||||
expect(dockerGroup!.tags.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should group alphabetically', () => {
|
||||
component.groupMode.set('alpha');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
|
||||
// Should have groups for A, B, D
|
||||
expect(groups.length).toBeGreaterThan(1);
|
||||
|
||||
const aGroup = groups.find(g => g.label === 'A');
|
||||
expect(aGroup).toBeDefined();
|
||||
expect(aGroup!.tags.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Group expansion', () => {
|
||||
it('should toggle group expansion', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
const firstGroup = groups[0];
|
||||
const initialState = firstGroup.isExpanded;
|
||||
|
||||
component.toggleGroup(firstGroup.label);
|
||||
fixture.detectChanges();
|
||||
|
||||
const updatedGroups = component.displayedGroups();
|
||||
const updatedGroup = updatedGroups.find(g => g.label === firstGroup.label);
|
||||
expect(updatedGroup!.isExpanded).toBe(!initialState);
|
||||
});
|
||||
|
||||
it('should auto-expand all groups when switching modes', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
expect(groups.every(g => g.isExpanded)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Display helpers', () => {
|
||||
it('should display short name in hierarchy mode', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
|
||||
const result = component.displayTagName('docker', 'docker/automation');
|
||||
expect(result).toBe('automation');
|
||||
});
|
||||
|
||||
it('should display full name in non-hierarchy mode', () => {
|
||||
component.groupMode.set('none');
|
||||
|
||||
const result = component.displayTagName('docker', 'docker/automation');
|
||||
expect(result).toBe('docker/automation');
|
||||
});
|
||||
|
||||
it('should handle root tag correctly', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
|
||||
const result = component.displayTagName('docker', 'docker');
|
||||
expect(result).toBe('docker');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events', () => {
|
||||
it('should emit tagSelected on tag click', (done) => {
|
||||
component.tagSelected.subscribe((tagName: string) => {
|
||||
expect(tagName).toBe('docker/automation');
|
||||
done();
|
||||
});
|
||||
|
||||
component.onTagClick('docker/automation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset', () => {
|
||||
it('should reset all filters', () => {
|
||||
component.searchQuery.set('test');
|
||||
component.sortMode.set('freq-desc');
|
||||
component.groupMode.set('hierarchy');
|
||||
|
||||
component.resetFilters();
|
||||
|
||||
expect(component.searchQuery()).toBe('');
|
||||
expect(component.sortMode()).toBe('alpha-asc');
|
||||
expect(component.groupMode()).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stats', () => {
|
||||
it('should calculate total tags correctly', () => {
|
||||
const total = component.totalTags();
|
||||
expect(total).toBe(mockTags.length);
|
||||
});
|
||||
|
||||
it('should calculate displayed tags after filtering', () => {
|
||||
component.searchQuery.set('docker');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component.totalDisplayedTags();
|
||||
expect(displayed).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should handle large tag lists efficiently', () => {
|
||||
const largeTags: TagInfo[] = Array.from({ length: 1000 }, (_, i) => ({
|
||||
name: `tag-${i}`,
|
||||
count: Math.floor(Math.random() * 100)
|
||||
}));
|
||||
|
||||
fixture.componentRef.setInput('tags', largeTags);
|
||||
fixture.detectChanges();
|
||||
|
||||
const start = performance.now();
|
||||
component.sortMode.set('freq-desc');
|
||||
fixture.detectChanges();
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(50); // Should be < 50ms
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,149 +8,252 @@ import {
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TagInfo } from '../../types';
|
||||
|
||||
interface TagSection {
|
||||
letter: string;
|
||||
type SortMode = 'alpha-asc' | 'alpha-desc' | 'freq-desc' | 'freq-asc';
|
||||
type GroupMode = 'none' | 'hierarchy' | 'alpha';
|
||||
|
||||
interface TagGroup {
|
||||
label: string;
|
||||
tags: TagInfo[];
|
||||
isExpanded: boolean;
|
||||
level: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-tags-view',
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
--tv-scroll-thumb-light: color-mix(in srgb, var(--text-main) 35%, transparent);
|
||||
--tv-scroll-thumb-dark: color-mix(in srgb, var(--text-muted) 55%, transparent);
|
||||
--tv-scroll-thumb: rgba(128, 128, 128, 0.3);
|
||||
--tv-scroll-track: transparent;
|
||||
}
|
||||
|
||||
:host .custom-scrollbar {
|
||||
:host-context(.dark) {
|
||||
--tv-scroll-thumb: rgba(200, 200, 200, 0.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--tv-scroll-thumb-light) var(--tv-scroll-track);
|
||||
scrollbar-color: var(--tv-scroll-thumb) var(--tv-scroll-track);
|
||||
}
|
||||
|
||||
:host([data-theme='dark']) .custom-scrollbar,
|
||||
:host-context(.dark) .custom-scrollbar {
|
||||
scrollbar-color: var(--tv-scroll-thumb-dark) var(--tv-scroll-track);
|
||||
}
|
||||
|
||||
:host .custom-scrollbar::-webkit-scrollbar {
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:host .custom-scrollbar::-webkit-scrollbar-track {
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--tv-scroll-track);
|
||||
}
|
||||
|
||||
:host .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--tv-scroll-thumb-light);
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--tv-scroll-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.tag-item:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.group-header {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Toolbar layout and compact controls */
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.control-select {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
:host([data-theme='dark']) .custom-scrollbar::-webkit-scrollbar-thumb,
|
||||
:host-context(.dark) .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--tv-scroll-thumb-dark);
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Remove blue focus rectangle and native search decorations */
|
||||
#tag-search,
|
||||
#tag-search:focus,
|
||||
#tag-search:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
#tag-search {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
input[type='search']::-webkit-search-decoration,
|
||||
input[type='search']::-webkit-search-cancel-button,
|
||||
input[type='search']::-webkit-search-results-button,
|
||||
input[type='search']::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
.icon-btn--muted {
|
||||
color: var(--obs-l-text-muted);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="flex h-full flex-col gap-4 p-3 bg-card">
|
||||
<div class="w-full rounded-full border border-border bg-card px-3 py-1.5 shadow-subtle focus-within:outline-none focus-within:ring-0">
|
||||
<label class="sr-only" for="tag-search">Rechercher des tags</label>
|
||||
<div class="flex items-center gap-2.5 text-text-muted">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-5.2-5.2m0 0A7 7 0 105.8 5.8a7 7 0 0010 10z" />
|
||||
</svg>
|
||||
<div class="flex h-full flex-col bg-obs-l-bg-main dark:bg-obs-d-bg-main">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-3 border-b border-obs-l-border dark:border-obs-d-border p-3">
|
||||
<!-- Search bar -->
|
||||
<div class="relative">
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="tag-search"
|
||||
type="search"
|
||||
[value]="searchTerm()"
|
||||
(input)="onSearchChange($event.target?.value ?? '')"
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchChange()"
|
||||
placeholder="Rechercher un tag..."
|
||||
class="w-full border-0 bg-transparent text-sm text-text-main placeholder:text-text-muted outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0"
|
||||
class="w-full rounded-md border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary pl-9 pr-3 py-2 text-sm text-obs-l-text-main dark:text-obs-d-text-main placeholder:text-obs-l-text-muted dark:placeholder:text-obs-d-text-muted focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="toolbar-row">
|
||||
<!-- Sort dropdown -->
|
||||
<select
|
||||
[(ngModel)]="sortMode"
|
||||
(ngModelChange)="onSortChange()"
|
||||
class="control-select border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-xs text-obs-l-text-main dark:text-obs-d-text-main focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
|
||||
>
|
||||
<option value="alpha-asc">A → Z</option>
|
||||
<option value="alpha-desc">Z → A</option>
|
||||
<option value="freq-desc">Fréquence ↓</option>
|
||||
<option value="freq-asc">Fréquence ↑</option>
|
||||
</select>
|
||||
|
||||
<!-- Group mode dropdown -->
|
||||
<select
|
||||
[(ngModel)]="groupMode"
|
||||
(ngModelChange)="onGroupModeChange()"
|
||||
class="control-select border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-xs text-obs-l-text-main dark:text-obs-d-text-main focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
|
||||
>
|
||||
<option value="none">Sans groupe</option>
|
||||
<option value="hierarchy">Hiérarchie</option>
|
||||
<option value="alpha">Alphabétique</option>
|
||||
</select>
|
||||
|
||||
<!-- Reset button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="resetFilters()"
|
||||
class="icon-btn border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover transition-colors"
|
||||
title="Réinitialiser les filtres"
|
||||
aria-label="Réinitialiser les filtres"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Expand all toggle (only when grouped) -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleExpandAll()"
|
||||
[disabled]="groupMode() === 'none'"
|
||||
[attr.aria-pressed]="allExpanded()"
|
||||
class="icon-btn border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary transition-colors"
|
||||
[ngClass]="groupMode() === 'none' ? 'text-obs-l-text-muted/60 dark:text-obs-d-text-muted/60 cursor-not-allowed' : (allExpanded() ? 'text-obs-l-text-main dark:text-obs-d-text-main' : 'text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover')"
|
||||
[title]="allExpanded() ? 'Replier tout' : 'Déplier tout'"
|
||||
[attr.aria-label]="allExpanded() ? 'Replier tout' : 'Déplier tout'"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12h16M4 6h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 gap-3 overflow-hidden">
|
||||
<div class="custom-scrollbar flex-1 overflow-y-auto pr-1">
|
||||
@if (displayedSections().length > 0) {
|
||||
<div class="space-y-4">
|
||||
@for (section of displayedSections(); track section.letter) {
|
||||
<section class="space-y-2">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
{{ section.letter }}
|
||||
</h2>
|
||||
<span class="text-[0.65rem] text-text-muted/70">
|
||||
{{ section.tags.length }} tag(s)
|
||||
<!-- Tags list -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
@if (displayedGroups().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center h-full text-obs-l-text-muted dark:text-obs-d-text-muted p-8">
|
||||
<svg class="h-12 w-12 mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<p class="text-sm">Aucun tag trouvé</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="py-2">
|
||||
@for (group of displayedGroups(); track group.label) {
|
||||
<div class="mb-1">
|
||||
<!-- Group header (if grouped) -->
|
||||
@if (groupMode() !== 'none') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleGroup(group.label)"
|
||||
class="group-header w-full flex items-center justify-between px-3 py-2 text-left hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-3 w-3 text-obs-l-text-muted dark:text-obs-d-text-muted expand-icon"
|
||||
[class.rotate-90]="group.isExpanded"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="text-xs font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted uppercase tracking-wide">
|
||||
{{ group.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
{{ group.tags.length }}
|
||||
</span>
|
||||
</header>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (tag of section.tags; track tag.name) {
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Tags in group -->
|
||||
@if (groupMode() === 'none' || group.isExpanded) {
|
||||
<div [class.pl-6]="groupMode() !== 'none'">
|
||||
@for (tag of group.tags; track tag.name) {
|
||||
<button
|
||||
type="button"
|
||||
class="chip justify-between px-2 py-0.5 text-[0.7rem]"
|
||||
(click)="tagSelected.emit(tag.name)"
|
||||
(click)="onTagClick(tag.name)"
|
||||
class="tag-item w-full flex items-center justify-between px-3 py-2 text-left hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover group"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-text-main">
|
||||
<span aria-hidden="true">🔖</span>
|
||||
<span class="truncate">{{ tag.name }}</span>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<svg class="h-3.5 w-3.5 text-obs-l-text-muted dark:text-obs-d-text-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<span class="text-sm text-obs-l-text-main dark:text-obs-d-text-main truncate">
|
||||
{{ displayTagName(group.label, tag.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted ml-2 flex-shrink-0">
|
||||
{{ tag.count }}
|
||||
</span>
|
||||
<span class="badge text-text-muted">{{ tag.count }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center rounded-lg border border-dashed border-border bg-bg-muted px-4 py-6 text-center text-sm text-text-muted">
|
||||
Aucun tag ne correspond à votre recherche.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<nav class="custom-scrollbar sticky top-0 flex h-full w-12 flex-col items-center justify-start gap-2 self-start rounded-lg border border-border bg-card px-2 py-3 text-[0.7rem] font-medium text-text-muted shadow-subtle overflow-y-auto">
|
||||
<span class="text-[0.6rem] uppercase tracking-wide text-text-muted/70">A–Z</span>
|
||||
<div class="grid grid-cols-1 gap-1">
|
||||
@for (letter of availableLetters(); track letter) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full border border-transparent text-[0.7rem] transition"
|
||||
[ngClass]="activeLetter() === letter ? 'border-border bg-bg-muted text-text-main font-semibold' : 'text-text-muted hover:border-border hover:bg-bg-muted'"
|
||||
[attr.aria-pressed]="activeLetter() === letter"
|
||||
(click)="onLetterClick(letter)"
|
||||
>
|
||||
{{ letter }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Stats footer -->
|
||||
<div class="border-t border-obs-l-border dark:border-obs-d-border px-3 py-2">
|
||||
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
{{ totalDisplayedTags() }} tag(s) affiché(s) sur {{ totalTags() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -162,64 +265,199 @@ export class TagsViewComponent {
|
||||
numeric: true,
|
||||
});
|
||||
|
||||
// Inputs/Outputs
|
||||
readonly tags = input.required<TagInfo[]>();
|
||||
readonly tagSelected = output<string>();
|
||||
|
||||
readonly searchTerm = signal('');
|
||||
readonly activeLetter = signal<string | null>(null);
|
||||
// State signals
|
||||
searchQuery = signal('');
|
||||
sortMode = signal<SortMode>('alpha-asc');
|
||||
groupMode = signal<GroupMode>('none');
|
||||
private expandedGroups = signal<Set<string>>(new Set());
|
||||
private userCustomExpansion = signal(false);
|
||||
private lastMode: GroupMode | null = null;
|
||||
|
||||
private readonly sections = computed<TagSection[]>(() => {
|
||||
const rawTerm = this.searchTerm().trim();
|
||||
const normalizedTerm = this.normalize(rawTerm);
|
||||
const allTags = (this.tags() ?? []).slice().sort((a, b) => this.collator.compare(a.name, b.name));
|
||||
// Normalized tags with forward slashes
|
||||
private readonly normalizedTags = computed<TagInfo[]>(() => {
|
||||
return (this.tags() ?? []).map(t => ({
|
||||
...t,
|
||||
name: (t.name ?? '').replace(/\\/g, '/')
|
||||
}));
|
||||
});
|
||||
|
||||
const filtered = rawTerm
|
||||
? allTags.filter((tag) => this.normalize(tag.name).includes(normalizedTerm))
|
||||
: allTags;
|
||||
// Filtered tags based on search query
|
||||
private readonly filteredTags = computed<TagInfo[]>(() => {
|
||||
const query = this.normalize(this.searchQuery().trim());
|
||||
const allTags = this.normalizedTags();
|
||||
|
||||
if (!query) return allTags;
|
||||
|
||||
return allTags.filter(tag =>
|
||||
this.normalize(tag.name).includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Sorted tags based on sort mode
|
||||
private readonly sortedTags = computed<TagInfo[]>(() => {
|
||||
const tags = [...this.filteredTags()];
|
||||
const mode = this.sortMode();
|
||||
|
||||
switch (mode) {
|
||||
case 'alpha-asc':
|
||||
return tags.sort((a, b) => this.collator.compare(a.name, b.name));
|
||||
case 'alpha-desc':
|
||||
return tags.sort((a, b) => this.collator.compare(b.name, a.name));
|
||||
case 'freq-desc':
|
||||
return tags.sort((a, b) => b.count - a.count || this.collator.compare(a.name, b.name));
|
||||
case 'freq-asc':
|
||||
return tags.sort((a, b) => a.count - b.count || this.collator.compare(a.name, b.name));
|
||||
default:
|
||||
return tags;
|
||||
}
|
||||
});
|
||||
|
||||
// Grouped tags based on group mode
|
||||
readonly displayedGroups = computed<TagGroup[]>(() => {
|
||||
const tags = this.sortedTags();
|
||||
const mode = this.groupMode();
|
||||
const expanded = this.expandedGroups();
|
||||
|
||||
if (mode === 'none') {
|
||||
return [{
|
||||
label: 'all',
|
||||
tags,
|
||||
isExpanded: true,
|
||||
level: 0
|
||||
}];
|
||||
}
|
||||
|
||||
if (mode === 'hierarchy') {
|
||||
return this.buildHierarchyGroups(tags, expanded);
|
||||
}
|
||||
|
||||
if (mode === 'alpha') {
|
||||
return this.buildAlphaGroups(tags, expanded);
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
// Stats
|
||||
readonly totalTags = computed(() => this.normalizedTags().length);
|
||||
readonly totalDisplayedTags = computed(() => this.filteredTags().length);
|
||||
|
||||
constructor() {
|
||||
// Auto-expand groups on mode change; don't override user toggles
|
||||
effect(() => {
|
||||
const mode = this.groupMode();
|
||||
const labels = this.getCurrentGroupLabels();
|
||||
|
||||
// If switched mode, reset custom flag and initialize expansion
|
||||
if (this.lastMode !== mode) {
|
||||
this.userCustomExpansion.set(false);
|
||||
this.lastMode = mode;
|
||||
}
|
||||
|
||||
if (mode === 'none') {
|
||||
if (this.expandedGroups().size > 0) this.expandedGroups.set(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize only when user hasn't interacted yet or when current set mismatches and user hasn't customized
|
||||
const current = this.expandedGroups();
|
||||
const needsInit = current.size === 0 || current.size !== labels.length || labels.some(l => !current.has(l));
|
||||
if (!this.userCustomExpansion() && needsInit) {
|
||||
this.expandedGroups.set(new Set(labels));
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
// Compute current group labels from mode + sorted tags
|
||||
private getCurrentGroupLabels(): string[] {
|
||||
const mode = this.groupMode();
|
||||
const tags = this.sortedTags();
|
||||
if (mode === 'none') return [];
|
||||
if (mode === 'hierarchy') {
|
||||
const roots = new Set<string>();
|
||||
for (const t of tags) {
|
||||
const root = (t.name?.split('/')?.[0] || t.name) ?? '';
|
||||
if (root) roots.add(root);
|
||||
}
|
||||
return Array.from(roots).sort((a, b) => this.collator.compare(a, b));
|
||||
}
|
||||
// alpha
|
||||
const letters = new Set<string>();
|
||||
for (const t of tags) letters.add(this.extractLetter(t.name));
|
||||
return Array.from(letters).sort((a, b) => this.sortLetters(a, b));
|
||||
}
|
||||
|
||||
// Build hierarchy groups (e.g., docker/automation, docker/test -> docker group)
|
||||
private buildHierarchyGroups(tags: TagInfo[], expanded: Set<string>): TagGroup[] {
|
||||
const grouped = new Map<string, TagInfo[]>();
|
||||
for (const tag of filtered) {
|
||||
|
||||
for (const tag of tags) {
|
||||
const parts = tag.name.split('/');
|
||||
const root = parts[0] || tag.name;
|
||||
|
||||
if (!grouped.has(root)) {
|
||||
grouped.set(root, []);
|
||||
}
|
||||
grouped.get(root)!.push(tag);
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.sort((a, b) => this.collator.compare(a[0], b[0]))
|
||||
.map(([label, tags]) => ({
|
||||
label,
|
||||
tags: tags.sort((a, b) => this.collator.compare(a.name, b.name)),
|
||||
isExpanded: expanded.has(label),
|
||||
level: 0
|
||||
}));
|
||||
}
|
||||
|
||||
// Build alphabetical groups (A, B, C, etc.)
|
||||
private buildAlphaGroups(tags: TagInfo[], expanded: Set<string>): TagGroup[] {
|
||||
const grouped = new Map<string, TagInfo[]>();
|
||||
|
||||
for (const tag of tags) {
|
||||
const letter = this.extractLetter(tag.name);
|
||||
|
||||
if (!grouped.has(letter)) {
|
||||
grouped.set(letter, []);
|
||||
}
|
||||
grouped.get(letter)!.push(tag);
|
||||
}
|
||||
|
||||
const sortedSections = Array.from(grouped.entries())
|
||||
return Array.from(grouped.entries())
|
||||
.sort((a, b) => this.sortLetters(a[0], b[0]))
|
||||
.map(([letter, tags]) => ({
|
||||
letter,
|
||||
tags: tags.slice().sort((a, b) => this.collator.compare(a.name, b.name)),
|
||||
.map(([label, tags]) => ({
|
||||
label,
|
||||
tags,
|
||||
isExpanded: expanded.has(label),
|
||||
level: 0
|
||||
}));
|
||||
}
|
||||
|
||||
return sortedSections;
|
||||
});
|
||||
private extractLetter(name: string): string {
|
||||
const normalized = this.normalize(name).toUpperCase();
|
||||
const char = normalized.charAt(0);
|
||||
|
||||
if (!char) return '#';
|
||||
if (/[A-Z]/.test(char)) return char;
|
||||
if (/[0-9]/.test(char)) return '#';
|
||||
|
||||
return char;
|
||||
}
|
||||
|
||||
readonly availableLetters = computed(() => this.sections().map((section) => section.letter));
|
||||
|
||||
readonly displayedSections = computed(() => {
|
||||
const active = this.activeLetter();
|
||||
if (!active) {
|
||||
return this.sections();
|
||||
}
|
||||
return this.sections().filter((section) => section.letter === active);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const letters = this.availableLetters();
|
||||
const active = this.activeLetter();
|
||||
if (active && !letters.includes(active)) {
|
||||
this.resetLetterState();
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.searchTerm().trim()) {
|
||||
this.resetLetterState();
|
||||
}
|
||||
});
|
||||
private sortLetters(a: string, b: string): number {
|
||||
const rank = (value: string): number => {
|
||||
if (value === '#') return 0;
|
||||
if (/^[A-Z]$/.test(value)) return value.charCodeAt(0) - 64;
|
||||
return 100 + value.charCodeAt(0);
|
||||
};
|
||||
|
||||
const diff = rank(a) - rank(b);
|
||||
return diff !== 0 ? diff : this.collator.compare(a, b);
|
||||
}
|
||||
|
||||
private normalize(value: string): string {
|
||||
@@ -229,58 +467,80 @@ export class TagsViewComponent {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
private extractLetter(name: string): string {
|
||||
const normalized = this.normalize(name).toUpperCase();
|
||||
const char = normalized.charAt(0);
|
||||
if (!char) {
|
||||
return '#';
|
||||
}
|
||||
if (/[A-Z]/.test(char)) {
|
||||
return char;
|
||||
}
|
||||
if (/[0-9]/.test(char)) {
|
||||
return '#';
|
||||
}
|
||||
return char;
|
||||
}
|
||||
|
||||
private sortLetters(a: string, b: string): number {
|
||||
const rank = (value: string): number => {
|
||||
if (value === '#') {
|
||||
return 0;
|
||||
/**
|
||||
* Display helper: remove the group prefix from tag name
|
||||
* Example: group="docker", name="docker/automation" => "automation"
|
||||
*/
|
||||
displayTagName(groupLabel: string, tagName: string): string {
|
||||
const mode = this.groupMode();
|
||||
|
||||
// In hierarchy mode, remove the root prefix
|
||||
if (mode === 'hierarchy') {
|
||||
const full = String(tagName ?? '');
|
||||
const prefix = String(groupLabel ?? '');
|
||||
|
||||
if (!prefix || full === prefix) return full;
|
||||
if (full.startsWith(prefix + '/')) {
|
||||
return full.slice(prefix.length + 1);
|
||||
}
|
||||
if (/^[A-Z]$/.test(value)) {
|
||||
return value.charCodeAt(0) - 64;
|
||||
}
|
||||
|
||||
return tagName;
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
onSearchChange(): void {
|
||||
// Search query is bound via ngModel, no action needed
|
||||
}
|
||||
|
||||
onSortChange(): void {
|
||||
// Sort mode is bound via ngModel, computed signals will update
|
||||
}
|
||||
|
||||
onGroupModeChange(): void {
|
||||
// Group mode is bound via ngModel, computed signals will update
|
||||
}
|
||||
|
||||
toggleGroup(label: string): void {
|
||||
this.expandedGroups.update(groups => {
|
||||
const newGroups = new Set(groups);
|
||||
if (newGroups.has(label)) {
|
||||
newGroups.delete(label);
|
||||
} else {
|
||||
newGroups.add(label);
|
||||
}
|
||||
return 100 + value.charCodeAt(0);
|
||||
};
|
||||
const diff = rank(a) - rank(b);
|
||||
return diff !== 0 ? diff : this.collator.compare(a, b);
|
||||
return newGroups;
|
||||
});
|
||||
this.userCustomExpansion.set(true);
|
||||
}
|
||||
|
||||
onSearchChange(raw: string): void {
|
||||
this.searchTerm.set(raw);
|
||||
if (raw.trim()) {
|
||||
this.activeLetter.set(null);
|
||||
onTagClick(tagName: string): void {
|
||||
this.tagSelected.emit(tagName);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.searchQuery.set('');
|
||||
this.sortMode.set('alpha-asc');
|
||||
this.groupMode.set('none');
|
||||
this.userCustomExpansion.set(false);
|
||||
}
|
||||
|
||||
// Expand all toggle for grouped modes
|
||||
allExpanded = computed(() => {
|
||||
if (this.groupMode() === 'none') return false;
|
||||
const labels = this.getCurrentGroupLabels();
|
||||
const current = this.expandedGroups();
|
||||
return labels.length > 0 && labels.every(l => current.has(l));
|
||||
});
|
||||
|
||||
toggleExpandAll(): void {
|
||||
if (this.groupMode() === 'none') return;
|
||||
const labels = this.getCurrentGroupLabels();
|
||||
if (this.allExpanded()) {
|
||||
this.expandedGroups.set(new Set());
|
||||
} else {
|
||||
this.expandedGroups.set(new Set(labels));
|
||||
}
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchTerm.set('');
|
||||
this.resetLetterState();
|
||||
}
|
||||
|
||||
onLetterClick(letter: string): void {
|
||||
const active = this.activeLetter();
|
||||
if (active === letter) {
|
||||
this.resetLetterState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeLetter.set(letter);
|
||||
}
|
||||
|
||||
private resetLetterState(): void {
|
||||
this.activeLetter.set(null);
|
||||
this.userCustomExpansion.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user