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: `
-
-
-
+
`
})
@@ -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) {
+
+ }
+
+
+
+
+
+
+
+
+ Petit
+
+
+ Moyen
+
+
+ Grand
+
+
+
+
+
+
+
+
+
+
+
+
+ Annuler
+
+
+ Terminé
+
+
+
+
+ `,
+ 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
+
+
+
+
+
+
+
+ }
-
@@ -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 }}
+
+
+
+
+ URL
+
+
+
+
+ T
+
+
+ Titre
+ @if (siteTitle()) {
+ {{ siteTitle() }}
+ } @else if (loading()) {
+ Loading title...
+ }
+
+
+
+
+
+ Intégrer
+
+
+
+
+ đ
+
+ Marque-page
+
+
+
+
+
+
+ Bouton
+
+
+ `,
+ 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"
}
}
```