Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter, withHashLocation } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './interceptors/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor]))
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<router-outlet />
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./components/login/login.component'),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () => import('./components/register/register.component'),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./components/layout/layout.component'),
|
||||
children: [
|
||||
{
|
||||
path: 'inbox',
|
||||
loadComponent: () => import('./components/inbox/inbox.component'),
|
||||
},
|
||||
{
|
||||
path: 'compose',
|
||||
loadComponent: () => import('./components/compose/compose.component'),
|
||||
},
|
||||
{
|
||||
path: 'message/:id',
|
||||
loadComponent: () => import('./components/message-detail/message-detail.component'),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'inbox',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: 'login',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
imports: [RouterOutlet],
|
||||
selector: 'app-root',
|
||||
template: `<router-outlet />`,
|
||||
styles: ``
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MailService } from '../../services/mail.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-compose',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
template: `
|
||||
<div class="glass-panel p-6">
|
||||
<h2 class="text-lg font-orbitron font-bold text-synth-cyan mb-6 flex items-center gap-2">
|
||||
<span class="text-synth-pink">✉️</span> COMPOSE MESSAGE
|
||||
</h2>
|
||||
|
||||
@if (success) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-synth-cyan/20 border border-synth-cyan/40 text-synth-cyan text-sm font-mono">
|
||||
Message sent successfully!
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onSend()" class="space-y-4">
|
||||
<div>
|
||||
<label for="to" class="block text-xs font-mono text-synth-cyan mb-1">TO</label>
|
||||
<input id="to" type="email" [(ngModel)]="to" name="to" required
|
||||
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
|
||||
placeholder="recipient@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="subject" class="block text-xs font-mono text-synth-cyan mb-1">SUBJECT</label>
|
||||
<input id="subject" type="text" [(ngModel)]="subject" name="subject" required
|
||||
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
|
||||
placeholder="Enter subject...">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="body" class="block text-xs font-mono text-synth-cyan mb-1">BODY</label>
|
||||
<textarea id="body" [(ngModel)]="body" name="body" rows="10" required
|
||||
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all resize-none"
|
||||
placeholder="Write your message..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" [disabled]="sending"
|
||||
class="cyber-btn px-8 py-3 rounded-xl text-sm font-bold">
|
||||
{{ sending ? 'SENDING...' : 'SEND' }}
|
||||
</button>
|
||||
<a routerLink="/inbox" class="px-6 py-3 rounded-xl text-xs font-mono text-slate-400 border border-slate-600 hover:text-slate-200 hover:border-slate-400 transition-all text-center">
|
||||
CANCEL
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class ComposeComponent {
|
||||
private mailService = inject(MailService);
|
||||
private router = inject(Router);
|
||||
|
||||
to = '';
|
||||
subject = '';
|
||||
body = '';
|
||||
sending = false;
|
||||
success = false;
|
||||
|
||||
onSend(): void {
|
||||
this.sending = true;
|
||||
this.mailService.send({ to: this.to, subject: this.subject, body: this.body }).subscribe({
|
||||
next: () => {
|
||||
this.success = true;
|
||||
this.sending = false;
|
||||
this.to = '';
|
||||
this.subject = '';
|
||||
this.body = '';
|
||||
setTimeout(() => this.router.navigate(['/inbox']), 1500);
|
||||
},
|
||||
error: () => {
|
||||
this.sending = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-2 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
|
||||
placeholder="🔍 Search messages...">
|
||||
<button (click)="loadMessages()" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
||||
SEARCH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список писем -->
|
||||
<div class="glass-panel p-4">
|
||||
<h2 class="text-lg font-orbitron font-bold text-synth-cyan mb-4 flex items-center gap-2">
|
||||
<span class="text-synth-pink">📬</span> {{ 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">
|
||||
<p class="text-4xl mb-4">📭</p>
|
||||
<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-synth-cyan/10 hover:border-synth-cyan/40 hover:bg-synth-cyan/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-synth-pink 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-synth-yellow/20 text-synth-yellow font-mono">
|
||||
SPAM
|
||||
</span>
|
||||
}
|
||||
@if (msg.flagged) {
|
||||
<span class="text-synth-yellow text-xs">★</span>
|
||||
}
|
||||
</div>
|
||||
<div class="text-sm text-synth-cyan font-rajdhani truncate group-hover:text-synth-pink 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-synth-purple font-mono">📎 {{ msg.attachmentCount }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</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);
|
||||
|
||||
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();
|
||||
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).subscribe({
|
||||
next: (msgs) => this.messages.set(msgs),
|
||||
error: () => console.error('Failed to load messages')
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { MailService } from '../../services/mail.service';
|
||||
import { Folder } from '../../models/mail.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<div class="min-h-screen relative" [class.light-theme]="isLightTheme()">
|
||||
<!-- Synthwave фон -->
|
||||
<div class="synth-bg" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-grid">
|
||||
<div class="synth-grid-plane"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Контент -->
|
||||
<div class="relative z-10 flex flex-col min-h-screen">
|
||||
<!-- Шапка -->
|
||||
<header class="glass-panel rounded-none border-t-0 border-x-0 p-4 sticky top-0 z-40 backdrop-blur-xl">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-synth-pink to-synth-cyan flex items-center justify-center text-synth-dark font-black text-xl font-orbitron shadow-[0_0_15px_rgba(255,0,127,0.8)]">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-orbitron font-extrabold tracking-wider neon-text-primary">
|
||||
MAIL<span class="text-synth-cyan font-normal">SERVER</span>
|
||||
</h1>
|
||||
<p class="text-xs text-synth-cyan/70 font-mono tracking-widest uppercase">Synthwave Edition</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Переключатель темы -->
|
||||
<button (click)="toggleTheme()" class="cyber-btn px-3 py-2 rounded-lg text-xs font-bold" [title]="isLightTheme() ? 'Переключить на тёмную тему' : 'Переключить на светлую тему'">
|
||||
{{ isLightTheme() ? '🌙 Dark' : '☀️ Light' }}
|
||||
</button>
|
||||
|
||||
<span class="text-sm font-mono text-slate-400">{{ email }}</span>
|
||||
<button (click)="onLogout()" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold">
|
||||
<i class="mr-1">⏻</i> Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Основная область -->
|
||||
<div class="flex flex-1 max-w-7xl mx-auto w-full p-4 gap-4">
|
||||
<!-- Боковая панель с папками -->
|
||||
<aside class="w-64 flex-shrink-0">
|
||||
<div class="glass-panel p-4">
|
||||
<h2 class="text-sm font-orbitron font-bold text-synth-cyan mb-3 flex items-center gap-2">
|
||||
<span class="text-synth-pink">📁</span> FOLDERS
|
||||
</h2>
|
||||
<nav class="space-y-1">
|
||||
@for (folder of folders(); track folder.id) {
|
||||
<a routerLink="/inbox" [queryParams]="{folderId: folder.id}"
|
||||
routerLinkActive="bg-synth-pink/20 text-synth-pink border-synth-pink"
|
||||
[queryParamsHandling]=" 'merge'"
|
||||
class="block px-3 py-2 rounded-lg text-sm font-rajdhani text-slate-300 hover:bg-synth-cyan/10 hover:text-synth-cyan border border-transparent transition-all">
|
||||
{{ folder.name }}
|
||||
@if (folder.unreadCount > 0) {
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-synth-pink/30 text-synth-pink font-mono">
|
||||
{{ folder.unreadCount }}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка написать -->
|
||||
<div class="glass-panel p-4 mt-4">
|
||||
<a routerLink="/compose" class="cyber-btn w-full py-3 rounded-xl text-xs font-bold block text-center">
|
||||
✉️ NEW MESSAGE
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Контент маршрутов -->
|
||||
<main class="flex-1 min-w-0">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class LayoutComponent {
|
||||
authService = inject(AuthService);
|
||||
mailService = inject(MailService);
|
||||
|
||||
email = this.authService.getEmail() || '';
|
||||
folders = signal<Folder[]>([]);
|
||||
isLightTheme = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.loadFolders();
|
||||
// Загружаем сохранённую тему
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved === 'light') {
|
||||
this.isLightTheme.set(true);
|
||||
document.documentElement.classList.add('light-theme');
|
||||
}
|
||||
}
|
||||
|
||||
toggleTheme(): void {
|
||||
const isLight = !this.isLightTheme();
|
||||
this.isLightTheme.set(isLight);
|
||||
if (isLight) {
|
||||
document.documentElement.classList.add('light-theme');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.remove('light-theme');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
loadFolders(): void {
|
||||
this.mailService.getFolders().subscribe({
|
||||
next: (folders) => this.folders.set(folders),
|
||||
error: () => console.error('Failed to load folders')
|
||||
});
|
||||
}
|
||||
|
||||
onLogout(): void {
|
||||
this.authService.logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
template: `
|
||||
<div class="min-h-screen relative flex items-center justify-center">
|
||||
<!-- Synthwave фон -->
|
||||
<div class="synth-bg" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-grid">
|
||||
<div class="synth-grid-plane"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма логина -->
|
||||
<div class="relative z-10 w-full max-w-md px-4">
|
||||
<div class="glass-panel p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-synth-pink to-synth-cyan flex items-center justify-center text-synth-dark font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
|
||||
M
|
||||
</div>
|
||||
<h1 class="text-3xl font-orbitron font-extrabold tracking-wider neon-text-primary mb-2">
|
||||
MAIL<span class="text-synth-cyan font-normal">SERVER</span>
|
||||
</h1>
|
||||
<p class="text-sm text-slate-400 font-mono">Sign in to your mailbox</p>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-synth-pink/20 border border-synth-pink/40 text-synth-pink text-sm font-mono">
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onLogin()" class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-mono text-synth-cyan mb-1">EMAIL</label>
|
||||
<input id="email" type="email" [(ngModel)]="email" name="email" required
|
||||
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
|
||||
placeholder="user@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-mono text-synth-cyan mb-1">PASSWORD</label>
|
||||
<input id="password" type="password" [(ngModel)]="password" name="password" required
|
||||
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="loading"
|
||||
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
|
||||
{{ loading ? 'CONNECTING...' : 'LOGIN' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-400 mt-6">
|
||||
No account?
|
||||
<a routerLink="/register" class="text-synth-cyan hover:text-synth-pink font-mono transition-colors">
|
||||
Register here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class LoginComponent {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
error = '' as string | null;
|
||||
loading = false as boolean;
|
||||
|
||||
onLogin(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.authService.login({ email: this.email, password: this.password }).subscribe({
|
||||
next: () => this.router.navigate(['/inbox']),
|
||||
error: (err) => {
|
||||
this.error = 'Invalid email or password';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MailService } from '../../services/mail.service';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Message } from '../../models/mail.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-message-detail',
|
||||
standalone: true,
|
||||
imports: [RouterLink, DatePipe],
|
||||
template: `
|
||||
@if (message) {
|
||||
<div class="glass-panel p-6">
|
||||
<!-- Заголовок письма -->
|
||||
<div class="mb-6 pb-4 border-b border-synth-cyan/20">
|
||||
<h1 class="text-xl font-orbitron font-bold neon-text-secondary mb-3">
|
||||
{{ message.subject || '(No subject)' }}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm font-mono">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-synth-cyan">FROM:</span>
|
||||
<span class="text-slate-200">{{ message.senderName || message.sender }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-synth-cyan">DATE:</span>
|
||||
<span class="text-slate-200">{{ message.sentAt | date:'MMM d, y, HH:mm' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (message.spam) {
|
||||
<div class="mt-3 px-3 py-1.5 inline-block rounded-full bg-synth-yellow/20 border border-synth-yellow/40 text-synth-yellow text-xs font-mono">
|
||||
⚠️ MARKED AS SPAM
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Тело письма -->
|
||||
<div class="prose prose-invert max-w-none mb-6">
|
||||
<p class="text-slate-300 font-rajdhani text-lg whitespace-pre-wrap leading-relaxed">
|
||||
{{ message.body }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Вложения -->
|
||||
@if (message.hasAttachments) {
|
||||
<div class="mb-6 p-4 rounded-xl bg-synth-darker/60 border border-synth-purple/30">
|
||||
<h3 class="text-sm font-orbitron font-bold text-synth-purple mb-3">
|
||||
📎 ATTACHMENTS ({{ message.attachmentCount }})
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 font-mono">
|
||||
Attachments are available via the API
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Действия -->
|
||||
<div class="flex gap-3 pt-4 border-t border-synth-cyan/20">
|
||||
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
||||
← BACK TO INBOX
|
||||
</a>
|
||||
<button (click)="onDelete()" class="px-6 py-2 rounded-xl text-xs font-mono text-synth-pink border border-synth-pink/40 hover:bg-synth-pink/20 transition-all">
|
||||
🗑️ DELETE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="glass-panel p-6 text-center py-12">
|
||||
<p class="text-4xl mb-4">📭</p>
|
||||
<p class="text-slate-400 font-mono">Message not found</p>
|
||||
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold mt-4 inline-block">
|
||||
← BACK TO INBOX
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class MessageDetailComponent {
|
||||
private mailService = inject(MailService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
message: Message | null = null;
|
||||
messageId = 0;
|
||||
|
||||
constructor() {
|
||||
this.messageId = Number(this.route.snapshot.paramMap.get('id'));
|
||||
this.loadMessage();
|
||||
}
|
||||
|
||||
loadMessage(): void {
|
||||
this.mailService.getMessage(this.messageId).subscribe({
|
||||
next: (msg) => {
|
||||
this.message = msg;
|
||||
if (!msg.read) {
|
||||
this.mailService.markAsRead(this.messageId).subscribe();
|
||||
}
|
||||
},
|
||||
error: () => console.error('Failed to load message')
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(): void {
|
||||
if (confirm('Delete this message?')) {
|
||||
this.mailService.deleteMessage(this.messageId).subscribe({
|
||||
next: () => (window.location.href = '/inbox'),
|
||||
error: () => console.error('Failed to delete message')
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
template: `
|
||||
<div class="min-h-screen relative flex items-center justify-center">
|
||||
<!-- Synthwave фон -->
|
||||
<div class="synth-bg" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-grid">
|
||||
<div class="synth-grid-plane"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма регистрации -->
|
||||
<div class="relative z-10 w-full max-w-md px-4">
|
||||
<div class="glass-panel p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-synth-pink to-synth-cyan flex items-center justify-center text-synth-dark font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
|
||||
M
|
||||
</div>
|
||||
<h1 class="text-3xl font-orbitron font-extrabold tracking-wider neon-text-primary mb-2">
|
||||
MAIL<span class="text-synth-cyan font-normal">SERVER</span>
|
||||
</h1>
|
||||
<p class="text-sm text-slate-400 font-mono">Create your mailbox</p>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-synth-pink/20 border border-synth-pink/40 text-synth-pink text-sm font-mono">
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
@if (success) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-synth-cyan/20 border border-synth-cyan/40 text-synth-cyan text-sm font-mono">
|
||||
Account created! You can now login.
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onRegister()" class="space-y-4">
|
||||
<div>
|
||||
<label for="displayName" class="block text-xs font-mono text-synth-cyan mb-1">DISPLAY NAME</label>
|
||||
<input id="displayName" type="text" [(ngModel)]="displayName" name="displayName" required
|
||||
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
|
||||
placeholder="John Doe">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-mono text-synth-cyan mb-1">EMAIL</label>
|
||||
<input id="email" type="email" [(ngModel)]="email" name="email" required
|
||||
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
|
||||
placeholder="user@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-mono text-synth-cyan mb-1">PASSWORD</label>
|
||||
<input id="password" type="password" [(ngModel)]="password" name="password" required
|
||||
class="w-full bg-synth-darker/80 border border-synth-cyan/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-synth-pink focus:border-synth-cyan transition-all"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="loading"
|
||||
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
|
||||
{{ loading ? 'CREATING...' : 'REGISTER' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-400 mt-6">
|
||||
Already have an account?
|
||||
<a routerLink="/login" class="text-synth-cyan hover:text-synth-pink font-mono transition-colors">
|
||||
Login here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class RegisterComponent {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
displayName = '';
|
||||
email = '';
|
||||
password = '';
|
||||
error = '' as string | null;
|
||||
success = false as boolean;
|
||||
loading = false as boolean;
|
||||
|
||||
onRegister(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.success = false;
|
||||
this.authService.register({
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
displayName: this.displayName
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.success = true;
|
||||
this.loading = false;
|
||||
setTimeout(() => this.router.navigate(['/login']), 1500);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Registration failed. Email may already be taken.';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (token) {
|
||||
const cloned = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
return next(cloned);
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
export interface Message {
|
||||
id: number;
|
||||
subject: string;
|
||||
body: string;
|
||||
sender: string;
|
||||
senderName?: string;
|
||||
recipient: string;
|
||||
sentAt: string;
|
||||
read: boolean;
|
||||
flagged: boolean;
|
||||
spam: boolean;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
hasAttachments: boolean;
|
||||
attachmentCount: number;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: number;
|
||||
name: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SendRequest {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface BlacklistEntry {
|
||||
senderEmail: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
content: T[];
|
||||
number: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, BehaviorSubject } from 'rxjs';
|
||||
import { tap, shareReplay } from 'rxjs/operators';
|
||||
import { LoginRequest, LoginResponse, RegisterRequest } from '../models/mail.models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private apiUrl = 'http://localhost:8080/api';
|
||||
|
||||
private currentUserSubject = new BehaviorSubject<string | null>(
|
||||
localStorage.getItem('email')
|
||||
);
|
||||
currentUser$ = this.currentUserSubject.asObservable();
|
||||
|
||||
private tokenSubject = new BehaviorSubject<string | null>(
|
||||
localStorage.getItem('token')
|
||||
);
|
||||
token$ = this.tokenSubject.asObservable();
|
||||
|
||||
login({ email, password }: LoginRequest): Observable<LoginResponse> {
|
||||
return this.http.post<LoginResponse>(`${this.apiUrl}/login`, { email, password }).pipe(
|
||||
tap(({ token, email }) => {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('email', email);
|
||||
this.tokenSubject.next(token);
|
||||
this.currentUserSubject.next(email);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
register({ email, password, displayName }: RegisterRequest): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiUrl}/register`, { email, password, displayName });
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('email');
|
||||
this.tokenSubject.next(null);
|
||||
this.currentUserSubject.next(null);
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
return !!localStorage.getItem('token');
|
||||
}
|
||||
|
||||
getEmail(): string | null {
|
||||
return localStorage.getItem('email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { Message, Folder, Attachment, SendRequest, BlacklistEntry, PageResponse } from '../models/mail.models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MailService {
|
||||
private http = inject(HttpClient);
|
||||
private apiUrl = 'http://localhost:8080/api';
|
||||
|
||||
// Папки
|
||||
getFolders(): Observable<Folder[]> {
|
||||
return this.http.get<Folder[]>(`${this.apiUrl}/folders`);
|
||||
}
|
||||
|
||||
// Письма
|
||||
getMessages(folderId: number, page: number = 0, size: number = 20): Observable<Message[]> {
|
||||
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages`, {
|
||||
params: { folderId, page, size }
|
||||
}).pipe(map(response => response.content));
|
||||
}
|
||||
|
||||
getMessage(id: number): Observable<Message> {
|
||||
return this.http.get<Message>(`${this.apiUrl}/messages/${id}`);
|
||||
}
|
||||
|
||||
searchMessages(
|
||||
folderId: number,
|
||||
keyword?: string,
|
||||
subject?: string,
|
||||
body?: string,
|
||||
sender?: string,
|
||||
recipient?: string
|
||||
): Observable<Message[]> {
|
||||
const params: { [key: string]: string } = { folderId: folderId.toString() };
|
||||
if (keyword) params['keyword'] = keyword;
|
||||
if (subject) params['subject'] = subject;
|
||||
if (body) params['body'] = body;
|
||||
if (sender) params['sender'] = sender;
|
||||
if (recipient) params['recipient'] = recipient;
|
||||
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages/search`, { params })
|
||||
.pipe(map(response => response.content));
|
||||
}
|
||||
|
||||
// Отправка
|
||||
send(sendRequest: SendRequest): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiUrl}/send`, sendRequest);
|
||||
}
|
||||
|
||||
// Статус
|
||||
markAsRead(id: number): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/messages/${id}/read`, null);
|
||||
}
|
||||
|
||||
deleteMessage(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/messages/${id}`);
|
||||
}
|
||||
|
||||
// Вложения
|
||||
getAttachments(messageId: number): Observable<Attachment[]> {
|
||||
return this.http.get<Attachment[]>(`${this.apiUrl}/messages/${messageId}/attachments`);
|
||||
}
|
||||
|
||||
uploadAttachment(messageId: number, file: File): Observable<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.post<void>(`${this.apiUrl}/messages/${messageId}/attachments`, formData);
|
||||
}
|
||||
|
||||
downloadAttachment(attachmentId: number): Observable<Blob> {
|
||||
return this.http.get<Blob>(`${this.apiUrl}/attachments/${attachmentId}/download`, {
|
||||
responseType: 'blob' as 'json'
|
||||
});
|
||||
}
|
||||
|
||||
deleteAttachment(attachmentId: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/attachments/${attachmentId}`);
|
||||
}
|
||||
|
||||
// Чёрный список
|
||||
getBlacklist(): Observable<string[]> {
|
||||
return this.http.get<string[]>(`${this.apiUrl}/spam/blacklist`);
|
||||
}
|
||||
|
||||
addToBlacklist(entry: BlacklistEntry): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiUrl}/spam/blacklist`, entry);
|
||||
}
|
||||
|
||||
removeFromBlacklist(senderEmail: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/spam/blacklist`, {
|
||||
params: { senderEmail }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MailServer — Synthwave Edition</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800;900&family=Rajdhani:wght@400;500;600;700&family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-synth-dark">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
@@ -0,0 +1,254 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800;900&family=Rajdhani:wght@400;500;600;700&family=Fira+Code:wght@400;500;600&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-synth-dark: #0d0221;
|
||||
--color-synth-darker: #0a0118;
|
||||
--color-synth-pink: #ff007f;
|
||||
--color-synth-cyan: #00f0ff;
|
||||
--color-synth-purple: #7928ca;
|
||||
--color-synth-yellow: #ffbe0b;
|
||||
--color-synth-glass: rgba(13, 2, 33, 0.6);
|
||||
|
||||
--font-family-orbitron: 'Orbitron', sans-serif;
|
||||
--font-family-rajdhani: 'Rajdhani', sans-serif;
|
||||
--font-family-fira-code: 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-synth-dark: #0d0221;
|
||||
--color-synth-pink: #ff007f;
|
||||
--color-synth-cyan: #00f0ff;
|
||||
--color-synth-purple: #7928ca;
|
||||
--color-synth-yellow: #ffbe0b;
|
||||
--color-synth-glass: rgba(13, 2, 33, 0.6);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-synth-dark);
|
||||
color: #f1f5f9;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Glassmorphism панель */
|
||||
.glass-panel {
|
||||
background: var(--color-synth-glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(0, 240, 255, 0.25);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), inset 0 0 15px rgba(255, 255, 255, 0.03);
|
||||
border-radius: 16px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
border-color: var(--color-synth-cyan);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.6), 0 0 15px rgba(0, 240, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Неоновый текст */
|
||||
.neon-text-primary {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 5px var(--color-synth-pink), 0 0 15px var(--color-synth-pink);
|
||||
}
|
||||
|
||||
.neon-text-secondary {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 5px var(--color-synth-cyan), 0 0 15px var(--color-synth-cyan);
|
||||
}
|
||||
|
||||
/* Cyber кнопка */
|
||||
.cyber-btn {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, rgba(255, 0, 127, 0.2), rgba(0, 240, 255, 0.2));
|
||||
border: 1px solid var(--color-synth-cyan);
|
||||
color: #ffffff;
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cyber-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||
transition: 0.5s;
|
||||
}
|
||||
|
||||
.cyber-btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.cyber-btn:hover {
|
||||
background: var(--color-synth-cyan);
|
||||
color: var(--color-synth-dark);
|
||||
box-shadow: 0 0 20px var(--color-synth-cyan);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Synthwave фон с сеткой */
|
||||
.synth-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.synth-sun {
|
||||
position: absolute;
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(to bottom, #ffbe0b, var(--color-synth-pink), var(--color-synth-purple));
|
||||
box-shadow: 0 0 60px var(--color-synth-pink), 0 0 100px var(--color-synth-purple);
|
||||
}
|
||||
|
||||
.synth-sun::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
transparent 4px,
|
||||
var(--color-synth-dark) 4px,
|
||||
var(--color-synth-dark) 8px
|
||||
);
|
||||
mask-image: linear-gradient(to bottom, transparent 40%, black 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 40%, black 100%);
|
||||
}
|
||||
|
||||
.synth-grid {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 40%;
|
||||
perspective: 400px;
|
||||
mask-image: linear-gradient(to top, rgba(0,0,0,1) 60%, rgba(0,0,0,0) 100%);
|
||||
-webkit-mask-image: linear-gradient(to top, rgba(0,0,0,1) 60%, rgba(0,0,0,0) 100%);
|
||||
}
|
||||
|
||||
.synth-grid-plane {
|
||||
width: 100%;
|
||||
height: 200%;
|
||||
transform: rotateX(75deg);
|
||||
background-size: 40px 40px;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 0, 127, 0.3) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 0, 127, 0.3) 1px, transparent 1px);
|
||||
animation: gridMove 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: rotateX(75deg) translateY(0); }
|
||||
100% { transform: rotateX(75deg) translateY(40px); }
|
||||
}
|
||||
|
||||
/* Кастомный скроллбар */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(13, 2, 33, 0.8);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-synth-pink);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 10px var(--color-synth-pink);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-synth-cyan);
|
||||
}
|
||||
|
||||
/* Неоновое свечение */
|
||||
.neon-glow-pink {
|
||||
box-shadow: 0 0 10px var(--color-synth-pink), 0 0 20px var(--color-synth-pink);
|
||||
}
|
||||
|
||||
.neon-glow-cyan {
|
||||
box-shadow: 0 0 10px var(--color-synth-cyan), 0 0 20px var(--color-synth-cyan);
|
||||
}
|
||||
|
||||
/* Светлая тема */
|
||||
.light-theme {
|
||||
--color-synth-dark: #f0f0f5;
|
||||
--color-synth-glass: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.light-theme body,
|
||||
.light-theme {
|
||||
background-color: #f0f0f5 !important;
|
||||
color: #1a1a2e !important;
|
||||
}
|
||||
|
||||
.light-theme .glass-panel {
|
||||
background: rgba(255, 255, 255, 0.8) !important;
|
||||
border-color: rgba(121, 40, 202, 0.3) !important;
|
||||
color: #1a1a2e !important;
|
||||
}
|
||||
|
||||
.light-theme .synth-bg {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.light-theme .neon-text-primary,
|
||||
.light-theme .neon-text-secondary {
|
||||
text-shadow: none !important;
|
||||
color: #1a1a2e !important;
|
||||
}
|
||||
|
||||
.light-theme input,
|
||||
.light-theme .cyber-btn {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
color: #1a1a2e !important;
|
||||
border-color: rgba(121, 40, 202, 0.4) !important;
|
||||
}
|
||||
|
||||
.light-theme .cyber-btn:hover {
|
||||
background: var(--color-synth-purple) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.light-theme a,
|
||||
.light-theme span,
|
||||
.light-theme p,
|
||||
.light-theme h1,
|
||||
.light-theme h2,
|
||||
.light-theme label {
|
||||
color: #1a1a2e !important;
|
||||
}
|
||||
|
||||
.light-theme .text-slate-200,
|
||||
.light-theme .text-slate-300,
|
||||
.light-theme .text-slate-400 {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.light-theme .bg-synth-darker\/80 {
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
Reference in New Issue
Block a user