UI-Improvements
This commit is contained in:
@@ -5,16 +5,17 @@ import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/rou
|
||||
interface AdminNavEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const ENTRIES: AdminNavEntry[] = [
|
||||
{ id: 'general', label: 'General', path: '/admin/general', enabled: true },
|
||||
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
|
||||
{ id: 'players', label: 'Players', path: '/admin/players', enabled: true },
|
||||
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: true },
|
||||
{ id: 'system', label: 'System', path: '/admin/system', enabled: true },
|
||||
{ id: 'general', label: 'GENERAL', icon: '⚙️', path: '/admin/general', enabled: true },
|
||||
{ id: 'challenges', label: 'CHALLENGES', icon: '🚩', path: '/admin/challenges', enabled: true },
|
||||
{ id: 'blog', label: 'BLOG', icon: '📰', path: '/admin/blog', enabled: true },
|
||||
{ id: 'players', label: 'PLAYERS', icon: '👥', path: '/admin/players', enabled: true },
|
||||
{ id: 'system', label: 'TOOLS', icon: '🔧', path: '/admin/system', enabled: true },
|
||||
];
|
||||
|
||||
@Component({
|
||||
@@ -23,34 +24,64 @@ const ENTRIES: AdminNavEntry[] = [
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet],
|
||||
styles: [`
|
||||
.admin-shell { display: flex; gap: 16px; align-items: flex-start; }
|
||||
.admin-shell { display: flex; gap: 24px; align-items: flex-start; max-width: 1200px; margin: 0 auto; padding: 24px 0; }
|
||||
.admin-aside {
|
||||
min-width: 180px;
|
||||
padding: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.admin-aside ul { list-style: none; padding: 0; margin: 0; }
|
||||
.admin-title {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
text-transform: uppercase;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
border-bottom: 2px solid var(--neon-red);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.admin-aside ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.admin-aside li {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
padding: 12px 16px;
|
||||
background: #000000;
|
||||
border: 1px solid #1a1a24;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 800;
|
||||
font-size: 0.85rem;
|
||||
color: #888899;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #000);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.admin-aside li:hover:not(.active):not(.disabled) {
|
||||
border-color: var(--neon-purple);
|
||||
color: #ffffff;
|
||||
}
|
||||
.admin-aside li.active {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: #fff;
|
||||
background: var(--neon-red);
|
||||
color: #000000;
|
||||
border-color: var(--neon-red);
|
||||
box-shadow: 0 0 15px rgba(255, 0, 51, 0.4);
|
||||
}
|
||||
.admin-aside li.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pause-board-btn {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.admin-body { flex: 1; min-width: 0; }
|
||||
`],
|
||||
template: `
|
||||
<section class="admin-shell">
|
||||
<aside class="admin-aside" data-testid="admin-aside">
|
||||
<h2>Admin area</h2>
|
||||
<h2 class="admin-title">ADMIN</h2>
|
||||
<ul data-testid="admin-nav">
|
||||
@for (e of entries; track e.id) {
|
||||
<li
|
||||
@@ -59,7 +90,8 @@ const ENTRIES: AdminNavEntry[] = [
|
||||
[attr.data-testid]="'admin-nav-' + e.id"
|
||||
(click)="go(e)"
|
||||
>
|
||||
{{ e.label }}
|
||||
<span>{{ e.icon }}</span>
|
||||
<span>{{ e.label }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -8,27 +8,53 @@ import { AdminBlogPost } from '../../../core/services/blog.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.error { color: var(--color-danger, #f00); margin-top: 8px; }
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
|
||||
padding: 24px; min-width: 380px; max-width: 90vw;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.5rem; font-weight: 900; font-style: italic; text-transform: uppercase;
|
||||
color: #ffffff; margin-bottom: 14px;
|
||||
}
|
||||
p { font-size: 0.95rem; color: #ddddee; margin-bottom: 16px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px; }
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--neon-purple); color: #000000; }
|
||||
.btn-ok {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-ok:hover { background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4); }
|
||||
.error { color: var(--neon-red); margin-top: 8px; font-weight: bold; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-backdrop" data-testid="post-delete-backdrop" (click)="onCancel()">
|
||||
<div class="modal" (click)="$event.stopPropagation()" data-testid="post-delete-modal">
|
||||
<h3>Delete post</h3>
|
||||
<h3>DELETE BROADCAST</h3>
|
||||
@if (post(); as p) {
|
||||
<p data-testid="post-delete-name">
|
||||
Delete <b>{{ p.title }}</b>? This cannot be undone.
|
||||
Delete post <b>{{ p.title }}</b>? This action cannot be undone.
|
||||
</p>
|
||||
}
|
||||
@if (errorMessage()) {
|
||||
<p class="error" data-testid="post-delete-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="post-delete-cancel">Cancel</button>
|
||||
<button type="button" [disabled]="deleting()" (click)="onConfirm()" data-testid="post-delete-ok">Delete</button>
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" data-testid="post-delete-cancel">CANCEL</button>
|
||||
<button type="button" class="btn-ok" [disabled]="deleting()" (click)="onConfirm()" data-testid="post-delete-ok">
|
||||
{{ deleting() ? 'DELETING...' : 'DELETE' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,14 +37,53 @@ export interface PostFormSubmitPayload {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 480px; max-width: 90vw; max-height: 90vh; overflow: auto; }
|
||||
.row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.layout { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: var(--radius-sm, 4px); background: var(--color-surface, #fafafa); min-height: 200px; max-height: 320px; overflow: auto; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.field-error { color: var(--color-danger, #f00); font-size: 12px; }
|
||||
.editing-meta { font-size: 12px; opacity: 0.7; }
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
|
||||
padding: 24px; min-width: 640px; max-width: 92vw; max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.8rem; font-weight: 900; font-style: italic; text-transform: uppercase;
|
||||
color: #ffffff; margin-bottom: 16px; letter-spacing: 0.5px;
|
||||
}
|
||||
.row { margin: 12px 0; display: flex; flex-direction: column; gap: 6px; text-align: left; }
|
||||
.row label {
|
||||
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 800;
|
||||
color: #888899; text-transform: uppercase; letter-spacing: 1px;
|
||||
}
|
||||
.row input[type="text"], .row textarea {
|
||||
font-family: var(--font-mono); background-color: #000000; color: #ffffff;
|
||||
border: 2px solid var(--neon-purple); padding: 12px 14px;
|
||||
font-size: 0.95rem; outline: none; width: 100%;
|
||||
}
|
||||
.row input:focus, .row textarea:focus {
|
||||
border-color: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan-glow);
|
||||
}
|
||||
.layout { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.preview {
|
||||
border: 2px solid var(--neon-cyan); padding: 14px; background: #050508;
|
||||
color: #e0d0ff; min-height: 200px; max-height: 320px; overflow-y: auto;
|
||||
box-shadow: 0 0 10px var(--neon-cyan-glow);
|
||||
}
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||
.field-error { color: var(--neon-red); font-size: 0.85rem; font-weight: bold; }
|
||||
.editing-meta { font-size: 0.75rem; color: #777788; margin-bottom: 12px; }
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--neon-purple); color: #000000; }
|
||||
.btn-ok {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-ok:hover:not([disabled]) { background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4); }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
@@ -56,26 +95,27 @@ export interface PostFormSubmitPayload {
|
||||
(submit)="$event.preventDefault()"
|
||||
data-testid="post-form-modal"
|
||||
>
|
||||
<h3>{{ mode() === 'create' ? 'New post' : 'Edit post' }}</h3>
|
||||
<h3>{{ mode() === 'create' ? 'NEW BROADCAST' : 'EDIT BROADCAST' }}</h3>
|
||||
|
||||
@if (post(); as p) {
|
||||
@if (mode() === 'edit') {
|
||||
<p class="editing-meta">
|
||||
Created: {{ p.createdAt | date: 'medium' }} ·
|
||||
Updated: {{ p.updatedAt | date: 'medium' }}
|
||||
CREATED: {{ p.createdAt | date: 'medium' }} ·
|
||||
UPDATED: {{ p.updatedAt | date: 'medium' }}
|
||||
@if (p.publishedAt) {
|
||||
· Published: {{ p.publishedAt | date: 'medium' }}
|
||||
· PUBLISHED: {{ p.publishedAt | date: 'medium' }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<label for="pf-title">Title (required)</label>
|
||||
<label for="pf-title">BROADCAST TITLE</label>
|
||||
<input
|
||||
id="pf-title"
|
||||
type="text"
|
||||
formControlName="title"
|
||||
placeholder="ANNOUNCEMENT TITLE"
|
||||
data-testid="pf-title"
|
||||
[attr.aria-invalid]="titleError() ? 'true' : null"
|
||||
[attr.aria-describedby]="titleError() ? 'pf-title-error' : null"
|
||||
@@ -87,11 +127,12 @@ export interface PostFormSubmitPayload {
|
||||
|
||||
<div class="layout">
|
||||
<div class="row">
|
||||
<label for="pf-body">Markdown body</label>
|
||||
<label for="pf-body">MARKDOWN BODY</label>
|
||||
<textarea
|
||||
id="pf-body"
|
||||
rows="14"
|
||||
rows="12"
|
||||
formControlName="bodyMd"
|
||||
placeholder="Enter markdown body..."
|
||||
data-testid="pf-body"
|
||||
(input)="onBodyInput()"
|
||||
></textarea>
|
||||
@@ -100,7 +141,7 @@ export interface PostFormSubmitPayload {
|
||||
}
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Live preview</label>
|
||||
<label>LIVE PREVIEW</label>
|
||||
<div class="preview" data-testid="pf-preview" [innerHTML]="previewHtml()"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,19 +151,21 @@ export interface PostFormSubmitPayload {
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="pf-cancel">Cancel</button>
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" data-testid="pf-cancel">CANCEL</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
[disabled]="!canSaveDraft()"
|
||||
(click)="onSubmit('draft')"
|
||||
data-testid="pf-save-draft"
|
||||
>Save draft</button>
|
||||
>SAVE DRAFT</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ok"
|
||||
[disabled]="!canPublish()"
|
||||
(click)="onSubmit('publish')"
|
||||
data-testid="pf-publish"
|
||||
>Publish</button>
|
||||
>PUBLISH BROADCAST</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -179,8 +222,6 @@ export class BlogFormModalComponent {
|
||||
}
|
||||
|
||||
onBodyInput(): void {
|
||||
// valueChanges already triggers the preview; this is here as an explicit
|
||||
// hook in case the template wiring changes.
|
||||
}
|
||||
|
||||
titleError(): string | null {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { AdminService, AdminCategory } from '../../../core/services/admin.service';
|
||||
import { CategoryFormModalComponent, CategoryFormSubmit } from './category-form-modal.component';
|
||||
import { CategoryDeleteModalComponent } from './category-delete-modal.component';
|
||||
import { CategoryIconComponent } from '../../../shared/components/category-icon/category-icon.component';
|
||||
|
||||
export type ModalMode = 'create' | 'edit';
|
||||
|
||||
@@ -10,40 +11,66 @@ export type ModalMode = 'create' | 'edit';
|
||||
selector: 'app-admin-categories',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, CategoryFormModalComponent, CategoryDeleteModalComponent],
|
||||
imports: [CommonModule, CategoryFormModalComponent, CategoryDeleteModalComponent, CategoryIconComponent],
|
||||
styles: [`
|
||||
.cat-list { list-style: none; padding: 0; margin: 0; }
|
||||
.cat-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc);
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 12px 14px; border-bottom: 1px solid #1a1a24;
|
||||
background: #000000;
|
||||
}
|
||||
.cat-row .icon { width: 32px; height: 32px; object-fit: cover; border-radius: 4px; background: #eee; }
|
||||
.cat-row .abbr { font-weight: bold; min-width: 60px; }
|
||||
.cat-row .desc { flex: 1; }
|
||||
.cat-row button { margin-left: 4px; }
|
||||
.header-bar { display: flex; justify-content: space-between; align-items: center; }
|
||||
.error { color: var(--color-danger, #f00); }
|
||||
.cat-row .abbr {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 800; min-width: 60px; color: #ffffff;
|
||||
}
|
||||
.cat-row .desc { flex: 1; font-family: var(--font-mono); font-size: 0.9rem; color: #888899; }
|
||||
.cat-row button {
|
||||
background: #000000; border: 1px solid var(--neon-purple); color: var(--neon-purple);
|
||||
padding: 6px 12px; font-family: var(--font-mono); font-weight: bold; cursor: pointer;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.cat-row button:hover {
|
||||
background: var(--neon-purple); color: #000000;
|
||||
}
|
||||
.cat-row button.btn-del {
|
||||
border-color: var(--neon-red); color: var(--neon-red);
|
||||
}
|
||||
.cat-row button.btn-del:hover {
|
||||
background: var(--neon-red); color: #000000;
|
||||
}
|
||||
.header-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.header-bar h2 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.6rem; font-weight: 900; font-style: italic; text-transform: uppercase;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn-add {
|
||||
background: var(--neon-red); border: 2px solid var(--neon-red); color: #000000;
|
||||
font-family: var(--font-mono); font-weight: 900; padding: 8px 16px; cursor: pointer;
|
||||
}
|
||||
.btn-add:hover { background: #000000; color: var(--neon-red); box-shadow: 0 0 12px rgba(255, 0, 51, 0.4); }
|
||||
.error { color: var(--neon-red); font-family: var(--font-mono); font-weight: bold; }
|
||||
`],
|
||||
template: `
|
||||
<section data-testid="admin-categories">
|
||||
<div class="header-bar">
|
||||
<h2>Categories</h2>
|
||||
<button type="button" (click)="openCreate()" data-testid="cat-add">+</button>
|
||||
<h2>CATEGORIES</h2>
|
||||
<button type="button" class="btn-add" (click)="openCreate()" data-testid="cat-add">+ ADD CATEGORY</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<p data-testid="cat-loading">Loading categories...</p>
|
||||
<p data-testid="cat-loading" style="color:#777788;font-family:var(--font-mono);">Loading categories...</p>
|
||||
} @else if (loadError()) {
|
||||
<p class="error" data-testid="cat-error">{{ loadError() }}</p>
|
||||
} @else {
|
||||
<ul class="cat-list" data-testid="cat-list">
|
||||
@for (c of categories(); track c.id) {
|
||||
<li class="cat-row" [attr.data-testid]="'cat-row-' + c.abbreviation">
|
||||
<img class="icon" [src]="c.iconPath || '/uploads/icons/placeholder.png'" alt="" />
|
||||
<app-category-icon [category]="c.abbreviation" [iconPath]="c.iconPath" [size]="32" />
|
||||
<span class="abbr">{{ c.abbreviation }}</span>
|
||||
<span class="desc">{{ c.description }}</span>
|
||||
<button type="button" (click)="openEdit(c)" [attr.data-testid]="'cat-edit-' + c.abbreviation">✎</button>
|
||||
<button type="button" (click)="openDelete(c)" [attr.data-testid]="'cat-delete-' + c.abbreviation">🗑</button>
|
||||
<button type="button" (click)="openEdit(c)" [attr.data-testid]="'cat-edit-' + c.abbreviation">EDIT</button>
|
||||
<button type="button" class="btn-del" (click)="openDelete(c)" [attr.data-testid]="'cat-delete-' + c.abbreviation">DELETE</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -8,31 +8,54 @@ import { AdminCategory } from '../../../core/services/admin.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 320px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.error { color: var(--color-danger, #f00); margin-top: 8px; }
|
||||
.note { color: var(--color-warning, #fa0); margin-top: 8px; }
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
|
||||
padding: 24px; min-width: 380px; max-width: 90vw;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.5rem; font-weight: 900; font-style: italic; text-transform: uppercase;
|
||||
color: #ffffff; margin-bottom: 14px;
|
||||
}
|
||||
p { font-size: 0.95rem; color: #ddddee; margin-bottom: 16px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px; }
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--neon-purple); color: #000000; }
|
||||
.btn-ok {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-ok:hover { background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4); }
|
||||
.error { color: var(--neon-red); margin-top: 8px; font-weight: bold; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-backdrop" data-testid="cat-delete-backdrop" (click)="onCancel()">
|
||||
<div class="modal" (click)="$event.stopPropagation()" data-testid="cat-delete-modal">
|
||||
<h3>Delete category</h3>
|
||||
<h3>DELETE CATEGORY</h3>
|
||||
@if (category(); as c) {
|
||||
@if (c.isSystem) {
|
||||
<p>This is a system category and cannot be deleted.</p>
|
||||
} @else {
|
||||
<p>Delete <b>{{ c.name }}</b> ({{ c.abbreviation }})? This cannot be undone.</p>
|
||||
<p>Delete category <b>{{ c.name }}</b> ({{ c.abbreviation }})? This action cannot be undone.</p>
|
||||
}
|
||||
}
|
||||
@if (errorMessage()) {
|
||||
<p class="error" data-testid="cat-delete-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="cat-delete-cancel">Cancel</button>
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" data-testid="cat-delete-cancel">CANCEL</button>
|
||||
@if (canConfirm()) {
|
||||
<button type="button" (click)="onConfirm()" data-testid="cat-delete-ok">OK</button>
|
||||
<button type="button" class="btn-ok" (click)="onConfirm()" data-testid="cat-delete-ok">DELETE</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,49 +56,86 @@ export function syncCategoryForm(
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; }
|
||||
.row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
|
||||
padding: 24px; min-width: 440px; max-width: 90vw;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.8rem; font-weight: 900; font-style: italic; text-transform: uppercase;
|
||||
color: #ffffff; margin-bottom: 16px; letter-spacing: 0.5px;
|
||||
}
|
||||
.row { margin: 12px 0; display: flex; flex-direction: column; gap: 6px; text-align: left; }
|
||||
.row label {
|
||||
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 800;
|
||||
color: #888899; text-transform: uppercase; letter-spacing: 1px;
|
||||
}
|
||||
.row input[type="text"], .row textarea {
|
||||
font-family: var(--font-mono); background-color: #000000; color: #ffffff;
|
||||
border: 2px solid var(--neon-purple); padding: 12px 14px;
|
||||
font-size: 0.95rem; outline: none; width: 100%;
|
||||
}
|
||||
.row input:focus, .row textarea:focus {
|
||||
border-color: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan-glow);
|
||||
}
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--neon-purple); color: #000000; }
|
||||
.btn-ok {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-ok:hover:not([disabled]) { background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4); }
|
||||
.error { color: var(--neon-red); margin-top: 8px; font-weight: bold; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-backdrop" data-testid="cat-form-backdrop" (click)="onCancel()">
|
||||
<form class="modal" [formGroup]="form" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="cat-form-modal">
|
||||
<h3>{{ mode() === 'create' ? 'Add category' : 'Edit category' }}</h3>
|
||||
<h3>{{ mode() === 'create' ? 'ADD CATEGORY' : 'EDIT CATEGORY' }}</h3>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-name">Name</label>
|
||||
<input id="cf-name" type="text" formControlName="name" data-testid="cf-name" />
|
||||
<label for="cf-name">CATEGORY NAME</label>
|
||||
<input id="cf-name" type="text" formControlName="name" placeholder="CATEGORY NAME" data-testid="cf-name" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-abbr">Abbreviation (uppercase)</label>
|
||||
<label for="cf-abbr">ABBREVIATION (UPPERCASE)</label>
|
||||
<input id="cf-abbr" type="text" formControlName="abbreviation"
|
||||
[attr.readonly]="abbreviationReadonly() ? '' : null"
|
||||
placeholder="E.G. WEB, PWN, REV"
|
||||
data-testid="cf-abbr" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-desc">Description</label>
|
||||
<textarea id="cf-desc" rows="3" formControlName="description" data-testid="cf-desc"></textarea>
|
||||
<label for="cf-desc">DESCRIPTION</label>
|
||||
<textarea id="cf-desc" rows="3" formControlName="description" placeholder="Category description..." data-testid="cf-desc"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-icon">Icon (image, will be normalized to 128x128)</label>
|
||||
<label for="cf-icon">ICON (NORMALIZED TO 128x128)</label>
|
||||
<input id="cf-icon" type="file" accept="image/*" (change)="onFile($event)" data-testid="cf-icon" />
|
||||
@if (iconPreview()) {
|
||||
<img [src]="iconPreview()" alt="icon preview" style="width:64px;height:64px;object-fit:cover;border-radius:4px;" />
|
||||
<img [src]="iconPreview()" alt="icon preview" style="width:64px;height:64px;object-fit:cover;border:2px solid var(--neon-purple);margin-top:6px;" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="error" data-testid="cf-error" style="color: var(--color-danger, #f00); margin-top: 8px;">{{ errorMessage() }}</p>
|
||||
<p class="error" data-testid="cf-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="cf-cancel">Cancel</button>
|
||||
<button type="button" [disabled]="form.invalid || submitting() || saving()" (click)="onOk()" data-testid="cf-ok">OK</button>
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" data-testid="cf-cancel">CANCEL</button>
|
||||
<button type="button" class="btn-ok" [disabled]="form.invalid || submitting() || saving()" (click)="onOk()" data-testid="cf-ok">SAVE</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -14,29 +14,50 @@ import { AdminChallengeListItem } from '../../../core/services/admin.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(420px, 90vw); }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.error { color: var(--color-danger, #f00); margin-top: 8px; }
|
||||
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
|
||||
padding: 24px; width: min(440px, 92vw);
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.5rem; font-weight: 900; font-style: italic; text-transform: uppercase;
|
||||
color: #ffffff; margin-bottom: 14px;
|
||||
}
|
||||
p { font-size: 0.95rem; color: #ddddee; margin-bottom: 16px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px; }
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--neon-purple); color: #000000; }
|
||||
.btn-ok {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-ok:hover:not([disabled]) { background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4); }
|
||||
.error { color: var(--neon-red); margin-top: 8px; font-weight: bold; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-overlay" data-testid="challenge-delete-overlay">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="cd-title" data-testid="challenge-delete-modal">
|
||||
<h3 id="cd-title">Delete challenge</h3>
|
||||
<h3 id="cd-title">DELETE CHALLENGE</h3>
|
||||
@if (challenge(); as c) {
|
||||
<p>Delete challenge '<b>{{ c.name }}</b>'? This will also remove all files and solve records associated with it.</p>
|
||||
<p>Delete challenge <b>{{ c.name }}</b>? This will permanently remove all files and solve records associated with it.</p>
|
||||
}
|
||||
@if (errorMessage(); as msg) {
|
||||
<p class="error" role="alert" data-testid="challenge-delete-error">{{ msg }}</p>
|
||||
}
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-delete-cancel">Cancel</button>
|
||||
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-delete-ok">
|
||||
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
|
||||
Delete
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-delete-cancel">CANCEL</button>
|
||||
<button type="button" class="btn-ok" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-delete-ok">
|
||||
{{ submitting() ? 'DELETING...' : 'DELETE' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,54 +135,128 @@ export function syncChallengeForm(
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
styles: [`
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 60; padding: 16px; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(720px, 100%); max-height: 90vh; overflow: auto; }
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 8px; }
|
||||
.tabs button { padding: 6px 12px; border-radius: 4px; border: 1px solid var(--color-secondary, #ccc); background: transparent; cursor: pointer; }
|
||||
.tabs button.active { background: var(--color-primary, #3b82f6); color: #fff; border-color: var(--color-primary, #3b82f6); }
|
||||
.row { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
|
||||
.row label { font-weight: 600; font-size: 13px; }
|
||||
.row .error { color: var(--color-danger, #f00); font-size: 12px; }
|
||||
.markdown-preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: 4px; min-height: 60px; }
|
||||
.field-row { display: flex; gap: 8px; }
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1200;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #000000; color: #ffffff;
|
||||
border: 3px solid var(--neon-red);
|
||||
padding: 24px; width: min(720px, 94vw); max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
h2 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.8rem; font-weight: 900; font-style: italic;
|
||||
text-transform: uppercase; color: #ffffff; margin-bottom: 16px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
.tabs button {
|
||||
padding: 8px 16px; font-family: var(--font-mono); font-weight: 800;
|
||||
text-transform: uppercase; background: #000000; color: #ffffff;
|
||||
border: 2px solid var(--neon-purple); cursor: pointer; transition: all 0.15s ease;
|
||||
}
|
||||
.tabs button.active {
|
||||
background: var(--neon-red); color: #000000; border-color: var(--neon-red);
|
||||
box-shadow: 0 0 12px rgba(255, 0, 51, 0.4);
|
||||
}
|
||||
.row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; text-align: left; }
|
||||
.row label {
|
||||
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 800;
|
||||
color: #888899; text-transform: uppercase; letter-spacing: 1px;
|
||||
}
|
||||
.row input[type="text"], .row input[type="number"], .row textarea, .row select {
|
||||
font-family: var(--font-mono); background-color: #000000; color: #ffffff;
|
||||
border: 2px solid var(--neon-purple); padding: 12px 14px;
|
||||
font-size: 0.95rem; outline: none; width: 100%;
|
||||
}
|
||||
.row input:focus, .row textarea:focus, .row select:focus {
|
||||
border-color: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan-glow);
|
||||
}
|
||||
.row .error { color: var(--neon-red); font-size: 0.85rem; font-weight: bold; }
|
||||
.connection-panel {
|
||||
border: 2px solid var(--neon-cyan); padding: 16px; margin-bottom: 16px;
|
||||
background: rgba(0, 240, 255, 0.02);
|
||||
box-shadow: 0 0 10px var(--neon-cyan-glow);
|
||||
}
|
||||
.connection-panel-title {
|
||||
font-family: var(--font-mono); font-weight: 800; color: var(--neon-cyan);
|
||||
font-size: 0.85rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px;
|
||||
}
|
||||
.markdown-preview {
|
||||
border: 2px solid var(--neon-purple); padding: 12px; background: #050508;
|
||||
color: #e0d0ff; min-height: 80px; margin-top: 8px;
|
||||
}
|
||||
.field-row { display: flex; gap: 12px; }
|
||||
.field-row > * { flex: 1; }
|
||||
.file-list { list-style: none; padding: 0; margin: 0; }
|
||||
.file-list li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--color-secondary, #ccc); }
|
||||
.file-list li .remove { margin-left: auto; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.flag-input { display: flex; gap: 4px; }
|
||||
.radio-group { display: flex; gap: 16px; align-items: center; padding: 6px 0; }
|
||||
.radio-group label { color: #ffffff; font-weight: bold; cursor: pointer; }
|
||||
.file-list { list-style: none; padding: 0; margin: 0 0 16px 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.file-list li {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border: 2px solid var(--neon-purple); background: #000000;
|
||||
}
|
||||
.file-list li .remove {
|
||||
background: none; border: none; color: var(--neon-red); font-size: 1.1rem; cursor: pointer;
|
||||
}
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||
.flag-input { display: flex; gap: 8px; }
|
||||
.flag-input input { flex: 1; }
|
||||
.preview-toggle { font-size: 12px; }
|
||||
.upload-error { color: var(--color-danger, #f00); font-size: 12px; }
|
||||
.drop-zone { border: 2px dashed var(--color-secondary, #ccc); border-radius: 6px; padding: 12px; text-align: center; transition: background 0.15s, border-color 0.15s; }
|
||||
.drop-zone.dragover { background: rgba(59,130,246,0.08); border-color: var(--color-primary, #3b82f6); }
|
||||
.drop-zone__hint { margin: 0 0 8px; font-size: 12px; color: var(--color-secondary, #666); }
|
||||
.flag-toggle-btn {
|
||||
background: #000000; border: 2px solid var(--neon-purple); color: #ffffff;
|
||||
padding: 0 14px; cursor: pointer; font-size: 1.1rem;
|
||||
}
|
||||
.preview-toggle {
|
||||
background: transparent; border: none; color: var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: bold; cursor: pointer;
|
||||
text-decoration: underline; text-align: left; padding: 4px 0; font-size: 0.8rem;
|
||||
}
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--neon-purple); padding: 20px; text-align: center;
|
||||
background: #000000; cursor: pointer; transition: all 0.15s ease;
|
||||
}
|
||||
.drop-zone.dragover { background: rgba(176,0,255,0.1); border-color: var(--neon-cyan); }
|
||||
.drop-zone__hint { margin: 0 0 8px; font-size: 0.85rem; color: #ffffff; font-weight: bold; }
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 12px 24px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover:not([disabled]) { background: var(--neon-purple); color: #000000; }
|
||||
.btn-ok {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 12px 24px; cursor: pointer;
|
||||
}
|
||||
.btn-ok:hover:not([disabled]) { background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4); }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-overlay" role="presentation" (click)="onCancel()" data-testid="challenge-form-backdrop">
|
||||
<form class="modal" role="dialog" aria-modal="true" [attr.aria-label]="mode() === 'create' ? 'Add challenge' : 'Edit challenge'" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="challenge-form-modal">
|
||||
<h2>{{ mode() === 'create' ? 'Add challenge' : 'Edit challenge' }}</h2>
|
||||
<h2>{{ mode() === 'create' ? 'ADD CHALLENGE' : 'MANAGE CHALLENGE' }}</h2>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button type="button" role="tab" [class.active]="tab() === 'general'" [attr.aria-selected]="tab() === 'general'" (click)="setTab('general')" data-testid="cf-tab-general">General</button>
|
||||
<button type="button" role="tab" [class.active]="tab() === 'connection'" [attr.aria-selected]="tab() === 'connection'" (click)="setTab('connection')" data-testid="cf-tab-connection">Connection</button>
|
||||
<button type="button" role="tab" [class.active]="tab() === 'files'" [attr.aria-selected]="tab() === 'files'" (click)="setTab('files')" data-testid="cf-tab-files">Files</button>
|
||||
<button type="button" role="tab" [class.active]="tab() === 'general'" [attr.aria-selected]="tab() === 'general'" (click)="setTab('general')" data-testid="cf-tab-general">GENERAL</button>
|
||||
<button type="button" role="tab" [class.active]="tab() === 'connection'" [attr.aria-selected]="tab() === 'connection'" (click)="setTab('connection')" data-testid="cf-tab-connection">CONNECTION</button>
|
||||
<button type="button" role="tab" [class.active]="tab() === 'files'" [attr.aria-selected]="tab() === 'files'" (click)="setTab('files')" data-testid="cf-tab-files">FILES</button>
|
||||
</div>
|
||||
|
||||
<div [formGroup]="form">
|
||||
@if (tab() === 'general') {
|
||||
<div class="row">
|
||||
<label for="cf-name">Name</label>
|
||||
<input id="cf-name" type="text" formControlName="name" maxlength="128" data-testid="cf-name" />
|
||||
<label for="cf-name">CHALLENGE NAME</label>
|
||||
<input id="cf-name" type="text" formControlName="name" maxlength="128" placeholder="CHALLENGE TITLE" data-testid="cf-name" />
|
||||
@if (showError('name'); as msg) { <span class="error" data-testid="cf-error-name">{{ msg }}</span> }
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-desc">Description (Markdown)</label>
|
||||
<textarea id="cf-desc" rows="4" formControlName="descriptionMd" data-testid="cf-desc"></textarea>
|
||||
<label for="cf-desc">CHALLENGE INTEL (MARKDOWN)</label>
|
||||
<textarea id="cf-desc" rows="5" formControlName="descriptionMd" placeholder="Enter challenge description..." data-testid="cf-desc"></textarea>
|
||||
<button type="button" class="preview-toggle" (click)="togglePreview()" data-testid="cf-desc-preview-toggle">
|
||||
{{ showPreview() ? 'Hide preview' : 'Preview' }}
|
||||
{{ showPreview() ? 'HIDE PREVIEW' : 'SHOW PREVIEW' }}
|
||||
</button>
|
||||
@if (showPreview()) {
|
||||
<div class="markdown-preview" data-testid="cf-desc-preview" [innerHTML]="previewHtml()"></div>
|
||||
@@ -191,7 +265,7 @@ export function syncChallengeForm(
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-category">Category</label>
|
||||
<label for="cf-category">CATEGORY</label>
|
||||
<select id="cf-category" formControlName="categoryId" data-testid="cf-category">
|
||||
<option value="">Select a category…</option>
|
||||
@for (cat of categories(); track cat.id) {
|
||||
@@ -202,8 +276,8 @@ export function syncChallengeForm(
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Difficulty</label>
|
||||
<div role="radiogroup" aria-label="Difficulty">
|
||||
<label>DIFFICULTY</label>
|
||||
<div class="radio-group" role="radiogroup" aria-label="Difficulty">
|
||||
@for (d of difficulties; track d) {
|
||||
<label>
|
||||
<input type="radio" [value]="d" formControlName="difficulty" [attr.data-testid]="'cf-difficulty-' + d" /> {{ d }}
|
||||
@@ -214,69 +288,80 @@ export function syncChallengeForm(
|
||||
|
||||
<div class="field-row">
|
||||
<div class="row">
|
||||
<label for="cf-initial">Initial points</label>
|
||||
<label for="cf-initial">INITIAL PTS</label>
|
||||
<input id="cf-initial" type="number" min="1" max="100000" formControlName="initialPoints" data-testid="cf-initial" />
|
||||
@if (showError('initialPoints'); as msg) { <span class="error" data-testid="cf-error-initial">{{ msg }}</span> }
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="cf-minimum">Minimum points</label>
|
||||
<label for="cf-minimum">MINIMUM POINTS</label>
|
||||
<input id="cf-minimum" type="number" min="1" max="100000" formControlName="minimumPoints" data-testid="cf-minimum" />
|
||||
@if (showError('minimumPoints'); as msg) { <span class="error" data-testid="cf-error-minimum">{{ msg }}</span> }
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="cf-decay">Decay solves</label>
|
||||
<label for="cf-decay">DECAY SOLVES</label>
|
||||
<input id="cf-decay" type="number" min="1" max="10000" formControlName="decaySolves" data-testid="cf-decay" />
|
||||
@if (showError('decaySolves'); as msg) { <span class="error" data-testid="cf-error-decay">{{ msg }}</span> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-flag">Secret flag</label>
|
||||
<label for="cf-flag">FLAG PROTOCOL</label>
|
||||
<div class="flag-input">
|
||||
<input id="cf-flag" [type]="flagVisible() ? 'text' : 'password'" formControlName="flag" data-testid="cf-flag" />
|
||||
<button type="button" (click)="toggleFlag()" [attr.aria-label]="flagVisible() ? 'Hide flag' : 'Reveal flag'" data-testid="cf-flag-toggle">{{ flagVisible() ? '🙈' : '👁' }}</button>
|
||||
<input id="cf-flag" [type]="flagVisible() ? 'text' : 'password'" formControlName="flag" placeholder="{flag: insert_the_flag}" data-testid="cf-flag" />
|
||||
<button type="button" class="flag-toggle-btn" (click)="toggleFlag()" [attr.aria-label]="flagVisible() ? 'Hide flag' : 'Reveal flag'" data-testid="cf-flag-toggle">{{ flagVisible() ? '🙈' : '👁' }}</button>
|
||||
</div>
|
||||
@if (showError('flag'); as msg) { <span class="error" data-testid="cf-error-flag">{{ msg }}</span> }
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (tab() === 'connection') {
|
||||
<div class="row">
|
||||
<label>Protocol</label>
|
||||
<div role="radiogroup" aria-label="Protocol">
|
||||
@for (p of protocols; track p) {
|
||||
<label>
|
||||
<input type="radio" [value]="p" formControlName="protocol" [attr.data-testid]="'cf-protocol-' + p" /> {{ p }}
|
||||
</label>
|
||||
}
|
||||
<div class="connection-panel">
|
||||
<div class="connection-panel-title">🌐 CONNECTION SETTINGS</div>
|
||||
|
||||
<div class="row">
|
||||
<label>PROTOCOL</label>
|
||||
<div class="radio-group" role="radiogroup" aria-label="Protocol">
|
||||
@for (p of protocols; track p) {
|
||||
<label>
|
||||
<input type="radio" [value]="p" formControlName="protocol" [attr.data-testid]="'cf-protocol-' + p" /> {{ p }}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-port">Port</label>
|
||||
<input id="cf-port" type="text" inputmode="numeric" formControlName="port" data-testid="cf-port" />
|
||||
@if (showError('port'); as msg) { <span class="error" data-testid="cf-error-port">{{ msg }}</span> }
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="cf-port">PORT</label>
|
||||
<input id="cf-port" type="text" inputmode="numeric" formControlName="port" placeholder="0" data-testid="cf-port" />
|
||||
@if (showError('port'); as msg) { <span class="error" data-testid="cf-error-port">{{ msg }}</span> }
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-ip">IP address (optional; falls back to Default Challenge IP)</label>
|
||||
<input id="cf-ip" type="text" formControlName="ipAddress" data-testid="cf-ip" />
|
||||
@if (showError('ipAddress'); as msg) { <span class="error" data-testid="cf-error-ip">{{ msg }}</span> }
|
||||
<div class="row">
|
||||
<label for="cf-ip">NODE IP OVERRIDE (OPTIONAL)</label>
|
||||
<input id="cf-ip" type="text" formControlName="ipAddress" placeholder="Optional" data-testid="cf-ip" />
|
||||
@if (showError('ipAddress'); as msg) { <span class="error" data-testid="cf-error-ip">{{ msg }}</span> }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (tab() === 'files') {
|
||||
<ul class="file-list" data-testid="cf-files">
|
||||
@for (file of files(); track $index) {
|
||||
<li>
|
||||
<span>{{ file.originalFilename }}</span>
|
||||
<small>{{ file.mimeType }} · {{ formatSize(file.sizeBytes) }}</small>
|
||||
<button type="button" class="remove" (click)="removeFile(file)" [attr.aria-label]="'Remove ' + file.originalFilename" data-testid="cf-file-remove">🗑</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="row">
|
||||
<label for="cf-upload">Upload files (any file type)</label>
|
||||
<label>EXISTING ASSETS</label>
|
||||
<ul class="file-list" data-testid="cf-files">
|
||||
@for (file of files(); track $index) {
|
||||
<li>
|
||||
<span>{{ file.originalFilename }}</span>
|
||||
<small style="color:#777788">{{ file.mimeType }} · {{ formatSize(file.sizeBytes) }}</small>
|
||||
<button type="button" class="remove" (click)="removeFile(file)" [attr.aria-label]="'Remove ' + file.originalFilename" data-testid="cf-file-remove">🗑</button>
|
||||
</li>
|
||||
}
|
||||
@if (files().length === 0) {
|
||||
<li style="color:#666677; justify-content:center;">No assets uploaded yet.</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-upload">FILE MANAGEMENT</label>
|
||||
<div
|
||||
class="drop-zone"
|
||||
[class.dragover]="dragOver()"
|
||||
@@ -285,13 +370,13 @@ export function syncChallengeForm(
|
||||
(drop)="onDrop($event)"
|
||||
data-testid="cf-drop-zone"
|
||||
>
|
||||
<p class="drop-zone__hint">Drop files here or use the picker below</p>
|
||||
<p class="drop-zone__hint">+ SELECT ASSETS (or drop files here)</p>
|
||||
<input id="cf-upload" type="file" multiple (change)="onFileChange($event)" data-testid="cf-upload" />
|
||||
</div>
|
||||
@if (fileUploadError(); as err) {
|
||||
<span class="upload-error" data-testid="cf-upload-error">{{ err }}</span>
|
||||
<span class="error" data-testid="cf-upload-error">{{ err }}</span>
|
||||
}
|
||||
<small>Limit: {{ formatSize(globalFileLimit()) }} per file</small>
|
||||
<small style="color:#777788">Limit: {{ formatSize(globalFileLimit()) }} per file</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -301,9 +386,9 @@ export function syncChallengeForm(
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="cf-cancel">Cancel</button>
|
||||
<button type="button" (click)="onOk()" [disabled]="submitting()" data-testid="cf-ok">
|
||||
{{ submitting() ? 'Saving…' : (mode() === 'create' ? 'Create' : 'Save') }}
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" [disabled]="submitting()" data-testid="cf-cancel">CANCEL</button>
|
||||
<button type="button" class="btn-ok" (click)="onOk()" [disabled]="submitting()" data-testid="cf-ok">
|
||||
{{ submitting() ? 'SAVING…' : (mode() === 'create' ? 'CREATE CHALLENGE' : 'SAVE ALL CHANGES') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -447,7 +532,6 @@ export class ChallengeFormModalComponent {
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
// discard any pending staged tokens
|
||||
for (const f of this.files()) {
|
||||
if (f.kind === 'staged' && f.token) {
|
||||
void this.admin.discardChallengeFile(f.token).catch(() => undefined);
|
||||
@@ -625,5 +709,4 @@ function categoryIdUuidValidator(control: AbstractControl): ValidationErrors | n
|
||||
return CATEGORY_ID_PATTERN.test(String(raw)) ? null : { uuid: true };
|
||||
}
|
||||
|
||||
// Re-export so tests can find default helpers.
|
||||
export { defaultMinimumPoints };
|
||||
@@ -15,21 +15,43 @@ import { ImportPreviewResponse, ImportResultResponse } from '../../../core/servi
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(520px, 90vw); }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.error { color: var(--color-danger, #f00); margin-top: 8px; }
|
||||
.warnings { color: var(--color-warning, #b45309); margin-top: 8px; }
|
||||
.result { color: var(--color-success, #065f46); margin-top: 8px; }
|
||||
ul { margin: 0; padding-left: 18px; }
|
||||
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
|
||||
padding: 24px; width: min(540px, 92vw); max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.6rem; font-weight: 900; font-style: italic; text-transform: uppercase;
|
||||
color: #ffffff; margin-bottom: 14px; letter-spacing: 0.5px;
|
||||
}
|
||||
p { font-size: 0.95rem; color: #ddddee; margin-bottom: 12px; }
|
||||
ul { margin: 8px 0 16px 0; padding-left: 20px; font-size: 0.9rem; color: #aaaabb; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--neon-purple); color: #000000; }
|
||||
.btn-ok {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-ok:hover:not([disabled]) { background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4); }
|
||||
.error { color: var(--neon-red); font-weight: bold; margin-top: 8px; }
|
||||
.warnings { color: var(--neon-yellow); font-weight: bold; margin-top: 8px; }
|
||||
.result { color: var(--neon-green); font-weight: bold; margin-top: 8px; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-overlay" data-testid="challenge-import-overlay">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="ci-title" data-testid="challenge-import-modal">
|
||||
<h3 id="ci-title">Import challenges</h3>
|
||||
<h3 id="ci-title">IMPORT CHALLENGES</h3>
|
||||
|
||||
@if (result(); as r) {
|
||||
<p class="result" data-testid="challenge-import-result">
|
||||
@@ -43,7 +65,7 @@ import { ImportPreviewResponse, ImportResultResponse } from '../../../core/servi
|
||||
</ul>
|
||||
}
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="challenge-import-close">Close</button>
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" data-testid="challenge-import-close">CLOSE</button>
|
||||
</div>
|
||||
} @else if (preview()) {
|
||||
@if (preview(); as p) {
|
||||
@@ -54,7 +76,7 @@ import { ImportPreviewResponse, ImportResultResponse } from '../../../core/servi
|
||||
Continue?
|
||||
</p>
|
||||
@if (p.conflicts.length > 0) {
|
||||
<p>Will overwrite:</p>
|
||||
<p style="color:var(--neon-yellow);font-weight:bold;">Will overwrite:</p>
|
||||
<ul>
|
||||
@for (name of p.conflicts; track name) { <li>{{ name }}</li> }
|
||||
</ul>
|
||||
@@ -70,19 +92,18 @@ import { ImportPreviewResponse, ImportResultResponse } from '../../../core/servi
|
||||
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
|
||||
}
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-import-cancel">Cancel</button>
|
||||
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-import-confirm">
|
||||
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
|
||||
Overwrite & Import
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-import-cancel">CANCEL</button>
|
||||
<button type="button" class="btn-ok" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-import-confirm">
|
||||
{{ submitting() ? 'IMPORTING...' : 'OVERWRITE & IMPORT' }}
|
||||
</button>
|
||||
</div>
|
||||
} @else if (errorMessage()) {
|
||||
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="challenge-import-close">Close</button>
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" data-testid="challenge-import-close">CLOSE</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p>Reading file…</p>
|
||||
<p>READING FILE…</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,12 +14,34 @@ import { AdminUser } from '../../core/services/admin.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(420px, 90vw); }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.error { color: var(--color-danger, #f00); margin-top: 8px; }
|
||||
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
|
||||
padding: 24px; width: min(420px, 92vw);
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.5rem; font-weight: 900; font-style: italic; text-transform: uppercase;
|
||||
color: #ffffff; margin-bottom: 14px;
|
||||
}
|
||||
p { font-size: 0.95rem; color: #ddddee; margin-bottom: 16px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px; }
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--neon-purple); color: #000000; }
|
||||
.btn-ok {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-ok:hover:not([disabled]) { background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4); }
|
||||
.error { color: var(--neon-red); margin-top: 8px; font-weight: bold; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
@@ -32,16 +54,15 @@ import { AdminUser } from '../../core/services/admin.service';
|
||||
data-testid="player-delete-modal"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<h3 id="pd-title">Delete user</h3>
|
||||
<p>Delete this user?</p>
|
||||
<h3 id="pd-title">DELETE USER</h3>
|
||||
<p>Delete this user? All associated activity will be permanently wiped.</p>
|
||||
@if (errorMessage(); as msg) {
|
||||
<p class="error" role="alert" data-testid="player-delete-error">{{ msg }}</p>
|
||||
}
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="player-delete-cancel">Cancel</button>
|
||||
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="player-delete-ok">
|
||||
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
|
||||
Delete
|
||||
<button type="button" class="btn-cancel" (click)="onCancel()" [disabled]="submitting()" data-testid="player-delete-cancel">CANCEL</button>
|
||||
<button type="button" class="btn-ok" (click)="onConfirm()" [disabled]="submitting()" data-testid="player-delete-ok">
|
||||
{{ submitting() ? 'DELETING...' : 'DELETE USER' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,23 +18,54 @@ import { destructiveCopy } from './system.pure';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, FormsModule],
|
||||
styles: [`
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 80; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px 20px; border-radius: 10px; width: min(520px, 92vw); max-height: 90vh; overflow: auto; box-shadow: 0 12px 40px rgba(0,0,0,0.2); }
|
||||
.modal h3 { margin: 0 0 8px; color: var(--color-danger, #991b1b); }
|
||||
.stage { padding: 8px 12px; border-radius: 6px; background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-text, #92400e); margin: 12px 0; }
|
||||
.row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
||||
.row label { font-weight: 600; font-size: 14px; }
|
||||
.row input { padding: 8px; border-radius: 6px; border: 1px solid var(--color-secondary, #ccc); }
|
||||
.confirm-box { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.actions button.danger { background: var(--color-danger, #991b1b); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
|
||||
.actions button.cancel { background: transparent; border: 1px solid var(--color-secondary, #ccc); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
|
||||
.error { color: var(--color-danger, #b91c1c); margin-top: 8px; }
|
||||
.progress { color: var(--color-warning, #92400e); margin-top: 8px; }
|
||||
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.preserved { color: var(--color-success, #065f46); font-size: 13px; }
|
||||
.detail { font-size: 14px; line-height: 1.45; }
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
|
||||
padding: 24px; width: min(540px, 92vw); max-height: 90vh; overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.modal h3 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.6rem; font-weight: 900; font-style: italic; text-transform: uppercase;
|
||||
color: var(--neon-red); margin-bottom: 12px; letter-spacing: 0.5px;
|
||||
}
|
||||
.stage {
|
||||
padding: 10px 14px; border: 2px solid var(--neon-yellow); background: rgba(255, 234, 0, 0.05);
|
||||
color: var(--neon-yellow); font-family: var(--font-mono); font-weight: bold; font-size: 0.85rem; margin: 14px 0;
|
||||
}
|
||||
.row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; text-align: left; }
|
||||
.row label, .confirm-box label {
|
||||
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 800;
|
||||
color: #888899; text-transform: uppercase; letter-spacing: 1px;
|
||||
}
|
||||
.row input, .confirm-box input {
|
||||
font-family: var(--font-mono); background-color: #000000; color: #ffffff;
|
||||
border: 2px solid var(--neon-purple); padding: 12px 14px;
|
||||
font-size: 0.95rem; outline: none; width: 100%;
|
||||
}
|
||||
.row input:focus, .confirm-box input:focus {
|
||||
border-color: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan-glow);
|
||||
}
|
||||
.confirm-box { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; text-align: left; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--neon-purple); color: #000000; }
|
||||
.btn-danger {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
font-family: var(--font-mono); font-weight: 800; padding: 10px 20px; cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover:not([disabled]) { background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4); }
|
||||
.error { color: var(--neon-red); margin-top: 8px; font-weight: bold; }
|
||||
.progress { color: var(--neon-cyan); margin-top: 8px; font-weight: bold; }
|
||||
.preserved { color: var(--neon-green); font-size: 0.85rem; font-weight: bold; margin-bottom: 12px; }
|
||||
.detail { font-size: 0.9rem; color: #ddddee; line-height: 1.45; margin-bottom: 12px; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
@@ -51,14 +82,15 @@ import { destructiveCopy } from './system.pure';
|
||||
<p class="detail" data-testid="system-confirm-detail">{{ copy().detail }}</p>
|
||||
<p class="preserved" data-testid="system-confirm-preserved">{{ copy().preserved }}</p>
|
||||
|
||||
<div class="stage" data-testid="system-confirm-stage">{{ stageMessage() }}</div>
|
||||
<div class="stage" data-testid="system-confirm-stage">⚠️ {{ stageMessage() }}</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="system-confirm-password">Current administrator password</label>
|
||||
<label for="system-confirm-password">CURRENT ADMINISTRATOR PASSWORD</label>
|
||||
<input
|
||||
id="system-confirm-password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="ENTER ADMIN PASSWORD"
|
||||
[value]="password()"
|
||||
(input)="onPasswordInput($event)"
|
||||
[disabled]="submitting()"
|
||||
@@ -67,10 +99,11 @@ import { destructiveCopy } from './system.pure';
|
||||
</div>
|
||||
|
||||
<div class="confirm-box">
|
||||
<label for="system-confirm-phrase">Type <strong>{{ copy().confirmation }}</strong> to confirm</label>
|
||||
<label for="system-confirm-phrase">TYPE <strong style="color:var(--neon-red);">{{ copy().confirmation }}</strong> TO CONFIRM</label>
|
||||
<input
|
||||
id="system-confirm-phrase"
|
||||
type="text"
|
||||
placeholder="CONFIRMATION PHRASE"
|
||||
[value]="confirmation()"
|
||||
(input)="onConfirmInput($event)"
|
||||
[disabled]="submitting()"
|
||||
@@ -83,26 +116,25 @@ import { destructiveCopy } from './system.pure';
|
||||
}
|
||||
@if (submitting()) {
|
||||
<p class="progress" data-testid="system-confirm-progress">
|
||||
<span class="spinner" aria-hidden="true"></span>Working…
|
||||
EXECUTING SYSTEM OPERATION…
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel"
|
||||
class="btn-cancel"
|
||||
(click)="onCancel()"
|
||||
[disabled]="submitting()"
|
||||
data-testid="system-confirm-cancel"
|
||||
>Cancel</button>
|
||||
>CANCEL</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
class="btn-danger"
|
||||
(click)="onConfirm()"
|
||||
[disabled]="!canConfirm()"
|
||||
data-testid="system-confirm-confirm"
|
||||
>
|
||||
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
|
||||
{{ confirmLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -133,8 +165,8 @@ export class SystemConfirmModalComponent {
|
||||
});
|
||||
|
||||
readonly confirmLabel = computed(() => {
|
||||
if (this.submitting()) return 'Working…';
|
||||
return 'Confirm';
|
||||
if (this.submitting()) return 'WORKING…';
|
||||
return 'CONFIRM DESTRUCTIVE ACTION';
|
||||
});
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
|
||||
@@ -9,19 +9,62 @@ import { PublicBlogPost } from '../../core/services/blog.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.blog-item { display: block; margin: 0 0 16px 0; }
|
||||
.blog-title { margin: 0 0 4px 0; font-size: 18px; }
|
||||
.blog-date { display: block; font-size: 12px; opacity: 0.7; margin-bottom: 8px; }
|
||||
.blog-body { font-size: 14px; }
|
||||
.blog-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0 24px 0;
|
||||
background: #000000;
|
||||
border: 2px solid var(--neon-purple);
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 0 15px var(--neon-purple-glow);
|
||||
position: relative;
|
||||
}
|
||||
.blog-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid #1a1a24;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.blog-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.blog-date {
|
||||
display: inline-block;
|
||||
background: var(--neon-purple);
|
||||
color: #000000;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 800;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.blog-body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
color: #e0d0ff;
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<article class="blog-item" [attr.data-testid]="'blog-presenter-' + post().id">
|
||||
<h2 class="blog-title" [attr.data-testid]="'blog-presenter-title-' + post().id">
|
||||
{{ post().title }}
|
||||
</h2>
|
||||
<time class="blog-date" [attr.data-testid]="'blog-presenter-date-' + post().id">
|
||||
{{ post().publishedAt | date: 'medium' }}
|
||||
</time>
|
||||
<div class="blog-head">
|
||||
<h2 class="blog-title" [attr.data-testid]="'blog-presenter-title-' + post().id">
|
||||
{{ post().title }}
|
||||
</h2>
|
||||
<time class="blog-date" [attr.data-testid]="'blog-presenter-date-' + post().id">
|
||||
{{ post().publishedAt | date: 'M/d/yyyy, h:mm:ss a' }}
|
||||
</time>
|
||||
</div>
|
||||
<div
|
||||
class="blog-body"
|
||||
[attr.data-testid]="'blog-presenter-body-' + post().id"
|
||||
|
||||
@@ -2,31 +2,40 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CategoryColumn } from './challenges.pure';
|
||||
import { ChallengeCardComponent } from './challenge-card.component';
|
||||
import { CategoryIconComponent } from '../../shared/components/category-icon/category-icon.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-column',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ChallengeCardComponent],
|
||||
imports: [CommonModule, ChallengeCardComponent, CategoryIconComponent],
|
||||
styles: [`
|
||||
:host { display: block; width: 280px; flex: 0 0 280px; }
|
||||
.col {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
padding: 12px; background: var(--color-surface-2, rgba(255,255,255,0.02));
|
||||
border: 1px solid var(--color-border, #2a2f3a); border-radius: var(--radius-md, 8px);
|
||||
min-height: 200px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
padding: 16px 12px; background: #000000;
|
||||
border: 1px solid #1a1a24;
|
||||
min-height: 220px;
|
||||
}
|
||||
.head { display: flex; align-items: center; gap: 8px; }
|
||||
.icon { width: 32px; height: 32px; object-fit: contain; }
|
||||
.abbr { font-weight: 700; letter-spacing: 0.06em; }
|
||||
.name { font-size: 12px; opacity: 0.7; }
|
||||
.cards { display: flex; flex-direction: column; gap: 8px; }
|
||||
.head { display: flex; align-items: center; gap: 10px; padding-bottom: 8px; border-bottom: 1px solid #1a1a24; }
|
||||
.head-info { display: flex; flex-direction: column; }
|
||||
.abbr {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 1px;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
.name { font-size: 11px; color: #888899; font-weight: bold; margin-top: 2px; }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
`],
|
||||
template: `
|
||||
<section class="col" [attr.data-testid]="'category-column-' + column.abbreviation">
|
||||
<header class="head">
|
||||
<img *ngIf="column.iconPath" class="icon" [src]="column.iconPath" [alt]="column.abbreviation" />
|
||||
<div>
|
||||
<app-category-icon [category]="column.abbreviation" [size]="28" [color]="'var(--neon-purple)'" />
|
||||
<div class="head-info">
|
||||
<div class="abbr">{{ column.abbreviation }}</div>
|
||||
<div class="name">{{ column.name }}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,77 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { BoardCard } from './challenges.pure';
|
||||
import { CategoryIconComponent } from '../../shared/components/category-icon/category-icon.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-challenge-card',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, CategoryIconComponent],
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.card {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 10px 12px; border: 1px solid var(--color-border, #2a2f3a);
|
||||
border-radius: var(--radius-sm, 4px); background: var(--color-surface, #1c1f26);
|
||||
cursor: pointer; transition: transform 0.1s ease;
|
||||
min-height: 96px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
padding: 12px 14px; border: 2px solid #1a1a24;
|
||||
background: #000000;
|
||||
cursor: pointer; transition: all 0.15s ease-in-out;
|
||||
min-height: 90px;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--neon-purple);
|
||||
box-shadow: 0 0 12px var(--neon-purple-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card.solved {
|
||||
border-color: var(--neon-green);
|
||||
}
|
||||
.card-head { display: flex; align-items: center; gap: 10px; }
|
||||
.name {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 800;
|
||||
font-size: 0.95rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.check {
|
||||
color: var(--neon-green);
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #111116;
|
||||
}
|
||||
.points {
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
}
|
||||
.solves-count {
|
||||
color: #777788;
|
||||
}
|
||||
.card:hover { transform: translateY(-1px); border-color: var(--color-primary, #3b82f6); }
|
||||
.card.solved { border-color: var(--color-success, #22c55e); }
|
||||
.card-head { display: flex; align-items: center; gap: 8px; }
|
||||
.icon { width: 28px; height: 28px; object-fit: contain; }
|
||||
.name { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.check { color: var(--color-success, #22c55e); font-size: 18px; }
|
||||
.meta { display: flex; gap: 8px; font-size: 12px; opacity: 0.85; align-items: center; }
|
||||
.points { font-weight: 600; }
|
||||
.diff {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 999px; font-size: 10px; letter-spacing: 0.04em;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.diff-LOW { background: rgba(34,197,94,0.18); color: #16a34a; }
|
||||
.diff-MEDIUM { background: rgba(234,179,8,0.18); color: #ca8a04; }
|
||||
.diff-HIGH { background: rgba(239,68,68,0.18); color: #dc2626; }
|
||||
.diff-LOW { color: var(--neon-green); }
|
||||
.diff-MEDIUM { color: var(--neon-yellow); }
|
||||
.diff-HIGH { color: var(--neon-red); }
|
||||
`],
|
||||
template: `
|
||||
<button
|
||||
@@ -46,7 +85,11 @@ import { BoardCard } from './challenges.pure';
|
||||
(click)="open.emit(card.id)"
|
||||
>
|
||||
<div class="card-head">
|
||||
<img *ngIf="card.categoryIconPath" class="icon" [src]="card.categoryIconPath" [alt]="card.categoryAbbreviation" />
|
||||
<app-category-icon
|
||||
[category]="card.categoryAbbreviation"
|
||||
[size]="20"
|
||||
[color]="card.solvedByMe ? 'var(--neon-green)' : 'var(--neon-purple)'"
|
||||
/>
|
||||
<span class="name">{{ card.name }}</span>
|
||||
<span
|
||||
*ngIf="card.solvedByMe"
|
||||
@@ -57,9 +100,8 @@ import { BoardCard } from './challenges.pure';
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="diff" [class]="'diff-' + card.difficulty">{{ card.difficulty }}</span>
|
||||
<span class="points" [attr.data-testid]="'points-' + card.id">{{ card.livePoints }} pts</span>
|
||||
<span>·</span>
|
||||
<span>{{ card.solveCount }} solve<span *ngIf="card.solveCount !== 1">s</span></span>
|
||||
<span class="points" [attr.data-testid]="'points-' + card.id">{{ card.livePoints }} PTS</span>
|
||||
<span class="solves-count">{{ card.solveCount }} 👥</span>
|
||||
</div>
|
||||
</button>
|
||||
`,
|
||||
|
||||
@@ -32,92 +32,196 @@ import { ApiErrorEnvelope } from './challenges.service';
|
||||
imports: [CommonModule, FormsModule],
|
||||
styles: [`
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1200;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
width: min(720px, 92vw); max-height: 90vh; overflow: auto;
|
||||
background: var(--color-surface, #1c1f26); color: var(--color-text, #f4f4f5);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 16px; display: flex; flex-direction: column; gap: 12px;
|
||||
width: min(680px, 94vw); max-height: 90vh; overflow-y: auto;
|
||||
background: #000000; color: #ffffff;
|
||||
border: 3px solid var(--neon-red);
|
||||
padding: 24px; display: flex; flex-direction: column; gap: 16px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
position: relative;
|
||||
}
|
||||
.header { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.header h3 { margin: 0; flex: 1; }
|
||||
.pill { padding: 2px 8px; border-radius: 999px; font-size: 12px; background: rgba(255,255,255,0.06); }
|
||||
.pill.diff-LOW { background: rgba(34,197,94,0.18); color: #16a34a; }
|
||||
.pill.diff-MEDIUM { background: rgba(234,179,8,0.18); color: #ca8a04; }
|
||||
.pill.diff-HIGH { background: rgba(239,68,68,0.18); color: #dc2626; }
|
||||
.body { white-space: normal; line-height: 1.45; }
|
||||
.form { display: flex; gap: 8px; align-items: center; }
|
||||
.form input { flex: 1; padding: 8px 10px; border-radius: var(--radius-sm, 4px); border: 1px solid var(--color-border, #2a2f3a); background: var(--color-input, rgba(255,255,255,0.04)); color: inherit; }
|
||||
.actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.error { color: var(--color-danger, #ef4444); font-size: 14px; }
|
||||
.notice { color: var(--color-warning, #eab308); font-size: 14px; }
|
||||
.solvers { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.solvers li { display: grid; grid-template-columns: 28px 1fr auto auto; gap: 8px; align-items: center; padding: 4px 8px; border-radius: var(--radius-sm, 4px); background: rgba(255,255,255,0.03); }
|
||||
.rank-1 { color: #fbbf24; font-weight: 700; }
|
||||
.rank-2 { color: #d4d4d8; font-weight: 700; }
|
||||
.rank-3 { color: #b45309; font-weight: 700; }
|
||||
.rank-other { color: var(--color-success, #22c55e); }
|
||||
.points { font-variant-numeric: tabular-nums; }
|
||||
.close { background: transparent; border: 1px solid var(--color-border, #2a2f3a); color: inherit; padding: 4px 10px; border-radius: var(--radius-sm, 4px); cursor: pointer; }
|
||||
.solved-banner { padding: 8px 10px; border-radius: var(--radius-sm, 4px); background: rgba(34,197,94,0.12); color: #16a34a; }
|
||||
.awarded-banner { padding: 8px 10px; border-radius: var(--radius-sm, 4px); background: rgba(59,130,246,0.12); color: #60a5fa; }
|
||||
.modal-head {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
border-bottom: 1px solid #1a1a24; padding-bottom: 12px;
|
||||
}
|
||||
.modal-title-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.modal-title {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 2rem; font-weight: 900; font-style: italic;
|
||||
text-transform: uppercase; color: #ffffff; margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.modal-subhead {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-family: var(--font-mono); font-size: 0.85rem; font-weight: 800;
|
||||
}
|
||||
.sub-cat { color: var(--neon-purple); }
|
||||
.sub-diff-LOW { color: var(--neon-green); }
|
||||
.sub-diff-MEDIUM { color: var(--neon-yellow); }
|
||||
.sub-diff-HIGH { color: var(--neon-red); }
|
||||
.sub-pts { color: #ffffff; }
|
||||
|
||||
.close-btn {
|
||||
background: var(--neon-red); color: #000000; border: none;
|
||||
width: 32px; height: 32px; font-weight: bold; font-size: 1.1rem;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.close-btn:hover { background: #ffffff; color: var(--neon-red); }
|
||||
|
||||
.desc-box {
|
||||
background: #000000; border: 2px solid var(--neon-purple);
|
||||
padding: 16px; font-family: var(--font-mono); font-size: 0.9rem;
|
||||
line-height: 1.6; color: #e0d0ff;
|
||||
box-shadow: 0 0 10px var(--neon-purple-glow);
|
||||
}
|
||||
|
||||
.solved-banner {
|
||||
padding: 10px 14px; border: 2px solid var(--neon-green);
|
||||
background: rgba(0, 255, 102, 0.08); color: var(--neon-green);
|
||||
font-family: var(--font-mono); font-weight: bold; font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.awarded-banner {
|
||||
padding: 10px 14px; border: 2px solid var(--neon-cyan);
|
||||
background: rgba(0, 240, 255, 0.08); color: var(--neon-cyan);
|
||||
font-family: var(--font-mono); font-weight: bold; font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: var(--neon-yellow); font-family: var(--font-mono);
|
||||
font-size: 0.85rem; font-weight: bold;
|
||||
}
|
||||
|
||||
.form-group { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; }
|
||||
.flag-input {
|
||||
font-family: var(--font-mono); background: #000000; color: #ffffff;
|
||||
border: 2px solid var(--neon-purple); padding: 12px 14px;
|
||||
font-size: 1rem; outline: none; width: 100%;
|
||||
}
|
||||
.flag-input:focus {
|
||||
border-color: var(--neon-cyan); box-shadow: 0 0 12px var(--neon-cyan-glow);
|
||||
}
|
||||
.solve-btn {
|
||||
width: 100%; padding: 14px; font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--neon-red); font-family: var(--font-mono);
|
||||
font-weight: bold; font-size: 0.85rem; padding: 6px 0;
|
||||
}
|
||||
|
||||
.solvers-section {
|
||||
border-top: 1px solid #1a1a24; padding-top: 16px; margin-top: 8px;
|
||||
}
|
||||
.solvers-heading {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.1rem; font-weight: 900; font-style: italic;
|
||||
text-transform: uppercase; color: #ffffff; margin-bottom: 12px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.solvers-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.solver-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: #050508; border: 1px solid #1c1c28; padding: 8px 12px;
|
||||
font-family: var(--font-mono); font-size: 0.85rem;
|
||||
}
|
||||
.solver-left { display: flex; align-items: center; gap: 10px; }
|
||||
.rank-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; font-weight: 800; font-size: 0.75rem;
|
||||
background: #22222e; color: #ffffff;
|
||||
}
|
||||
.rank-1 { background: #ffb700; color: #000000; }
|
||||
.rank-2 { background: #c0c0c0; color: #000000; }
|
||||
.rank-3 { background: #cd7f32; color: #000000; }
|
||||
.solver-name { font-weight: 800; color: #ffffff; }
|
||||
.solver-right { display: flex; align-items: center; gap: 12px; }
|
||||
.pts-badge {
|
||||
background: var(--neon-purple); color: #000000;
|
||||
font-weight: 800; font-size: 0.75rem; padding: 2px 8px;
|
||||
}
|
||||
.solver-time { color: #666677; font-size: 0.75rem; }
|
||||
`],
|
||||
template: `
|
||||
<div class="backdrop" (click)="onBackdrop($event)">
|
||||
<div class="modal" role="dialog" aria-modal="true" [attr.data-testid]="'challenge-modal-' + (card?.id ?? '')">
|
||||
<div class="header" *ngIf="card">
|
||||
<h3>{{ card.name }}</h3>
|
||||
<span class="pill">{{ card.categoryAbbreviation }}</span>
|
||||
<span class="pill" [class]="'pill diff-' + card.difficulty">{{ card.difficulty }}</span>
|
||||
<span class="pill" data-testid="modal-points">{{ card.livePoints }} pts</span>
|
||||
<button type="button" class="close" (click)="close.emit()" aria-label="Close">Close</button>
|
||||
<div class="modal-head" *ngIf="card">
|
||||
<div class="modal-title-group">
|
||||
<h3 class="modal-title">{{ card.name }}</h3>
|
||||
<div class="modal-subhead">
|
||||
<span class="sub-cat">{{ card.categoryAbbreviation }}</span>
|
||||
<span>|</span>
|
||||
<span [class]="'sub-diff-' + card.difficulty">{{ card.difficulty }}</span>
|
||||
<span>|</span>
|
||||
<span class="sub-pts" data-testid="modal-points">{{ card.livePoints }} PTS</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="close-btn" (click)="close.emit()" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="solved-banner" *ngIf="card?.solvedByMe" data-testid="modal-solved-banner">
|
||||
You solved this challenge.
|
||||
✓ YOU SOLVED THIS CHALLENGE!
|
||||
</div>
|
||||
|
||||
<div class="awarded-banner" *ngIf="awarded() as a" data-testid="modal-awarded-banner">
|
||||
Awarded {{ a.awardedPoints }} pts
|
||||
<span *ngIf="a.rankBonus > 0">(base {{ a.basePoints }} + rank bonus {{ a.rankBonus }})</span>
|
||||
AWARDED {{ a.awardedPoints }} PTS
|
||||
<span *ngIf="a.rankBonus > 0">(BASE {{ a.basePoints }} + RANK BONUS {{ a.rankBonus }})</span>
|
||||
</div>
|
||||
|
||||
<div class="notice" *ngIf="notice() as n">{{ n }}</div>
|
||||
|
||||
<div class="body" [innerHTML]="renderedDescription()"></div>
|
||||
<div class="desc-box" [innerHTML]="renderedDescription()"></div>
|
||||
|
||||
<form class="form" (ngSubmit)="onSubmit()" *ngIf="card">
|
||||
<form class="form-group" (ngSubmit)="onSubmit()" *ngIf="card">
|
||||
<input
|
||||
type="text"
|
||||
class="flag-input"
|
||||
[(ngModel)]="flagValue"
|
||||
name="flag"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="flag{...}"
|
||||
placeholder="{flag: insert_the_flag}"
|
||||
[disabled]="submitting() || !canSubmit"
|
||||
data-testid="flag-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="hxp-btn solve-btn"
|
||||
[disabled]="submitting() || !canSubmit"
|
||||
data-testid="solve-button"
|
||||
>Solve</button>
|
||||
>
|
||||
{{ submitting() ? 'SUBMITTING...' : 'SOLVE' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="error" *ngIf="errorMessage() as e" data-testid="modal-error">{{ e }}</div>
|
||||
|
||||
<h4>Solvers ({{ solvers().length }})</h4>
|
||||
<ul class="solvers" data-testid="modal-solvers">
|
||||
<li *ngFor="let s of solvers(); trackBy: trackBySolver">
|
||||
<span [class]="rankClass(s.position)">#{{ s.position }}</span>
|
||||
<span>{{ s.playerName || ('Player ' + s.playerId.slice(0, 6)) }}</span>
|
||||
<span>{{ formatLocal(s.solvedAtUtc) }}</span>
|
||||
<span class="points">{{ s.awardedPoints }} pts</span>
|
||||
</li>
|
||||
<li *ngIf="!solvers().length"><em>No solvers yet.</em></li>
|
||||
</ul>
|
||||
<div class="solvers-section">
|
||||
<h4 class="solvers-heading"><span>✨</span> SOLVES ({{ solvers().length }})</h4>
|
||||
<ul class="solvers-list" data-testid="modal-solvers">
|
||||
<li class="solver-item" *ngFor="let s of solvers(); trackBy: trackBySolver">
|
||||
<div class="solver-left">
|
||||
<span class="rank-badge" [class.rank-1]="s.position===1" [class.rank-2]="s.position===2" [class.rank-3]="s.position===3">
|
||||
{{ s.position }}
|
||||
</span>
|
||||
<span class="solver-name">{{ s.playerName || ('Player ' + s.playerId.slice(0, 6)) }}</span>
|
||||
</div>
|
||||
<div class="solver-right">
|
||||
<span class="pts-badge">{{ s.awardedPoints }} PTS</span>
|
||||
<span class="solver-time">{{ formatLocal(s.solvedAtUtc) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="solver-item" *ngIf="!solvers().length">
|
||||
<em style="color:#666677">NO SOLVES YET. BE THE FIRST!</em>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -208,13 +312,6 @@ export class ChallengeModalComponent implements OnChanges {
|
||||
return formatUtcToLocal(utc);
|
||||
}
|
||||
|
||||
rankClass(position: number): string {
|
||||
if (position === 1) return 'rank-1';
|
||||
if (position === 2) return 'rank-2';
|
||||
if (position === 3) return 'rank-3';
|
||||
return 'rank-other';
|
||||
}
|
||||
|
||||
trackBySolver(_idx: number, s: SolverRow): string {
|
||||
return `${s.playerId}-${s.solvedAtUtc}`;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ const TABS: ShellTab[] = [
|
||||
<div class="container">
|
||||
<app-shell-header
|
||||
[pageTitle]="bootstrap.payload()?.pageTitle ?? 'HIPCTF'"
|
||||
[logoUrl]="bootstrap.payload()?.logo ?? ''"
|
||||
[activeSection]="activeSection()"
|
||||
[eventState]="eventStatus.state()"
|
||||
[countdownText]="eventStatus.countdownText()"
|
||||
|
||||
@@ -1,190 +1,225 @@
|
||||
.landing {
|
||||
max-width: 720px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
padding: 3rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.landing-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-logo {
|
||||
max-height: 120px;
|
||||
max-height: 140px;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.landing-sticker-wrapper {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.landing-title {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 15px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.landing-welcome {
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--color-surface, #eee);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: var(--color-surface, #fafafa);
|
||||
padding: 1.75rem 2rem;
|
||||
background: #000000;
|
||||
max-width: 680px;
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 20px rgba(255, 0, 51, 0.25);
|
||||
}
|
||||
|
||||
.landing-login-btn {
|
||||
align-self: center;
|
||||
.landing-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.landing-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.landing-actions .hxp-btn {
|
||||
font-size: 1.25rem;
|
||||
padding: 12px 36px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.landing-blog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.landing-blog-item {
|
||||
border-top: 1px solid var(--color-surface, #eee);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.landing-blog-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.landing-blog-date {
|
||||
display: block;
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.landing-blog-body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.landing-blog app-blog-presenter .blog-item {
|
||||
border-top: 1px solid var(--color-surface, #eee);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.landing-blog app-blog-presenter .blog-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.landing-blog app-blog-presenter .blog-date {
|
||||
display: block;
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.landing-blog app-blog-presenter .blog-body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.landing-blog-empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #888);
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
z-index: 1200;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text, #222);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
width: min(420px, 92vw);
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
padding: 2.5rem 2rem;
|
||||
width: min(440px, 94vw);
|
||||
position: relative;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.9), 0 0 20px rgba(255, 0, 51, 0.3);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.75rem;
|
||||
background: transparent;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: var(--neon-red);
|
||||
color: #000000;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #ffffff;
|
||||
color: var(--neon-red);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 2.25rem;
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffffff;
|
||||
margin-bottom: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
color: #888899;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-surface, #ccc);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-family: var(--font-mono);
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
border: 2px solid var(--neon-purple);
|
||||
padding: 12px 14px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 10px var(--neon-cyan-glow);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--color-danger, #c00);
|
||||
color: var(--neon-red);
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.server-error {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: rgba(204, 0, 0, 0.08);
|
||||
color: var(--color-danger, #c00);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--neon-red);
|
||||
background: rgba(255, 0, 51, 0.1);
|
||||
color: var(--neon-red);
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-error-warn {
|
||||
background: rgba(204, 153, 0, 0.12);
|
||||
color: var(--color-warning, #b80);
|
||||
border-color: var(--neon-yellow);
|
||||
background: rgba(255, 204, 0, 0.1);
|
||||
color: var(--neon-yellow);
|
||||
}
|
||||
|
||||
.server-error-hint {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.primary {
|
||||
.modal-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-primary, #06c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md, 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary[disabled] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
margin-top: 1rem;
|
||||
padding: 14px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-link-row {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
color: #888899;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-primary, #06c);
|
||||
color: var(--neon-purple);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
padding: 0 0 0 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
@@ -2,20 +2,54 @@
|
||||
<header class="landing-header">
|
||||
@if (logo()) {
|
||||
<img class="landing-logo" [src]="logo()" alt="" data-testid="landing-logo" />
|
||||
} @else {
|
||||
<div class="landing-sticker-wrapper">
|
||||
<app-sticker-logo [size]="140" />
|
||||
</div>
|
||||
}
|
||||
<h1 class="landing-title" data-testid="landing-title">{{ pageTitle() }}</h1>
|
||||
</header>
|
||||
|
||||
<article class="landing-welcome" [innerHTML]="welcomeHtml()" data-testid="landing-welcome"></article>
|
||||
@if (welcomeHtml()) {
|
||||
<article class="landing-welcome hxp-border" [innerHTML]="welcomeHtml()" data-testid="landing-welcome"></article>
|
||||
} @else {
|
||||
<article class="landing-welcome hxp-border" data-testid="landing-welcome">
|
||||
<p>
|
||||
HIP HIP HOORAY! DIVE INTO CHALLENGES RANGING FROM WEB, PWN, AND REVERSE ENGINEERING DESIGNED FOR ALL SKILL LEVELS. YOU CAN ALSO GRAB AN ARDUINO AND A LOGIC ANALYZER AT THE INFODESK AND TEST YOUR SKILLS ON THE PHYSICAL LAYER. 💀💀💀
|
||||
</p>
|
||||
</article>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="landing-login-btn"
|
||||
(click)="openLogin()"
|
||||
data-testid="landing-open-login"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<div class="landing-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="hxp-btn hxp-btn-secondary"
|
||||
routerLink="/blog"
|
||||
data-testid="landing-open-blog"
|
||||
>
|
||||
📰 BLOG
|
||||
</button>
|
||||
<div class="landing-action-row">
|
||||
@if (registrationsEnabled()) {
|
||||
<button
|
||||
type="button"
|
||||
class="hxp-btn"
|
||||
(click)="openRegister()"
|
||||
data-testid="landing-open-register"
|
||||
>
|
||||
REGISTER
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="hxp-btn hxp-btn-secondary"
|
||||
(click)="openLogin()"
|
||||
data-testid="landing-open-login"
|
||||
>
|
||||
LOGIN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (landing.posts().length > 0) {
|
||||
<section class="landing-blog" data-testid="landing-blog">
|
||||
@@ -23,8 +57,6 @@
|
||||
<app-blog-presenter [post]="post" />
|
||||
}
|
||||
</section>
|
||||
} @else if (!landing.loading()) {
|
||||
<p class="landing-blog-empty">No announcements yet.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -35,7 +67,7 @@
|
||||
data-testid="landing-modal-overlay"
|
||||
>
|
||||
<div
|
||||
class="modal"
|
||||
class="modal hxp-border"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="mode() === 'login' ? 'login-title' : 'register-title'"
|
||||
@@ -47,29 +79,32 @@
|
||||
(click)="closeModal()"
|
||||
aria-label="Close"
|
||||
data-testid="landing-modal-close"
|
||||
>×</button>
|
||||
>✕</button>
|
||||
|
||||
@if (mode() === 'login') {
|
||||
<h2 id="login-title">Sign in</h2>
|
||||
<h2 id="login-title" class="modal-title">LOGIN</h2>
|
||||
<form [formGroup]="loginForm" (ngSubmit)="submitLogin()" novalidate>
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<span class="field-label">PLAYER</span>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
formControlName="username"
|
||||
placeholder="PLAYER NAME"
|
||||
data-testid="login-username"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<span class="field-label">PASSWORD</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
formControlName="password"
|
||||
placeholder="PASSWORD"
|
||||
data-testid="login-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (loginError()) {
|
||||
<small class="field-error" data-testid="login-field-error">{{ loginError() }}</small>
|
||||
}
|
||||
@@ -88,19 +123,21 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
class="hxp-btn modal-submit"
|
||||
[disabled]="submitting()"
|
||||
data-testid="login-submit"
|
||||
>
|
||||
{{ submitting() ? 'Signing in...' : 'Login' }}
|
||||
{{ submitting() ? 'SIGNING IN...' : 'LOGIN' }}
|
||||
</button>
|
||||
|
||||
@if (registrationsEnabled()) {
|
||||
<p class="modal-link-row" data-testid="login-switch">
|
||||
No account?
|
||||
NEW PLAYER?
|
||||
<button type="button" class="link" (click)="switchToRegister()" data-testid="login-switch-btn">
|
||||
Register here
|
||||
REGISTER
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
@@ -108,35 +145,39 @@
|
||||
}
|
||||
|
||||
@if (mode() === 'register') {
|
||||
<h2 id="register-title">Create your account</h2>
|
||||
<h2 id="register-title" class="modal-title">REGISTER</h2>
|
||||
<form [formGroup]="registerForm" (ngSubmit)="submitRegister()" novalidate>
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<span class="field-label">PLAYER NAME</span>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
formControlName="username"
|
||||
placeholder="DESIRED NAME"
|
||||
data-testid="register-username"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<span class="field-label">PASSWORD</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="password"
|
||||
placeholder="PASSWORD"
|
||||
data-testid="register-password"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Confirm password</span>
|
||||
<span class="field-label">CONFIRM PASSWORD</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="passwordConfirm"
|
||||
placeholder="CONFIRM PASSWORD"
|
||||
data-testid="register-confirm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (registerError()) {
|
||||
<small class="field-error" data-testid="register-field-error">{{ registerError() }}</small>
|
||||
}
|
||||
@@ -155,18 +196,20 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
class="hxp-btn modal-submit"
|
||||
[disabled]="submitting()"
|
||||
data-testid="register-submit"
|
||||
>
|
||||
{{ submitting() ? 'Registering...' : 'Register' }}
|
||||
{{ submitting() ? 'REGISTERING...' : 'REGISTER' }}
|
||||
</button>
|
||||
|
||||
<p class="modal-link-row" data-testid="register-switch">
|
||||
Already have an account?
|
||||
PLAYER ALREADY?
|
||||
<button type="button" class="link" (click)="switchToLogin()" data-testid="register-switch-btn">
|
||||
Login here
|
||||
LOGIN
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||
import { MarkdownService } from '../../core/services/markdown.service';
|
||||
@@ -17,6 +17,7 @@ import { LandingService } from './landing.service';
|
||||
import { buildLoginFailureMessage } from './login-modal.service';
|
||||
import { passwordMatchValidator } from '../setup/setup-create-admin.validators';
|
||||
import { BlogPresenterComponent } from '../blog/blog-presenter.component';
|
||||
import { StickerLogoComponent } from '../../shared/components/sticker-logo/sticker-logo.component';
|
||||
|
||||
type ModalMode = 'closed' | 'login' | 'register';
|
||||
|
||||
@@ -24,7 +25,13 @@ type ModalMode = 'closed' | 'login' | 'register';
|
||||
selector: 'app-landing',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule, BlogPresenterComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
BlogPresenterComponent,
|
||||
StickerLogoComponent,
|
||||
],
|
||||
templateUrl: './landing.component.html',
|
||||
styleUrls: ['./landing.component.css'],
|
||||
})
|
||||
@@ -42,7 +49,7 @@ export class LandingComponent implements OnInit {
|
||||
readonly errorCode = signal<string | null>(null);
|
||||
readonly retryAfterSeconds = signal<number | null>(null);
|
||||
|
||||
readonly pageTitle = computed(() => this.bootstrap.payload()?.pageTitle ?? 'HIPCTF');
|
||||
readonly pageTitle = computed(() => this.bootstrap.payload()?.pageTitle ?? 'HIP7CTF TEST PLAYGROUND');
|
||||
readonly logo = computed(() => this.bootstrap.payload()?.logo ?? '');
|
||||
readonly welcomeHtml = computed(() => this.markdown.render(this.bootstrap.payload()?.welcomeMarkdown ?? ''));
|
||||
readonly registrationsEnabled = computed(
|
||||
|
||||
@@ -8,44 +8,64 @@ import { EventLogRow, formatSolveDateTime } from './scoreboard.pure';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.empty { padding: 32px; text-align: center; opacity: 0.7; }
|
||||
:host { display: block; max-width: 840px; margin: 0 auto; }
|
||||
.empty { padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono); }
|
||||
ol {
|
||||
list-style: none; padding: 0; margin: 0;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
max-height: 70vh; overflow: auto;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
max-height: 75vh; overflow-y: auto;
|
||||
}
|
||||
li {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px 10px;
|
||||
background: var(--color-surface, #1c1f26);
|
||||
border: 1px solid var(--color-border, #2a2f3a);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 13px;
|
||||
padding: 12px 16px;
|
||||
background: #000000;
|
||||
border: 2px solid #1a1a24;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
color: #ffffff;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
li:hover {
|
||||
border-color: var(--neon-purple);
|
||||
box-shadow: 0 0 10px var(--neon-purple-glow);
|
||||
}
|
||||
.icon { font-size: 1.1rem; min-width: 24px; text-align: center; }
|
||||
.icon-gold { color: #ffb700; }
|
||||
.icon-silver { color: #c0c0c0; }
|
||||
.icon-bronze { color: #cd7f32; }
|
||||
.icon-check { color: var(--neon-green); }
|
||||
.time { color: #777788; font-size: 0.8rem; }
|
||||
.who {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
.action { color: #aaaaee; }
|
||||
.cat { color: var(--neon-purple); font-weight: bold; }
|
||||
.challenge { color: var(--neon-green); font-weight: bold; }
|
||||
.pts-box {
|
||||
margin-left: auto;
|
||||
border: 2px solid var(--neon-purple);
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.icon { font-size: 18px; min-width: 22px; text-align: center; }
|
||||
.icon-gold { color: #d4a017; }
|
||||
.icon-silver { color: #9aa3b0; }
|
||||
.icon-bronze { color: #b87333; }
|
||||
.icon-check { color: var(--color-success, #22c55e); }
|
||||
.time { opacity: 0.7; font-variant-numeric: tabular-nums; min-width: 170px; }
|
||||
.who { font-weight: 600; min-width: 120px; }
|
||||
.what { flex: 1; min-width: 0; }
|
||||
.abbrev { opacity: 0.65; margin-right: 6px; font-size: 11px; }
|
||||
.pts { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
`],
|
||||
template: `
|
||||
<ng-container *ngIf="rows?.length; else empty">
|
||||
<ol data-testid="scoreboard-event-log">
|
||||
<li *ngFor="let r of rows; trackBy: trackBySolve" [attr.data-testid]="'event-log-row-' + r.solveId">
|
||||
<span [class]="iconClass(r)" class="icon" aria-hidden="true">{{ icon(r) }}</span>
|
||||
<span class="time">{{ format(r.awardedAtUtc) }}</span>
|
||||
<span class="time">[{{ format(r.awardedAtUtc) }}]</span>
|
||||
<span class="who">{{ r.playerName }}</span>
|
||||
<span class="what">
|
||||
<span class="abbrev" *ngIf="r.categoryAbbreviation">{{ r.categoryAbbreviation }}</span>
|
||||
{{ r.challengeName }}
|
||||
</span>
|
||||
<span class="pts">+{{ r.awardedPoints }}</span>
|
||||
<span class="action">solved</span>
|
||||
<span class="cat" *ngIf="r.categoryAbbreviation">{{ r.categoryAbbreviation }} -</span>
|
||||
<span class="challenge">{{ r.challengeName }}</span>
|
||||
<div class="pts-box">{{ r.awardedPoints }} points</div>
|
||||
</li>
|
||||
</ol>
|
||||
</ng-container>
|
||||
@@ -60,9 +80,9 @@ export class EventLogComponent {
|
||||
format = formatSolveDateTime;
|
||||
|
||||
icon(r: EventLogRow): string {
|
||||
if (r.position === 1) return '★';
|
||||
if (r.position === 2) return '★';
|
||||
if (r.position === 3) return '★';
|
||||
if (r.position === 1) return '🏆';
|
||||
if (r.position === 2) return '🥈';
|
||||
if (r.position === 3) return '🥉';
|
||||
return '✓';
|
||||
}
|
||||
|
||||
|
||||
@@ -8,65 +8,93 @@ import { PLAYER_COLOR_PALETTE, MatrixView } from './scoreboard.pure';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.empty { padding: 32px; text-align: center; opacity: 0.7; }
|
||||
.scroll {
|
||||
:host { display: block; width: 100%; }
|
||||
.empty { padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono); }
|
||||
.matrix-container {
|
||||
background: #000000;
|
||||
border: 3px solid var(--neon-red);
|
||||
box-shadow: 0 0 20px rgba(255, 0, 51, 0.25);
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--color-border, #2a2f3a);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-surface, #1c1f26);
|
||||
max-height: 75vh;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
th, td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--color-border, #2a2f3a);
|
||||
border-right: 1px solid var(--color-border, #2a2f3a);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #1a1a24;
|
||||
border-right: 1px solid #1a1a24;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
min-width: 56px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th.sticky-x, td.sticky-x {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--color-surface, #1c1f26);
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
min-width: 160px;
|
||||
min-width: 48px;
|
||||
}
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface, #1c1f26);
|
||||
background: #0b0b10;
|
||||
z-index: 2;
|
||||
vertical-align: bottom;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 12px;
|
||||
height: 180px;
|
||||
}
|
||||
thead th.sticky-x { z-index: 3; }
|
||||
.abbrev { opacity: 0.65; margin-right: 6px; font-size: 11px; }
|
||||
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
|
||||
.cell-gold { color: #d4a017; }
|
||||
.cell-silver { color: #9aa3b0; }
|
||||
.cell-bronze { color: #b87333; }
|
||||
.cell-check { color: var(--color-success, #22c55e); }
|
||||
.th-vertical {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-weight: 800;
|
||||
color: var(--neon-green);
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
margin: 0 auto;
|
||||
max-height: 160px;
|
||||
}
|
||||
th.sticky-x, td.sticky-x {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: #050508;
|
||||
z-index: 5;
|
||||
text-align: left;
|
||||
min-width: 180px;
|
||||
font-weight: 800;
|
||||
}
|
||||
thead th.sticky-x {
|
||||
z-index: 10;
|
||||
vertical-align: middle;
|
||||
height: auto;
|
||||
writing-mode: horizontal-tb;
|
||||
transform: none;
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
color: #888899;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
||||
margin-right: 8px; vertical-align: middle;
|
||||
}
|
||||
.cell-gold { color: #ffb700; font-size: 14px; font-weight: bold; }
|
||||
.cell-silver { color: #c0c0c0; font-size: 14px; font-weight: bold; }
|
||||
.cell-bronze { color: #cd7f32; font-size: 14px; font-weight: bold; }
|
||||
.cell-check { color: var(--neon-green); font-size: 14px; font-weight: bold; }
|
||||
`],
|
||||
template: `
|
||||
<ng-container *ngIf="view && view.players.length; else empty">
|
||||
<div class="scroll" data-testid="scoreboard-matrix-scroll">
|
||||
<div class="matrix-container" data-testid="scoreboard-matrix-scroll">
|
||||
<table data-testid="scoreboard-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky-x" data-testid="matrix-player-header">Player</th>
|
||||
<th class="sticky-x" data-testid="matrix-player-header">PLAYER / CHALLENGE</th>
|
||||
<th
|
||||
*ngFor="let ch of view.challenges; trackBy: trackByCh"
|
||||
[attr.data-testid]="'matrix-col-' + ch.id"
|
||||
>
|
||||
<span class="abbrev" *ngIf="ch.categoryAbbreviation">{{ ch.categoryAbbreviation }}</span>
|
||||
<span>{{ ch.name }}</span>
|
||||
<div class="th-vertical">
|
||||
<span>{{ ch.name }}</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -84,11 +112,11 @@ import { PLAYER_COLOR_PALETTE, MatrixView } from './scoreboard.pure';
|
||||
[attr.data-testid]="'matrix-cell-' + p.playerId + '-' + ch.id"
|
||||
>
|
||||
<ng-container [ngSwitch]="view.cells[p.playerId]?.[ch.id]">
|
||||
<span *ngSwitchCase="1" class="cell-gold" title="1st solver">★</span>
|
||||
<span *ngSwitchCase="2" class="cell-silver" title="2nd solver">★</span>
|
||||
<span *ngSwitchCase="3" class="cell-bronze" title="3rd solver">★</span>
|
||||
<span *ngSwitchCase="1" class="cell-gold" title="1st solver">🏆</span>
|
||||
<span *ngSwitchCase="2" class="cell-silver" title="2nd solver">🥈</span>
|
||||
<span *ngSwitchCase="3" class="cell-bronze" title="3rd solver">🥉</span>
|
||||
<span *ngSwitchCase="'solved'" class="cell-check" title="solved">✓</span>
|
||||
<span *ngSwitchDefault></span>
|
||||
<span *ngSwitchDefault>·</span>
|
||||
</ng-container>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,53 +8,115 @@ import { PLAYER_COLOR_PALETTE, RankingRow } from './scoreboard.pure';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.empty { padding: 32px; text-align: center; opacity: 0.7; }
|
||||
:host { display: block; max-width: 860px; margin: 0 auto; }
|
||||
.empty { padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono); }
|
||||
.ranking-container {
|
||||
background: #000000;
|
||||
border: 3px solid var(--neon-red);
|
||||
box-shadow: 0 0 20px rgba(255, 0, 51, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--color-surface, #1c1f26);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 12px;
|
||||
thead th {
|
||||
background: #111118;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 2px solid var(--neon-red);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border, #2a2f3a);
|
||||
}
|
||||
th { font-weight: 600; opacity: 0.85; }
|
||||
.rank { width: 64px; }
|
||||
.points { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.solved { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; vertical-align: middle; }
|
||||
tbody tr {
|
||||
border-bottom: 1px solid #1a1a24;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: #0d0d14;
|
||||
}
|
||||
td {
|
||||
padding: 14px 20px;
|
||||
font-size: 1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
.col-rank { width: 90px; }
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-size: 1.1rem;
|
||||
background: #1a1a24;
|
||||
color: #ffffff;
|
||||
transform: skewX(-10deg);
|
||||
}
|
||||
.rank-1 { background: #ffb700; color: #000000; }
|
||||
.rank-2 { background: #c0c0c0; color: #000000; }
|
||||
.rank-3 { background: #cd7f32; color: #000000; }
|
||||
.col-player { font-weight: 800; font-style: italic; font-size: 1.1rem; }
|
||||
.swatch {
|
||||
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
||||
margin-right: 10px; vertical-align: middle;
|
||||
}
|
||||
.col-solved {
|
||||
text-align: center;
|
||||
color: var(--neon-purple);
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.col-points {
|
||||
text-align: right;
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<ng-container *ngIf="rows?.length; else empty">
|
||||
<table data-testid="scoreboard-ranking">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rank" aria-sort="none" data-testid="ranking-rank-header">Rank</th>
|
||||
<th>Player</th>
|
||||
<th class="solved" data-testid="ranking-solved-header">Solved</th>
|
||||
<th class="points" data-testid="ranking-points-header">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let row of rows; trackBy: trackById"
|
||||
[attr.data-testid]="'ranking-row-' + row.playerId"
|
||||
[attr.data-rank]="row.rank"
|
||||
>
|
||||
<td class="rank">{{ row.rank }}</td>
|
||||
<td>
|
||||
<span class="swatch" [style.background-color]="color(row.colorIndex)" aria-hidden="true"></span>
|
||||
{{ row.playerName }}
|
||||
</td>
|
||||
<td class="solved">{{ row.solvedCount }}</td>
|
||||
<td class="points">{{ row.points }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="ranking-container">
|
||||
<table data-testid="scoreboard-ranking">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-rank" aria-sort="none" data-testid="ranking-rank-header">RANK</th>
|
||||
<th class="col-player">PLAYER</th>
|
||||
<th class="col-solved" style="text-align:center;" data-testid="ranking-solved-header">SOLVES</th>
|
||||
<th class="col-points" style="text-align:right;" data-testid="ranking-points-header">POINTS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let row of rows; trackBy: trackById"
|
||||
[attr.data-testid]="'ranking-row-' + row.playerId"
|
||||
[attr.data-rank]="row.rank"
|
||||
>
|
||||
<td class="col-rank">
|
||||
<span class="rank-badge" [class.rank-1]="row.rank===1" [class.rank-2]="row.rank===2" [class.rank-3]="row.rank===3">
|
||||
{{ row.rank }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-player">
|
||||
<span class="swatch" [style.background-color]="color(row.colorIndex)" aria-hidden="true"></span>
|
||||
{{ row.playerName }}
|
||||
</td>
|
||||
<td class="col-solved">{{ row.solvedCount }}</td>
|
||||
<td class="col-points">{{ row.points }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #empty>
|
||||
<p class="empty" data-testid="ranking-empty">No players yet</p>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
projectGraphPoints,
|
||||
} from './scoreboard.pure';
|
||||
|
||||
interface Point { x: number; y: number; }
|
||||
interface RenderedSeries extends GraphSeries {
|
||||
pointsAttr: string;
|
||||
}
|
||||
@@ -20,22 +19,28 @@ interface RenderedSeries extends GraphSeries {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
:host { display: block; max-width: 880px; margin: 0 auto; }
|
||||
.empty, .not-started {
|
||||
padding: 32px; text-align: center; opacity: 0.7;
|
||||
padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono);
|
||||
}
|
||||
.legend {
|
||||
display: flex; flex-wrap: wrap; gap: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
display: flex; flex-wrap: wrap; justify-content: center; gap: 10px 16px;
|
||||
margin-top: 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px; font-weight: bold;
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; color: #ffffff; }
|
||||
.swatch { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
.frame {
|
||||
border: 1px solid var(--color-border, #2a2f3a);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-surface, #1c1f26);
|
||||
padding: 8px;
|
||||
border: 3px solid var(--neon-red);
|
||||
background: #000000;
|
||||
padding: 20px;
|
||||
box-shadow: 0 0 20px rgba(255, 0, 51, 0.25);
|
||||
}
|
||||
.graph-title {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 1.1rem; font-weight: 900; font-style: italic;
|
||||
text-transform: uppercase; color: #ffffff; margin-bottom: 16px;
|
||||
}
|
||||
svg { display: block; width: 100%; height: auto; }
|
||||
`],
|
||||
@@ -50,6 +55,32 @@ interface RenderedSeries extends GraphSeries {
|
||||
<ng-container *ngSwitchDefault>
|
||||
<ng-container *ngIf="renderedSeries().length; else emptySeries">
|
||||
<div class="frame" data-testid="score-graph">
|
||||
<h3 class="graph-title">TOP PLAYERS - SCORE PROGRESSION</h3>
|
||||
<svg
|
||||
[attr.viewBox]="'0 0 ' + width + ' ' + height"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
data-testid="score-graph-svg"
|
||||
>
|
||||
<g class="axes">
|
||||
<line [attr.x1]="padL" [attr.y1]="height - padB" [attr.x2]="width - padR" [attr.y2]="height - padB" stroke="#333344" stroke-width="2" />
|
||||
<line [attr.x1]="padL" [attr.y1]="padT" [attr.x2]="padL" [attr.y2]="height - padB" stroke="#333344" stroke-width="2" />
|
||||
<text [attr.x]="padL" [attr.y]="padT - 6" font-size="11" font-weight="bold" fill="#888899">Points</text>
|
||||
<text [attr.x]="width - padR" [attr.y]="height - 6" text-anchor="end" font-size="11" font-weight="bold" fill="#888899">Time</text>
|
||||
<text [attr.x]="padL - 6" [attr.y]="height - padB + 4" text-anchor="end" font-size="11" fill="#777788">0</text>
|
||||
<text [attr.x]="padL - 6" [attr.y]="padT + 4" text-anchor="end" font-size="11" fill="#ffffff" font-weight="bold">{{ maxValue() }}</text>
|
||||
</g>
|
||||
<polyline
|
||||
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
|
||||
[attr.points]="s.pointsAttr"
|
||||
fill="none"
|
||||
[attr.stroke]="color(s.colorIndex)"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
[attr.data-testid]="'score-graph-line-' + s.playerId"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="legend">
|
||||
<span
|
||||
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
|
||||
@@ -60,28 +91,6 @@ interface RenderedSeries extends GraphSeries {
|
||||
{{ s.playerName }}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
[attr.viewBox]="'0 0 ' + width + ' ' + height"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
data-testid="score-graph-svg"
|
||||
>
|
||||
<g class="axes">
|
||||
<line [attr.x1]="padL" [attr.y1]="height - padB" [attr.x2]="width - padR" [attr.y2]="height - padB" stroke="currentColor" stroke-opacity="0.3" />
|
||||
<line [attr.x1]="padL" [attr.y1]="padT" [attr.x2]="padL" [attr.y2]="height - padB" stroke="currentColor" stroke-opacity="0.3" />
|
||||
<text [attr.x]="padL" [attr.y]="padT - 4" font-size="10" fill="currentColor" fill-opacity="0.6">pts</text>
|
||||
<text [attr.x]="width - padR" [attr.y]="height - 4" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">time</text>
|
||||
<text [attr.x]="padL - 4" [attr.y]="height - padB" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">0</text>
|
||||
<text [attr.x]="padL - 4" [attr.y]="padT + 4" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.6">{{ maxValue() }}</text>
|
||||
</g>
|
||||
<polyline
|
||||
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
|
||||
[attr.points]="s.pointsAttr"
|
||||
fill="none"
|
||||
[attr.stroke]="color(s.colorIndex)"
|
||||
stroke-width="2"
|
||||
[attr.data-testid]="'score-graph-line-' + s.playerId"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #emptySeries>
|
||||
@@ -100,11 +109,11 @@ export class ScoreGraphComponent {
|
||||
}
|
||||
|
||||
readonly width = 800;
|
||||
readonly height = 320;
|
||||
readonly padL = 40;
|
||||
readonly padR = 16;
|
||||
readonly padT = 16;
|
||||
readonly padB = 28;
|
||||
readonly height = 340;
|
||||
readonly padL = 50;
|
||||
readonly padR = 20;
|
||||
readonly padT = 24;
|
||||
readonly padB = 30;
|
||||
|
||||
private readonly _view = signal<GraphView | null>(null);
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import { SCOREBOARD_TABS, ScoreboardTab } from './scoreboard.pure';
|
||||
interface TabSpec {
|
||||
id: ScoreboardTab;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
|
||||
ranking: 'Ranking',
|
||||
matrix: 'Matrix',
|
||||
'event-log': 'Event Log',
|
||||
graph: 'Score Graph',
|
||||
const TAB_SPECS: Readonly<Record<ScoreboardTab, { label: string; icon: string }>> = {
|
||||
ranking: { label: 'RANKINGS', icon: '🏆' },
|
||||
matrix: { label: 'MATRIX', icon: '▦' },
|
||||
'event-log': { label: 'EVENT LOG', icon: '≡' },
|
||||
graph: { label: 'SCORE GRAPH', icon: '📈' },
|
||||
};
|
||||
|
||||
@Component({
|
||||
@@ -22,25 +23,32 @@ const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
nav {
|
||||
display: flex; gap: 6px;
|
||||
border-bottom: 1px solid var(--color-border, #2a2f3a);
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 12px;
|
||||
overflow-x: auto;
|
||||
display: flex; gap: 10px; justify-content: center;
|
||||
margin-bottom: 24px; flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-surface, #1c1f26);
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2f3a);
|
||||
padding: 10px 20px;
|
||||
background: #000000;
|
||||
color: #aaaaee;
|
||||
border: 2px solid #22222e;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 800;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
button:hover:not(.active) {
|
||||
border-color: var(--neon-purple);
|
||||
color: #ffffff;
|
||||
}
|
||||
button.active {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: var(--color-primary-contrast, #fff);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
background: var(--neon-red);
|
||||
color: #000000;
|
||||
border-color: var(--neon-red);
|
||||
box-shadow: 0 0 15px rgba(255, 0, 51, 0.4);
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
@@ -51,7 +59,10 @@ const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
|
||||
[class.active]="t.id === active"
|
||||
[attr.data-testid]="'scoreboard-tab-' + t.id"
|
||||
(click)="select.emit(t.id)"
|
||||
>{{ t.label }}</button>
|
||||
>
|
||||
<span>{{ t.icon }}</span>
|
||||
<span>{{ t.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
@@ -59,5 +70,9 @@ export class ScoreboardTabsComponent {
|
||||
@Input({ required: true }) active!: ScoreboardTab;
|
||||
@Output() select = new EventEmitter<ScoreboardTab>();
|
||||
|
||||
readonly tabs: TabSpec[] = SCOREBOARD_TABS.map((id) => ({ id, label: TAB_LABELS[id] }));
|
||||
readonly tabs: TabSpec[] = SCOREBOARD_TABS.map((id) => ({
|
||||
id,
|
||||
label: TAB_SPECS[id].label,
|
||||
icon: TAB_SPECS[id].icon,
|
||||
}));
|
||||
}
|
||||
@@ -33,6 +33,68 @@ const DEFAULT_POLICY: PasswordPolicy = {
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
styles: [`
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1200;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal-card {
|
||||
background: #000000; color: #ffffff;
|
||||
border: 3px solid var(--neon-red);
|
||||
padding: 28px 24px;
|
||||
width: min(440px, 94vw); position: relative;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
||||
}
|
||||
h2 {
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-size: 2rem; font-weight: 900; font-style: italic;
|
||||
text-transform: uppercase; color: #ffffff; margin-bottom: 20px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.field {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
margin-bottom: 16px; text-align: left;
|
||||
}
|
||||
.field span {
|
||||
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 800;
|
||||
color: #888899; text-transform: uppercase; letter-spacing: 1px;
|
||||
}
|
||||
.field input {
|
||||
font-family: var(--font-mono); background-color: #000000; color: #ffffff;
|
||||
border: 2px solid var(--neon-purple); padding: 12px 14px;
|
||||
font-size: 0.95rem; outline: none; width: 100%;
|
||||
}
|
||||
.field input:focus {
|
||||
border-color: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan-glow);
|
||||
}
|
||||
.error {
|
||||
color: var(--neon-red); font-family: var(--font-mono); font-size: 0.85rem; font-weight: bold;
|
||||
}
|
||||
.hint {
|
||||
color: #777788; font-family: var(--font-mono); font-size: 0.75rem; margin-bottom: 20px;
|
||||
}
|
||||
.actions {
|
||||
display: flex; justify-content: flex-end; gap: 12px; margin-top: 10px;
|
||||
}
|
||||
.actions button {
|
||||
font-family: var(--font-mono); font-weight: 800; text-transform: uppercase;
|
||||
padding: 10px 20px; font-size: 0.9rem; cursor: pointer;
|
||||
}
|
||||
.btn-cancel {
|
||||
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
||||
}
|
||||
.btn-cancel:hover:not([disabled]) {
|
||||
background: var(--neon-purple); color: #000000;
|
||||
}
|
||||
.btn-ok {
|
||||
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
||||
}
|
||||
.btn-ok:hover:not([disabled]) {
|
||||
background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4);
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-overlay" data-testid="change-password-overlay" (click)="onCancel()">
|
||||
@@ -44,27 +106,29 @@ const DEFAULT_POLICY: PasswordPolicy = {
|
||||
data-testid="change-password-modal"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<h2 id="cp-title">Change Password</h2>
|
||||
<h2 id="cp-title">CHANGE PASSWORD</h2>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
@if (mode() === 'self') {
|
||||
<label class="field">
|
||||
<span>Old password</span>
|
||||
<span>OLD PASSWORD</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
formControlName="oldPassword"
|
||||
placeholder="CURRENT PASSWORD"
|
||||
data-testid="cp-old"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
|
||||
<label class="field">
|
||||
<span>New password</span>
|
||||
<span>NEW PASSWORD</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="newPassword"
|
||||
placeholder="NEW PASSWORD"
|
||||
data-testid="cp-new"
|
||||
/>
|
||||
@if (fieldErrorFor('newPassword'); as msg) {
|
||||
@@ -73,11 +137,12 @@ const DEFAULT_POLICY: PasswordPolicy = {
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Confirm new password</span>
|
||||
<span>CONFIRM NEW PASSWORD</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="confirmNewPassword"
|
||||
placeholder="CONFIRM NEW PASSWORD"
|
||||
data-testid="cp-confirm"
|
||||
/>
|
||||
@if (fieldErrorFor('confirmPassword'); as msg) {
|
||||
@@ -99,18 +164,20 @@ const DEFAULT_POLICY: PasswordPolicy = {
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
data-testid="cp-cancel"
|
||||
(click)="onCancel()"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
Cancel
|
||||
CANCEL
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-ok"
|
||||
data-testid="cp-ok"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
OK
|
||||
{{ submitting() ? 'SAVING...' : 'OK' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,13 +1,92 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { EventState, ledBackgroundColor } from '../../../core/services/event-status.store';
|
||||
import { StickerLogoComponent } from '../../../shared/components/sticker-logo/sticker-logo.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell-header',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, StickerLogoComponent],
|
||||
styles: [`
|
||||
.shell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
padding: 0 24px;
|
||||
background-color: rgba(5, 5, 5, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 2px solid #22222e;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.shell-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shell-logo {
|
||||
height: 36px;
|
||||
max-width: 120px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.shell-title {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.shell-title:hover {
|
||||
color: var(--neon-red);
|
||||
}
|
||||
|
||||
.shell-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-size: 1.35rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.shell-section-icon {
|
||||
color: var(--neon-purple);
|
||||
}
|
||||
|
||||
.shell-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.led-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.led {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
@@ -15,35 +94,164 @@ import { EventState, ledBackgroundColor } from '../../../core/services/event-sta
|
||||
border-radius: 50%;
|
||||
border: 0;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: var(--neon-cyan);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.user-menu-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
border: 2px solid var(--neon-purple);
|
||||
padding: 6px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover {
|
||||
border-color: var(--neon-cyan);
|
||||
box-shadow: 0 0 10px var(--neon-purple-glow);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
width: 240px;
|
||||
background-color: #000000;
|
||||
border: 2px solid var(--neon-purple);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.9), 0 0 15px var(--neon-purple-glow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1050;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-menu-header {
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #22222e;
|
||||
}
|
||||
|
||||
.user-menu-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
color: #777788;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.user-menu-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
margin: 2px 0 6px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.user-menu-score-pill {
|
||||
display: inline-block;
|
||||
background-color: var(--neon-purple);
|
||||
color: #000000;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 800;
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 8px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ddddee;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background-color: #151520;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.user-menu-item-admin {
|
||||
color: var(--neon-red);
|
||||
}
|
||||
|
||||
.user-menu-item-admin:hover {
|
||||
background-color: #1a0008;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.user-menu-divider {
|
||||
height: 1px;
|
||||
background-color: #22222e;
|
||||
margin: 4px 0;
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<header class="shell-header" data-testid="shell-header">
|
||||
<button
|
||||
type="button"
|
||||
class="shell-title"
|
||||
data-testid="shell-title"
|
||||
(click)="titleClick.emit()"
|
||||
>
|
||||
{{ pageTitle() }}
|
||||
</button>
|
||||
<div class="shell-left" (click)="titleClick.emit()">
|
||||
@if (logoUrl()) {
|
||||
<img class="shell-logo" [src]="logoUrl()" alt="Site Logo" data-testid="shell-logo" />
|
||||
} @else {
|
||||
<app-sticker-logo [size]="32" />
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="shell-title"
|
||||
data-testid="shell-title"
|
||||
>
|
||||
{{ pageTitle() }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="shell-section" data-testid="shell-active-section">
|
||||
{{ activeSection() }}
|
||||
<span class="shell-section-icon">{{ sectionIcon() }}</span>
|
||||
<span>{{ activeSection() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="shell-right">
|
||||
<span
|
||||
class="led"
|
||||
[class.led-running]="eventState() === 'running'"
|
||||
[class.led-countdown]="eventState() === 'countdown'"
|
||||
[class.led-stopped]="eventState() === 'stopped'"
|
||||
[class.led-unconfigured]="eventState() === 'unconfigured'"
|
||||
[style.background-color]="ledColor()"
|
||||
data-testid="shell-led"
|
||||
[attr.aria-label]="'Event status: ' + eventState()"
|
||||
></span>
|
||||
<span class="countdown" data-testid="shell-countdown">{{ countdownText() }}</span>
|
||||
<div class="led-wrapper">
|
||||
<span
|
||||
class="led"
|
||||
[class.led-running]="eventState() === 'running'"
|
||||
[class.led-countdown]="eventState() === 'countdown'"
|
||||
[class.led-stopped]="eventState() === 'stopped'"
|
||||
[class.led-unconfigured]="eventState() === 'unconfigured'"
|
||||
[style.background-color]="ledColor()"
|
||||
data-testid="shell-led"
|
||||
[attr.aria-label]="'Event status: ' + eventState()"
|
||||
></span>
|
||||
<span class="countdown" data-testid="shell-countdown">{{ countdownText() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="user-menu-wrapper">
|
||||
<button
|
||||
@@ -52,44 +260,54 @@ import { EventState, ledBackgroundColor } from '../../../core/services/event-sta
|
||||
data-testid="user-menu-trigger"
|
||||
(click)="usernameMenuToggle.emit()"
|
||||
>
|
||||
{{ username() }} <span aria-hidden="true">▾</span>
|
||||
<span>{{ username() }}</span>
|
||||
<span aria-hidden="true">▾</span>
|
||||
</button>
|
||||
|
||||
@if (userMenuOpen()) {
|
||||
<div class="user-menu" role="menu" data-testid="user-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-rank"
|
||||
(click)="rankClick.emit()"
|
||||
>
|
||||
{{ rankText() }}
|
||||
</button>
|
||||
<div class="user-menu-header">
|
||||
<div class="user-menu-label">PLAYER</div>
|
||||
<div class="user-menu-name">{{ username() }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-score-pill"
|
||||
data-testid="user-menu-rank"
|
||||
(click)="rankClick.emit()"
|
||||
>
|
||||
{{ rankText() }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (canAccessAdmin()) {
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item user-menu-item-admin"
|
||||
data-testid="user-menu-admin"
|
||||
(click)="adminClick.emit()"
|
||||
>
|
||||
<span>⚙️</span> ADMIN PANEL
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-change-password"
|
||||
(click)="changePasswordClick.emit()"
|
||||
>
|
||||
Change password
|
||||
<span>🔑</span> CHANGE PASSWORD
|
||||
</button>
|
||||
@if (canAccessAdmin()) {
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-admin"
|
||||
(click)="adminClick.emit()"
|
||||
>
|
||||
Admin area
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="user-menu-divider"></div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-logout"
|
||||
(click)="logoutClick.emit()"
|
||||
>
|
||||
Logout
|
||||
<span>↳</span> LOGOUT
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -100,6 +318,7 @@ import { EventState, ledBackgroundColor } from '../../../core/services/event-sta
|
||||
})
|
||||
export class ShellHeaderComponent {
|
||||
readonly pageTitle = input<string>('HIPCTF');
|
||||
readonly logoUrl = input<string>('');
|
||||
readonly activeSection = input<string>('Challenges');
|
||||
readonly eventState = input<EventState>('unconfigured');
|
||||
readonly countdownText = input<string>('');
|
||||
@@ -116,4 +335,13 @@ export class ShellHeaderComponent {
|
||||
readonly logoutClick = output<void>();
|
||||
|
||||
readonly ledColor = computed(() => ledBackgroundColor(this.eventState()));
|
||||
|
||||
readonly sectionIcon = computed(() => {
|
||||
const sec = (this.activeSection() || '').toLowerCase();
|
||||
if (sec.includes('challenge')) return '🚩';
|
||||
if (sec.includes('score') || sec.includes('rank') || sec.includes('matrix')) return '🏆';
|
||||
if (sec.includes('blog')) return '📰';
|
||||
if (sec.includes('admin')) return '⚙️';
|
||||
return '>_';
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user