feat: enhance Unsplash integration with pagination, random images, and portrait orientation

- Added pagination support to search endpoint with page parameter and orientation control (defaults to portrait)
- Implemented new /random endpoint for fetching random portrait images with configurable count
- Enhanced UnsplashPickerComponent with infinite scroll, loading states, and automatic random image loading on open
- Added debounced search input, scroll detection for pagination, and improved error
This commit is contained in:
Bruno Charest 2025-11-17 15:38:07 -05:00
parent 5e8cddf92e
commit 332f586d7b
11 changed files with 828 additions and 55 deletions

View File

@ -12,14 +12,18 @@ router.get('/search', async (req, res) => {
}
const q = String(req.query.q || '').trim();
const perPage = Math.min(50, Math.max(1, Number(req.query.perPage || 24)));
const page = Math.max(1, Number(req.query.page || 1));
if (!q) return res.json({ results: [] });
const url = new URL('https://api.unsplash.com/search/photos');
url.searchParams.set('query', q);
url.searchParams.set('per_page', String(perPage));
url.searchParams.set('page', String(page));
url.searchParams.set('client_id', ACCESS_KEY);
// Prefer landscape/small for editor usage
url.searchParams.set('orientation', 'landscape');
// Force portrait orientation for better display in editor
const orientation = String(req.query.orientation || 'portrait');
url.searchParams.set('orientation', orientation);
console.log('[Unsplash] Search with orientation:', orientation);
const upstream = await fetch(url.toString(), { headers: { 'Accept-Version': 'v1' } });
if (!upstream.ok) {
@ -35,6 +39,7 @@ router.get('/search', async (req, res) => {
links: r.links,
user: r.user ? { name: r.user.name } : undefined,
})) : [];
console.log(`[Unsplash] Search returned ${results.length} results for orientation="${orientation}"`);
return res.json({ results });
} catch (e) {
console.error('[Unsplash] proxy error', e);
@ -42,4 +47,41 @@ router.get('/search', async (req, res) => {
}
});
router.get('/random', async (req, res) => {
try {
const ACCESS_KEY = process.env.UNSPLASH_ACCESS_KEY;
if (!ACCESS_KEY) {
return res.status(501).json({ error: 'unsplash_disabled' });
}
const perPage = Math.min(50, Math.max(1, Number(req.query.perPage || 24)));
const orientation = String(req.query.orientation || 'portrait');
console.log(`[Unsplash] Random request with orientation="${orientation}", count=${perPage}`);
const url = new URL('https://api.unsplash.com/photos/random');
url.searchParams.set('count', String(perPage));
url.searchParams.set('client_id', ACCESS_KEY);
url.searchParams.set('orientation', orientation);
const upstream = await fetch(url.toString(), { headers: { 'Accept-Version': 'v1' } });
if (!upstream.ok) {
const text = await upstream.text().catch(() => '');
return res.status(502).json({ error: 'unsplash_upstream_error', status: upstream.status, message: text });
}
const json = await upstream.json();
const arr = Array.isArray(json) ? json : (json ? [json] : []);
const results = arr.map((r) => ({
id: r.id,
alt_description: r.alt_description || null,
urls: r.urls,
links: r.links,
user: r.user ? { name: r.user.name } : undefined,
}));
console.log(`[Unsplash] Random returned ${results.length} results for orientation="${orientation}"`);
return res.json({ results });
} catch (e) {
console.error('[Unsplash] random proxy error', e);
return res.status(500).json({ error: 'internal_error' });
}
});
export default router;

View File

