Change klippy window behavour, Fix user management behavour, clean up search server page

This commit is contained in:
2026-03-08 00:00:17 +01:00
parent 90f067e662
commit d20509566d
56 changed files with 1783 additions and 489 deletions

View File

@@ -21,13 +21,41 @@
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()"
(messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()"
(heightChanged)="handleComposerHeightChanged($event)"
(klipyGifPickerToggleRequested)="toggleKlipyGifPicker()"
/>
</div>
@if (showKlipyGifPicker()) {
<div
class="fixed inset-0 z-[89]"
(click)="closeKlipyGifPicker()"
(keydown.enter)="closeKlipyGifPicker()"
(keydown.space)="closeKlipyGifPicker()"
tabindex="0"
role="button"
aria-label="Close GIF picker"
style="-webkit-app-region: no-drag"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()"
>
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
</div>
</div>
}
<app-chat-message-overlays
[lightboxAttachment]="lightboxAttachment()"
[imageContextMenu]="imageContextMenu()"

View File

@@ -1,12 +1,15 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
HostListener,
ViewChild,
computed,
inject,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Attachment, AttachmentService } from '../../../core/services/attachment.service';
import { KlipyGif } from '../../../core/services/klipy.service';
import { MessagesActions } from '../../../store/messages/messages.actions';
import {
selectAllMessages,
@@ -18,6 +21,7 @@ import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/r
import { Message } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
import {
@@ -34,6 +38,7 @@ import {
standalone: true,
imports: [
ChatMessageComposerComponent,
KlipyGifPickerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent
],
@@ -41,6 +46,8 @@ import {
styleUrl: './chat-messages.component.scss'
})
export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
private readonly store = inject(Store);
private readonly webrtc = inject(WebRTCService);
private readonly attachmentsSvc = inject(AttachmentService);
@@ -69,10 +76,19 @@ export class ChatMessagesComponent {
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
);
readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null);
readonly showKlipyGifPicker = signal(false);
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
@HostListener('window:resize')
onWindowResize(): void {
if (this.showKlipyGifPicker()) {
this.syncKlipyGifPickerAnchor();
}
}
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
this.store.dispatch(
MessagesActions.sendMessage({
@@ -163,6 +179,57 @@ export class ChatMessagesComponent {
this.composerBottomPadding.set(height + 20);
}
toggleKlipyGifPicker(): void {
const nextState = !this.showKlipyGifPicker();
this.showKlipyGifPicker.set(nextState);
if (nextState) {
requestAnimationFrame(() => this.syncKlipyGifPickerAnchor());
}
}
closeKlipyGifPicker(): void {
this.showKlipyGifPicker.set(false);
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.closeKlipyGifPicker();
this.composer?.handleKlipyGifSelected(gif);
}
private syncKlipyGifPickerAnchor(): void {
const triggerRect = this.composer?.getKlipyTriggerRect();
if (!triggerRect) {
this.klipyGifPickerAnchorRight.set(16);
return;
}
const viewportWidth = window.innerWidth;
const popupWidth = this.getKlipyGifPickerWidth(viewportWidth);
const preferredRight = viewportWidth - triggerRect.right;
const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.klipyGifPickerAnchorRight.set(
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
);
}
private getKlipyGifPickerWidth(viewportWidth: number): number {
if (viewportWidth >= 1280)
return 52 * 16;
if (viewportWidth >= 768)
return 42 * 16;
if (viewportWidth >= 640)
return 34 * 16;
return Math.max(0, viewportWidth - 32);
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.lightboxAttachment.set(attachment);

View File

@@ -135,8 +135,9 @@
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2">
@if (klipy.isEnabled()) {
<button
#klipyTrigger
type="button"
(click)="openKlipyGifPicker()"
(click)="toggleKlipyGifPicker()"
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.opacity-100]="inputHovered() || showKlipyGifPicker()"
@@ -250,10 +251,3 @@
</div>
</div>
</div>
@if (showKlipyGifPicker() && klipy.isEnabled()) {
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
}

View File

@@ -22,7 +22,6 @@ import {
import { KlipyGif, KlipyService } from '../../../../../core/services/klipy.service';
import { Message } from '../../../../../core/models';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { KlipyGifPickerComponent } from '../../../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
@@ -33,7 +32,6 @@ import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model
CommonModule,
FormsModule,
NgIcon,
KlipyGifPickerComponent,
TypingIndicatorComponent
],
viewProviders: [
@@ -54,19 +52,21 @@ import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
readonly replyTo = input<Message | null>(null);
readonly showKlipyGifPicker = input(false);
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
readonly typingStarted = output();
readonly replyCleared = output();
readonly heightChanged = output<number>();
readonly klipyGifPickerToggleRequested = output();
readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly showKlipyGifPicker = signal(false);
readonly toolbarVisible = signal(false);
readonly dragActive = signal(false);
readonly inputHovered = signal(false);
@@ -194,20 +194,19 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
this.setSelection(result.selectionStart, result.selectionEnd);
}
openKlipyGifPicker(): void {
toggleKlipyGifPicker(): void {
if (!this.klipy.isEnabled())
return;
this.showKlipyGifPicker.set(true);
this.klipyGifPickerToggleRequested.emit();
}
closeKlipyGifPicker(): void {
this.showKlipyGifPicker.set(false);
getKlipyTriggerRect(): DOMRect | null {
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.pendingKlipyGif.set(gif);
this.closeKlipyGifPicker();
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
this.sendMessage();

View File

@@ -78,7 +78,7 @@ export class ChatMarkdownService {
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'code';
const after = content.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const fenced = `\`\`\`\n${selected}\n\`\`\`\n\n`;
const text = `${before}${fenced}${after}`;
const cursor = before.length + fenced.length;

View File

@@ -1,144 +1,133 @@
<!-- 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>
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
role="dialog"
aria-label="KLIPY GIF picker"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
>
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 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"
<button
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 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/70 bg-secondary/10 px-5 py-4">
<label class="relative block">
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
[ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY"
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm 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 backdrop-blur-sm"
>
<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()) {
<span>{{ errorMessage() }}</span>
<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"
(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"
>
{{ loading() ? 'Loading…' : 'Load more' }}
Retry
</button>
}
</div>
</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/80 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/80 bg-secondary/10 text-left shadow-sm 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/70 bg-secondary/10 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/80 bg-background/60 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>