feat: enhance embed block resizing, improve bookmark viewMode reactivity, and redesign tests panel UI

- Fixed bookmark block viewMode not updating on block changes by converting to setter/getter pattern with cached property
- Enhanced embed block with persistent resize handles using opacity transitions instead of DOM changes to prevent iframe flashing
- Improved YouTube URL handling to support watch, youtu.be, shorts, and embed formats with normalized embed URLs
- Added iframe permissions (
This commit is contained in:
Bruno Charest 2025-11-20 22:00:15 -05:00
parent 6b47ec39ff
commit 843258be44
8 changed files with 319 additions and 75 deletions

View File

@ -127,7 +127,19 @@ import { UrlPreviewService } from '../../../services/url-preview.service';
`, `,
}) })
export class BookmarkBlockComponent implements AfterViewInit, OnDestroy { export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
@Input({ required: true }) block!: Block<BookmarkProps>; private _block!: Block<BookmarkProps>;
@Input({ required: true })
set block(value: Block<BookmarkProps>) {
this._block = value;
// Update viewMode immediately when block changes
this.viewMode = this.props.viewMode || 'card';
}
get block(): Block<BookmarkProps> {
return this._block;
}
@Output() update = new EventEmitter<BookmarkProps>(); @Output() update = new EventEmitter<BookmarkProps>();
@Input() compact = false; @Input() compact = false;
@ -145,9 +157,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
return this.block.props; return this.block.props;
} }
get viewMode(): 'card' | 'tile' | 'cover' { viewMode: 'card' | 'tile' | 'cover' = 'card';
return this.props.viewMode || 'card';
}
get backgroundColor(): string | null { get backgroundColor(): string | null {
const meta: any = this.block.meta || {}; const meta: any = this.block.meta || {};

View File

@ -12,8 +12,8 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
@if (props.url) { @if (props.url) {
<div <div
class="border rounded-xl overflow-hidden group bg-surface2" class="border rounded-xl overflow-hidden group bg-surface2"
(mouseenter)="showHandle = true" [class.w-full]="!props.width"
(mouseleave)="showHandle = false" [style.width.px]="props.width || null"
> >
<div <div
#frameContainer #frameContainer
@ -25,25 +25,28 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
<iframe <iframe
[src]="getSafeUrl()" [src]="getSafeUrl()"
class="w-full h-full border-0" class="w-full h-full border-0"
referrerpolicy="no-referrer" referrerpolicy="no-referrer-when-downgrade"
loading="lazy" loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe> ></iframe>
@if (showHandle) { <!-- Handles toujours présents, affichés via group-hover pour éviter les
<!-- Poignée principale (texte) en bas à droite --> changements de DOM qui peuvent faire "flasher" l'iframe. -->
<div class="pointer-events-none">
<div <div
class="absolute right-2 bottom-2 flex items-center gap-1 text-[11px] text-neutral-200 bg-neutral-900/70 px-2 py-1 rounded-full cursor-row-resize select-none" class="absolute right-2 bottom-2 flex items-center gap-1 text-[11px] text-neutral-200 bg-neutral-900/70 px-2 py-1 rounded-full cursor-row-resize select-none opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
(mousedown)="onResizeStart($event)" (mousedown)="onResizeStart($event)"
> >
<span>Redimensionner</span> <span>Redimensionner</span>
</div> </div>
<!-- Poignées discrètes aux 4 coins pour un redimensionnement plus fin --> <!-- Poignées discrètes aux 4 coins pour un redimensionnement plus fin -->
<div class="embed-resize-handle corner top-left" (mousedown)="onResizeStart($event, 'nw')"></div> <div class="embed-resize-handle corner top-left opacity-0 group-hover:opacity-100 pointer-events-auto" (mousedown)="onResizeStart($event, 'nw')"></div>
<div class="embed-resize-handle corner top-right" (mousedown)="onResizeStart($event, 'ne')"></div> <div class="embed-resize-handle corner top-right opacity-0 group-hover:opacity-100 pointer-events-auto" (mousedown)="onResizeStart($event, 'ne')"></div>
<div class="embed-resize-handle corner bottom-left" (mousedown)="onResizeStart($event, 'sw')"></div> <div class="embed-resize-handle corner bottom-left opacity-0 group-hover:opacity-100 pointer-events-auto" (mousedown)="onResizeStart($event, 'sw')"></div>
<div class="embed-resize-handle corner bottom-right" (mousedown)="onResizeStart($event, 'se')"></div> <div class="embed-resize-handle corner bottom-right opacity-0 group-hover:opacity-100 pointer-events-auto" (mousedown)="onResizeStart($event, 'se')"></div>
} </div>
</div> </div>
<div class="p-2 bg-surface1 text-xs text-text-muted truncate border-t border-neutral-800"> <div class="p-2 bg-surface1 text-xs text-text-muted truncate border-t border-neutral-800">
{{ props.url }} {{ props.url }}
@ -189,16 +192,53 @@ export class EmbedBlockComponent {
// Transform URLs for embedding // Transform URLs for embedding
let url = this.props.url; let url = this.props.url;
// YouTube // Normalize to handle common YouTube URL shapes and generate
if (url.includes('youtube.com/watch')) { // standard https://www.youtube.com/embed/VIDEO_ID URLs.
const videoId = new URL(url).searchParams.get('v'); try {
return this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube.com/embed/${videoId}`); const u = new URL(url);
const host = u.host;
// Standard watch URL: https://www.youtube.com/watch?v=VIDEO_ID
if (host.includes('youtube.com') && u.pathname === '/watch') {
const videoId = u.searchParams.get('v');
if (videoId) {
return this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube.com/embed/${videoId}?rel=0`
);
} }
if (url.includes('youtu.be/')) {
const videoId = url.split('youtu.be/')[1].split('?')[0];
return this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube.com/embed/${videoId}`);
} }
// Short URL: https://youtu.be/VIDEO_ID
if (host === 'youtu.be') {
const parts = u.pathname.split('/').filter(Boolean);
const videoId = parts[0];
if (videoId) {
return this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube.com/embed/${videoId}?rel=0`
);
}
}
// Shorts URL: https://www.youtube.com/shorts/VIDEO_ID
if (host.includes('youtube.com') && u.pathname.startsWith('/shorts/')) {
const parts = u.pathname.split('/').filter(Boolean);
const videoId = parts[1];
if (videoId) {
return this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube.com/embed/${videoId}?rel=0`
);
}
}
// Already an embed URL: keep it but sanitize
if (host.includes('youtube.com') && u.pathname.startsWith('/embed/')) {
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
}
} catch {
// If URL parsing fails, fallback to the raw value below.
}
// Fallback: trust the original URL as-is (for other providers).
return this.sanitizer.bypassSecurityTrustResourceUrl(url); return this.sanitizer.bypassSecurityTrustResourceUrl(url);
} }

View File

@ -31,12 +31,39 @@ interface TestResult {
</p> </p>
</div> </div>
<div class="panel-layout">
<div class="panel-content"> <div class="panel-content">
<!-- Test Sections --> <!-- Test Sections -->
<div class="test-sections"> <div class="test-sections">
<div class="test-section">
<h3> Quick checks</h3>
<div class="quick-actions">
<button type="button" class="test-btn test-btn--primary" (click)="runHealthCheck()" [disabled]="isRunning()">
/api/health
</button>
<button type="button" class="test-btn test-btn--secondary" (click)="runVaultMetadata()" [disabled]="isRunning()">
/api/vault/metadata
</button>
<button type="button" class="test-btn test-btn--secondary" (click)="runVaultPaginated()" [disabled]="isRunning()">
/api/vault/metadata/paginated
</button>
<button type="button" class="test-btn test-btn--info" (click)="runLogTest()" [disabled]="isRunning()">
Logging
</button>
<button type="button" class="test-btn test-btn--accent" (click)="runSimulateEvent()" [disabled]="isRunning()">
Simulate event
</button>
<button type="button" class="test-btn test-btn--warning quick-actions__full" (click)="runBatchTest()" [disabled]="isRunning()">
Full smoke suite
</button>
</div>
<p class="quick-actions__hint">
Results and timings appear on the right. Use filters there to focus on failed calls.
</p>
</div>
<div class="test-section"> <div class="test-section">
<h3>🔭 All APIs Explorer</h3> <h3>🔭 API Explorer</h3>
<div class="file-test-form" style="margin-bottom: 12px;"> <div class="file-test-form" style="margin-bottom: 12px;">
<div class="form-row"> <div class="form-row">
<label for="apiSearch">Search endpoint</label> <label for="apiSearch">Search endpoint</label>
@ -53,7 +80,6 @@ interface TestResult {
</div> </div>
</div> </div>
<div class="file-test-form" *ngIf="currentEndpoint"> <div class="file-test-form" *ngIf="currentEndpoint">
<div class="form-row" *ngIf="currentEndpoint.pathParams?.length"> <div class="form-row" *ngIf="currentEndpoint.pathParams?.length">
<label>Path params</label> <label>Path params</label>
@ -102,27 +128,44 @@ interface TestResult {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Results Panel --> <!-- Results Panel -->
<div class="results-panel"> <div class="results-panel">
<div class="results-header"> <div class="results-header">
<div>
<h3>Test Results</h3> <h3>Test Results</h3>
<p class="results-subtitle" *ngIf="results().length === 0">No tests yet. Use a quick check or the explorer to start.</p>
<p class="results-subtitle" *ngIf="results().length > 0">Newest first {{ results().length }} result{{ results().length > 1 ? 's' : '' }}</p>
</div>
<div class="results-tools">
<div class="results-filters">
<button type="button" class="filter-chip" [class.filter-chip--active]="isStatusFilterActive('all')" (click)="setStatusFilter('all')">All</button>
<button type="button" class="filter-chip filter-chip--success" [class.filter-chip--active]="isStatusFilterActive('success')" (click)="setStatusFilter('success')">Success</button>
<button type="button" class="filter-chip filter-chip--error" [class.filter-chip--active]="isStatusFilterActive('error')" (click)="setStatusFilter('error')">Errors</button>
<button type="button" class="filter-chip filter-chip--running" [class.filter-chip--active]="isStatusFilterActive('running')" (click)="setStatusFilter('running')">Running</button>
</div>
<button type="button" class="clear-btn" (click)="clearResults()" [disabled]="results().length === 0"> <button type="button" class="clear-btn" (click)="clearResults()" [disabled]="results().length === 0">
Clear Results Clear
</button> </button>
</div> </div>
</div>
<div class="results-list" *ngIf="results().length > 0; else noResults"> <div class="results-list" *ngIf="getFilteredResults().length > 0; else noResults">
<div *ngFor="let result of results()" class="result-item" [ngClass]="getResultClass(result)"> <div *ngFor="let result of getFilteredResults()" class="result-item" [ngClass]="getResultClass(result)">
<div class="result-header"> <div class="result-header">
<span class="result-method">{{ result.method }}</span> <span class="result-method">{{ result.method }}</span>
<span class="result-endpoint">{{ result.endpoint }}</span> <span class="result-endpoint">{{ result.endpoint }}</span>
<span class="result-status">{{ result.status.toUpperCase() }}</span> <span class="result-status">{{ result.status.toUpperCase() }}</span>
<span class="result-duration" *ngIf="result.duration">({{ result.duration }}ms)</span> <span class="result-duration" *ngIf="result.duration">({{ result.duration }}ms)</span>
</div> </div>
<div class="result-details" *ngIf="result.response || result.error"> <div class="result-meta">
<span class="result-timestamp">{{ result.timestamp | date:'mediumTime' }}</span>
<button type="button" class="result-toggle" (click)="toggleResultDetails(result)">
{{ isResultExpanded(result) ? 'Hide payload' : 'View payload' }}
</button>
</div>
<div class="result-details" *ngIf="isResultExpanded(result) && (result.response || result.error)">
<pre class="result-json">{{ result.response ? (result.response | json) : result.error }}</pre> <pre class="result-json">{{ result.response ? (result.response | json) : result.error }}</pre>
</div> </div>
</div> </div>
@ -130,7 +173,8 @@ interface TestResult {
<ng-template #noResults> <ng-template #noResults>
<div class="no-results"> <div class="no-results">
<p>No tests run yet. Click a button above to start testing!</p> <p>No tests run yet.</p>
<p>Start with a quick health check or run the full smoke suite.</p>
</div> </div>
</ng-template> </ng-template>
</div> </div>
@ -174,11 +218,10 @@ interface TestResult {
margin: 0 auto; margin: 0 auto;
} }
.panel-content { .panel-layout {
display: grid; display: flex;
gap: 30px; flex-direction: column;
grid-template-columns: minmax(0, 1fr) 520px; gap: 20px;
align-items: start;
} }
.test-sections { .test-sections {
@ -200,6 +243,22 @@ interface TestResult {
color: var(--text-main); color: var(--text-main);
} }
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.quick-actions__full {
flex: 1 1 100%;
}
.quick-actions__hint {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.test-group { .test-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -322,11 +381,10 @@ interface TestResult {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
background: var(--card); background: var(--card);
position: sticky;
top: 20px;
max-height: calc(100vh - 60px);
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
position: static;
z-index: 0;
} }
.results-header { .results-header {
@ -344,6 +402,55 @@ interface TestResult {
color: var(--text-main); color: var(--text-main);
} }
.results-subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
}
.results-tools {
display: flex;
gap: 10px;
align-items: center;
}
.results-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-chip {
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-1);
font-size: 12px;
cursor: pointer;
color: var(--text-muted);
}
.filter-chip--success {
border-color: color-mix(in srgb, var(--success) 70%, var(--border));
color: var(--success);
}
.filter-chip--error {
border-color: color-mix(in srgb, var(--danger) 70%, var(--border));
color: var(--danger);
}
.filter-chip--running {
border-color: color-mix(in srgb, var(--warning) 70%, var(--border));
color: var(--warning);
}
.filter-chip--active {
background: color-mix(in srgb, var(--primary) 25%, transparent);
color: var(--text-main);
border-color: color-mix(in srgb, var(--primary) 80%, var(--border));
}
.clear-btn { .clear-btn {
padding: 6px 12px; padding: 6px 12px;
background: var(--danger); background: var(--danger);
@ -365,8 +472,7 @@ interface TestResult {
.results-list { .results-list {
overflow-y: auto; overflow-y: auto;
height: 100%; max-height: 500px;
max-height: none;
} }
.result-item { .result-item {
@ -454,6 +560,29 @@ interface TestResult {
color: var(--text-muted); color: var(--text-muted);
} }
.result-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.result-timestamp {
font-size: 12px;
color: var(--text-muted);
}
.result-toggle {
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
font-size: 12px;
cursor: pointer;
color: var(--text-muted);
}
.result-details { .result-details {
margin-top: 10px; margin-top: 10px;
} }
@ -529,13 +658,6 @@ interface TestResult {
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.panel-content {
grid-template-columns: 1fr;
}
.results-panel {
position: static;
max-height: none;
}
.results-list { .results-list {
max-height: 400px; max-height: 400px;
} }
@ -552,13 +674,21 @@ interface TestResult {
padding: 12px 12px 12px 12px; padding: 12px 12px 12px 12px;
} }
.tests-panel--fullscreen .panel-content { .tests-panel--fullscreen .panel-layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
height: calc(100vh - 100px); height: calc(100vh - 100px);
} }
.tests-panel--fullscreen .test-sections { .tests-panel--fullscreen .test-sections {
overflow: auto; overflow: auto;
} }
.tests-panel--fullscreen .results-panel {
align-self: stretch;
}
`] `]
}) })
export class TestsPanelComponent { export class TestsPanelComponent {
@ -574,6 +704,10 @@ export class TestsPanelComponent {
sseEvents = signal<Array<{type: string, data: any, timestamp: Date}>>([]); sseEvents = signal<Array<{type: string, data: any, timestamp: Date}>>([]);
isRunning = signal(false); isRunning = signal(false);
// UI state for results panel
private statusFilter = signal<'all' | 'success' | 'error' | 'running'>('all');
private expandedResults = signal<Set<TestResult>>(new Set<TestResult>());
// SSE connection // SSE connection
private sseConnection: EventSource | null = null; private sseConnection: EventSource | null = null;
isFullscreen = signal(false); isFullscreen = signal(false);
@ -726,6 +860,39 @@ export class TestsPanelComponent {
return sp.toString(); return sp.toString();
} }
// ---------- Results helpers ----------
setStatusFilter(filter: 'all' | 'success' | 'error' | 'running'): void {
this.statusFilter.set(filter);
}
isStatusFilterActive(filter: 'all' | 'success' | 'error' | 'running'): boolean {
return this.statusFilter() === filter;
}
getFilteredResults(): TestResult[] {
const filter = this.statusFilter();
const all = this.results();
if (filter === 'all') {
return all;
}
return all.filter(r => r.status === filter);
}
toggleResultDetails(result: TestResult): void {
const current = new Set(this.expandedResults());
if (current.has(result)) {
current.delete(result);
} else {
current.add(result);
}
this.expandedResults.set(current);
}
isResultExpanded(result: TestResult): boolean {
return this.expandedResults().has(result);
}
async runDynamicRequest(): Promise<void> { async runDynamicRequest(): Promise<void> {
const ep = this.currentEndpoint; const ep = this.currentEndpoint;
if (!ep) return; if (!ep) return;

17
vault/folder-4/NewPage.md Normal file
View File

@ -0,0 +1,17 @@
---
titre: NewPage
auteur: Bruno Charest
creation_date: 2025-11-21T02:01:06.468Z
modification_date: 2025-11-20T21:01:07-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -0,0 +1,15 @@
---
titre: "NewPage"
auteur: "Bruno Charest"
creation_date: "2025-11-21T02:01:06.468Z"
modification_date: "2025-11-21T02:01:06.468Z"
status: "en-cours"
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -1,8 +1,8 @@
--- ---
titre: test-file titre: test-file
auteur: Bruno Charest auteur: Bruno Charest
creation_date: 2025-10-23T13:00:42-04:00 creation_date: 2025-11-20T16:27:20-04:00
modification_date: 2025-10-23T13:00:42-04:00 modification_date: 2025-11-20T16:27:20-04:00
catégorie: "" catégorie: ""
tags: [] tags: []
aliases: [] aliases: []

3
vault/test-file.md.bak Normal file
View File

@ -0,0 +1,3 @@
# Test File
This is a test file for API testing.

View File

@ -2,29 +2,21 @@
title: "Éditeur Nimbus — Section Tests" title: "Éditeur Nimbus — Section Tests"
nimbusEditor: true nimbusEditor: true
documentModelFormat: "block-model-v1" documentModelFormat: "block-model-v1"
tags:
- test2
- configuration
- bruno
- titi
--- ---
```json ```json
{ {
"id": "block_1763149113471_461xyut80", "id": "block_1763149113471_461xyut80",
"title": "Page Tests", "title": "Page Tests",
"blocks": [ "blocks": [],
{
"id": "block_1763664525710_yg13o2ra5",
"type": "embed",
"props": {
"url": "https://www.youtube.com/watch?v=9z1GInFvwA0",
"provider": "generic"
},
"meta": {
"createdAt": "2025-11-20T18:48:45.710Z",
"updatedAt": "2025-11-20T18:48:53.317Z"
}
}
],
"meta": { "meta": {
"createdAt": "2025-11-14T19:38:33.471Z", "createdAt": "2025-11-14T19:38:33.471Z",
"updatedAt": "2025-11-20T18:48:53.317Z" "updatedAt": "2025-11-21T02:28:15.835Z"
} }
} }
``` ```