first commit
This commit is contained in:
12
fog/apps/twitch-extension-panel/Dockerfile
Executable file
12
fog/apps/twitch-extension-panel/Dockerfile
Executable file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npx nx build twitch-extension-panel
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /workspace/dist/apps/twitch-extension-panel/browser /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
26
fog/apps/twitch-extension-panel/project.json
Executable file
26
fog/apps/twitch-extension-panel/project.json
Executable file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "twitch-extension-panel",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/twitch-extension-panel/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/twitch-extension-panel",
|
||||
"index": "apps/twitch-extension-panel/src/index.html",
|
||||
"browser": "apps/twitch-extension-panel/src/main.ts",
|
||||
"tsConfig": "apps/twitch-extension-panel/tsconfig.app.json",
|
||||
"assets": [],
|
||||
"styles": ["apps/twitch-extension-panel/src/styles.css"]
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/angular:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "twitch-extension-panel:build"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["scope:panel"]
|
||||
}
|
||||
28
fog/apps/twitch-extension-panel/src/app/live-log.component.ts
Executable file
28
fog/apps/twitch-extension-panel/src/app/live-log.component.ts
Executable file
@@ -0,0 +1,28 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { EncounterLogLine } from '@fog-explorer/api-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'fog-live-log',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="panel-card">
|
||||
<h3>Live Log</h3>
|
||||
<div class="log-list">
|
||||
<p *ngFor="let line of lines">#{{ line.sequence }} - {{ line.text }}</p>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.panel-card { padding: 0.75rem; background: #1a1e2a; border-radius: 8px; }
|
||||
.log-list { max-height: 220px; overflow: auto; }
|
||||
p { margin: 0 0 0.5rem; font-size: 0.9rem; }
|
||||
`
|
||||
]
|
||||
})
|
||||
export class LiveLogComponent {
|
||||
@Input({ required: true }) lines: EncounterLogLine[] = [];
|
||||
}
|
||||
66
fog/apps/twitch-extension-panel/src/app/panel-shell.component.ts
Executable file
66
fog/apps/twitch-extension-panel/src/app/panel-shell.component.ts
Executable file
@@ -0,0 +1,66 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, computed, inject } from '@angular/core';
|
||||
|
||||
import { MissionDifficulty } from '@fog-explorer/api-interfaces';
|
||||
|
||||
import { EbsApiService } from './services/ebs-api.service';
|
||||
import { MissionStateStore } from './services/mission-state.store';
|
||||
import { PubSubService } from './services/pubsub.service';
|
||||
import { TwitchAuthService } from './services/twitch-auth.service';
|
||||
import { LiveLogComponent } from './live-log.component';
|
||||
import { SurvivorStatusComponent } from './survivor-status.component';
|
||||
|
||||
@Component({
|
||||
selector: 'fog-panel-shell',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LiveLogComponent, SurvivorStatusComponent],
|
||||
template: `
|
||||
<main class="layout">
|
||||
<header>
|
||||
<h2>Fog Expedition</h2>
|
||||
<p *ngIf="reconnecting()">reconnecting...</p>
|
||||
</header>
|
||||
<button (click)="startMission()">Start Mission</button>
|
||||
<fog-survivor-status [mission]="mission()"></fog-survivor-status>
|
||||
<fog-live-log [lines]="logLines()"></fog-live-log>
|
||||
</main>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.layout { display: grid; gap: 0.75rem; padding: 0.75rem; }
|
||||
button { background: #6d28d9; border: 0; color: white; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; }
|
||||
h2, p { margin: 0; }
|
||||
`
|
||||
]
|
||||
})
|
||||
export class PanelShellComponent implements OnInit {
|
||||
private readonly auth = inject(TwitchAuthService);
|
||||
private readonly api = inject(EbsApiService);
|
||||
private readonly store = inject(MissionStateStore);
|
||||
private readonly pubsub = inject(PubSubService);
|
||||
|
||||
readonly mission = this.store.mission;
|
||||
readonly reconnecting = this.store.reconnecting;
|
||||
readonly logLines = computed(() => this.store.logLines());
|
||||
|
||||
ngOnInit(): void {
|
||||
this.auth.init();
|
||||
}
|
||||
|
||||
async startMission(): Promise<void> {
|
||||
const auth = this.auth.auth();
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
const mission = await this.api.startMission(auth.token, {
|
||||
survivorId: auth.userId,
|
||||
difficulty: MissionDifficulty.Normal
|
||||
});
|
||||
this.store.setMission(mission);
|
||||
this.pubsub.subscribeToMissionUpdates(`mission.${mission.id}`, async () => {
|
||||
this.store.setReconnecting(true);
|
||||
const fresh = await this.api.getMissionState(auth.token, mission.id);
|
||||
this.store.setMission(fresh);
|
||||
});
|
||||
}
|
||||
}
|
||||
45
fog/apps/twitch-extension-panel/src/app/services/ebs-api.service.ts
Executable file
45
fog/apps/twitch-extension-panel/src/app/services/ebs-api.service.ts
Executable file
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { MissionSnapshot } from '@fog-explorer/api-interfaces';
|
||||
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EbsApiService {
|
||||
async startMission(token: string, body: { survivorId: string; difficulty: string }) {
|
||||
return this.fetchWithRetry('/missions/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
async getMissionState(token: string, missionId: string): Promise<MissionSnapshot> {
|
||||
return this.fetchWithRetry(`/missions/state?missionId=${missionId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchWithRetry<T>(path: string, init: RequestInit, attempt = 0): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${environment.apiBaseUrl}${path}`, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (attempt >= 4) {
|
||||
throw error;
|
||||
}
|
||||
const delayMs = Math.min(5000, Math.round(350 * 2 ** attempt + Math.random() * 150));
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
return this.fetchWithRetry(path, init, attempt + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
fog/apps/twitch-extension-panel/src/app/services/mission-state.store.ts
Executable file
19
fog/apps/twitch-extension-panel/src/app/services/mission-state.store.ts
Executable file
@@ -0,0 +1,19 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import { MissionSnapshot } from '@fog-explorer/api-interfaces';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MissionStateStore {
|
||||
readonly mission = signal<MissionSnapshot | null>(null);
|
||||
readonly reconnecting = signal(false);
|
||||
readonly logLines = computed(() => this.mission()?.recentLog ?? []);
|
||||
|
||||
setMission(snapshot: MissionSnapshot): void {
|
||||
this.mission.set(snapshot);
|
||||
this.reconnecting.set(false);
|
||||
}
|
||||
|
||||
setReconnecting(value: boolean): void {
|
||||
this.reconnecting.set(value);
|
||||
}
|
||||
}
|
||||
43
fog/apps/twitch-extension-panel/src/app/services/pubsub.service.ts
Executable file
43
fog/apps/twitch-extension-panel/src/app/services/pubsub.service.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { MissionSnapshot } from '@fog-explorer/api-interfaces';
|
||||
|
||||
import { MissionStateStore } from './mission-state.store';
|
||||
import { TwitchAuthService } from './twitch-auth.service';
|
||||
|
||||
interface MissionDeltaMessage {
|
||||
sequence: number;
|
||||
missionId: string;
|
||||
state: string;
|
||||
recentLog: MissionSnapshot['recentLog'];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PubSubService {
|
||||
private lastSequence = 0;
|
||||
|
||||
constructor(
|
||||
private readonly auth: TwitchAuthService,
|
||||
private readonly store: MissionStateStore
|
||||
) {}
|
||||
|
||||
subscribeToMissionUpdates(topic: string, fullRefetch: () => Promise<void>): void {
|
||||
this.auth.onPubSub(topic, async (_target, _contentType, message) => {
|
||||
const parsed = JSON.parse(message) as MissionDeltaMessage;
|
||||
if (parsed.sequence !== this.lastSequence + 1 && this.lastSequence !== 0) {
|
||||
await fullRefetch();
|
||||
} else {
|
||||
const current = this.store.mission();
|
||||
if (current && current.id === parsed.missionId) {
|
||||
this.store.setMission({
|
||||
...current,
|
||||
state: parsed.state as MissionSnapshot['state'],
|
||||
recentLog: parsed.recentLog.slice(-15),
|
||||
tickIndex: parsed.sequence
|
||||
});
|
||||
}
|
||||
}
|
||||
this.lastSequence = parsed.sequence;
|
||||
});
|
||||
}
|
||||
}
|
||||
47
fog/apps/twitch-extension-panel/src/app/services/twitch-auth.service.ts
Executable file
47
fog/apps/twitch-extension-panel/src/app/services/twitch-auth.service.ts
Executable file
@@ -0,0 +1,47 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
import { twitchExtMock, TwitchExtLike } from '../../mocks/twitch-ext.mock';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Twitch?: {
|
||||
ext?: TwitchExtLike;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface TwitchAuthState {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TwitchAuthService {
|
||||
readonly auth = signal<TwitchAuthState | null>(null);
|
||||
|
||||
init(): void {
|
||||
const ext = this.getExt();
|
||||
ext.onAuthorized(({ token, userId }) => {
|
||||
this.auth.set({ token, userId });
|
||||
});
|
||||
}
|
||||
|
||||
onPubSub(
|
||||
topic: string,
|
||||
callback: (target: string, contentType: string, message: string) => void
|
||||
): void {
|
||||
this.getExt().listen(topic, callback);
|
||||
}
|
||||
|
||||
private getExt(): TwitchExtLike {
|
||||
if (environment.mock) {
|
||||
return twitchExtMock;
|
||||
}
|
||||
const ext = window.Twitch?.ext;
|
||||
if (!ext) {
|
||||
return twitchExtMock;
|
||||
}
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
35
fog/apps/twitch-extension-panel/src/app/survivor-status.component.ts
Executable file
35
fog/apps/twitch-extension-panel/src/app/survivor-status.component.ts
Executable file
@@ -0,0 +1,35 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { MissionSnapshot } from '@fog-explorer/api-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'fog-survivor-status',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="panel-card">
|
||||
<h3>Survivor Status</h3>
|
||||
<ng-container *ngIf="mission; else idle">
|
||||
<p>Mission: {{ mission.id }}</p>
|
||||
<p>State: {{ mission.state }}</p>
|
||||
<p>Health: {{ mission.stats.health }}</p>
|
||||
<p>Stealth: {{ mission.stats.stealth }}</p>
|
||||
<p>Teamwork: {{ mission.stats.teamwork }}</p>
|
||||
<p>Luck: {{ mission.stats.luck }}</p>
|
||||
</ng-container>
|
||||
<ng-template #idle>
|
||||
<p>No active mission.</p>
|
||||
</ng-template>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.panel-card { padding: 0.75rem; background: #1a1e2a; border-radius: 8px; }
|
||||
p { margin: 0 0 0.25rem; font-size: 0.9rem; }
|
||||
`
|
||||
]
|
||||
})
|
||||
export class SurvivorStatusComponent {
|
||||
@Input() mission: MissionSnapshot | null = null;
|
||||
}
|
||||
5
fog/apps/twitch-extension-panel/src/environments/environment.prod.ts
Executable file
5
fog/apps/twitch-extension-panel/src/environments/environment.prod.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
mock: false,
|
||||
apiBaseUrl: '/api'
|
||||
};
|
||||
5
fog/apps/twitch-extension-panel/src/environments/environment.ts
Executable file
5
fog/apps/twitch-extension-panel/src/environments/environment.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
mock: true,
|
||||
apiBaseUrl: 'http://localhost:3333'
|
||||
};
|
||||
11
fog/apps/twitch-extension-panel/src/index.html
Executable file
11
fog/apps/twitch-extension-panel/src/index.html
Executable file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fog Expedition Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<fog-panel-shell></fog-panel-shell>
|
||||
</body>
|
||||
</html>
|
||||
7
fog/apps/twitch-extension-panel/src/main.ts
Executable file
7
fog/apps/twitch-extension-panel/src/main.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
|
||||
import { PanelShellComponent } from './app/panel-shell.component';
|
||||
|
||||
bootstrapApplication(PanelShellComponent).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
25
fog/apps/twitch-extension-panel/src/mocks/twitch-ext.mock.ts
Executable file
25
fog/apps/twitch-extension-panel/src/mocks/twitch-ext.mock.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
export interface TwitchExtLike {
|
||||
onAuthorized(callback: (auth: { token: string; userId: string }) => void): void;
|
||||
onContext(callback: (context: Record<string, unknown>) => void): void;
|
||||
onVisibilityChanged(callback: (isVisible: boolean, context: unknown) => void): void;
|
||||
listen(topic: string, callback: (target: string, contentType: string, message: string) => void): void;
|
||||
}
|
||||
|
||||
export const twitchExtMock: TwitchExtLike = {
|
||||
onAuthorized(callback) {
|
||||
callback({
|
||||
token:
|
||||
'mock.jwt.token',
|
||||
userId: 'U_mock_viewer'
|
||||
});
|
||||
},
|
||||
onContext(callback) {
|
||||
callback({ theme: 'dark' });
|
||||
},
|
||||
onVisibilityChanged(callback) {
|
||||
callback(true, {});
|
||||
},
|
||||
listen(_topic, _callback) {
|
||||
// No-op in local mock mode.
|
||||
}
|
||||
};
|
||||
10
fog/apps/twitch-extension-panel/src/styles.css
Executable file
10
fog/apps/twitch-extension-panel/src/styles.css
Executable file
@@ -0,0 +1,10 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f1117;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
8
fog/apps/twitch-extension-panel/tsconfig.app.json
Executable file
8
fog/apps/twitch-extension-panel/tsconfig.app.json
Executable file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user