Refactor and code designing
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { Component, input, output, HostListener } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Reusable confirmation dialog modal.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* @if (showConfirm()) {
|
||||
* <app-confirm-dialog
|
||||
* title="Delete Room?"
|
||||
* confirmLabel="Delete"
|
||||
* variant="danger"
|
||||
* (confirmed)="onDelete()"
|
||||
* (cancelled)="showConfirm.set(false)"
|
||||
* >
|
||||
* <p>This will permanently delete the room.</p>
|
||||
* </app-confirm-dialog>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
standalone: true,
|
||||
template: `
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelled.emit()"></div>
|
||||
<!-- Dialog -->
|
||||
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
|
||||
[class]="widthClass()">
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<ng-content />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button
|
||||
(click)="cancelled.emit()"
|
||||
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
|
||||
>
|
||||
{{ cancelLabel() }}
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmed.emit()"
|
||||
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||
[class.bg-primary]="variant() === 'primary'"
|
||||
[class.text-primary-foreground]="variant() === 'primary'"
|
||||
[class.hover:bg-primary/90]="variant() === 'primary'"
|
||||
[class.bg-destructive]="variant() === 'danger'"
|
||||
[class.text-destructive-foreground]="variant() === 'danger'"
|
||||
[class.hover:bg-destructive/90]="variant() === 'danger'"
|
||||
>
|
||||
{{ confirmLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`:host { display: contents; }`],
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
/** Dialog title. */
|
||||
title = input.required<string>();
|
||||
/** Label for the confirm button. */
|
||||
confirmLabel = input<string>('Confirm');
|
||||
/** Label for the cancel button. */
|
||||
cancelLabel = input<string>('Cancel');
|
||||
/** Visual style of the confirm button. */
|
||||
variant = input<'primary' | 'danger'>('primary');
|
||||
/** Tailwind width class for the dialog. */
|
||||
widthClass = input<string>('w-[320px]');
|
||||
/** Emitted when the user confirms. */
|
||||
confirmed = output<void>();
|
||||
/** Emitted when the user cancels (backdrop click, Cancel button, or Escape). */
|
||||
cancelled = output<void>();
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Component, input, output, HostListener } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Generic positioned context-menu overlay.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* @if (showMenu()) {
|
||||
* <app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-48'">
|
||||
* <button (click)="doSomething()" class="context-menu-item">Action</button>
|
||||
* </app-context-menu>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Built-in item classes are available via the host styles:
|
||||
* - `.context-menu-item` — normal item
|
||||
* - `.context-menu-item-danger` — destructive (red) item
|
||||
* - `.context-menu-divider` — horizontal separator
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
standalone: true,
|
||||
template: `
|
||||
<!-- Invisible backdrop that captures clicks outside -->
|
||||
<div class="fixed inset-0 z-40" (click)="closed.emit()"></div>
|
||||
<!-- Positioned menu panel -->
|
||||
<div
|
||||
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||
[class]="width()"
|
||||
[style.left.px]="x()"
|
||||
[style.top.px]="y()"
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
/* Convenience classes consumers can use on projected buttons */
|
||||
:host ::ng-deep .context-menu-item {
|
||||
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground;
|
||||
}
|
||||
:host ::ng-deep .context-menu-item-danger {
|
||||
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive;
|
||||
}
|
||||
:host ::ng-deep .context-menu-item-icon {
|
||||
@apply w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2;
|
||||
}
|
||||
:host ::ng-deep .context-menu-item-icon-danger {
|
||||
@apply w-full text-left px-3 py-2 text-sm hover:bg-destructive/10 transition-colors text-destructive flex items-center gap-2;
|
||||
}
|
||||
:host ::ng-deep .context-menu-divider {
|
||||
@apply border-t border-border my-1;
|
||||
}
|
||||
:host ::ng-deep .context-menu-empty {
|
||||
@apply px-3 py-1.5 text-sm text-muted-foreground;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ContextMenuComponent {
|
||||
/** Horizontal position (px from left). */
|
||||
x = input.required<number>();
|
||||
/** Vertical position (px from top). */
|
||||
y = input.required<number>();
|
||||
/** Tailwind width class for the panel (default `w-48`). */
|
||||
width = input<string>('w-48');
|
||||
/** Emitted when the menu should close (backdrop click or Escape). */
|
||||
closed = output<void>();
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.closed.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Reusable user avatar circle.
|
||||
*
|
||||
* Displays the user's image when `avatarUrl` is provided, otherwise
|
||||
* falls back to a colored circle with the first letter of `name`.
|
||||
*
|
||||
* Optional rings (e.g. voice-state colours) can be applied via `ringClass`.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="md" />
|
||||
* <app-user-avatar [name]="user.displayName" size="sm" ringClass="ring-2 ring-green-500" />
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-user-avatar',
|
||||
standalone: true,
|
||||
template: `
|
||||
@if (avatarUrl()) {
|
||||
<img
|
||||
[src]="avatarUrl()"
|
||||
alt=""
|
||||
class="rounded-full object-cover"
|
||||
[class]="sizeClasses() + ' ' + ringClass()"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
|
||||
>
|
||||
{{ initial() }}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`:host { display: contents; }`],
|
||||
})
|
||||
export class UserAvatarComponent {
|
||||
/** Display name — first character is used as fallback initial. */
|
||||
name = input.required<string>();
|
||||
/** Optional avatar image URL. */
|
||||
avatarUrl = input<string | undefined | null>();
|
||||
/** Predefined size: `xs` (28px), `sm` (32px), `md` (40px), `lg` (48px). */
|
||||
size = input<'xs' | 'sm' | 'md' | 'lg'>('sm');
|
||||
/** Extra ring classes, e.g. `'ring-2 ring-green-500'`. */
|
||||
ringClass = input<string>('');
|
||||
|
||||
/** Compute the first-letter initial. */
|
||||
initial(): string {
|
||||
return this.name()?.charAt(0)?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
/** Map size token to Tailwind dimension classes. */
|
||||
sizeClasses(): string {
|
||||
switch (this.size()) {
|
||||
case 'xs': return 'w-7 h-7';
|
||||
case 'sm': return 'w-8 h-8';
|
||||
case 'md': return 'w-10 h-10';
|
||||
case 'lg': return 'w-12 h-12';
|
||||
}
|
||||
}
|
||||
|
||||
/** Map size token to text size for initials. */
|
||||
textClass(): string {
|
||||
switch (this.size()) {
|
||||
case 'xs': return 'text-xs';
|
||||
case 'sm': return 'text-sm';
|
||||
case 'md': return 'text-base font-semibold';
|
||||
case 'lg': return 'text-lg font-semibold';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user