@ -2,7 +2,7 @@ import { Component, Output, EventEmitter, Input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface InlineToolbarAction {
type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'new-page' | 'heading-2' | 'more' | 'drag' | 'menu';
type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'link' | 'heading-2' | 'more' | 'drag' | 'menu';
}
@Component({
@ -177,10 +177,10 @@ export interface InlineToolbarAction {
</button>
<!-- Link/New Page -->
<button *ngIf="!actions || actions.includes('new-page')"
<button *ngIf="!actions || actions.includes('link')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="Link to page"
(click)="onAction('new-page')"
(click)="onAction('link')"
type="button"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">

View File

@ -129,15 +129,23 @@ export class LinkBlockComponent implements OnInit {
}
ngOnInit(): void {
// Auto-open edit modal when a new link block is created without URL.
// Auto-open edit modal when a new link block is created.
// This is triggered both when inserting a fresh link block and when converting
// from another block type, so the user can confirm text and enter the URL.
setTimeout(() => {
if (this.autoEditInitialized) return;
if (!this.props?.url) {
this.autoEditInitialized = true;
try {
console.debug('[LinkBlock] ngOnInit auto-open check', {
id: this.block?.id,
text: this.props?.text,
url: this.props?.url
});
} catch {}
// Open the editor whenever the block is created via palette / toolbar.
// If a URL already exists (e.g. imported content), the user can still confirm it.
this.startEdit();
}
}, 0);
}
@ -181,6 +189,13 @@ export class LinkBlockComponent implements OnInit {
this.editText = this.props.text || '';
this.editUrl = this.props.url || '';
this.editing.set(true);
try {
console.debug('[LinkBlock] startEdit()', {
id: this.block?.id,
editText: this.editText,
editUrl: this.editUrl
});
} catch {}
// Focus the text input on next tick for better keyboard UX
setTimeout(() => {
try {

View File

@ -182,6 +182,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
'table': 'table',
'image': 'image',
'file': 'file',
'link': 'link',
'heading-2': 'heading-2',
};
const id = map[type];

View File

@ -11,7 +11,7 @@ import { BlockMenuComponent } from '../palette/block-menu.component';
import { BlockMenuAction } from '../block/block-initial-menu.component';
import { TocButtonComponent } from '../toc/toc-button.component';
import { TocPanelComponent } from '../toc/toc-panel.component';
import { UnsplashPickerComponent } from '../unsplash/unsplash-picker.component';
import { UnsplashPickerV2Component } from '../unsplash/unsplash-picker-v2.component';
import { DragDropService } from '../../services/drag-drop.service';
import { DragDropFilesDirective } from '../../../blocks/file/directives/drag-drop-files.directive';
import { FilePickerService } from '../../../blocks/file/services/file-picker.service';
@ -21,7 +21,7 @@ import { PaletteItem, PALETTE_ITEMS } from '../../core/constants/palette-items';
@Component({
selector: 'app-editor-shell',
standalone: true,
imports: [CommonModule, FormsModule, BlockHostComponent, BlockMenuComponent, TocButtonComponent, TocPanelComponent, UnsplashPickerComponent, DragDropFilesDirective],
imports: [CommonModule, FormsModule, BlockHostComponent, BlockMenuComponent, TocButtonComponent, TocPanelComponent, UnsplashPickerV2Component, DragDropFilesDirective],
template: `
<div class="grid h-full w-full grid-rows-[auto,1fr] grid-cols-[1fr,auto] overflow-hidden">
<div class="row-[1] col-[1/3] px-8 py-4 bg-card dark:bg-main border-b border-border" (click)="onShellClick()">
@ -120,8 +120,8 @@ import { PaletteItem, PALETTE_ITEMS } from '../../core/constants/palette-items';
<!-- Block Menu -->
<app-block-menu (itemSelected)="onPaletteItemSelected($event)" />
<!-- Unsplash Picker Modal -->
<app-unsplash-picker />
<!-- Unsplash Picker Modal (v2) -->
<app-unsplash-picker-v2 />
`,
styles: [`
@ -295,6 +295,7 @@ export class EditorShellComponent implements AfterViewInit {
'image': 'image',
'file': 'file',
'heading-2': 'heading-2',
'link': 'link',
// 'new-page' and 'use-ai' are placeholders and not palette-backed
};
const id = idMap[action];

View File

@ -2,7 +2,7 @@ import { Component, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface ToolbarAction {
type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'new-page' | 'heading-2' | 'more';
type: 'use-ai' | 'checkbox-list' | 'numbered-list' | 'bullet-list' | 'table' | 'image' | 'file' | 'new-page' | 'heading-2' | 'link' | 'more';
label: string;
}
@ -106,6 +106,18 @@ export interface ToolbarAction {
</svg>
</button>
<!-- Link -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"
title="Link"
(click)="onAction('link')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.5 1.5" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.5-1.5" />
</svg>
</button>
<!-- New Page -->
<button
class="p-1.5 rounded hover:bg-neutral-700 transition-colors text-gray-400 hover:text-gray-200"

View File

@ -0,0 +1,385 @@
import { Component, OnDestroy, signal, ChangeDetectorRef, NgZone } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
interface UnsplashImage {
id: string;
alt_description: string | null;
urls: { thumb: string; small: string; regular: string; full: string };
links?: { html?: string };
user?: { name?: string };
}
@Component({
selector: 'app-unsplash-picker-v2',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div *ngIf="open()" class="fixed inset-0 z-[1000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" (click)="close()"></div>
<div class="relative w-[min(720px,90vw)] max-h-[80vh] bg-surface1 text-main dark:text-neutral-100 rounded-xl shadow-2xl border border-border dark:border-gray-700 overflow-hidden flex flex-col">
<div class="px-4 py-3 border-b border-border dark:border-gray-700 flex items-center gap-2">
<h3 class="text-lg font-semibold flex-1">Search image (V2)</h3>
<button class="btn btn-xs" (click)="close()">Close</button>
</div>
<div class="p-3 flex items-center gap-2">
<input class="input input-bordered flex-1" placeholder="Search portrait images..." [(ngModel)]="query" (keyup.enter)="onSearchEnter()" (input)="onSearchInput()" />
<button class="btn btn-primary btn-sm" (click)="onSearchClick()" [disabled]="loading">Search</button>
</div>
<!-- Status Messages -->
<div class="px-3 pb-3" *ngIf="error || notice || loading">
<div class="flex items-start gap-2 text-xs" *ngIf="error">
<svg class="w-4 h-4 text-error flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-error">{{ error }}</span>
</div>
<div class="flex items-start gap-2 text-xs" *ngIf="notice && !error">
<svg class="w-4 h-4 text-warning flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-text-muted">{{ notice }}</span>
</div>
<div class="flex items-center gap-2 text-xs" *ngIf="loading">
<span class="inline-block w-3 h-3 rounded-full border-2 border-primary border-t-transparent animate-spin flex-shrink-0"></span>
<span class="text-text-muted">{{ loading && results.length === 0 ? 'Loading images…' : 'Loading more…' }}</span>
</div>
</div>
<div class="p-3 pt-0 overflow-auto custom-scrollbar grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 min-h-[200px]" (scroll)="onScroll($event)">
<button *ngFor="let img of results" class="relative rounded-md overflow-hidden border border-border dark:border-gray-700 hover:border-primary hover:shadow-md transition-all focus:outline-none focus:ring-2 focus:ring-primary group"
title="Click to insert" (click)="select(img)">
<div class="relative w-full aspect-[3/4] min-h-[180px] bg-surface2">
<img [src]="img.urls.thumb"
[alt]="img.alt_description || ''"
loading="lazy"
class="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" />
</div>
</button>
</div>
<!-- Footer -->
<div class="px-4 py-2 border-t border-border dark:border-gray-700 bg-surface2/50 flex items-center justify-between text-xs text-text-muted">
<span>{{ results.length }} image{{ results.length !== 1 ? 's' : '' }}</span>
<a href="https://unsplash.com" target="_blank" rel="noopener noreferrer" class="hover:text-text-main transition-colors">
Powered by <span class="font-semibold">Unsplash</span>
</a>
</div>
</div>
</div>
`,
styles: [`
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.4);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.6);
}
`]
})
export class UnsplashPickerV2Component implements OnDestroy {
open = signal(false);
query = '';
results: UnsplashImage[] = [];
error = '';
notice = '';
loading = false;
page = 1;
hasMore = true;
private mode: 'random' | 'search' = 'random';
private onSelect: ((url: string) => void) | null = null;
private listener: any;
private abortController: AbortController | null = null;
private scrollDebounceTimer: any = null;
private searchDebounceTimer: any = null;
constructor(private cdr: ChangeDetectorRef, private zone: NgZone) {
this.listener = (ev: CustomEvent) => {
// Important: run inside Angular zone so that change detection triggers
this.zone.run(() => {
this.onSelect = (ev.detail?.callback as (url: string) => void) || null;
this.reset();
this.open.set(true);
console.log('[Unsplash V2] Picker opened, loading initial random images...');
// Load an initial batch of random portrait images
void this.loadRandom(false);
});
};
window.addEventListener('nimbus-open-unsplash', this.listener as any);
}
ngOnDestroy(): void {
window.removeEventListener('nimbus-open-unsplash', this.listener as any);
this.cleanup();
}
private cleanup(): void {
if (this.scrollDebounceTimer) {
clearTimeout(this.scrollDebounceTimer);
this.scrollDebounceTimer = null;
}
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = null;
}
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
private reset(): void {
this.cleanup();
this.results = [];
this.error = '';
this.notice = '';
this.query = '';
this.page = 1;
this.hasMore = true;
this.loading = false;
this.mode = 'random';
}
async search(append: boolean = false): Promise<void> {
this.error = '';
this.notice = '';
const q = (this.query || '').trim();
// Empty query: fall back to random images
if (!q) {
this.mode = 'random';
this.page = 1;
this.hasMore = true;
await this.loadRandom(false);
return;
}
this.mode = 'search';
if (!append) {
this.results = [];
this.page = 1;
this.hasMore = true;
}
if (this.loading) return;
if (!this.hasMore && append) return;
console.log(`[Unsplash V2] Searching for "${q}" (page ${this.page}, append=${append})`);
// Cancel any ongoing request
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
this.loading = true;
this.cdr.detectChanges();
try {
const url = `/api/integrations/unsplash/search?q=${encodeURIComponent(q)}&perPage=24&page=${this.page}&orientation=portrait`;
const res = await fetch(url, { signal: this.abortController.signal });
if (res.status === 501) {
const data = await res.json().catch(() => ({}));
console.error('[Unsplash V2] API key not configured:', data);
this.notice = '⚠️ Unsplash API key not configured. Please set UNSPLASH_ACCESS_KEY in your server environment.';
this.loading = false;
return;
}
if (res.status === 502) {
const data = await res.json().catch(() => ({}));
console.error('[Unsplash V2] Upstream API error:', data);
this.error = `Unsplash API error (${data.status || 'unknown'}). Please try again later.`;
this.loading = false;
return;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error('[Unsplash V2] Search failed:', res.status, text);
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
}
const data = await res.json();
const newResults = Array.isArray(data?.results) ? data.results : [];
console.log(`[Unsplash V2] Received ${newResults.length} search results`);
this.results = append ? [...this.results, ...newResults] : newResults;
if (!newResults.length) {
this.hasMore = false;
if (!this.results.length) {
this.notice = `No images found for "${q}". Try different keywords.`;
}
} else {
this.page += 1;
}
} catch (e: any) {
if (e.name === 'AbortError') {
console.log('[Unsplash V2] Search request aborted');
return; // Don't show error for aborted requests
}
console.error('[Unsplash V2] Search error:', e);
if (e.name === 'TypeError' && e.message.includes('fetch')) {
this.error = 'Network error. Please check your internet connection.';
} else {
this.error = `Search failed: ${e.message || 'Unknown error'}. Please try again.`;
}
} finally {
this.loading = false;
// Force change detection so results appear without needing an extra click
this.cdr.detectChanges();
}
}
async loadRandom(append: boolean = false): Promise<void> {
this.error = '';
if (!append) {
this.notice = '';
this.results = [];
}
if (this.loading) return;
console.log(`[Unsplash V2] Loading random images (append=${append})`);
// Cancel any ongoing request
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
this.loading = true;
this.mode = 'random';
try {
const url = '/api/integrations/unsplash/random?perPage=24&orientation=portrait';
const res = await fetch(url, { signal: this.abortController.signal });
if (res.status === 501) {
const data = await res.json().catch(() => ({}));
console.error('[Unsplash V2] API key not configured:', data);
this.notice = '⚠️ Unsplash API key not configured. Please set UNSPLASH_ACCESS_KEY in your server environment.';
this.loading = false;
return;
}
if (res.status === 502) {
const data = await res.json().catch(() => ({}));
console.error('[Unsplash V2] Upstream API error:', data);
this.error = `Unsplash API error (${data.status || 'unknown'}). Please try again later.`;
this.loading = false;
return;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error('[Unsplash V2] Random load failed:', res.status, text);
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
}
const data = await res.json();
const newResults = Array.isArray(data?.results) ? data.results : [];
console.log(`[Unsplash V2] Received ${newResults.length} random images`);
this.results = append ? [...this.results, ...newResults] : newResults;
if (!this.results.length) {
this.notice = 'No images available. Please try again later.';
}
} catch (e: any) {
if (e.name === 'AbortError') {
console.log('[Unsplash V2] Random request aborted');
return; // Don't show error for aborted requests
}
console.error('[Unsplash V2] Random load error:', e);
if (e.name === 'TypeError' && e.message.includes('fetch')) {
this.error = 'Network error. Please check your internet connection.';
} else {
this.error = `Failed to load images: ${e.message || 'Unknown error'}. Please try again.`;
}
} finally {
this.loading = false;
// Ensure UI updates after async random / lazy-loaded images
this.cdr.detectChanges();
}
}
async loadMore(): Promise<void> {
if (this.loading) return;
if (this.mode === 'search') {
if (!this.hasMore) return;
await this.search(true);
} else {
await this.loadRandom(true);
}
}
onScroll(event: Event): void {
const el = event.target as HTMLElement | null;
if (!el) return;
const threshold = 200; // px from bottom
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight);
console.log('[Unsplash V2] Scroll event', {
scrollTop: el.scrollTop,
clientHeight: el.clientHeight,
scrollHeight: el.scrollHeight,
distance,
});
if (distance <= threshold) {
console.log('[Unsplash V2] Scroll threshold reached, loading more...');
void this.loadMore();
}
}
select(img: UnsplashImage): void {
const url = img?.urls?.regular || img?.urls?.full || img?.urls?.small;
if (url && this.onSelect) this.onSelect(url);
this.close();
}
onSearchInput(): void {
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
this.searchDebounceTimer = setTimeout(() => {
const query = (this.query || '').trim();
if (!query) {
console.log('[Unsplash V2] Input cleared, loading random images.');
this.mode = 'random';
this.page = 1;
this.hasMore = true;
void this.loadRandom(false);
return;
}
console.log('[Unsplash V2] Debounced input search triggered.');
void this.search(false);
}, 400);
}
onSearchEnter(): void {
console.log('[Unsplash V2] Search triggered by Enter key');
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = null;
}
void this.search(false);
}
onSearchClick(): void {
console.log('[Unsplash V2] Search triggered by button click');
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = null;
}
void this.search(false);
}
close(): void {
console.log('[Unsplash V2] Picker closing...');
this.open.set(false);
// Don't reset immediately to keep results visible during close animation
// The reset will happen on next open
}
}

View File

@ -17,26 +17,70 @@ interface UnsplashImage {
template: `
<div *ngIf="open()" class="fixed inset-0 z-[1000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" (click)="close()"></div>
<div class="relative w-[min(900px,95vw)] max-h-[85vh] bg-surface1 text-main dark:text-neutral-100 rounded-xl shadow-2xl border border-border dark:border-gray-700 overflow-hidden flex flex-col">
<div class="relative w-[min(720px,90vw)] max-h-[80vh] bg-surface1 text-main dark:text-neutral-100 rounded-xl shadow-2xl border border-border dark:border-gray-700 overflow-hidden flex flex-col">
<div class="px-4 py-3 border-b border-border dark:border-gray-700 flex items-center gap-2">
<h3 class="text-lg font-semibold flex-1">Search image</h3>
<button class="btn btn-xs" (click)="close()">Close</button>
</div>
<div class="p-3 flex items-center gap-2">
<input class="input input-bordered flex-1" placeholder="Search for an image" [(ngModel)]="query" (keyup.enter)="search()" />
<button class="btn btn-primary btn-sm" (click)="search()">Search</button>
<input class="input input-bordered flex-1" placeholder="Search portrait images..." [(ngModel)]="query" (keyup.enter)="onSearchEnter()" (input)="onSearchInput()" />
<button class="btn btn-primary btn-sm" (click)="onSearchClick()" [disabled]="loading">Search</button>
</div>
<div class="px-3 pb-3 text-xs text-text-muted" *ngIf="error">{{ error }}</div>
<div class="px-3 pb-3 text-xs text-text-muted" *ngIf="notice">{{ notice }}</div>
<div class="p-3 pt-0 overflow-auto grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 min-h-[200px]">
<button *ngFor="let img of results" class="relative rounded-md overflow-hidden border border-border dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-primary"
<!-- Status Messages -->
<div class="px-3 pb-3" *ngIf="error || notice || loading">
<div class="flex items-start gap-2 text-xs" *ngIf="error">
<svg class="w-4 h-4 text-error flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-error">{{ error }}</span>
</div>
<div class="flex items-start gap-2 text-xs" *ngIf="notice && !error">
<svg class="w-4 h-4 text-warning flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-text-muted">{{ notice }}</span>
</div>
<div class="flex items-center gap-2 text-xs" *ngIf="loading">
<span class="inline-block w-3 h-3 rounded-full border-2 border-primary border-t-transparent animate-spin flex-shrink-0"></span>
<span class="text-text-muted">{{ loading && results.length === 0 ? 'Loading images…' : 'Loading more…' }}</span>
</div>
</div>
<div class="p-3 pt-0 overflow-auto custom-scrollbar grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 min-h-[200px]" (scroll)="onScroll($event)">
<button *ngFor="let img of results" class="relative rounded-md overflow-hidden border border-border dark:border-gray-700 hover:border-primary hover:shadow-md transition-all focus:outline-none focus:ring-2 focus:ring-primary group"
title="Click to insert" (click)="select(img)">
<img [src]="img.urls.small" [alt]="img.alt_description || ''" class="block w-full h-full object-cover" />
<div class="relative w-full aspect-[3/4] min-h-[180px] bg-surface2">
<img [src]="img.urls.thumb"
[alt]="img.alt_description || ''"
loading="lazy"
class="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105" />
</div>
</button>
</div>
<!-- Footer -->
<div class="px-4 py-2 border-t border-border dark:border-gray-700 bg-surface2/50 flex items-center justify-between text-xs text-text-muted">
<span>{{ results.length }} image{{ results.length !== 1 ? 's' : '' }}</span>
<a href="https://unsplash.com" target="_blank" rel="noopener noreferrer" class="hover:text-text-main transition-colors">
Powered by <span class="font-semibold">Unsplash</span>
</a>
</div>
</div>
</div>
`,
styles: [`
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.4);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.6);
}
`]
})
export class UnsplashPickerComponent implements OnDestroy {
open = signal(false);
@ -44,44 +88,243 @@ export class UnsplashPickerComponent implements OnDestroy {
results: UnsplashImage[] = [];
error = '';
notice = '';
loading = false;
page = 1;
hasMore = true;
private mode: 'random' | 'search' = 'random';
private onSelect: ((url: string) => void) | null = null;
private listener: any;
private abortController: AbortController | null = null;
private scrollDebounceTimer: any = null;
private searchDebounceTimer: any = null;
constructor() {
this.listener = (ev: CustomEvent) => {
this.onSelect = (ev.detail?.callback as (url: string) => void) || null;
this.error = '';
this.notice = '';
this.results = [];
this.query = '';
this.reset();
this.open.set(true);
console.log('[Unsplash] Picker opened, loading initial random images...');
// Load an initial batch of random portrait images
void this.loadRandom(false);
};
window.addEventListener('nimbus-open-unsplash', this.listener as any);
}
ngOnDestroy(): void {
window.removeEventListener('nimbus-open-unsplash', this.listener as any);
this.cleanup();
}
async search(): Promise<void> {
private cleanup(): void {
if (this.scrollDebounceTimer) {
clearTimeout(this.scrollDebounceTimer);
this.scrollDebounceTimer = null;
}
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = null;
}
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
private reset(): void {
this.cleanup();
this.results = [];
this.error = '';
this.notice = '';
this.query = '';
this.page = 1;
this.hasMore = true;
this.loading = false;
this.mode = 'random';
}
async search(append: boolean = false): Promise<void> {
this.error = '';
this.notice = '';
this.results = [];
const q = (this.query || '').trim();
if (!q) return;
try {
const res = await fetch(`/api/integrations/unsplash/search?q=${encodeURIComponent(q)}&perPage=24`);
if (res.status === 501) {
this.notice = 'Unsplash access key missing. Set UNSPLASH_ACCESS_KEY in server environment to enable search.';
// Empty query: fall back to random images
if (!q) {
this.mode = 'random';
this.page = 1;
this.hasMore = true;
await this.loadRandom(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this.results = Array.isArray(data?.results) ? data.results : [];
if (!this.results.length) this.notice = 'No results.';
} catch (e: any) {
this.error = 'Search failed. Please try again.';
this.mode = 'search';
if (!append) {
this.results = [];
this.page = 1;
this.hasMore = true;
}
if (this.loading) return;
if (!this.hasMore && append) return;
console.log(`[Unsplash] Searching for "${q}" (page ${this.page}, append=${append})`);
// Cancel any ongoing request
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
this.loading = true;
try {
const url = `/api/integrations/unsplash/search?q=${encodeURIComponent(q)}&perPage=24&page=${this.page}&orientation=portrait`;
const res = await fetch(url, { signal: this.abortController.signal });
if (res.status === 501) {
const data = await res.json().catch(() => ({}));
console.error('[Unsplash] API key not configured:', data);
this.notice = '⚠️ Unsplash API key not configured. Please set UNSPLASH_ACCESS_KEY in your server environment.';
this.loading = false;
return;
}
if (res.status === 502) {
const data = await res.json().catch(() => ({}));
console.error('[Unsplash] Upstream API error:', data);
this.error = `Unsplash API error (${data.status || 'unknown'}). Please try again later.`;
this.loading = false;
return;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error('[Unsplash] Search failed:', res.status, text);
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
}
const data = await res.json();
const newResults = Array.isArray(data?.results) ? data.results : [];
console.log(`[Unsplash] Received ${newResults.length} search results`);
this.results = append ? [...this.results, ...newResults] : newResults;
if (!newResults.length) {
this.hasMore = false;
if (!this.results.length) {
this.notice = `No images found for "${q}". Try different keywords.`;
}
} else {
this.page += 1;
}
} catch (e: any) {
if (e.name === 'AbortError') {
console.log('[Unsplash] Search request aborted');
return; // Don't show error for aborted requests
}
console.error('[Unsplash] Search error:', e);
if (e.name === 'TypeError' && e.message.includes('fetch')) {
this.error = 'Network error. Please check your internet connection.';
} else {
this.error = `Search failed: ${e.message || 'Unknown error'}. Please try again.`;
}
} finally {
this.loading = false;
}
}
async loadRandom(append: boolean = false): Promise<void> {
this.error = '';
if (!append) {
this.notice = '';
this.results = [];
}
if (this.loading) return;
console.log(`[Unsplash] Loading random images (append=${append})`);
// Cancel any ongoing request
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
this.loading = true;
this.mode = 'random';
try {
const url = '/api/integrations/unsplash/random?perPage=24&orientation=portrait';
const res = await fetch(url, { signal: this.abortController.signal });
if (res.status === 501) {
const data = await res.json().catch(() => ({}));
console.error('[Unsplash] API key not configured:', data);
this.notice = '⚠️ Unsplash API key not configured. Please set UNSPLASH_ACCESS_KEY in your server environment.';
this.loading = false;
return;
}
if (res.status === 502) {
const data = await res.json().catch(() => ({}));
console.error('[Unsplash] Upstream API error:', data);
this.error = `Unsplash API error (${data.status || 'unknown'}). Please try again later.`;
this.loading = false;
return;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error('[Unsplash] Random load failed:', res.status, text);
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
}
const data = await res.json();
const newResults = Array.isArray(data?.results) ? data.results : [];
console.log(`[Unsplash] Received ${newResults.length} random images`);
this.results = append ? [...this.results, ...newResults] : newResults;
if (!this.results.length) {
this.notice = 'No images available. Please try again later.';
}
} catch (e: any) {
if (e.name === 'AbortError') {
console.log('[Unsplash] Random request aborted');
return; // Don't show error for aborted requests
}
console.error('[Unsplash] Random load error:', e);
if (e.name === 'TypeError' && e.message.includes('fetch')) {
this.error = 'Network error. Please check your internet connection.';
} else {
this.error = `Failed to load images: ${e.message || 'Unknown error'}. Please try again.`;
}
} finally {
this.loading = false;
}
}
async loadMore(): Promise<void> {
if (this.loading) return;
if (this.mode === 'search') {
if (!this.hasMore) return;
await this.search(true);
} else {
await this.loadRandom(true);
}
}
onScroll(event: Event): void {
// Debounce scroll events to avoid too many loadMore calls
if (this.scrollDebounceTimer) {
clearTimeout(this.scrollDebounceTimer);
}
const target = event.target as HTMLElement | null;
if (!target) return;
this.scrollDebounceTimer = setTimeout(() => {
const el = target;
if (!el) return;
const threshold = 200; // px from bottom
if (el.scrollTop + el.clientHeight >= el.scrollHeight - threshold) {
console.log('[Unsplash] Scroll threshold reached, loading more...');
void this.loadMore();
}
}, 150); // 150ms debounce
}
select(img: UnsplashImage): void {
@ -90,5 +333,47 @@ export class UnsplashPickerComponent implements OnDestroy {
this.close();
}
close(): void { this.open.set(false); }
onSearchInput(): void {
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
this.searchDebounceTimer = setTimeout(() => {
const query = (this.query || '').trim();
if (!query) {
console.log('[Unsplash] Input cleared, loading random images.');
this.mode = 'random';
this.page = 1;
this.hasMore = true;
void this.loadRandom(false);
return;
}
console.log('[Unsplash] Debounced input search triggered.');
void this.search(false);
}, 400);
}
onSearchEnter(): void {
console.log('[Unsplash] Search triggered by Enter key');
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = null;
}
void this.search(false);
}
onSearchClick(): void {
console.log('[Unsplash] Search triggered by button click');
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = null;
}
void this.search(false);
}
close(): void {
console.log('[Unsplash] Picker closing...');
this.open.set(false);
// Don't reset immediately to keep results visible during close animation
// The reset will happen on next open
}
}

View File

@ -49,6 +49,53 @@ export class PaletteService {
* Used by all menus to create/insert blocks consistently.
*/
async applySelection(item: PaletteItem): Promise<void> {
// Special handling for Unsplash: open the global picker modal and insert an image block
if (item.id === 'unsplash') {
this.close();
try {
const ev = new CustomEvent('nimbus-open-unsplash', {
detail: {
callback: async (imageUrl: string) => {
if (!imageUrl) return;
const blocks = this.documentService.blocks();
const activeId = this.selectionService.getActive();
let replaceBlockId: string | null = null;
let insertAfterId: string | null = null;
if (activeId) {
const idx = blocks.findIndex(b => b.id === activeId);
if (idx >= 0) {
const blk: any = blocks[idx];
if (blk.type === 'paragraph' && (!blk.props?.text || String(blk.props.text).trim() === '')) {
replaceBlockId = blk.id;
} else {
insertAfterId = blk.id;
}
}
}
if (replaceBlockId) {
this.documentService.convertBlock(replaceBlockId, 'image' as any, { src: imageUrl, alt: '' });
this.selectionService.setActive(replaceBlockId);
} else {
const imgBlock = this.documentService.createBlock('image' as any, { src: imageUrl, alt: '' });
if (insertAfterId) {
this.documentService.insertBlock(insertAfterId, imgBlock);
} else {
this.documentService.appendBlock(imgBlock);
}
this.selectionService.setActive(imgBlock.id);
}
}
}
});
window.dispatchEvent(ev);
} catch {
// Swallow errors: if the picker is not mounted, failing silently is acceptable
}
return;
}
// Special handling for File: open multi-picker and create N blocks
if (item.type === 'file' || item.id === 'file') {
const files = await this.filePicker.pick({ multiple: true, accept: '*/*' });

View File

@ -19,9 +19,6 @@ color: "#A855F7"
catégorie: ""
description: "Allo ceci est un tests toto Test 1 Markdown Titres Niveau 1 #tag1 #tag2 #test..."
---
Allo ceci est un tests
toto
# Test 1 Markdown
## Titres

View File

@ -8,22 +8,10 @@ documentModelFormat: "block-model-v1"
{
"id": "block_1763149113471_461xyut80",
"title": "Page Tests",
"blocks": [
{
"id": "block_1763391929543_lu1lzz0yz",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-17T15:05:29.543Z",
"updatedAt": "2025-11-17T15:05:29.543Z"
}
}
],
"blocks": [],
"meta": {
"createdAt": "2025-11-14T19:38:33.471Z",
"updatedAt": "2025-11-17T15:05:29.543Z"
"updatedAt": "2025-11-17T20:27:14.570Z"
}
}
```