import { Component, computed, effect, inject, OnDestroy, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideCheck, lucideChevronDown, lucideGamepad2 } from '@ng-icons/lucide'; import { UserAvatarComponent } from '../user-avatar/user-avatar.component'; import { UserStatusService } from '../../../core/services/user-status.service'; import { GameActivity, User, UserStatus } from '../../../shared-kernel'; import { EditableProfileAvatarSource, ProfileAvatarFacade, ProfileAvatarEditorService, PROFILE_AVATAR_ACCEPT_ATTRIBUTE, ProcessedProfileAvatar } from '../../../domains/profile-avatar'; import { UsersActions } from '../../../store/users/users.actions'; import { selectUsersEntities } from '../../../store/users/users.selectors'; import { ThemeNodeDirective } from '../../../domains/theme'; import { formatGameActivityElapsed } from '../../../domains/game-activity'; import { ExternalLinkService } from '../../../core/platform/external-link.service'; @Component({ selector: 'app-profile-card', standalone: true, imports: [ CommonModule, NgIcon, UserAvatarComponent, ThemeNodeDirective ], viewProviders: [provideIcons({ lucideCheck, lucideChevronDown, lucideGamepad2 })], templateUrl: './profile-card.component.html' }) export class ProfileCardComponent implements OnDestroy { readonly user = signal({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 }); readonly displayedUser = computed(() => { const snapshot = this.user(); const entities = this.users(); const liveUser = entities[snapshot.id] ?? entities[snapshot.oderId]; return liveUser ? { ...snapshot, ...liveUser } : snapshot; }); readonly editable = signal(false); readonly showStatusMenu = signal(false); readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE; readonly avatarError = signal(null); readonly avatarSaving = signal(false); readonly editingField = signal<'displayName' | 'description' | null>(null); readonly displayNameDraft = signal(''); readonly descriptionDraft = signal(''); readonly activityNow = signal(Date.now()); readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [ { value: null, label: 'Online', color: 'bg-green-500' }, { value: 'away', label: 'Away', color: 'bg-yellow-500' }, { value: 'busy', label: 'Do Not Disturb', color: 'bg-red-500' }, { value: 'offline', label: 'Invisible', color: 'bg-gray-500' } ]; private readonly store = inject(Store); private readonly users = this.store.selectSignal(selectUsersEntities); private readonly userStatus = inject(UserStatusService); private readonly profileAvatar = inject(ProfileAvatarFacade); private readonly profileAvatarEditor = inject(ProfileAvatarEditorService); private readonly externalLinks = inject(ExternalLinkService); private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000); private readonly syncProfileDrafts = effect( () => { const user = this.displayedUser(); const editingField = this.editingField(); if (editingField !== 'displayName') { this.displayNameDraft.set(user.displayName || ''); } if (editingField !== 'description') { this.descriptionDraft.set(user.description || ''); } }, { allowSignalWrites: true } ); currentStatusColor(): string { switch (this.displayedUser().status) { case 'online': return 'bg-green-500'; case 'away': return 'bg-yellow-500'; case 'busy': return 'bg-red-500'; case 'offline': return 'bg-gray-500'; case 'disconnected': return 'bg-gray-500'; default: return 'bg-green-500'; } } currentStatusLabel(): string { switch (this.displayedUser().status) { case 'online': return 'Online'; case 'away': return 'Away'; case 'busy': return 'Do Not Disturb'; case 'offline': return 'Invisible'; case 'disconnected': return 'Offline'; default: return 'Online'; } } gameActivityElapsed(): string { const activity = this.displayedUser().gameActivity; return activity ? formatGameActivityElapsed(activity.startedAt, this.activityNow()) : ''; } openGameStore(activity: GameActivity, event: Event): void { event.stopPropagation(); if (activity.store?.url) { this.externalLinks.open(activity.store.url); } } ngOnDestroy(): void { clearInterval(this.activityTimer); } toggleStatusMenu(): void { this.showStatusMenu.update((isOpen) => !isOpen); } setStatus(status: UserStatus | null): void { this.userStatus.setManualStatus(status); this.showStatusMenu.set(false); } isStatusOptionSelected(status: UserStatus | null): boolean { const currentStatus = this.displayedUser().status; return status === null ? currentStatus === 'online' : currentStatus === status; } onDisplayNameInput(event: Event): void { this.displayNameDraft.set((event.target as HTMLInputElement).value); } onDescriptionInput(event: Event): void { this.descriptionDraft.set((event.target as HTMLTextAreaElement).value); } startEdit(field: 'displayName' | 'description'): void { if (!this.editable() || this.editingField() === field) { return; } this.editingField.set(field); } finishEdit(field: 'displayName' | 'description'): void { if (this.editingField() !== field) { return; } this.commitProfileDrafts(); this.editingField.set(null); } pickAvatar(fileInput: HTMLInputElement): void { if (!this.editable() || this.avatarSaving()) { return; } this.avatarError.set(null); fileInput.click(); } async onAvatarSelected(event: Event): Promise { const input = event.target as HTMLInputElement; const file = input.files?.[0]; let source: EditableProfileAvatarSource | null = null; input.value = ''; if (!file) { return; } const validationError = this.profileAvatar.validateFile(file); if (validationError) { this.avatarError.set(validationError); return; } try { source = await this.profileAvatar.prepareEditableSource(file); const avatar = await this.profileAvatarEditor.open(source); if (!avatar) { return; } await this.applyAvatar(avatar); } catch { this.avatarError.set('Failed to open selected image.'); } finally { this.profileAvatar.releaseEditableSource(source); } } async applyAvatar(avatar: ProcessedProfileAvatar): Promise { const currentUser = this.displayedUser(); this.avatarSaving.set(true); this.avatarError.set(null); try { await this.profileAvatar.persistProcessedAvatar(currentUser, avatar); const updates = this.profileAvatar.buildAvatarUpdates(avatar); this.store.dispatch(UsersActions.updateCurrentUserAvatar({ avatar: updates })); this.user.update((user) => ({ ...user, ...updates })); } catch { this.avatarError.set('Failed to save profile image.'); } finally { this.avatarSaving.set(false); } } private commitProfileDrafts(): void { if (!this.editable()) { return; } const displayName = this.normalizeDisplayName(this.displayNameDraft()); if (!displayName) { this.displayNameDraft.set(this.user().displayName || ''); return; } const user = this.displayedUser(); const description = this.normalizeDescription(this.descriptionDraft()); if (displayName === this.normalizeDisplayName(user.displayName) && description === this.normalizeDescription(user.description)) { return; } const profile = { displayName, description, profileUpdatedAt: Date.now() }; this.store.dispatch(UsersActions.updateCurrentUserProfile({ profile })); this.user.update((user) => ({ ...user, ...profile })); } private normalizeDisplayName(value: string | undefined): string { return value?.trim().replace(/\s+/g, ' ') || ''; } private normalizeDescription(value: string | undefined): string | undefined { const normalized = value?.trim(); return normalized || undefined; } }