All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
299 lines
8.3 KiB
TypeScript
299 lines
8.3 KiB
TypeScript
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<User>({ 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<string | null>(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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|