- V1__init.sql: синхронизированы таблицы с Java моделями (users, folders, messages, contacts) - docker-compose.yml: обновлён контекст сборки, переменные R2DBC/Flyway, порты (5433, 3026, 1443) - backend/Dockerfile: обновлены пути COPY для нового контекста сборки - settings.gradle: удалён frontend проект из Gradle - contacts.component.html: исправлен ternary оператор с HTML на Angular @if/@else - Фронтенд компоненты: обновления compose, inbox, layout, login, message-detail, register - index.html, styles.css: обновления стилей и мета-данных Co-authored-by: Junie <junie@jetbrains.com>
197 lines
8.2 KiB
TypeScript
197 lines
8.2 KiB
TypeScript
import { Component, inject, signal } from '@angular/core';
|
|
import { RouterLink, ActivatedRoute } from '@angular/router';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { MailService } from '../../services/mail.service';
|
|
import { Message, Folder } from '../../models/mail.models';
|
|
import { DatePipe, SlicePipe } from '@angular/common';
|
|
import { map, switchMap } from 'rxjs/operators';
|
|
import { of } from 'rxjs';
|
|
|
|
@Component({
|
|
selector: 'app-inbox',
|
|
standalone: true,
|
|
imports: [RouterLink, FormsModule, DatePipe, SlicePipe],
|
|
providers: [MailService],
|
|
template: `
|
|
<div class="space-y-4">
|
|
<!-- Поиск -->
|
|
<div class="glass-panel p-4">
|
|
<div class="flex gap-3">
|
|
<input [(ngModel)]="searchQuery" (keyup.enter)="loadMessages()"
|
|
class="flex-1 bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
|
placeholder="Search messages...">
|
|
<button (click)="loadMessages()" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
|
<i class="fa-solid fa-magnifying-glass mr-1"></i> SEARCH
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Список писем -->
|
|
<div class="glass-panel p-4">
|
|
<h2 class="text-lg font-orbitron font-bold text-cyan-300 mb-4 flex items-center gap-2">
|
|
<i class="fa-solid fa-inbox text-pink-500"></i> {{ folderName() }}
|
|
<span class="text-sm font-mono text-slate-400 ml-auto">{{ messages()?.length || 0 }} messages</span>
|
|
</h2>
|
|
|
|
@if (!messages() || messages().length === 0) {
|
|
<div class="text-center py-12 text-slate-400 font-mono">
|
|
<i class="fa-solid fa-envelope-open-text text-4xl mb-4 text-slate-600"></i>
|
|
<p>No messages yet</p>
|
|
</div>
|
|
} @else {
|
|
<div class="space-y-2">
|
|
@for (msg of messages() || []; track msg.id) {
|
|
<a routerLink="/message/{{ msg.id }}"
|
|
class="block p-4 rounded-xl border border-cyan-500/10 hover:border-cyan-500/40 hover:bg-cyan-500/5 transition-all cursor-pointer group">
|
|
<div class="flex items-start gap-3">
|
|
<!-- Индикатор прочитанности -->
|
|
<div class="mt-1">
|
|
@if (!msg.read) {
|
|
<span class="w-3 h-3 rounded-full bg-pink-500 inline-block shadow-[0_0_8px_rgba(255,0,127,0.8)]"></span>
|
|
} @else {
|
|
<span class="w-3 h-3 rounded-full border border-slate-500 inline-block"></span>
|
|
}
|
|
</div>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="font-bold text-sm text-slate-200 font-rajdhani truncate">
|
|
{{ msg.senderName || msg.sender }}
|
|
</span>
|
|
@if (msg.spam) {
|
|
<span class="px-2 py-0.5 text-xs rounded-full bg-yellow-500/20 text-yellow-400 font-mono">
|
|
SPAM
|
|
</span>
|
|
}
|
|
@if (msg.flagged) {
|
|
<i class="fa-solid fa-star text-yellow-400 text-xs"></i>
|
|
}
|
|
</div>
|
|
<div class="text-sm text-cyan-400 font-rajdhani truncate group-hover:text-pink-400 transition-colors">
|
|
{{ msg.subject || '(No subject)' }}
|
|
</div>
|
|
<div class="text-xs text-slate-400 font-mono truncate">
|
|
{{ msg.body || '' | slice:0:100 }}{{ (msg.body || '').length > 100 ? '...' : '' }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-right flex-shrink-0">
|
|
<div class="text-xs text-slate-400 font-mono">
|
|
{{ msg.sentAt | date:'MMM d, HH:mm' }}
|
|
</div>
|
|
@if (msg.hasAttachments) {
|
|
<span class="text-xs text-purple-400 font-mono"><i class="fa-solid fa-paperclip mr-1"></i>{{ msg.attachmentCount }}</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<!-- Пагинация -->
|
|
@if (totalPages() > 1) {
|
|
<div class="glass-panel p-4 flex items-center justify-center gap-2">
|
|
<button (click)="goToPage(page() - 1)" [disabled]="page() === 0"
|
|
class="px-3 py-1 rounded-lg text-xs font-mono text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
|
<i class="fa-solid fa-chevron-left mr-1"></i> Prev
|
|
</button>
|
|
@for (p of pageNumbers; track p) {
|
|
<button (click)="goToPage(p)"
|
|
[class.bg-pink-500/20]="p === page()" [class.text-pink-400]="p === page()"
|
|
[class.border-pink-500]="p === page()"
|
|
class="px-3 py-1 rounded-lg text-xs font-mono text-slate-400 border border-cyan-500/20 hover:bg-cyan-500/10 hover:text-cyan-300 transition-all">
|
|
{{ p + 1 }}
|
|
</button>
|
|
}
|
|
<button (click)="goToPage(page() + 1)" [disabled]="page() >= totalPages() - 1"
|
|
class="px-3 py-1 rounded-lg text-xs font-mono text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
|
Next <i class="fa-solid fa-chevron-right ml-1"></i>
|
|
</button>
|
|
<span class="text-xs font-mono text-slate-500 ml-2">
|
|
Page {{ page() + 1 }} of {{ totalPages() }}
|
|
</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
`,
|
|
styles: ``
|
|
})
|
|
export default class InboxComponent {
|
|
private route = inject(ActivatedRoute);
|
|
private mailService = inject(MailService);
|
|
messages = signal<Message[]>([]);
|
|
folderName = signal<string>('INBOX');
|
|
searchQuery = '';
|
|
folderId = signal<number>(1);
|
|
page = signal<number>(0);
|
|
totalPages = signal<number>(1);
|
|
pageSize = 20;
|
|
|
|
constructor() {
|
|
// Подписываемся на изменения folderId в queryParams
|
|
this.route.queryParams.subscribe(params => {
|
|
const newFolderId = params['folderId'] ? Number(params['folderId']) : 1;
|
|
if (newFolderId !== this.folderId()) {
|
|
this.folderId.set(newFolderId);
|
|
this.loadFolderName(newFolderId);
|
|
this.loadMessages();
|
|
}
|
|
});
|
|
// Начальная загрузка
|
|
const initialFolderId = this.route.snapshot.queryParams['folderId'] ? Number(this.route.snapshot.queryParams['folderId']) : 1;
|
|
this.folderId.set(initialFolderId);
|
|
this.loadFolderName(initialFolderId);
|
|
this.loadMessages();
|
|
}
|
|
|
|
loadFolderName(folderId: number): void {
|
|
this.mailService.getFolders().subscribe({
|
|
next: (folders) => {
|
|
const folder = folders.find(f => f.id === folderId);
|
|
this.folderName.set(folder ? folder.name : 'INBOX');
|
|
},
|
|
error: () => this.folderName.set('INBOX')
|
|
});
|
|
}
|
|
|
|
loadMessages(): void {
|
|
const currentFolderId = this.folderId();
|
|
const currentPage = this.page();
|
|
if (this.searchQuery.trim()) {
|
|
this.mailService.searchMessages(currentFolderId, this.searchQuery.trim()).subscribe({
|
|
next: (msgs) => this.messages.set(msgs),
|
|
error: () => console.error('Search failed')
|
|
});
|
|
} else {
|
|
this.mailService.getMessages(currentFolderId, currentPage, this.pageSize).subscribe({
|
|
next: (result) => {
|
|
this.messages.set(result.content);
|
|
this.totalPages.set(result.totalPages);
|
|
},
|
|
error: () => console.error('Failed to load messages')
|
|
});
|
|
}
|
|
}
|
|
|
|
goToPage(page: number): void {
|
|
if (page >= 0 && page < this.totalPages()) {
|
|
this.page.set(page);
|
|
this.loadMessages();
|
|
}
|
|
}
|
|
|
|
get pageNumbers(): number[] {
|
|
const pages: number[] = [];
|
|
const total = this.totalPages();
|
|
const current = this.page();
|
|
const start = Math.max(0, current - 2);
|
|
const end = Math.min(total - 1, current + 2);
|
|
for (let i = start; i <= end; i++) {
|
|
pages.push(i);
|
|
}
|
|
return pages;
|
|
}
|
|
}
|