feat: add folder management endpoints and CORS support
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
Output,
|
||||
Renderer2,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
type CtxAction =
|
||||
| 'create-subfolder'
|
||||
| 'rename'
|
||||
| 'duplicate'
|
||||
| 'create-page'
|
||||
| 'copy-link'
|
||||
| 'delete-folder'
|
||||
| 'delete-all';
|
||||
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
:host { position: fixed; inset: 0; pointer-events: none; }
|
||||
.ctx {
|
||||
pointer-events: auto;
|
||||
min-width: 14rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.25rem 0.25rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.25);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: fadeIn .12s ease-out;
|
||||
transform-origin: top left;
|
||||
user-select: none;
|
||||
/* Theme-aware background and border */
|
||||
background: var(--card, #ffffff);
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
color: var(--fg, #111827);
|
||||
}
|
||||
.item {
|
||||
display: block; width: 100%;
|
||||
text-align: left;
|
||||
padding: .5rem .75rem;
|
||||
border-radius: .5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background-color 0.08s ease;
|
||||
color: var(--text-main, #111827);
|
||||
}
|
||||
.item:hover {
|
||||
background: color-mix(in oklab, var(--surface-1, #f8fafc) 90%, black 0%);
|
||||
}
|
||||
.item:active {
|
||||
background: color-mix(in oklab, var(--surface-2, #eef2f7) 85%, black 0%);
|
||||
}
|
||||
.item.danger { color: var(--danger, #ef4444); }
|
||||
.item.warning { color: var(--warning, #f59e0b); }
|
||||
.sep {
|
||||
border-top: 1px solid var(--border, #e5e7eb);
|
||||
margin:.25rem 0;
|
||||
}
|
||||
.row {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-around;
|
||||
gap:.5rem;
|
||||
padding:.5rem .5rem;
|
||||
}
|
||||
.dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: transform .08s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.dot:hover {
|
||||
transform: scale(1.15);
|
||||
outline: 2px solid color-mix(in oklab, var(--canvas, #ffffff) 70%, var(--fg, #111827) 15%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity:0; transform: scale(.95);} to { opacity:1; transform: scale(1);} }
|
||||
`],
|
||||
template: `
|
||||
<ng-container *ngIf="visible">
|
||||
<!-- Backdrop pour capter les clics extérieurs -->
|
||||
<div class="fixed inset-0" (click)="close()" aria-hidden="true"></div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div
|
||||
#menu
|
||||
class="ctx"
|
||||
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed' }"
|
||||
role="menu"
|
||||
(contextmenu)="$event.preventDefault()"
|
||||
>
|
||||
<button class="item" (click)="emitAction('create-subfolder')">
|
||||
<span class="inline-block mr-2">📁</span>Create subfolder
|
||||
</button>
|
||||
<button class="item" (click)="emitAction('rename')">
|
||||
<span class="inline-block mr-2">✏️</span>Rename
|
||||
</button>
|
||||
<button class="item" (click)="emitAction('duplicate')">
|
||||
<span class="inline-block mr-2">📋</span>Duplicate
|
||||
</button>
|
||||
<button class="item" (click)="emitAction('create-page')">
|
||||
<span class="inline-block mr-2">📄</span>Create new page
|
||||
</button>
|
||||
<button class="item" (click)="emitAction('copy-link')">
|
||||
<span class="inline-block mr-2">🔗</span>Copy internal link
|
||||
</button>
|
||||
|
||||
<div class="sep"></div>
|
||||
|
||||
<button class="item danger" (click)="emitAction('delete-folder')">
|
||||
<span class="inline-block mr-2">🗑️</span>Delete folder
|
||||
</button>
|
||||
<button class="item warning" (click)="emitAction('delete-all')">
|
||||
<span class="inline-block mr-2">⚠️</span>Delete all pages in folder
|
||||
</button>
|
||||
|
||||
<div class="sep"></div>
|
||||
|
||||
<div class="row">
|
||||
<div *ngFor="let c of colors"
|
||||
class="dot"
|
||||
[style.background]="c"
|
||||
(click)="emitColor(c)"
|
||||
[attr.aria-label]="'color ' + c"
|
||||
role="button"
|
||||
title="Set folder color"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
`,
|
||||
})
|
||||
export class ContextMenuComponent implements OnChanges, OnDestroy {
|
||||
/** Position demandée (pixels viewport) */
|
||||
@Input() x = 0;
|
||||
@Input() y = 0;
|
||||
/** Contrôle d'affichage */
|
||||
@Input() visible = false;
|
||||
|
||||
/** Actions/retours */
|
||||
@Output() action = new EventEmitter<CtxAction>();
|
||||
@Output() color = new EventEmitter<string>();
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
|
||||
/** Palette 8 couleurs */
|
||||
colors = ['#0ea5e9','#3b82f6','#22c55e','#eab308','#f97316','#ef4444','#a855f7','#64748b'];
|
||||
|
||||
/** Position corrigée (anti overflow) */
|
||||
left = 0;
|
||||
top = 0;
|
||||
|
||||
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
|
||||
|
||||
private removeResize?: () => void;
|
||||
private removeScroll?: () => void;
|
||||
|
||||
constructor(private r2: Renderer2, private host: ElementRef<HTMLElement>) {
|
||||
// listeners globaux qui ferment le menu
|
||||
this.removeResize = this.r2.listen('window', 'resize', () => this.reposition());
|
||||
this.removeScroll = this.r2.listen('window', 'scroll', () => this.reposition());
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['visible'] && this.visible) {
|
||||
// Laisse le DOM peindre le menu, puis calcule les bornes
|
||||
queueMicrotask(() => this.reposition());
|
||||
}
|
||||
if ((changes['x'] || changes['y']) && this.visible) {
|
||||
queueMicrotask(() => this.reposition());
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.removeResize?.();
|
||||
this.removeScroll?.();
|
||||
}
|
||||
|
||||
/** Ferme le menu */
|
||||
close() {
|
||||
if (!this.visible) return;
|
||||
this.visible = false;
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
emitAction(a: CtxAction) {
|
||||
this.action.emit(a);
|
||||
this.close();
|
||||
}
|
||||
|
||||
emitColor(c: string) {
|
||||
this.color.emit(c);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/** Corrige la position si le menu sortirait du viewport */
|
||||
private reposition() {
|
||||
const el = this.menuRef?.nativeElement;
|
||||
if (!el) { this.left = this.x; this.top = this.y; return; }
|
||||
|
||||
const menuRect = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let left = this.x;
|
||||
let top = this.y;
|
||||
|
||||
if (left + menuRect.width > vw - 8) left = Math.max(8, vw - menuRect.width - 8);
|
||||
if (top + menuRect.height > vh - 8) top = Math.max(8, vh - menuRect.height - 8);
|
||||
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
}
|
||||
|
||||
/** Fermer avec ESC */
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') this.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Context Menu Configuration
|
||||
* Defines all available actions and color palette for folder context menu
|
||||
*/
|
||||
|
||||
export type ContextMenuAction =
|
||||
| 'create-subfolder'
|
||||
| 'rename'
|
||||
| 'duplicate'
|
||||
| 'create-page'
|
||||
| 'copy-link'
|
||||
| 'delete-folder'
|
||||
| 'delete-all';
|
||||
|
||||
export interface ContextMenuActionConfig {
|
||||
id: ContextMenuAction;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
isDangerous: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuColorConfig {
|
||||
hex: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available actions in the context menu
|
||||
*/
|
||||
export const CONTEXT_MENU_ACTIONS: ContextMenuActionConfig[] = [
|
||||
{
|
||||
id: 'create-subfolder',
|
||||
label: 'Create subfolder',
|
||||
icon: '📁',
|
||||
description: 'Create a new subfolder inside this folder',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'rename',
|
||||
label: 'Rename',
|
||||
icon: '✏️',
|
||||
description: 'Rename this folder',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: '📋',
|
||||
description: 'Create a copy of this folder with all its contents',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'create-page',
|
||||
label: 'Create new page',
|
||||
icon: '📄',
|
||||
description: 'Create a new markdown page in this folder',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'copy-link',
|
||||
label: 'Copy internal link',
|
||||
icon: '🔗',
|
||||
description: 'Copy the internal link to this folder',
|
||||
isDangerous: false,
|
||||
requiresConfirmation: false,
|
||||
},
|
||||
{
|
||||
id: 'delete-folder',
|
||||
label: 'Delete folder',
|
||||
icon: '🗑️',
|
||||
description: 'Delete this folder',
|
||||
isDangerous: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
{
|
||||
id: 'delete-all',
|
||||
label: 'Delete all pages in folder',
|
||||
icon: '⚠️',
|
||||
description: 'Delete all pages in this folder (cannot be undone)',
|
||||
isDangerous: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Color palette for folder categorization
|
||||
*/
|
||||
export const CONTEXT_MENU_COLORS: ContextMenuColorConfig[] = [
|
||||
{
|
||||
hex: '#0ea5e9',
|
||||
name: 'Sky Blue',
|
||||
description: 'Default, general purpose',
|
||||
},
|
||||
{
|
||||
hex: '#3b82f6',
|
||||
name: 'Blue',
|
||||
description: 'Important folders',
|
||||
},
|
||||
{
|
||||
hex: '#22c55e',
|
||||
name: 'Green',
|
||||
description: 'Active projects',
|
||||
},
|
||||
{
|
||||
hex: '#eab308',
|
||||
name: 'Yellow',
|
||||
description: 'Attention needed',
|
||||
},
|
||||
{
|
||||
hex: '#f97316',
|
||||
name: 'Orange',
|
||||
description: 'In progress',
|
||||
},
|
||||
{
|
||||
hex: '#ef4444',
|
||||
name: 'Red',
|
||||
description: 'Critical/Urgent',
|
||||
},
|
||||
{
|
||||
hex: '#a855f7',
|
||||
name: 'Purple',
|
||||
description: 'Archive/Special',
|
||||
},
|
||||
{
|
||||
hex: '#64748b',
|
||||
name: 'Gray',
|
||||
description: 'Inactive/Old',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get action configuration by ID
|
||||
*/
|
||||
export function getActionConfig(id: ContextMenuAction): ContextMenuActionConfig | undefined {
|
||||
return CONTEXT_MENU_ACTIONS.find(action => action.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color configuration by hex value
|
||||
*/
|
||||
export function getColorConfig(hex: string): ContextMenuColorConfig | undefined {
|
||||
return CONTEXT_MENU_COLORS.find(color => color.hex === hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hex colors for the palette
|
||||
*/
|
||||
export function getAllColors(): string[] {
|
||||
return CONTEXT_MENU_COLORS.map(color => color.hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action is dangerous (requires confirmation)
|
||||
*/
|
||||
export function isActionDangerous(id: ContextMenuAction): boolean {
|
||||
const action = getActionConfig(id);
|
||||
return action?.isDangerous ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confirmation message for an action
|
||||
*/
|
||||
export function getConfirmationMessage(id: ContextMenuAction, folderName: string): string {
|
||||
switch (id) {
|
||||
case 'delete-folder':
|
||||
return `Are you sure you want to delete the folder "${folderName}"?`;
|
||||
case 'delete-all':
|
||||
return `Are you sure you want to delete ALL pages in "${folderName}"? This cannot be undone.`;
|
||||
default:
|
||||
return 'Are you sure?';
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, inject } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, input, output, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { VaultNode, VaultFile, VaultFolder } from '../../types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { NoteCreationService } from '../../app/services/note-creation.service';
|
||||
import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
|
||||
import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-file-explorer',
|
||||
@@ -15,12 +18,13 @@ import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
|
||||
<div>
|
||||
<div
|
||||
(click)="onFolderClick(folder)"
|
||||
(contextmenu)="openContextMenu($event, folder)"
|
||||
class="flex items-center cursor-pointer px-3 py-2 rounded-lg my-0.5 text-sm hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform text-obs-l-text-muted dark:text-obs-d-text-muted" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform flex-shrink-0" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0 text-obs-l-text-muted dark:text-obs-d-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" [style.color]="getFolderColor(folder.path)"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
|
||||
<span class="font-semibold truncate">{{ folder.name }}</span>
|
||||
<app-badge-count class="ml-auto" [count]="folderCount(folder.path)" color="slate"></app-badge-count>
|
||||
</div>
|
||||
@@ -57,9 +61,55 @@ import { BadgeCountComponent } from '../../app/shared/ui/badge-count.component';
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<app-context-menu
|
||||
[x]="ctxX()"
|
||||
[y]="ctxY()"
|
||||
[visible]="ctxVisible()"
|
||||
(action)="onContextMenuAction($event)"
|
||||
(color)="onContextMenuColor($event)"
|
||||
(closed)="ctxVisible.set(false)">
|
||||
</app-context-menu>
|
||||
|
||||
<!-- Rename Modal -->
|
||||
<div *ngIf="renameModalVisible()" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div class="bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg p-6 w-full max-w-md mx-4">
|
||||
<h3 class="text-lg font-semibold text-[var(--fg)] mb-4">Rename Folder</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-[var(--text-main)] mb-2">
|
||||
New folder name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="renameInputValue"
|
||||
class="w-full px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] text-[var(--fg)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
placeholder="Enter new folder name"
|
||||
(keydown.enter)="confirmRename()"
|
||||
#renameInput
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
(click)="cancelRename()"
|
||||
class="px-4 py-2 text-[var(--text-main)] hover:bg-[var(--surface-1)] rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmRename()"
|
||||
class="px-4 py-2 bg-[var(--primary)] text-white rounded-md hover:bg-[var(--brand-700)] transition-colors"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, BadgeCountComponent],
|
||||
imports: [CommonModule, FormsModule, BadgeCountComponent, ContextMenuComponent],
|
||||
})
|
||||
export class FileExplorerComponent {
|
||||
nodes = input.required<VaultNode[]>();
|
||||
@@ -70,7 +120,22 @@ export class FileExplorerComponent {
|
||||
fileSelected = output<string>();
|
||||
folderSelected = output<string>();
|
||||
|
||||
// Context menu state
|
||||
ctxVisible = signal(false);
|
||||
ctxX = signal(0);
|
||||
ctxY = signal(0);
|
||||
ctxTarget: VaultFolder | null = null;
|
||||
|
||||
// Rename modal state
|
||||
renameModalVisible = signal(false);
|
||||
renameInputValue = '';
|
||||
renameTarget: VaultFolder | null = null;
|
||||
|
||||
// Folder colors storage
|
||||
private folderColors = new Map<string, string>();
|
||||
|
||||
private vaultService = inject(VaultService);
|
||||
private noteCreation = inject(NoteCreationService);
|
||||
|
||||
folderCount(path: string): number {
|
||||
const quickLink = this.quickLinkFilter();
|
||||
@@ -104,6 +169,70 @@ export class FileExplorerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// FOLDER COLOR MANAGEMENT
|
||||
// ========================================
|
||||
|
||||
getFolderColor(folderPath: string): string {
|
||||
return this.folderColors.get(folderPath) || 'currentColor';
|
||||
}
|
||||
|
||||
private setFolderColor(folderPath: string, color: string) {
|
||||
this.folderColors.set(folderPath, color);
|
||||
// Persist to localStorage
|
||||
const colors = Object.fromEntries(this.folderColors);
|
||||
localStorage.setItem('folderColors', JSON.stringify(colors));
|
||||
}
|
||||
|
||||
private loadFolderColors() {
|
||||
const stored = localStorage.getItem('folderColors');
|
||||
if (stored) {
|
||||
try {
|
||||
const colors = JSON.parse(stored);
|
||||
this.folderColors = new Map(Object.entries(colors));
|
||||
} catch (e) {
|
||||
console.error('Failed to load folder colors:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private persistFolderColors() {
|
||||
const colors = Object.fromEntries(this.folderColors);
|
||||
localStorage.setItem('folderColors', JSON.stringify(colors));
|
||||
}
|
||||
|
||||
private migrateFolderColors(oldBase: string, newBase: string) {
|
||||
if (!oldBase || !newBase || oldBase === newBase) return;
|
||||
const updates: Array<{ from: string; to: string; value: string }> = [];
|
||||
const prefix = oldBase + '/';
|
||||
|
||||
for (const [key, value] of this.folderColors.entries()) {
|
||||
if (key === oldBase) {
|
||||
updates.push({ from: key, to: newBase, value });
|
||||
} else if (key.startsWith(prefix)) {
|
||||
const remainder = key.slice(prefix.length);
|
||||
updates.push({ from: key, to: `${newBase}/${remainder}`, value });
|
||||
}
|
||||
}
|
||||
|
||||
for (const u of updates) {
|
||||
this.folderColors.delete(u.from);
|
||||
this.folderColors.set(u.to, u.value);
|
||||
}
|
||||
}
|
||||
|
||||
private removeFolderColorsRecursively(base: string) {
|
||||
if (!base) return;
|
||||
const toDelete: string[] = [];
|
||||
const prefix = base + '/';
|
||||
for (const key of this.folderColors.keys()) {
|
||||
if (key === base || key.startsWith(prefix)) {
|
||||
toDelete.push(key);
|
||||
}
|
||||
}
|
||||
for (const k of toDelete) this.folderColors.delete(k);
|
||||
}
|
||||
|
||||
onFileSelected(noteId: string) {
|
||||
if(noteId) {
|
||||
this.fileSelected.emit(noteId);
|
||||
@@ -124,4 +253,234 @@ export class FileExplorerComponent {
|
||||
isFolder(node: VaultNode): node is VaultFolder {
|
||||
return node.type === 'folder';
|
||||
}
|
||||
|
||||
// Context menu methods
|
||||
openContextMenu(event: MouseEvent, folder: VaultFolder) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.ctxTarget = folder;
|
||||
this.ctxX.set(event.clientX);
|
||||
this.ctxY.set(event.clientY);
|
||||
this.ctxVisible.set(true);
|
||||
}
|
||||
|
||||
onContextMenuAction(action: string) {
|
||||
if (!this.ctxTarget) return;
|
||||
|
||||
switch (action) {
|
||||
case 'create-subfolder':
|
||||
this.createSubfolder();
|
||||
break;
|
||||
case 'rename':
|
||||
this.openRenameModal();
|
||||
break;
|
||||
case 'duplicate':
|
||||
this.duplicateFolder();
|
||||
break;
|
||||
case 'create-page':
|
||||
this.createPageInFolder();
|
||||
break;
|
||||
case 'copy-link':
|
||||
this.copyInternalLink();
|
||||
break;
|
||||
case 'delete-folder':
|
||||
this.deleteFolder();
|
||||
break;
|
||||
case 'delete-all':
|
||||
this.deleteAllPagesInFolder();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onContextMenuColor(color: string) {
|
||||
if (!this.ctxTarget) return;
|
||||
this.setFolderColor(this.ctxTarget.path, color);
|
||||
this.showNotification(`Folder color updated to ${color}`, 'success');
|
||||
}
|
||||
|
||||
// Action implementations
|
||||
private createSubfolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const name = prompt('Enter subfolder name:');
|
||||
if (!name) return;
|
||||
const newPath = `${this.ctxTarget.path}/${name}`;
|
||||
// TODO: Implement actual folder creation via VaultService
|
||||
this.showNotification(`Creating subfolder: ${newPath}`, 'info');
|
||||
}
|
||||
|
||||
private openRenameModal() {
|
||||
if (!this.ctxTarget) return;
|
||||
this.renameTarget = this.ctxTarget;
|
||||
this.renameInputValue = this.ctxTarget.name;
|
||||
this.renameModalVisible.set(true);
|
||||
}
|
||||
|
||||
private async confirmRename() {
|
||||
if (!this.renameTarget || !this.renameInputValue.trim()) return;
|
||||
|
||||
const newName = this.renameInputValue.trim();
|
||||
if (newName === this.renameTarget.name) {
|
||||
this.cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/folders/rename', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
oldPath: this.renameTarget.path,
|
||||
newName: newName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to rename folder');
|
||||
}
|
||||
|
||||
// Compute new folder path (relative)
|
||||
const oldPath = this.renameTarget.path;
|
||||
const parent = oldPath.includes('/') ? oldPath.slice(0, oldPath.lastIndexOf('/')) : '';
|
||||
const newPath = parent ? `${parent}/${newName}` : newName;
|
||||
|
||||
// Migrate colors for the folder and its subfolders
|
||||
this.migrateFolderColors(oldPath, newPath);
|
||||
this.persistFolderColors();
|
||||
|
||||
this.showNotification(`Folder renamed to "${newName}" successfully`, 'success');
|
||||
this.cancelRename();
|
||||
|
||||
// Trigger an authoritative refresh to avoid any stale state until SSE arrives
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Rename folder error:', error);
|
||||
this.showNotification(`Failed to rename folder: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
private cancelRename() {
|
||||
this.renameModalVisible.set(false);
|
||||
this.renameInputValue = '';
|
||||
this.renameTarget = null;
|
||||
}
|
||||
|
||||
private duplicateFolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const newName = prompt('Enter duplicate folder name:', `${this.ctxTarget.name} (copy)`);
|
||||
if (!newName) return;
|
||||
const src = this.ctxTarget.path;
|
||||
const parent = src.includes('/') ? src.slice(0, src.lastIndexOf('/')) : '';
|
||||
const dst = parent ? `${parent}/${newName}` : newName;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/folders/duplicate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourcePath: src, destinationPath: dst })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to duplicate folder');
|
||||
this.showNotification(`Folder duplicated to: ${dst}`, 'success');
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
} catch (err) {
|
||||
console.error('Duplicate folder error:', err);
|
||||
this.showNotification(`Failed to duplicate folder: ${err.message}`, 'error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private createPageInFolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const pageName = prompt('Enter page name:');
|
||||
if (!pageName) return;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await this.noteCreation.createNote(pageName, this.ctxTarget!.path);
|
||||
this.showNotification(`Page created: ${resp.fileName}`, 'success');
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
} catch (e:any) {
|
||||
console.error('Create page error:', e);
|
||||
this.showNotification(`Failed to create page: ${e.message || e}`, 'error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private copyInternalLink() {
|
||||
if (!this.ctxTarget) return;
|
||||
const link = `[[${this.ctxTarget.path}]]`;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
this.showNotification('Internal link copied to clipboard!', 'success');
|
||||
}).catch(() => {
|
||||
this.showNotification('Failed to copy link', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
private deleteFolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const confirmed = confirm(`Are you sure you want to delete the folder "${this.ctxTarget.name}"?`);
|
||||
if (!confirmed) return;
|
||||
const target = this.ctxTarget;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/folders?path=' + encodeURIComponent(target.path), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to delete folder');
|
||||
|
||||
// Remove any stored colors for this folder and its descendants
|
||||
this.removeFolderColorsRecursively(target.path);
|
||||
this.persistFolderColors();
|
||||
|
||||
this.showNotification(`Folder deleted: ${target.name}`, 'success');
|
||||
this.ctxVisible.set(false);
|
||||
|
||||
// Trigger an authoritative refresh to avoid any stale state until SSE arrives
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Delete folder error:', err);
|
||||
this.showNotification(`Failed to delete folder: ${err.message}`, 'error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private deleteAllPagesInFolder() {
|
||||
if (!this.ctxTarget) return;
|
||||
const confirmed = confirm(`Are you sure you want to delete ALL pages in "${this.ctxTarget.name}"? This cannot be undone.`);
|
||||
if (!confirmed) return;
|
||||
const target = this.ctxTarget.path;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/folders/pages?path=' + encodeURIComponent(target), { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to delete pages');
|
||||
this.showNotification(`Deleted ${data.deletedCount ?? 0} pages in folder: ${this.ctxTarget!.name}`, 'success');
|
||||
this.vaultService.refreshFoldersTree(true);
|
||||
} catch (err:any) {
|
||||
console.error('Delete all pages error:', err);
|
||||
this.showNotification(`Failed to delete pages: ${err.message || err}`, 'error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private showNotification(message: string, type: 'success' | 'info' | 'warning' | 'error') {
|
||||
// Simple notification using browser's native capabilities
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
// You can replace this with a proper toast notification service if available
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.loadFolderColors();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Initialize component
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user