AI Implementation feature(892): Admin Area General Settings and Categories 1.10 (#32)
This commit was merged in pull request #32.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { BootstrapService } from './core/services/bootstrap.service';
|
||||
import { BootstrapEventService } from './core/services/bootstrap-event.service';
|
||||
import { AuthService } from './core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
@@ -9,11 +10,17 @@ import { AuthService } from './core/services/auth.service';
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet></router-outlet>`,
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
private bootstrap = inject(BootstrapService);
|
||||
private auth = inject(AuthService);
|
||||
private readonly bootstrapEvent = inject(BootstrapEventService);
|
||||
async ngOnInit() {
|
||||
await this.bootstrap.load();
|
||||
await this.auth.restoreSession();
|
||||
this.bootstrapEvent.start();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.bootstrapEvent.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { EventSourceLike } from './event-status.pure';
|
||||
|
||||
export function isBootstrapGeneralFrame(frame: unknown): frame is { topic: 'general'; themeKey?: string; registrationsEnabled?: boolean } {
|
||||
if (!frame || typeof frame !== 'object') return false;
|
||||
const f = frame as Record<string, unknown>;
|
||||
return f['topic'] === 'general';
|
||||
}
|
||||
|
||||
export type BootstrapGeneralPayload = { topic: 'general'; themeKey?: string; registrationsEnabled?: boolean };
|
||||
|
||||
export function parseSseDataLines(raw: string): string | null {
|
||||
let event = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith(':')) continue;
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
return dataLines.join('\n');
|
||||
}
|
||||
|
||||
export function makeBootstrapEventSource(url: string, fetchImpl: typeof globalThis.fetch = fetch): EventSourceLike {
|
||||
const listeners = new Map<string, Array<(ev: MessageEvent | Event) => void>>();
|
||||
let controller: AbortController | null = new AbortController();
|
||||
let closed = false;
|
||||
let buffered: string[] = [];
|
||||
|
||||
const dispatch = (type: string, ev: MessageEvent | Event) => {
|
||||
const arr = listeners.get(type);
|
||||
if (!arr) return;
|
||||
for (const fn of arr) {
|
||||
try {
|
||||
fn(ev);
|
||||
} catch {
|
||||
// ignore listener errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const flushBuffered = () => {
|
||||
if (closed) return;
|
||||
const items = buffered;
|
||||
buffered = [];
|
||||
for (const data of items) {
|
||||
dispatch('message', new MessageEvent('message', { data }));
|
||||
}
|
||||
};
|
||||
|
||||
const fireError = () => {
|
||||
if (closed) return;
|
||||
dispatch('error', new Event('error'));
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
credentials: 'include',
|
||||
signal: controller!.signal,
|
||||
});
|
||||
if (!res.ok || !(res as unknown as { body?: unknown }).body) {
|
||||
fireError();
|
||||
return;
|
||||
}
|
||||
const body = (res as unknown as { body: ReadableStream<Uint8Array> }).body;
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
||||
const raw = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
const payload = parseSseDataLines(raw);
|
||||
if (payload === null) continue;
|
||||
if (listeners.has('message')) {
|
||||
dispatch('message', new MessageEvent('message', { data: payload }));
|
||||
} else {
|
||||
buffered.push(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!closed) fireError();
|
||||
}
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return {
|
||||
addEventListener(type, listener) {
|
||||
const arr = listeners.get(type) ?? [];
|
||||
arr.push(listener);
|
||||
listeners.set(type, arr);
|
||||
if (type === 'message' && buffered.length > 0) {
|
||||
queueMicrotask(flushBuffered);
|
||||
}
|
||||
},
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (controller) {
|
||||
try {
|
||||
controller.abort();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
controller = null;
|
||||
}
|
||||
listeners.clear();
|
||||
buffered = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { BootstrapService } from './bootstrap.service';
|
||||
import { EventSourceLike } from './event-status.pure';
|
||||
import {
|
||||
isBootstrapGeneralFrame,
|
||||
makeBootstrapEventSource,
|
||||
} from './bootstrap-event.pure';
|
||||
|
||||
export {
|
||||
isBootstrapGeneralFrame,
|
||||
parseSseDataLines,
|
||||
makeBootstrapEventSource as defaultCreateSource,
|
||||
} from './bootstrap-event.pure';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BootstrapEventService {
|
||||
private readonly bootstrap = inject(BootstrapService);
|
||||
private current: EventSourceLike | null = null;
|
||||
private messageHandler: ((ev: MessageEvent | Event) => void) | null = null;
|
||||
private errorHandler: ((ev: MessageEvent | Event) => void) | null = null;
|
||||
|
||||
start(createSource: (url: string) => EventSourceLike = makeBootstrapEventSource): void {
|
||||
if (this.current) return;
|
||||
const source = createSource('/api/v1/event/stream');
|
||||
this.messageHandler = (ev) => this.handleMessage(ev);
|
||||
this.errorHandler = () => {
|
||||
this.stop();
|
||||
};
|
||||
source.addEventListener('message', this.messageHandler);
|
||||
source.addEventListener('error', this.errorHandler);
|
||||
this.current = source;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const src = this.current;
|
||||
if (!src) return;
|
||||
this.current = null;
|
||||
this.messageHandler = null;
|
||||
this.errorHandler = null;
|
||||
try {
|
||||
src.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.current !== null;
|
||||
}
|
||||
|
||||
handleFrame(frame: unknown): Promise<void> {
|
||||
if (!isBootstrapGeneralFrame(frame)) return Promise.resolve();
|
||||
return this.bootstrap.refresh().then(() => undefined);
|
||||
}
|
||||
|
||||
private handleMessage(ev: MessageEvent | Event): void {
|
||||
const data = (ev as MessageEvent).data;
|
||||
if (typeof data !== 'string') return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void this.handleFrame(parsed);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export class LandingComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.landing.refresh();
|
||||
void this.bootstrap.refresh();
|
||||
}
|
||||
|
||||
openLogin(): void {
|
||||
|
||||
@@ -43,4 +43,14 @@ export function buildLoginFailureMessage(input: FailureMessageInput): FailureMes
|
||||
default:
|
||||
return { text: input.message || 'Something went wrong. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure predicate that mirrors `landing.component.html`'s `@if (registrationsEnabled())`
|
||||
* guard around the "Register here" / "Login here" switch button. Keeping it here
|
||||
* lets the test suite lock in the contract "the modal exposes a Register control
|
||||
* iff the bootstrap payload says the API will accept a registration."
|
||||
*/
|
||||
export function landingModalShowsRegister(registrationsEnabled: unknown): boolean {
|
||||
return registrationsEnabled === true;
|
||||
}
|
||||
Reference in New Issue
Block a user