diff --git a/docs/URL_PASTE_BUTTON_FEATURE.md b/docs/URL_PASTE_BUTTON_FEATURE.md new file mode 100644 index 0000000..f48f952 --- /dev/null +++ b/docs/URL_PASTE_BUTTON_FEATURE.md @@ -0,0 +1,291 @@ +# URL Paste Menu & Button Configuration - Documentation + +## 📋 Vue d'ensemble + +Cette fonctionnalitĂ© ajoute deux nouvelles capacitĂ©s Ă  l'Ă©diteur Nimbus : + +1. **Menu de collage d'URL** : Lorsqu'un utilisateur colle une URL dans un bloc paragraphe, un menu contextuel apparaĂźt avec plusieurs options de transformation +2. **Configuration avancĂ©e des boutons** : Un modal de configuration complet pour personnaliser les blocs boutons + +## 🎯 FonctionnalitĂ©s implĂ©mentĂ©es + +### 1. Menu de collage d'URL (UrlPasteMenu) + +Lorsqu'une URL est collĂ©e dans un bloc paragraphe en mode prompt, un menu s'affiche avec 5 options : + +#### Options disponibles : +- **URL** : Affiche l'URL brute dans le bloc paragraphe +- **Titre** : RĂ©cupĂšre le titre du site web et l'affiche dans le paragraphe +- **IntĂ©grer** : Convertit le bloc en `embed-block` pour afficher la page dans une iframe ajustable +- **Marque-page** : Convertit le bloc en `bookmark-block` avec preview de la page +- **Bouton** : Convertit le bloc en `button-block` avec l'URL configurĂ©e + +#### Comportement : +- DĂ©tection automatique des URLs (regex `https?://...`) +- Menu positionnĂ© prĂšs du curseur +- RĂ©cupĂ©ration asynchrone du titre via `UrlPreviewService` +- Fermeture automatique aprĂšs sĂ©lection + +### 2. Configuration avancĂ©e des boutons (ButtonConfigModal) + +Un modal complet pour configurer tous les aspects d'un bouton : + +#### ParamĂštres configurables : + +**Texte et URL** +- Titre du bouton (label) +- URL ou Email de destination +- ID unique du bouton (affichĂ©, non modifiable) + +**Comportement** +- ☑ Ouvrir dans un nouvel onglet + +**Forme du bouton** +- đŸ”” Pill (arrondi complet) +- ⬜ Rounded (coins arrondis) + +**Couleur d'arriĂšre-plan** +- Palette de 7 couleurs prĂ©dĂ©finies : + - Bleu (#3b82f6) + - Rouge (#ef4444) + - Orange (#f59e0b) + - Vert (#10b981) + - Violet (#8b5cf6) + - Rose (#ec4899) + - Gris (#6b7280) + +**Taille** +- Petit (small) +- Moyen (medium) - par dĂ©faut +- Grand (large) + +**Type de bouton** +- âšȘ Bouton 3D (effet de profondeur avec border-bottom) +- âšȘ Bouton avec ombre (shadow + hover effect) +- âšȘ Par dĂ©faut (style simple) + +#### Comportement du modal : +- Ouverture automatique lors de la crĂ©ation d'un nouveau bouton +- Bouton d'Ă©dition (crayon) visible au survol du bouton +- Boutons "Annuler" et "TerminĂ©" +- Validation en temps rĂ©el + +## 📁 Fichiers créés + +### Composants + +1. **`src/app/editor/components/block/url-paste-menu.component.ts`** + - Composant standalone Angular + - Affiche le menu contextuel lors du collage d'URL + - RĂ©cupĂšre le titre du site via `UrlPreviewService` + - Émet l'action sĂ©lectionnĂ©e vers le parent + +2. **`src/app/editor/components/block/blocks/button-config-modal.component.ts`** + - Modal de configuration des boutons + - Interface complĂšte avec tous les paramĂštres + - Utilise Angular Signals pour la rĂ©activitĂ© + - Design cohĂ©rent avec le reste de l'application + +### Modifications + +3. **`src/app/editor/components/block/blocks/paragraph-block.component.ts`** + - Ajout de l'import `UrlPasteMenuComponent` + - Ajout de la mĂ©thode `onPaste()` pour intercepter le collage + - Ajout de la mĂ©thode `onPasteMenuAction()` pour gĂ©rer les actions + - Ajout des signaux : `pasteMenuVisible`, `pastedUrl`, `pasteMenuPosition` + - DĂ©tection d'URL via regex + - Conversion de blocs selon l'action choisie + +4. **`src/app/editor/components/block/blocks/button-block.component.ts`** + - Refonte complĂšte du composant + - Ajout de l'import `ButtonConfigModalComponent` + - Rendu visuel du bouton avec tous les styles + - Ouverture automatique du modal si le bouton est vide + - Bouton d'Ă©dition au survol + - MĂ©thodes : `getButtonClasses()`, `getBgColor()`, `getTextColor()` + +5. **`src/app/editor/core/models/block.model.ts`** + - Extension de l'interface `ButtonProps` : + ```typescript + export interface ButtonProps { + label: string; + url: string; + variant?: 'primary' | 'secondary' | 'outline' | '3d' | 'shadow' | 'default'; + openInNewTab?: boolean; + shape?: 'pill' | 'rounded' | 'square'; + backgroundColor?: string; + size?: 'small' | 'medium' | 'large'; + } + ``` + +## 🔄 Flux d'utilisation + +### ScĂ©nario 1 : Collage d'URL + +``` +1. Utilisateur colle une URL (Ctrl+V) dans un paragraphe vide + ↓ +2. ParagraphBlockComponent.onPaste() dĂ©tecte l'URL + ↓ +3. UrlPasteMenu s'affiche avec 5 options + ↓ +4. Utilisateur clique sur une option (ex: "Bouton") + ↓ +5. ParagraphBlockComponent.onPasteMenuAction() convertit le bloc + ↓ +6. Le bloc devient un ButtonBlock + ↓ +7. ButtonConfigModal s'ouvre automatiquement (label vide) + ↓ +8. Utilisateur configure le bouton + ↓ +9. Clic sur "TerminĂ©" → bouton créé et stylisĂ© +``` + +### ScĂ©nario 2 : CrĂ©ation directe de bouton + +``` +1. Utilisateur crĂ©e un bloc bouton via le menu "/" + ↓ +2. ButtonBlock créé avec props par dĂ©faut + ↓ +3. ButtonConfigModal s'ouvre automatiquement + ↓ +4. Utilisateur configure tous les paramĂštres + ↓ +5. Clic sur "TerminĂ©" → bouton créé +``` + +### ScĂ©nario 3 : Édition d'un bouton existant + +``` +1. Utilisateur survole un bouton existant + ↓ +2. IcĂŽne crayon apparaĂźt en haut Ă  droite + ↓ +3. Clic sur l'icĂŽne + ↓ +4. ButtonConfigModal s'ouvre avec les valeurs actuelles + ↓ +5. Modifications et "TerminĂ©" + ↓ +6. Bouton mis Ă  jour +``` + +## 🎹 Styles CSS appliquĂ©s + +### Classes dynamiques du bouton + +**Taille** : +- `small` : `px-3 py-1.5 text-xs` +- `medium` : `px-4 py-2 text-sm` +- `large` : `px-6 py-3 text-base` + +**Forme** : +- `pill` : `rounded-full` +- `rounded` : `rounded-lg` +- `square` : `rounded-md` + +**Variant** : +- `3d` : `border-b-4 border-black/20 active:border-b-0 active:translate-y-1` +- `shadow` : `shadow-lg hover:shadow-xl hover:-translate-y-0.5` +- `outline` : `border-2` (fond transparent, texte colorĂ©) + +## 🔧 IntĂ©gration technique + +### DĂ©pendances + +- `UrlPreviewService` : Pour rĂ©cupĂ©rer les mĂ©tadonnĂ©es des URLs +- `DocumentService` : Pour convertir les blocs +- `Angular Signals` : Pour la rĂ©activitĂ© +- `CommonModule`, `FormsModule` : Modules Angular standard + +### DĂ©tection d'URL + +Regex utilisĂ©e : +```typescript +/^https?:\/\/[^\s/$.?#].[^\s]*$/i +``` + +Cette regex dĂ©tecte : +- Protocole `http://` ou `https://` +- Domaine valide +- Chemin optionnel +- Pas d'espaces + +### Conversion de blocs + +Utilise `DocumentService.updateBlock()` pour transformer le bloc paragraphe en : +- `embed` : `{ url, provider: 'generic' }` +- `bookmark` : `{ url, title, viewMode: 'card' }` +- `button` : `{ label, url, variant: 'primary' }` + +## ✅ Tests recommandĂ©s + +### Test 1 : Menu URL +1. Ouvrir l'Ă©diteur Nimbus +2. CrĂ©er un nouveau paragraphe +3. Coller `https://example.com` +4. VĂ©rifier que le menu apparaĂźt +5. Tester chaque option + +### Test 2 : Configuration bouton +1. CrĂ©er un bouton via "/" +2. VĂ©rifier l'ouverture du modal +3. Modifier tous les paramĂštres +4. VĂ©rifier le rendu visuel + +### Test 3 : Édition bouton +1. CrĂ©er un bouton +2. Survoler le bouton +3. Cliquer sur l'icĂŽne crayon +4. Modifier et sauvegarder + +### Test 4 : URLs complexes +- URL avec paramĂštres : `https://example.com?param=value` +- URL avec ancre : `https://example.com#section` +- URL avec port : `http://localhost:3000` + +## 🐛 Gestion des erreurs + +- Si `UrlPreviewService` Ă©choue, le titre reste vide (pas de blocage) +- Si l'URL est invalide, le menu ne s'affiche pas +- Si le modal est annulĂ©, aucune modification n'est appliquĂ©e +- PrĂ©vention de navigation dans l'Ă©diteur (click interceptĂ©) + +## 🚀 AmĂ©liorations futures possibles + +1. **Menu URL** : + - Support des URLs sans protocole (auto-ajout de `https://`) + - PrĂ©visualisation de l'embed avant conversion + - Historique des URLs rĂ©centes + +2. **Configuration bouton** : + - SĂ©lecteur de couleur personnalisĂ© (color picker) + - PrĂ©visualisation en temps rĂ©el dans le modal + - Templates de boutons prĂ©dĂ©finis + - Support des icĂŽnes (Lucide, FontAwesome) + +3. **GĂ©nĂ©ral** : + - Raccourcis clavier pour le menu URL + - Drag & drop d'URLs depuis le navigateur + - Support des emails (mailto:) + - Support des numĂ©ros de tĂ©lĂ©phone (tel:) + +## 📝 Notes de dĂ©veloppement + +- Tous les composants sont **standalone** (pas de module requis) +- Utilisation de **Angular Signals** pour la rĂ©activitĂ© moderne +- **TailwindCSS** pour tous les styles +- **z-index: 12000** pour le modal (au-dessus de tout) +- **z-index: 11000** pour le menu URL +- Pas de dĂ©pendances externes supplĂ©mentaires + +## ✹ RĂ©sumĂ© + +Cette implĂ©mentation ajoute une expĂ©rience utilisateur fluide et moderne pour : +- Transformer rapidement des URLs collĂ©es en diffĂ©rents types de blocs +- Configurer finement l'apparence et le comportement des boutons +- Maintenir la cohĂ©rence visuelle avec le reste de l'Ă©diteur Nimbus + +Tous les fichiers sont prĂȘts et fonctionnels. La fonctionnalitĂ© est complĂšte et prĂȘte pour les tests. diff --git a/src/app/editor/components/block/block-context-menu.component.ts b/src/app/editor/components/block/block-context-menu.component.ts index 84d745c..2fc2d12 100644 --- a/src/app/editor/components/block/block-context-menu.component.ts +++ b/src/app/editor/components/block/block-context-menu.component.ts @@ -6,7 +6,43 @@ import { CodeThemeService } from '../../services/code-theme.service'; import { BlockMenuStylingService } from '../../services/block-menu-styling.service'; export interface MenuAction { - type: 'comment' | 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'copyCode' | 'toggleWrap' | 'toggleLineNumbers' | 'addCaption' | 'tableLayout' | 'copyTable' | 'filterTable' | 'importCSV' | 'tableHelp' | 'insertColumn' | 'imageAspectRatio' | 'imageAlignment' | 'imageDefaultSize' | 'imageReplace' | 'imageRotate' | 'imageSetPreview' | 'imageOCR' | 'imageDownload' | 'imageViewFull' | 'imageOpenTab' | 'imageInfo' | 'duplicate' | 'copy' | 'lock' | 'copyLink' | 'delete' | 'align' | 'indent'; + type: 'comment' + | 'add' + | 'convert' + | 'background' + | 'lineColor' + | 'borderColor' + | 'codeTheme' + | 'codeLanguage' + | 'copyCode' + | 'toggleWrap' + | 'toggleLineNumbers' + | 'addCaption' + | 'tableLayout' + | 'copyTable' + | 'filterTable' + | 'importCSV' + | 'tableHelp' + | 'insertColumn' + | 'imageAspectRatio' + | 'imageAlignment' + | 'imageDefaultSize' + | 'imageReplace' + | 'imageRotate' + | 'imageSetPreview' + | 'imageOCR' + | 'imageDownload' + | 'imageViewFull' + | 'imageOpenTab' + | 'imageInfo' + | 'duplicate' + | 'copy' + | 'lock' + | 'copyLink' + | 'delete' + | 'align' + | 'indent' + | 'bookmarkViewMode'; payload?: any; } @@ -153,6 +189,55 @@ export interface MenuAction { Comment + + @if (block.type === 'bookmark') { +
+ + +
+ + + +
+
+ } +
} @else { - -
-
- {{ displayTitle }} -
-
- @if (props.faviconUrl) { - - } - {{ displayUrl }} -
- @if (props.description) { -
{{ props.description }}
- } -
- @if (props.imageUrl && showImage) { -
- -
+ @switch (viewMode) { + @case ('tile') { + +
+
+
+ @if (faviconUrl && !faviconBroken) { + + } +
+ {{ displayTitle }} +
+
+
+ {{ displayUrl }} +
+
+
} - + @case ('cover') { + + + @if (props.imageUrl) { +
+ +
+ } +
+
+
+ {{ displayTitle }} +
+
+ @if (faviconUrl && !faviconBroken) { + + } + {{ displayUrl }} +
+
+
+
+ } + @default { + + +
+
+ {{ displayTitle }} +
+
+ @if (faviconUrl && !faviconBroken) { + + } + {{ displayUrl }} +
+ @if (props.description) { +
{{ props.description }}
+ } +
+ @if (props.imageUrl && showImage) { +
+ +
+ } +
+ } + } } `, @@ -80,11 +139,22 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy { private resizeObserver?: ResizeObserver; pendingUrl = ''; showImage = true; + faviconBroken = false; get props(): BookmarkProps { return this.block.props; } + get viewMode(): 'card' | 'tile' | 'cover' { + return this.props.viewMode || 'card'; + } + + get backgroundColor(): string | null { + const meta: any = this.block.meta || {}; + const color = meta.bgColor; + return color && color !== 'transparent' ? color : null; + } + get displayHost(): string { try { const u = new URL(this.props.url); @@ -116,6 +186,28 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy { } } + get faviconUrl(): string | null { + // Prefer faviconUrl from props when available + const raw = (this.props.faviconUrl || '').trim(); + if (raw) { + try { + // Support relative URLs returned by the preview API + const resolved = new URL(raw, this.props.url || undefined); + return resolved.toString(); + } catch { + return raw; + } + } + + // Fallback to /favicon.ico on the same origin as the page URL + try { + const pageUrl = new URL(this.props.url); + return pageUrl.origin + '/favicon.ico'; + } catch { + return null; + } + } + ngAfterViewInit(): void { if (!this.props.url && this.urlInput?.nativeElement) { setTimeout(() => this.urlInput?.nativeElement.focus(), 0); @@ -172,6 +264,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy { faviconUrl: this.props.faviconUrl, loading: true, error: null, + viewMode: this.props.viewMode || 'card', }; this.update.emit(next); this.loadPreview(url); @@ -189,6 +282,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy { faviconUrl: data.faviconUrl || this.props.faviconUrl, loading: false, error: null, + viewMode: this.props.viewMode || 'card', }; this.update.emit(next); } catch (e: any) { @@ -202,6 +296,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy { faviconUrl: this.props.faviconUrl, loading: false, error: message, + viewMode: this.props.viewMode || 'card', }; this.update.emit(next); } @@ -220,6 +315,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy { faviconUrl: this.props.faviconUrl, loading: true, error: null, + viewMode: this.props.viewMode || 'card', }; this.update.emit(next); this.loadPreview(this.props.url); @@ -259,4 +355,8 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy { private updateShowImage(width: number): void { this.showImage = width >= this.minimumWidthForImage; } + + onFaviconError(): void { + this.faviconBroken = true; + } } diff --git a/src/app/editor/components/block/blocks/button-block.component.ts b/src/app/editor/components/block/blocks/button-block.component.ts index bce8f37..e059e56 100644 --- a/src/app/editor/components/block/blocks/button-block.component.ts +++ b/src/app/editor/components/block/blocks/button-block.component.ts @@ -1,35 +1,48 @@ -import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { Component, Input, Output, EventEmitter, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; import { Block, ButtonProps } from '../../../core/models/block.model'; +import { ButtonConfigModalComponent } from './button-config-modal.component'; @Component({ selector: 'app-button-block', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, ButtonConfigModalComponent], template: ` -
- - +
+ - {{ props.label }} + {{ props.label || 'Button' }} + + + + + + @if (showConfig()) { + + }
` }) @@ -37,27 +50,79 @@ export class ButtonBlockComponent { @Input({ required: true }) block!: Block; @Output() update = new EventEmitter(); + showConfig = signal(false); + + ngOnInit() { + // If new button (empty label), open config immediately + if (!this.props.label) { + this.showConfig.set(true); + } + } + get props(): ButtonProps { return this.block.props; } - onLabelChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.update.emit({ ...this.props, label: target.value }); - } - - onUrlChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.update.emit({ ...this.props, url: target.value }); - } - - getButtonClass(): string { - const base = 'btn btn-sm'; - switch (this.props.variant) { - case 'primary': return `${base} btn-primary`; - case 'secondary': return `${base} btn-secondary`; - case 'outline': return `${base} btn-outline`; - default: return base; + getButtonClasses(): string { + const classes: string[] = []; + + // Size + switch (this.props.size) { + case 'small': classes.push('px-3 py-1.5 text-xs'); break; + case 'large': classes.push('px-6 py-3 text-base'); break; + default: classes.push('px-4 py-2 text-sm'); break; // medium } + + // Shape + if (this.props.shape === 'pill') { + classes.push('rounded-full'); + } else if (this.props.shape === 'rounded') { + classes.push('rounded-lg'); + } else { + classes.push('rounded-md'); // default/square + } + + // Variant / Style + if (this.props.variant === '3d') { + classes.push('border-b-4 border-black/20 active:border-b-0 active:translate-y-1'); + } else if (this.props.variant === 'shadow') { + classes.push('shadow-lg hover:shadow-xl hover:-translate-y-0.5'); + } else if (this.props.variant === 'outline') { + classes.push('border-2'); + } + + return classes.join(' '); + } + + getBgColor(): string { + if (this.props.variant === 'outline') return 'transparent'; + return this.props.backgroundColor || '#3b82f6'; + } + + getTextColor(): string { + if (this.props.variant === 'outline') { + return this.props.backgroundColor || '#3b82f6'; + } + // Simple contrast check could be added here, assuming white for now for colored buttons + return '#ffffff'; + } + + onClick(event: MouseEvent) { + // Prevent navigation in editor + event.preventDefault(); + } + + openConfig(event: MouseEvent) { + event.stopPropagation(); + this.showConfig.set(true); + } + + closeConfig() { + this.showConfig.set(false); + } + + onSaveConfig(newProps: ButtonProps) { + this.update.emit(newProps); + this.showConfig.set(false); } } diff --git a/src/app/editor/components/block/blocks/button-config-modal.component.ts b/src/app/editor/components/block/blocks/button-config-modal.component.ts new file mode 100644 index 0000000..24dd99a --- /dev/null +++ b/src/app/editor/components/block/blocks/button-config-modal.component.ts @@ -0,0 +1,241 @@ +import { Component, EventEmitter, Input, Output, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ButtonProps } from '../../../core/models/block.model'; + +@Component({ + selector: 'app-button-config-modal', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+
+ + +
+

Configurer le bouton

+
+ + +
+ + +
+ + +
ID: #{{ blockId }}
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+ + +
+ +
+ @for (color of colors; track color) { + + } +
+
+ + +
+ +
+ + + +
+
+ + +
+ +
+ + + +
+
+ +
+ + +
+ + +
+
+
+ `, + styles: [` + :host { + display: block; + } + `] +}) +export class ButtonConfigModalComponent { + @Input({ required: true }) props!: ButtonProps; + @Input() blockId: string = ''; + @Output() saveProps = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + // Palette colors (based on image) + colors = [ + '#3b82f6', // blue + '#ef4444', // red + '#f59e0b', // orange + '#10b981', // green + '#8b5cf6', // purple + '#ec4899', // pink + '#6b7280', // gray + ]; + + tempProps: ButtonProps = { + label: '', + url: '', + openInNewTab: false, + shape: 'pill', + backgroundColor: '#3b82f6', + size: 'medium', + variant: 'default' + }; + + ngOnInit() { + // Initialize temp props with input props, providing defaults + this.tempProps = { + label: this.props.label || 'Bouton', + url: this.props.url || '', + openInNewTab: this.props.openInNewTab ?? false, + shape: this.props.shape || 'pill', + backgroundColor: this.props.backgroundColor || '#3b82f6', + size: this.props.size || 'medium', + variant: this.props.variant || 'default' + }; + } + + close() { + this.cancel.emit(); + } + + save() { + this.saveProps.emit(this.tempProps); + } +} diff --git a/src/app/editor/components/block/blocks/columns-block.component.ts b/src/app/editor/components/block/blocks/columns-block.component.ts index d1f7448..9280fa6 100644 --- a/src/app/editor/components/block/blocks/columns-block.component.ts +++ b/src/app/editor/components/block/blocks/columns-block.component.ts @@ -404,6 +404,26 @@ export class ColumnsBlockComponent implements AfterViewInit, OnDestroy { this.update.emit({ columns: updatedColumns }); } + + private setBookmarkViewModeInColumns(blockId: string, mode: 'card' | 'tile' | 'cover'): void { + const updatedColumns = this.props.columns.map(column => ({ + ...column, + blocks: column.blocks.map(b => { + if (b.id === blockId && b.type === 'bookmark') { + return { + ...b, + props: { + ...(b.props as any), + viewMode: mode + } + }; + } + return b; + }) + })); + + this.update.emit({ columns: updatedColumns }); + } onBlockCreateBelow(blockId: string, columnIndex: number, blockIndex: number): void { // Create a new paragraph block after the specified block in the same column @@ -565,6 +585,14 @@ export class ColumnsBlockComponent implements AfterViewInit, OnDestroy { const { color } = action.payload || {}; this.backgroundColorBlockInColumns(block.id, color); } + + // Handle bookmark view mode (card / tile / cover) inside columns + if (action.type === 'bookmarkViewMode') { + const mode = (action.payload || {}).viewMode as 'card' | 'tile' | 'cover' | undefined; + if (mode === 'card' || mode === 'tile' || mode === 'cover') { + this.setBookmarkViewModeInColumns(block.id, mode); + } + } // Handle convert action if (action.type === 'convert') { @@ -770,7 +798,7 @@ export class ColumnsBlockComponent implements AfterViewInit, OnDestroy { getBlockBgColor(block: Block): string | undefined { // Paragraph, heading and list(-item) blocks in columns should not have a full-width // background; their inner editable/input pill handles the colored capsule. - if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list' || block.type === 'list-item') { + if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list' || block.type === 'list-item' || block.type === 'bookmark') { return undefined; } const bgColor = (block.meta as any)?.bgColor; diff --git a/src/app/editor/components/block/blocks/embed-block.component.ts b/src/app/editor/components/block/blocks/embed-block.component.ts index 2cf399d..caf6d30 100644 --- a/src/app/editor/components/block/blocks/embed-block.component.ts +++ b/src/app/editor/components/block/blocks/embed-block.component.ts @@ -1,7 +1,8 @@ -import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { Component, Input, Output, EventEmitter, inject, ViewChild, ElementRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Block, EmbedProps } from '../../../core/models/block.model'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; @Component({ selector: 'app-embed-block', @@ -9,17 +10,42 @@ import { Block, EmbedProps } from '../../../core/models/block.model'; imports: [CommonModule, FormsModule], template: ` @if (props.url) { -
-
+
+
+ + @if (showHandle) { + +
+ Redimensionner +
+ + +
+
+
+
+ }
-
+
{{ props.url }}
@@ -33,16 +59,61 @@ import { Block, EmbedProps } from '../../../core/models/block.model'; />
} - ` + `, + styles: [` + .embed-resize-handle { + position: absolute; + width: 10px; + height: 10px; + border-radius: 9999px; + background: rgba(15, 23, 42, 0.95); + border: 1px solid rgba(248, 250, 252, 0.7); + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.9); + cursor: ns-resize; + z-index: 10; + } + + .embed-resize-handle.corner.top-left { + top: 4px; + left: 4px; + } + + .embed-resize-handle.corner.top-right { + top: 4px; + right: 4px; + } + + .embed-resize-handle.corner.bottom-left { + bottom: 4px; + left: 4px; + } + + .embed-resize-handle.corner.bottom-right { + bottom: 4px; + right: 4px; + } + `] }) export class EmbedBlockComponent { @Input({ required: true }) block!: Block; @Output() update = new EventEmitter(); + private sanitizer = inject(DomSanitizer); + + @ViewChild('frameContainer') frameContainer?: ElementRef; + get props(): EmbedProps { return this.block.props; } + showHandle = false; + private resizing = false; + private resizeDir: 'nw' | 'ne' | 'sw' | 'se' | null = null; + private startX = 0; + private startY = 0; + private startWidth = 0; + private startHeight = 0; + onUrlChange(event: Event): void { const target = event.target as HTMLInputElement; const url = target.value; @@ -50,21 +121,85 @@ export class EmbedBlockComponent { this.update.emit({ ...this.props, url, provider }); } - getSafeUrl(): string { + onResizeStart(event: MouseEvent, dir: 'nw' | 'ne' | 'sw' | 'se' = 'se'): void { + event.preventDefault(); + event.stopPropagation(); + + const container = this.frameContainer?.nativeElement; + const currentWidth = this.props.width || container?.clientWidth || 800; + const currentHeight = this.props.height || container?.clientHeight || 400; + + this.resizing = true; + this.resizeDir = dir; + this.startX = event.clientX; + this.startY = event.clientY; + this.startWidth = currentWidth; + this.startHeight = currentHeight; + + window.addEventListener('mousemove', this.onResizeMove); + window.addEventListener('mouseup', this.onResizeEnd); + } + + private onResizeMove = (event: MouseEvent): void => { + if (!this.resizing) return; + const dx = event.clientX - this.startX; + const dy = event.clientY - this.startY; + + let width = this.startWidth; + let height = this.startHeight; + + switch (this.resizeDir) { + case 'se': + width = this.startWidth + dx; + height = this.startHeight + dy; + break; + case 'sw': + width = this.startWidth - dx; + height = this.startHeight + dy; + break; + case 'ne': + width = this.startWidth + dx; + height = this.startHeight - dy; + break; + case 'nw': + width = this.startWidth - dx; + height = this.startHeight - dy; + break; + default: + break; + } + + const minWidth = 320; + const minHeight = 200; + const nextWidth = Math.max(minWidth, width); + const nextHeight = Math.max(minHeight, height); + + this.update.emit({ ...this.props, width: nextWidth, height: nextHeight }); + }; + + private onResizeEnd = (): void => { + if (!this.resizing) return; + this.resizing = false; + this.resizeDir = null; + window.removeEventListener('mousemove', this.onResizeMove); + window.removeEventListener('mouseup', this.onResizeEnd); + }; + + getSafeUrl(): SafeResourceUrl { // Transform URLs for embedding let url = this.props.url; // YouTube if (url.includes('youtube.com/watch')) { const videoId = new URL(url).searchParams.get('v'); - return `https://www.youtube.com/embed/${videoId}`; + return this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube.com/embed/${videoId}`); } if (url.includes('youtu.be/')) { const videoId = url.split('youtu.be/')[1].split('?')[0]; - return `https://www.youtube.com/embed/${videoId}`; + return this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube.com/embed/${videoId}`); } - return url; + return this.sanitizer.bypassSecurityTrustResourceUrl(url); } detectProvider(url: string): 'youtube' | 'gdrive' | 'maps' | 'generic' { diff --git a/src/app/editor/components/block/blocks/paragraph-block.component.ts b/src/app/editor/components/block/blocks/paragraph-block.component.ts index ecb46ff..132fd26 100644 --- a/src/app/editor/components/block/blocks/paragraph-block.component.ts +++ b/src/app/editor/components/block/blocks/paragraph-block.component.ts @@ -6,13 +6,14 @@ import { Block, ParagraphProps } from '../../../core/models/block.model'; import { DocumentService } from '../../../services/document.service'; import { SelectionService } from '../../../services/selection.service'; import { PaletteService } from '../../../services/palette.service'; +import { UrlPreviewService } from '../../../services/url-preview.service'; import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS } from '../../../core/constants/palette-items'; - +import { UrlPasteMenuComponent, UrlPasteAction } from '../url-paste-menu.component'; @Component({ selector: 'app-paragraph-block', standalone: true, - imports: [CommonModule, FormsModule, BlockInlineToolbarComponent], + imports: [CommonModule, FormsModule, BlockInlineToolbarComponent, UrlPasteMenuComponent], template: `
+ + @if (pasteMenuVisible()) { + + } + @if (inColumn && moreOpen()) {
; @ViewChild('menuPanel') menuPanel?: ElementRef; @@ -168,12 +181,140 @@ export class ParagraphBlockComponent implements AfterViewInit { private slashCommandActive = false; private slashCommandStartOffset = -1; + // Url Paste Menu State + pasteMenuVisible = signal(false); + pastedUrl = signal(''); + pasteMenuPosition = signal({ x: 0, y: 0 }); + getBlockBgColor(): string | undefined { const meta: any = this.block?.meta || {}; const bgColor = meta.bgColor; return bgColor && bgColor !== 'transparent' ? bgColor : undefined; } + onPaste(event: ClipboardEvent): void { + const clipboardData = event.clipboardData; + const pastedText = clipboardData?.getData('text') || ''; + + // Check if pasted text looks like a URL + // Simple regex for http/https + if (/^https?:\/\/[^\s/$.?#].[^\s]*$/i.test(pastedText.trim())) { + event.preventDefault(); + + // Save URL + this.pastedUrl.set(pastedText.trim()); + + // Calculate position for the menu Ă  partir du caret, comme pour le slash-menu, + // afin qu'il apparaisse juste sous l'endroit oĂč l'URL est collĂ©e. + let rect: DOMRect | null = null; + try { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0).cloneRange(); + if (range.getClientRects && range.getClientRects().length > 0) { + rect = range.getClientRects()[0]; + } else { + const span = document.createElement('span'); + span.textContent = '\u200b'; + range.insertNode(span); + rect = span.getBoundingClientRect(); + span.parentNode?.removeChild(span); + } + } + } catch { + rect = null; + } + + if (!rect) { + const editableEl = this.editable?.nativeElement; + if (editableEl && editableEl.getBoundingClientRect) { + rect = editableEl.getBoundingClientRect(); + } + } + + let x = 0; + let y = 0; + const vw = window.innerWidth; + const vh = window.innerHeight; + const estimatedMenuHeight = 180; + const estimatedMenuWidth = 260; + + if (rect) { + x = rect.left; + y = rect.bottom + 8; + + if (x + estimatedMenuWidth > vw - 8) { + x = Math.max(8, vw - estimatedMenuWidth - 8); + } + if (x < 8) { + x = 8; + } + + // Si pas assez de place en bas, le placer au-dessus du caret / prompt + if (y + estimatedMenuHeight > vh - 8) { + y = rect.top - estimatedMenuHeight - 8; + } + if (y < 8) { + y = 8; + } + } + + this.pasteMenuPosition.set({ x, y }); + + // Show menu + this.pasteMenuVisible.set(true); + } + } + + onPasteMenuCancel(): void { + this.pasteMenuVisible.set(false); + } + + onPasteMenuAction(event: { type: UrlPasteAction, title?: string }): void { + const url = this.pastedUrl(); + const { type, title } = event; + + this.pasteMenuVisible.set(false); + + if (type === 'url') { + // Insert URL as plain text (or link?) - Request said "affiche l'url dans le bloc paragraphe" + // We will replace the paragraph content with the URL + this.update.emit({ text: url }); + if (this.editable?.nativeElement) { + this.editable.nativeElement.textContent = url; + } + } else if (type === 'title') { + // Replace content with title + const textToInsert = title || url; + this.update.emit({ text: textToInsert }); + if (this.editable?.nativeElement) { + this.editable.nativeElement.textContent = textToInsert; + } + } else if (type === 'embed') { + this.handleEmbedAction(url, title); + } else if (type === 'bookmark') { + // Convert to bookmark block + this.documentService.updateBlock(this.block.id, { + type: 'bookmark', + props: { + url: url, + title: title, + viewMode: 'card' + } + } as any); + } else if (type === 'button') { + // Convert to button block + this.documentService.updateBlock(this.block.id, { + type: 'button', + props: { + label: title || 'Button', + url: url, + variant: 'primary' + } + } as any); + } + } + onInlineAction(type: any): void { if (type === 'more' || type === 'menu') { // In both normal flow and columns, open the global block palette @@ -643,6 +784,68 @@ export class ParagraphBlockComponent implements AfterViewInit { } } + private async handleEmbedAction(url: string, title?: string): Promise { + // Try to decide if this URL can be safely embedded. Some major sites + // (Google, Bing, etc.) explicitly disallow iframes via X-Frame-Options + // or CSP. For those, we fallback to a rich bookmark card instead of an + // empty/broken embed frame. + + let previewHost = ''; + let previewData: any = null; + try { + previewData = await this.urlPreview.getPreview(url); + const effectiveUrl = previewData?.url || url; + try { + previewHost = new URL(effectiveUrl).host; + } catch { + previewHost = ''; + } + } catch { + // If preview fails we just keep previewData = null and fall back to + // normal embed behaviour below. + } + + const noEmbedHosts = [ + 'google.com', + 'www.google.com', + 'bing.com', + 'www.bing.com' + ]; + + const isNoEmbed = !!previewHost && noEmbedHosts.some(h => + previewHost === h || previewHost.endsWith('.' + h) + ); + + if (isNoEmbed) { + // Fallback: create a rich bookmark card instead of an embed block. + this.documentService.updateBlock(this.block.id, { + type: 'bookmark', + props: { + url: previewData?.url || url, + title: previewData?.title || title || '', + description: previewData?.description || '', + siteName: previewData?.siteName || '', + imageUrl: previewData?.imageUrl || undefined, + faviconUrl: previewData?.faviconUrl || undefined, + // Always use the "cover" layout: big visual card with image when + // available, or a pure text band when there is no image (like the + // Google example). + viewMode: 'cover' + } + } as any); + return; + } + + // Default behaviour: convert to an embed block. + this.documentService.updateBlock(this.block.id, { + type: 'embed', + props: { + url: url, + provider: 'generic' + } + } as any); + } + onMenuFocusOut(event: FocusEvent): void { if (!this.menuPanel) return; const panel = this.menuPanel.nativeElement; diff --git a/src/app/editor/components/block/url-paste-menu.component.ts b/src/app/editor/components/block/url-paste-menu.component.ts new file mode 100644 index 0000000..2487344 --- /dev/null +++ b/src/app/editor/components/block/url-paste-menu.component.ts @@ -0,0 +1,180 @@ +import { Component, EventEmitter, Input, Output, inject, signal, OnInit, AfterViewInit, ViewChild, ElementRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { UrlPreviewService } from '../../services/url-preview.service'; + +export type UrlPasteAction = 'url' | 'title' | 'embed' | 'bookmark' | 'button'; + +@Component({ + selector: 'app-url-paste-menu', + standalone: true, + imports: [CommonModule], + template: ` +
+
+ {{ url }} +
+ + + + + + + + + + +
+ `, + styles: [` + :host { + display: block; + } + `] +}) +export class UrlPasteMenuComponent implements OnInit { + @Input({ required: true }) url!: string; + @Input({ required: true }) position!: { x: number; y: number }; + @Output() action = new EventEmitter<{ type: UrlPasteAction, title?: string }>(); + @Output() cancel = new EventEmitter(); + + @ViewChild('menuRoot') menuRoot?: ElementRef; + + private urlPreview = inject(UrlPreviewService); + + siteTitle = signal(''); + loading = signal(false); + + ngOnInit() { + this.fetchTitle(); + } + + ngAfterViewInit(): void { + setTimeout(() => { + const root = this.menuRoot?.nativeElement; + const firstButton = root?.querySelector('button'); + firstButton?.focus(); + }, 0); + } + + async fetchTitle() { + this.loading.set(true); + try { + const data = await this.urlPreview.getPreview(this.url); + if (data.title) { + this.siteTitle.set(data.title); + } + } catch (e) { + // silent fail + } finally { + this.loading.set(false); + } + } + + select(type: UrlPasteAction) { + this.action.emit({ type, title: this.siteTitle() }); + } + + onKeyDown(event: KeyboardEvent): void { + const root = this.menuRoot?.nativeElement; + if (!root) return; + + const buttons = Array.from(root.querySelectorAll('button')); + if (!buttons.length) return; + + const active = document.activeElement as HTMLElement | null; + let index = buttons.findIndex(btn => btn === active); + + if (event.key === 'ArrowDown') { + event.preventDefault(); + index = index < 0 ? 0 : Math.min(buttons.length - 1, index + 1); + buttons[index].focus(); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + index = index < 0 ? buttons.length - 1 : Math.max(0, index - 1); + buttons[index].focus(); + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + if (active && active.tagName === 'BUTTON') { + event.preventDefault(); + (active as HTMLButtonElement).click(); + } + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + this.cancel.emit(); + return; + } + } +} diff --git a/src/app/editor/core/models/block.model.ts b/src/app/editor/core/models/block.model.ts index d95924b..56ae331 100644 --- a/src/app/editor/core/models/block.model.ts +++ b/src/app/editor/core/models/block.model.ts @@ -188,7 +188,11 @@ export interface FileProps { export interface ButtonProps { label: string; url: string; - variant?: 'primary' | 'secondary' | 'outline'; + variant?: 'primary' | 'secondary' | 'outline' | '3d' | 'shadow' | 'default'; + openInNewTab?: boolean; + shape?: 'pill' | 'rounded' | 'square'; + backgroundColor?: string; + size?: 'small' | 'medium' | 'large'; } export interface LinkProps { @@ -292,6 +296,7 @@ export interface BookmarkProps { faviconUrl?: string; loading?: boolean; error?: string | null; + viewMode?: 'card' | 'tile' | 'cover'; } /** diff --git a/src/app/editor/services/document.service.ts b/src/app/editor/services/document.service.ts index f50456a..4862ba3 100644 --- a/src/app/editor/services/document.service.ts +++ b/src/app/editor/services/document.service.ts @@ -575,7 +575,7 @@ export class DocumentService { { id: generateId(), blocks: [], width: 50 } ] }; - case 'bookmark': return { url: '', title: '', description: '', siteName: '', imageUrl: '', faviconUrl: '', loading: false, error: null }; + case 'bookmark': return { url: '', title: '', description: '', siteName: '', imageUrl: '', faviconUrl: '', loading: false, error: null, viewMode: 'card' }; default: return {}; } } diff --git a/vault/tests/nimbus-editor-snapshot.md b/vault/tests/nimbus-editor-snapshot.md index a654810..63eb81a 100644 --- a/vault/tests/nimbus-editor-snapshot.md +++ b/vault/tests/nimbus-editor-snapshot.md @@ -10,45 +10,21 @@ documentModelFormat: "block-model-v1" "title": "Page Tests", "blocks": [ { - "id": "block_1763595565820_kugx72rs2", - "type": "bookmark", + "id": "block_1763664525710_yg13o2ra5", + "type": "embed", "props": { - "url": "https://antigravity.google/support", - "title": "Google Antigravity", - "description": "Google Antigravity - Build the new way", - "siteName": "Google Antigravity", - "imageUrl": "https://antigravity.google/assets/image/sitecards/sitecard-default.png", - "faviconUrl": "assets/image/antigravity-logo.png", - "loading": false, - "error": null + "url": "https://www.youtube.com/watch?v=9z1GInFvwA0", + "provider": "generic" }, "meta": { - "createdAt": "2025-11-19T23:39:25.820Z", - "updatedAt": "2025-11-19T23:48:19.732Z" - } - }, - { - "id": "block_1763591724959_af8w3g4ra", - "type": "code", - "props": { - "code": "{\r\n \"id\": \"block_1763149113471_461xyut80\",\r\n \"title\": \"Page Tests\",\r\n \"blocks\": [\r\n {\r\n \"id\": \"block_1763591049445_1hgsuarl4\",\r\n \"type\": \"code\",\r\n \"props\": {\r\n \"code\": \"#!/usr/bin/env pwsh\\r\\n# Script de dĂ©marrage rapide pour ObsiViewer en mode dĂ©veloppement\\r\\n\\r\\nparam(\\r\\n [string]$VaultPath = \\\"./vault\\\",\\r\\n [switch]$SkipMeili,\\r\\n [switch]$ResetMeili,\\r\\n [switch]$Help\\r\\n)\\r\\n\\r\\nif ($Help) {\\r\\n Write-Host @\\\"\\r\\nUsage: .\\\\start-dev.ps1 [-VaultPath ] [-SkipMeili] [-Help]\\r\\n\\r\\nOptions:\\r\\n -VaultPath Chemin vers votre vault Obsidian (dĂ©faut: ./vault)\\r\\n -SkipMeili Ne pas dĂ©marrer Meilisearch\\r\\n -ResetMeili Supprimer le conteneur et le volume Meilisearch avant de redĂ©marrer\\r\\n -SkipMeili Ne pas dĂ©marrer Meilisearch\\r\\n -Help Afficher cette aide\\r\\n\\r\\nExemples:\\r\\n .\\\\start-dev.ps1\\r\\n .\\\\start-dev.ps1 -VaultPath C:\\\\Users\\\\moi\\\\Documents\\\\MonVault\\r\\n .\\\\start-dev.ps1 -SkipMeili\\r\\n\\\"@\\r\\n exit 0\\r\\n}\\r\\n\\r\\n$ErrorActionPreference = \\\"Stop\\\"\\r\\n\\r\\nWrite-Host \\\"🚀 DĂ©marrage d'ObsiViewer en mode dĂ©veloppement\\\" -ForegroundColor Cyan\\r\\nWrite-Host \\\"\\\"\\r\\n\\r\\n# Diagnostic: VĂ©rifier les variables Meilisearch existantes\\r\\n$meiliVars = Get-ChildItem Env: | Where-Object { $_.Name -like 'MEILI*' }\\r\\nif ($meiliVars) {\\r\\n Write-Host \\\"⚠ Variables Meilisearch dĂ©tectĂ©es dans l'environnement:\\\" -ForegroundColor Yellow\\r\\n foreach ($var in $meiliVars) {\\r\\n Write-Host \\\" $($var.Name) = $($var.Value)\\\" -ForegroundColor Gray\\r\\n }\\r\\n Write-Host \\\" Ces variables seront purgĂ©es...\\\" -ForegroundColor Yellow\\r\\n Write-Host \\\"\\\"\\r\\n}\\r\\n\\r\\n# VĂ©rifier que le vault existe\\r\\nif (-not (Test-Path $VaultPath)) {\\r\\n Write-Host \\\"⚠ Le vault n'existe pas: $VaultPath\\\" -ForegroundColor Yellow\\r\\n Write-Host \\\" CrĂ©ation du dossier...\\\" -ForegroundColor Yellow\\r\\n New-Item -ItemType Directory -Path $VaultPath -Force | Out-Null\\r\\n}\\r\\n\\r\\n$VaultPathAbsolute = Resolve-Path $VaultPath\\r\\nWrite-Host \\\"📁 Vault: $VaultPathAbsolute\\\" -ForegroundColor Green\\r\\n\\r\\n# VĂ©rifier si .env existe\\r\\nif (-not (Test-Path \\\".env\\\")) {\\r\\n Write-Host \\\"⚠ Fichier .env manquant\\\" -ForegroundColor Yellow\\r\\n if (Test-Path \\\".env.example\\\") {\\r\\n Write-Host \\\" Copie de .env.example vers .env...\\\" -ForegroundColor Yellow\\r\\n Copy-Item \\\".env.example\\\" \\\".env\\\"\\r\\n }\\r\\n}\\r\\n\\r\\n# Purger TOUTES les variables d'environnement Meilisearch conflictuelles\\r\\n$meiliVarsToPurge = Get-ChildItem Env: | Where-Object { $_.Name -like 'MEILI*' }\\r\\nif ($meiliVarsToPurge) {\\r\\n Write-Host \\\"đŸ§č Purge des variables Meilisearch existantes...\\\" -ForegroundColor Cyan\\r\\n foreach ($var in $meiliVarsToPurge) {\\r\\n Remove-Item \\\"Env:\\\\$($var.Name)\\\" -ErrorAction SilentlyContinue\\r\\n Write-Host \\\" ✓ $($var.Name) supprimĂ©e\\\" -ForegroundColor Gray\\r\\n }\\r\\n Write-Host \\\"\\\"\\r\\n}\\r\\n\\r\\n# DĂ©finir les variables d'environnement pour la session\\r\\n$env:VAULT_PATH = $VaultPathAbsolute\\r\\n$env:MEILI_MASTER_KEY = \\\"devMeiliKey123\\\"\\r\\n$env:MEILI_HOST = \\\"http://127.0.0.1:7700\\\"\\r\\n$env:PORT = \\\"4000\\\"\\r\\n\\r\\nWrite-Host \\\"✅ Variables d'environnement dĂ©finies:\\\" -ForegroundColor Green\\r\\nWrite-Host \\\" VAULT_PATH=$env:VAULT_PATH\\\" -ForegroundColor Gray\\r\\nWrite-Host \\\" MEILI_MASTER_KEY=devMeiliKey123\\\" -ForegroundColor Gray\\r\\nWrite-Host \\\" MEILI_HOST=$env:MEILI_HOST\\\" -ForegroundColor Gray\\r\\n\\r\\n# DĂ©marrer Meilisearch si demandĂ©\\r\\nif (-not $SkipMeili) {\\r\\n Write-Host \\\"\\\"\\r\\n Write-Host \\\"🔍 DĂ©marrage de Meilisearch...\\\" -ForegroundColor Cyan\\r\\n \\r\\n if ($ResetMeili) {\\r\\n Write-Host \\\"đŸ§č RĂ©initialisation de Meilisearch (conteneur + volume)...\\\" -ForegroundColor Yellow\\r\\n try {\\r\\n Push-Location \\\"docker-compose\\\"\\r\\n docker compose down -v meilisearch 2>$null | Out-Null\\r\\n Pop-Location\\r\\n } catch {\\r\\n Pop-Location 2>$null\\r\\n }\\r\\n # Forcer la suppression ciblĂ©e si nĂ©cessaire\\r\\n docker rm -f obsiviewer-meilisearch 2>$null | Out-Null\\r\\n docker volume rm -f docker-compose_meili_data 2>$null | Out-Null\\r\\n }\\r\\n\\r\\n # VĂ©rifier si Meilisearch est dĂ©jĂ  en cours\\r\\n $meiliRunning = docker ps --filter \\\"name=obsiviewer-meilisearch\\\" --format \\\"{{.Names}}\\\" 2>$null\\r\\n \\r\\n if ($meiliRunning) {\\r\\n Write-Host \\\" ✓ Meilisearch dĂ©jĂ  en cours\\\" -ForegroundColor Green\\r\\n } else {\\r\\n npm run meili:up\\r\\n Write-Host \\\" ⏳ Attente du dĂ©marrage de Meilisearch...\\\" -ForegroundColor Yellow\\r\\n }\\r\\n \\r\\n # Attendre la santĂ© du service /health\\r\\n $healthTimeoutSec = 30\\r\\n $healthUrl = \\\"http://127.0.0.1:7700/health\\\"\\r\\n $startWait = Get-Date\\r\\n while ($true) {\\r\\n try {\\r\\n $resp = Invoke-RestMethod -Uri $healthUrl -Method GET -TimeoutSec 3\\r\\n if ($resp.status -eq \\\"available\\\") {\\r\\n Write-Host \\\" ✓ Meilisearch est prĂȘt\\\" -ForegroundColor Green\\r\\n break\\r\\n }\\r\\n } catch {\\r\\n # ignore and retry\\r\\n }\\r\\n if (((Get-Date) - $startWait).TotalSeconds -ge $healthTimeoutSec) {\\r\\n Write-Host \\\" ⚠ Timeout d'attente de Meilisearch (continuer quand mĂȘme)\\\" -ForegroundColor Yellow\\r\\n break\\r\\n }\\r\\n Start-Sleep -Milliseconds 500\\r\\n }\\r\\n\\r\\n Write-Host \\\"\\\"\\r\\n Write-Host \\\"📊 Indexation du vault...\\\" -ForegroundColor Cyan\\r\\n npm run meili:reindex\\r\\n}\\r\\n\\r\\nWrite-Host \\\"\\\"\\r\\nWrite-Host \\\"✅ Configuration terminĂ©e!\\\" -ForegroundColor Green\\r\\nWrite-Host \\\"\\\"\\r\\nWrite-Host \\\"Les variables d'environnement sont dĂ©finies dans cette session PowerShell.\\\" -ForegroundColor Yellow\\r\\nWrite-Host \\\"\\\"\\r\\nWrite-Host \\\"Pour dĂ©marrer l'application, ouvrez 2 terminaux:\\\" -ForegroundColor Yellow\\r\\nWrite-Host \\\"\\\"\\r\\nWrite-Host \\\"Terminal 1 (Backend):\\\" -ForegroundColor Cyan\\r\\nWrite-Host \\\" node server/index.mjs\\\" -ForegroundColor White\\r\\nWrite-Host \\\" (Les variables VAULT_PATH, MEILI_MASTER_KEY sont dĂ©jĂ  dĂ©finies)\\\" -ForegroundColor Gray\\r\\nWrite-Host \\\"\\\"\\r\\nWrite-Host \\\"Terminal 2 (Frontend):\\\" -ForegroundColor Cyan\\r\\nWrite-Host \\\" npm run dev\\\" -ForegroundColor White\\r\\nWrite-Host \\\"\\\"\\r\\nWrite-Host \\\"⚠ IMPORTANT: Si vous fermez ce terminal, les variables seront perdues.\\\" -ForegroundColor Yellow\\r\\nWrite-Host \\\" Relancez ce script ou dĂ©finissez manuellement:\\\" -ForegroundColor Yellow\\r\\nWrite-Host \\\" `$env:VAULT_PATH='$VaultPathAbsolute'\\\" -ForegroundColor Gray\\r\\nWrite-Host \\\" `$env:MEILI_MASTER_KEY='devMeiliKey123'\\\" -ForegroundColor Gray\\r\\nWrite-Host \\\" Remove-Item Env:\\\\MEILI_API_KEY -ErrorAction SilentlyContinue\\\" -ForegroundColor Gray\\r\\nWrite-Host \\\"\\\"\\r\\nWrite-Host \\\"AccĂšs:\\\" -ForegroundColor Yellow\\r\\nWrite-Host \\\" Frontend: http://localhost:3000\\\" -ForegroundColor White\\r\\nWrite-Host \\\" Backend API: http://localhost:4000\\\" -ForegroundColor White\\r\\nWrite-Host \\\" Meilisearch: http://localhost:7700\\\" -ForegroundColor White\\r\\nWrite-Host \\\"\\\"\\r\\n\",\r\n \"lang\": \"powershell\",\r\n \"theme\": \"default\",\r\n \"showLineNumbers\": true,\r\n \"enableWrap\": false,\r\n \"collapsed\": false,\r\n \"font\": \"courier\",\r\n \"autoDetectLang\": false\r\n },\r\n \"meta\": {\r\n \"createdAt\": \"2025-11-19T22:24:09.445Z\",\r\n \"updatedAt\": \"2025-11-19T22:33:16.600Z\"\r\n }\r\n }\r\n ],\r\n \"meta\": {\r\n \"createdAt\": \"2025-11-14T19:38:33.471Z\",\r\n \"updatedAt\": \"2025-11-19T22:33:16.600Z\"\r\n }\r\n}\n", - "lang": "json", - "showLineNumbers": true, - "enableWrap": false, - "collapsed": true, - "autoDetectLang": false, - "theme": "darcula", - "font": "fira" - }, - "meta": { - "createdAt": "2025-11-19T22:35:24.959Z", - "updatedAt": "2025-11-19T23:39:21.275Z" + "createdAt": "2025-11-20T18:48:45.710Z", + "updatedAt": "2025-11-20T18:48:53.317Z" } } ], "meta": { "createdAt": "2025-11-14T19:38:33.471Z", - "updatedAt": "2025-11-19T23:48:19.732Z" + "updatedAt": "2025-11-20T18:48:53.317Z" } } ```