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