feat: revamp editor UI and enhance about panel
- Updated markdown editor with modern toolbar design and improved visual hierarchy - Added auto-save indicator and note color support in editor header - Redesigned note list cards with hover effects and quick action buttons - Added version, build date and copyright info to about panel - Enhanced scrollbar styling and padding in editor container - Improved responsive layout and dark mode support across components
This commit is contained in:
@@ -78,6 +78,10 @@ import { trigger, transition, style, animate } from '@angular/animations';
|
||||
<p class="text-sm text-muted">
|
||||
Créé par <span class="font-semibold text-main dark:text-white">Bruno Charest</span>
|
||||
</p>
|
||||
<div class="mt-1 text-xs text-muted">
|
||||
<div>Version: {{ version }}</div>
|
||||
<div>Date de compilation: {{ buildDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credits -->
|
||||
@@ -104,6 +108,7 @@ import { trigger, transition, style, animate } from '@angular/animations';
|
||||
</svg>
|
||||
Voir sur GitHub
|
||||
</a>
|
||||
<div class="mt-4 text-[11px] text-muted">© {{ currentYear }} ObsiViewer. Tous droits réservés.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,6 +124,8 @@ export class AboutPanelComponent {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
readonly version = '1.0.0';
|
||||
readonly buildDate = new Date().toLocaleString();
|
||||
readonly currentYear = new Date().getFullYear();
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
|
||||
@@ -43,84 +43,25 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="markdown-editor" [class.markdown-editor--dark]="isDarkTheme()">
|
||||
<!-- Toolbar -->
|
||||
<div class="markdown-editor__toolbar">
|
||||
<div class="markdown-editor__toolbar-left">
|
||||
<span class="text-sm font-medium text-muted">Editing</span>
|
||||
<span class="text-sm text-muted-foreground" *ngIf="filePath()">{{ fileName() }}</span>
|
||||
<!-- Revamped Toolbar -->
|
||||
<header
|
||||
class="flex items-center justify-between px-4 py-2 bg-gradient-to-r from-[#0f172a] to-[#1e293b] border-b border-slate-700/50 shadow-sm backdrop-blur-sm relative"
|
||||
[style.border-top]="noteColor() ? '3px solid ' + noteColor() : 'none'"
|
||||
>
|
||||
<h1 class="text-slate-200 font-semibold text-base">{{ fileName() }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<button class="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 transition" (click)="save()" [disabled]="isSaving()">
|
||||
{{ isSaving() ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<span *ngIf="isAutoSaving()" class="absolute top-0 right-0 -mt-1 -mr-1 w-3 h-3 bg-green-500 rounded-full animate-ping"></span>
|
||||
<span *ngIf="isAutoSaving()" class="absolute top-0 right-0 -mt-1 -mr-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
</div>
|
||||
<button class="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 transition" (click)="toggleWordWrap()">Wrap</button>
|
||||
<button class="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 transition" (click)="undo()">Undo</button>
|
||||
<button class="px-3 py-1 rounded-lg bg-red-500/20 hover:bg-red-500/40 text-red-200 transition" (click)="close()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="markdown-editor__toolbar-right">
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn editor-btn--primary"
|
||||
[disabled]="isSaving()"
|
||||
(click)="save()"
|
||||
[attr.aria-label]="'Save (Ctrl+S)'">
|
||||
<svg *ngIf="!isSaving()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="editor-btn__icon">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>
|
||||
<svg *ngIf="isSaving()" class="animate-spin editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" stroke-dasharray="60" stroke-dashoffset="15"/>
|
||||
</svg>
|
||||
<span class="editor-btn__label hidden sm:inline">{{ isSaving() ? 'Saving...' : 'Save' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Wrap Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn editor-btn--neutral"
|
||||
(click)="toggleWordWrap()"
|
||||
[attr.aria-label]="'Toggle word wrap'">
|
||||
<svg class="editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 7 4 4 20 4 20 7"/>
|
||||
<line x1="9" y1="20" x2="15" y2="20"/>
|
||||
<line x1="12" y1="4" x2="12" y2="20"/>
|
||||
</svg>
|
||||
<span class="editor-btn__label hidden md:inline">Wrap</span>
|
||||
</button>
|
||||
|
||||
<!-- Undo -->
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn editor-btn--ghost editor-btn--icon hidden sm:inline-flex"
|
||||
(click)="undo()"
|
||||
[attr.aria-label]="'Undo (Ctrl+Z)'">
|
||||
<svg class="editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Redo -->
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn editor-btn--ghost editor-btn--icon hidden sm:inline-flex"
|
||||
(click)="redo()"
|
||||
[attr.aria-label]="'Redo (Ctrl+Y)'">
|
||||
<svg class="editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Close/Cancel -->
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn editor-btn--neutral"
|
||||
(click)="close()"
|
||||
[attr.aria-label]="'Close editor (Esc)'">
|
||||
<svg class="editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<span class="editor-btn__label hidden sm:inline">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Editor Container -->
|
||||
<div class="markdown-editor__container">
|
||||
@@ -152,137 +93,41 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
.markdown-editor__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.markdown-editor__toolbar-left,
|
||||
.markdown-editor__toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 6px 14px -12px color-mix(in oklab, var(--brand) 70%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--brand) 35%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.editor-btn__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.editor-btn__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-btn--icon {
|
||||
padding: 0.4rem;
|
||||
border-radius: 9999px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.editor-btn--primary {
|
||||
color: #0f172a;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--brand) 55%, transparent) 0%,
|
||||
color-mix(in oklab, var(--brand-700) 45%, transparent) 100%);
|
||||
border-color: color-mix(in oklab, var(--brand-700) 40%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn--primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 22px -12px color-mix(in oklab, var(--brand) 80%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn--neutral {
|
||||
color: inherit;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--surface-1, #e5e7eb) 65%, transparent) 0%,
|
||||
color-mix(in oklab, var(--surface-2, #d1d5db) 55%, transparent) 100%);
|
||||
border-color: color-mix(in oklab, var(--border, #cbd5e1) 55%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.editor-btn--neutral:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 18px -14px color-mix(in oklab, var(--border, #cbd5e1) 65%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn--ghost {
|
||||
color: var(--brand-500, var(--btn-bg));
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.editor-btn--ghost:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--brand) 10%, transparent);
|
||||
}
|
||||
|
||||
:host-context(.dark) .editor-btn--primary {
|
||||
color: #e2e8f0;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--brand-700) 55%, transparent) 0%,
|
||||
color-mix(in oklab, var(--brand-900, #1e1b4b) 55%, transparent) 100%);
|
||||
border-color: color-mix(in oklab, var(--brand-700) 45%, transparent);
|
||||
}
|
||||
|
||||
:host-context(.dark) .editor-btn--neutral {
|
||||
color: #e2e8f0;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--card, #111827) 65%, transparent) 0%,
|
||||
color-mix(in oklab, var(--surface2, #0b1220) 55%, transparent) 100%);
|
||||
border-color: color-mix(in oklab, var(--border, #334155) 60%, transparent);
|
||||
}
|
||||
|
||||
:host-context(.dark) .editor-btn--ghost {
|
||||
color: color-mix(in oklab, var(--brand-200, #c7d2fe) 75%, #93c5fd 25%);
|
||||
}
|
||||
|
||||
.editor-btn--ghost.editor-btn--icon:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.markdown-editor__container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 1rem; /* Add some space around the editor */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.markdown-editor__host {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Webkit browsers */
|
||||
.markdown-editor__host::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.markdown-editor__host::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-editor__host::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(100, 116, 139, 0.6);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.markdown-editor__host::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(100, 116, 139, 0.8);
|
||||
}
|
||||
|
||||
.markdown-editor__statusbar {
|
||||
@@ -309,7 +154,7 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser
|
||||
}
|
||||
|
||||
:host ::ng-deep .cm-content {
|
||||
padding: 1rem;
|
||||
padding: 1.5rem; /* p-6 */
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@@ -355,6 +200,7 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
|
||||
content = signal<string>('');
|
||||
isDirty = signal<boolean>(false);
|
||||
isSaving = signal<boolean>(false);
|
||||
isAutoSaving = signal<boolean>(false);
|
||||
wordWrap = signal<boolean>(false);
|
||||
cursorLine = signal<number>(1);
|
||||
cursorCol = signal<number>(1);
|
||||
@@ -362,6 +208,12 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
|
||||
isDarkTheme = signal<boolean>(false);
|
||||
|
||||
// Computed
|
||||
noteColor = computed(() => {
|
||||
const content = this.content();
|
||||
const match = content.match(/---\s*[\s\S]*?color:\s*['"]?(#[0-9a-fA-F]{6}|[a-zA-Z]+)['"]?[\s\S]*?---/);
|
||||
return match ? match[1] : null;
|
||||
});
|
||||
|
||||
fileName = computed(() => {
|
||||
const path = this.filePath();
|
||||
return path ? path.split('/').pop() || '' : '';
|
||||
@@ -632,9 +484,11 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Autosave after 5 seconds of inactivity
|
||||
this.autosaveTimer = setTimeout(() => {
|
||||
this.autosaveTimer = setTimeout(async () => {
|
||||
if (this.isDirty() && !this.isSaving()) {
|
||||
this.save();
|
||||
this.isAutoSaving.set(true);
|
||||
await this.save();
|
||||
this.isAutoSaving.set(false);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { NoteContextMenuComponent } from '../../../components/note-context-menu/
|
||||
import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component';
|
||||
import { NoteContextMenuService } from '../../services/note-context-menu.service';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
import { EditorStateService } from '../../../services/editor-state.service';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
@@ -133,7 +135,7 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
|
||||
<ul class="notes-list">
|
||||
<li *ngFor="let n of filtered()"
|
||||
class="note-row cursor-pointer"
|
||||
class="note-row note-card group cursor-pointer relative"
|
||||
[class.active]="(selectedId() === n.id) || (pendingSelectId() === n.id)"
|
||||
[ngClass]="getListItemClasses()"
|
||||
[ngStyle]="getNoteGradientStyle(n)"
|
||||
@@ -142,24 +144,50 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
tabindex="-1"
|
||||
(click)="openNote.emit(n.id)"
|
||||
(contextmenu)="openContextMenu($event, n)">
|
||||
|
||||
<!-- Action Buttons (hover reveal) -->
|
||||
<div class="note-card-actions absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn edit inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-surface1 dark:hover:bg-surface2 transition-colors backdrop-blur-sm"
|
||||
title="Éditer la note"
|
||||
(click)="$event.stopPropagation(); editNote(n)">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn delete inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-red-100 dark:hover:bg-red-950 transition-colors backdrop-blur-sm"
|
||||
title="Supprimer la note"
|
||||
(click)="$event.stopPropagation(); openDeleteWarning(n)">
|
||||
<svg class="w-4 h-4 text-red-600 dark:text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Compact View -->
|
||||
<div *ngIf="state.viewMode() === 'compact'" class="note-inner">
|
||||
<div *ngIf="state.viewMode() === 'compact'" class="note-inner flex items-center gap-2">
|
||||
<span class="note-color-dot flex-shrink-0" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
|
||||
<div class="title text-xs truncate">{{ n.title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Comfortable View (default) -->
|
||||
<div *ngIf="state.viewMode() === 'comfortable'" class="note-inner">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
<div *ngIf="state.viewMode() === 'comfortable'" class="note-inner flex items-start gap-2">
|
||||
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed View -->
|
||||
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner space-y-1.5">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
<div class="excerpt text-xs">
|
||||
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
|
||||
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
|
||||
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex items-start gap-2 space-y-0">
|
||||
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
<div class="excerpt text-xs">
|
||||
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
|
||||
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -362,6 +390,73 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
color: var(--primary-foreground, #fafafa);
|
||||
border-color: color-mix(in oklab, var(--primary) 35%, white 65%);
|
||||
}
|
||||
|
||||
/* Enhanced note card with color indicator and action buttons */
|
||||
.note-card {
|
||||
/* Smooth hover lift effect */
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.note-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Gradient background positioning */
|
||||
.note-card {
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 120px;
|
||||
background-position: top center;
|
||||
}
|
||||
|
||||
/* Color dot indicator */
|
||||
.note-color-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--text-main) 15%, transparent 85%);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.note-row:hover .note-color-dot {
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--text-main) 25%, transparent 75%);
|
||||
}
|
||||
|
||||
/* Action buttons container */
|
||||
.note-card-actions {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.action-btn.edit {
|
||||
color: var(--primary, #3b82f6);
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:host-context(html.dark) .action-btn.delete {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Touch devices: keep actions visible (no hover) */
|
||||
@media (pointer: coarse) {
|
||||
.note-card .note-card-actions {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure proper stacking context for action buttons */
|
||||
.note-row {
|
||||
overflow: visible;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class NotesListComponent {
|
||||
@@ -385,6 +480,8 @@ export class NotesListComponent {
|
||||
@ViewChild('listContainer') listContainer?: ElementRef<HTMLElement>;
|
||||
private urlState = inject(UrlStateService);
|
||||
private pendingSelectId = signal<string | null>(null);
|
||||
private editorState = inject(EditorStateService);
|
||||
private vault = inject(VaultService);
|
||||
|
||||
// Delete warning modal state
|
||||
deleteWarningOpen = signal<boolean>(false);
|
||||
@@ -743,4 +840,25 @@ export class NotesListComponent {
|
||||
backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)`
|
||||
} as Record<string, string>;
|
||||
}
|
||||
|
||||
// Get note color for the indicator dot
|
||||
getNoteColor(note: Note): string {
|
||||
return note.frontmatter?.color || 'var(--text-muted)';
|
||||
}
|
||||
|
||||
// Edit note action
|
||||
editNote(note: Note): void {
|
||||
try {
|
||||
const full = this.vault.getNoteById(note.id);
|
||||
if (full?.filePath) {
|
||||
const content = (full as any).rawContent ?? full.content ?? '';
|
||||
this.editorState.enterEditMode(full.filePath, content);
|
||||
// also select the note in the list for visual sync
|
||||
this.openNote.emit(note.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[NotesList] Failed to enter edit mode for note:', note?.id, e);
|
||||
this.openNote.emit(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy, HostListener, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { Note } from '../../../types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-note-info-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] flex items-center justify-center p-4 animate-fadeIn" (click)="onBackdrop()">
|
||||
<!-- Panel -->
|
||||
<div class="bg-card dark:bg-main border border-border dark:border-gray-700 rounded-2xl shadow-2xl shadow-[var(--shadow-glow,0_0_24px_var(--primary))] w-full max-w-4xl p-5 md:p-6 relative scale-100 animate-[fadeIn_120ms_ease,scaleIn_140ms_ease] flex flex-col max-h-[min(92vh,880px)] overflow-hidden" (click)="$event.stopPropagation()">
|
||||
<!-- Decorative corner image/icon -->
|
||||
<div class="absolute -top-3 -right-3 opacity-20 select-none pointer-events-none">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-muted"><path d="M4 19.5A2.5 2.5 0 0 0 6.5 22h11A2.5 2.5 0 0 0 20 19.5v-15A2.5 2.5 0 0 0 17.5 2h-11A2.5 2.5 0 0 0 4 4.5Z"/><path d="M8 7h8"/><path d="M8 11h8"/><path d="M8 15h6"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button type="button" class="absolute top-4 right-4 w-8 h-8 rounded-full hover:bg-surface1 dark:hover:bg-card transition-colors flex items-center justify-center text-muted hover:text-main" (click)="close.emit()" aria-label="Fermer">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 overflow-y-auto pr-1">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start gap-3 mb-6">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-xl bg-surface1 dark:bg-card flex items-center justify-center text-lg">
|
||||
<span>🧾</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-xl md:text-2xl font-bold truncate">{{ note?.title }}</h2>
|
||||
<div class="text-xs md:text-sm text-muted mt-1 break-all">{{ note?.filePath }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pb-4">
|
||||
<!-- A. Informations de base -->
|
||||
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>🧾</span> Informations de base</h3>
|
||||
<div class="text-sm space-y-1">
|
||||
<div><span class="text-muted">📄 Nom du fichier:</span> {{ base.fileName }}</div>
|
||||
<div><span class="text-muted">📁 Dossier parent:</span> {{ base.parent }}</div>
|
||||
<div><span class="text-muted">🧩 Type:</span> {{ base.type }}</div>
|
||||
<div><span class="text-muted">💾 Taille:</span> {{ tech.sizeExact }}</div>
|
||||
<div><span class="text-muted">🔡 Encodage:</span> UTF-8</div>
|
||||
<div><span class="text-muted">🧠 MIME:</span> {{ tech.mime }}</div>
|
||||
<div><span class="text-muted">🧭 Emplacement serveur:</span> /vault/{{ note?.filePath }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- B. Métadonnées (Front-matter) -->
|
||||
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>🧠</span> Métadonnées</h3>
|
||||
<div class="text-sm space-y-1">
|
||||
<div><span class="text-muted">👤 Auteur:</span> {{ fm.auteur || note?.author || '—' }}</div>
|
||||
<div><span class="text-muted">🕒 Création:</span> {{ note?.createdAt || '—' }}</div>
|
||||
<div><span class="text-muted">🕒 Modification:</span> {{ note?.updatedAt || (note?.mtime ? (note?.mtime | date:'medium') : '—') }}</div>
|
||||
<div><span class="text-muted">📜 Tags:</span> {{ (fm.tags || note?.tags || []).join(', ') || '—' }}</div>
|
||||
<div><span class="text-muted">🗂️ Catégorie:</span> {{ fm['catégorie'] || fm.categorie || '—' }}</div>
|
||||
<div><span class="text-muted">🪪 Alias:</span> {{ (fm.aliases || []).join(', ') || '—' }}</div>
|
||||
<div><span class="text-muted">🔖 Statut:</span> {{ fm.status || '—' }}</div>
|
||||
<div><span class="text-muted">⭐ Favoris:</span> {{ fm.favoris ? 'true' : 'false' }}</div>
|
||||
<div><span class="text-muted">🔒 Lecture seule:</span> {{ fm.readOnly ? 'true' : 'false' }}</div>
|
||||
<div *ngIf="hash" class="mt-2"><span class="text-muted">🔐 Hash (SHA-1):</span> {{ hash }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- C. Statistiques de contenu -->
|
||||
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>📊</span> Statistiques de contenu</h3>
|
||||
<div class="text-sm grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<div><span class="text-muted">🔤 Caractères:</span> {{ stats.chars }}</div>
|
||||
<div><span class="text-muted">📝 Mots:</span> {{ stats.words }}</div>
|
||||
<div><span class="text-muted">✉️ Phrases:</span> {{ stats.sentences }}</div>
|
||||
<div><span class="text-muted">📑 Paragraphes:</span> {{ stats.paragraphs }}</div>
|
||||
<div><span class="text-muted">📏 Longueur moyenne:</span> {{ stats.avgSentenceLen }}</div>
|
||||
<div><span class="text-muted">#️⃣ Titres H1–H6:</span> {{ stats.headings }}</div>
|
||||
<div><span class="text-muted">✅ Listes:</span> {{ stats.lists }}</div>
|
||||
<div><span class="text-muted">🧩 Code:</span> {{ stats.codeBlocks }}</div>
|
||||
<div><span class="text-muted">🖼️ Images:</span> {{ stats.images }}</div>
|
||||
<div><span class="text-muted">📊 Tableaux:</span> {{ stats.tables }}</div>
|
||||
<div><span class="text-muted">💬 Citations:</span> {{ stats.quotes }}</div>
|
||||
<div><span class="text-muted">🧠 Lisibilité (Flesch approx):</span> {{ stats.readability }}</div>
|
||||
<div><span class="text-muted">🌍 Langue (naive):</span> {{ stats.lang }}</div>
|
||||
<div><span class="text-muted">📌 Qualité contenu:</span> {{ stats.sizeClass }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- D. Liens -->
|
||||
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>🔗</span> Liens</h3>
|
||||
<div class="text-sm space-y-2">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<span class="text-muted">Internes ({{ links.internalCount }}):</span>
|
||||
<ng-container *ngFor="let l of links.internalList; let i = index">
|
||||
<button type="button" class="px-2 py-0.5 rounded-full text-xs bg-surface1 dark:bg-card hover:bg-surface2 transition border border-border/70" (click)="openInternalLink(l)">[[{{ l }}]]</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted">Externes ({{ links.externalCount }}):</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a *ngFor="let u of links.externalList" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-border/70 hover:bg-surface1 dark:hover:bg-card transition" [href]="u" target="_blank" rel="noopener noreferrer">🔗 {{ toHost(u) }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div><span class="text-muted">Cassés (internes):</span> {{ links.brokenInternal }}</div>
|
||||
<div *ngIf="links.uniqueExternalDomains.length"><span class="text-muted">Domaines:</span> {{ links.uniqueExternalDomains.join(', ') }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer buttons -->
|
||||
<div class="mt-6 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors" (click)="openFile()">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 13-5 5-5-5"/><path d="m18 6-5 5-5-5"/></svg>
|
||||
Ouvrir le fichier
|
||||
</button>
|
||||
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-surface1 dark:bg-card hover:bg-surface2 text-sm transition-colors" (click)="exportMetadata()">📤 Exporter les métadonnées</button>
|
||||
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-surface1 dark:bg-card hover:bg-surface2 text-sm transition-colors" (click)="copyAll()">📄 Copier toutes les infos</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors" (click)="close.emit()">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class NoteInfoModalComponent {
|
||||
@Input() note: Note | null = null;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
private vault = inject(VaultService);
|
||||
private urlState = inject(UrlStateService);
|
||||
|
||||
hash: string | null = null;
|
||||
constructor() {
|
||||
// Precompute hash asynchronously when opened
|
||||
setTimeout(() => this.computeHash().catch(() => {}), 0);
|
||||
}
|
||||
|
||||
get fm() { return this.note?.frontmatter || ({} as any); }
|
||||
|
||||
get base() {
|
||||
const fileName = this.note?.fileName || '';
|
||||
const parent = (this.note?.filePath || '').split('/').slice(0, -1).join('/') || '/';
|
||||
const pathLower = (this.note?.filePath || '').toLowerCase();
|
||||
const type = pathLower.endsWith('.excalidraw.md') ? 'Excalidraw' : (pathLower.endsWith('.json') ? 'JSON' : 'Markdown');
|
||||
const sizeKb = Math.max(1, Math.round(((this.note?.content || '').length) / 1024));
|
||||
return { fileName, parent, type, sizeKb };
|
||||
}
|
||||
|
||||
get tech() {
|
||||
const content = this.note?.content || '';
|
||||
const bytes = new Blob([content]).size;
|
||||
const sizeExact = this.formatBytes(bytes);
|
||||
const mime = this.guessMime(this.note?.filePath || '');
|
||||
return { sizeExact, mime };
|
||||
}
|
||||
|
||||
get stats() {
|
||||
const content = this.note?.content || '';
|
||||
const chars = content.length;
|
||||
const words = (content.trim().match(/\S+/g) || []).length;
|
||||
const sentences = (content.match(/[\.!?]+\s|$/g) || []).length;
|
||||
const paragraphs = content.split(/\n\s*\n/).filter(Boolean).length;
|
||||
const headings = (content.match(/^#{1,6}\s+/gm) || []).length;
|
||||
const lists = (content.match(/^(?:\s*[-*+]\s+|\s*\d+\.\s+)/gm) || []).length;
|
||||
const codeBlocks = Math.floor(((content.match(/```/g) || []).length) / 2);
|
||||
const images = (content.match(/!\[[^\]]*\]\([^\)]+\)/g) || []).length;
|
||||
const tables = (content.match(/^\s*\|.*\|\s*$/gm) || []).length;
|
||||
const quotes = (content.match(/^>\s+/gm) || []).length;
|
||||
const avgSentenceLen = sentences ? Math.round((words / sentences) * 10) / 10 : 0;
|
||||
const syllables = this.estimateSyllables(content);
|
||||
const flesch = this.fleschReadingEase(words, sentences, syllables);
|
||||
const readability = isFinite(flesch) ? Math.round(flesch) : 0;
|
||||
const lang = this.detectLanguage(content);
|
||||
const sizeClass = chars < 800 ? 'Note courte' : (chars < 2500 ? 'Note moyenne' : 'Note longue');
|
||||
return { chars, words, sentences, paragraphs, headings, lists, codeBlocks, images, tables, quotes, avgSentenceLen, readability, lang, sizeClass };
|
||||
}
|
||||
|
||||
get links() {
|
||||
const content = this.note?.content || '';
|
||||
const internal = Array.from(content.matchAll(/\[\[([^\]]+)\]\]/g)).map(m => m[1]);
|
||||
const external = Array.from(content.matchAll(/https?:\/\/[^\s)]+/g)).map(m => m[0]);
|
||||
|
||||
const allNotes = this.vault.allNotes() || [];
|
||||
const paths = new Set(allNotes.map(n => (n.filePath || '').toLowerCase()));
|
||||
|
||||
const brokenInternal = internal.filter(link => {
|
||||
// Try to resolve to a markdown path: allow [[name]] or [[path/name]] (without .md)
|
||||
const normalized = (link || '').replace(/\\/g, '/').replace(/\s/g, '%20');
|
||||
// Check with and without .md
|
||||
const withMd = (normalized.endsWith('.md') ? normalized : normalized + '.md').toLowerCase();
|
||||
return !paths.has(withMd);
|
||||
}).length;
|
||||
|
||||
const uniqueExternalDomains = Array.from(new Set(external.map(url => {
|
||||
try { return new URL(url).host; } catch { return null; }
|
||||
}).filter(Boolean) as string[]));
|
||||
|
||||
return { internalCount: internal.length, externalCount: external.length, brokenInternal, uniqueExternalDomains, internalList: internal, externalList: external };
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape') onEsc() { this.close.emit(); }
|
||||
onBackdrop() { this.close.emit(); }
|
||||
|
||||
openFile() {
|
||||
const path = this.note?.filePath;
|
||||
if (!path) return;
|
||||
try { this.urlState.openNote(path); } catch {}
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
exportMetadata() {
|
||||
const payload = {
|
||||
title: this.note?.title,
|
||||
path: this.note?.filePath,
|
||||
base: this.base,
|
||||
frontmatter: this.fm,
|
||||
stats: this.stats,
|
||||
links: this.links,
|
||||
tech: this.tech,
|
||||
hash: this.hash
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${(this.note?.fileName || 'note')}.info.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async copyAll() {
|
||||
try {
|
||||
const text = [
|
||||
`Titre: ${this.note?.title}`,
|
||||
`Chemin: ${this.note?.filePath}`,
|
||||
`Nom fichier: ${this.base.fileName}`,
|
||||
`Dossier: ${this.base.parent}`,
|
||||
`Type: ${this.base.type}`,
|
||||
`Taille: ${this.tech.sizeExact}`,
|
||||
`MIME: ${this.tech.mime}`,
|
||||
`Hash: ${this.hash ?? ''}`,
|
||||
`Tags: ${(this.fm.tags || this.note?.tags || []).join(', ')}`,
|
||||
].join('\n');
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
openInternalLink(label: string) {
|
||||
const normalized = (label || '').replace(/\\/g, '/');
|
||||
const target = normalized.endsWith('.md') ? normalized : `${normalized}.md`;
|
||||
const all = this.vault.allNotes() || [];
|
||||
const found = all.find(n => (n.filePath || '').toLowerCase() === target.toLowerCase());
|
||||
if (found?.filePath) {
|
||||
try { this.urlState.openNote(found.filePath); } catch {}
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
|
||||
toHost(url: string): string {
|
||||
try { return new URL(url).host; } catch { return url; }
|
||||
}
|
||||
|
||||
private guessMime(p: string): string {
|
||||
const low = p.toLowerCase();
|
||||
if (low.endsWith('.md')) return 'text/markdown';
|
||||
if (low.endsWith('.json')) return 'application/json';
|
||||
if (low.endsWith('.excalidraw') || low.endsWith('.excalidraw.md')) return 'application/vnd.excalidraw+json';
|
||||
return 'text/plain';
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
private async computeHash() {
|
||||
try {
|
||||
const content = this.note?.content || '';
|
||||
const enc = new TextEncoder();
|
||||
const data = enc.encode(content);
|
||||
const digest = await crypto.subtle.digest('SHA-1', data);
|
||||
this.hash = Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} catch {
|
||||
this.hash = null;
|
||||
}
|
||||
}
|
||||
|
||||
private estimateSyllables(text: string): number {
|
||||
const vowels = text.match(/[aeiouyàâäéèêëîïôöùûü]/gi) || [];
|
||||
return Math.max(1, Math.round(vowels.length / 3));
|
||||
}
|
||||
|
||||
private fleschReadingEase(words: number, sentences: number, syllables: number): number {
|
||||
if (!words || !sentences || !syllables) return 0;
|
||||
// Flesch Reading Ease (approx)
|
||||
const ASL = words / sentences; // average sentence length
|
||||
const ASW = syllables / words; // average syllables per word
|
||||
return 206.835 - (1.015 * ASL) - (84.6 * ASW);
|
||||
}
|
||||
|
||||
private detectLanguage(text: string): string {
|
||||
const hasFrenchChars = /[àâäçéèêëîïôöùûüÿœ]/i.test(text);
|
||||
const hasEnglishWords = /\b(the|and|of|to|in|is|that|it|for|on)\b/i.test(text);
|
||||
if (hasFrenchChars) return 'fr (naif)';
|
||||
if (hasEnglishWords) return 'en (naive)';
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.move-menu-trigger {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--surface-1) 60%, transparent);
|
||||
color: var(--text-muted);
|
||||
transition: background var(--transition-base), border-color var(--transition-base), color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.move-menu-trigger:hover,
|
||||
.move-menu-trigger:focus-visible {
|
||||
color: var(--text-main);
|
||||
border-color: color-mix(in srgb, var(--border) 90%, transparent);
|
||||
background: color-mix(in srgb, var(--surface-2) 80%, transparent);
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.move-menu-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.move-menu-panel {
|
||||
/* Solid, non-transparent panel aligned with site menus */
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 16px 42px var(--shadow-color),
|
||||
0 4px 10px color-mix(in srgb, var(--shadow-color) 55%, transparent);
|
||||
overflow: hidden;
|
||||
backdrop-filter: none;
|
||||
opacity: 1;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.move-menu-header {
|
||||
padding: 1rem 1.1rem 0.7rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
}
|
||||
|
||||
.move-menu-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.move-menu-link {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.move-menu-link:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.move-menu-body {
|
||||
padding: 0.9rem 1.1rem 1.1rem;
|
||||
}
|
||||
|
||||
.move-menu-search {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.move-menu-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.move-menu-input::placeholder {
|
||||
color: color-mix(in srgb, var(--text-muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.move-menu-clear {
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--surface-1) 80%, transparent);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
.move-menu-clear:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.move-menu-empty {
|
||||
font-size: 0.75rem;
|
||||
color: color-mix(in srgb, var(--text-muted) 85%, transparent);
|
||||
padding: 0.25rem 0.1rem;
|
||||
}
|
||||
|
||||
.move-menu-section-title {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--text-muted) 75%, transparent);
|
||||
}
|
||||
|
||||
.move-menu-breadcrumb {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.move-menu-crumb {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 0.45rem;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-base), background var(--transition-base);
|
||||
}
|
||||
|
||||
.move-menu-crumb:hover {
|
||||
color: var(--text-main);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.move-menu-crumb-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.move-menu-list,
|
||||
.move-menu-search-results {
|
||||
max-height: 14rem;
|
||||
border-radius: 0.9rem;
|
||||
overflow: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.move-folder-button,
|
||||
.move-search-result {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--text-main);
|
||||
transition: background var(--transition-base), color var(--transition-base), transform var(--transition-base);
|
||||
}
|
||||
|
||||
.move-folder-button:hover,
|
||||
.move-search-result:hover {
|
||||
background: var(--surface-2);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.move-folder-expand {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.move-folder-expand:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.move-menu-footer {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.move-menu-panel {
|
||||
width: calc(100vw - 2rem);
|
||||
left: 1rem !important;
|
||||
right: 1rem;
|
||||
}
|
||||
}
|
||||
+24
-24
@@ -2,7 +2,7 @@
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-sm text-muted hover:text-main transition-color"
|
||||
class="move-menu-trigger inline-flex items-center gap-1 text-sm text-muted hover:text-main transition-color"
|
||||
(click)="toggleMenu()"
|
||||
>
|
||||
<span class="truncate" [title]="currentPath">
|
||||
@@ -12,15 +12,15 @@
|
||||
</button>
|
||||
|
||||
@if (showMenu()) {
|
||||
<div class="fixed z-50 w-80 rounded-xl border border-border bg-card shadow-lg"
|
||||
<div class="fixed z-[1000] w-80 move-menu-panel"
|
||||
[style.top.px]="menuTop()" [style.left.px]="menuLeft()">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border/60">
|
||||
<div class="text-xs uppercase tracking-wide text-muted">Move note to folder</div>
|
||||
<button class="text-xs text-accent hover:underline" (click)="openFolderInSidebar()">Open sidebar</button>
|
||||
<div class="move-menu-header flex items-center justify-between">
|
||||
<div class="move-menu-title">Move note to folder</div>
|
||||
<button class="move-menu-link" (click)="openFolderInSidebar()">Open sidebar</button>
|
||||
</div>
|
||||
|
||||
<div class="p-3 space-y-3">
|
||||
<div class="flex items-center gap-2 bg-surface1 rounded-lg px-2 py-1.5">
|
||||
<div class="move-menu-body space-y-3">
|
||||
<div class="move-menu-search flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
@@ -28,38 +28,38 @@
|
||||
<input
|
||||
#searchField
|
||||
type="text"
|
||||
class="bg-transparent text-sm flex-1 outline-none"
|
||||
class="move-menu-input text-sm flex-1"
|
||||
placeholder="Search folders"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchChange($any($event.target).value)"
|
||||
/>
|
||||
@if (searchQuery()) {
|
||||
<button type="button" class="text-muted hover:text-main" (click)="clearSearch()">✕</button>
|
||||
<button type="button" class="move-menu-clear" (click)="clearSearch()">✕</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isSearching() && searchResults().length === 0) {
|
||||
<div class="text-xs text-muted px-1">No matching folders</div>
|
||||
<div class="move-menu-empty">No matching folders</div>
|
||||
}
|
||||
|
||||
@if (!isSearching()) {
|
||||
<div class="text-xs text-muted px-1">All my folders</div>
|
||||
<div class="flex items-center gap-1 text-xs text-muted/80">
|
||||
<button class="hover:text-main" (click)="goToRoot()">All my folders</button>
|
||||
<div class="move-menu-section-title">All my folders</div>
|
||||
<div class="move-menu-breadcrumb flex items-center gap-1">
|
||||
<button class="move-menu-crumb" (click)="goToRoot()">All my folders</button>
|
||||
@for (crumb of breadcrumb(); track crumb.path; let i = $index) {
|
||||
<span>›</span>
|
||||
<button class="hover:text-main" (click)="navigateTo(i + 1)">{{ crumb.name }}</button>
|
||||
<span class="move-menu-crumb-separator">›</span>
|
||||
<button class="move-menu-crumb" (click)="navigateTo(i + 1)">{{ crumb.name }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto space-y-1">
|
||||
<div class="move-menu-list">
|
||||
@if (loading()) {
|
||||
<div class="text-xs text-muted px-2 py-3">Loading folders…</div>
|
||||
<div class="move-menu-empty">Loading folders…</div>
|
||||
} @else {
|
||||
@for (folder of currentLevelFolders(); track folder.path) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-sm hover:bg-surface1"
|
||||
class="move-folder-button"
|
||||
(click)="onFolderSelected(folder, $event)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
@@ -69,7 +69,7 @@
|
||||
<span class="truncate">{{ folder.name }}</span>
|
||||
</span>
|
||||
@if (folder.children?.length) {
|
||||
<button type="button" class="text-xs text-muted hover:text-main" (click)="navigateInto(folder, $event)">
|
||||
<button type="button" class="move-folder-expand" (click)="navigateInto(folder, $event)">
|
||||
▶ {{ folder.children.length }} subfolder{{ folder.children.length > 1 ? 's' : '' }}
|
||||
</button>
|
||||
}
|
||||
@@ -80,11 +80,11 @@
|
||||
}
|
||||
|
||||
@if (isSearching()) {
|
||||
<div class="max-h-64 overflow-y-auto space-y-1">
|
||||
<div class="move-menu-search-results">
|
||||
@for (result of searchResults(); track result.path) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex flex-col items-start px-2 py-1.5 rounded-lg text-sm hover:bg-surface1 text-left"
|
||||
class="move-search-result"
|
||||
(click)="onSearchResultSelected(result)"
|
||||
>
|
||||
<span class="font-medium">{{ result.name }}</span>
|
||||
@@ -94,9 +94,9 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-border/60">
|
||||
<button class="text-xs text-muted hover:text-main" (click)="moveToCurrentFolder()">Move here</button>
|
||||
<button class="text-xs text-muted hover:text-main" (click)="closeMenu()">Cancel</button>
|
||||
<div class="move-menu-footer flex items-center justify-between">
|
||||
<button class="btn btn-standard-primary btn-standard-sm" (click)="moveToCurrentFolder()">Move here</button>
|
||||
<button class="btn btn-standard-sm" (click)="closeMenu()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+9
-6
@@ -33,7 +33,8 @@ interface FlattenedFolder {
|
||||
selector: 'app-move-note-to-folder',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './move-note-to-folder.component.html'
|
||||
templateUrl: './move-note-to-folder.component.html',
|
||||
styleUrls: ['./move-note-to-folder.component.css']
|
||||
})
|
||||
export class MoveNoteToFolderComponent {
|
||||
@Input()
|
||||
@@ -322,11 +323,13 @@ export class MoveNoteToFolderComponent {
|
||||
const t = this.trigger?.nativeElement;
|
||||
if (!t) return;
|
||||
const rect = t.getBoundingClientRect();
|
||||
// Place menu just under the trigger, with small margin
|
||||
this.menuTop.set(Math.round(rect.bottom + window.scrollY + 8));
|
||||
// Try to align left edges; ensure not off-screen (simple clamp)
|
||||
const left = Math.round(rect.left + window.scrollX);
|
||||
this.menuLeft.set(Math.max(8, left));
|
||||
// Place menu just under the trigger (fixed panel -> viewport coords)
|
||||
this.menuTop.set(Math.round(rect.bottom + 8));
|
||||
// Align left edge; clamp to viewport
|
||||
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
|
||||
const desiredLeft = Math.round(rect.left);
|
||||
const clampedLeft = Math.min(Math.max(8, desiredLeft), vw - 8 - 320); // ~w-80
|
||||
this.menuLeft.set(clampedLeft);
|
||||
}
|
||||
|
||||
private attachGlobalListeners(): void {
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Full-width top gradient background layer */
|
||||
.note-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px; /* covers the whole top area visually */
|
||||
background-image: var(--note-accent-gradient, none);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Ensure content renders above the gradient layer */
|
||||
.note-header > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.path-wrap {
|
||||
|
||||
Reference in New Issue
Block a user