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 {
@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>();
@Input() compact = false;
@ -145,9 +157,7 @@ export class BookmarkBlockComponent implements AfterViewInit, OnDestroy {
return this.block.props;
}
get viewMode(): 'card' | 'tile' | 'cover' {
return this.props.viewMode || 'card';
}
viewMode: 'card' | 'tile' | 'cover' = 'card';
get backgroundColor(): string | null {
const meta: any = this.block.meta || {};

View File

@ -12,8 +12,8 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
@if (props.url) {
<div
class="border rounded-xl overflow-hidden group bg-surface2"
(mouseenter)="showHandle = true"
(mouseleave)="showHandle = false"
[class.w-full]="!props.width"
[style.width.px]="props.width || null"
>
<div
#frameContainer
@ -25,25 +25,28 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
<iframe
[src]="getSafeUrl()"
class="w-full h-full border-0"
referrerpolicy="no-referrer"
referrerpolicy="no-referrer-when-downgrade"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
@if (showHandle) {
<!-- Poignée principale (texte) en bas à droite -->
<!-- Handles toujours présents, affichés via group-hover pour éviter les
changements de DOM qui peuvent faire "flasher" l'iframe. -->
<div class="pointer-events-none">
<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)"
>
<span>Redimensionner</span>
</div>
<!-- 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-right" (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-right" (mousedown)="onResizeStart($event, 'se')"></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 opacity-0 group-hover:opacity-100 pointer-events-auto" (mousedown)="onResizeStart($event, 'ne')"></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 opacity-0 group-hover:opacity-100 pointer-events-auto" (mousedown)="onResizeStart($event, 'se')"></div>
</div>
</div>
<div class="p-2 bg-surface1 text-xs text-text-muted truncate border-t border-neutral-800">
{{ props.url }}
@ -188,17 +191,54 @@ export class EmbedBlockComponent {
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 this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube.com/embed/${videoId}`);
// Normalize to handle common YouTube URL shapes and generate
// standard https://www.youtube.com/embed/VIDEO_ID URLs.
try {
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`
);
}
}
// 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.
}
if (url.includes('youtu.be/')) {
const videoId = url.split('youtu.be/')[1].split('?')[0];
return this.sanitizer.bypassSecurityTrustResourceUrl(`https://www.youtube.com/embed/${videoId}`);
}
// Fallback: trust the original URL as-is (for other providers).
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
}

View File

@ -31,12 +31,39 @@ interface TestResult {
</p>
</div>
<div class="panel-content">
<!-- Test Sections -->
<div class="test-sections">
<div class="panel-layout">
<div class="panel-content">
<!-- Test Sections -->
<div class="test-sections">
<div class="test-section">
<h3>🔭 All APIs Explorer</h3>
<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">
<h3>🔭 API Explorer</h3>
<div class="file-test-form" style="margin-bottom: 12px;">
<div class="form-row">
<label for="apiSearch">Search endpoint</label>
@ -53,7 +80,6 @@ interface TestResult {
</div>
</div>
<div class="file-test-form" *ngIf="currentEndpoint">
<div class="form-row" *ngIf="currentEndpoint.pathParams?.length">
<label>Path params</label>
@ -102,27 +128,44 @@ interface TestResult {
</div>
</div>
</div>
</div>
<!-- Results Panel -->
<div class="results-panel">
<div class="results-header">
<h3>Test Results</h3>
<button type="button" class="clear-btn" (click)="clearResults()" [disabled]="results().length === 0">
Clear Results
</button>
<div>
<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">
Clear
</button>
</div>
</div>
<div class="results-list" *ngIf="results().length > 0; else noResults">
<div *ngFor="let result of results()" class="result-item" [ngClass]="getResultClass(result)">
<div class="results-list" *ngIf="getFilteredResults().length > 0; else noResults">
<div *ngFor="let result of getFilteredResults()" class="result-item" [ngClass]="getResultClass(result)">
<div class="result-header">
<span class="result-method">{{ result.method }}</span>
<span class="result-endpoint">{{ result.endpoint }}</span>
<span class="result-status">{{ result.status.toUpperCase() }}</span>
<span class="result-duration" *ngIf="result.duration">({{ result.duration }}ms)</span>
</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>
</div>
</div>
@ -130,7 +173,8 @@ interface TestResult {
<ng-template #noResults>
<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>
</ng-template>
</div>
@ -174,11 +218,10 @@ interface TestResult {
margin: 0 auto;
}
.panel-content {
display: grid;
gap: 30px;
grid-template-columns: minmax(0, 1fr) 520px;
align-items: start;
.panel-layout {
display: flex;
flex-direction: column;
gap: 20px;
}
.test-sections {
@ -200,6 +243,22 @@ interface TestResult {
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 {
display: flex;
flex-wrap: wrap;
@ -322,11 +381,10 @@ interface TestResult {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
position: sticky;
top: 20px;
max-height: calc(100vh - 60px);
display: grid;
grid-template-rows: auto 1fr;
position: static;
z-index: 0;
}
.results-header {
@ -344,6 +402,55 @@ interface TestResult {
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 {
padding: 6px 12px;
background: var(--danger);
@ -365,8 +472,7 @@ interface TestResult {
.results-list {
overflow-y: auto;
height: 100%;
max-height: none;
max-height: 500px;
}
.result-item {
@ -454,6 +560,29 @@ interface TestResult {
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 {
margin-top: 10px;
}
@ -529,13 +658,6 @@ interface TestResult {
}
@media (max-width: 1024px) {
.panel-content {
grid-template-columns: 1fr;
}
.results-panel {
position: static;
max-height: none;
}
.results-list {
max-height: 400px;
}
@ -552,13 +674,21 @@ interface TestResult {
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);
}
.tests-panel--fullscreen .test-sections {
overflow: auto;
}
.tests-panel--fullscreen .results-panel {
align-self: stretch;
}
`]
})
export class TestsPanelComponent {
@ -574,6 +704,10 @@ export class TestsPanelComponent {
sseEvents = signal<Array<{type: string, data: any, timestamp: Date}>>([]);
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
private sseConnection: EventSource | null = null;
isFullscreen = signal(false);
@ -726,6 +860,39 @@ export class TestsPanelComponent {
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> {
const ep = this.currentEndpoint;
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
auteur: Bruno Charest
creation_date: 2025-10-23T13:00:42-04:00
modification_date: 2025-10-23T13:00:42-04:00
creation_date: 2025-11-20T16:27:20-04:00
modification_date: 2025-11-20T16:27:20-04:00
catégorie: ""
tags: []
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"
nimbusEditor: true
documentModelFormat: "block-model-v1"
tags:
- test2
- configuration
- bruno
- titi
---
```json
{
"id": "block_1763149113471_461xyut80",
"title": "Page Tests",
"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"
}
}
],
"blocks": [],
"meta": {
"createdAt": "2025-11-14T19:38:33.471Z",
"updatedAt": "2025-11-20T18:48:53.317Z"
"updatedAt": "2025-11-21T02:28:15.835Z"
}
}
```