refactor: improve block UI consistency and comment icon styling

- Wrapped heading blocks in rounded pill containers matching paragraph block styling
- Unified comment icon design across all block types (full-width and columns) with consistent SVG speech bubble
- Reduced block wrapper vertical padding (py-1 → py-0.5) and inline toolbar padding for more compact layout
- Added right padding to block content (pr-8) to prevent comment icon overlap with colored backgrounds
- Excluded paragraph,
This commit is contained in:
Bruno Charest 2025-11-16 10:55:59 -05:00
parent ba86bd4b91
commit 8b2510e9cc
14 changed files with 347 additions and 414 deletions

View File

@ -1,4 +1,4 @@
import { Component, Input, Output, EventEmitter, inject, HostListener, ElementRef, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
import { Component, Input, Output, EventEmitter, inject, HostListener, ElementRef, OnChanges, SimpleChanges, ViewChild, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, BlockType } from '../../core/models/block.model';
import { DocumentService } from '../../services/document.service';
@ -818,7 +818,7 @@ export interface MenuAction {
@keyframes fadeIn { from { opacity:0; transform: scale(.97);} to { opacity:1; transform: scale(1);} }
`]
})
export class BlockContextMenuComponent implements OnChanges {
export class BlockContextMenuComponent implements OnChanges, OnDestroy {
@Input() block!: Block;
@Input() visible = false;
@Input() position = { x: 0, y: 0 };
@ -838,15 +838,28 @@ export class BlockContextMenuComponent implements OnChanges {
top = -9999;
opacity = 0;
private appendedToBody = false;
constructor() {
try {
const el = this.elementRef.nativeElement as HTMLElement;
if (el && el.parentElement !== document.body) {
document.body.appendChild(el);
this.appendedToBody = true;
}
} catch {}
}
ngOnDestroy(): void {
try {
const el = this.elementRef.nativeElement as HTMLElement;
if (el && el.parentElement === document.body && this.appendedToBody) {
document.body.removeChild(el);
}
} catch {}
this.closeSubmenu();
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
const root = this.menuRef?.nativeElement;
@ -1217,6 +1230,11 @@ export class BlockContextMenuComponent implements OnChanges {
// Emit action for parent to handle (including ratios/alignment payload)
this.action.emit({ type, payload });
}
// Locally hide the menu to ensure it disappears immediately, even if the host is destroyed right after
this.visible = false;
this.opacity = 0;
this.left = -9999;
this.top = -9999;
this.close.emit();
}

View File

@ -76,7 +76,7 @@ import { CollapsibleBlockComponent } from './blocks/collapsible-block.component'
[attr.data-block-index]="index"
[class.active]="isActive()"
[class.locked]="block.meta?.locked"
[style.background-color]="(block.type === 'list-item' || block.type === 'file') ? null : block.meta?.bgColor"
[style.background-color]="(block.type === 'list-item' || block.type === 'file' || block.type === 'paragraph' || block.type === 'list' || block.type === 'heading') ? null : block.meta?.bgColor"
[ngStyle]="blockStyles()"
(click)="onBlockClick($event)"
>
@ -101,8 +101,8 @@ import { CollapsibleBlockComponent } from './blocks/collapsible-block.component'
</button>
}
<!-- Block content -->
<div class="block-content" [class.locked]="block.meta?.locked">
<!-- Block content (extra right padding so the comment icon sits on the dark background, not on top of the pill) -->
<div class="block-content pr-8" [class.locked]="block.meta?.locked">
@switch (block.type) {
@case ('paragraph') {
<div class="flex items-center gap-2">
@ -191,16 +191,38 @@ import { CollapsibleBlockComponent } from './blocks/collapsible-block.component'
}
</div>
<ng-container *ngIf="block.type !== 'table' && block.type !== 'columns'">
<!-- Filled white speech bubble with count (count in black) -->
<!-- Filled comment icon with count -->
<button *ngIf="totalComments() > 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 flex items-center justify-center z-20"
title="View comments" (click)="openComments()">
<svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="#e5e7eb" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<svg viewBox="0 0 64 64" class="w-5 h-5 text-gray-100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M32 8C19.85 8 10 17.85 10 30c0 4.2 1.2 8.3 3.3 11.7L10 56l13.1-4.4C25.9 53.2 28.9 54 32 54c12.15 0 22-9.85 22-22S44.15 8 32 8Z"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="24" cy="30" r="3" fill="currentColor" />
<circle cx="32" cy="30" r="3" fill="currentColor" />
<circle cx="40" cy="30" r="3" fill="currentColor" />
</svg>
<span class="absolute text-[11px] font-semibold text-black">{{ totalComments() }}</span>
</button>
<!-- Outline bubble (no comments) -->
<!-- Outline comment icon when there are no comments -->
<button *ngIf="totalComments() === 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-20"
title="Add a comment" (click)="openComments()">
<svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="none" stroke="#e5e7eb" stroke-width="1.5" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<svg viewBox="0 0 64 64" class="w-5 h-5 text-gray-100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M32 8C19.85 8 10 17.85 10 30c0 4.2 1.2 8.3 3.3 11.7L10 56l13.1-4.4C25.9 53.2 28.9 54 32 54c12.15 0 22-9.85 22-22S44.15 8 32 8Z"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="24" cy="30" r="3" fill="currentColor" />
<circle cx="32" cy="30" r="3" fill="currentColor" />
<circle cx="40" cy="30" r="3" fill="currentColor" />
</svg>
</button>
</ng-container>
</div>
@ -216,7 +238,7 @@ import { CollapsibleBlockComponent } from './blocks/collapsible-block.component'
`,
styles: [`
.block-wrapper {
@apply relative py-1 px-3 rounded-md transition-all;
@apply relative py-0.5 px-3 rounded-md transition-all;
/* No fixed min-height; let content define height */
}
@ -691,6 +713,7 @@ export class BlockHostComponent implements OnDestroy {
this.documentService.duplicateBlock(this.block.id);
break;
case 'delete':
this.closeMenu();
this.documentService.deleteBlock(this.block.id);
break;
case 'lock':

View File

@ -10,7 +10,7 @@ export interface InlineToolbarAction {
standalone: true,
imports: [CommonModule],
template: `
<div class="group/block relative flex items-center justify-between gap-2 bg-[var(--block-bg,var(--editor-bg))] rounded-2xl px-4 py-1 shadow-sm z-[10]">
<div class="group/block relative flex items-center justify-between gap-2 bg-[var(--block-bg,var(--editor-bg))] rounded-sm px-4 py-0.5 shadow-sm z-[10]">
<!-- Drag handle (visible on hover) -->
@if (showDragHandle) {
<div

View File

@ -1,10 +1,12 @@
import { Component, Input, Output, EventEmitter, inject, ViewChild, signal, effect, ElementRef, HostListener, AfterViewInit } from '@angular/core';
import { Component, Input, Output, EventEmitter, inject, ViewChild, signal, effect, ElementRef, HostListener, AfterViewInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Block, ColumnsProps, ColumnItem } from '../../../core/models/block.model';
import { DragDropService } from '../../../services/drag-drop.service';
import { CommentService } from '../../../services/comment.service';
import { DocumentService } from '../../../services/document.service';
import { SelectionService } from '../../../services/selection.service';
import { CommentStoreService } from '../../../services/comment-store.service';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
// Import ALL block components for full support
import { ParagraphBlockComponent } from './paragraph-block.component';
@ -27,7 +29,7 @@ import { KanbanBlockComponent } from './kanban-block.component';
import { EmbedBlockComponent } from './embed-block.component';
import { OutlineBlockComponent } from './outline-block.component';
import { ListBlockComponent } from './list-block.component';
import { CommentsPanelComponent } from '../../comments/comments-panel.component';
import { BlockCommentComposerComponent } from '../../comment/block-comment-composer.component';
import { BlockContextMenuComponent } from '../block-context-menu.component';
import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-drop-files.directive';
import { PaletteItem } from '../../../core/constants/palette-items';
@ -57,7 +59,6 @@ import { PaletteItem } from '../../../core/constants/palette-items';
EmbedBlockComponent,
OutlineBlockComponent,
ListBlockComponent,
CommentsPanelComponent,
BlockContextMenuComponent,
DragDropFilesDirective
],
@ -80,12 +81,12 @@ import { PaletteItem } from '../../../core/constants/palette-items';
<!-- Menu button (3 dots) - Outside left, centered vertically -->
<button
type="button"
class="menu-handle absolute -left-9 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center opacity-0 group-hover/block:opacity-100 transition-opacity bg-gray-700 hover:bg-gray-600 rounded-md z-10"
class="menu-handle opacity-0 group-hover/block:opacity-100 transition-opacity absolute -left-5 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center rounded-full bg-surface2/80 hover:bg-surface2 dark:bg-gray-700/80 dark:hover:bg-gray-700 shadow-sm z-10"
title="Drag to move or click for menu"
(click)="openMenu(block, $event)"
(mousedown)="onDragStart(block, colIndex, blockIndex, $event)"
>
<svg class="w-4 h-4 text-gray-300" viewBox="0 0 16 16" fill="currentColor">
<svg class="w-2 h-2 text-gray-300" viewBox="0 0 16 16" fill="currentColor">
<circle cx="3" cy="8" r="1.5"/>
<circle cx="8" cy="8" r="1.5"/>
<circle cx="13" cy="8" r="1.5"/>
@ -100,16 +101,40 @@ import { PaletteItem } from '../../../core/constants/palette-items';
[style.background-color]="getBlockBgColor(block)"
[ngStyle]="getBlockStyles(block)"
>
<!-- Comment icon inside the block, aligned to the right -->
<!-- Comment icon inside the block, aligned to the right (same size/position as full-width blocks) -->
<ng-container>
<button *ngIf="getBlockCommentCount(block.id) > 0" class="absolute top-1/2 -translate-y-1/2 right-1 w-7 h-7 flex items-center justify-center z-20"
<!-- Filled comment icon with count -->
<button *ngIf="getBlockCommentCount(block.id) > 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 flex items-center justify-center z-20"
title="View comments" (click)="openComments(block.id)">
<svg viewBox="0 0 24 24" class="w-6 h-6"><path fill="#e5e7eb" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<svg viewBox="0 0 64 64" class="w-5 h-5 text-gray-100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M32 8C19.85 8 10 17.85 10 30c0 4.2 1.2 8.3 3.3 11.7L10 56l13.1-4.4C25.9 53.2 28.9 54 32 54c12.15 0 22-9.85 22-22S44.15 8 32 8Z"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="24" cy="30" r="3" fill="currentColor" />
<circle cx="32" cy="30" r="3" fill="currentColor" />
<circle cx="40" cy="30" r="3" fill="currentColor" />
</svg>
<span class="absolute text-[11px] font-semibold text-black">{{ getBlockCommentCount(block.id) }}</span>
</button>
<button *ngIf="getBlockCommentCount(block.id) === 0" class="absolute top-1/2 -translate-y-1/2 right-1 w-7 h-7 opacity-0 group-hover/block:opacity-100 transition-opacity flex items-center justify-center z-20"
<!-- Outline comment icon when there are no comments -->
<button *ngIf="getBlockCommentCount(block.id) === 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 opacity-0 group-hover/block:opacity-100 transition-opacity flex items-center justify-center z-20"
title="Add a comment" (click)="openComments(block.id)">
<svg viewBox="0 0 24 24" class="w-6 h-6"><path fill="none" stroke="#e5e7eb" stroke-width="1.5" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<svg viewBox="0 0 64 64" class="w-5 h-5 text-gray-100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M32 8C19.85 8 10 17.85 10 30c0 4.2 1.2 8.3 3.3 11.7L10 56l13.1-4.4C25.9 53.2 28.9 54 32 54c12.15 0 22-9.85 22-22S44.15 8 32 8Z"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="24" cy="30" r="3" fill="currentColor" />
<circle cx="32" cy="30" r="3" fill="currentColor" />
<circle cx="40" cy="30" r="3" fill="currentColor" />
</svg>
</button>
</ng-container>
@switch (block.type) {
@ -134,7 +159,11 @@ import { PaletteItem } from '../../../core/constants/palette-items';
/>
}
@case ('list-item') {
<app-list-item-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
<app-list-item-block
[block]="block"
(update)="onBlockUpdate($event, block.id)"
(createBelow)="onListItemCreateBelow(block.id, colIndex, blockIndex)"
/>
}
@case ('code') {
<app-code-block [block]="block" (update)="onBlockUpdate($event, block.id)" />
@ -218,9 +247,6 @@ import { PaletteItem } from '../../../core/constants/palette-items';
}
</div>
<!-- Comments Panel -->
<app-comments-panel #commentsPanel />
<!-- Block Context Menu -->
<app-block-context-menu
[block]="selectedBlock() || createDummyBlock()"
@ -258,15 +284,15 @@ import { PaletteItem } from '../../../core/constants/palette-items';
}
`]
})
export class ColumnsBlockComponent implements AfterViewInit {
export class ColumnsBlockComponent implements AfterViewInit, OnDestroy {
private readonly dragDrop = inject(DragDropService);
private readonly commentService = inject(CommentService);
private readonly commentsStore = inject(CommentStoreService);
private readonly documentService = inject(DocumentService);
private readonly selectionService = inject(SelectionService);
private readonly overlay = inject(Overlay);
@Input({ required: true }) block!: Block<ColumnsProps>;
@Output() update = new EventEmitter<ColumnsProps>();
@ViewChild('commentsPanel') commentsPanel?: CommentsPanelComponent;
@ViewChild('columnsContainer', { static: true }) columnsContainerRef!: ElementRef<HTMLElement>;
// Menu state
@ -283,12 +309,20 @@ export class ColumnsBlockComponent implements AfterViewInit {
private resizeState: { active: boolean; index: number; startX: number; containerWidth: number; leftStart: number; rightStart: number } | null = null;
resizerPositions = signal<number[]>([]);
// Comments popover state
private commentRef?: OverlayRef;
private commentSub: { unsubscribe(): void } | null = null;
get props(): ColumnsProps {
return this.block.props;
}
getBlockCommentCount(blockId: string): number {
return this.commentService.getCommentCount(blockId);
try {
return this.commentsStore.count(blockId);
} catch {
return 0;
}
}
onConvertRequested(item: PaletteItem, blockId: string): void {
@ -315,7 +349,40 @@ export class ColumnsBlockComponent implements AfterViewInit {
}
openComments(blockId: string): void {
this.commentsPanel?.open(blockId);
this.closeComments();
const container = this.columnsContainerRef?.nativeElement;
const anchor = (container?.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement) || container;
if (!anchor) return;
const pos = this.overlay.position().flexibleConnectedTo(anchor).withPositions([
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 },
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -8 }
]);
this.commentRef = this.overlay.create({
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
positionStrategy: pos,
panelClass: 'nimbus-menu-panel'
});
const portal = new ComponentPortal(BlockCommentComposerComponent);
const ref = this.commentRef.attach(portal);
ref.instance.blockId = blockId;
this.commentSub = ref.instance.close.subscribe(() => this.closeComments());
this.commentRef.backdropClick().subscribe(() => this.closeComments());
this.commentRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') this.closeComments(); });
}
private closeComments(): void {
if (this.commentSub) {
try { this.commentSub.unsubscribe(); } catch {}
this.commentSub = null;
}
if (this.commentRef) {
this.commentRef.dispose();
this.commentRef = undefined;
}
}
onBlockMetaChange(metaChanges: any, blockId: string): void {
@ -363,6 +430,54 @@ export class ColumnsBlockComponent implements AfterViewInit {
}, 50);
}
onListItemCreateBelow(blockId: string, columnIndex: number, blockIndex: number): void {
// Insert a new list-item block directly after the given list-item within the same column
const updatedColumns = this.props.columns.map((column, colIdx) => {
if (colIdx !== columnIndex) return column;
const blocks = [...column.blocks];
const existing = blocks[blockIndex];
if (!existing || existing.type !== 'list-item') {
return column;
}
const props: any = existing.props || {};
const newProps: any = {
kind: props.kind,
text: '',
checked: props.kind === 'check' ? false : undefined,
indent: props.indent || 0,
align: props.align
};
if (props.kind === 'numbered' && typeof props.number === 'number') {
newProps.number = props.number + 1;
}
const newBlock = this.documentService.createBlock('list-item' as any, newProps);
const newBlocks = [...blocks];
newBlocks.splice(blockIndex + 1, 0, newBlock);
return { ...column, blocks: newBlocks };
});
this.update.emit({ columns: updatedColumns });
// Focus the new list-item input once it is rendered
const newId = updatedColumns[columnIndex]?.blocks[blockIndex + 1]?.id;
if (!newId) return;
setTimeout(() => {
const input = document.querySelector(
`[data-block-id="${newId}"] input[type="text"]`
) as HTMLInputElement | null;
if (input) {
input.focus();
const len = input.value.length;
input.setSelectionRange(len, len);
}
}, 50);
}
onBlockDelete(blockId: string): void {
// Delete a specific block from columns
const updatedColumns = this.props.columns.map(column => ({
@ -648,7 +763,9 @@ export class ColumnsBlockComponent implements AfterViewInit {
}
getBlockBgColor(block: Block): string | undefined {
if (block.type === 'paragraph') {
// 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') {
return undefined;
}
const bgColor = (block.meta as any)?.bgColor;
@ -680,6 +797,10 @@ export class ColumnsBlockComponent implements AfterViewInit {
setTimeout(() => this.computeResizerPositions(), 0);
}
ngOnDestroy(): void {
this.closeComments();
}
@HostListener('window:resize')
onWindowResize(): void {
this.computeResizerPositions();

View File

@ -9,38 +9,46 @@ import { DocumentService } from '../../../services/document.service';
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="w-full" [style.--block-bg]="getBlockBgColor()">
@switch (props.level) {
@case (1) {
<div class="group/block relative flex items-center justify-between gap-2 bg-[var(--block-bg,var(--editor-bg))] rounded-sm px-4 py-0.5 shadow-sm">
<h1
contenteditable="true"
class="text-xl font-bold focus:outline-none px-2 py-0.5 rounded-md m-0 w-full bg-transparent"
class="text-xl font-bold focus:outline-none m-0 bg-transparent"
#editable
(input)="onInput($event)"
(keydown)="onKeyDown($event)"
placeholder="Heading 1"
></h1>
</div>
}
@case (2) {
<div class="group/block relative flex items-center justify-between gap-2 bg-[var(--block-bg,var(--editor-bg))] rounded-sm px-4 py-0.5 shadow-sm">
<h2
contenteditable="true"
class="text-lg font-semibold focus:outline-none px-2 py-0.5 rounded-md m-0 w-full bg-transparent"
class="text-lg font-semibold focus:outline-none m-0 bg-transparent"
#editable
(input)="onInput($event)"
(keydown)="onKeyDown($event)"
placeholder="Heading 2"
></h2>
</div>
}
@case (3) {
<div class="group/block relative flex items-center justify-between gap-2 bg-[var(--block-bg,var(--editor-bg))] rounded-sm px-4 py-0.5 shadow-sm">
<h3
contenteditable="true"
class="text-base font-semibold focus:outline-none px-2 py-0.5 rounded-md m-0 w-full bg-transparent"
class="text-base font-semibold focus:outline-none m-0 bg-transparent"
#editable
(input)="onInput($event)"
(keydown)="onKeyDown($event)"
placeholder="Heading 3"
></h3>
</div>
}
}
</div>
`,
styles: [`
[contenteditable]:empty:before {
@ -67,6 +75,12 @@ export class HeadingBlockComponent implements AfterViewInit {
return this.block.props;
}
getBlockBgColor(): string | undefined {
const meta: any = this.block?.meta || {};
const bgColor = meta.bgColor;
return bgColor && bgColor !== 'transparent' ? bgColor : undefined;
}
ngAfterViewInit(): void {
if (this.editable?.nativeElement) {
this.editable.nativeElement.textContent = this.props.text || '';

View File

@ -13,18 +13,18 @@ import { SelectionService } from '../../../services/selection.service';
template: `
<div class="w-full space-y-2">
@for (it of items(); track it.id; let i = $index) {
<div class="flex items-center gap-3 cursor-text" (click)="onItemClick(i)">
<div class="flex items-center gap-2 cursor-text" (click)="onItemClick(i)">
<!-- Marker (bullet, checkbox, or number) -->
<div class="flex-shrink-0 flex items-center justify-center" style="width: 24px; min-width: 24px;">
<div class="flex-shrink-0 flex items-center justify-center" style="width: 22px; min-width: 22px;">
@if (kind() === 'bullet') {
<div class="rounded-full bg-slate-200" style="width: 8px; height: 8px;"></div>
<div class="rounded-full bg-slate-200" style="width: 6px; height: 6px;"></div>
} @else if (kind() === 'check') {
<button type="button"
class="cursor-pointer flex items-center justify-center focus:outline-none"
(click)="onToggleCheck(it.id, $event)"
(keydown)="onCheckboxKeyDown(i, $event)"
>
<div class="flex items-center justify-center bg-transparent" style="width: 20px; height: 20px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 2px;">
<div class="flex items-center justify-center bg-transparent" style="width: 18px; height: 18px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 3px;">
@if (it.checked) {
<svg viewBox="0 0 24 24" class="w-4 h-4 text-slate-900">
<path fill="currentColor" d="M9.5 17.25L4.75 12.5L6.16 11.09L9.5 14.43L17.84 6.09L19.25 7.5L9.5 17.25Z" />
@ -33,13 +33,13 @@ import { SelectionService } from '../../../services/selection.service';
</div>
</button>
} @else {
<span class="text-slate-200" style="font-size: 16px; font-weight: 500;">{{ i + 1 }}.</span>
<span class="text-slate-200" style="font-size: 14px; font-weight: 500;">{{ i + 1 }}.</span>
}
</div>
<!-- Input pill - inherits block color or uses transparent background -->
<input #inp type="text"
class="flex-1 rounded-xl px-5 py-2.5 text-xl leading-7 focus:outline-none cursor-text border-none"
class="flex-1 rounded-sm px-4 py-0.5 text-sm leading-tight focus:outline-none cursor-text border-none shadow-sm"
[class.text-slate-900]="hasBlockColor() && !(kind() === 'check' && it.checked)"
[class.text-slate-100]="!hasBlockColor() && !(kind() === 'check' && it.checked)"
[class.text-slate-500]="kind() === 'check' && it.checked"

View File

@ -10,11 +10,11 @@ import { DocumentService } from '../../../services/document.service';
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="flex items-center gap-3 cursor-text w-full" (click)="onContainerClick($event)" [style.padding-left.px]="getIndentPadding()">
<div class="flex items-center gap-2 cursor-text w-full" (click)="onContainerClick($event)" [style.padding-left.px]="getIndentPadding()">
<!-- Marker (bullet, checkbox, or number) -->
<div class="flex-shrink-0 flex items-center justify-center" style="width: 24px; min-width: 24px;">
<div class="flex-shrink-0 flex items-center justify-center" style="width: 22px; min-width: 22px;">
@if (props.kind === 'bullet') {
<span class="text-slate-200" style="font-size: 18px; line-height: 1;">{{ getBulletSymbol() }}</span>
<span class="text-slate-200" style="font-size: 14px; line-height: 1;">{{ getBulletSymbol() }}</span>
} @else if (props.kind === 'check') {
<button type="button"
class="cursor-pointer flex items-center justify-center focus:outline-none"
@ -26,7 +26,7 @@ import { DocumentService } from '../../../services/document.service';
class="flex items-center justify-center"
[class.bg-slate-100]="props.checked"
[class.text-slate-900]="props.checked"
style="width: 20px; height: 20px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 2px;">
style="width: 18px; height: 18px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 3px;">
@if (props.checked) {
<svg viewBox="0 0 24 24" class="w-4 h-4">
<path fill="currentColor" d="M9.5 17.25L4.75 12.5L6.16 11.09L9.5 14.43L17.84 6.09L19.25 7.5L9.5 17.25Z" />
@ -35,13 +35,13 @@ import { DocumentService } from '../../../services/document.service';
</div>
</button>
} @else {
<span class="text-slate-200" style="font-size: 16px; font-weight: 500;">{{ props.number || 1 }}.</span>
<span class="text-slate-200" style="font-size: 14px; font-weight: 500;">{{ props.number || 1 }}.</span>
}
</div>
<!-- Input text - inherits block color or uses transparent background -->
<input #inp type="text"
class="flex-1 rounded-xl px-5 py-2.5 text-xl leading-7 cursor-text border-none shadow-none"
class="flex-1 rounded-sm px-4 py-0.5 text-sm leading-tight cursor-text border-none shadow-sm"
[class.text-slate-900]="hasBlockColor() && !(props.kind === 'check' && props.checked)"
[class.text-slate-100]="!hasBlockColor() && !(props.kind === 'check' && props.checked)"
[class.text-slate-500]="props.kind === 'check' && props.checked"
@ -72,6 +72,9 @@ import { DocumentService } from '../../../services/document.service';
export class ListItemBlockComponent implements OnInit, AfterViewInit {
@Input({ required: true }) block!: Block<ListItemProps>;
@Output() update = new EventEmitter<ListItemProps>();
// Optional: when provided (e.g. in ColumnsBlockComponent), delegate creation of the next item
// to the parent container instead of inserting a top-level block via DocumentService.
@Output() createBelow = new EventEmitter<void>();
@ViewChild('inp') input!: ElementRef<HTMLInputElement>;
@ -162,12 +165,16 @@ export class ListItemBlockComponent implements OnInit, AfterViewInit {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
// Get the current block index
// If parent container listens to createBelow (e.g. columns), delegate creation to it
if (this.createBelow.observers.length > 0) {
this.createBelow.emit();
return;
}
// Fallback: legacy behavior for top-level list-item blocks
const blocks = this.documentService.blocks();
const currentIndex = blocks.findIndex(b => b.id === this.block.id);
if (currentIndex !== -1) {
// Create new list item with same kind and indent
let newProps: ListItemProps = {
kind: this.props.kind,
text: '',
@ -176,7 +183,6 @@ export class ListItemBlockComponent implements OnInit, AfterViewInit {
align: this.props.align
};
// For numbered lists, increment the number
if (this.props.kind === 'numbered' && this.props.number) {
newProps.number = this.props.number + 1;
}

View File

@ -11,53 +11,57 @@ import { CommentActionMenuComponent } from './comment-action-menu.component';
standalone: true,
imports: [CommonModule, FormsModule, OverlayModule, PortalModule],
template: `
<div class="bg-[#333333] border border-neutral-600 rounded-xl shadow-xl w-[420px] max-w-[95vw]">
<div class="flex items-center justify-between px-3 py-2 border-b border-neutral-700">
<div class="text-gray-200 font-medium">Comments</div>
<button class="text-gray-300" (click)="close.emit()">
<div class="panel bg-surface-elevated shadow-elevated ring-1 ring-app w-[420px] max-w-[95vw]">
<div class="flex items-center justify-between px-3 py-2 border-b border-border bg-surface">
<div class="text-sm font-semibold text-text-main">Comments</div>
<button class="text-muted hover:text-text-main transition-colors" (click)="close.emit()">
<svg viewBox="0 0 24 24" class="w-5 h-5"><path fill="currentColor" d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="relative z-20 max-h-[300px] overflow-auto px-3 py-2 space-y-3">
<div class="relative z-20 max-h-[300px] overflow-auto px-3 py-2 space-y-3 bg-surface">
<div *ngFor="let c of comments()" class="space-y-1 relative">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full overflow-hidden bg-neutral-600"></div>
<div class="w-8 h-8 rounded-full overflow-hidden bg-neutral-500 flex items-center justify-center text-xs font-semibold text-white">
{{ (c.author || 'U').slice(0, 2) }}
</div>
<div class="flex-1">
<div class="flex items-center justify-between text-sm">
<div class="text-gray-200 font-semibold">{{ c.author || 'User' }}</div>
<div class="flex items-center justify-between text-xs">
<div class="font-semibold text-text-main">{{ c.author || 'User' }}</div>
<div class="flex items-center gap-2">
<div class="text-gray-400">{{ c.createdAt | date:'shortTime' }}</div>
<button class="text-gray-400" (click)="openCommentMenu($event, c)">
<div class="text-muted">{{ c.createdAt | date:'shortTime' }}</div>
<button class="text-muted hover:text-text-main transition-colors" (click)="openCommentMenu($event, c)">
<svg viewBox="0 0 24 24" class="w-5 h-5"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
</div>
</div>
<ng-container *ngIf="editingId !== c.id; else editTpl">
<div class="text-gray-200 whitespace-pre-wrap">{{ c.text }}</div>
<div class="mt-1 text-sm text-text-main whitespace-pre-wrap">{{ c.text }}</div>
</ng-container>
<ng-template #editTpl>
<div class="space-y-2">
<div class="mt-1 space-y-2">
<input class="nimbus-input w-full" [(ngModel)]="editText" autofocus />
<div class="flex justify-end gap-2">
<button class="px-3 py-1 rounded bg-neutral-700" (click)="cancelEdit()">Cancel</button>
<button class="px-3 py-1 rounded bg-sky-600 text-white" (click)="saveEdit(c.id)">Save</button>
<button class="btn btn-secondary btn-sm px-3 py-1" (click)="cancelEdit()">Cancel</button>
<button class="btn btn-primary btn-sm px-3 py-1" (click)="saveEdit(c.id)">Save</button>
</div>
</div>
</ng-template>
<!-- Reply preview inside item (optional) could go here -->
</div>
</div>
<div class="h-px bg-neutral-700"></div>
<div class="h-px bg-border/70"></div>
<!-- Action menu now rendered via CDK Overlay -->
</div>
<div *ngIf="!comments().length" class="text-sm text-gray-400">No comments yet.</div>
<div *ngIf="!comments().length" class="text-sm text-muted">No comments yet.</div>
</div>
<div class="relative z-10 px-3 py-2 border-t border-neutral-700" (click)="menuForId = null">
<div class="relative z-10 px-3 py-2 border-t border-border bg-surface" (click)="menuForId = null">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full overflow-hidden bg-neutral-600"></div>
<div class="w-8 h-8 rounded-full overflow-hidden bg-neutral-500 flex items-center justify-center text-xs font-semibold text-white">
{{ 'You' | slice:0:2 }}
</div>
<input type="text" class="flex-1 nimbus-input" placeholder="Add a comment" [(ngModel)]="text" (keydown.enter)="send()" />
<button class="px-2 py-1 rounded-md bg-sky-500 text-white disabled:opacity-50" [disabled]="!text" (click)="send()">
<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="currentColor" d="M3 13l17-9-7 18-2-7z"/></svg>
<button class="btn btn-primary btn-icon px-2 py-1 min-h-0 h-8 w-8 rounded-md disabled:opacity-50" [disabled]="!text" (click)="send()">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="currentColor" d="M3 13l17-9-7 18-2-7z"/></svg>
</button>
</div>
</div>

View File

@ -12,16 +12,16 @@ export interface CommentMenuItem {
standalone: true,
imports: [CommonModule],
template: `
<div class="w-36 bg-neutral-800 border border-neutral-700 rounded-lg py-1 shadow-xl">
<button class="w-full text-left px-3 py-1.5 hover:bg-neutral-700 flex items-center gap-2" (click)="reply.emit(context)">
<div class="w-40 panel bg-surface-elevated shadow-elevated border border-border py-1">
<button class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition-colors flex items-center gap-2 text-sm text-text-main" (click)="reply.emit(context)">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="currentColor" d="M10 19l-7-7 7-7v14z"/></svg>
<span>Reply</span>
</button>
<button class="w-full text-left px-3 py-1.5 hover:bg-neutral-700 flex items-center gap-2" (click)="edit.emit(context)">
<button class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition-colors flex items-center gap-2 text-sm text-text-main" (click)="edit.emit(context)">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25z"/></svg>
<span>Edit</span>
</button>
<button class="w-full text-left px-3 py-1.5 hover:bg-neutral-700 flex items-center gap-2 text-red-300" (click)="remove.emit(context)">
<button class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition-colors flex items-center gap-2 text-sm" (click)="remove.emit(context)" style="color: var(--danger)">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="currentColor" d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z"/></svg>
<span>Delete</span>
</button>

View File

@ -148,15 +148,37 @@
</div>
</div>
<!-- Table-level comments bubble (speech bubble style to match other blocks) -->
<!-- Table-level comments bubble (uses same comment icon as other blocks) -->
<button *ngIf="totalComments() > 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 flex items-center justify-center"
title="View comments" (click)="openFirstComment()">
<svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="#e5e7eb" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<svg viewBox="0 0 64 64" class="w-5 h-5 text-gray-100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M32 8C19.85 8 10 17.85 10 30c0 4.2 1.2 8.3 3.3 11.7L10 56l13.1-4.4C25.9 53.2 28.9 54 32 54c12.15 0 22-9.85 22-22S44.15 8 32 8Z"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="24" cy="30" r="3" fill="currentColor" />
<circle cx="32" cy="30" r="3" fill="currentColor" />
<circle cx="40" cy="30" r="3" fill="currentColor" />
</svg>
<span class="absolute text-[11px] font-semibold text-black">{{ totalComments() }}</span>
</button>
<button *ngIf="totalComments() === 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Add a comment" (click)="openBlockComment()">
<svg viewBox="0 0 24 24" class="w-7 h-7"><path fill="none" stroke="#e5e7eb" stroke-width="1.5" d="M12 4c4.97 0 9 2.96 9 6.6S16.97 17.2 12 17.2c-.75 0-1.49-.07-2.2-.22L6 20l1.57-3.14C6.13 16 3 13.86 3 10.6 3 6.96 7.03 4 12 4z"/></svg>
<svg viewBox="0 0 64 64" class="w-5 h-5 text-gray-100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M32 8C19.85 8 10 17.85 10 30c0 4.2 1.2 8.3 3.3 11.7L10 56l13.1-4.4C25.9 53.2 28.9 54 32 54c12.15 0 22-9.85 22-22S44.15 8 32 8Z"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="24" cy="30" r="3" fill="currentColor" />
<circle cx="32" cy="30" r="3" fill="currentColor" />
<circle cx="40" cy="30" r="3" fill="currentColor" />
</svg>
</button>
<!-- Quick-add controls: positioned outside the table -->

View File

@ -10,7 +10,7 @@
"type": "file",
"path": "tata/Les Compléments Alimentaires Un Guide Général.md",
"title": "Les Compléments Alimentaires Un Guide Général.md",
"ctime": 1763156633684
"ctime": 1763267370357
}
]
}

View File

@ -3,7 +3,6 @@ titre: "Nouvelle note 1"
auteur: "Bruno Charest"
creation_date: "2025-10-24T03:30:58.977Z"
modification_date: "2025-11-03T22:06:07-04:00"
tags: [""]
status: "en-cours"
publish: false
favoris: true
@ -13,6 +12,11 @@ archive: true
draft: false
private: false
description: "Une expédition militaire et scientifique découvre la cité mythique d'Atlantis dans la galaxie de Pégase et affronte les Wraiths."
tags:
- titi
- test
- tag4
- configuration
---
*Stargate Atlantis* est une série de science-fiction dérivée de la populaire *Stargate SG-1*. Elle suit les aventures d'une expédition internationale, composée de scientifiques et de militaires, qui voyage à travers la porte des étoiles vers la lointaine galaxie de Pégase. Leur destination est la cité mythique d'Atlantis, une métropole volante abandonnée construite par une race ancienne et technologiquement supérieure connue sous le nom d'Anciens.

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

View File

@ -10,338 +10,59 @@ documentModelFormat: "block-model-v1"
"title": "Page Tests",
"blocks": [
{
"id": "block_1763234865120_um14zlycy",
"id": "block_1763307699824_r3elleo33",
"type": "heading",
"props": {
"level": 1,
"text": "H1"
"text": "asdassda"
},
"meta": {
"createdAt": "2025-11-15T19:27:45.120Z",
"updatedAt": "2025-11-15T20:06:49.723Z",
"align": "center",
"bgColor": "#dc2626"
"createdAt": "2025-11-16T15:41:39.824Z",
"updatedAt": "2025-11-16T15:41:42.459Z"
}
},
{
"id": "block_1763240860187_hdklpobdf",
"type": "line",
"props": {
"style": "solid"
},
"meta": {
"createdAt": "2025-11-15T21:07:40.187Z",
"updatedAt": "2025-11-15T21:36:29.164Z"
}
},
{
"id": "block_1763234657433_5malb56fe",
"type": "list-item",
"props": {
"kind": "check",
"text": "checkbox",
"checked": false,
"indent": 0,
"align": "left"
},
"meta": {
"createdAt": "2025-11-15T19:24:17.433Z",
"updatedAt": "2025-11-15T20:13:56.404Z",
"bgColor": "#dc2626"
}
},
{
"id": "block_1763237180130_m82opx9yx",
"type": "paragraph",
"props": {
"text": "paragraphe"
},
"meta": {
"createdAt": "2025-11-15T20:06:20.130Z",
"updatedAt": "2025-11-15T20:13:50.464Z",
"bgColor": "#dc2626"
}
},
{
"id": "block_1763234665227_e09ql6sb4",
"type": "list-item",
"props": {
"kind": "bullet",
"text": "bullet",
"indent": 0,
"align": "left"
},
"meta": {
"createdAt": "2025-11-15T19:24:25.227Z",
"updatedAt": "2025-11-15T20:07:27.797Z",
"bgColor": "#dc2626"
}
},
{
"id": "block_1763237840896_fuw5hvm9t",
"type": "columns",
"id": "block_1763308160356_nfhdtf1p1",
"type": "kanban",
"props": {
"columns": [
{
"id": "or66s9hqb",
"blocks": [
"id": "block_1763308177122_doyf2zh37",
"title": "To Do",
"cards": [
{
"id": "block_1763237836743_a06ez4lux",
"type": "paragraph",
"props": {
"text": "paragraphe"
"id": "item_1763308197023_pbeezlint",
"title": "New Card 2",
"description": ""
},
"meta": {
"createdAt": "2025-11-15T20:17:16.743Z",
"updatedAt": "2025-11-15T20:17:16.743Z",
"bgColor": "#dc2626"
{
"id": "item_1763308195207_1skel85f7",
"title": "New Card 1",
"description": ""
},
{
"id": "item_1763308197933_aj34wtfd9",
"title": "New Card 3",
"description": ""
}
]
},
{
"id": "n90fsx72s",
"type": "paragraph",
"props": {
"text": "paragraphe"
},
"children": [],
"meta": {
"bgColor": "#dc2626"
}
},
{
"id": "tkk3ir62w",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
},
{
"id": "ljyumvc5w",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
},
{
"id": "rae60bsx3",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
},
{
"id": "xa3p8sybb",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
},
{
"id": "qln2xcscy",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
}
],
"width": 50
},
{
"id": "s16nsirzh",
"blocks": [
{
"id": "block_1763237223906_8fazui2p9",
"type": "paragraph",
"props": {
"text": "paragraphe"
},
"meta": {
"createdAt": "2025-11-15T20:07:03.906Z",
"updatedAt": "2025-11-15T20:07:24.349Z",
"bgColor": "#dc2626"
}
}
],
"width": 50
"id": "item_1763308190239_dmw2vomdm",
"title": "done",
"cards": []
}
]
},
"meta": {
"createdAt": "2025-11-15T20:17:20.896Z",
"updatedAt": "2025-11-15T22:13:34.707Z"
}
},
{
"id": "block_1763237641170_alnjnsj8c",
"type": "list-item",
"props": {
"kind": "numbered",
"text": "number list 1",
"indent": 0,
"align": "left",
"number": 1
},
"meta": {
"createdAt": "2025-11-15T20:14:01.171Z",
"updatedAt": "2025-11-15T20:14:14.163Z",
"bgColor": "#dc2626"
}
},
{
"id": "block_1763238667046_3xvnp49yo",
"type": "heading",
"props": {
"level": 1,
"text": "H2"
},
"meta": {
"createdAt": "2025-11-15T20:31:07.046Z",
"updatedAt": "2025-11-15T21:07:39.675Z",
"bgColor": "#525252"
}
},
{
"id": "block_1763238706236_v3kaqyax7",
"type": "heading",
"props": {
"level": 1,
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:46.236Z",
"updatedAt": "2025-11-15T21:55:37.233Z"
}
},
{
"id": "block_1763238706552_9zpoiafug",
"type": "table",
"props": {
"rows": [
{
"id": "block_1763248205146_pnw8cifrx",
"cells": [
{
"id": "block_1763248205146_p9wc7se4g",
"text": ""
}
]
}
],
"header": false
},
"meta": {
"createdAt": "2025-11-15T20:31:46.552Z",
"updatedAt": "2025-11-15T23:10:05.176Z"
}
},
{
"id": "block_1763238706720_x5fdjzcm1",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:46.720Z",
"updatedAt": "2025-11-15T20:32:24.875Z"
}
},
{
"id": "block_1763238706867_cbgv0yetb",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:46.867Z",
"updatedAt": "2025-11-15T20:31:46.867Z"
}
},
{
"id": "block_1763238707162_bh255zvaf",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:47.162Z",
"updatedAt": "2025-11-15T20:31:47.162Z"
}
},
{
"id": "block_1763248197366_ynjr796za",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T23:09:57.366Z",
"updatedAt": "2025-11-15T23:09:57.366Z"
}
},
{
"id": "block_1763248197834_sb477zyix",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T23:09:57.834Z",
"updatedAt": "2025-11-15T23:09:57.834Z"
}
},
{
"id": "block_1763248198129_38lauudq6",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T23:09:58.130Z",
"updatedAt": "2025-11-15T23:09:58.130Z"
}
},
{
"id": "block_1763248199064_xs3run960",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T23:09:59.064Z",
"updatedAt": "2025-11-15T23:09:59.064Z"
}
},
{
"id": "block_1763238707334_uupqjbbia",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:47.334Z",
"updatedAt": "2025-11-15T20:31:47.334Z"
}
},
{
"id": "block_1763238707506_lj73550jb",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:47.506Z",
"updatedAt": "2025-11-15T20:31:47.506Z"
"createdAt": "2025-11-16T15:49:20.356Z",
"updatedAt": "2025-11-16T15:50:10.618Z"
}
}
],
"meta": {
"createdAt": "2025-11-14T19:38:33.471Z",
"updatedAt": "2025-11-15T23:10:05.176Z"
"updatedAt": "2025-11-16T15:50:10.618Z"
}
}
```