Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors

This commit is contained in:
2026-03-06 04:47:07 +01:00
parent 2d84fbd91a
commit fe2347b54e
65 changed files with 3593 additions and 1030 deletions

View File

@@ -128,6 +128,26 @@
<pre><code>{{ node.value }}</code></pre>
}
</ng-template>
<ng-template
[remarkTemplate]="'image'"
let-node
>
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<img
[src]="getMarkdownImageSource(node.url)"
[alt]="node.alt || 'Shared image'"
class="block max-h-80 w-auto max-w-full"
loading="lazy"
/>
@if (isKlipyMediaUrl(node.url)) {
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
}
</div>
</ng-template>
</remark>
</div>
@if (getAttachments(message.id).length > 0) {
@@ -206,7 +226,15 @@
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div class="text-xs text-muted-foreground/70 mt-0.5 italic">Waiting for image source…</div>
<div
class="text-xs mt-0.5"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
[class.opacity-70]="!att.requestError"
[class.italic]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source…' }}
</div>
</div>
</div>
<button
@@ -217,6 +245,74 @@
</button>
</div>
}
} @else if (isVideoAttachment(att) || isAudioAttachment(att)) {
@if (att.available && att.objectUrl) {
@if (isVideoAttachment(att)) {
<app-chat-video-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
} @else {
<app-chat-audio-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xl">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<button
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
(click)="cancelAttachment(att, message.id)"
>
Cancel
</button>
</div>
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>{{ formatSpeed(att.speedBps) }}</span>
}
</div>
</div>
} @else {
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xl">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-1 text-xs leading-relaxed"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
[class.opacity-80]="!att.requestError"
>
{{ getMediaAttachmentStatusText(att) }}
</div>
</div>
<button
(click)="requestAttachment(att, message.id)"
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
{{ getMediaAttachmentActionLabel(att) }}
</button>
</div>
</div>
}
} @else {
<div class="border border-border rounded-md p-2 bg-secondary/40">
<div class="flex items-center justify-between">
@@ -244,7 +340,7 @@
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
(click)="requestAttachment(att, message.id)"
>
Request
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else {
<button
@@ -267,6 +363,13 @@
}
</div>
</div>
@if (!att.available && att.requestError) {
<div
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
>
{{ att.requestError }}
</div>
}
</div>
}
}
@@ -521,6 +624,43 @@
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
>
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2">
@if (klipy.isEnabled()) {
<button
type="button"
(click)="openKlipyGifPicker()"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.border-primary]="showKlipyGifPicker()"
[class.text-primary]="showKlipyGifPicker()"
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
aria-label="Search KLIPY GIFs"
title="Search KLIPY GIFs"
>
<ng-icon
name="lucideImage"
class="h-4 w-4"
/>
<span class="hidden sm:inline">GIF</span>
</button>
}
<button
type="button"
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
aria-label="Send message"
title="Send message"
>
<ng-icon
name="lucideSend"
class="h-5 w-5"
/>
</button>
</div>
<textarea
#messageInputRef
rows="1"
@@ -534,24 +674,14 @@
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
class="chat-textarea w-full pl-3 pr-12 py-2 rounded-2xl border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
class="chat-textarea w-full rounded-[1.35rem] border border-border py-2 pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.pr-16]="!klipy.isEnabled()"
[class.pr-40]="klipy.isEnabled()"
[class.border-primary]="dragActive()"
[class.border-dashed]="dragActive()"
[class.ctrl-resize]="ctrlHeld()"
></textarea>
<button
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0"
class="send-btn absolute right-2 bottom-[15px] w-8 h-8 rounded-full bg-primary text-primary-foreground grid place-items-center hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
[class.visible]="inputHovered() || messageContent.trim().length > 0"
>
<ng-icon
name="lucideSend"
class="w-4 h-4"
/>
</button>
@if (dragActive()) {
<div
class="pointer-events-none absolute inset-0 rounded-2xl border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center"
@@ -560,6 +690,39 @@
</div>
}
@if (pendingKlipyGif()) {
<div class="mt-2 flex">
<div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2">
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
<img
[src]="getPendingKlipyGifPreviewUrl()"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
class="h-full w-full object-cover"
loading="lazy"
/>
<span
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
>
KLIPY
</span>
</div>
<div class="min-w-0">
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
</div>
</div>
<button
type="button"
(click)="removePendingKlipyGif()"
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
>
Remove
</button>
</div>
</div>
}
@if (pendingFiles.length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (file of pendingFiles; track file.name) {
@@ -580,6 +743,13 @@
</div>
</div>
@if (showKlipyGifPicker() && klipy.isEnabled()) {
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
}
<!-- Image Lightbox Modal -->
@if (lightboxAttachment()) {
<div

View File

@@ -15,7 +15,12 @@ import {
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AttachmentService, Attachment } from '../../../core/services/attachment.service';
import {
AttachmentService,
Attachment,
MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../core/services/attachment.service';
import { KlipyGif, KlipyService } from '../../../core/services/klipy.service';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSend,
@@ -42,8 +47,13 @@ import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/user
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
import {
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
ContextMenuComponent,
UserAvatarComponent
} from '../../../shared';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
import { RemarkModule, MermaidComponent } from 'ngx-remark';
import remarkGfm from 'remark-gfm';
@@ -70,8 +80,11 @@ const COMMON_EMOJIS = [
CommonModule,
FormsModule,
NgIcon,
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
ContextMenuComponent,
UserAvatarComponent,
KlipyGifPickerComponent,
TypingIndicatorComponent,
RemarkModule,
MermaidComponent
@@ -109,8 +122,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
@ViewChild('bottomBar') bottomBar!: ElementRef;
private store = inject(Store);
readonly klipy = inject(KlipyService);
private webrtc = inject(WebRTCService);
private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService);
private cdr = inject(ChangeDetectorRef);
private markdown = inject(ChatMarkdownService);
@@ -163,6 +176,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
editingMessageId = signal<string | null>(null);
replyTo = signal<Message | null>(null);
showEmojiPicker = signal<string | null>(null);
pendingKlipyGif = signal<KlipyGif | null>(null);
showKlipyGifPicker = signal(false);
readonly commonEmojis = COMMON_EMOJIS;
@@ -327,10 +342,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
sendMessage(): void {
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0)
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
return;
const content = this.markdown.appendImageMarkdown(raw);
const content = this.buildOutgoingMessageContent(raw);
this.store.dispatch(
MessagesActions.sendMessage({
@@ -341,6 +356,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
);
this.messageContent = '';
this.pendingKlipyGif.set(null);
this.clearReply();
this.shouldScrollToBottom = true;
// Reset textarea height after sending
@@ -764,6 +780,51 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.setSelection(result.selectionStart, result.selectionEnd);
}
openKlipyGifPicker(): void {
if (!this.klipy.isEnabled())
return;
this.showKlipyGifPicker.set(true);
}
closeKlipyGifPicker(): void {
this.showKlipyGifPicker.set(false);
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.pendingKlipyGif.set(gif);
this.closeKlipyGifPicker();
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
this.sendMessage();
return;
}
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
removePendingKlipyGif(): void {
this.pendingKlipyGif.set(null);
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
getPendingKlipyGifPreviewUrl(): string {
const gif = this.pendingKlipyGif();
return gif ? this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url) : '';
}
getMarkdownImageSource(url?: string): string {
return url ? this.klipy.buildRenderableImageUrl(url) : '';
}
isKlipyMediaUrl(url?: string): boolean {
if (!url)
return false;
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
}
/** Handle drag-enter to activate the drop zone overlay. */
// Attachments: drag/drop and rendering
onDragEnter(evt: DragEvent): void {
@@ -945,6 +1006,47 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
}
/** Whether an attachment can be played inline as video. */
isVideoAttachment(att: Attachment): boolean {
return att.mime.startsWith('video/');
}
/** Whether an attachment can be played inline as audio. */
isAudioAttachment(att: Attachment): boolean {
return att.mime.startsWith('audio/');
}
/** Whether the user must explicitly accept a media download before playback. */
requiresMediaDownloadAcceptance(att: Attachment): boolean {
return (this.isVideoAttachment(att) || this.isAudioAttachment(att)) &&
att.size > MAX_AUTO_SAVE_SIZE_BYTES;
}
/** User-facing status copy for an unavailable audio/video attachment. */
getMediaAttachmentStatusText(att: Attachment): string {
if (att.requestError)
return att.requestError;
if (this.requiresMediaDownloadAcceptance(att)) {
return this.isVideoAttachment(att)
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.';
}
return this.isVideoAttachment(att)
? 'Waiting for video source…'
: 'Waiting for audio source…';
}
/** Action label for requesting an audio/video attachment. */
getMediaAttachmentActionLabel(att: Attachment): string {
if (this.requiresMediaDownloadAcceptance(att)) {
return att.requestError ? 'Retry download' : 'Accept download';
}
return att.requestError ? 'Retry' : 'Request';
}
/** Remove a pending file from the upload queue. */
removePendingFile(file: File): void {
const idx = this.pendingFiles.findIndex((pending) => pending === file);
@@ -955,10 +1057,33 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
/** Download a completed attachment to the user's device. */
downloadAttachment(att: Attachment): void {
async downloadAttachment(att: Attachment): Promise<void> {
if (!att.available || !att.objectUrl)
return;
const electronApi = (window as any)?.electronAPI as {
saveFileAs?: (
defaultFileName: string,
data: string
) => Promise<{ saved: boolean; cancelled: boolean }>;
} | undefined;
if (electronApi?.saveFileAs) {
const blob = await this.getAttachmentBlob(att);
if (blob) {
try {
const result = await electronApi.saveFileAs(
att.filename,
await this.blobToBase64(blob)
);
if (result.saved || result.cancelled)
return;
} catch { /* fall back to browser download */ }
}
}
const a = document.createElement('a');
a.href = att.objectUrl;
@@ -968,6 +1093,39 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
document.body.removeChild(a);
}
private async getAttachmentBlob(att: Attachment): Promise<Blob | null> {
if (!att.objectUrl)
return null;
try {
const response = await fetch(att.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
/** Request a file attachment to be transferred from the uploader peer. */
requestAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestFile(messageId, att);
@@ -1093,6 +1251,24 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.pendingFiles = [];
}
private buildOutgoingMessageContent(raw: string): string {
const withEmbeddedImages = this.markdown.appendImageMarkdown(raw);
const gif = this.pendingKlipyGif();
if (!gif)
return withEmbeddedImages;
const gifMarkdown = this.buildKlipyGifMarkdown(gif);
return withEmbeddedImages
? `${withEmbeddedImages}\n${gifMarkdown}`
: gifMarkdown;
}
private buildKlipyGifMarkdown(gif: KlipyGif): string {
return `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
}
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
autoResizeTextarea(): void {
const el = this.messageInputRef?.nativeElement;

View File

@@ -0,0 +1,144 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
<div
class="fixed inset-0 z-[94] bg-black/70 backdrop-blur-sm"
(click)="close()"
(keydown.enter)="close()"
(keydown.space)="close()"
role="button"
tabindex="0"
aria-label="Close GIF picker"
></div>
<div class="fixed inset-0 z-[95] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto flex h-[min(80vh,48rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
role="dialog"
aria-modal="true"
aria-label="KLIPY GIF picker"
>
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</p>
</div>
<button
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Close GIF picker"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<div class="border-b border-border px-5 py-4">
<label class="relative block">
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
[ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY"
class="w-full rounded-xl border border-border bg-background px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4">
@if (errorMessage()) {
<div
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive"
>
<span>{{ errorMessage() }}</span>
<button
type="button"
(click)="retry()"
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Retry
</button>
</div>
}
@if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY…</p>
</div>
} @else if (results().length === 0) {
<div
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border bg-secondary/10 px-6 text-center text-muted-foreground"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ng-icon
name="lucideImage"
class="h-5 w-5"
/>
</div>
<div>
<p class="text-sm font-medium text-foreground">No GIFs found</p>
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
</div>
</div>
} @else {
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
@for (gif of results(); track gif.id) {
<button
type="button"
(click)="selectGif(gif)"
class="group overflow-hidden rounded-2xl border border-border bg-secondary/10 text-left transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
>
<div
class="relative overflow-hidden bg-secondary/30"
[style.aspect-ratio]="gifAspectRatio(gif)"
>
<img
[src]="gifPreviewUrl(gif)"
[alt]="gif.title || 'KLIPY GIF'"
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
</div>
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
</button>
}
</div>
}
</div>
<div class="flex items-center justify-between gap-4 border-t border-border px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
@if (hasNext()) {
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="rounded-full border border-border px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading…' : 'Load more' }}
</button>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
AfterViewInit,
Component,
ElementRef,
HostListener,
OnDestroy,
OnInit,
ViewChild,
inject,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideImage,
lucideSearch,
lucideX
} from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../../core/services/klipy.service';
@Component({
selector: 'app-klipy-gif-picker',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideImage,
lucideSearch,
lucideX
})
],
templateUrl: './klipy-gif-picker.component.html'
})
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
readonly gifSelected = output<KlipyGif>();
readonly closed = output<undefined>();
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private readonly klipy = inject(KlipyService);
private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0;
searchQuery = '';
results = signal<KlipyGif[]>([]);
loading = signal(false);
errorMessage = signal('');
hasNext = signal(false);
ngOnInit(): void {
void this.loadResults(true);
}
ngAfterViewInit(): void {
requestAnimationFrame(() => {
this.searchInput?.nativeElement.focus();
this.searchInput?.nativeElement.select();
});
}
ngOnDestroy(): void {
this.clearSearchTimer();
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.close();
}
onSearchQueryChanged(query: string): void {
this.searchQuery = query;
this.clearSearchTimer();
this.searchTimer = setTimeout(() => {
void this.loadResults(true);
}, 250);
}
retry(): void {
void this.loadResults(true);
}
async loadMore(): Promise<void> {
if (this.loading() || !this.hasNext())
return;
this.currentPage += 1;
await this.loadResults(false);
}
selectGif(gif: KlipyGif): void {
this.gifSelected.emit(gif);
}
close(): void {
this.closed.emit(undefined);
}
gifAspectRatio(gif: KlipyGif): string {
if (gif.width > 0 && gif.height > 0) {
return `${gif.width} / ${gif.height}`;
}
return '1 / 1';
}
gifPreviewUrl(gif: KlipyGif): string {
return this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url);
}
private async loadResults(reset: boolean): Promise<void> {
if (reset) {
this.currentPage = 1;
}
const requestId = ++this.requestId;
this.loading.set(true);
this.errorMessage.set('');
try {
const response = await firstValueFrom(
this.klipy.searchGifs(this.searchQuery, this.currentPage)
);
if (requestId !== this.requestId)
return;
this.results.set(
reset
? response.results
: this.mergeResults(this.results(), response.results)
);
this.hasNext.set(response.hasNext);
} catch (error) {
if (requestId !== this.requestId)
return;
this.errorMessage.set(
error instanceof Error
? error.message
: 'Failed to load GIFs from KLIPY.'
);
if (reset) {
this.results.set([]);
}
this.hasNext.set(false);
} finally {
if (requestId === this.requestId) {
this.loading.set(false);
}
}
}
private mergeResults(existing: KlipyGif[], incoming: KlipyGif[]): KlipyGif[] {
const seen = new Set(existing.map((gif) => gif.id));
const merged = [...existing];
for (const gif of incoming) {
if (seen.has(gif.id))
continue;
merged.push(gif);
seen.add(gif.id);
}
return merged;
}
private clearSearchTimer(): void {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
this.searchTimer = null;
}
}
}