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