chore: update Angular cache and TypeScript build info
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" (click)="onBackdropClick($event)">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full p-6" (click)="$event.stopPropagation()">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ isEditMode() ? 'Edit bookmark' : 'Add bookmark' }}
|
||||
</h2>
|
||||
<button
|
||||
(click)="onCancel()"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="space-y-4">
|
||||
<!-- Path -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="path()"
|
||||
(input)="onPathChange($any($event.target).value)"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. Tests/Allo note.md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="title()"
|
||||
(input)="onTitleChange($any($event.target).value)"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Display name (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bookmark group -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Bookmark group
|
||||
</label>
|
||||
<select
|
||||
[value]="selectedGroupCtime() || ''"
|
||||
(change)="onGroupChange($any($event.target).value)"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Root (no group)</option>
|
||||
@for (group of groups(); track group.ctime) {
|
||||
<option [value]="group.ctime">{{ group.title }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-3 mt-6">
|
||||
<button
|
||||
(click)="onCancel()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="onSave()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
:host-context(.dark) select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Add Bookmark Modal Component
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BookmarksService } from '../../core/bookmarks/bookmarks.service';
|
||||
import type { BookmarkGroup } from '../../core/bookmarks/types';
|
||||
|
||||
export interface BookmarkFormData {
|
||||
path: string;
|
||||
title: string;
|
||||
groupCtime: number | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-bookmark-modal',
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './add-bookmark-modal.component.html',
|
||||
styleUrls: ['./add-bookmark-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AddBookmarkModalComponent {
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
|
||||
@Input() notePath: string = '';
|
||||
@Input() noteTitle: string = '';
|
||||
@Input() existingBookmark: { ctime: number; title?: string; groupCtime?: number } | null = null;
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() save = new EventEmitter<BookmarkFormData>();
|
||||
|
||||
readonly path = signal('');
|
||||
readonly title = signal('');
|
||||
readonly selectedGroupCtime = signal<number | null>(null);
|
||||
|
||||
readonly groups = computed(() => {
|
||||
const doc = this.bookmarksService.doc();
|
||||
const result: Array<{ ctime: number; title: string }> = [];
|
||||
|
||||
const collectGroups = (items: any[], level = 0) => {
|
||||
for (const item of items) {
|
||||
if (item.type === 'group') {
|
||||
result.push({
|
||||
ctime: item.ctime,
|
||||
title: ' '.repeat(level) + (item.title || 'Untitled Group'),
|
||||
});
|
||||
if (item.items) {
|
||||
collectGroups(item.items, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collectGroups(doc.items);
|
||||
return result;
|
||||
});
|
||||
|
||||
readonly isEditMode = computed(() => this.existingBookmark !== null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.path.set(this.notePath);
|
||||
this.title.set(this.noteTitle);
|
||||
|
||||
if (this.existingBookmark) {
|
||||
this.title.set(this.existingBookmark.title || this.noteTitle);
|
||||
this.selectedGroupCtime.set(this.existingBookmark.groupCtime || null);
|
||||
}
|
||||
}
|
||||
|
||||
onPathChange(value: string): void {
|
||||
this.path.set(value);
|
||||
}
|
||||
|
||||
onTitleChange(value: string): void {
|
||||
this.title.set(value);
|
||||
}
|
||||
|
||||
onGroupChange(value: string): void {
|
||||
const ctime = value ? parseInt(value, 10) : null;
|
||||
this.selectedGroupCtime.set(ctime);
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
const pathValue = this.path().trim();
|
||||
const titleValue = this.title().trim();
|
||||
|
||||
if (!pathValue) {
|
||||
alert('Path is required');
|
||||
return;
|
||||
}
|
||||
|
||||
this.save.emit({
|
||||
path: pathValue,
|
||||
title: titleValue || pathValue,
|
||||
groupCtime: this.selectedGroupCtime(),
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
onBackdropClick(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget) {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<div class="bookmark-node">
|
||||
<div
|
||||
class="relative flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md cursor-pointer transition-colors"
|
||||
[style.padding-left]="indentStyle"
|
||||
(click)="onClick($event)"
|
||||
(contextmenu)="onContextMenu($event)"
|
||||
[class.bg-gray-50]="isGroup"
|
||||
[class.dark:bg-gray-800/50]="isGroup">
|
||||
|
||||
<!-- Icon -->
|
||||
<span class="text-base select-none" [class.cursor-pointer]="isGroup">
|
||||
{{ icon }}
|
||||
</span>
|
||||
|
||||
<!-- Text -->
|
||||
<span
|
||||
class="flex-1 text-sm text-gray-900 dark:text-gray-100 truncate"
|
||||
[title]="displayText">
|
||||
{{ displayText }}
|
||||
</span>
|
||||
|
||||
@if (isGroup) {
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
title="Ajouter un favori dans ce groupe"
|
||||
(click)="onAddBookmark($event)">
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||
title="Supprimer ce groupe"
|
||||
(click)="onDelete($event)">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Badge for group count -->
|
||||
@if (isGroup && hasChildren) {
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 bg-gray-200 dark:bg-gray-700 px-2 py-0.5 rounded-full">
|
||||
{{ children.length }}
|
||||
</span>
|
||||
}
|
||||
|
||||
<!-- Context Menu Button -->
|
||||
<button
|
||||
class="opacity-0 hover:opacity-100 focus:opacity-100 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded transition-opacity"
|
||||
(click)="onContextMenu($event); $event.stopPropagation()"
|
||||
title="More options">
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<!-- Context Menu -->
|
||||
@if (showMenu()) {
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 z-10 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg"
|
||||
(click)="$event.stopPropagation()">
|
||||
@if (isGroup) {
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
(click)="onAddBookmark()">
|
||||
Add Bookmark Here
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
(click)="onAddGroup()">
|
||||
Add Subgroup
|
||||
</button>
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
(click)="onDelete()">
|
||||
Delete Group
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
(click)="onEdit()">
|
||||
Edit
|
||||
</button>
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
<button
|
||||
class="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
(click)="onDelete()">
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Children -->
|
||||
@if (isGroup && isExpanded()) {
|
||||
<div
|
||||
class="ml-6 border-l border-gray-200 dark:border-gray-700 pl-2 min-h-[40px]"
|
||||
cdkDropList
|
||||
[cdkDropListData]="children"
|
||||
[cdkDropListConnectedTo]="getDropListConnections()"
|
||||
[cdkDropListDisabled]="dragDisabled"
|
||||
[cdkDropListSortingDisabled]="false"
|
||||
[cdkDropListId]="dropListId"
|
||||
cdkDropListOrientation="vertical"
|
||||
(cdkDropListDropped)="onChildDrop($event)">
|
||||
@for (child of children; track trackByCtime($index, child)) {
|
||||
<app-bookmark-item
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="dragDisabled"
|
||||
[cdkDragData]="{ ctime: child.ctime, parentCtime: bookmark.ctime }"
|
||||
[node]="child"
|
||||
[level]="level + 1"
|
||||
[dragDisabled]="dragDisabled"
|
||||
[dropListIds]="dropListIds"
|
||||
(bookmarkClick)="bookmarkClick.emit($event)"
|
||||
class="mt-1" />
|
||||
}
|
||||
@if (children.length === 0) {
|
||||
<div class="py-2 px-3 text-xs text-gray-400 dark:text-gray-500 italic">
|
||||
Drop items here
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close menu -->
|
||||
@if (showMenu()) {
|
||||
<div
|
||||
class="fixed inset-0 z-0"
|
||||
(click)="closeMenu()">
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.bookmark-item {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
|
||||
&:hover .context-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-item:active {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
:host-context(.dark) .bookmark-item:active {
|
||||
background-color: #374151;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Bookmark Item Component - Displays a single bookmark in the tree
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
inject,
|
||||
signal,
|
||||
forwardRef,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
import type { BookmarkNode, BookmarkGroup } from '../../core/bookmarks/types';
|
||||
import { BookmarksService } from '../../core/bookmarks/bookmarks.service';
|
||||
import { BookmarksPanelComponent } from '../bookmarks-panel/bookmarks-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmark-item',
|
||||
imports: [CommonModule, DragDropModule, forwardRef(() => BookmarkItemComponent)],
|
||||
templateUrl: './bookmark-item.component.html',
|
||||
styleUrls: ['./bookmark-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BookmarkItemComponent {
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
private readonly panel = inject(BookmarksPanelComponent, { optional: true });
|
||||
|
||||
@Input({ required: true }) node!: BookmarkNode;
|
||||
@Input() level = 0;
|
||||
@Input() dragDisabled = false;
|
||||
@Input() dropListIds: string[] = [];
|
||||
|
||||
@Output() bookmarkClick = new EventEmitter<BookmarkNode>();
|
||||
|
||||
readonly showMenu = signal(false);
|
||||
readonly isExpanded = signal(true);
|
||||
|
||||
get bookmark(): BookmarkNode {
|
||||
return this.node;
|
||||
}
|
||||
|
||||
get indentStyle(): string {
|
||||
return `${this.level * 20}px`;
|
||||
}
|
||||
|
||||
get icon(): string {
|
||||
switch (this.bookmark.type) {
|
||||
case 'group':
|
||||
return this.isExpanded() ? '📂' : '📁';
|
||||
case 'file':
|
||||
return '📄';
|
||||
case 'folder':
|
||||
return '📁';
|
||||
case 'search':
|
||||
return '🔍';
|
||||
case 'heading':
|
||||
return '📌';
|
||||
case 'block':
|
||||
return '🔗';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
get displayText(): string {
|
||||
const node = this.bookmark;
|
||||
if (node.title) {
|
||||
return node.title;
|
||||
}
|
||||
|
||||
if (node.type === 'file' || node.type === 'folder') {
|
||||
return node.path;
|
||||
}
|
||||
|
||||
if (node.type === 'search') {
|
||||
return node.query;
|
||||
}
|
||||
|
||||
if (node.type === 'heading' || node.type === 'block') {
|
||||
return `${node.path} > ${node.subpath}`;
|
||||
}
|
||||
|
||||
return 'Untitled';
|
||||
}
|
||||
|
||||
get isGroup(): boolean {
|
||||
return this.bookmark.type === 'group';
|
||||
}
|
||||
|
||||
get hasChildren(): boolean {
|
||||
return this.isGroup && (this.bookmark as BookmarkGroup).items?.length > 0;
|
||||
}
|
||||
|
||||
get children(): BookmarkNode[] {
|
||||
if (!this.isGroup) {
|
||||
return [];
|
||||
}
|
||||
return (this.bookmark as BookmarkGroup).items;
|
||||
}
|
||||
|
||||
trackByCtime(index: number, item: BookmarkNode): number {
|
||||
return item.ctime ?? index;
|
||||
}
|
||||
|
||||
get dropListId(): string {
|
||||
return `group-${this.bookmark.ctime}`;
|
||||
}
|
||||
|
||||
getDropListConnections(): string[] {
|
||||
return this.dropListIds.filter(id => id !== this.dropListId);
|
||||
}
|
||||
|
||||
onChildDrop(event: CdkDragDrop<BookmarkNode[]>): void {
|
||||
if (this.dragDisabled || !this.isGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.item.data as { ctime: number; parentCtime: number | null } | undefined;
|
||||
if (!data || typeof data.ctime !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.ctime === this.bookmark.ctime) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookmarksService.moveBookmark(data.ctime, this.bookmark.ctime, event.currentIndex);
|
||||
}
|
||||
|
||||
toggleExpand(): void {
|
||||
if (this.isGroup) {
|
||||
this.isExpanded.update(v => !v);
|
||||
}
|
||||
}
|
||||
|
||||
onClick(event: MouseEvent): void {
|
||||
if (this.isGroup) {
|
||||
this.toggleExpand();
|
||||
} else {
|
||||
this.bookmarkClick.emit(this.bookmark);
|
||||
}
|
||||
}
|
||||
|
||||
onContextMenu(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.showMenu.set(true);
|
||||
}
|
||||
|
||||
closeMenu(): void {
|
||||
this.showMenu.set(false);
|
||||
}
|
||||
|
||||
onEdit(): void {
|
||||
this.closeMenu();
|
||||
// TODO: Open edit modal
|
||||
console.log('Edit bookmark:', this.bookmark);
|
||||
}
|
||||
|
||||
onDelete(event?: MouseEvent): void {
|
||||
event?.stopPropagation();
|
||||
this.closeMenu();
|
||||
if (this.isGroup) {
|
||||
this.deleteGroup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Delete "${this.displayText}"?`)) {
|
||||
this.bookmarksService.deleteBookmark(this.bookmark.ctime);
|
||||
}
|
||||
}
|
||||
|
||||
onAddGroup(event?: MouseEvent): void {
|
||||
event?.stopPropagation();
|
||||
if (!this.isGroup) return;
|
||||
this.closeMenu();
|
||||
this.panel?.createGroup(this.bookmark.ctime);
|
||||
}
|
||||
|
||||
onAddBookmark(event?: MouseEvent): void {
|
||||
event?.stopPropagation();
|
||||
if (!this.isGroup) return;
|
||||
this.closeMenu();
|
||||
if (this.panel) {
|
||||
this.panel.startCreateBookmarkInGroup(this.bookmark.ctime);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteGroup(): void {
|
||||
if (!this.isGroup) return;
|
||||
if (this.panel) {
|
||||
this.panel.deleteGroup(this.bookmark.ctime);
|
||||
} else if (confirm(`Delete group "${this.displayText}" and all its items?`)) {
|
||||
this.bookmarksService.deleteBookmark(this.bookmark.ctime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<div class="bookmarks-panel flex flex-col h-full bg-white dark:bg-gray-900">
|
||||
<!-- Header -->
|
||||
<div class="bookmarks-header flex-shrink-0 p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Bookmarks</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
(click)="createGroup()"
|
||||
title="Créer un groupe">
|
||||
+ Group
|
||||
</button>
|
||||
@if (saving()) {
|
||||
<span class="text-xs text-blue-600 dark:text-blue-400 animate-pulse">Saving...</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
[value]="searchTerm()"
|
||||
(input)="onSearchChange($any($event.target).value)"
|
||||
placeholder="Search bookmarks..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
@if (searchTerm()) {
|
||||
<button
|
||||
(click)="onSearchChange('')"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Clear search">
|
||||
✕
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="bookmarks-body flex-1 overflow-y-auto p-4">
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-red-600 dark:text-red-400 font-semibold">Error:</span>
|
||||
<span class="text-sm text-red-700 dark:text-red-300 flex-1">{{ error() }}</span>
|
||||
<button
|
||||
(click)="clearError()"
|
||||
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
||||
title="Dismiss">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (isEmpty()) {
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p class="mb-4">No bookmarks yet</p>
|
||||
<p class="text-sm">Use the bookmark icon in the note toolbar to add one.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bookmarks-tree mt-4 border-2 border-dashed border-blue-500/60 dark:border-blue-400/60 bg-blue-500/10 dark:bg-blue-400/10 text-blue-600 dark:text-blue-300 rounded-md min-h-[80px] flex items-center justify-center"
|
||||
cdkDropList
|
||||
[cdkDropListData]="displayItems()"
|
||||
cdkDropListId="root"
|
||||
[cdkDropListConnectedTo]="getDropListConnections('root')"
|
||||
cdkDropListOrientation="vertical"
|
||||
[cdkDropListDisabled]="dragDisabled"
|
||||
(cdkDropListDropped)="handleRootDrop($event)">
|
||||
<span class="text-sm font-medium">Drop items here</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="bookmarks-tree"
|
||||
cdkDropList
|
||||
[cdkDropListData]="displayItems()"
|
||||
cdkDropListId="root"
|
||||
[cdkDropListConnectedTo]="getDropListConnections('root')"
|
||||
cdkDropListOrientation="vertical"
|
||||
[cdkDropListDisabled]="dragDisabled"
|
||||
(cdkDropListDropped)="handleRootDrop($event)">
|
||||
@if (!dragDisabled) {
|
||||
<div class="mb-2 rounded-md border border-dashed border-blue-500/40 dark:border-blue-400/40 bg-blue-500/5 dark:bg-blue-400/5 px-3 py-2 text-xs font-medium text-blue-600 dark:text-blue-300 text-center">
|
||||
Drop here to move to root
|
||||
</div>
|
||||
}
|
||||
@for (node of displayItems(); track node.ctime) {
|
||||
<app-bookmark-item
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="dragDisabled"
|
||||
[cdkDragData]="{ ctime: node.ctime, parentCtime: null }"
|
||||
[node]="node"
|
||||
[level]="0"
|
||||
[dragDisabled]="dragDisabled"
|
||||
[dropListIds]="dropListIds()"
|
||||
(bookmarkClick)="onBookmarkClick($event)"
|
||||
class="mb-1" />
|
||||
}
|
||||
@if (displayItems().length === 0) {
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 py-4 text-center">No bookmarks to display.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Conflict Modal -->
|
||||
@if (conflictInfo()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Conflict Detected</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
The bookmarks file has been modified externally. What would you like to do?
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
(click)="resolveConflictReload()"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors">
|
||||
Reload from file
|
||||
</button>
|
||||
<button
|
||||
(click)="resolveConflictOverwrite()"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors">
|
||||
Overwrite file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Modals placeholders - will be implemented separately -->
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
.bookmarks-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bookmarks-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
.bookmarks-body {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark mode */
|
||||
.bookmarks-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.bookmarks-body::-webkit-scrollbar-track {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
:host-context(.dark) .bookmarks-body::-webkit-scrollbar-track {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.bookmarks-body::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
:host-context(.dark) .bookmarks-body::-webkit-scrollbar-thumb {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.bookmarks-body::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
:host-context(.dark) .bookmarks-body::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #6b7280;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Bookmarks Panel Component - Main UI for managing bookmarks
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
Output,
|
||||
EventEmitter,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DragDropModule, CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||
import type { BookmarkNode } from '../../core/bookmarks/types';
|
||||
import { BookmarksService } from '../../core/bookmarks/bookmarks.service';
|
||||
import { BookmarkItemComponent } from '../bookmark-item/bookmark-item.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks-panel',
|
||||
imports: [CommonModule, FormsModule, DragDropModule, BookmarkItemComponent],
|
||||
templateUrl: './bookmarks-panel.component.html',
|
||||
styleUrls: ['./bookmarks-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BookmarksPanelComponent {
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
|
||||
@Output() bookmarkClick = new EventEmitter<BookmarkNode>();
|
||||
|
||||
// Local state
|
||||
readonly searchTerm = signal('');
|
||||
|
||||
// Service state
|
||||
readonly doc = this.bookmarksService.doc;
|
||||
readonly filteredDoc = this.bookmarksService.filteredDoc;
|
||||
readonly saving = this.bookmarksService.saving;
|
||||
readonly loading = this.bookmarksService.loading;
|
||||
readonly error = this.bookmarksService.error;
|
||||
readonly conflictInfo = this.bookmarksService.conflictInfo;
|
||||
|
||||
readonly displayDoc = computed(() =>
|
||||
this.searchTerm().trim() ? this.filteredDoc() : this.doc()
|
||||
);
|
||||
|
||||
readonly displayItems = computed(() => this.displayDoc().items ?? []);
|
||||
|
||||
readonly dragDisabledSignal = computed(() => this.searchTerm().trim().length > 0);
|
||||
readonly isEmpty = computed(() => this.displayItems().length === 0);
|
||||
|
||||
readonly dropListIds = computed(() => {
|
||||
const ids: string[] = ['root'];
|
||||
|
||||
const collect = (items: BookmarkNode[]) => {
|
||||
for (const item of items) {
|
||||
if (item.type === 'group') {
|
||||
ids.push(`group-${item.ctime}`);
|
||||
if (item.items?.length) {
|
||||
collect(item.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collect(this.displayItems());
|
||||
return ids;
|
||||
});
|
||||
|
||||
readonly trackNode = (index: number, node: BookmarkNode) => node.ctime ?? index;
|
||||
onSearchChange(term: string): void {
|
||||
this.searchTerm.set(term);
|
||||
this.bookmarksService.setFilterTerm(term);
|
||||
}
|
||||
|
||||
clearError(): void {
|
||||
this.bookmarksService.clearError();
|
||||
}
|
||||
|
||||
get dragDisabled(): boolean {
|
||||
return this.dragDisabledSignal();
|
||||
}
|
||||
|
||||
getDropListConnections(id: string): string[] {
|
||||
return this.dropListIds().filter(existingId => existingId !== id);
|
||||
}
|
||||
|
||||
createGroup(parentCtime: number | null = null): void {
|
||||
const title = window.prompt('Nom du groupe');
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookmarksService.createGroup(title.trim(), parentCtime ?? null);
|
||||
}
|
||||
|
||||
deleteGroup(ctime: number): void {
|
||||
if (!window.confirm('Supprimer ce groupe et tous ses favoris ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookmarksService.deleteBookmark(ctime);
|
||||
}
|
||||
|
||||
startCreateBookmarkInGroup(parentCtime: number): void {
|
||||
const path = window.prompt('Chemin du fichier (ex: Notes/note.md)');
|
||||
if (!path || !path.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = window.prompt('Titre (optionnel)')?.trim();
|
||||
this.bookmarksService.createFileBookmark(path.trim(), title || undefined, parentCtime);
|
||||
}
|
||||
|
||||
onBookmarkClick(bookmark: BookmarkNode): void {
|
||||
this.bookmarkClick.emit(bookmark);
|
||||
}
|
||||
|
||||
handleDrop(event: CdkDragDrop<BookmarkNode[]>, parentCtime: number | null): void {
|
||||
if (this.dragDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.item.data as { ctime: number; parentCtime: number | null } | undefined;
|
||||
if (!data || typeof data.ctime !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if dropping into itself
|
||||
if (parentCtime === data.ctime) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bookmarksService.moveBookmark(data.ctime, parentCtime, event.currentIndex);
|
||||
}
|
||||
|
||||
handleRootDrop(event: CdkDragDrop<BookmarkNode[]>): void {
|
||||
this.handleDrop(event, null);
|
||||
}
|
||||
|
||||
async resolveConflictReload(): Promise<void> {
|
||||
await this.bookmarksService.resolveConflictReload();
|
||||
}
|
||||
|
||||
async resolveConflictOverwrite(): Promise<void> {
|
||||
await this.bookmarksService.resolveConflictOverwrite();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user