UI-Improvements

This commit is contained in:
m0rph3us1987
2026-07-24 12:58:34 +02:00
parent 143ed3c538
commit b99577e76a
40 changed files with 2202 additions and 821 deletions
+1
View File
@@ -1,4 +1,5 @@
export const THEME_IDS = [ export const THEME_IDS = [
'hip',
'classic', 'classic',
'midnight', 'midnight',
'sunset', 'sunset',
@@ -11,7 +11,7 @@ import { SETTINGS_KEYS } from '../../config/env.schema';
export class ThemeLoaderService implements OnModuleInit { export class ThemeLoaderService implements OnModuleInit {
private readonly logger = new Logger(ThemeLoaderService.name); private readonly logger = new Logger(ThemeLoaderService.name);
private themes: Map<string, Theme> = new Map(); private themes: Map<string, Theme> = new Map();
private defaultThemeId: ThemeId = 'classic'; private defaultThemeId: ThemeId = 'hip';
private themesDir = './themes'; private themesDir = './themes';
constructor( constructor(
@@ -54,11 +54,6 @@ export class ThemeLoaderService implements OnModuleInit {
} }
} }
/**
* After disk-loading, backfill any of the 10 canonical themes that are
* missing with the matching built-in. This guarantees the service
* ALWAYS exposes all 10 theme ids regardless of disk state.
*/
private backfillMissing(): void { private backfillMissing(): void {
for (const t of BUILTIN_THEMES) { for (const t of BUILTIN_THEMES) {
if (!this.themes.has(t.id)) { if (!this.themes.has(t.id)) {
@@ -68,29 +63,21 @@ export class ThemeLoaderService implements OnModuleInit {
} }
} }
/**
* Validate the configured theme key (from settings) against the 10
* canonical ids. If it's missing or invalid, fall back to 'classic' with
* a warning. Persists the canonical value back to settings so future
* reads are consistent.
*/
private async validateConfiguredKey(): Promise<void> { private async validateConfiguredKey(): Promise<void> {
let configured = 'classic'; let configured = 'hip';
try { try {
configured = await this.settings.get(SETTINGS_KEYS.THEME_KEY, 'classic'); configured = await this.settings.get(SETTINGS_KEYS.THEME_KEY, 'hip');
} catch (e) { } catch (e) {
// The setting table may not exist yet (early boot, migrations not this.logger.warn(`Could not read configured themeKey (${(e as Error).message}); falling back to 'hip'`);
// run). Fall back to the safe default and try again later. this.defaultThemeId = 'hip';
this.logger.warn(`Could not read configured themeKey (${(e as Error).message}); falling back to 'classic'`);
this.defaultThemeId = 'classic';
return; return;
} }
const validIds = new Set<string>(THEME_IDS); const validIds = new Set<string>(THEME_IDS);
if (!configured || !validIds.has(configured)) { if (!configured || !validIds.has(configured)) {
this.logger.warn(`Configured themeKey '${configured}' is not in [${THEME_IDS.join(',')}]; falling back to 'classic'`); this.logger.warn(`Configured themeKey '${configured}' is not in [${THEME_IDS.join(',')}]; falling back to 'hip'`);
this.defaultThemeId = 'classic'; this.defaultThemeId = 'hip';
try { try {
await this.settings.set(SETTINGS_KEYS.THEME_KEY, 'classic'); await this.settings.set(SETTINGS_KEYS.THEME_KEY, 'hip');
} catch { } catch {
// Setting table may still not exist; ignore. // Setting table may still not exist; ignore.
} }
@@ -125,13 +112,29 @@ export class ThemeLoaderService implements OnModuleInit {
return Array.from(this.themes.values()); return Array.from(this.themes.values());
} }
/** Test/internal accessor for the resolved default id. */
getDefaultId(): ThemeId { getDefaultId(): ThemeId {
return this.defaultThemeId; return this.defaultThemeId;
} }
} }
export const BUILTIN_THEMES: Theme[] = [ export const BUILTIN_THEMES: Theme[] = [
{
id: 'hip',
name: 'HIP',
tokens: {
primary: '#ff0033',
secondary: '#b000ff',
accent: '#00f0ff',
surface: '#050505',
text: '#ffffff',
success: '#00ff66',
warning: '#ffea00',
danger: '#ff0033',
fontFamily: "'Chakra Petch', 'JetBrains Mono', monospace",
radii: { sm: '0px', md: '0px', lg: '0px' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{ {
id: 'classic', id: 'classic',
name: 'Classic', name: 'Classic',
+39 -69
View File
@@ -5,24 +5,8 @@ import sharp from 'sharp';
/** /**
* Canonical system-category icon seeder. * Canonical system-category icon seeder.
* *
* The canonical six system categories (CRY / HW / MSC / PWN / REV / WEB) are * Generates deterministic 128×128 PNG vector icon images for each canonical system
* declared as `CANONICAL_SYSTEM_CATEGORIES` in * category key (CRY, HW, MSC, PWN, REV, WEB) matching the legacy CTF app layout.
* `1700000000400-RepairCategorySchemaAndSystemCategories.ts`, whose
* `icon_path` column references `/uploads/icons/<KEY>.png`. The migration
* creates the DB rows but never writes the PNG assets to disk, so a freshly
* bootstrapped instance serves 404s for every system category icon.
*
* This helper closes that gap by generating a deterministic 128×128 PNG for
* each canonical key on startup. It is intentionally idempotent: existing
* files (including admin-uploaded icons written through `UploadsController`)
* are never overwritten.
*
* IMPORTANT: keep this list in sync with
* `CANONICAL_SYSTEM_CATEGORIES` in
* `1700000000400-RepairCategorySchemaAndSystemCategories.ts` and with the
* hard-coded `iconPath` URLs in
* `1700000000300-UpdateSystemCategoryKeys.ts` and
* `1700000000400-RepairCategorySchemaAndSystemCategories.ts`.
*/ */
export const CANONICAL_SYSTEM_ICON_KEYS = ['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'] as const; export const CANONICAL_SYSTEM_ICON_KEYS = ['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'] as const;
@@ -30,46 +14,44 @@ export type CanonicalSystemIconKey = (typeof CANONICAL_SYSTEM_ICON_KEYS)[number]
export const SYSTEM_ICON_SIZE = 128; export const SYSTEM_ICON_SIZE = 128;
const PALETTE: ReadonlyArray<{ r: number; g: number; b: number }> = [ function renderCategoryIconSvg(key: string): Buffer {
{ r: 0x1f, g: 0x6f, b: 0xeb }, let strokeColor = '#b000ff';
{ r: 0x16, g: 0xa3, b: 0x4a }, let pathSvg = '<polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" />';
{ r: 0xea, g: 0x58, b: 0x0c },
{ r: 0x93, g: 0x3f, b: 0xea },
{ r: 0xdb, g: 0x27, b: 0x77 },
{ r: 0x06, g: 0xb6, b: 0xd4 },
{ r: 0xdc, g: 0x26, b: 0x26 },
{ r: 0x65, g: 0x73, b: 0x0d },
];
function colorForKey(key: string): { r: number; g: number; b: number } { switch (key) {
let hash = 0; case 'WEB':
for (let i = 0; i < key.length; i++) { strokeColor = '#ff0033';
hash = (hash * 31 + key.charCodeAt(i)) >>> 0; pathSvg = '<path d="M19.07 4.93A10 10 0 0 0 6.99 3.34" /><path d="M4.93 19.07A10 10 0 0 0 20.66 17" /><path d="M2 12h20" /><path d="M12 2v20" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" />';
break;
case 'PWN':
strokeColor = '#ffaa00';
pathSvg = '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />';
break;
case 'REV':
strokeColor = '#00f0ff';
pathSvg = '<path d="M3 12a9 9 0 0 1 15-6.7L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-15 6.7L3 16" /><path d="M3 21v-5h5" />';
break;
case 'CRY':
strokeColor = '#b000ff';
pathSvg = '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" />';
break;
case 'HW':
strokeColor = '#00ff66';
pathSvg = '<rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M15 2v2M9 2v2M15 20v2M9 20v2M2 15h2M2 9h2M20 15h2M20 9h2" />';
break;
default:
strokeColor = '#b000ff';
pathSvg = '<polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" />';
break;
} }
return PALETTE[hash % PALETTE.length];
}
function renderLabel(label: string): Buffer { const svg = `<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
const width = SYSTEM_ICON_SIZE; <rect width="128" height="128" rx="8" fill="#080808" stroke="#33333d" stroke-width="4" />
const height = SYSTEM_ICON_SIZE; <g transform="translate(24, 24) scale(3.33)" fill="none" stroke="${strokeColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
const cellW = Math.floor(width / Math.max(label.length, 1)); ${pathSvg}
const cellH = Math.floor(height / 2); </g>
const cells: Array<{ x: number; y: number; on: boolean }> = []; </svg>`;
for (let i = 0; i < label.length; i++) { return Buffer.from(svg);
const cx = Math.floor(cellW * (i + 0.5));
const cy = cellH;
cells.push({ x: cx, y: cy, on: true });
}
const grid = Buffer.alloc(width * height * 4, 0);
for (const c of cells) {
if (c.x < 0 || c.x >= width || c.y < 0 || c.y >= height) continue;
const idx = (c.y * width + c.x) * 4;
grid[idx] = 255;
grid[idx + 1] = 255;
grid[idx + 2] = 255;
grid[idx + 3] = 220;
}
return grid;
} }
export interface SystemCategoryIconSeedResult { export interface SystemCategoryIconSeedResult {
@@ -95,20 +77,8 @@ export async function seedSystemCategoryIcons(uploadDir: string): Promise<System
/* fall through and write */ /* fall through and write */
} }
const bg = colorForKey(key); const svgBuf = renderCategoryIconSvg(key);
const overlay = renderLabel(key); const buf = await sharp(svgBuf).png().toBuffer();
const buf = await sharp({
create: {
width: SYSTEM_ICON_SIZE,
height: SYSTEM_ICON_SIZE,
channels: 4,
background: { r: bg.r, g: bg.g, b: bg.b, alpha: 1 },
},
})
.composite([{ input: overlay, raw: { width: SYSTEM_ICON_SIZE, height: SYSTEM_ICON_SIZE, channels: 4 } }])
.png()
.toBuffer();
fs.writeFileSync(target, buf); fs.writeFileSync(target, buf);
result.written.push(target); result.written.push(target);
} }
+17
View File
@@ -0,0 +1,17 @@
{
"id": "hip",
"name": "HIP",
"tokens": {
"primary": "#ff0033",
"secondary": "#b000ff",
"accent": "#00f0ff",
"surface": "#050505",
"text": "#ffffff",
"success": "#00ff66",
"warning": "#ffea00",
"danger": "#ff0033",
"fontFamily": "'Chakra Petch', 'JetBrains Mono', monospace",
"radii": { "sm": "0px", "md": "0px", "lg": "0px" },
"spacingScale": [4, 8, 12, 16, 24, 32, 48]
}
}
+3
View File
@@ -50,5 +50,8 @@
} }
} }
} }
},
"cli": {
"analytics": false
} }
} }
@@ -50,6 +50,13 @@ export const THEME_CSS_PROPERTIES = [
'--radius-sm', '--radius-sm',
'--radius-md', '--radius-md',
'--radius-lg', '--radius-lg',
'--neon-red',
'--neon-purple',
'--neon-cyan',
'--neon-green',
'--neon-yellow',
'--bg-primary',
'--font-heading',
] as const; ] as const;
export function applyThemeToCss(theme: Theme | null | undefined, root: ThemeCssTarget = document.documentElement.style): boolean { export function applyThemeToCss(theme: Theme | null | undefined, root: ThemeCssTarget = document.documentElement.style): boolean {
@@ -67,6 +74,15 @@ export function applyThemeToCss(theme: Theme | null | undefined, root: ThemeCssT
root.setProperty('--radius-sm', t.radii.sm); root.setProperty('--radius-sm', t.radii.sm);
root.setProperty('--radius-md', t.radii.md); root.setProperty('--radius-md', t.radii.md);
root.setProperty('--radius-lg', t.radii.lg); root.setProperty('--radius-lg', t.radii.lg);
// Synchronize dynamic theme aliases
root.setProperty('--neon-red', t.primary);
root.setProperty('--neon-purple', t.secondary);
root.setProperty('--neon-cyan', t.accent);
root.setProperty('--neon-green', t.success);
root.setProperty('--neon-yellow', t.warning);
root.setProperty('--bg-primary', t.surface);
root.setProperty('--font-heading', t.fontFamily);
return true; return true;
} }
@@ -5,16 +5,17 @@ import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/rou
interface AdminNavEntry { interface AdminNavEntry {
id: string; id: string;
label: string; label: string;
icon: string;
path: string; path: string;
enabled: boolean; enabled: boolean;
} }
const ENTRIES: AdminNavEntry[] = [ const ENTRIES: AdminNavEntry[] = [
{ id: 'general', label: 'General', path: '/admin/general', enabled: true }, { id: 'general', label: 'GENERAL', icon: '⚙️', path: '/admin/general', enabled: true },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true }, { id: 'challenges', label: 'CHALLENGES', icon: '🚩', path: '/admin/challenges', enabled: true },
{ id: 'players', label: 'Players', path: '/admin/players', enabled: true }, { id: 'blog', label: 'BLOG', icon: '📰', path: '/admin/blog', enabled: true },
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: true }, { id: 'players', label: 'PLAYERS', icon: '👥', path: '/admin/players', enabled: true },
{ id: 'system', label: 'System', path: '/admin/system', enabled: true }, { id: 'system', label: 'TOOLS', icon: '🔧', path: '/admin/system', enabled: true },
]; ];
@Component({ @Component({
@@ -23,34 +24,64 @@ const ENTRIES: AdminNavEntry[] = [
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet], imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet],
styles: [` 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 { .admin-aside {
min-width: 180px; width: 240px;
padding: 12px; flex-shrink: 0;
background: var(--color-surface, #fff); display: flex;
border-radius: var(--radius-md, 8px); 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 { .admin-aside li {
padding: 8px 10px; padding: 12px 16px;
border-radius: var(--radius-sm, 4px); background: #000000;
border: 1px solid #1a1a24;
font-family: var(--font-mono);
font-weight: 800;
font-size: 0.85rem;
color: #888899;
cursor: pointer; 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 { .admin-aside li.active {
background: var(--color-primary, #3b82f6); background: var(--neon-red);
color: #fff; color: #000000;
border-color: var(--neon-red);
box-shadow: 0 0 15px rgba(255, 0, 51, 0.4);
} }
.admin-aside li.disabled { .admin-aside li.disabled {
opacity: 0.45; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
.pause-board-btn {
width: 100%;
margin-top: 12px;
}
.admin-body { flex: 1; min-width: 0; } .admin-body { flex: 1; min-width: 0; }
`], `],
template: ` template: `
<section class="admin-shell"> <section class="admin-shell">
<aside class="admin-aside" data-testid="admin-aside"> <aside class="admin-aside" data-testid="admin-aside">
<h2>Admin area</h2> <h2 class="admin-title">ADMIN</h2>
<ul data-testid="admin-nav"> <ul data-testid="admin-nav">
@for (e of entries; track e.id) { @for (e of entries; track e.id) {
<li <li
@@ -59,7 +90,8 @@ const ENTRIES: AdminNavEntry[] = [
[attr.data-testid]="'admin-nav-' + e.id" [attr.data-testid]="'admin-nav-' + e.id"
(click)="go(e)" (click)="go(e)"
> >
{{ e.label }} <span>{{ e.icon }}</span>
<span>{{ e.label }}</span>
</li> </li>
} }
</ul> </ul>
@@ -8,27 +8,53 @@ import { AdminBlogPost } from '../../../core/services/blog.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` 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-backdrop {
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
.error { color: var(--color-danger, #f00); margin-top: 8px; } }
.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: ` template: `
@if (open()) { @if (open()) {
<div class="modal-backdrop" data-testid="post-delete-backdrop" (click)="onCancel()"> <div class="modal-backdrop" data-testid="post-delete-backdrop" (click)="onCancel()">
<div class="modal" (click)="$event.stopPropagation()" data-testid="post-delete-modal"> <div class="modal" (click)="$event.stopPropagation()" data-testid="post-delete-modal">
<h3>Delete post</h3> <h3>DELETE BROADCAST</h3>
@if (post(); as p) { @if (post(); as p) {
<p data-testid="post-delete-name"> <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> </p>
} }
@if (errorMessage()) { @if (errorMessage()) {
<p class="error" data-testid="post-delete-error">{{ errorMessage() }}</p> <p class="error" data-testid="post-delete-error">{{ errorMessage() }}</p>
} }
<div class="actions"> <div class="actions">
<button type="button" (click)="onCancel()" data-testid="post-delete-cancel">Cancel</button> <button type="button" class="btn-cancel" (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-ok" [disabled]="deleting()" (click)="onConfirm()" data-testid="post-delete-ok">
{{ deleting() ? 'DELETING...' : 'DELETE' }}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -37,14 +37,53 @@ export interface PostFormSubmitPayload {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule],
styles: [` 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-backdrop {
.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; } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
.row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; } backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
.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; } .modal {
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
.field-error { color: var(--color-danger, #f00); font-size: 12px; } padding: 24px; min-width: 640px; max-width: 92vw; max-height: 90vh; overflow-y: auto;
.editing-meta { font-size: 12px; opacity: 0.7; } 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: ` template: `
@if (open()) { @if (open()) {
@@ -56,26 +95,27 @@ export interface PostFormSubmitPayload {
(submit)="$event.preventDefault()" (submit)="$event.preventDefault()"
data-testid="post-form-modal" 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 (post(); as p) {
@if (mode() === 'edit') { @if (mode() === 'edit') {
<p class="editing-meta"> <p class="editing-meta">
Created: {{ p.createdAt | date: 'medium' }} · CREATED: {{ p.createdAt | date: 'medium' }} ·
Updated: {{ p.updatedAt | date: 'medium' }} UPDATED: {{ p.updatedAt | date: 'medium' }}
@if (p.publishedAt) { @if (p.publishedAt) {
· Published: {{ p.publishedAt | date: 'medium' }} · PUBLISHED: {{ p.publishedAt | date: 'medium' }}
} }
</p> </p>
} }
} }
<div class="row"> <div class="row">
<label for="pf-title">Title (required)</label> <label for="pf-title">BROADCAST TITLE</label>
<input <input
id="pf-title" id="pf-title"
type="text" type="text"
formControlName="title" formControlName="title"
placeholder="ANNOUNCEMENT TITLE"
data-testid="pf-title" data-testid="pf-title"
[attr.aria-invalid]="titleError() ? 'true' : null" [attr.aria-invalid]="titleError() ? 'true' : null"
[attr.aria-describedby]="titleError() ? 'pf-title-error' : null" [attr.aria-describedby]="titleError() ? 'pf-title-error' : null"
@@ -87,11 +127,12 @@ export interface PostFormSubmitPayload {
<div class="layout"> <div class="layout">
<div class="row"> <div class="row">
<label for="pf-body">Markdown body</label> <label for="pf-body">MARKDOWN BODY</label>
<textarea <textarea
id="pf-body" id="pf-body"
rows="14" rows="12"
formControlName="bodyMd" formControlName="bodyMd"
placeholder="Enter markdown body..."
data-testid="pf-body" data-testid="pf-body"
(input)="onBodyInput()" (input)="onBodyInput()"
></textarea> ></textarea>
@@ -100,7 +141,7 @@ export interface PostFormSubmitPayload {
} }
</div> </div>
<div class="row"> <div class="row">
<label>Live preview</label> <label>LIVE PREVIEW</label>
<div class="preview" data-testid="pf-preview" [innerHTML]="previewHtml()"></div> <div class="preview" data-testid="pf-preview" [innerHTML]="previewHtml()"></div>
</div> </div>
</div> </div>
@@ -110,19 +151,21 @@ export interface PostFormSubmitPayload {
} }
<div class="actions"> <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 <button
type="button" type="button"
class="btn-cancel"
[disabled]="!canSaveDraft()" [disabled]="!canSaveDraft()"
(click)="onSubmit('draft')" (click)="onSubmit('draft')"
data-testid="pf-save-draft" data-testid="pf-save-draft"
>Save draft</button> >SAVE DRAFT</button>
<button <button
type="button" type="button"
class="btn-ok"
[disabled]="!canPublish()" [disabled]="!canPublish()"
(click)="onSubmit('publish')" (click)="onSubmit('publish')"
data-testid="pf-publish" data-testid="pf-publish"
>Publish</button> >PUBLISH BROADCAST</button>
</div> </div>
</form> </form>
</div> </div>
@@ -179,8 +222,6 @@ export class BlogFormModalComponent {
} }
onBodyInput(): void { onBodyInput(): void {
// valueChanges already triggers the preview; this is here as an explicit
// hook in case the template wiring changes.
} }
titleError(): string | null { titleError(): string | null {
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { AdminService, AdminCategory } from '../../../core/services/admin.service'; import { AdminService, AdminCategory } from '../../../core/services/admin.service';
import { CategoryFormModalComponent, CategoryFormSubmit } from './category-form-modal.component'; import { CategoryFormModalComponent, CategoryFormSubmit } from './category-form-modal.component';
import { CategoryDeleteModalComponent } from './category-delete-modal.component'; import { CategoryDeleteModalComponent } from './category-delete-modal.component';
import { CategoryIconComponent } from '../../../shared/components/category-icon/category-icon.component';
export type ModalMode = 'create' | 'edit'; export type ModalMode = 'create' | 'edit';
@@ -10,40 +11,66 @@ export type ModalMode = 'create' | 'edit';
selector: 'app-admin-categories', selector: 'app-admin-categories',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, CategoryFormModalComponent, CategoryDeleteModalComponent], imports: [CommonModule, CategoryFormModalComponent, CategoryDeleteModalComponent, CategoryIconComponent],
styles: [` styles: [`
.cat-list { list-style: none; padding: 0; margin: 0; } .cat-list { list-style: none; padding: 0; margin: 0; }
.cat-row { .cat-row {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 14px;
padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc); 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 {
.cat-row .abbr { font-weight: bold; min-width: 60px; } font-family: var(--font-mono);
.cat-row .desc { flex: 1; } font-weight: 800; min-width: 60px; color: #ffffff;
.cat-row button { margin-left: 4px; } }
.header-bar { display: flex; justify-content: space-between; align-items: center; } .cat-row .desc { flex: 1; font-family: var(--font-mono); font-size: 0.9rem; color: #888899; }
.error { color: var(--color-danger, #f00); } .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: ` template: `
<section data-testid="admin-categories"> <section data-testid="admin-categories">
<div class="header-bar"> <div class="header-bar">
<h2>Categories</h2> <h2>CATEGORIES</h2>
<button type="button" (click)="openCreate()" data-testid="cat-add">+</button> <button type="button" class="btn-add" (click)="openCreate()" data-testid="cat-add">+ ADD CATEGORY</button>
</div> </div>
@if (loading()) { @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()) { } @else if (loadError()) {
<p class="error" data-testid="cat-error">{{ loadError() }}</p> <p class="error" data-testid="cat-error">{{ loadError() }}</p>
} @else { } @else {
<ul class="cat-list" data-testid="cat-list"> <ul class="cat-list" data-testid="cat-list">
@for (c of categories(); track c.id) { @for (c of categories(); track c.id) {
<li class="cat-row" [attr.data-testid]="'cat-row-' + c.abbreviation"> <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="abbr">{{ c.abbreviation }}</span>
<span class="desc">{{ c.description }}</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)="openEdit(c)" [attr.data-testid]="'cat-edit-' + c.abbreviation">EDIT</button>
<button type="button" (click)="openDelete(c)" [attr.data-testid]="'cat-delete-' + c.abbreviation">🗑</button> <button type="button" class="btn-del" (click)="openDelete(c)" [attr.data-testid]="'cat-delete-' + c.abbreviation">DELETE</button>
</li> </li>
} }
</ul> </ul>
@@ -8,31 +8,54 @@ import { AdminCategory } from '../../../core/services/admin.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` 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-backdrop {
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 320px; } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
.error { color: var(--color-danger, #f00); margin-top: 8px; } }
.note { color: var(--color-warning, #fa0); margin-top: 8px; } .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: ` template: `
@if (open()) { @if (open()) {
<div class="modal-backdrop" data-testid="cat-delete-backdrop" (click)="onCancel()"> <div class="modal-backdrop" data-testid="cat-delete-backdrop" (click)="onCancel()">
<div class="modal" (click)="$event.stopPropagation()" data-testid="cat-delete-modal"> <div class="modal" (click)="$event.stopPropagation()" data-testid="cat-delete-modal">
<h3>Delete category</h3> <h3>DELETE CATEGORY</h3>
@if (category(); as c) { @if (category(); as c) {
@if (c.isSystem) { @if (c.isSystem) {
<p>This is a system category and cannot be deleted.</p> <p>This is a system category and cannot be deleted.</p>
} @else { } @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()) { @if (errorMessage()) {
<p class="error" data-testid="cat-delete-error">{{ errorMessage() }}</p> <p class="error" data-testid="cat-delete-error">{{ errorMessage() }}</p>
} }
<div class="actions"> <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()) { @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>
</div> </div>
@@ -56,49 +56,86 @@ export function syncCategoryForm(
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule],
styles: [` 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-backdrop {
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
.row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; } backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } }
.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: ` template: `
@if (open()) { @if (open()) {
<div class="modal-backdrop" data-testid="cat-form-backdrop" (click)="onCancel()"> <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"> <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"> <div class="row">
<label for="cf-name">Name</label> <label for="cf-name">CATEGORY NAME</label>
<input id="cf-name" type="text" formControlName="name" data-testid="cf-name" /> <input id="cf-name" type="text" formControlName="name" placeholder="CATEGORY NAME" data-testid="cf-name" />
</div> </div>
<div class="row"> <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" <input id="cf-abbr" type="text" formControlName="abbreviation"
[attr.readonly]="abbreviationReadonly() ? '' : null" [attr.readonly]="abbreviationReadonly() ? '' : null"
placeholder="E.G. WEB, PWN, REV"
data-testid="cf-abbr" /> data-testid="cf-abbr" />
</div> </div>
<div class="row"> <div class="row">
<label for="cf-desc">Description</label> <label for="cf-desc">DESCRIPTION</label>
<textarea id="cf-desc" rows="3" formControlName="description" data-testid="cf-desc"></textarea> <textarea id="cf-desc" rows="3" formControlName="description" placeholder="Category description..." data-testid="cf-desc"></textarea>
</div> </div>
<div class="row"> <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" /> <input id="cf-icon" type="file" accept="image/*" (change)="onFile($event)" data-testid="cf-icon" />
@if (iconPreview()) { @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> </div>
@if (errorMessage()) { @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"> <div class="actions">
<button type="button" (click)="onCancel()" data-testid="cf-cancel">Cancel</button> <button type="button" class="btn-cancel" (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-ok" [disabled]="form.invalid || submitting() || saving()" (click)="onOk()" data-testid="cf-ok">SAVE</button>
</div> </div>
</form> </form>
</div> </div>
@@ -14,29 +14,50 @@ import { AdminChallengeListItem } from '../../../core/services/admin.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` 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-overlay {
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(420px, 90vw); } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
.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; } .modal {
@keyframes spin { to { transform: rotate(360deg); } } 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: ` template: `
@if (open()) { @if (open()) {
<div class="modal-overlay" data-testid="challenge-delete-overlay"> <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"> <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) { @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) { @if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="challenge-delete-error">{{ msg }}</p> <p class="error" role="alert" data-testid="challenge-delete-error">{{ msg }}</p>
} }
<div class="actions"> <div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-delete-cancel">Cancel</button> <button type="button" class="btn-cancel" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-delete-cancel">CANCEL</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-delete-ok"> <button type="button" class="btn-ok" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-delete-ok">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span> {{ submitting() ? 'DELETING...' : 'DELETE' }}
Delete
</button> </button>
</div> </div>
</div> </div>
@@ -135,54 +135,128 @@ export function syncChallengeForm(
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule],
styles: [` 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-overlay {
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(720px, 100%); max-height: 90vh; overflow: auto; } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
.tabs { display: flex; gap: 4px; margin-bottom: 8px; } backdrop-filter: blur(4px);
.tabs button { padding: 6px 12px; border-radius: 4px; border: 1px solid var(--color-secondary, #ccc); background: transparent; cursor: pointer; } display: flex; align-items: center; justify-content: center; z-index: 1200;
.tabs button.active { background: var(--color-primary, #3b82f6); color: #fff; border-color: var(--color-primary, #3b82f6); } padding: 16px;
.row { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; } }
.row label { font-weight: 600; font-size: 13px; } .modal {
.row .error { color: var(--color-danger, #f00); font-size: 12px; } background: #000000; color: #ffffff;
.markdown-preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: 4px; min-height: 60px; } border: 3px solid var(--neon-red);
.field-row { display: flex; gap: 8px; } 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; } .field-row > * { flex: 1; }
.file-list { list-style: none; padding: 0; margin: 0; } .radio-group { display: flex; gap: 16px; align-items: center; padding: 6px 0; }
.file-list li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--color-secondary, #ccc); } .radio-group label { color: #ffffff; font-weight: bold; cursor: pointer; }
.file-list li .remove { margin-left: auto; } .file-list { list-style: none; padding: 0; margin: 0 0 16px 0; display: flex; flex-direction: column; gap: 8px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } .file-list li {
.flag-input { display: flex; gap: 4px; } 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; } .flag-input input { flex: 1; }
.preview-toggle { font-size: 12px; } .flag-toggle-btn {
.upload-error { color: var(--color-danger, #f00); font-size: 12px; } background: #000000; border: 2px solid var(--neon-purple); color: #ffffff;
.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; } padding: 0 14px; cursor: pointer; font-size: 1.1rem;
.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); } .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: ` template: `
@if (open()) { @if (open()) {
<div class="modal-overlay" role="presentation" (click)="onCancel()" data-testid="challenge-form-backdrop"> <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"> <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"> <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() === '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() === '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() === 'files'" [attr.aria-selected]="tab() === 'files'" (click)="setTab('files')" data-testid="cf-tab-files">FILES</button>
</div> </div>
<div [formGroup]="form"> <div [formGroup]="form">
@if (tab() === 'general') { @if (tab() === 'general') {
<div class="row"> <div class="row">
<label for="cf-name">Name</label> <label for="cf-name">CHALLENGE NAME</label>
<input id="cf-name" type="text" formControlName="name" maxlength="128" data-testid="cf-name" /> <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> } @if (showError('name'); as msg) { <span class="error" data-testid="cf-error-name">{{ msg }}</span> }
</div> </div>
<div class="row"> <div class="row">
<label for="cf-desc">Description (Markdown)</label> <label for="cf-desc">CHALLENGE INTEL (MARKDOWN)</label>
<textarea id="cf-desc" rows="4" formControlName="descriptionMd" data-testid="cf-desc"></textarea> <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"> <button type="button" class="preview-toggle" (click)="togglePreview()" data-testid="cf-desc-preview-toggle">
{{ showPreview() ? 'Hide preview' : 'Preview' }} {{ showPreview() ? 'HIDE PREVIEW' : 'SHOW PREVIEW' }}
</button> </button>
@if (showPreview()) { @if (showPreview()) {
<div class="markdown-preview" data-testid="cf-desc-preview" [innerHTML]="previewHtml()"></div> <div class="markdown-preview" data-testid="cf-desc-preview" [innerHTML]="previewHtml()"></div>
@@ -191,7 +265,7 @@ export function syncChallengeForm(
</div> </div>
<div class="row"> <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"> <select id="cf-category" formControlName="categoryId" data-testid="cf-category">
<option value="">Select a category…</option> <option value="">Select a category…</option>
@for (cat of categories(); track cat.id) { @for (cat of categories(); track cat.id) {
@@ -202,8 +276,8 @@ export function syncChallengeForm(
</div> </div>
<div class="row"> <div class="row">
<label>Difficulty</label> <label>DIFFICULTY</label>
<div role="radiogroup" aria-label="Difficulty"> <div class="radio-group" role="radiogroup" aria-label="Difficulty">
@for (d of difficulties; track d) { @for (d of difficulties; track d) {
<label> <label>
<input type="radio" [value]="d" formControlName="difficulty" [attr.data-testid]="'cf-difficulty-' + d" /> {{ d }} <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="field-row">
<div class="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" /> <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> } @if (showError('initialPoints'); as msg) { <span class="error" data-testid="cf-error-initial">{{ msg }}</span> }
</div> </div>
<div class="row"> <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" /> <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> } @if (showError('minimumPoints'); as msg) { <span class="error" data-testid="cf-error-minimum">{{ msg }}</span> }
</div> </div>
<div class="row"> <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" /> <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> } @if (showError('decaySolves'); as msg) { <span class="error" data-testid="cf-error-decay">{{ msg }}</span> }
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<label for="cf-flag">Secret flag</label> <label for="cf-flag">FLAG PROTOCOL</label>
<div class="flag-input"> <div class="flag-input">
<input id="cf-flag" [type]="flagVisible() ? 'text' : 'password'" formControlName="flag" data-testid="cf-flag" /> <input id="cf-flag" [type]="flagVisible() ? 'text' : 'password'" formControlName="flag" placeholder="{flag: insert_the_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> <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> </div>
@if (showError('flag'); as msg) { <span class="error" data-testid="cf-error-flag">{{ msg }}</span> } @if (showError('flag'); as msg) { <span class="error" data-testid="cf-error-flag">{{ msg }}</span> }
</div> </div>
} }
@if (tab() === 'connection') { @if (tab() === 'connection') {
<div class="row"> <div class="connection-panel">
<label>Protocol</label> <div class="connection-panel-title">🌐 CONNECTION SETTINGS</div>
<div role="radiogroup" aria-label="Protocol">
@for (p of protocols; track p) { <div class="row">
<label> <label>PROTOCOL</label>
<input type="radio" [value]="p" formControlName="protocol" [attr.data-testid]="'cf-protocol-' + p" /> {{ p }} <div class="radio-group" role="radiogroup" aria-label="Protocol">
</label> @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>
<div class="row"> <div class="row">
<label for="cf-port">Port</label> <label for="cf-port">PORT</label>
<input id="cf-port" type="text" inputmode="numeric" formControlName="port" data-testid="cf-port" /> <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> } @if (showError('port'); as msg) { <span class="error" data-testid="cf-error-port">{{ msg }}</span> }
</div> </div>
<div class="row"> <div class="row">
<label for="cf-ip">IP address (optional; falls back to Default Challenge IP)</label> <label for="cf-ip">NODE IP OVERRIDE (OPTIONAL)</label>
<input id="cf-ip" type="text" formControlName="ipAddress" data-testid="cf-ip" /> <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> } @if (showError('ipAddress'); as msg) { <span class="error" data-testid="cf-error-ip">{{ msg }}</span> }
</div>
</div> </div>
} }
@if (tab() === 'files') { @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"> <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 <div
class="drop-zone" class="drop-zone"
[class.dragover]="dragOver()" [class.dragover]="dragOver()"
@@ -285,13 +370,13 @@ export function syncChallengeForm(
(drop)="onDrop($event)" (drop)="onDrop($event)"
data-testid="cf-drop-zone" 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" /> <input id="cf-upload" type="file" multiple (change)="onFileChange($event)" data-testid="cf-upload" />
</div> </div>
@if (fileUploadError(); as err) { @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> </div>
} }
@@ -301,9 +386,9 @@ export function syncChallengeForm(
</div> </div>
<div class="actions"> <div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="cf-cancel">Cancel</button> <button type="button" class="btn-cancel" (click)="onCancel()" [disabled]="submitting()" data-testid="cf-cancel">CANCEL</button>
<button type="button" (click)="onOk()" [disabled]="submitting()" data-testid="cf-ok"> <button type="button" class="btn-ok" (click)="onOk()" [disabled]="submitting()" data-testid="cf-ok">
{{ submitting() ? 'Saving…' : (mode() === 'create' ? 'Create' : 'Save') }} {{ submitting() ? 'SAVING…' : (mode() === 'create' ? 'CREATE CHALLENGE' : 'SAVE ALL CHANGES') }}
</button> </button>
</div> </div>
</form> </form>
@@ -447,7 +532,6 @@ export class ChallengeFormModalComponent {
} }
onCancel(): void { onCancel(): void {
// discard any pending staged tokens
for (const f of this.files()) { for (const f of this.files()) {
if (f.kind === 'staged' && f.token) { if (f.kind === 'staged' && f.token) {
void this.admin.discardChallengeFile(f.token).catch(() => undefined); 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 }; return CATEGORY_ID_PATTERN.test(String(raw)) ? null : { uuid: true };
} }
// Re-export so tests can find default helpers.
export { defaultMinimumPoints }; export { defaultMinimumPoints };
@@ -15,21 +15,43 @@ import { ImportPreviewResponse, ImportResultResponse } from '../../../core/servi
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` 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-overlay {
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(520px, 90vw); } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
.error { color: var(--color-danger, #f00); margin-top: 8px; } }
.warnings { color: var(--color-warning, #b45309); margin-top: 8px; } .modal {
.result { color: var(--color-success, #065f46); margin-top: 8px; } background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
ul { margin: 0; padding-left: 18px; } padding: 24px; width: min(540px, 92vw); max-height: 90vh; overflow-y: auto;
.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; } box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
@keyframes spin { to { transform: rotate(360deg); } } 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: ` template: `
@if (open()) { @if (open()) {
<div class="modal-overlay" data-testid="challenge-import-overlay"> <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"> <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) { @if (result(); as r) {
<p class="result" data-testid="challenge-import-result"> <p class="result" data-testid="challenge-import-result">
@@ -43,7 +65,7 @@ import { ImportPreviewResponse, ImportResultResponse } from '../../../core/servi
</ul> </ul>
} }
<div class="actions"> <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> </div>
} @else if (preview()) { } @else if (preview()) {
@if (preview(); as p) { @if (preview(); as p) {
@@ -54,7 +76,7 @@ import { ImportPreviewResponse, ImportResultResponse } from '../../../core/servi
Continue? Continue?
</p> </p>
@if (p.conflicts.length > 0) { @if (p.conflicts.length > 0) {
<p>Will overwrite:</p> <p style="color:var(--neon-yellow);font-weight:bold;">Will overwrite:</p>
<ul> <ul>
@for (name of p.conflicts; track name) { <li>{{ name }}</li> } @for (name of p.conflicts; track name) { <li>{{ name }}</li> }
</ul> </ul>
@@ -70,19 +92,18 @@ import { ImportPreviewResponse, ImportResultResponse } from '../../../core/servi
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p> <p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
} }
<div class="actions"> <div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-import-cancel">Cancel</button> <button type="button" class="btn-cancel" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-import-cancel">CANCEL</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-import-confirm"> <button type="button" class="btn-ok" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-import-confirm">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span> {{ submitting() ? 'IMPORTING...' : 'OVERWRITE & IMPORT' }}
Overwrite &amp; Import
</button> </button>
</div> </div>
} @else if (errorMessage()) { } @else if (errorMessage()) {
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p> <p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
<div class="actions"> <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> </div>
} @else { } @else {
<p>Reading file…</p> <p>READING FILE…</p>
} }
</div> </div>
</div> </div>
@@ -14,12 +14,34 @@ import { AdminUser } from '../../core/services/admin.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` 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-overlay {
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(420px, 90vw); } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
.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; } .modal {
@keyframes spin { to { transform: rotate(360deg); } } 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: ` template: `
@if (open()) { @if (open()) {
@@ -32,16 +54,15 @@ import { AdminUser } from '../../core/services/admin.service';
data-testid="player-delete-modal" data-testid="player-delete-modal"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<h3 id="pd-title">Delete user</h3> <h3 id="pd-title">DELETE USER</h3>
<p>Delete this user?</p> <p>Delete this user? All associated activity will be permanently wiped.</p>
@if (errorMessage(); as msg) { @if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="player-delete-error">{{ msg }}</p> <p class="error" role="alert" data-testid="player-delete-error">{{ msg }}</p>
} }
<div class="actions"> <div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="player-delete-cancel">Cancel</button> <button type="button" class="btn-cancel" (click)="onCancel()" [disabled]="submitting()" data-testid="player-delete-cancel">CANCEL</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="player-delete-ok"> <button type="button" class="btn-ok" (click)="onConfirm()" [disabled]="submitting()" data-testid="player-delete-ok">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span> {{ submitting() ? 'DELETING...' : 'DELETE USER' }}
Delete
</button> </button>
</div> </div>
</div> </div>
@@ -18,23 +18,54 @@ import { destructiveCopy } from './system.pure';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
styles: [` 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-overlay {
.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); } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
.modal h3 { margin: 0 0 8px; color: var(--color-danger, #991b1b); } backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1200; padding: 16px;
.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; } .modal {
.row label { font-weight: 600; font-size: 14px; } background: #000000; color: #ffffff; border: 3px solid var(--neon-red);
.row input { padding: 8px; border-radius: 6px; border: 1px solid var(--color-secondary, #ccc); } padding: 24px; width: min(540px, 92vw); max-height: 90vh; overflow-y: auto;
.confirm-box { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; } box-shadow: 0 10px 40px rgba(0,0,0,0.9), 0 0 25px rgba(255, 0, 51, 0.35);
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } font-family: var(--font-mono);
.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; } .modal h3 {
.error { color: var(--color-danger, #b91c1c); margin-top: 8px; } font-family: var(--font-heading, 'Chakra Petch', sans-serif);
.progress { color: var(--color-warning, #92400e); margin-top: 8px; } font-size: 1.6rem; font-weight: 900; font-style: italic; text-transform: uppercase;
.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; } color: var(--neon-red); margin-bottom: 12px; letter-spacing: 0.5px;
@keyframes spin { to { transform: rotate(360deg); } } }
.preserved { color: var(--color-success, #065f46); font-size: 13px; } .stage {
.detail { font-size: 14px; line-height: 1.45; } 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: ` template: `
@if (open()) { @if (open()) {
@@ -51,14 +82,15 @@ import { destructiveCopy } from './system.pure';
<p class="detail" data-testid="system-confirm-detail">{{ copy().detail }}</p> <p class="detail" data-testid="system-confirm-detail">{{ copy().detail }}</p>
<p class="preserved" data-testid="system-confirm-preserved">{{ copy().preserved }}</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"> <div class="row">
<label for="system-confirm-password">Current administrator password</label> <label for="system-confirm-password">CURRENT ADMINISTRATOR PASSWORD</label>
<input <input
id="system-confirm-password" id="system-confirm-password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
placeholder="ENTER ADMIN PASSWORD"
[value]="password()" [value]="password()"
(input)="onPasswordInput($event)" (input)="onPasswordInput($event)"
[disabled]="submitting()" [disabled]="submitting()"
@@ -67,10 +99,11 @@ import { destructiveCopy } from './system.pure';
</div> </div>
<div class="confirm-box"> <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 <input
id="system-confirm-phrase" id="system-confirm-phrase"
type="text" type="text"
placeholder="CONFIRMATION PHRASE"
[value]="confirmation()" [value]="confirmation()"
(input)="onConfirmInput($event)" (input)="onConfirmInput($event)"
[disabled]="submitting()" [disabled]="submitting()"
@@ -83,26 +116,25 @@ import { destructiveCopy } from './system.pure';
} }
@if (submitting()) { @if (submitting()) {
<p class="progress" data-testid="system-confirm-progress"> <p class="progress" data-testid="system-confirm-progress">
<span class="spinner" aria-hidden="true"></span>Working EXECUTING SYSTEM OPERATION
</p> </p>
} }
<div class="actions"> <div class="actions">
<button <button
type="button" type="button"
class="cancel" class="btn-cancel"
(click)="onCancel()" (click)="onCancel()"
[disabled]="submitting()" [disabled]="submitting()"
data-testid="system-confirm-cancel" data-testid="system-confirm-cancel"
>Cancel</button> >CANCEL</button>
<button <button
type="button" type="button"
class="danger" class="btn-danger"
(click)="onConfirm()" (click)="onConfirm()"
[disabled]="!canConfirm()" [disabled]="!canConfirm()"
data-testid="system-confirm-confirm" data-testid="system-confirm-confirm"
> >
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
{{ confirmLabel() }} {{ confirmLabel() }}
</button> </button>
</div> </div>
@@ -133,8 +165,8 @@ export class SystemConfirmModalComponent {
}); });
readonly confirmLabel = computed(() => { readonly confirmLabel = computed(() => {
if (this.submitting()) return 'Working…'; if (this.submitting()) return 'WORKING…';
return 'Confirm'; return 'CONFIRM DESTRUCTIVE ACTION';
}); });
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
@@ -9,19 +9,62 @@ import { PublicBlogPost } from '../../core/services/blog.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` styles: [`
.blog-item { display: block; margin: 0 0 16px 0; } .blog-item {
.blog-title { margin: 0 0 4px 0; font-size: 18px; } display: flex;
.blog-date { display: block; font-size: 12px; opacity: 0.7; margin-bottom: 8px; } flex-direction: column;
.blog-body { font-size: 14px; } 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: ` template: `
<article class="blog-item" [attr.data-testid]="'blog-presenter-' + post().id"> <article class="blog-item" [attr.data-testid]="'blog-presenter-' + post().id">
<h2 class="blog-title" [attr.data-testid]="'blog-presenter-title-' + post().id"> <div class="blog-head">
{{ post().title }} <h2 class="blog-title" [attr.data-testid]="'blog-presenter-title-' + post().id">
</h2> {{ post().title }}
<time class="blog-date" [attr.data-testid]="'blog-presenter-date-' + post().id"> </h2>
{{ post().publishedAt | date: 'medium' }} <time class="blog-date" [attr.data-testid]="'blog-presenter-date-' + post().id">
</time> {{ post().publishedAt | date: 'M/d/yyyy, h:mm:ss a' }}
</time>
</div>
<div <div
class="blog-body" class="blog-body"
[attr.data-testid]="'blog-presenter-body-' + post().id" [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 { CommonModule } from '@angular/common';
import { CategoryColumn } from './challenges.pure'; import { CategoryColumn } from './challenges.pure';
import { ChallengeCardComponent } from './challenge-card.component'; import { ChallengeCardComponent } from './challenge-card.component';
import { CategoryIconComponent } from '../../shared/components/category-icon/category-icon.component';
@Component({ @Component({
selector: 'app-category-column', selector: 'app-category-column',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ChallengeCardComponent], imports: [CommonModule, ChallengeCardComponent, CategoryIconComponent],
styles: [` styles: [`
:host { display: block; width: 280px; flex: 0 0 280px; } :host { display: block; width: 280px; flex: 0 0 280px; }
.col { .col {
display: flex; flex-direction: column; gap: 12px; display: flex; flex-direction: column; gap: 14px;
padding: 12px; background: var(--color-surface-2, rgba(255,255,255,0.02)); padding: 16px 12px; background: #000000;
border: 1px solid var(--color-border, #2a2f3a); border-radius: var(--radius-md, 8px); border: 1px solid #1a1a24;
min-height: 200px; min-height: 220px;
} }
.head { display: flex; align-items: center; gap: 8px; } .head { display: flex; align-items: center; gap: 10px; padding-bottom: 8px; border-bottom: 1px solid #1a1a24; }
.icon { width: 32px; height: 32px; object-fit: contain; } .head-info { display: flex; flex-direction: column; }
.abbr { font-weight: 700; letter-spacing: 0.06em; } .abbr {
.name { font-size: 12px; opacity: 0.7; } font-family: var(--font-heading, 'Chakra Petch', sans-serif);
.cards { display: flex; flex-direction: column; gap: 8px; } 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: ` template: `
<section class="col" [attr.data-testid]="'category-column-' + column.abbreviation"> <section class="col" [attr.data-testid]="'category-column-' + column.abbreviation">
<header class="head"> <header class="head">
<img *ngIf="column.iconPath" class="icon" [src]="column.iconPath" [alt]="column.abbreviation" /> <app-category-icon [category]="column.abbreviation" [size]="28" [color]="'var(--neon-purple)'" />
<div> <div class="head-info">
<div class="abbr">{{ column.abbreviation }}</div> <div class="abbr">{{ column.abbreviation }}</div>
<div class="name">{{ column.name }}</div> <div class="name">{{ column.name }}</div>
</div> </div>
@@ -1,38 +1,77 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { BoardCard } from './challenges.pure'; import { BoardCard } from './challenges.pure';
import { CategoryIconComponent } from '../../shared/components/category-icon/category-icon.component';
@Component({ @Component({
selector: 'app-challenge-card', selector: 'app-challenge-card',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule, CategoryIconComponent],
styles: [` styles: [`
:host { display: block; } :host { display: block; }
.card { .card {
display: flex; flex-direction: column; gap: 6px; display: flex; flex-direction: column; gap: 8px;
padding: 10px 12px; border: 1px solid var(--color-border, #2a2f3a); padding: 12px 14px; border: 2px solid #1a1a24;
border-radius: var(--radius-sm, 4px); background: var(--color-surface, #1c1f26); background: #000000;
cursor: pointer; transition: transform 0.1s ease; cursor: pointer; transition: all 0.15s ease-in-out;
min-height: 96px; min-height: 90px;
text-align: left; text-align: left;
color: inherit; color: inherit;
font: 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 { .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-LOW { color: var(--neon-green); }
.diff-MEDIUM { background: rgba(234,179,8,0.18); color: #ca8a04; } .diff-MEDIUM { color: var(--neon-yellow); }
.diff-HIGH { background: rgba(239,68,68,0.18); color: #dc2626; } .diff-HIGH { color: var(--neon-red); }
`], `],
template: ` template: `
<button <button
@@ -46,7 +85,11 @@ import { BoardCard } from './challenges.pure';
(click)="open.emit(card.id)" (click)="open.emit(card.id)"
> >
<div class="card-head"> <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 class="name">{{ card.name }}</span>
<span <span
*ngIf="card.solvedByMe" *ngIf="card.solvedByMe"
@@ -57,9 +100,8 @@ import { BoardCard } from './challenges.pure';
</div> </div>
<div class="meta"> <div class="meta">
<span class="diff" [class]="'diff-' + card.difficulty">{{ card.difficulty }}</span> <span class="diff" [class]="'diff-' + card.difficulty">{{ card.difficulty }}</span>
<span class="points" [attr.data-testid]="'points-' + card.id">{{ card.livePoints }} pts</span> <span class="points" [attr.data-testid]="'points-' + card.id">{{ card.livePoints }} PTS</span>
<span</span> <span class="solves-count">{{ card.solveCount }} 👥</span>
<span>{{ card.solveCount }} solve<span *ngIf="card.solveCount !== 1">s</span></span>
</div> </div>
</button> </button>
`, `,
@@ -32,92 +32,196 @@ import { ApiErrorEnvelope } from './challenges.service';
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
styles: [` styles: [`
.backdrop { .backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
display: flex; align-items: center; justify-content: center; z-index: 1000; backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center; z-index: 1200;
padding: 16px;
} }
.modal { .modal {
width: min(720px, 92vw); max-height: 90vh; overflow: auto; width: min(680px, 94vw); max-height: 90vh; overflow-y: auto;
background: var(--color-surface, #1c1f26); color: var(--color-text, #f4f4f5); background: #000000; color: #ffffff;
border-radius: var(--radius-md, 8px); border: 3px solid var(--neon-red);
padding: 16px; display: flex; flex-direction: column; gap: 12px; 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; } .modal-head {
.header h3 { margin: 0; flex: 1; } display: flex; justify-content: space-between; align-items: flex-start;
.pill { padding: 2px 8px; border-radius: 999px; font-size: 12px; background: rgba(255,255,255,0.06); } border-bottom: 1px solid #1a1a24; padding-bottom: 12px;
.pill.diff-LOW { background: rgba(34,197,94,0.18); color: #16a34a; } }
.pill.diff-MEDIUM { background: rgba(234,179,8,0.18); color: #ca8a04; } .modal-title-group { display: flex; flex-direction: column; gap: 4px; }
.pill.diff-HIGH { background: rgba(239,68,68,0.18); color: #dc2626; } .modal-title {
.body { white-space: normal; line-height: 1.45; } font-family: var(--font-heading, 'Chakra Petch', sans-serif);
.form { display: flex; gap: 8px; align-items: center; } font-size: 2rem; font-weight: 900; font-style: italic;
.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; } text-transform: uppercase; color: #ffffff; margin: 0;
.actions { display: flex; gap: 8px; justify-content: flex-end; } letter-spacing: 0.5px;
.error { color: var(--color-danger, #ef4444); font-size: 14px; } }
.notice { color: var(--color-warning, #eab308); font-size: 14px; } .modal-subhead {
.solvers { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; } display: flex; align-items: center; gap: 10px;
.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); } font-family: var(--font-mono); font-size: 0.85rem; font-weight: 800;
.rank-1 { color: #fbbf24; font-weight: 700; } }
.rank-2 { color: #d4d4d8; font-weight: 700; } .sub-cat { color: var(--neon-purple); }
.rank-3 { color: #b45309; font-weight: 700; } .sub-diff-LOW { color: var(--neon-green); }
.rank-other { color: var(--color-success, #22c55e); } .sub-diff-MEDIUM { color: var(--neon-yellow); }
.points { font-variant-numeric: tabular-nums; } .sub-diff-HIGH { color: var(--neon-red); }
.close { background: transparent; border: 1px solid var(--color-border, #2a2f3a); color: inherit; padding: 4px 10px; border-radius: var(--radius-sm, 4px); cursor: pointer; } .sub-pts { color: #ffffff; }
.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; } .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: ` template: `
<div class="backdrop" (click)="onBackdrop($event)"> <div class="backdrop" (click)="onBackdrop($event)">
<div class="modal" role="dialog" aria-modal="true" [attr.data-testid]="'challenge-modal-' + (card?.id ?? '')"> <div class="modal" role="dialog" aria-modal="true" [attr.data-testid]="'challenge-modal-' + (card?.id ?? '')">
<div class="header" *ngIf="card"> <div class="modal-head" *ngIf="card">
<h3>{{ card.name }}</h3> <div class="modal-title-group">
<span class="pill">{{ card.categoryAbbreviation }}</span> <h3 class="modal-title">{{ card.name }}</h3>
<span class="pill" [class]="'pill diff-' + card.difficulty">{{ card.difficulty }}</span> <div class="modal-subhead">
<span class="pill" data-testid="modal-points">{{ card.livePoints }} pts</span> <span class="sub-cat">{{ card.categoryAbbreviation }}</span>
<button type="button" class="close" (click)="close.emit()" aria-label="Close">Close</button> <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>
<div class="solved-banner" *ngIf="card?.solvedByMe" data-testid="modal-solved-banner"> <div class="solved-banner" *ngIf="card?.solvedByMe" data-testid="modal-solved-banner">
You solved this challenge. ✓ YOU SOLVED THIS CHALLENGE!
</div> </div>
<div class="awarded-banner" *ngIf="awarded() as a" data-testid="modal-awarded-banner"> <div class="awarded-banner" *ngIf="awarded() as a" data-testid="modal-awarded-banner">
Awarded {{ a.awardedPoints }} pts AWARDED {{ a.awardedPoints }} PTS
<span *ngIf="a.rankBonus > 0">(base {{ a.basePoints }} + rank bonus {{ a.rankBonus }})</span> <span *ngIf="a.rankBonus > 0">(BASE {{ a.basePoints }} + RANK BONUS {{ a.rankBonus }})</span>
</div> </div>
<div class="notice" *ngIf="notice() as n">{{ n }}</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 <input
type="text" type="text"
class="flag-input"
[(ngModel)]="flagValue" [(ngModel)]="flagValue"
name="flag" name="flag"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
placeholder="flag{...}" placeholder="{flag: insert_the_flag}"
[disabled]="submitting() || !canSubmit" [disabled]="submitting() || !canSubmit"
data-testid="flag-input" data-testid="flag-input"
/> />
<button <button
type="submit" type="submit"
class="hxp-btn solve-btn"
[disabled]="submitting() || !canSubmit" [disabled]="submitting() || !canSubmit"
data-testid="solve-button" data-testid="solve-button"
>Solve</button> >
{{ submitting() ? 'SUBMITTING...' : 'SOLVE' }}
</button>
</form> </form>
<div class="error" *ngIf="errorMessage() as e" data-testid="modal-error">{{ e }}</div> <div class="error" *ngIf="errorMessage() as e" data-testid="modal-error">{{ e }}</div>
<h4>Solvers ({{ solvers().length }})</h4> <div class="solvers-section">
<ul class="solvers" data-testid="modal-solvers"> <h4 class="solvers-heading"><span>✨</span> SOLVES ({{ solvers().length }})</h4>
<li *ngFor="let s of solvers(); trackBy: trackBySolver"> <ul class="solvers-list" data-testid="modal-solvers">
<span [class]="rankClass(s.position)">#{{ s.position }}</span> <li class="solver-item" *ngFor="let s of solvers(); trackBy: trackBySolver">
<span>{{ s.playerName || ('Player ' + s.playerId.slice(0, 6)) }}</span> <div class="solver-left">
<span>{{ formatLocal(s.solvedAtUtc) }}</span> <span class="rank-badge" [class.rank-1]="s.position===1" [class.rank-2]="s.position===2" [class.rank-3]="s.position===3">
<span class="points">{{ s.awardedPoints }} pts</span> {{ s.position }}
</li> </span>
<li *ngIf="!solvers().length"><em>No solvers yet.</em></li> <span class="solver-name">{{ s.playerName || ('Player ' + s.playerId.slice(0, 6)) }}</span>
</ul> </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>
</div> </div>
`, `,
@@ -208,13 +312,6 @@ export class ChallengeModalComponent implements OnChanges {
return formatUtcToLocal(utc); 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 { trackBySolver(_idx: number, s: SolverRow): string {
return `${s.playerId}-${s.solvedAtUtc}`; return `${s.playerId}-${s.solvedAtUtc}`;
} }
@@ -50,6 +50,7 @@ const TABS: ShellTab[] = [
<div class="container"> <div class="container">
<app-shell-header <app-shell-header
[pageTitle]="bootstrap.payload()?.pageTitle ?? 'HIPCTF'" [pageTitle]="bootstrap.payload()?.pageTitle ?? 'HIPCTF'"
[logoUrl]="bootstrap.payload()?.logo ?? ''"
[activeSection]="activeSection()" [activeSection]="activeSection()"
[eventState]="eventStatus.state()" [eventState]="eventStatus.state()"
[countdownText]="eventStatus.countdownText()" [countdownText]="eventStatus.countdownText()"
@@ -1,190 +1,225 @@
.landing { .landing {
max-width: 720px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1rem; padding: 3rem 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; align-items: center;
gap: 2.5rem;
} }
.landing-header { .landing-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1rem; gap: 1.5rem;
text-align: center;
} }
.landing-logo { .landing-logo {
max-height: 120px; max-height: 140px;
max-width: 100%; max-width: 100%;
object-fit: contain; object-fit: contain;
} }
.landing-sticker-wrapper {
margin-bottom: 0.5rem;
}
.landing-title { .landing-title {
margin: 0; margin: 0;
font-size: 2rem; font-family: var(--font-heading, 'Chakra Petch', sans-serif);
text-align: center; 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 { .landing-welcome {
padding: 1rem 1.25rem; padding: 1.75rem 2rem;
border: 1px solid var(--color-surface, #eee); background: #000000;
border-radius: var(--radius-md, 6px); max-width: 680px;
background: var(--color-surface, #fafafa); 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 { .landing-actions {
align-self: center; 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 { .landing-blog {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
margin-top: 2rem; width: 100%;
} margin-top: 1rem;
.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);
} }
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(4px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 100; z-index: 1200;
padding: 1rem;
} }
.modal { .modal {
background: var(--color-bg, #fff); background: #000000;
color: var(--color-text, #222); color: #ffffff;
padding: 1.5rem; padding: 2.5rem 2rem;
border-radius: var(--radius-lg, 8px); width: min(440px, 94vw);
width: min(420px, 92vw);
position: relative; 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 { .modal-close {
position: absolute; position: absolute;
top: 0.5rem; top: 1rem;
right: 0.75rem; right: 1rem;
background: transparent; background: var(--neon-red);
color: #000000;
border: none; border: none;
font-size: 1.5rem; width: 32px;
height: 32px;
font-size: 1rem;
font-weight: bold;
cursor: pointer; 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 { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.5rem;
margin-bottom: 0.75rem; 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 { .field input {
padding: 0.5rem; font-family: var(--font-mono);
border: 1px solid var(--color-surface, #ccc); background-color: #000000;
border-radius: var(--radius-sm, 4px); 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 { .field-error {
color: var(--color-danger, #c00); color: var(--neon-red);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: bold;
margin-top: 0.25rem;
} }
.server-error { .server-error {
margin-top: 0.5rem; margin-bottom: 1.25rem;
padding: 0.5rem 0.75rem; padding: 0.75rem 1rem;
border-radius: var(--radius-sm, 4px); border: 2px solid var(--neon-red);
background: rgba(204, 0, 0, 0.08); background: rgba(255, 0, 51, 0.1);
color: var(--color-danger, #c00); color: var(--neon-red);
font-size: 0.9rem; font-size: 0.85rem;
font-weight: bold;
} }
.server-error-warn { .server-error-warn {
background: rgba(204, 153, 0, 0.12); border-color: var(--neon-yellow);
color: var(--color-warning, #b80); background: rgba(255, 204, 0, 0.1);
color: var(--neon-yellow);
} }
.server-error-hint { .modal-submit {
display: inline-block;
margin-left: 0.5rem;
font-variant-numeric: tabular-nums;
}
.primary {
width: 100%; width: 100%;
padding: 0.6rem 1rem; margin-top: 1rem;
background: var(--color-primary, #06c); padding: 14px;
color: #fff; font-size: 1.1rem;
border: none;
border-radius: var(--radius-md, 4px);
cursor: pointer;
}
.primary[disabled] {
opacity: 0.6;
cursor: not-allowed;
} }
.modal-link-row { .modal-link-row {
margin-top: 0.75rem; margin-top: 1.5rem;
font-size: 0.9rem; font-size: 0.8rem;
font-weight: 800;
color: #888899;
text-align: center; text-align: center;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.link { .link {
background: transparent; background: transparent;
border: none; border: none;
color: var(--color-primary, #06c); color: var(--neon-purple);
font-family: var(--font-mono);
font-weight: 800;
cursor: pointer; cursor: pointer;
text-decoration: underline; text-decoration: underline;
padding: 0; padding: 0 0 0 4px;
font: inherit; font-size: 0.8rem;
}
.link:hover {
color: var(--neon-cyan);
} }
@@ -2,20 +2,54 @@
<header class="landing-header"> <header class="landing-header">
@if (logo()) { @if (logo()) {
<img class="landing-logo" [src]="logo()" alt="" data-testid="landing-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> <h1 class="landing-title" data-testid="landing-title">{{ pageTitle() }}</h1>
</header> </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 <div class="landing-actions">
type="button" <button
class="landing-login-btn" type="button"
(click)="openLogin()" class="hxp-btn hxp-btn-secondary"
data-testid="landing-open-login" routerLink="/blog"
> data-testid="landing-open-blog"
Login >
</button> 📰 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) { @if (landing.posts().length > 0) {
<section class="landing-blog" data-testid="landing-blog"> <section class="landing-blog" data-testid="landing-blog">
@@ -23,8 +57,6 @@
<app-blog-presenter [post]="post" /> <app-blog-presenter [post]="post" />
} }
</section> </section>
} @else if (!landing.loading()) {
<p class="landing-blog-empty">No announcements yet.</p>
} }
</section> </section>
@@ -35,7 +67,7 @@
data-testid="landing-modal-overlay" data-testid="landing-modal-overlay"
> >
<div <div
class="modal" class="modal hxp-border"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
[attr.aria-labelledby]="mode() === 'login' ? 'login-title' : 'register-title'" [attr.aria-labelledby]="mode() === 'login' ? 'login-title' : 'register-title'"
@@ -47,29 +79,32 @@
(click)="closeModal()" (click)="closeModal()"
aria-label="Close" aria-label="Close"
data-testid="landing-modal-close" data-testid="landing-modal-close"
>×</button> ></button>
@if (mode() === 'login') { @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> <form [formGroup]="loginForm" (ngSubmit)="submitLogin()" novalidate>
<label class="field"> <label class="field">
<span>Username</span> <span class="field-label">PLAYER</span>
<input <input
type="text" type="text"
autocomplete="username" autocomplete="username"
formControlName="username" formControlName="username"
placeholder="PLAYER NAME"
data-testid="login-username" data-testid="login-username"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Password</span> <span class="field-label">PASSWORD</span>
<input <input
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
formControlName="password" formControlName="password"
placeholder="PASSWORD"
data-testid="login-password" data-testid="login-password"
/> />
</label> </label>
@if (loginError()) { @if (loginError()) {
<small class="field-error" data-testid="login-field-error">{{ loginError() }}</small> <small class="field-error" data-testid="login-field-error">{{ loginError() }}</small>
} }
@@ -88,19 +123,21 @@
} }
</div> </div>
} }
<button <button
type="submit" type="submit"
class="primary" class="hxp-btn modal-submit"
[disabled]="submitting()" [disabled]="submitting()"
data-testid="login-submit" data-testid="login-submit"
> >
{{ submitting() ? 'Signing in...' : 'Login' }} {{ submitting() ? 'SIGNING IN...' : 'LOGIN' }}
</button> </button>
@if (registrationsEnabled()) { @if (registrationsEnabled()) {
<p class="modal-link-row" data-testid="login-switch"> <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"> <button type="button" class="link" (click)="switchToRegister()" data-testid="login-switch-btn">
Register here REGISTER
</button> </button>
</p> </p>
} }
@@ -108,35 +145,39 @@
} }
@if (mode() === 'register') { @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> <form [formGroup]="registerForm" (ngSubmit)="submitRegister()" novalidate>
<label class="field"> <label class="field">
<span>Username</span> <span class="field-label">PLAYER NAME</span>
<input <input
type="text" type="text"
autocomplete="username" autocomplete="username"
formControlName="username" formControlName="username"
placeholder="DESIRED NAME"
data-testid="register-username" data-testid="register-username"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Password</span> <span class="field-label">PASSWORD</span>
<input <input
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
formControlName="password" formControlName="password"
placeholder="PASSWORD"
data-testid="register-password" data-testid="register-password"
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Confirm password</span> <span class="field-label">CONFIRM PASSWORD</span>
<input <input
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
formControlName="passwordConfirm" formControlName="passwordConfirm"
placeholder="CONFIRM PASSWORD"
data-testid="register-confirm" data-testid="register-confirm"
/> />
</label> </label>
@if (registerError()) { @if (registerError()) {
<small class="field-error" data-testid="register-field-error">{{ registerError() }}</small> <small class="field-error" data-testid="register-field-error">{{ registerError() }}</small>
} }
@@ -155,18 +196,20 @@
} }
</div> </div>
} }
<button <button
type="submit" type="submit"
class="primary" class="hxp-btn modal-submit"
[disabled]="submitting()" [disabled]="submitting()"
data-testid="register-submit" data-testid="register-submit"
> >
{{ submitting() ? 'Registering...' : 'Register' }} {{ submitting() ? 'REGISTERING...' : 'REGISTER' }}
</button> </button>
<p class="modal-link-row" data-testid="register-switch"> <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"> <button type="button" class="link" (click)="switchToLogin()" data-testid="register-switch-btn">
Login here LOGIN
</button> </button>
</p> </p>
</form> </form>
@@ -9,7 +9,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; 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 { AuthService } from '../../core/services/auth.service';
import { BootstrapService } from '../../core/services/bootstrap.service'; import { BootstrapService } from '../../core/services/bootstrap.service';
import { MarkdownService } from '../../core/services/markdown.service'; import { MarkdownService } from '../../core/services/markdown.service';
@@ -17,6 +17,7 @@ import { LandingService } from './landing.service';
import { buildLoginFailureMessage } from './login-modal.service'; import { buildLoginFailureMessage } from './login-modal.service';
import { passwordMatchValidator } from '../setup/setup-create-admin.validators'; import { passwordMatchValidator } from '../setup/setup-create-admin.validators';
import { BlogPresenterComponent } from '../blog/blog-presenter.component'; import { BlogPresenterComponent } from '../blog/blog-presenter.component';
import { StickerLogoComponent } from '../../shared/components/sticker-logo/sticker-logo.component';
type ModalMode = 'closed' | 'login' | 'register'; type ModalMode = 'closed' | 'login' | 'register';
@@ -24,7 +25,13 @@ type ModalMode = 'closed' | 'login' | 'register';
selector: 'app-landing', selector: 'app-landing',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule, BlogPresenterComponent], imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
BlogPresenterComponent,
StickerLogoComponent,
],
templateUrl: './landing.component.html', templateUrl: './landing.component.html',
styleUrls: ['./landing.component.css'], styleUrls: ['./landing.component.css'],
}) })
@@ -42,7 +49,7 @@ export class LandingComponent implements OnInit {
readonly errorCode = signal<string | null>(null); readonly errorCode = signal<string | null>(null);
readonly retryAfterSeconds = signal<number | 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 logo = computed(() => this.bootstrap.payload()?.logo ?? '');
readonly welcomeHtml = computed(() => this.markdown.render(this.bootstrap.payload()?.welcomeMarkdown ?? '')); readonly welcomeHtml = computed(() => this.markdown.render(this.bootstrap.payload()?.welcomeMarkdown ?? ''));
readonly registrationsEnabled = computed( readonly registrationsEnabled = computed(
@@ -8,44 +8,64 @@ import { EventLogRow, formatSolveDateTime } from './scoreboard.pure';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` styles: [`
:host { display: block; } :host { display: block; max-width: 840px; margin: 0 auto; }
.empty { padding: 32px; text-align: center; opacity: 0.7; } .empty { padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono); }
ol { ol {
list-style: none; padding: 0; margin: 0; list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px; display: flex; flex-direction: column; gap: 10px;
max-height: 70vh; overflow: auto; max-height: 75vh; overflow-y: auto;
} }
li { li {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 8px 10px; padding: 12px 16px;
background: var(--color-surface, #1c1f26); background: #000000;
border: 1px solid var(--color-border, #2a2f3a); border: 2px solid #1a1a24;
border-radius: var(--radius-sm, 4px); font-family: var(--font-mono);
font-size: 13px; 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: ` template: `
<ng-container *ngIf="rows?.length; else empty"> <ng-container *ngIf="rows?.length; else empty">
<ol data-testid="scoreboard-event-log"> <ol data-testid="scoreboard-event-log">
<li *ngFor="let r of rows; trackBy: trackBySolve" [attr.data-testid]="'event-log-row-' + r.solveId"> <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]="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="who">{{ r.playerName }}</span>
<span class="what"> <span class="action">solved</span>
<span class="abbrev" *ngIf="r.categoryAbbreviation">{{ r.categoryAbbreviation }}</span> <span class="cat" *ngIf="r.categoryAbbreviation">{{ r.categoryAbbreviation }} -</span>
{{ r.challengeName }} <span class="challenge">{{ r.challengeName }}</span>
</span> <div class="pts-box">{{ r.awardedPoints }} points</div>
<span class="pts">+{{ r.awardedPoints }}</span>
</li> </li>
</ol> </ol>
</ng-container> </ng-container>
@@ -60,9 +80,9 @@ export class EventLogComponent {
format = formatSolveDateTime; format = formatSolveDateTime;
icon(r: EventLogRow): string { icon(r: EventLogRow): string {
if (r.position === 1) return ''; if (r.position === 1) return '🏆';
if (r.position === 2) return ''; if (r.position === 2) return '🥈';
if (r.position === 3) return ''; if (r.position === 3) return '🥉';
return '✓'; return '✓';
} }
@@ -8,65 +8,93 @@ import { PLAYER_COLOR_PALETTE, MatrixView } from './scoreboard.pure';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` styles: [`
:host { display: block; } :host { display: block; width: 100%; }
.empty { padding: 32px; text-align: center; opacity: 0.7; } .empty { padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono); }
.scroll { .matrix-container {
background: #000000;
border: 3px solid var(--neon-red);
box-shadow: 0 0 20px rgba(255, 0, 51, 0.25);
overflow: auto; overflow: auto;
max-height: 70vh; max-height: 75vh;
max-width: 100%;
border: 1px solid var(--color-border, #2a2f3a);
border-radius: var(--radius-sm, 4px);
background: var(--color-surface, #1c1f26);
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
width: max-content; width: max-content;
min-width: 100%; min-width: 100%;
font-family: var(--font-mono);
} }
th, td { th, td {
padding: 6px 10px; padding: 8px 10px;
border-bottom: 1px solid var(--color-border, #2a2f3a); border-bottom: 1px solid #1a1a24;
border-right: 1px solid var(--color-border, #2a2f3a); border-right: 1px solid #1a1a24;
font-size: 13px; font-size: 12px;
text-align: center; text-align: center;
min-width: 56px; min-width: 48px;
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;
} }
thead th { thead th {
position: sticky; position: sticky;
top: 0; top: 0;
background: var(--color-surface, #1c1f26); background: #0b0b10;
z-index: 2; z-index: 2;
vertical-align: bottom;
padding-top: 16px;
padding-bottom: 12px;
height: 180px;
} }
thead th.sticky-x { z-index: 3; } .th-vertical {
.abbrev { opacity: 0.65; margin-right: 6px; font-size: 11px; } writing-mode: vertical-rl;
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: middle; } transform: rotate(180deg);
.cell-gold { color: #d4a017; } font-weight: 800;
.cell-silver { color: #9aa3b0; } color: var(--neon-green);
.cell-bronze { color: #b87333; } letter-spacing: 0.5px;
.cell-check { color: var(--color-success, #22c55e); } 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: ` template: `
<ng-container *ngIf="view && view.players.length; else empty"> <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"> <table data-testid="scoreboard-matrix">
<thead> <thead>
<tr> <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 <th
*ngFor="let ch of view.challenges; trackBy: trackByCh" *ngFor="let ch of view.challenges; trackBy: trackByCh"
[attr.data-testid]="'matrix-col-' + ch.id" [attr.data-testid]="'matrix-col-' + ch.id"
> >
<span class="abbrev" *ngIf="ch.categoryAbbreviation">{{ ch.categoryAbbreviation }}</span> <div class="th-vertical">
<span>{{ ch.name }}</span> <span>{{ ch.name }}</span>
</div>
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -84,11 +112,11 @@ import { PLAYER_COLOR_PALETTE, MatrixView } from './scoreboard.pure';
[attr.data-testid]="'matrix-cell-' + p.playerId + '-' + ch.id" [attr.data-testid]="'matrix-cell-' + p.playerId + '-' + ch.id"
> >
<ng-container [ngSwitch]="view.cells[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="1" class="cell-gold" title="1st solver">🏆</span>
<span *ngSwitchCase="2" class="cell-silver" title="2nd solver"></span> <span *ngSwitchCase="2" class="cell-silver" title="2nd solver">🥈</span>
<span *ngSwitchCase="3" class="cell-bronze" title="3rd solver"></span> <span *ngSwitchCase="3" class="cell-bronze" title="3rd solver">🥉</span>
<span *ngSwitchCase="'solved'" class="cell-check" title="solved">✓</span> <span *ngSwitchCase="'solved'" class="cell-check" title="solved">✓</span>
<span *ngSwitchDefault></span> <span *ngSwitchDefault>·</span>
</ng-container> </ng-container>
</td> </td>
</tr> </tr>
@@ -8,53 +8,115 @@ import { PLAYER_COLOR_PALETTE, RankingRow } from './scoreboard.pure';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` styles: [`
:host { display: block; } :host { display: block; max-width: 860px; margin: 0 auto; }
.empty { padding: 32px; text-align: center; opacity: 0.7; } .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 { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: var(--color-surface, #1c1f26); font-family: var(--font-mono);
border-radius: var(--radius-sm, 4px);
overflow: hidden;
} }
th, td { thead th {
padding: 8px 12px; 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; text-align: left;
border-bottom: 1px solid var(--color-border, #2a2f3a);
} }
th { font-weight: 600; opacity: 0.85; } tbody tr {
.rank { width: 64px; } border-bottom: 1px solid #1a1a24;
.points { text-align: right; font-variant-numeric: tabular-nums; } transition: background-color 0.15s ease;
.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: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: ` template: `
<ng-container *ngIf="rows?.length; else empty"> <ng-container *ngIf="rows?.length; else empty">
<table data-testid="scoreboard-ranking"> <div class="ranking-container">
<thead> <table data-testid="scoreboard-ranking">
<tr> <thead>
<th class="rank" aria-sort="none" data-testid="ranking-rank-header">Rank</th> <tr>
<th>Player</th> <th class="col-rank" aria-sort="none" data-testid="ranking-rank-header">RANK</th>
<th class="solved" data-testid="ranking-solved-header">Solved</th> <th class="col-player">PLAYER</th>
<th class="points" data-testid="ranking-points-header">Points</th> <th class="col-solved" style="text-align:center;" data-testid="ranking-solved-header">SOLVES</th>
</tr> <th class="col-points" style="text-align:right;" data-testid="ranking-points-header">POINTS</th>
</thead> </tr>
<tbody> </thead>
<tr <tbody>
*ngFor="let row of rows; trackBy: trackById" <tr
[attr.data-testid]="'ranking-row-' + row.playerId" *ngFor="let row of rows; trackBy: trackById"
[attr.data-rank]="row.rank" [attr.data-testid]="'ranking-row-' + row.playerId"
> [attr.data-rank]="row.rank"
<td class="rank">{{ row.rank }}</td> >
<td> <td class="col-rank">
<span class="swatch" [style.background-color]="color(row.colorIndex)" aria-hidden="true"></span> <span class="rank-badge" [class.rank-1]="row.rank===1" [class.rank-2]="row.rank===2" [class.rank-3]="row.rank===3">
{{ row.playerName }} {{ row.rank }}
</td> </span>
<td class="solved">{{ row.solvedCount }}</td> </td>
<td class="points">{{ row.points }}</td> <td class="col-player">
</tr> <span class="swatch" [style.background-color]="color(row.colorIndex)" aria-hidden="true"></span>
</tbody> {{ row.playerName }}
</table> </td>
<td class="col-solved">{{ row.solvedCount }}</td>
<td class="col-points">{{ row.points }}</td>
</tr>
</tbody>
</table>
</div>
</ng-container> </ng-container>
<ng-template #empty> <ng-template #empty>
<p class="empty" data-testid="ranking-empty">No players yet</p> <p class="empty" data-testid="ranking-empty">No players yet</p>
@@ -9,7 +9,6 @@ import {
projectGraphPoints, projectGraphPoints,
} from './scoreboard.pure'; } from './scoreboard.pure';
interface Point { x: number; y: number; }
interface RenderedSeries extends GraphSeries { interface RenderedSeries extends GraphSeries {
pointsAttr: string; pointsAttr: string;
} }
@@ -20,22 +19,28 @@ interface RenderedSeries extends GraphSeries {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule],
styles: [` styles: [`
:host { display: block; } :host { display: block; max-width: 880px; margin: 0 auto; }
.empty, .not-started { .empty, .not-started {
padding: 32px; text-align: center; opacity: 0.7; padding: 32px; text-align: center; color: #777788; font-family: var(--font-mono);
} }
.legend { .legend {
display: flex; flex-wrap: wrap; gap: 8px 12px; display: flex; flex-wrap: wrap; justify-content: center; gap: 10px 16px;
margin-bottom: 8px; margin-top: 16px;
font-size: 12px; 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; } .swatch { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.frame { .frame {
border: 1px solid var(--color-border, #2a2f3a); border: 3px solid var(--neon-red);
border-radius: var(--radius-sm, 4px); background: #000000;
background: var(--color-surface, #1c1f26); padding: 20px;
padding: 8px; 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; } svg { display: block; width: 100%; height: auto; }
`], `],
@@ -50,6 +55,32 @@ interface RenderedSeries extends GraphSeries {
<ng-container *ngSwitchDefault> <ng-container *ngSwitchDefault>
<ng-container *ngIf="renderedSeries().length; else emptySeries"> <ng-container *ngIf="renderedSeries().length; else emptySeries">
<div class="frame" data-testid="score-graph"> <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"> <div class="legend">
<span <span
*ngFor="let s of renderedSeries(); trackBy: trackByPlayer" *ngFor="let s of renderedSeries(); trackBy: trackByPlayer"
@@ -60,28 +91,6 @@ interface RenderedSeries extends GraphSeries {
{{ s.playerName }} {{ s.playerName }}
</span> </span>
</div> </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> </div>
</ng-container> </ng-container>
<ng-template #emptySeries> <ng-template #emptySeries>
@@ -100,11 +109,11 @@ export class ScoreGraphComponent {
} }
readonly width = 800; readonly width = 800;
readonly height = 320; readonly height = 340;
readonly padL = 40; readonly padL = 50;
readonly padR = 16; readonly padR = 20;
readonly padT = 16; readonly padT = 24;
readonly padB = 28; readonly padB = 30;
private readonly _view = signal<GraphView | null>(null); private readonly _view = signal<GraphView | null>(null);
@@ -5,13 +5,14 @@ import { SCOREBOARD_TABS, ScoreboardTab } from './scoreboard.pure';
interface TabSpec { interface TabSpec {
id: ScoreboardTab; id: ScoreboardTab;
label: string; label: string;
icon: string;
} }
const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = { const TAB_SPECS: Readonly<Record<ScoreboardTab, { label: string; icon: string }>> = {
ranking: 'Ranking', ranking: { label: 'RANKINGS', icon: '🏆' },
matrix: 'Matrix', matrix: { label: 'MATRIX', icon: '▦' },
'event-log': 'Event Log', 'event-log': { label: 'EVENT LOG', icon: '≡' },
graph: 'Score Graph', graph: { label: 'SCORE GRAPH', icon: '📈' },
}; };
@Component({ @Component({
@@ -22,25 +23,32 @@ const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
styles: [` styles: [`
:host { display: block; } :host { display: block; }
nav { nav {
display: flex; gap: 6px; display: flex; gap: 10px; justify-content: center;
border-bottom: 1px solid var(--color-border, #2a2f3a); margin-bottom: 24px; flex-wrap: wrap;
padding-bottom: 6px;
margin-bottom: 12px;
overflow-x: auto;
} }
button { button {
padding: 6px 12px; padding: 10px 20px;
border-radius: var(--radius-sm, 4px); background: #000000;
background: var(--color-surface, #1c1f26); color: #aaaaee;
color: inherit; border: 2px solid #22222e;
border: 1px solid var(--color-border, #2a2f3a);
cursor: pointer; 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 { button.active {
background: var(--color-primary, #3b82f6); background: var(--neon-red);
color: var(--color-primary-contrast, #fff); color: #000000;
border-color: var(--color-primary, #3b82f6); border-color: var(--neon-red);
box-shadow: 0 0 15px rgba(255, 0, 51, 0.4);
} }
`], `],
template: ` template: `
@@ -51,7 +59,10 @@ const TAB_LABELS: Readonly<Record<ScoreboardTab, string>> = {
[class.active]="t.id === active" [class.active]="t.id === active"
[attr.data-testid]="'scoreboard-tab-' + t.id" [attr.data-testid]="'scoreboard-tab-' + t.id"
(click)="select.emit(t.id)" (click)="select.emit(t.id)"
>{{ t.label }}</button> >
<span>{{ t.icon }}</span>
<span>{{ t.label }}</span>
</button>
</nav> </nav>
`, `,
}) })
@@ -59,5 +70,9 @@ export class ScoreboardTabsComponent {
@Input({ required: true }) active!: ScoreboardTab; @Input({ required: true }) active!: ScoreboardTab;
@Output() select = new EventEmitter<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, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule], 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: ` template: `
@if (open()) { @if (open()) {
<div class="modal-overlay" data-testid="change-password-overlay" (click)="onCancel()"> <div class="modal-overlay" data-testid="change-password-overlay" (click)="onCancel()">
@@ -44,27 +106,29 @@ const DEFAULT_POLICY: PasswordPolicy = {
data-testid="change-password-modal" data-testid="change-password-modal"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<h2 id="cp-title">Change Password</h2> <h2 id="cp-title">CHANGE PASSWORD</h2>
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <form [formGroup]="form" (ngSubmit)="onSubmit()">
@if (mode() === 'self') { @if (mode() === 'self') {
<label class="field"> <label class="field">
<span>Old password</span> <span>OLD PASSWORD</span>
<input <input
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
formControlName="oldPassword" formControlName="oldPassword"
placeholder="CURRENT PASSWORD"
data-testid="cp-old" data-testid="cp-old"
/> />
</label> </label>
} }
<label class="field"> <label class="field">
<span>New password</span> <span>NEW PASSWORD</span>
<input <input
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
formControlName="newPassword" formControlName="newPassword"
placeholder="NEW PASSWORD"
data-testid="cp-new" data-testid="cp-new"
/> />
@if (fieldErrorFor('newPassword'); as msg) { @if (fieldErrorFor('newPassword'); as msg) {
@@ -73,11 +137,12 @@ const DEFAULT_POLICY: PasswordPolicy = {
</label> </label>
<label class="field"> <label class="field">
<span>Confirm new password</span> <span>CONFIRM NEW PASSWORD</span>
<input <input
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
formControlName="confirmNewPassword" formControlName="confirmNewPassword"
placeholder="CONFIRM NEW PASSWORD"
data-testid="cp-confirm" data-testid="cp-confirm"
/> />
@if (fieldErrorFor('confirmPassword'); as msg) { @if (fieldErrorFor('confirmPassword'); as msg) {
@@ -99,18 +164,20 @@ const DEFAULT_POLICY: PasswordPolicy = {
<div class="actions"> <div class="actions">
<button <button
type="button" type="button"
class="btn-cancel"
data-testid="cp-cancel" data-testid="cp-cancel"
(click)="onCancel()" (click)="onCancel()"
[disabled]="submitting()" [disabled]="submitting()"
> >
Cancel CANCEL
</button> </button>
<button <button
type="submit" type="submit"
class="btn-ok"
data-testid="cp-ok" data-testid="cp-ok"
[disabled]="submitting()" [disabled]="submitting()"
> >
OK {{ submitting() ? 'SAVING...' : 'OK' }}
</button> </button>
</div> </div>
</form> </form>
@@ -1,13 +1,92 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { EventState, ledBackgroundColor } from '../../../core/services/event-status.store'; import { EventState, ledBackgroundColor } from '../../../core/services/event-status.store';
import { StickerLogoComponent } from '../../../shared/components/sticker-logo/sticker-logo.component';
@Component({ @Component({
selector: 'app-shell-header', selector: 'app-shell-header',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule, StickerLogoComponent],
styles: [` 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 { .led {
display: inline-block; display: inline-block;
width: 10px; width: 10px;
@@ -15,35 +94,164 @@ import { EventState, ledBackgroundColor } from '../../../core/services/event-sta
border-radius: 50%; border-radius: 50%;
border: 0; border: 0;
vertical-align: middle; 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: ` template: `
<header class="shell-header" data-testid="shell-header"> <header class="shell-header" data-testid="shell-header">
<button <div class="shell-left" (click)="titleClick.emit()">
type="button" @if (logoUrl()) {
class="shell-title" <img class="shell-logo" [src]="logoUrl()" alt="Site Logo" data-testid="shell-logo" />
data-testid="shell-title" } @else {
(click)="titleClick.emit()" <app-sticker-logo [size]="32" />
> }
{{ pageTitle() }} <button
</button> type="button"
class="shell-title"
data-testid="shell-title"
>
{{ pageTitle() }}
</button>
</div>
<div class="shell-section" data-testid="shell-active-section"> <div class="shell-section" data-testid="shell-active-section">
{{ activeSection() }} <span class="shell-section-icon">{{ sectionIcon() }}</span>
<span>{{ activeSection() }}</span>
</div> </div>
<div class="shell-right"> <div class="shell-right">
<span <div class="led-wrapper">
class="led" <span
[class.led-running]="eventState() === 'running'" class="led"
[class.led-countdown]="eventState() === 'countdown'" [class.led-running]="eventState() === 'running'"
[class.led-stopped]="eventState() === 'stopped'" [class.led-countdown]="eventState() === 'countdown'"
[class.led-unconfigured]="eventState() === 'unconfigured'" [class.led-stopped]="eventState() === 'stopped'"
[style.background-color]="ledColor()" [class.led-unconfigured]="eventState() === 'unconfigured'"
data-testid="shell-led" [style.background-color]="ledColor()"
[attr.aria-label]="'Event status: ' + eventState()" data-testid="shell-led"
></span> [attr.aria-label]="'Event status: ' + eventState()"
<span class="countdown" data-testid="shell-countdown">{{ countdownText() }}</span> ></span>
<span class="countdown" data-testid="shell-countdown">{{ countdownText() }}</span>
</div>
<div class="user-menu-wrapper"> <div class="user-menu-wrapper">
<button <button
@@ -52,44 +260,54 @@ import { EventState, ledBackgroundColor } from '../../../core/services/event-sta
data-testid="user-menu-trigger" data-testid="user-menu-trigger"
(click)="usernameMenuToggle.emit()" (click)="usernameMenuToggle.emit()"
> >
{{ username() }} <span aria-hidden="true">▾</span> <span>{{ username() }}</span>
<span aria-hidden="true">▾</span>
</button> </button>
@if (userMenuOpen()) { @if (userMenuOpen()) {
<div class="user-menu" role="menu" data-testid="user-menu"> <div class="user-menu" role="menu" data-testid="user-menu">
<button <div class="user-menu-header">
type="button" <div class="user-menu-label">PLAYER</div>
class="user-menu-item" <div class="user-menu-name">{{ username() }}</div>
data-testid="user-menu-rank" <button
(click)="rankClick.emit()" type="button"
> class="user-menu-score-pill"
{{ rankText() }} data-testid="user-menu-rank"
</button> (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 <button
type="button" type="button"
class="user-menu-item" class="user-menu-item"
data-testid="user-menu-change-password" data-testid="user-menu-change-password"
(click)="changePasswordClick.emit()" (click)="changePasswordClick.emit()"
> >
Change password <span>🔑</span> CHANGE PASSWORD
</button> </button>
@if (canAccessAdmin()) {
<button <div class="user-menu-divider"></div>
type="button"
class="user-menu-item"
data-testid="user-menu-admin"
(click)="adminClick.emit()"
>
Admin area
</button>
}
<button <button
type="button" type="button"
class="user-menu-item" class="user-menu-item"
data-testid="user-menu-logout" data-testid="user-menu-logout"
(click)="logoutClick.emit()" (click)="logoutClick.emit()"
> >
Logout <span>↳</span> LOGOUT
</button> </button>
</div> </div>
} }
@@ -100,6 +318,7 @@ import { EventState, ledBackgroundColor } from '../../../core/services/event-sta
}) })
export class ShellHeaderComponent { export class ShellHeaderComponent {
readonly pageTitle = input<string>('HIPCTF'); readonly pageTitle = input<string>('HIPCTF');
readonly logoUrl = input<string>('');
readonly activeSection = input<string>('Challenges'); readonly activeSection = input<string>('Challenges');
readonly eventState = input<EventState>('unconfigured'); readonly eventState = input<EventState>('unconfigured');
readonly countdownText = input<string>(''); readonly countdownText = input<string>('');
@@ -116,4 +335,13 @@ export class ShellHeaderComponent {
readonly logoutClick = output<void>(); readonly logoutClick = output<void>();
readonly ledColor = computed(() => ledBackgroundColor(this.eventState())); 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 '>_';
});
} }
@@ -0,0 +1,99 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-category-icon',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.cat-box {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.03);
border: 1px solid #33333a;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
user-select: none;
}
.cat-box:hover {
border-color: var(--neon-purple);
box-shadow: 0 0 10px rgba(176, 0, 255, 0.2);
}
.cat-img {
object-fit: cover;
}
`],
template: `
@if (iconPath()) {
<span class="cat-box" [style.width.px]="size()" [style.height.px]="size()">
<img class="cat-img" [src]="iconPath()" [style.width.px]="iconSize()" [style.height.px]="iconSize()" alt="" />
</span>
} @else {
<span class="cat-box" [style.width.px]="size()" [style.height.px]="size()" data-testid="category-icon">
@switch (normCategory()) {
@case ('WEB') {
<!-- WEB: Red Radar -->
<svg [attr.width]="iconSize()" [attr.height]="iconSize()" viewBox="0 0 24 24" fill="none" stroke="#ff0033" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19.07 4.93A10 10 0 0 0 6.99 3.34" />
<path d="M4.93 19.07A10 10 0 0 0 20.66 17" />
<path d="M2 12h20" />
<path d="M12 2v20" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
}
@case ('PWN') {
<!-- PWN: Yellow Zap -->
<svg [attr.width]="iconSize()" [attr.height]="iconSize()" viewBox="0 0 24 24" fill="none" stroke="#ffaa00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
}
@case ('REV') {
<!-- REV: Cyan RefreshCw -->
<svg [attr.width]="iconSize()" [attr.height]="iconSize()" viewBox="0 0 24 24" fill="none" stroke="#00f0ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
<path d="M3 21v-5h5" />
</svg>
}
@case ('CRY') {
<!-- CRY: Purple Box -->
<svg [attr.width]="iconSize()" [attr.height]="iconSize()" viewBox="0 0 24 24" fill="none" stroke="#b000ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
}
@case ('HW') {
<!-- HW: Green Microchip -->
<svg [attr.width]="iconSize()" [attr.height]="iconSize()" viewBox="0 0 24 24" fill="none" stroke="#00ff66" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="9" y="9" width="6" height="6" />
<path d="M15 2v2M9 2v2M15 20v2M9 20v2M2 15h2M2 9h2M20 15h2M20 9h2" />
</svg>
}
@default {
<!-- MSC / Default: Purple Terminal -->
<svg [attr.width]="iconSize()" [attr.height]="iconSize()" viewBox="0 0 24 24" fill="none" stroke="#b000ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" />
</svg>
}
}
</span>
}
`,
})
export class CategoryIconComponent {
readonly category = input<string>('MSC');
readonly iconPath = input<string | null | undefined>(null);
readonly size = input<number>(44);
readonly color = input<string | null>(null);
readonly normCategory = computed(() => (this.category() || 'MSC').trim().toUpperCase());
readonly iconSize = computed(() => Math.round(this.size() * 0.58));
}
@@ -0,0 +1,49 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-sticker-logo',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.hip-sticker {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--neon-red, #ff0033);
color: #ffffff;
border-radius: 50%;
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
font-weight: 900;
font-style: italic;
text-transform: uppercase;
user-select: none;
box-shadow: 0 0 15px rgba(255, 0, 51, 0.4);
transform: rotate(-12deg);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hip-sticker:hover {
transform: rotate(-5deg) scale(1.05);
box-shadow: 0 0 25px rgba(255, 0, 51, 0.7);
}
.hip-text {
line-height: 1;
letter-spacing: -0.5px;
}
`],
template: `
<div
class="hip-sticker"
[style.width.px]="size()"
[style.height.px]="size()"
[style.font-size.px]="fontSize()"
>
<span class="hip-text">HIP!</span>
</div>
`,
})
export class StickerLogoComponent {
readonly size = input<number>(36);
readonly fontSize = computed(() => Math.round(this.size() * 0.42));
}
+3
View File
@@ -5,6 +5,9 @@
<title>HIPCTF</title> <title>HIPCTF</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" /> <base href="/" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:ital,wght@0,600;0,700;1,700;1,900&family=JetBrains+Mono:ital,wght@0,400;0,700;0,800;1,700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
+176 -20
View File
@@ -1,23 +1,179 @@
:root { :root {
--color-primary: #3b82f6; --color-primary: #ff0033;
--color-secondary: #64748b; --color-secondary: #b000ff;
--color-accent: #f59e0b; --color-accent: #00f0ff;
--color-surface: #ffffff; --color-surface: #050505;
--color-text: #0f172a; --color-surface-elevated: #121215;
--color-success: #10b981; --color-bg: var(--color-surface, #050505);
--color-warning: #f59e0b; --color-text: #ffffff;
--color-danger: #ef4444; --color-text-muted: #888899;
--radius-sm: 4px; --color-success: #00ff66;
--radius-md: 8px; --color-warning: #ffea00;
--radius-lg: 16px; --color-danger: #ff0033;
--font-family: system-ui, sans-serif;
--neon-red: var(--color-primary, #ff0033);
--neon-purple: var(--color-secondary, #b000ff);
--neon-cyan: var(--color-accent, #00f0ff);
--neon-green: var(--color-success, #00ff66);
--neon-yellow: var(--color-warning, #ffea00);
--border-red: 2px solid var(--neon-red);
--border-purple: 2px solid var(--neon-purple);
--border-cyan: 2px solid var(--neon-cyan);
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
--font-heading: var(--font-family, 'Chakra Petch', sans-serif);
} }
* { box-sizing: border-box; } * {
html, body { margin: 0; padding: 0; height: 100%; font-family: var(--font-family); background: var(--color-surface); color: var(--color-text); } box-sizing: border-box;
button { font: inherit; padding: 8px 16px; border: 0; border-radius: var(--radius-md); background: var(--color-primary); color: #fff; cursor: pointer; } margin: 0;
button[disabled] { opacity: .5; cursor: not-allowed; } padding: 0;
input { font: inherit; padding: 8px 12px; border: 1px solid var(--color-secondary); border-radius: var(--radius-sm); width: 100%; } }
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
.card { padding: 24px; border-radius: var(--radius-lg); box-shadow: 0 1px 4px rgba(0,0,0,.1); } html, body {
.led { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 0; background-color: transparent; vertical-align: middle; } height: 100%;
font-family: var(--font-mono);
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
background-image:
radial-gradient(circle at 50% 50%, rgba(20, 20, 25, 0.4) 0%, rgba(5, 5, 5, 0.95) 100%),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cg fill='%231a1a24' fill-opacity='0.18'%3E%3Cpath d='M60 15c-15 0-25 10-25 25 0 12 8 20 15 23v7h20v-7c7-3 15-11 15-23 0-15-10-25-25-25zm-10 20a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm20 0a5 5 0 1 1 0-10 5 5 0 0 1 0 10zM15 75h90v4H15zm10 15h70v4H25z'/%3E%3Cpath d='M0 0h120v120H0z' fill='none' stroke='%23ffffff' stroke-opacity='0.02' stroke-width='1'/%3E%3C/g%3E%3C/svg%3E");
background-attachment: fixed;
}
/* Custom Scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #000000;
}
::-webkit-scrollbar-thumb {
background: var(--neon-red);
border-radius: 0px;
}
::-webkit-scrollbar-thumb:hover {
background: #e0002d;
}
/* Typography Utilities */
.font-italic-heading {
font-family: var(--font-heading);
font-style: italic;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Cyberpunk Border & Card Helpers */
.hxp-border {
border: 3px solid var(--neon-red);
}
.hxp-border-purple {
border: 2px solid var(--neon-purple);
}
.hxp-border-cyan {
border: 2px solid var(--neon-cyan);
}
.hxp-card {
background-color: #000000;
border: 2px solid #22222c;
transition: all 0.15s ease-in-out;
}
.hxp-card:hover {
border-color: var(--neon-purple);
box-shadow: 0 0 12px var(--neon-purple-glow);
}
/* Buttons */
button, .hxp-btn {
font-family: var(--font-mono);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 10px 20px;
border: 2px solid var(--neon-red);
background-color: var(--neon-red);
color: #000000;
cursor: pointer;
transition: all 0.15s ease-in-out;
}
button:hover:not([disabled]), .hxp-btn:hover:not([disabled]) {
background-color: #000000;
color: var(--neon-red);
box-shadow: 0 0 15px rgba(255, 0, 51, 0.4);
}
button[disabled], .hxp-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.hxp-btn-secondary {
background-color: #000000;
color: var(--neon-purple);
border: 2px solid var(--neon-purple);
}
.hxp-btn-secondary:hover:not([disabled]) {
background-color: var(--neon-purple);
color: #000000;
box-shadow: 0 0 15px var(--neon-purple-glow);
}
/* Form Controls */
input[type="text"],
input[type="password"],
input[type="number"],
textarea,
select {
font-family: var(--font-mono);
background-color: #000000;
color: #ffffff;
border: 2px solid var(--neon-purple);
padding: 10px 14px;
width: 100%;
outline: none;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--neon-cyan);
box-shadow: 0 0 10px var(--neon-cyan-glow);
}
/* Badges */
.badge-low {
color: var(--neon-green);
font-weight: bold;
}
.badge-medium {
color: var(--neon-yellow);
font-weight: bold;
}
.badge-high {
color: var(--neon-red);
font-weight: bold;
}
/* LED Indicator */
.led {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 0;
vertical-align: middle;
}
@@ -1,9 +1,6 @@
process.env.DATABASE_PATH = ':memory:'; process.env.DATABASE_PATH = ':memory:';
process.env.FRONTEND_DIST = './frontend/dist'; process.env.FRONTEND_DIST = './frontend/dist';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
// Intentionally do NOT set process.env.THEMES_DIR — this spec exercises the
// default path-resolution behaviour so the test fails if the default ever
// regresses to a CWD-relative path.
import { AdminGeneralService } from '../../backend/src/modules/admin/general.service'; import { AdminGeneralService } from '../../backend/src/modules/admin/general.service';
import { THEME_IDS } from '../../backend/src/common/types/theme-ids'; import { THEME_IDS } from '../../backend/src/common/types/theme-ids';
@@ -20,16 +17,14 @@ describe('AdminGeneralService.listThemes - default THEMES_DIR resolution (Job 89
const fakeSettings = { get: jest.fn(), set: jest.fn() } as any; const fakeSettings = { get: jest.fn(), set: jest.fn() } as any;
const fakeHub = { emitEvent: jest.fn() } as any; const fakeHub = { emitEvent: jest.fn() } as any;
const fakeThemes = { listThemes: () => makeFakeThemeLoader() } as any; const fakeThemes = { listThemes: () => makeFakeThemeLoader() } as any;
// Mimic the production Nest ConfigService: .get('THEMES_DIR', './themes')
// returns the literal default string when no env override is present.
const fakeConfig = { const fakeConfig = {
get: jest.fn().mockImplementation((key: string, fallback?: any) => fallback), get: jest.fn().mockImplementation((key: string, fallback?: any) => fallback),
} as any; } as any;
it('returns all 10 canonical themes when THEMES_DIR is left at its default', () => { it('returns all canonical themes when THEMES_DIR is left at its default', () => {
const svc = new AdminGeneralService(fakeSettings, fakeThemes, fakeHub, fakeConfig); const svc = new AdminGeneralService(fakeSettings, fakeThemes, fakeHub, fakeConfig);
const themes = svc.listThemes(); const themes = svc.listThemes();
expect(themes.length).toBe(10); expect(themes.length).toBe(THEME_IDS.length);
const ids = themes.map((t) => t.id).sort(); const ids = themes.map((t) => t.id).sort();
expect(ids).toEqual([...THEME_IDS].sort()); expect(ids).toEqual([...THEME_IDS].sort());
}); });
@@ -44,11 +39,11 @@ describe('AdminGeneralService.listThemes - default THEMES_DIR resolution (Job 89
} }
}); });
it('produces the same 10-entry list on repeated calls (no I/O ordering surprises)', () => { it('produces the same canonical entry list on repeated calls', () => {
const svc = new AdminGeneralService(fakeSettings, fakeThemes, fakeHub, fakeConfig); const svc = new AdminGeneralService(fakeSettings, fakeThemes, fakeHub, fakeConfig);
const a = svc.listThemes().map((t) => t.id).sort(); const a = svc.listThemes().map((t) => t.id).sort();
const b = svc.listThemes().map((t) => t.id).sort(); const b = svc.listThemes().map((t) => t.id).sort();
expect(b).toEqual(a); expect(b).toEqual(a);
expect(a.length).toBe(10); expect(a.length).toBe(THEME_IDS.length);
}); });
}); });
+3 -3
View File
@@ -267,7 +267,7 @@ describe('AdminGeneralService.listThemes - filter to on-disk themes', () => {
} }
}); });
it('returns the 10 theme entries that have on-disk files', () => { it('returns all theme entries that have on-disk files', () => {
const svc = new AdminGeneralService( const svc = new AdminGeneralService(
{ get: jest.fn(), set: jest.fn() } as any, { get: jest.fn(), set: jest.fn() } as any,
{ listThemes: () => makeFakeThemeLoader() } as any, { listThemes: () => makeFakeThemeLoader() } as any,
@@ -275,7 +275,7 @@ describe('AdminGeneralService.listThemes - filter to on-disk themes', () => {
{ get: jest.fn().mockReturnValue(themesDir) } as any, { get: jest.fn().mockReturnValue(themesDir) } as any,
); );
const themes = svc.listThemes(); const themes = svc.listThemes();
expect(themes.length).toBe(10); expect(themes.length).toBe(THEME_IDS.length);
for (const t of themes) { for (const t of themes) {
expect(t.id).toBe(t.key); expect(t.id).toBe(t.key);
expect(typeof t.name).toBe('string'); expect(typeof t.name).toBe('string');
@@ -292,7 +292,7 @@ describe('AdminGeneralService.listThemes - filter to on-disk themes', () => {
); );
const themes = svc.listThemes(); const themes = svc.listThemes();
expect(themes.find((t) => t.id === 'classic')).toBeUndefined(); expect(themes.find((t) => t.id === 'classic')).toBeUndefined();
expect(themes.length).toBe(9); expect(themes.length).toBe(THEME_IDS.length - 1);
}); });
}); });
+11 -12
View File
@@ -12,7 +12,7 @@ import { DatabaseModule } from '../../backend/src/database/database.module';
import { DatabaseInitService } from '../../backend/src/database/database-init.service'; import { DatabaseInitService } from '../../backend/src/database/database-init.service';
import { THEME_IDS } from '../../backend/src/common/types/theme-ids'; import { THEME_IDS } from '../../backend/src/common/types/theme-ids';
describe('ThemeLoaderService - require 10 + validate configured key', () => { describe('ThemeLoaderService - require canonical + validate configured key', () => {
let app: INestApplication; let app: INestApplication;
let svc: ThemeLoaderService; let svc: ThemeLoaderService;
@@ -41,12 +41,12 @@ describe('ThemeLoaderService - require 10 + validate configured key', () => {
if (app) await app.close(); if (app) await app.close();
}); });
it('exposes all 10 canonical theme ids even when THEMES_DIR is empty', async () => { it('exposes all canonical theme ids even when THEMES_DIR is empty', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-empty-')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-empty-'));
await buildApp(tmp, 'classic'); await buildApp(tmp, 'hip');
const ids = svc.listThemes().map((t) => t.id).sort(); const ids = svc.listThemes().map((t) => t.id).sort();
expect(ids).toEqual([ expect(ids).toEqual([
'classic', 'crimson', 'cyber', 'forest', 'midnight', 'classic', 'crimson', 'cyber', 'forest', 'hip', 'midnight',
'monochrome', 'neon', 'ocean', 'paper', 'sunset', 'monochrome', 'neon', 'ocean', 'paper', 'sunset',
]); ]);
expect(svc.listThemes().length).toBe(THEME_IDS.length); expect(svc.listThemes().length).toBe(THEME_IDS.length);
@@ -54,8 +54,7 @@ describe('ThemeLoaderService - require 10 + validate configured key', () => {
it('backfills missing disk themes with built-ins (per-id warn)', async () => { it('backfills missing disk themes with built-ins (per-id warn)', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-partial-')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-partial-'));
// Only write 3 of the 10 themes to disk. for (const id of ['hip', 'classic', 'midnight', 'cyber']) {
for (const id of ['classic', 'midnight', 'cyber']) {
fs.writeFileSync(path.join(tmp, `${id}.json`), JSON.stringify({ fs.writeFileSync(path.join(tmp, `${id}.json`), JSON.stringify({
id, id,
name: id, name: id,
@@ -67,7 +66,7 @@ describe('ThemeLoaderService - require 10 + validate configured key', () => {
}, },
})); }));
} }
await buildApp(tmp, 'classic'); await buildApp(tmp, 'hip');
expect(svc.listThemes().length).toBe(THEME_IDS.length); expect(svc.listThemes().length).toBe(THEME_IDS.length);
}); });
@@ -79,21 +78,21 @@ describe('ThemeLoaderService - require 10 + validate configured key', () => {
expect(theme.id).toBe('ocean'); expect(theme.id).toBe('ocean');
}); });
it('falls back to classic and warns when configured themeKey is invalid', async () => { it('falls back to default and warns when configured themeKey is invalid', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-'));
await buildApp(tmp, 'this-theme-does-not-exist'); await buildApp(tmp, 'this-theme-does-not-exist');
expect(svc.getDefaultId()).toBe('classic'); expect(svc.getDefaultId()).toBe('hip');
}); });
it('falls back to classic and warns when configured themeKey is empty', async () => { it('falls back to default and warns when configured themeKey is empty', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-'));
await buildApp(tmp, ''); await buildApp(tmp, '');
expect(svc.getDefaultId()).toBe('classic'); expect(svc.getDefaultId()).toBe('hip');
}); });
it('getTheme() returns a real theme for any canonical id', async () => { it('getTheme() returns a real theme for any canonical id', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'themes-'));
await buildApp(tmp, 'classic'); await buildApp(tmp, 'hip');
for (const id of THEME_IDS) { for (const id of THEME_IDS) {
expect(svc.getTheme(id).id).toBe(id); expect(svc.getTheme(id).id).toBe(id);
} }
+4 -4
View File
@@ -1,10 +1,10 @@
import { ThemeLoaderService, BUILTIN_THEMES } from '../../backend/src/common/utils/theme-loader.service'; import { ThemeLoaderService, BUILTIN_THEMES } from '../../backend/src/common/utils/theme-loader.service';
describe('ThemeLoaderService builtins', () => { describe('ThemeLoaderService builtins', () => {
it('includes all 10 canonical theme ids', () => { it('includes all canonical theme ids', () => {
const ids = BUILTIN_THEMES.map((t) => t.id).sort(); const ids = BUILTIN_THEMES.map((t) => t.id).sort();
expect(ids).toEqual([ expect(ids).toEqual([
'classic', 'crimson', 'cyber', 'forest', 'midnight', 'classic', 'crimson', 'cyber', 'forest', 'hip', 'midnight',
'monochrome', 'neon', 'ocean', 'paper', 'sunset', 'monochrome', 'neon', 'ocean', 'paper', 'sunset',
]); ]);
}); });
@@ -19,11 +19,11 @@ describe('ThemeLoaderService builtins', () => {
it('falls back to default for unknown id', () => { it('falls back to default for unknown id', () => {
const config = { get: jest.fn((k: string, d: any) => d) } as any; const config = { get: jest.fn((k: string, d: any) => d) } as any;
const settings = { get: jest.fn().mockResolvedValue('classic') } as any; const settings = { get: jest.fn().mockResolvedValue('hip') } as any;
const svc = new ThemeLoaderService(config, settings); const svc = new ThemeLoaderService(config, settings);
return svc.onModuleInit().then(() => { return svc.onModuleInit().then(() => {
const t = svc.getTheme('totally-unknown'); const t = svc.getTheme('totally-unknown');
expect(t.id).toBe('classic'); expect(t.id).toBe('hip');
}); });
}); });
}); });