```
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:
parent
6b47ec39ff
commit
843258be44
@ -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 || {};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
17
vault/folder-4/NewPage.md
Normal 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
|
||||
---
|
||||
15
vault/folder-4/NewPage.md.bak
Normal file
15
vault/folder-4/NewPage.md.bak
Normal 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
|
||||
---
|
||||
|
||||
@ -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
3
vault/test-file.md.bak
Normal file
@ -0,0 +1,3 @@
|
||||
# Test File
|
||||
|
||||
This is a test file for API testing.
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user