chore: initialize repository baseline
Import the existing Electron + React + TypeScript app as the version-control baseline before the scanner rework (C# input/capture sidecar, resolution-anchored layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop). Housekeeping in this commit: - Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script). - Ignore .claude/scheduled_tasks.lock local session state. - Add .gitattributes to normalize line endings (LF in repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { useCallback } from "react";
|
||||
import type { CaptureOptions, CaptureResult } from "../../../types/global";
|
||||
import { captureSelectedSourceAction, exportCurrentGoodAction, loadStoredArtifactSnapshotAction, refreshCaptureSourcesAction, runDemoScanAction, showOverlayAction } from "../services/appControllerService";
|
||||
import type { AppControllerContext } from "../services/appControllerService";
|
||||
|
||||
interface AppControllerActionsInput {
|
||||
appControllerContext: AppControllerContext;
|
||||
}
|
||||
|
||||
export interface AppControllerActionsResult {
|
||||
loadStoredArtifactSnapshot: () => Promise<void>;
|
||||
refreshCaptureSources: () => Promise<void>;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
runDemoScan: () => Promise<void>;
|
||||
exportCurrentGood: () => Promise<void>;
|
||||
showOverlay: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAppControllerActions({
|
||||
appControllerContext,
|
||||
}: AppControllerActionsInput): AppControllerActionsResult {
|
||||
const loadStoredArtifactSnapshot = useCallback(async () => {
|
||||
await loadStoredArtifactSnapshotAction(appControllerContext);
|
||||
}, [appControllerContext]);
|
||||
|
||||
const refreshCaptureSources = useCallback(async () => {
|
||||
await refreshCaptureSourcesAction(appControllerContext);
|
||||
}, [appControllerContext]);
|
||||
|
||||
const captureSelectedSource = useCallback(async (delayMs = 0, focusGenshin = false, options?: CaptureOptions) => {
|
||||
return captureSelectedSourceAction(appControllerContext, delayMs, focusGenshin, options);
|
||||
}, [appControllerContext]);
|
||||
|
||||
const runDemoScan = useCallback(async () => {
|
||||
await runDemoScanAction(appControllerContext, captureSelectedSource);
|
||||
}, [appControllerContext, captureSelectedSource]);
|
||||
|
||||
const exportCurrentGood = useCallback(async () => {
|
||||
await exportCurrentGoodAction(appControllerContext);
|
||||
}, [appControllerContext]);
|
||||
|
||||
const showOverlay = useCallback(async () => {
|
||||
await showOverlayAction(appControllerContext);
|
||||
}, [appControllerContext]);
|
||||
|
||||
return {
|
||||
loadStoredArtifactSnapshot,
|
||||
refreshCaptureSources,
|
||||
captureSelectedSource,
|
||||
runDemoScan,
|
||||
exportCurrentGood,
|
||||
showOverlay,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
import { initializeAppControllerStateAction } from "../services/appControllerService";
|
||||
import type { AppControllerContext } from "../services/appControllerService";
|
||||
|
||||
interface AppControllerLifecycleInput {
|
||||
appControllerContext: AppControllerContext;
|
||||
}
|
||||
|
||||
export function useAppControllerLifecycle({ appControllerContext }: AppControllerLifecycleInput) {
|
||||
useEffect(() => {
|
||||
void initializeAppControllerStateAction(appControllerContext);
|
||||
}, [appControllerContext.artifactRepo, appControllerContext.captureRepo, appControllerContext.snapshotRepo, appControllerContext.isOverlay]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { AppSnapshot } from "../../../types/domain";
|
||||
import type { CaptureResult, CaptureSourceInfo } from "../../../types/global";
|
||||
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import { createAppMetrics } from "../services/appMetricsService";
|
||||
import type { AppControllerResult } from "../types";
|
||||
import { createInitialSnapshot, createAppControllerContext } from "../services/appControllerService";
|
||||
import { useAppControllerActions } from "./useAppControllerActions";
|
||||
import { useAppControllerLifecycle } from "./useAppControllerLifecycle";
|
||||
import { type NavigationId } from "../../layout/types";
|
||||
|
||||
export function useAppControllerState(): AppControllerResult {
|
||||
const isOverlay = new URLSearchParams(window.location.search).get("overlay") === "1";
|
||||
const repositories = useMemo(() => createRendererRepositories(), []);
|
||||
|
||||
const [snapshot, setSnapshot] = useState<AppSnapshot>(createInitialSnapshot);
|
||||
const [activeView, setActiveView] = useState<NavigationId>("scan");
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [captureSources, setCaptureSources] = useState<CaptureSourceInfo[]>([]);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState("");
|
||||
const [latestCapture, setLatestCapture] = useState<CaptureResult | null>(null);
|
||||
const [topbarStatus, setTopbarStatus] = useState(
|
||||
repositories
|
||||
? "Electron bridge connected. Refresh sources after Genshin is open."
|
||||
: "Electron bridge missing. Open the Electron app window, not the browser preview URL.",
|
||||
);
|
||||
const appControllerContext = useMemo(
|
||||
() =>
|
||||
createAppControllerContext(repositories, {
|
||||
isOverlay,
|
||||
selectedSourceId,
|
||||
snapshot,
|
||||
setCaptureSources,
|
||||
setSelectedSourceId,
|
||||
setTopbarStatus,
|
||||
setSnapshot,
|
||||
setLatestCapture,
|
||||
setIsScanning,
|
||||
}),
|
||||
[
|
||||
repositories,
|
||||
isOverlay,
|
||||
selectedSourceId,
|
||||
snapshot,
|
||||
setCaptureSources,
|
||||
setSelectedSourceId,
|
||||
setTopbarStatus,
|
||||
setSnapshot,
|
||||
setLatestCapture,
|
||||
setIsScanning,
|
||||
],
|
||||
);
|
||||
|
||||
const bridgeReady = Boolean(repositories?.isAvailable);
|
||||
const canExportGood = Boolean(repositories?.canExportGood);
|
||||
const canShowOverlay = Boolean(repositories?.canShowOverlay);
|
||||
const { cards: metricCards } = useMemo(() => createAppMetrics(snapshot), [snapshot]);
|
||||
|
||||
useAppControllerLifecycle({ appControllerContext });
|
||||
|
||||
const {
|
||||
loadStoredArtifactSnapshot,
|
||||
refreshCaptureSources,
|
||||
captureSelectedSource,
|
||||
runDemoScan,
|
||||
exportCurrentGood,
|
||||
showOverlay,
|
||||
} = useAppControllerActions({ appControllerContext });
|
||||
|
||||
return {
|
||||
isOverlay,
|
||||
snapshot,
|
||||
activeView,
|
||||
setActiveView,
|
||||
isScanning,
|
||||
captureSources,
|
||||
selectedSourceId,
|
||||
setSelectedSourceId,
|
||||
latestCapture,
|
||||
topbarStatus,
|
||||
bridgeReady,
|
||||
canExportGood,
|
||||
canShowOverlay,
|
||||
metricCards,
|
||||
loadStoredArtifactSnapshot,
|
||||
refreshCaptureSources,
|
||||
captureSelectedSource,
|
||||
runDemoScan,
|
||||
exportCurrentGood,
|
||||
showOverlay,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { createDemoSnapshot, getPresets } from "../../../lib/demoData";
|
||||
import { createLocalAccountSnapshot } from "../../../lib/localAccountSnapshot";
|
||||
import { exportGood } from "../../../lib/goodFormat";
|
||||
import { runAccountScan } from "../../../lib/scanner";
|
||||
import type { AppSnapshot } from "../../../types/domain";
|
||||
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, SaveResultWithPath, ArtifactStoreLoadResult } from "../../../types/global";
|
||||
import type {
|
||||
ArtifactRepositoryPort,
|
||||
CaptureRepositoryPort,
|
||||
OverlayRepositoryPort,
|
||||
RendererRepositories,
|
||||
ScanExportPort,
|
||||
SnapshotRepositoryPort,
|
||||
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export interface AppControllerContext {
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
captureRepo?: CaptureRepositoryPort;
|
||||
exportRepo?: ScanExportPort;
|
||||
overlayRepo?: OverlayRepositoryPort;
|
||||
snapshotRepo?: SnapshotRepositoryPort;
|
||||
isOverlay: boolean;
|
||||
selectedSourceId: string;
|
||||
snapshot: AppSnapshot;
|
||||
setCaptureSources: Dispatch<SetStateAction<CaptureSourceInfo[]>>;
|
||||
setSelectedSourceId: (value: string) => void;
|
||||
setTopbarStatus: (value: string) => void;
|
||||
setSnapshot: Dispatch<SetStateAction<AppSnapshot>>;
|
||||
setLatestCapture?: Dispatch<SetStateAction<CaptureResult | null>>;
|
||||
setIsScanning?: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
interface AppControllerUIState {
|
||||
isOverlay: boolean;
|
||||
selectedSourceId: string;
|
||||
snapshot: AppSnapshot;
|
||||
setCaptureSources: Dispatch<SetStateAction<CaptureSourceInfo[]>>;
|
||||
setSelectedSourceId: (value: string) => void;
|
||||
setTopbarStatus: (value: string) => void;
|
||||
setSnapshot: Dispatch<SetStateAction<AppSnapshot>>;
|
||||
setLatestCapture?: Dispatch<SetStateAction<CaptureResult | null>>;
|
||||
setIsScanning?: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function createAppControllerContext(
|
||||
repositories: RendererRepositories | null,
|
||||
uiState: AppControllerUIState,
|
||||
): AppControllerContext {
|
||||
return {
|
||||
artifactRepo: repositories?.artifacts,
|
||||
captureRepo: repositories?.capture,
|
||||
exportRepo: repositories?.export,
|
||||
overlayRepo: repositories?.overlay,
|
||||
snapshotRepo: repositories?.snapshot,
|
||||
isOverlay: uiState.isOverlay,
|
||||
selectedSourceId: uiState.selectedSourceId,
|
||||
snapshot: uiState.snapshot,
|
||||
setCaptureSources: uiState.setCaptureSources,
|
||||
setSelectedSourceId: uiState.setSelectedSourceId,
|
||||
setTopbarStatus: uiState.setTopbarStatus,
|
||||
setSnapshot: uiState.setSnapshot,
|
||||
setLatestCapture: uiState.setLatestCapture,
|
||||
setIsScanning: uiState.setIsScanning,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadStoredArtifactSnapshotAction(context: AppControllerContext): Promise<void> {
|
||||
const { artifactRepo, setSnapshot } = context;
|
||||
if (!artifactRepo?.loadAll) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return artifactRepo.loadAll().then((result: ArtifactStoreLoadResult) => {
|
||||
if (!result?.ok || result.artifacts.length === 0) return;
|
||||
const nextSnapshot = createLocalAccountSnapshot(result.artifacts, getPresets());
|
||||
if (!nextSnapshot) return;
|
||||
setSnapshot(nextSnapshot);
|
||||
});
|
||||
}
|
||||
|
||||
export async function initializeAppControllerStateAction(context: AppControllerContext): Promise<void> {
|
||||
const { snapshotRepo, isOverlay, setTopbarStatus, setSnapshot } = context;
|
||||
try {
|
||||
await loadStoredArtifactSnapshotAction(context);
|
||||
if (isOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = await snapshotRepo?.load();
|
||||
if (stored) {
|
||||
setSnapshot((current) =>
|
||||
current.artifacts.length > 0 && current.scanEvents[0]?.id === "local-db-loaded" ? current : stored,
|
||||
);
|
||||
}
|
||||
await refreshCaptureSourcesAction(context);
|
||||
} catch (error) {
|
||||
setTopbarStatus(error instanceof Error ? error.message : "Controller initialization failed.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshCaptureSourcesAction(context: AppControllerContext): Promise<void> {
|
||||
const { captureRepo, selectedSourceId, setCaptureSources, setSelectedSourceId, setTopbarStatus } = context;
|
||||
if (!captureRepo?.listSources) {
|
||||
setTopbarStatus("Electron bridge missing. Die Browser-Vorschau kann Genshin nicht scannen - Electron-App verwenden.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sources = await captureRepo.listSources();
|
||||
setCaptureSources(sources);
|
||||
const currentStillExists = sources.some((source: CaptureSourceInfo) => source.id === selectedSourceId);
|
||||
const genshinCandidate = sources.find((source: CaptureSourceInfo) => source.isGenshinCandidate);
|
||||
const current = currentStillExists ? sources.find((source: CaptureSourceInfo) => source.id === selectedSourceId) : null;
|
||||
const preferred = genshinCandidate ?? current ?? sources.find((source: CaptureSourceInfo) => source.id.startsWith("screen:")) ?? sources[0];
|
||||
if (preferred && preferred.id !== selectedSourceId) setSelectedSourceId(preferred.id);
|
||||
setTopbarStatus(
|
||||
`${sources.length} capture sources found${preferred?.isGenshinCandidate ? "; Genshin candidate selected" : ""}. Keep Genshin visible on the captured screen.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setTopbarStatus(error instanceof Error ? error.message : "Could not list capture sources.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureSelectedSourceAction(
|
||||
context: AppControllerContext,
|
||||
delayMs = 0,
|
||||
focusGenshin = false,
|
||||
options?: CaptureOptions,
|
||||
): Promise<CaptureResult | null> {
|
||||
const { captureRepo, selectedSourceId, setTopbarStatus, setLatestCapture } = context;
|
||||
if (!captureRepo?.captureSource) {
|
||||
setTopbarStatus("Electron bridge missing. Capture only works inside the Electron app.");
|
||||
return null;
|
||||
}
|
||||
if (!selectedSourceId) {
|
||||
setTopbarStatus("No capture source selected. Press Sources after Genshin is open.");
|
||||
return null;
|
||||
}
|
||||
if (!setLatestCapture) {
|
||||
setTopbarStatus("Capture state cannot be updated.");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (delayMs > 0) {
|
||||
setTopbarStatus(`Capture in ${Math.round(delayMs / 1000)}s. Put Genshin in front and leave it visible.`);
|
||||
}
|
||||
const capture = await captureRepo.captureSource(selectedSourceId, delayMs, focusGenshin, options);
|
||||
setLatestCapture(capture);
|
||||
const ocrStatus = capture.ocrSkipped
|
||||
? "OCR skipped for fast scan."
|
||||
: capture.ocrTimedOut
|
||||
? "OCR timed out; review sample needed."
|
||||
: "OCR handoff is next.";
|
||||
setTopbarStatus(`Captured ${capture.name} at ${capture.width}x${capture.height}. ${ocrStatus}`);
|
||||
return capture;
|
||||
} catch (error) {
|
||||
setTopbarStatus(error instanceof Error ? error.message : "Capture failed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDemoScanAction(
|
||||
context: AppControllerContext,
|
||||
captureSelectedSource: () => Promise<CaptureResult | null>,
|
||||
): Promise<void> {
|
||||
const { snapshotRepo, setSnapshot, setIsScanning, setTopbarStatus } = context;
|
||||
if (!setIsScanning) {
|
||||
return;
|
||||
}
|
||||
setIsScanning(true);
|
||||
try {
|
||||
await captureSelectedSource();
|
||||
const next = await (snapshotRepo?.runMockScan() ?? Promise.resolve(null));
|
||||
const resolved = next ?? (await runAccountScan());
|
||||
setSnapshot(resolved);
|
||||
await snapshotRepo?.save(resolved);
|
||||
} catch (error) {
|
||||
setTopbarStatus(error instanceof Error ? error.message : "Demo scan failed.");
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function createInitialSnapshot(): AppSnapshot {
|
||||
return createDemoSnapshot();
|
||||
}
|
||||
|
||||
export function exportCurrentGoodAction(context: AppControllerContext): Promise<void> {
|
||||
const { exportRepo, snapshot, setTopbarStatus } = context;
|
||||
if (!exportRepo?.exportGood) {
|
||||
setTopbarStatus("GOOD-Export ist nur in der Electron-App verfuegbar.");
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (snapshot.artifacts.length === 0) {
|
||||
setTopbarStatus("Keine Artifacts zum Exportieren vorhanden.");
|
||||
return Promise.resolve();
|
||||
}
|
||||
return exportRepo
|
||||
.exportGood(exportGood(snapshot.artifacts))
|
||||
.then((result: SaveResultWithPath) => {
|
||||
setTopbarStatus(result.ok ? `GOOD exportiert: ${result.path}` : "GOOD-Export fehlgeschlagen.");
|
||||
});
|
||||
}
|
||||
|
||||
export async function showOverlayAction(context: AppControllerContext): Promise<void> {
|
||||
const { overlayRepo, setTopbarStatus } = context;
|
||||
if (!overlayRepo?.show) {
|
||||
setTopbarStatus("Overlay braucht die Electron-Bridge.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await overlayRepo.show();
|
||||
} catch {
|
||||
setTopbarStatus("Overlay konnte nicht gestartet werden.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ArtifactVerdict, AppSnapshot } from "../../../types/domain";
|
||||
import { summarizeSnapshot } from "../../../lib/snapshotSummary";
|
||||
import type { AppMetricCard } from "../../layout/types";
|
||||
|
||||
const verdictLabelByType: Record<ArtifactVerdict, string> = {
|
||||
keep: "Keep",
|
||||
maybe_level: "Test level",
|
||||
character_specific: "Character-specific",
|
||||
trash_candidate: "Trash",
|
||||
needs_review: "Needs review",
|
||||
};
|
||||
|
||||
export interface AppTopbarSafetyCounts {
|
||||
keep: number;
|
||||
maybe: number;
|
||||
trash: number;
|
||||
review: number;
|
||||
}
|
||||
|
||||
export interface AppMetricsModel {
|
||||
topbarSafety: AppTopbarSafetyCounts;
|
||||
cards: AppMetricCard[];
|
||||
}
|
||||
|
||||
export function createAppMetrics(snapshot: AppSnapshot): AppMetricsModel {
|
||||
const summary = summarizeSnapshot(snapshot);
|
||||
const recommendations = snapshot.recommendations;
|
||||
const topbarSafety: AppTopbarSafetyCounts = {
|
||||
keep: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "keep").length,
|
||||
maybe: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "maybe_level").length,
|
||||
trash: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "trash_candidate").length,
|
||||
review: recommendations.filter((entry: { verdict: ArtifactVerdict }) => entry.verdict === "needs_review").length,
|
||||
};
|
||||
|
||||
const cards: AppMetricCard[] = [
|
||||
{ label: verdictLabelByType.keep, value: summary.artifacts, detail: `${topbarSafety.review} brauchen Review` },
|
||||
{ label: "Behalten", value: summary.useful, detail: `${topbarSafety.keep + topbarSafety.maybe} / ${snapshot.artifacts.length} usable` },
|
||||
{ label: "Trash-Kandidaten", value: summary.trash, detail: "wird nie automatisch geloescht" },
|
||||
{ label: "Build-Optionen", value: summary.builds, detail: "Top-Vorschlaege verfuegbar" },
|
||||
];
|
||||
|
||||
return { topbarSafety, cards };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AppSnapshot } from "../../types/domain";
|
||||
import type { CaptureOptions, CaptureResult, CaptureSourceInfo } from "../../types/global";
|
||||
import { type AppMetricCard, type NavigationId } from "../layout/types";
|
||||
|
||||
export interface AppControllerResult {
|
||||
activeView: NavigationId;
|
||||
setActiveView: (value: NavigationId) => void;
|
||||
isOverlay: boolean;
|
||||
isScanning: boolean;
|
||||
snapshot: AppSnapshot;
|
||||
captureSources: CaptureSourceInfo[];
|
||||
selectedSourceId: string;
|
||||
setSelectedSourceId: (value: string) => void;
|
||||
latestCapture: CaptureResult | null;
|
||||
topbarStatus: string;
|
||||
bridgeReady: boolean;
|
||||
canExportGood: boolean;
|
||||
canShowOverlay: boolean;
|
||||
metricCards: AppMetricCard[];
|
||||
refreshCaptureSources: () => Promise<void>;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
runDemoScan: () => Promise<void>;
|
||||
exportCurrentGood: () => Promise<void>;
|
||||
loadStoredArtifactSnapshot: () => Promise<void>;
|
||||
showOverlay: () => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { AppControllerResult } from "./types";
|
||||
import { useAppControllerState } from "./hooks/useAppControllerState";
|
||||
|
||||
export function useAppController(): AppControllerResult {
|
||||
return useAppControllerState();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useBuildCardModel } from "./hooks/useBuildCardModel";
|
||||
import { useBuildsViewModel } from "./hooks/useBuildsViewModel";
|
||||
import type { BuildCardProps, BuildsViewProps } from "./types";
|
||||
|
||||
function BuildCard({ build, snapshot }: BuildCardProps) {
|
||||
const artifactLookup = new Map(snapshot.artifacts.map((artifact) => [artifact.id, artifact]));
|
||||
const {
|
||||
characterName,
|
||||
qualityLabel,
|
||||
roundedScore,
|
||||
explanation,
|
||||
artifactRows,
|
||||
warnings,
|
||||
hasWarnings,
|
||||
} = useBuildCardModel({
|
||||
build,
|
||||
characters: snapshot.characters,
|
||||
artifactLookup,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel build-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{qualityLabel}</p>
|
||||
<h2>{characterName}</h2>
|
||||
</div>
|
||||
<strong className="score">{roundedScore}</strong>
|
||||
</div>
|
||||
<p className="build-copy">{explanation}</p>
|
||||
<div className="build-pieces">
|
||||
{artifactRows.map((artifact) => (
|
||||
<div key={artifact.id}>
|
||||
<span>{artifact.slot}</span>
|
||||
<strong>{artifact.setName}</strong>
|
||||
<small>{artifact.details}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasWarnings && (
|
||||
<div className="warning-list">
|
||||
{warnings.map((warning) => (
|
||||
<span key={warning}><AlertTriangle size={14} />{warning}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuildsView({ snapshot }: BuildsViewProps) {
|
||||
const { visibleBuilds } = useBuildsViewModel({ snapshot });
|
||||
|
||||
return (
|
||||
<div className="build-grid">
|
||||
{visibleBuilds.map(({ build }) => (
|
||||
<BuildCard key={build.id} build={build} snapshot={snapshot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { BuildSuggestion } from "../../../types/domain";
|
||||
import type { Artifact, Character } from "../../../types/domain";
|
||||
|
||||
export interface BuildCardModel {
|
||||
characterName: string;
|
||||
qualityLabel: string;
|
||||
roundedScore: number;
|
||||
explanation: string;
|
||||
hasWarnings: boolean;
|
||||
artifactRows: Array<{
|
||||
id: string;
|
||||
slot: string;
|
||||
setName: string;
|
||||
details: string;
|
||||
}>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface UseBuildCardModelInput {
|
||||
build: BuildSuggestion;
|
||||
characters: Character[];
|
||||
artifactLookup: Map<string, Artifact>;
|
||||
}
|
||||
|
||||
export function useBuildCardModel({
|
||||
build,
|
||||
characters,
|
||||
artifactLookup,
|
||||
}: UseBuildCardModelInput): BuildCardModel {
|
||||
const character = characters.find((entry) => entry.id === build.characterId);
|
||||
const artifacts = build.artifactIds
|
||||
.map((id) => artifactLookup.get(id))
|
||||
.filter((artifact): artifact is Artifact => Boolean(artifact));
|
||||
|
||||
return {
|
||||
characterName: character?.name ?? build.characterId,
|
||||
qualityLabel: build.quality.replace("_", " "),
|
||||
roundedScore: Math.round(build.score),
|
||||
explanation: build.explanation,
|
||||
hasWarnings: build.warnings.length > 0,
|
||||
artifactRows: artifacts.map((artifact) => ({
|
||||
id: artifact.id,
|
||||
slot: artifact.slot,
|
||||
setName: artifact.setName,
|
||||
details: `+${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`,
|
||||
})),
|
||||
warnings: build.warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AppSnapshot } from "../../../types/domain";
|
||||
import type { BuildSuggestion } from "../../../types/domain";
|
||||
|
||||
interface UseBuildsViewModelInput {
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
|
||||
export interface BuildsViewModel {
|
||||
visibleBuilds: Array<{
|
||||
build: BuildSuggestion;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function useBuildsViewModel({ snapshot }: UseBuildsViewModelInput): BuildsViewModel {
|
||||
return {
|
||||
visibleBuilds: snapshot.builds.slice(0, 9).map((build) => ({ build })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AppSnapshot, BuildSuggestion } from "../../types/domain";
|
||||
|
||||
export interface BuildsViewProps {
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
|
||||
export interface BuildCardProps {
|
||||
build: BuildSuggestion;
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AlertTriangle, BadgeCheck, Lock, Sparkles, Trash2 } from "lucide-react";
|
||||
import type { ArtifactVerdict } from "../../types/domain";
|
||||
|
||||
export const verdictMeta: Record<ArtifactVerdict, { label: string; className: string; icon: JSX.Element }> = {
|
||||
keep: { label: "Keep", className: "pill keep", icon: <Lock size={14} /> },
|
||||
maybe_level: { label: "Test level", className: "pill maybe", icon: <Sparkles size={14} /> },
|
||||
character_specific: { label: "Character-specific", className: "pill specific", icon: <BadgeCheck size={14} /> },
|
||||
trash_candidate: { label: "Trash candidate", className: "pill trash", icon: <Trash2 size={14} /> },
|
||||
needs_review: { label: "Needs review", className: "pill review", icon: <AlertTriangle size={14} /> },
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import type {
|
||||
AppMetricsProps,
|
||||
AppShellProps,
|
||||
AppSidebarProps,
|
||||
AppTopbarProps,
|
||||
} from "./types";
|
||||
import { useAppSidebarModel } from "./hooks/useAppSidebarModel";
|
||||
import { useAppTopbarModel } from "./hooks/useAppTopbarModel";
|
||||
|
||||
export function AppSidebar({ activeView, items, onSelect }: AppSidebarProps) {
|
||||
const { navigationItems } = useAppSidebarModel({ items, onSelect });
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">GA</div>
|
||||
<div>
|
||||
<div className="brand-title">Artifact Assistant</div>
|
||||
<div className="brand-subtitle">Scanner-first MVP</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="nav-list">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`nav-item ${activeView === item.id ? "active" : ""}`}
|
||||
disabled={Boolean(item.disabled)}
|
||||
onClick={item.onSelect}
|
||||
title={item.disabled ? item.disabledReason : undefined}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="safety-card">
|
||||
<ShieldCheck size={18} />
|
||||
<div>
|
||||
<strong>Sicherheits-Grenzen</strong>
|
||||
<span>Keine Eingriffe am Spielinventar, keine Ressourcen-Aktionen, nur Analyse und Scan.</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppTopbar({
|
||||
topbarStatus,
|
||||
canExportGood,
|
||||
canShowOverlay,
|
||||
isScanning,
|
||||
artifactCount,
|
||||
onShowOverlay,
|
||||
onDemoScan,
|
||||
onExportGood,
|
||||
exportIcon,
|
||||
overlayIcon,
|
||||
demoIcon,
|
||||
}: AppTopbarProps) {
|
||||
const {
|
||||
handleShowOverlay,
|
||||
handleExportGood,
|
||||
handleDemoScan,
|
||||
isExportDisabled,
|
||||
isDemoDisabled,
|
||||
isOverlayDisabled,
|
||||
exportButtonLabel,
|
||||
exportButtonTitle,
|
||||
overlayButtonLabel,
|
||||
overlayButtonTitle,
|
||||
demoButtonLabel,
|
||||
demoButtonTitle,
|
||||
} = useAppTopbarModel({
|
||||
onExportGood,
|
||||
onShowOverlay,
|
||||
onDemoScan,
|
||||
canExportGood,
|
||||
canShowOverlay,
|
||||
isScanning,
|
||||
artifactCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">Lokaler Windows-Assistent</p>
|
||||
<h1>Scanne dein Inventar, triff einfache Artifact-Entscheidungen.</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
{topbarStatus && <span className="topbar-status">{topbarStatus}</span>}
|
||||
<button className="ghost-button" onClick={handleExportGood} disabled={isExportDisabled} title={exportButtonTitle}>
|
||||
{exportIcon}
|
||||
<span>{exportButtonLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={handleShowOverlay}
|
||||
disabled={isOverlayDisabled}
|
||||
title={overlayButtonTitle}
|
||||
>
|
||||
{overlayIcon}
|
||||
<span>{overlayButtonLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={handleDemoScan}
|
||||
disabled={isDemoDisabled}
|
||||
title={demoButtonTitle}
|
||||
>
|
||||
{demoIcon}
|
||||
{demoButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppMetrics({ metricCards }: AppMetricsProps) {
|
||||
return (
|
||||
<section className="metrics-grid">
|
||||
{metricCards.map((metric) => (
|
||||
<div className="metric" key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>{metric.value}</strong>
|
||||
<small>{metric.detail}</small>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ sidebar, children }: AppShellProps) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
{sidebar}
|
||||
<main className="main-panel">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMemo } from "react";
|
||||
import type { NavigationId } from "../types";
|
||||
import type { AppNavigationItem, AppSidebarItemAction } from "../types";
|
||||
|
||||
export interface AppSidebarModel {
|
||||
navigationItems: AppSidebarItemAction[];
|
||||
}
|
||||
|
||||
interface UseAppSidebarModelInput {
|
||||
items: AppNavigationItem[];
|
||||
onSelect: (id: NavigationId) => void;
|
||||
}
|
||||
|
||||
export function useAppSidebarModel({ items, onSelect }: UseAppSidebarModelInput): AppSidebarModel {
|
||||
const navigationItems = useMemo(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
onSelect: () => onSelect(item.id),
|
||||
})),
|
||||
[items, onSelect],
|
||||
);
|
||||
|
||||
return {
|
||||
navigationItems,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface AppTopbarModel {
|
||||
handleShowOverlay: () => void;
|
||||
handleExportGood: () => void;
|
||||
handleDemoScan: () => void;
|
||||
isExportDisabled: boolean;
|
||||
isDemoDisabled: boolean;
|
||||
isOverlayDisabled: boolean;
|
||||
exportButtonLabel: string;
|
||||
exportButtonTitle: string;
|
||||
overlayButtonLabel: string;
|
||||
overlayButtonTitle: string;
|
||||
demoButtonLabel: string;
|
||||
demoButtonTitle: string;
|
||||
}
|
||||
|
||||
interface UseAppTopbarModelInput {
|
||||
onExportGood: () => Promise<void> | void;
|
||||
onShowOverlay: () => Promise<void>;
|
||||
onDemoScan: () => void;
|
||||
canExportGood: boolean;
|
||||
canShowOverlay: boolean;
|
||||
isScanning: boolean;
|
||||
artifactCount: number;
|
||||
}
|
||||
|
||||
export function useAppTopbarModel({
|
||||
onExportGood,
|
||||
onShowOverlay,
|
||||
onDemoScan,
|
||||
canExportGood,
|
||||
canShowOverlay,
|
||||
isScanning,
|
||||
artifactCount,
|
||||
}: UseAppTopbarModelInput): AppTopbarModel {
|
||||
const handleShowOverlay = useCallback(() => {
|
||||
void onShowOverlay();
|
||||
}, [onShowOverlay]);
|
||||
|
||||
const handleExportGood = useCallback(() => {
|
||||
void onExportGood();
|
||||
}, [onExportGood]);
|
||||
|
||||
const handleDemoScan = useCallback(() => {
|
||||
onDemoScan();
|
||||
}, [onDemoScan]);
|
||||
|
||||
const isExportDisabled = !canExportGood || artifactCount === 0;
|
||||
const isDemoDisabled = isScanning;
|
||||
const isOverlayDisabled = !canShowOverlay;
|
||||
|
||||
return {
|
||||
handleShowOverlay,
|
||||
handleExportGood,
|
||||
handleDemoScan,
|
||||
isExportDisabled,
|
||||
isDemoDisabled,
|
||||
isOverlayDisabled,
|
||||
exportButtonLabel: "GOOD Export",
|
||||
exportButtonTitle: canExportGood ? "GOOD-Export starten." : "GOOD-Export ist nur in der Electron-App verfuegbar.",
|
||||
overlayButtonLabel: "Overlay",
|
||||
overlayButtonTitle: canShowOverlay ? "Overlay oeffnen." : "Overlay benoetigt die Electron-Bridge.",
|
||||
demoButtonLabel: isScanning ? "Laedt..." : "Demo-Daten",
|
||||
demoButtonTitle: "Laedt Beispieldaten fuer die Triage- und Build-Ansicht.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { AppShell, AppMetrics, AppSidebar, AppTopbar } from "./AppLayout";
|
||||
export {
|
||||
type AppMetricCard,
|
||||
type AppNavigationItem,
|
||||
type AppBarAction,
|
||||
type NavigationId,
|
||||
type AppSidebarProps,
|
||||
type AppTopbarProps,
|
||||
type AppMetricsProps,
|
||||
type AppShellProps,
|
||||
} from "./types";
|
||||
export { appNavigationItems } from "./navigation";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Layers3, Radar, Wand2, Eye } from "lucide-react";
|
||||
import type { AppNavigationItem } from "./types";
|
||||
|
||||
export const appNavigationItems: AppNavigationItem[] = [
|
||||
{ id: "scan", label: "Scan", icon: Radar },
|
||||
{ id: "triage", label: "Triage", icon: Layers3 },
|
||||
{ id: "builds", label: "Builds", icon: Wand2 },
|
||||
{ id: "overlay", label: "Overlay", icon: Eye },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { type ComponentType, type ReactNode } from "react";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
|
||||
export type NavigationId = "scan" | "triage" | "builds" | "overlay";
|
||||
|
||||
export interface AppNavigationItem {
|
||||
id: NavigationId;
|
||||
label: string;
|
||||
icon: ComponentType<LucideProps>;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export interface AppSidebarItemAction extends AppNavigationItem {
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export interface AppMetricCard {
|
||||
label: string;
|
||||
value: number;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface AppBarAction {
|
||||
id: string;
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onAction: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AppSidebarProps {
|
||||
activeView: NavigationId;
|
||||
items: AppNavigationItem[];
|
||||
onSelect: (id: NavigationId) => void;
|
||||
}
|
||||
|
||||
export interface AppTopbarProps {
|
||||
topbarStatus: string;
|
||||
canExportGood: boolean;
|
||||
canShowOverlay: boolean;
|
||||
isScanning: boolean;
|
||||
artifactCount: number;
|
||||
onExportGood: () => Promise<void> | void;
|
||||
onShowOverlay: () => Promise<void>;
|
||||
onDemoScan: () => void;
|
||||
exportIcon?: ReactNode;
|
||||
overlayIcon?: ReactNode;
|
||||
demoIcon?: ReactNode;
|
||||
}
|
||||
|
||||
export interface AppMetricsProps {
|
||||
metricCards: AppMetricCard[];
|
||||
}
|
||||
|
||||
export interface AppShellProps {
|
||||
sidebar: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Eye } from "lucide-react";
|
||||
import type { OverlayPreviewProps, OverlaySettingsProps } from "./types";
|
||||
import { useOverlayPreviewModel } from "./hooks/useOverlayPreviewModel";
|
||||
import { useOverlaySettingsModel } from "./hooks/useOverlaySettingsModel";
|
||||
|
||||
export function OverlaySettings({
|
||||
canShowOverlay,
|
||||
onShowOverlay,
|
||||
}: OverlaySettingsProps) {
|
||||
const {
|
||||
handleShowOverlay,
|
||||
eyebrow,
|
||||
title,
|
||||
statusHeadline,
|
||||
statusText,
|
||||
buttonLabel,
|
||||
buttonTitle,
|
||||
} = useOverlaySettingsModel({ onShowOverlay });
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<Eye size={20} />
|
||||
</div>
|
||||
<div className="overlay-settings">
|
||||
<div className="overlay-settings-copy">
|
||||
<strong>{statusHeadline}</strong>
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={handleShowOverlay}
|
||||
disabled={!canShowOverlay}
|
||||
title={canShowOverlay ? buttonTitle : "Overlay benoetigt die Electron-Bridge."}
|
||||
>
|
||||
<Eye size={16} />
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverlayPreview({ snapshot }: OverlayPreviewProps) {
|
||||
const {
|
||||
artifactName,
|
||||
artifactSlotLine,
|
||||
artifactSubstatsText,
|
||||
artifactMainStat,
|
||||
characterNames,
|
||||
scoreText,
|
||||
metaClassName,
|
||||
metaIcon,
|
||||
metaLabel,
|
||||
reason,
|
||||
hasArtifact,
|
||||
} = useOverlayPreviewModel({ snapshot });
|
||||
|
||||
return (
|
||||
<div className="overlay-root">
|
||||
<div className="overlay-card">
|
||||
<div className="overlay-card-head">
|
||||
<div>
|
||||
<p className="eyebrow">Reward scan</p>
|
||||
<h2>{artifactName}</h2>
|
||||
</div>
|
||||
<strong>{scoreText}</strong>
|
||||
</div>
|
||||
<div className={metaClassName}>
|
||||
{metaIcon}
|
||||
{metaLabel}
|
||||
</div>
|
||||
{hasArtifact ? (
|
||||
<div className="overlay-artifact-mini">
|
||||
<span>{artifactSlotLine}</span>
|
||||
<strong>{artifactMainStat}</strong>
|
||||
<small>{artifactSubstatsText}</small>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overlay-character-list">
|
||||
{characterNames.map((name) => (
|
||||
<span key={name}>{name}</span>
|
||||
))}
|
||||
</div>
|
||||
<p>{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AppSnapshot, Recommendation } from "../../../types/domain";
|
||||
import type { ReactNode } from "react";
|
||||
import { verdictMeta } from "../../common/verdictMeta";
|
||||
|
||||
export interface OverlayPreviewModel {
|
||||
candidate: Recommendation | null;
|
||||
artifactName: string;
|
||||
scoreText: string;
|
||||
metaClassName: string;
|
||||
metaLabel: string;
|
||||
metaIcon: ReactNode;
|
||||
artifactSlotLine: string;
|
||||
artifactSubstatsText: string;
|
||||
artifactMainStat: string;
|
||||
characterNames: string[];
|
||||
reason: string;
|
||||
hasArtifact: boolean;
|
||||
}
|
||||
|
||||
interface UseOverlayPreviewModelInput {
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
|
||||
export function useOverlayPreviewModel({ snapshot }: UseOverlayPreviewModelInput): OverlayPreviewModel {
|
||||
const candidate = snapshot.recommendations.find((entry) => entry.verdict === "keep")
|
||||
?? snapshot.recommendations.find((entry) => entry.verdict === "character_specific")
|
||||
?? snapshot.recommendations.find((entry) => entry.verdict === "maybe_level")
|
||||
?? snapshot.recommendations[0]
|
||||
?? null;
|
||||
|
||||
const artifact = snapshot.artifacts.find((entry) => entry.id === candidate?.artifactId);
|
||||
const meta = verdictMeta[candidate?.verdict ?? "needs_review"];
|
||||
const characterNames =
|
||||
candidate?.bestCharacters.map((id) => snapshot.characters.find((character) => character.id === id)?.name ?? id).slice(0, 3) ?? [];
|
||||
const characterDisplayNames = characterNames.length > 0 ? characterNames : ["Review first"];
|
||||
|
||||
return {
|
||||
candidate,
|
||||
artifactName: artifact?.setName ?? "Artifact detected",
|
||||
scoreText: candidate ? `${Math.round(candidate.score)}` : "--",
|
||||
metaClassName: meta.className,
|
||||
metaLabel: meta.label,
|
||||
metaIcon: meta.icon,
|
||||
artifactSlotLine: artifact ? `${artifact.slot} | +${artifact.level}` : "No artifact",
|
||||
artifactSubstatsText: artifact?.substats
|
||||
.slice(0, 3)
|
||||
.map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`)
|
||||
.join(" | ") ?? "",
|
||||
artifactMainStat: artifact?.mainStat ?? "No main stat",
|
||||
characterNames: characterDisplayNames,
|
||||
reason: candidate?.reason ?? "Waiting for artifact detail view.",
|
||||
hasArtifact: Boolean(artifact),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface OverlaySettingsModel {
|
||||
handleShowOverlay: () => void;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
statusHeadline: string;
|
||||
statusText: string;
|
||||
buttonLabel: string;
|
||||
buttonTitle: string;
|
||||
}
|
||||
|
||||
interface UseOverlaySettingsModelInput {
|
||||
onShowOverlay: () => void;
|
||||
}
|
||||
|
||||
export function useOverlaySettingsModel({ onShowOverlay }: UseOverlaySettingsModelInput): OverlaySettingsModel {
|
||||
const handleShowOverlay = useCallback(() => {
|
||||
void onShowOverlay();
|
||||
}, [onShowOverlay]);
|
||||
|
||||
return {
|
||||
handleShowOverlay,
|
||||
eyebrow: "Farming overlay",
|
||||
title: "Read-only reward assistant",
|
||||
statusHeadline: "Aktueller MVP-Status",
|
||||
statusText:
|
||||
"Click-through Preview mit echten lokalen Recommendations. Live Reward Scan und Auto-DB-Sync kommen danach.",
|
||||
buttonLabel: "Show overlay preview",
|
||||
buttonTitle: "Overlay oeffnen.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AppSnapshot } from "../../types/domain";
|
||||
|
||||
export interface OverlaySettingsProps {
|
||||
canShowOverlay: boolean;
|
||||
onShowOverlay: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayPreviewProps {
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ScanViewLayout } from "./components/ScanViewLayout";
|
||||
import { useScanViewController } from "./hooks/useScanViewController";
|
||||
import type { ScanViewProps } from "./types";
|
||||
|
||||
export function ScanView(props: ScanViewProps) {
|
||||
const controller = useScanViewController(props);
|
||||
|
||||
return <ScanViewLayout {...props} controller={controller} />;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { AlertTriangle, Camera, Eye } from "lucide-react";
|
||||
import { ArtifactResultCard } from "./ScanResultCards";
|
||||
import { useScanMainSectionModel } from "./hooks/useScanMainSectionModel";
|
||||
import type { ScanMainSectionProps } from "./types";
|
||||
|
||||
export function ScanMainSection({
|
||||
latestCapture,
|
||||
captureStatus,
|
||||
parsedArtifact,
|
||||
sourceLabel,
|
||||
gridLabel,
|
||||
inventoryLabel,
|
||||
activeTargetCount,
|
||||
storedTotal,
|
||||
reviewSampleTotal,
|
||||
learningRulesLoaded,
|
||||
learningRuleCount,
|
||||
setDetailsOpen,
|
||||
autoScanRunning,
|
||||
canOpenReviewQueue,
|
||||
openReviewQueue,
|
||||
}: ScanMainSectionProps) {
|
||||
const {
|
||||
canOpenDetails,
|
||||
handleOpenDetails,
|
||||
handleOpenReviewQueue,
|
||||
captureImageSrc,
|
||||
captureImageAlt,
|
||||
hasCapture,
|
||||
captureModeText,
|
||||
resultHeading,
|
||||
noArtifactText,
|
||||
noCaptureMessage,
|
||||
targetLabel,
|
||||
dbLabel,
|
||||
reviewLabel,
|
||||
rulesLabel,
|
||||
} = useScanMainSectionModel({
|
||||
latestCapture,
|
||||
parsedArtifact,
|
||||
activeTargetCount,
|
||||
storedTotal,
|
||||
reviewSampleTotal,
|
||||
learningRulesLoaded,
|
||||
learningRuleCount,
|
||||
setDetailsOpen,
|
||||
openReviewQueue,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="scanner-main-grid">
|
||||
<div className="capture-stage-shell">
|
||||
<div className="capture-stage">
|
||||
{hasCapture ? (
|
||||
<img src={captureImageSrc} alt={captureImageAlt} />
|
||||
) : (
|
||||
<div className="empty-stage">
|
||||
<Camera size={28} />
|
||||
<strong>Noch kein Bild</strong>
|
||||
<span>{noCaptureMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="capture-stage-meta">
|
||||
<div><span>Quelle</span><strong>{sourceLabel}</strong></div>
|
||||
<div><span>Grid</span><strong>{gridLabel}</strong></div>
|
||||
<div><span>Inventar</span><strong>{inventoryLabel}</strong></div>
|
||||
<div><span>Modus</span><strong>{captureModeText}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="scanner-result-panel">
|
||||
<div className="result-heading">
|
||||
<p className="eyebrow">Zuletzt gelesen</p>
|
||||
<h3>{resultHeading}</h3>
|
||||
</div>
|
||||
{parsedArtifact ? <ArtifactResultCard parsed={parsedArtifact} /> : (
|
||||
<p className="result-empty">{noArtifactText}</p>
|
||||
)}
|
||||
<div className="scanner-result-brief">
|
||||
<span>{targetLabel}</span>
|
||||
<span>{dbLabel}</span>
|
||||
<span>{reviewLabel}</span>
|
||||
<span>{rulesLabel}</span>
|
||||
</div>
|
||||
<div className="scanner-result-actions">
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={handleOpenDetails}
|
||||
disabled={!canOpenDetails}
|
||||
>
|
||||
<Eye size={15} />
|
||||
Details
|
||||
</button>
|
||||
<button className="ghost-button" onClick={handleOpenReviewQueue} disabled={autoScanRunning || !canOpenReviewQueue}>
|
||||
<AlertTriangle size={15} />
|
||||
Review Queue
|
||||
</button>
|
||||
</div>
|
||||
<p className="scanner-result-caption">{captureStatus}</p>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ScanDiagnosticsModal } from "./modals/ScanDiagnosticsModal";
|
||||
import { ScanSettingsModal } from "./modals/ScanSettingsModal";
|
||||
import { ScanDetailsModal } from "./modals/ScanDetailsModal";
|
||||
import { ScanReviewQueueModal } from "./modals/ScanReviewQueueModal";
|
||||
import { ScanSummaryModal } from "./modals/ScanSummaryModal";
|
||||
import type { ScanModalsSectionProps } from "./types";
|
||||
|
||||
export function ScanModalsSection({
|
||||
captureStatus,
|
||||
latestCapture,
|
||||
diagnosticsOpen,
|
||||
settingsOpen,
|
||||
detailsOpen,
|
||||
reviewQueueOpen,
|
||||
controller,
|
||||
setDiagnosticsOpen,
|
||||
setSettingsOpen,
|
||||
setDetailsOpen,
|
||||
setReviewQueueOpen,
|
||||
setScanSummary,
|
||||
}: ScanModalsSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<ScanDiagnosticsModal
|
||||
open={diagnosticsOpen}
|
||||
captureStatus={captureStatus}
|
||||
latestCapture={latestCapture}
|
||||
controller={controller}
|
||||
setDetailsOpen={setDetailsOpen}
|
||||
setDiagnosticsOpen={setDiagnosticsOpen}
|
||||
/>
|
||||
<ScanSettingsModal
|
||||
open={settingsOpen}
|
||||
latestCapture={latestCapture}
|
||||
controller={controller}
|
||||
setSettingsOpen={setSettingsOpen}
|
||||
/>
|
||||
<ScanDetailsModal
|
||||
open={detailsOpen}
|
||||
latestCapture={latestCapture}
|
||||
controller={controller}
|
||||
setDetailsOpen={setDetailsOpen}
|
||||
/>
|
||||
<ScanReviewQueueModal
|
||||
open={reviewQueueOpen}
|
||||
controller={controller}
|
||||
setReviewQueueOpen={setReviewQueueOpen}
|
||||
/>
|
||||
<ScanSummaryModal
|
||||
open={Boolean(controller.scanSummary)}
|
||||
controller={controller}
|
||||
setScanSummary={setScanSummary}
|
||||
devMode={controller.devMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ArtifactResultCardProps, FieldConfidenceListProps, ReviewSampleCardProps, ScanSummaryFooterProps } from "./types";
|
||||
import { useFieldConfidenceRowsModel, useReviewSampleCardModel, useScanResultCardModel } from "./hooks/useScanResultCardsModel";
|
||||
import { useScanSummaryFooterModel } from "./hooks/useScanSummaryFooterModel";
|
||||
|
||||
export function FieldConfidenceList({ parsedArtifact }: FieldConfidenceListProps) {
|
||||
const rows = useFieldConfidenceRowsModel({ parsedArtifact });
|
||||
|
||||
return (
|
||||
<div className="field-confidence-list">
|
||||
{rows.map(({ label, field, confidenceClassName }) => (
|
||||
<div className={`field-confidence ${confidenceClassName}`} key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{field.confidence}%</strong>
|
||||
<small>{field.source}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactResultCard({ parsed }: ArtifactResultCardProps) {
|
||||
const { quality, levelText, equippedText, substats, noSubstatsText } = useScanResultCardModel({ parsed });
|
||||
|
||||
return (
|
||||
<div className="artifact-result-card">
|
||||
<div className="artifact-result-head">
|
||||
<span className="artifact-set">{parsed.setName}</span>
|
||||
<span className={`quality-chip ${quality.className}`}>{quality.label}</span>
|
||||
</div>
|
||||
<span className="artifact-slot">{parsed.slot}</span>
|
||||
<div className="artifact-mainstat">
|
||||
<span>Hauptwert</span>
|
||||
<strong>{parsed.mainStat}</strong>
|
||||
<em>{parsed.mainValue}</em>
|
||||
</div>
|
||||
<div className="artifact-equipped">
|
||||
{levelText}
|
||||
</div>
|
||||
<div className="artifact-substats">
|
||||
{substats.length > 0 ? substats.map((substat) => <span key={substat}>{substat}</span>) : <span className="none">{noSubstatsText}</span>}
|
||||
</div>
|
||||
<div className="artifact-equipped">
|
||||
{equippedText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewSampleCard({ entry }: ReviewSampleCardProps) {
|
||||
const model = useReviewSampleCardModel({ entry });
|
||||
const {
|
||||
artifactTitle,
|
||||
reasonText,
|
||||
sourceText,
|
||||
captureTargetText,
|
||||
resolutionText,
|
||||
gridText,
|
||||
ocrCountText,
|
||||
parsedSlotText,
|
||||
parsedMainText,
|
||||
parsedSetText,
|
||||
parsedEquippedText,
|
||||
savedAtText,
|
||||
showParsed,
|
||||
ocrRows,
|
||||
} = model;
|
||||
|
||||
return (
|
||||
<article className="review-sample-card">
|
||||
<div className="review-sample-head">
|
||||
<div>
|
||||
<strong>{artifactTitle}</strong>
|
||||
<span>{reasonText}</span>
|
||||
</div>
|
||||
<time>{savedAtText}</time>
|
||||
</div>
|
||||
<div className="review-sample-meta">
|
||||
<span>{sourceText}</span>
|
||||
{captureTargetText && <span>{captureTargetText}</span>}
|
||||
<span>{resolutionText}</span>
|
||||
<span>{gridText}</span>
|
||||
<span>{ocrCountText}</span>
|
||||
</div>
|
||||
{showParsed && (
|
||||
<div className="review-sample-parsed">
|
||||
<span>{parsedSlotText}</span>
|
||||
<strong>{parsedMainText}</strong>
|
||||
<span>{parsedSetText}</span>
|
||||
<span>{parsedEquippedText}</span>
|
||||
</div>
|
||||
)}
|
||||
{ocrRows.length > 0 && (
|
||||
<div className="review-sample-ocr">
|
||||
{ocrRows.map((ocrRow) => (
|
||||
<span key={ocrRow.id}>{ocrRow.text}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScanSummaryFooter({ devMode, scanSummary, storedTotal }: ScanSummaryFooterProps) {
|
||||
const { summaryCopy, devCopy } = useScanSummaryFooterModel({
|
||||
devMode,
|
||||
scanSummary,
|
||||
storedTotal,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="scan-summary-copy">{summaryCopy}</p>
|
||||
{devCopy && <p className="scan-summary-dev">{devCopy}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
Play,
|
||||
Radar,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import type { ScanTopControlsSectionProps } from "./types";
|
||||
import { useScanTopControlsModel } from "./hooks/useScanTopControlsModel";
|
||||
|
||||
export function ScanTopControlsSection({
|
||||
captureSources,
|
||||
selectedSourceId,
|
||||
setSelectedSourceId,
|
||||
refreshCaptureSources,
|
||||
captureSelectedSource,
|
||||
bridgeReady,
|
||||
isScanning,
|
||||
controller,
|
||||
}: ScanTopControlsSectionProps) {
|
||||
const {
|
||||
shouldShowGenshinSourceButton,
|
||||
bridgeDisabled,
|
||||
canStartAutoScan,
|
||||
canStartManualScan,
|
||||
canCaptureSingle,
|
||||
autoScanRunning,
|
||||
handleSourceChange,
|
||||
selectGenshinSource,
|
||||
openSettings,
|
||||
openDiagnostics,
|
||||
captureSingleArtifact,
|
||||
stopScan,
|
||||
runVisibleGridScan,
|
||||
runAutoReviewScan,
|
||||
bridgeStatusText,
|
||||
bridgePillClass,
|
||||
runtimeStatusText,
|
||||
runtimePillClass,
|
||||
playerStatusText,
|
||||
autoScanButtonTitle,
|
||||
autoScanButtonLabel,
|
||||
manualScanButtonTitle,
|
||||
captureSingleButtonTitle,
|
||||
refreshCaptureSourcesTitle,
|
||||
diagnosticsButtonTitle,
|
||||
scanSetupButtonTitle,
|
||||
showPlayerProgress,
|
||||
progressWidth,
|
||||
progressStats,
|
||||
} = useScanTopControlsModel({
|
||||
captureSources,
|
||||
selectedSourceId,
|
||||
setSelectedSourceId,
|
||||
captureSelectedSource,
|
||||
bridgeReady,
|
||||
isScanning,
|
||||
controller,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="scanner-header">
|
||||
<div>
|
||||
<p className="eyebrow">Scanner</p>
|
||||
<h2>Artifact capture workspace</h2>
|
||||
<p className="scanner-subcopy">Grosse Vorschau vorn, klare Aktionen oben, Diagnose und Review nur bei Bedarf.</p>
|
||||
</div>
|
||||
<div className="scanner-header-pills">
|
||||
<span className={`runtime-pill ${bridgePillClass}`}>{bridgeStatusText}</span>
|
||||
<span className={`runtime-pill ${runtimePillClass}`}>{runtimeStatusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!bridgeReady && (
|
||||
<div className="bridge-banner">
|
||||
<AlertTriangle size={16} />
|
||||
Die Capture-Verbindung fehlt. Bitte das Electron-App-Fenster verwenden - die Browser-Vorschau kann Genshin nicht scannen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="player-scan-card">
|
||||
<div className="player-scan-row">
|
||||
<label className="source-select">
|
||||
<span>Quelle</span>
|
||||
<select value={selectedSourceId} onChange={handleSourceChange} disabled={bridgeDisabled}>
|
||||
{captureSources.length === 0 && <option value="">Keine Quellen gefunden</option>}
|
||||
{captureSources.map((source) => (
|
||||
<option key={source.id} value={source.id}>
|
||||
{source.isGenshinCandidate ? "Genshin - " : ""}{source.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={refreshCaptureSources}
|
||||
title={refreshCaptureSourcesTitle}
|
||||
disabled={!bridgeReady}
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
Neu suchen
|
||||
</button>
|
||||
{shouldShowGenshinSourceButton && (
|
||||
<button className="ghost-button" onClick={selectGenshinSource} disabled={!bridgeReady}>
|
||||
<Radar size={15} />
|
||||
Genshin waehlen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={openSettings}
|
||||
disabled={bridgeDisabled}
|
||||
title={scanSetupButtonTitle}
|
||||
>
|
||||
<SlidersHorizontal size={15} />
|
||||
Scan-Setup
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button dev-toggle"
|
||||
onClick={openDiagnostics}
|
||||
disabled={!bridgeReady}
|
||||
title={diagnosticsButtonTitle}
|
||||
>
|
||||
<Wrench size={15} />
|
||||
Scanner Diagnose
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="player-scan-actions">
|
||||
<button
|
||||
className="primary-button scan-cta"
|
||||
onClick={runVisibleGridScan}
|
||||
disabled={!canStartAutoScan}
|
||||
title={autoScanButtonTitle}
|
||||
>
|
||||
<Play size={16} />
|
||||
{autoScanButtonLabel}
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={runAutoReviewScan}
|
||||
disabled={!canStartManualScan}
|
||||
title={manualScanButtonTitle}
|
||||
>
|
||||
<Radar size={15} />
|
||||
Manueller Scan
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={captureSingleArtifact}
|
||||
disabled={!canCaptureSingle}
|
||||
title={captureSingleButtonTitle}
|
||||
>
|
||||
<Camera size={15} />
|
||||
Einzelnes Artifact lesen
|
||||
</button>
|
||||
{autoScanRunning && (
|
||||
<button className="stop-button" onClick={stopScan}>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="player-status">
|
||||
{playerStatusText}
|
||||
</p>
|
||||
|
||||
{showPlayerProgress && (
|
||||
<div className="player-progress">
|
||||
<div className="player-progress-bar">
|
||||
<div style={{ width: `${progressWidth}%` }} />
|
||||
</div>
|
||||
<div className="player-progress-stats">
|
||||
{progressStats.map((entry) => (
|
||||
<span key={entry.label} className={entry.extraClass}>
|
||||
<strong>{entry.value}</strong> {entry.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ScanMainSection } from "./ScanMainSection";
|
||||
import { ScanModalsSection } from "./ScanModalsSection";
|
||||
import { ScanTopControlsSection } from "./ScanTopControlsSection";
|
||||
import type { ScanViewLayoutProps } from "./types";
|
||||
|
||||
export function ScanViewLayout({
|
||||
captureSources,
|
||||
selectedSourceId,
|
||||
setSelectedSourceId,
|
||||
latestCapture,
|
||||
captureStatus,
|
||||
refreshCaptureSources,
|
||||
captureSelectedSource,
|
||||
bridgeReady,
|
||||
controller,
|
||||
isScanning,
|
||||
}: ScanViewLayoutProps) {
|
||||
const {
|
||||
setDetailsOpen,
|
||||
setDiagnosticsOpen,
|
||||
setSettingsOpen,
|
||||
setReviewQueueOpen,
|
||||
setScanSummary,
|
||||
reviewSampleTotal,
|
||||
autoScanRunning,
|
||||
storedTotal,
|
||||
learningRulesLoaded,
|
||||
parsedArtifact,
|
||||
learningRuleCount,
|
||||
activeTargetCount,
|
||||
canReadReviewQueue,
|
||||
sourceLabel,
|
||||
gridLabel,
|
||||
inventoryLabel,
|
||||
openReviewQueue,
|
||||
} = controller;
|
||||
|
||||
return (
|
||||
<section className="scanner-workbench">
|
||||
<ScanTopControlsSection
|
||||
captureSources={captureSources}
|
||||
selectedSourceId={selectedSourceId}
|
||||
setSelectedSourceId={setSelectedSourceId}
|
||||
refreshCaptureSources={refreshCaptureSources}
|
||||
captureSelectedSource={captureSelectedSource}
|
||||
bridgeReady={bridgeReady}
|
||||
isScanning={isScanning}
|
||||
controller={controller}
|
||||
/>
|
||||
|
||||
<ScanMainSection
|
||||
latestCapture={latestCapture}
|
||||
captureStatus={captureStatus}
|
||||
parsedArtifact={parsedArtifact}
|
||||
sourceLabel={sourceLabel}
|
||||
gridLabel={gridLabel}
|
||||
inventoryLabel={inventoryLabel}
|
||||
activeTargetCount={activeTargetCount}
|
||||
storedTotal={storedTotal}
|
||||
reviewSampleTotal={reviewSampleTotal}
|
||||
learningRulesLoaded={learningRulesLoaded}
|
||||
learningRuleCount={learningRuleCount}
|
||||
canOpenReviewQueue={!autoScanRunning && canReadReviewQueue}
|
||||
openReviewQueue={openReviewQueue}
|
||||
autoScanRunning={autoScanRunning}
|
||||
setDetailsOpen={setDetailsOpen}
|
||||
/>
|
||||
|
||||
<ScanModalsSection
|
||||
captureStatus={captureStatus}
|
||||
latestCapture={latestCapture}
|
||||
diagnosticsOpen={Boolean(controller.diagnosticsOpen)}
|
||||
settingsOpen={Boolean(controller.settingsOpen)}
|
||||
detailsOpen={Boolean(controller.detailsOpen)}
|
||||
reviewQueueOpen={Boolean(controller.reviewQueueOpen)}
|
||||
controller={controller}
|
||||
setDiagnosticsOpen={setDiagnosticsOpen}
|
||||
setSettingsOpen={setSettingsOpen}
|
||||
setDetailsOpen={setDetailsOpen}
|
||||
setReviewQueueOpen={setReviewQueueOpen}
|
||||
setScanSummary={setScanSummary}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ScanMainSectionProps } from "../types";
|
||||
|
||||
export interface ScanMainSectionModel {
|
||||
canOpenDetails: boolean;
|
||||
handleOpenDetails: () => void;
|
||||
handleOpenReviewQueue: () => void;
|
||||
captureImageSrc: string;
|
||||
captureImageAlt: string;
|
||||
hasCapture: boolean;
|
||||
captureModeText: string;
|
||||
resultHeading: string;
|
||||
noArtifactText: string;
|
||||
noCaptureMessage: string;
|
||||
targetLabel: string;
|
||||
dbLabel: string;
|
||||
reviewLabel: string;
|
||||
rulesLabel: string;
|
||||
}
|
||||
|
||||
type UseScanMainSectionModelProps = Pick<
|
||||
ScanMainSectionProps,
|
||||
| "latestCapture"
|
||||
| "parsedArtifact"
|
||||
| "activeTargetCount"
|
||||
| "storedTotal"
|
||||
| "reviewSampleTotal"
|
||||
| "learningRulesLoaded"
|
||||
| "learningRuleCount"
|
||||
| "setDetailsOpen"
|
||||
| "openReviewQueue"
|
||||
>;
|
||||
|
||||
export function useScanMainSectionModel({
|
||||
latestCapture,
|
||||
parsedArtifact,
|
||||
activeTargetCount,
|
||||
storedTotal,
|
||||
reviewSampleTotal,
|
||||
learningRulesLoaded,
|
||||
learningRuleCount,
|
||||
setDetailsOpen,
|
||||
openReviewQueue,
|
||||
}: UseScanMainSectionModelProps): ScanMainSectionModel {
|
||||
const handleOpenDetails = useCallback(() => {
|
||||
setDetailsOpen(true);
|
||||
}, [setDetailsOpen]);
|
||||
const handleOpenReviewQueue = useCallback(() => {
|
||||
void openReviewQueue();
|
||||
}, [openReviewQueue]);
|
||||
const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length);
|
||||
const captureImageSrc = latestCapture ? latestCapture.detailDataUrl ?? latestCapture.dataUrl : "";
|
||||
const captureImageAlt = latestCapture
|
||||
? `Latest capture from ${latestCapture.name}`
|
||||
: "Latest capture is not available yet";
|
||||
const captureModeText = latestCapture ? "Erkannt" : "Warte";
|
||||
const resultHeading = parsedArtifact ? parsedArtifact.name : "Noch kein Artifact";
|
||||
const noArtifactText = "Oeffne ein Artifact in Genshin und nutze \"Einzelnes Artifact lesen\" - oder starte direkt den Auto-Scan.";
|
||||
const noCaptureMessage = noArtifactText;
|
||||
const targetLabel = `Ziel ${activeTargetCount}`;
|
||||
const dbLabel = `DB ${storedTotal ?? "-"}`;
|
||||
const reviewLabel = `Review ${reviewSampleTotal}`;
|
||||
const rulesLabel = `Regeln ${learningRulesLoaded ? learningRuleCount : "..."}`;
|
||||
|
||||
return {
|
||||
canOpenDetails,
|
||||
handleOpenDetails,
|
||||
handleOpenReviewQueue,
|
||||
captureImageSrc,
|
||||
captureImageAlt,
|
||||
hasCapture: Boolean(latestCapture),
|
||||
captureModeText,
|
||||
resultHeading,
|
||||
noArtifactText,
|
||||
noCaptureMessage,
|
||||
targetLabel,
|
||||
dbLabel,
|
||||
reviewLabel,
|
||||
rulesLabel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { ArtifactResultCardProps, FieldConfidenceListProps } from "../types";
|
||||
import type { ParsedArtifactCandidate, ParsedField } from "../../../../lib/artifactOcrParser";
|
||||
import type { ReviewSampleRecord } from "../../../../types/global";
|
||||
|
||||
export interface FieldConfidenceRowModel {
|
||||
label: string;
|
||||
field: ParsedField;
|
||||
confidenceClassName: string;
|
||||
}
|
||||
|
||||
export interface ScanResultCardModel {
|
||||
levelField: ParsedField;
|
||||
quality: {
|
||||
label: string;
|
||||
className: string;
|
||||
};
|
||||
fieldRows: FieldConfidenceRowModel[];
|
||||
levelText: string;
|
||||
equippedText: string;
|
||||
substats: string[];
|
||||
noSubstatsText: string;
|
||||
}
|
||||
|
||||
export interface ReviewSampleCardModel {
|
||||
artifactTitle: string;
|
||||
reasonText: string;
|
||||
sourceText: string;
|
||||
captureTargetText: string | null;
|
||||
resolutionText: string;
|
||||
gridText: string;
|
||||
ocrCountText: string;
|
||||
parsedSlotText: string;
|
||||
parsedMainText: string;
|
||||
parsedSetText: string;
|
||||
parsedEquippedText: string;
|
||||
savedAtText: string;
|
||||
showParsed: boolean;
|
||||
ocrRows: Array<{ id: string; label: string; text: string }>;
|
||||
}
|
||||
|
||||
export function useFieldConfidenceRowsModel({ parsedArtifact }: FieldConfidenceListProps): FieldConfidenceRowModel[] {
|
||||
const rows: Array<[string, ParsedField]> = [
|
||||
["Name", parsedArtifact.fields.name],
|
||||
["Slot", parsedArtifact.fields.slot],
|
||||
["Level", getLevelField(parsedArtifact)],
|
||||
["Main", mergeField(parsedArtifact.fields.mainStat, parsedArtifact.fields.mainValue)],
|
||||
["Set", parsedArtifact.fields.setName],
|
||||
["Equipped", parsedArtifact.fields.equipped],
|
||||
["Substats", parsedArtifact.fields.substats],
|
||||
];
|
||||
|
||||
return rows.map(([label, field]) => ({
|
||||
label,
|
||||
field,
|
||||
confidenceClassName: resolveFieldConfidenceClass(field),
|
||||
}));
|
||||
}
|
||||
|
||||
export function useScanResultCardModel({
|
||||
parsed,
|
||||
}: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel {
|
||||
const substats = parsed.substats;
|
||||
|
||||
return {
|
||||
levelField: getLevelField(parsed),
|
||||
quality: resolveQuality(parsed.confidence),
|
||||
fieldRows: [
|
||||
["Name", parsed.fields.name],
|
||||
["Slot", parsed.fields.slot],
|
||||
["Level", getLevelField(parsed)],
|
||||
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
|
||||
["Set", parsed.fields.setName],
|
||||
["Equipped", parsed.fields.equipped],
|
||||
["Substats", parsed.fields.substats],
|
||||
].map(([label, field]) => ({
|
||||
label,
|
||||
field,
|
||||
confidenceClassName: resolveFieldConfidenceClass(field),
|
||||
})),
|
||||
levelText: resolveLevelText(parsed),
|
||||
equippedText: resolveEquippedText(parsed.equipped),
|
||||
substats,
|
||||
noSubstatsText: "Keine Substats gelesen",
|
||||
};
|
||||
}
|
||||
|
||||
export function useReviewSampleCardModel({ entry }: { entry: ReviewSampleRecord }): ReviewSampleCardModel {
|
||||
const parsed = entry.sample?.parsed as Partial<ParsedArtifactCandidate> | undefined;
|
||||
const capture = entry.sample?.capture;
|
||||
const ocr = capture?.ocr ?? [];
|
||||
const grid = capture?.inventoryGrid;
|
||||
|
||||
const date = new Date(entry.savedAt);
|
||||
const savedAtText = Number.isNaN(date.getTime()) ? "Unbekannter Zeitpunkt" : date.toLocaleString();
|
||||
|
||||
return {
|
||||
artifactTitle: parsed?.name || "Unparsed capture",
|
||||
reasonText: entry.sample?.reason || "manual",
|
||||
sourceText: capture?.name || "Unknown source",
|
||||
captureTargetText: capture?.captureTarget || null,
|
||||
resolutionText: `${capture?.width ?? "?"}x${capture?.height ?? "?"}`,
|
||||
gridText: grid ? `${grid.cols}x${grid.rows} grid ${grid.confidence}%` : "no grid",
|
||||
ocrCountText: `${ocr.length} OCR fields`,
|
||||
parsedSlotText: parsed?.slot || "Unknown slot",
|
||||
parsedMainText: `${parsed?.mainStat || "Unknown main"} ${parsed?.mainValue || ""}`,
|
||||
parsedSetText: parsed?.setName || "Unknown set",
|
||||
parsedEquippedText: parsed?.equipped || "Not detected",
|
||||
savedAtText,
|
||||
showParsed: Boolean(parsed),
|
||||
ocrRows: ocr.slice(0, 4).map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
text: `${item.label}: ${item.confidence}%`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function getLevelField(parsedArtifact: ParsedArtifactCandidate): ParsedField {
|
||||
const fieldLevel = parsedArtifact.fields.level;
|
||||
if (fieldLevel?.value) {
|
||||
return fieldLevel;
|
||||
}
|
||||
|
||||
const derivedLevel = parsedArtifact.level;
|
||||
return {
|
||||
value: `${derivedLevel}`,
|
||||
confidence: derivedLevel > 0 ? 80 : 0,
|
||||
source: derivedLevel > 0 ? "derived" : "missing",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuality(confidence: number): { label: string; className: string } {
|
||||
return confidence >= 85
|
||||
? { label: "Sauber gelesen", className: "good" }
|
||||
: confidence >= 70
|
||||
? { label: "Etwas unsicher", className: "mid" }
|
||||
: { label: "Unsicher - bitte prüfen", className: "low" };
|
||||
}
|
||||
|
||||
function mergeField(primary: ParsedField, secondary: ParsedField): ParsedField {
|
||||
const confidence = primary.value && secondary.value
|
||||
? Math.round((primary.confidence + secondary.confidence) / 2)
|
||||
: Math.min(primary.confidence, secondary.confidence);
|
||||
const source = primary.source === secondary.source
|
||||
? primary.source
|
||||
: primary.source === "missing"
|
||||
? secondary.source
|
||||
: primary.source;
|
||||
return {
|
||||
value: `${primary.value} ${secondary.value}`.trim(),
|
||||
confidence,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFieldConfidenceClass(field: ParsedField): string {
|
||||
return field.confidence < 70 ? "low" : field.confidence < 86 ? "medium" : "high";
|
||||
}
|
||||
|
||||
function resolveLevelText(parsed: ParsedArtifactCandidate): string {
|
||||
return parsed.level > 0 ? `Level +${parsed.level}` : "Level nicht erkannt";
|
||||
}
|
||||
|
||||
function resolveEquippedText(equipped: string | null | undefined): string {
|
||||
return equipped && equipped !== "Not detected" ? `Ausgerüstet: ${equipped}` : "Nicht Ausgerüstet / nicht erkannt";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ScanSummaryFooterProps } from "../types";
|
||||
|
||||
export interface ScanSummaryFooterModel {
|
||||
summaryCopy: string;
|
||||
devCopy: string | null;
|
||||
}
|
||||
|
||||
export function useScanSummaryFooterModel({
|
||||
devMode,
|
||||
scanSummary,
|
||||
storedTotal,
|
||||
}: ScanSummaryFooterProps): ScanSummaryFooterModel {
|
||||
const summaryCopy = scanSummary.status === "blocked" && scanSummary.clicked === 0 && scanSummary.mode !== "Manueller Scan"
|
||||
? "Es wurden keine Klicks ausgefuehrt. Grund siehe oben."
|
||||
: `${scanSummary.attempted} Positionen bearbeitet, ${scanSummary.verified} Ansichten verifiziert, ${scanSummary.parsed} Artifact${scanSummary.parsed === 1 ? "" : "s"} gelesen. Deine Sammlung: ${storedTotal ?? "?"} Artifacts.`;
|
||||
|
||||
const devCopy = devMode
|
||||
? `clicked ${scanSummary.clicked} · attempted ${scanSummary.attempted} · verified ${scanSummary.verified} · parsed ${scanSummary.parsed} · misses ${scanSummary.misses} · pages ${scanSummary.pages}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
summaryCopy,
|
||||
devCopy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import type { ScanTopControlsSectionProps } from "../types";
|
||||
|
||||
export interface ScanTopControlsModel {
|
||||
shouldShowGenshinSourceButton: boolean;
|
||||
bridgeDisabled: boolean;
|
||||
canStartAutoScan: boolean;
|
||||
canStartManualScan: boolean;
|
||||
canCaptureSingle: boolean;
|
||||
handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void;
|
||||
selectGenshinSource: () => void;
|
||||
openSettings: () => void;
|
||||
openDiagnostics: () => void;
|
||||
captureSingleArtifact: () => void;
|
||||
stopScan: () => void;
|
||||
runVisibleGridScan: () => void;
|
||||
runAutoReviewScan: () => void;
|
||||
bridgeStatusText: string;
|
||||
bridgePillClass: string;
|
||||
runtimeStatusText: string;
|
||||
runtimePillClass: string;
|
||||
playerStatusText: string;
|
||||
autoScanButtonTitle: string;
|
||||
autoScanButtonLabel: string;
|
||||
manualScanButtonTitle: string;
|
||||
captureSingleButtonTitle: string;
|
||||
refreshCaptureSourcesTitle: string;
|
||||
diagnosticsButtonTitle: string;
|
||||
scanSetupButtonTitle: string;
|
||||
showPlayerProgress: boolean;
|
||||
progressWidth: number;
|
||||
progressStats: Array<{ label: string; value: number | string; extraClass?: string }>;
|
||||
}
|
||||
|
||||
export function useScanTopControlsModel({
|
||||
captureSources,
|
||||
selectedSourceId,
|
||||
setSelectedSourceId,
|
||||
captureSelectedSource,
|
||||
bridgeReady,
|
||||
isScanning,
|
||||
controller,
|
||||
}: ScanTopControlsSectionProps): ScanTopControlsModel {
|
||||
const {
|
||||
setSettingsOpen,
|
||||
setDiagnosticsOpen,
|
||||
requestScanStop,
|
||||
runVisibleGridScan,
|
||||
runAutoReviewScan,
|
||||
autoScanRunning,
|
||||
canCaptureSource,
|
||||
canAutoScan,
|
||||
hasSourceSelected,
|
||||
requiresAdminForAutoScan,
|
||||
reviewStatus,
|
||||
runtimeInfo,
|
||||
autoScanStats,
|
||||
storedTotal,
|
||||
scanProgressPercent,
|
||||
genshinSource,
|
||||
} = controller;
|
||||
|
||||
const handleSourceChange = useCallback(
|
||||
(event: ChangeEvent<HTMLSelectElement>) => setSelectedSourceId(event.target.value),
|
||||
[setSelectedSourceId],
|
||||
);
|
||||
|
||||
const selectGenshinSource = useCallback(() => {
|
||||
if (genshinSource) {
|
||||
setSelectedSourceId(genshinSource.id);
|
||||
}
|
||||
}, [genshinSource, setSelectedSourceId]);
|
||||
|
||||
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
|
||||
const openDiagnostics = useCallback(() => setDiagnosticsOpen(true), [setDiagnosticsOpen]);
|
||||
const captureSingleArtifact = useCallback(() => captureSelectedSource(0, true), [captureSelectedSource]);
|
||||
const stopScan = useCallback(() => requestScanStop("Stop-Button gedrueckt."), [requestScanStop]);
|
||||
const bridgeStatusText = bridgeReady ? "Bridge verbunden" : "Bridge fehlt";
|
||||
const bridgePillClass = bridgeReady ? "elevated" : "standard";
|
||||
const runtimeStatusText = runtimeInfo?.isElevated ? "Admin bereit" : "Standard";
|
||||
const runtimePillClass = runtimeInfo?.isElevated ? "elevated" : "standard";
|
||||
const playerStatusText = reviewStatus
|
||||
|| (runtimeInfo?.isElevated
|
||||
? "App laeuft als Administrator. Oeffne in Genshin das Artifact-Inventar und starte den Auto-Scan."
|
||||
: "App laeuft im Standard-Modus - Auto-Scan braucht Administrator-Rechte. Bitte die App schliessen und als Administrator neu starten.");
|
||||
const autoScanButtonTitle = requiresAdminForAutoScan
|
||||
? "App laeuft nicht als Administrator. Bitte die App als Administrator neu starten."
|
||||
: "Klickt und scrollt automatisch durch das sichtbare Artifact-Inventar.";
|
||||
const autoScanButtonLabel = autoScanRunning ? "Scan laeuft..." : "Auto-Scan starten";
|
||||
const manualScanButtonTitle = "Du klickst die Artifacts in Genshin selbst an; die App liest nur mit. Kein Auto-Klick, kein Scrollen.";
|
||||
const captureSingleButtonTitle = "Liest das gerade in Genshin geoeffnete Artifact einmalig.";
|
||||
const refreshCaptureSourcesTitle = "Fenster- und Bildschirmquellen neu suchen";
|
||||
const diagnosticsButtonTitle = bridgeReady
|
||||
? "Scanner Diagnose oeffnen: Rechte, Grid-Erkennung und Logs."
|
||||
: "Scanner Diagnose ist nur in der Electron-App vollstaendig nutzbar.";
|
||||
const scanSetupButtonTitle = bridgeReady
|
||||
? "Scan-Ziel, Skip-Zeilen und Operator-Optionen anpassen."
|
||||
: "Scan-Setup ist nur in der Electron-App vollstaendig nutzbar.";
|
||||
const showPlayerProgress = autoScanRunning || autoScanStats.clicked > 0 || autoScanStats.parsed > 0;
|
||||
const progressStats = useMemo(
|
||||
() => [
|
||||
{ label: "Klicks", value: autoScanStats.clicked },
|
||||
{ label: "Positionen", value: autoScanStats.attempted },
|
||||
{ label: "Verifiziert", value: autoScanStats.verified },
|
||||
{ label: "Gespeichert", value: autoScanStats.stored },
|
||||
{ label: "Review", value: autoScanStats.review },
|
||||
{ label: "Sammlung", value: storedTotal ?? "-", extraClass: "collection" },
|
||||
],
|
||||
[autoScanStats.clicked, autoScanStats.attempted, autoScanStats.verified, autoScanStats.stored, autoScanStats.review, storedTotal],
|
||||
);
|
||||
|
||||
return {
|
||||
shouldShowGenshinSourceButton: Boolean(genshinSource && selectedSourceId !== genshinSource.id),
|
||||
bridgeDisabled: !bridgeReady || captureSources.length === 0,
|
||||
canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan,
|
||||
canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
|
||||
canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
|
||||
handleSourceChange,
|
||||
selectGenshinSource,
|
||||
openSettings,
|
||||
openDiagnostics,
|
||||
captureSingleArtifact,
|
||||
stopScan,
|
||||
runVisibleGridScan,
|
||||
runAutoReviewScan,
|
||||
bridgeStatusText,
|
||||
bridgePillClass,
|
||||
runtimeStatusText,
|
||||
runtimePillClass,
|
||||
playerStatusText,
|
||||
autoScanButtonTitle,
|
||||
autoScanButtonLabel,
|
||||
manualScanButtonTitle,
|
||||
captureSingleButtonTitle,
|
||||
refreshCaptureSourcesTitle,
|
||||
diagnosticsButtonTitle,
|
||||
scanSetupButtonTitle,
|
||||
showPlayerProgress,
|
||||
progressWidth: scanProgressPercent,
|
||||
progressStats,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { FieldConfidenceList } from "../ScanResultCards";
|
||||
import type { ScanDetailsModalProps } from "./types";
|
||||
import { useScanDetailsModalModel } from "./hooks/useScanDetailsModalModel";
|
||||
|
||||
export function ScanDetailsModal({
|
||||
open,
|
||||
latestCapture,
|
||||
controller,
|
||||
setDetailsOpen,
|
||||
}: ScanDetailsModalProps) {
|
||||
const { parsedArtifact } = controller;
|
||||
const {
|
||||
closeDetails,
|
||||
stopPropagation,
|
||||
parsedNotes,
|
||||
showParsedNotes,
|
||||
cropRows,
|
||||
ocrRows,
|
||||
debugText,
|
||||
showCrops,
|
||||
showOcr,
|
||||
} = useScanDetailsModalModel({
|
||||
setDetailsOpen,
|
||||
parsedArtifact,
|
||||
latestCapture,
|
||||
});
|
||||
|
||||
if (!open || !latestCapture) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onClick={closeDetails}>
|
||||
<div className="modal-panel" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">Dev</p>
|
||||
<h2>Crops, OCR & Confidence</h2>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={closeDetails}>Schliessen</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{parsedArtifact && (
|
||||
<>
|
||||
<FieldConfidenceList parsedArtifact={parsedArtifact} />
|
||||
{showParsedNotes && (
|
||||
<div className="parsed-notes compact">
|
||||
{parsedNotes.map((note) => <span key={note}>{note}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showCrops && (
|
||||
<div className="crop-grid details-grid">
|
||||
{cropRows.map((crop) => (
|
||||
<div className="crop-card" key={crop.id}>
|
||||
<img src={crop.dataUrl} alt={crop.label} />
|
||||
<div>
|
||||
<strong>{crop.label}</strong>
|
||||
<span>{crop.x},{crop.y} - {crop.width}x{crop.height}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showOcr && (
|
||||
<div className="ocr-panel">
|
||||
<strong>OCR candidates</strong>
|
||||
{ocrRows.map((entry) => (
|
||||
<div className="ocr-row" key={entry.id}>
|
||||
<div>
|
||||
<span>{entry.label}</span>
|
||||
<small>{entry.confidence}% confidence</small>
|
||||
</div>
|
||||
<pre>{entry.text}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="capture-debug">{debugText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { AlertTriangle, Eye, Wrench } from "lucide-react";
|
||||
import type { ScanDiagnosticsModalProps } from "./types";
|
||||
import { useScanDiagnosticsModalModel } from "./hooks/useScanDiagnosticsModalModel";
|
||||
|
||||
export function ScanDiagnosticsModal({
|
||||
open,
|
||||
captureStatus,
|
||||
latestCapture,
|
||||
controller,
|
||||
setDetailsOpen,
|
||||
setDiagnosticsOpen,
|
||||
}: ScanDiagnosticsModalProps) {
|
||||
const {
|
||||
toggleDevMode,
|
||||
} = controller;
|
||||
|
||||
const {
|
||||
closeDiagnostics,
|
||||
openDetails,
|
||||
handleSaveReviewSample,
|
||||
stopPropagation,
|
||||
canOpenDetails,
|
||||
statusTitle,
|
||||
rightsClassName,
|
||||
rightsValue,
|
||||
genshinClassName,
|
||||
genshinValue,
|
||||
shouldShowAdminBanner,
|
||||
gridSourceClass,
|
||||
gridMainValue,
|
||||
gridMetaValue,
|
||||
learningRulesText,
|
||||
learningRulesSubtext,
|
||||
autoScanModeLabel,
|
||||
autoScanRunning,
|
||||
fingerprintText,
|
||||
runtimeRows,
|
||||
scanLimitText,
|
||||
scanTipText,
|
||||
autoScanStatsLines,
|
||||
playerProgress,
|
||||
showDevRows,
|
||||
reviewStatus,
|
||||
automationLogLines,
|
||||
canSaveReviewSample,
|
||||
} = useScanDiagnosticsModalModel({
|
||||
setDetailsOpen,
|
||||
setDiagnosticsOpen,
|
||||
saveReviewSample: controller.saveReviewSample,
|
||||
canSaveReviewSample: controller.canSaveReviewSample,
|
||||
latestCapture,
|
||||
controller,
|
||||
captureStatus,
|
||||
});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onClick={closeDiagnostics}>
|
||||
<div className="modal-panel scanner-diagnostics-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">Scanner Diagnose</p>
|
||||
<h2>Input, Grid & Lernstatus</h2>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={closeDiagnostics}>Schliessen</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="diagnostics-actions">
|
||||
<button className="ghost-button" onClick={toggleDevMode}>
|
||||
<Wrench size={15} />
|
||||
{statusTitle}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="scanner-preflight diagnostics-preflight">
|
||||
<div className={rightsClassName}>
|
||||
<span>App-Rechte</span>
|
||||
<strong>{rightsValue}</strong>
|
||||
</div>
|
||||
<div className={genshinClassName}>
|
||||
<span>Genshin</span>
|
||||
<strong>{genshinValue}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowAdminBanner && (
|
||||
<p className="scanner-subcopy">
|
||||
App laeuft im Standard-Modus. Auto-Scan braucht Administrator-Rechte: App schliessen und als Administrator neu starten (z.B. Terminal per Rechtsklick "Als Administrator ausfuehren" und darin "npm run dev").
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={`grid-detection-strip ${gridSourceClass}`}>
|
||||
<span>Tile grid</span>
|
||||
<strong>{gridMainValue}</strong>
|
||||
<small>{gridMetaValue}</small>
|
||||
</div>
|
||||
|
||||
<div className="learning-strip">
|
||||
<span>Learning</span>
|
||||
<strong>{learningRulesText}</strong>
|
||||
<small>{learningRulesSubtext}</small>
|
||||
</div>
|
||||
|
||||
{playerProgress.show && (
|
||||
<div className="auto-scan-strip">
|
||||
<span>{autoScanModeLabel}</span>
|
||||
{autoScanStatsLines.map((entry) => (
|
||||
<span className="auto-scan-stat" key={entry.label}>
|
||||
<strong>{entry.value}</strong>
|
||||
<small>{entry.label}</small>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="learning-strip">
|
||||
<span>Fingerprint</span>
|
||||
<strong>{fingerprintText}</strong>
|
||||
<small>Active capture fingerprint used for deterministic duplicate guard checks.</small>
|
||||
</div>
|
||||
|
||||
{showDevRows && (
|
||||
<div className="scanner-status-row diagnostics-status">
|
||||
{runtimeRows.map((row) => (
|
||||
<span key={row}>{row}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{autoScanRunning && playerProgress.show && (
|
||||
<div className="player-progress">
|
||||
<div className="player-progress-bar">
|
||||
<div style={{ width: `${playerProgress.width}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reviewStatus && (
|
||||
<p className="review-status">{reviewStatus}</p>
|
||||
)}
|
||||
|
||||
<div className="automation-log">
|
||||
<span>Automation</span>
|
||||
<div className="automation-log-lines">
|
||||
{automationLogLines.length > 0 ? (
|
||||
automationLogLines.map((line, index) => (
|
||||
<span key={`${index}-${line}`}>{line}</span>
|
||||
))
|
||||
) : (
|
||||
<strong>No scan activity yet.</strong>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dev-section-actions">
|
||||
<button className="ghost-button" onClick={openDetails} disabled={!canOpenDetails}>
|
||||
<Eye size={15} />
|
||||
Crops, OCR & Confidence
|
||||
</button>
|
||||
<button className="ghost-button" onClick={handleSaveReviewSample} disabled={!canSaveReviewSample}>
|
||||
<AlertTriangle size={15} />
|
||||
Review-Sample speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="scanner-result-caption">{scanLimitText}</p>
|
||||
<p className="scan-summary-copy">{scanTipText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { ReviewSampleCard } from "../ScanResultCards";
|
||||
import type { ScanReviewQueueModalProps } from "./types";
|
||||
import { useScanReviewQueueModalModel } from "./hooks/useScanReviewQueueModalModel";
|
||||
|
||||
export function ScanReviewQueueModal({
|
||||
open,
|
||||
controller,
|
||||
setReviewQueueOpen,
|
||||
}: ScanReviewQueueModalProps) {
|
||||
const { reviewSamples, reviewSampleTotal, reviewAnalysis, canReadReviewQueue, loadReviewQueue } = controller;
|
||||
const {
|
||||
closeReviewQueue,
|
||||
refreshReviewQueue,
|
||||
stopPropagation,
|
||||
emptyText,
|
||||
reviewAnalysisRows,
|
||||
showReviewAnalysis,
|
||||
} = useScanReviewQueueModalModel({
|
||||
setReviewQueueOpen,
|
||||
loadReviewQueue,
|
||||
reviewAnalysis,
|
||||
reviewSampleTotal,
|
||||
});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onClick={closeReviewQueue}>
|
||||
<div className="modal-panel review-queue-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">Review Queue</p>
|
||||
<h2>Unsichere Scanner-Faelle</h2>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={closeReviewQueue}>Schliessen</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="review-queue-summary">
|
||||
<strong>{reviewSampleTotal}</strong>
|
||||
<span>Samples gespeichert</span>
|
||||
<button className="ghost-button" onClick={refreshReviewQueue} disabled={!canReadReviewQueue}>Aktualisieren</button>
|
||||
</div>
|
||||
{showReviewAnalysis && (
|
||||
<div className="review-analysis">
|
||||
{reviewAnalysisRows.map((row) => (
|
||||
<div key={row.label}>
|
||||
<span>{row.label}</span><strong>{row.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{reviewSamples.length === 0 ? (
|
||||
<div className="empty-stage compact">
|
||||
<AlertTriangle size={22} />
|
||||
<strong>{emptyText}</strong>
|
||||
<span>Unsichere Auto-Scan- oder OCR-Faelle erscheinen hier automatisch.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="review-sample-list">
|
||||
{reviewSamples.map((entry, index) => (
|
||||
<ReviewSampleCard entry={entry} key={`${entry.savedAt}-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ScanSettingsModalProps } from "./types";
|
||||
import { useScanSettingsModalModel } from "./hooks/useScanSettingsModalModel";
|
||||
|
||||
export function ScanSettingsModal({
|
||||
open,
|
||||
latestCapture,
|
||||
controller,
|
||||
setSettingsOpen,
|
||||
}: ScanSettingsModalProps) {
|
||||
const {
|
||||
scanLimit,
|
||||
skipRows,
|
||||
runtimeInfo,
|
||||
activeTargetCount,
|
||||
} = controller;
|
||||
|
||||
const {
|
||||
closeSettings,
|
||||
handleScanLimitChange,
|
||||
handleSkipRowsChange,
|
||||
stopPropagation,
|
||||
inventoryCountText,
|
||||
inventoryClassName,
|
||||
scanLimitClassName,
|
||||
skipRowsClassName,
|
||||
activeTargetText,
|
||||
scanSummaryText,
|
||||
} = useScanSettingsModalModel({
|
||||
setSettingsOpen,
|
||||
setScanLimit: controller.setScanLimit,
|
||||
setScanLimitTouched: controller.setScanLimitTouched,
|
||||
setSkipRows: controller.setSkipRows,
|
||||
latestCapture,
|
||||
scanLimit,
|
||||
skipRows,
|
||||
activeTargetCount,
|
||||
runtimeInfo,
|
||||
});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onClick={closeSettings}>
|
||||
<div className="modal-panel scanner-settings-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">Scan-Setup</p>
|
||||
<h2>Operator-Einstellungen</h2>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={closeSettings}>Schliessen</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="scan-config-strip">
|
||||
<label>
|
||||
<span>Anzahl Artifacts</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1800}
|
||||
value={scanLimit}
|
||||
onChange={handleScanLimitChange}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zeilen ueberspringen</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={8}
|
||||
value={skipRows}
|
||||
onChange={handleSkipRowsChange}
|
||||
/>
|
||||
</label>
|
||||
<p>
|
||||
Die App uebernimmt die erkannte Inventar-Anzahl nur als Startwert und Deckel nach oben. Dein manuell gesetztes Ziel bleibt erhalten.
|
||||
Mit "Zeilen ueberspringen" kannst du den Startverzug korrigieren, falls du nicht am Anfang der Liste beginnst.
|
||||
</p>
|
||||
</div>
|
||||
<div className="scanner-preflight diagnostics-preflight">
|
||||
<div className={inventoryClassName}>
|
||||
<span>Inventarzaehler</span>
|
||||
<strong>{inventoryCountText}</strong>
|
||||
</div>
|
||||
<div className={scanLimitClassName}>
|
||||
<span>Limit</span>
|
||||
<strong>{scanLimit}</strong>
|
||||
</div>
|
||||
<div className={skipRowsClassName}>
|
||||
<span>Skip</span>
|
||||
<strong>{skipRows}</strong>
|
||||
</div>
|
||||
<p>{activeTargetText}</p>
|
||||
</div>
|
||||
<p className="scan-summary-copy">{scanSummaryText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Play, AlertTriangle } from "lucide-react";
|
||||
import { ScanSummaryFooter } from "../ScanResultCards";
|
||||
import type { ScanSummaryModalProps } from "./types";
|
||||
import { useScanSummaryModalModel } from "./hooks/useScanSummaryModalModel";
|
||||
|
||||
export function ScanSummaryModal({
|
||||
open,
|
||||
controller,
|
||||
setScanSummary,
|
||||
devMode,
|
||||
}: ScanSummaryModalProps) {
|
||||
const { closeSummary, stopPropagation, iconKind, summaryTitle, gridSummaryText } = useScanSummaryModalModel({
|
||||
setScanSummary,
|
||||
scanSummary: controller.scanSummary,
|
||||
});
|
||||
|
||||
if (!open || !controller.scanSummary) return null;
|
||||
return (
|
||||
<div className="modal-backdrop scan-summary-backdrop" role="presentation" onClick={closeSummary}>
|
||||
<div className="scan-summary-modal" role="dialog" aria-modal="true" onClick={stopPropagation}>
|
||||
<div className="scan-summary-icon">
|
||||
{iconKind === "blocked" ? <AlertTriangle size={24} /> : <Play size={24} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="eyebrow">Scan-Ergebnis</p>
|
||||
<h2>{summaryTitle}</h2>
|
||||
{gridSummaryText && <p className="scan-summary-copy">{gridSummaryText}</p>}
|
||||
</div>
|
||||
<div className="scan-summary-grid player">
|
||||
<div><strong>{controller.scanSummary.stored}</strong><span>neu gespeichert</span></div>
|
||||
<div><strong>{controller.scanSummary.review}</strong><span>unsicher (Review)</span></div>
|
||||
<div><strong>{controller.scanSummary.duplicates}</strong><span>Duplikate</span></div>
|
||||
<div><strong>{controller.scanSummary.verified}</strong><span>verifizierte Ansichten</span></div>
|
||||
</div>
|
||||
<ScanSummaryFooter devMode={devMode} scanSummary={controller.scanSummary} storedTotal={controller.storedTotal} />
|
||||
<button className="primary-button" onClick={closeSummary}>Ok</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, type MouseEvent } from "react";
|
||||
import type { ScanDetailsModalProps } from "../types";
|
||||
import type { ParsedArtifactCandidate } from "../../../../lib/artifactOcrParser";
|
||||
|
||||
export interface ScanDetailsModalModel {
|
||||
closeDetails: () => void;
|
||||
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
parsedNotes: string[];
|
||||
showParsedNotes: boolean;
|
||||
cropRows: Array<{ id: string; dataUrl: string; label: string; x: number; y: number; width: number; height: number }>;
|
||||
ocrRows: Array<{ id: string; label: string; confidence: number; text: string }>;
|
||||
debugText: string;
|
||||
showCrops: boolean;
|
||||
showOcr: boolean;
|
||||
}
|
||||
|
||||
interface UseScanDetailsModalModelInput {
|
||||
setDetailsOpen: ScanDetailsModalProps["setDetailsOpen"];
|
||||
parsedArtifact: ScanDetailsModalProps["controller"]["parsedArtifact"];
|
||||
latestCapture: ScanDetailsModalProps["latestCapture"];
|
||||
}
|
||||
|
||||
export function useScanDetailsModalModel({
|
||||
setDetailsOpen,
|
||||
parsedArtifact,
|
||||
latestCapture,
|
||||
}: UseScanDetailsModalModelInput): ScanDetailsModalModel {
|
||||
const closeDetails = useCallback(() => setDetailsOpen(false), [setDetailsOpen]);
|
||||
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const parsedNotes = (parsedArtifact?.notes ?? []) as ParsedArtifactCandidate["notes"];
|
||||
|
||||
const crops = latestCapture?.crops ?? [];
|
||||
const ocr = latestCapture?.ocr ?? [];
|
||||
|
||||
return {
|
||||
closeDetails,
|
||||
stopPropagation,
|
||||
parsedNotes,
|
||||
showParsedNotes: parsedNotes.length > 0,
|
||||
cropRows: crops.map((crop) => ({
|
||||
id: crop.id,
|
||||
dataUrl: crop.dataUrl,
|
||||
label: crop.label,
|
||||
x: crop.rect.x,
|
||||
y: crop.rect.y,
|
||||
width: crop.rect.width,
|
||||
height: crop.rect.height,
|
||||
})),
|
||||
ocrRows: ocr.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
confidence: entry.confidence,
|
||||
text: entry.text || "No text detected",
|
||||
})),
|
||||
debugText: `Debug: crops ${crops.length} / ocr ${ocr.length}`,
|
||||
showCrops: crops.length > 0,
|
||||
showOcr: ocr.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { detailFingerprint } from "../../../../lib/autoScanLoop";
|
||||
import { sourceVersion } from "../../../../lib/genshinData";
|
||||
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||
import type { ScanDiagnosticsModalProps } from "../types";
|
||||
|
||||
export interface ScanDiagnosticsModelProgress {
|
||||
width: number;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export interface ScanDiagnosticsModalModel {
|
||||
closeDiagnostics: () => void;
|
||||
openDetails: () => void;
|
||||
handleSaveReviewSample: () => void;
|
||||
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
canOpenDetails: boolean;
|
||||
statusTitle: string;
|
||||
rightsClassName: string;
|
||||
rightsValue: string;
|
||||
genshinClassName: string;
|
||||
genshinValue: string;
|
||||
shouldShowAdminBanner: boolean;
|
||||
gridSourceClass: string;
|
||||
gridMainValue: string;
|
||||
gridMetaValue: string;
|
||||
learningRulesText: string;
|
||||
learningRulesSubtext: string;
|
||||
autoScanModeLabel: string;
|
||||
fingerprintText: string;
|
||||
runtimeRows: string[];
|
||||
scanLimitText: string;
|
||||
scanTipText: string;
|
||||
autoScanStatsLines: Array<{ label: string; value: number }>;
|
||||
playerProgress: ScanDiagnosticsModelProgress;
|
||||
autoScanRunning: boolean;
|
||||
showDevRows: boolean;
|
||||
reviewStatus: string;
|
||||
automationLogLines: string[];
|
||||
canSaveReviewSample: boolean;
|
||||
}
|
||||
|
||||
interface UseScanDiagnosticsModalModelInput extends Pick<
|
||||
ScanDiagnosticsModalProps,
|
||||
| "setDetailsOpen"
|
||||
| "setDiagnosticsOpen"
|
||||
| "saveReviewSample"
|
||||
| "canSaveReviewSample"
|
||||
| "latestCapture"
|
||||
| "controller"
|
||||
> {
|
||||
captureStatus: string;
|
||||
}
|
||||
|
||||
export function useScanDiagnosticsModalModel({
|
||||
setDetailsOpen,
|
||||
setDiagnosticsOpen,
|
||||
saveReviewSample,
|
||||
canSaveReviewSample,
|
||||
latestCapture,
|
||||
controller,
|
||||
captureStatus,
|
||||
}: UseScanDiagnosticsModalModelInput): ScanDiagnosticsModalModel {
|
||||
const closeDiagnostics = useCallback(() => setDiagnosticsOpen(false), [setDiagnosticsOpen]);
|
||||
const openDetails = useCallback(() => setDetailsOpen(true), [setDetailsOpen]);
|
||||
const handleSaveReviewSample = useCallback(() => {
|
||||
if (canSaveReviewSample && controller.parsedArtifact) {
|
||||
void saveReviewSample();
|
||||
}
|
||||
}, [canSaveReviewSample, controller.parsedArtifact, saveReviewSample]);
|
||||
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const canOpenDetails = Boolean(latestCapture?.crops?.length || latestCapture?.ocr?.length);
|
||||
const shouldShowAdminBanner = !controller.runtimeInfo?.isElevated;
|
||||
const rightsClassName = controller.runtimeInfo?.isElevated ? "ok" : "blocked";
|
||||
const rightsValue = controller.runtimeInfo?.isElevated ? "Admin" : "Standard";
|
||||
const genshinClassName = controller.runtimeInfo?.genshinFound ? "ok" : "neutral";
|
||||
const genshinValue = controller.runtimeInfo?.genshinFound ? "Gefunden" : "Nicht gefunden";
|
||||
const statusTitle = controller.devMode ? "Dev-Ausgabe kompakt" : "Dev-Ausgabe erweitern";
|
||||
const autoScanModeLabel = controller.autoScanRunning ? "Scan active" : "Last scan";
|
||||
|
||||
const inventoryGrid = latestCapture?.inventoryGrid;
|
||||
const gridSourceClass = inventoryGrid?.source ?? "missing";
|
||||
const gridMainValue = inventoryGrid
|
||||
? inventoryGrid?.source === "missing"
|
||||
? "not detected"
|
||||
: `${inventoryGrid?.cols} x ${inventoryGrid?.rows}`
|
||||
: "waiting";
|
||||
const gridMetaValue = inventoryGrid
|
||||
? `${inventoryGrid?.confidence}% confidence - ${inventoryGrid?.centers.length} click targets`
|
||||
: "Run a capture once while the artifact inventory is visible.";
|
||||
|
||||
const learningRulesText = controller.learningRulesLoaded ? `${controller.learningRuleCount} local rules` : "loading";
|
||||
const learningRulesSubtext = `Review samples feed deterministic OCR fixes before each new parse. Data package: ${sourceVersion}`;
|
||||
|
||||
const playerProgress = useMemo(() => {
|
||||
const width = Math.min(
|
||||
100,
|
||||
controller.autoScanStats.attempted > 0
|
||||
? Math.round((controller.autoScanStats.verified / Math.max(1, controller.autoScanStats.attempted)) * 100)
|
||||
: 0,
|
||||
);
|
||||
const show = controller.autoScanRunning || controller.autoScanStats.clicked > 0 || controller.autoScanStats.parsed > 0;
|
||||
return { width, show };
|
||||
}, [
|
||||
controller.autoScanRunning,
|
||||
controller.autoScanStats.attempted,
|
||||
controller.autoScanStats.clicked,
|
||||
controller.autoScanStats.parsed,
|
||||
controller.autoScanStats.verified,
|
||||
]);
|
||||
|
||||
const autoScanStatsLines = useMemo(
|
||||
() => [
|
||||
{ label: "clicked", value: controller.autoScanStats.clicked },
|
||||
{ label: "attempted", value: controller.autoScanStats.attempted },
|
||||
{ label: "verified", value: controller.autoScanStats.verified },
|
||||
{ label: "parsed", value: controller.autoScanStats.parsed },
|
||||
{ label: "stored", value: controller.autoScanStats.stored },
|
||||
{ label: "review", value: controller.autoScanStats.review },
|
||||
{ label: "duplicates", value: controller.autoScanStats.duplicates },
|
||||
{ label: "misses", value: controller.autoScanStats.misses },
|
||||
{ label: "pages", value: controller.autoScanStats.pages },
|
||||
],
|
||||
[
|
||||
controller.autoScanStats.clicked,
|
||||
controller.autoScanStats.attempted,
|
||||
controller.autoScanStats.verified,
|
||||
controller.autoScanStats.parsed,
|
||||
controller.autoScanStats.stored,
|
||||
controller.autoScanStats.review,
|
||||
controller.autoScanStats.duplicates,
|
||||
controller.autoScanStats.misses,
|
||||
controller.autoScanStats.pages,
|
||||
],
|
||||
);
|
||||
|
||||
const runtimeRows = useMemo(() => {
|
||||
const selectedSourceLabel = controller.selectedSource
|
||||
? `Selected: ${controller.selectedSource.name}`
|
||||
: "No source selected";
|
||||
const storedTotalLabel = controller.storedTotal !== null ? `DB: ${controller.storedTotal} Artifacts` : "DB: -";
|
||||
const runtime = controller.runtimeInfo
|
||||
? `${controller.runtimeInfo?.isElevated ? "admin" : "standard"} - target ${controller.runtimeInfo?.genshinFound ? controller.runtimeInfo?.targetProcess || "Genshin" : "missing"} - fg ${controller.runtimeInfo?.foregroundProcess || "unknown"}`
|
||||
: "unknown";
|
||||
|
||||
return [selectedSourceLabel, storedTotalLabel, runtime, captureStatus, controller.reviewStatus].filter(Boolean);
|
||||
}, [controller.selectedSource, controller.storedTotal, controller.runtimeInfo, captureStatus, controller.reviewStatus]);
|
||||
|
||||
const fingerprintText = latestCapture ? detailFingerprint(latestCapture) : "--";
|
||||
const scanLimitText = controller.scanLimit ? `Scan-Limit: ${controller.scanLimit}` : "Keine Limitinfo.";
|
||||
const scanTipText = "Tip: Die Auto-Scan Logik bleibt aktivierbar, aber Dev-Ansicht ist rein informativ.";
|
||||
|
||||
return {
|
||||
closeDiagnostics,
|
||||
openDetails,
|
||||
handleSaveReviewSample,
|
||||
stopPropagation,
|
||||
canOpenDetails,
|
||||
statusTitle,
|
||||
rightsClassName,
|
||||
rightsValue,
|
||||
genshinClassName,
|
||||
genshinValue,
|
||||
shouldShowAdminBanner,
|
||||
gridSourceClass,
|
||||
gridMainValue,
|
||||
gridMetaValue,
|
||||
learningRulesText,
|
||||
learningRulesSubtext,
|
||||
autoScanModeLabel,
|
||||
fingerprintText,
|
||||
runtimeRows,
|
||||
scanLimitText,
|
||||
scanTipText,
|
||||
autoScanStatsLines,
|
||||
playerProgress,
|
||||
autoScanRunning: controller.autoScanRunning,
|
||||
showDevRows: controller.devMode,
|
||||
reviewStatus: controller.reviewStatus,
|
||||
automationLogLines: controller.automationLog,
|
||||
canSaveReviewSample: canSaveReviewSample && Boolean(controller.parsedArtifact),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||
import type { ReviewSampleAnalysis } from "../../../../lib/reviewSampleAnalysis";
|
||||
import type { ScanReviewQueueModalProps } from "../types";
|
||||
|
||||
export interface ScanReviewQueueRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ScanReviewQueueModalModel {
|
||||
closeReviewQueue: () => void;
|
||||
refreshReviewQueue: () => void;
|
||||
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
emptyText: string;
|
||||
reviewAnalysisRows: ScanReviewQueueRow[];
|
||||
showReviewAnalysis: boolean;
|
||||
}
|
||||
|
||||
interface UseScanReviewQueueModalModelInput extends Pick<
|
||||
ScanReviewQueueModalProps,
|
||||
"setReviewQueueOpen" | "loadReviewQueue"
|
||||
> {
|
||||
reviewAnalysis: ReviewSampleAnalysis;
|
||||
reviewSampleTotal: number;
|
||||
}
|
||||
|
||||
export function useScanReviewQueueModalModel({
|
||||
setReviewQueueOpen,
|
||||
loadReviewQueue,
|
||||
reviewAnalysis,
|
||||
reviewSampleTotal,
|
||||
}: UseScanReviewQueueModalModelInput): ScanReviewQueueModalModel {
|
||||
const closeReviewQueue = useCallback(() => setReviewQueueOpen(false), [setReviewQueueOpen]);
|
||||
const refreshReviewQueue = useCallback(() => {
|
||||
void loadReviewQueue();
|
||||
}, [loadReviewQueue]);
|
||||
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const weakFieldsSummary = useMemo(
|
||||
() => reviewAnalysis.weakFields.slice(0, 3).map((entry) => `${entry.field} ${entry.count}`).join(", "),
|
||||
[reviewAnalysis.weakFields],
|
||||
);
|
||||
const topReasonsSummary = useMemo(
|
||||
() => reviewAnalysis.reasons.slice(0, 2).map((entry) => `${entry.reason} ${entry.count}`).join(", "),
|
||||
[reviewAnalysis.reasons],
|
||||
);
|
||||
|
||||
const reviewAnalysisRows = useMemo(
|
||||
() => [
|
||||
{ label: "Parsed", value: `${reviewAnalysis.withParsed}/${reviewAnalysis.total}` },
|
||||
{ label: "Avg confidence", value: `${reviewAnalysis.averageConfidence}%` },
|
||||
{ label: "Weak fields", value: weakFieldsSummary || "none" },
|
||||
{ label: "Top reasons", value: topReasonsSummary || "none" },
|
||||
],
|
||||
[reviewAnalysis.averageConfidence, reviewAnalysis.total, reviewAnalysis.withParsed, topReasonsSummary, weakFieldsSummary],
|
||||
);
|
||||
|
||||
return {
|
||||
closeReviewQueue,
|
||||
refreshReviewQueue,
|
||||
stopPropagation,
|
||||
emptyText: reviewSampleTotal > 0 ? "none" : "Keine Review-Samples",
|
||||
reviewAnalysisRows,
|
||||
showReviewAnalysis: reviewSampleTotal > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
|
||||
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
|
||||
import { clampScanLimit, clampSkipRows } from "../../../../lib/scannerSession";
|
||||
|
||||
export interface ScanSettingsModalModel {
|
||||
closeSettings: () => void;
|
||||
handleScanLimitChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
handleSkipRowsChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
inventoryCountText: string;
|
||||
inventoryClassName: string;
|
||||
scanLimitClassName: string;
|
||||
skipRowsClassName: string;
|
||||
activeTargetText: string;
|
||||
scanSummaryText: string;
|
||||
}
|
||||
|
||||
export function useScanSettingsModalModel({
|
||||
setSettingsOpen,
|
||||
setScanLimit,
|
||||
setScanLimitTouched,
|
||||
setSkipRows,
|
||||
latestCapture,
|
||||
scanLimit,
|
||||
skipRows,
|
||||
activeTargetCount,
|
||||
runtimeInfo,
|
||||
}: {
|
||||
latestCapture: CaptureResult | null;
|
||||
scanLimit: number;
|
||||
skipRows: number;
|
||||
activeTargetCount: number;
|
||||
runtimeInfo: RuntimeInfo | null;
|
||||
setSettingsOpen: (open: boolean) => void;
|
||||
setScanLimit: (value: number) => void;
|
||||
setScanLimitTouched: (touched: boolean) => void;
|
||||
setSkipRows: (rows: number) => void;
|
||||
}): ScanSettingsModalModel {
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
|
||||
const handleScanLimitChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setScanLimitTouched(true);
|
||||
setScanLimit(clampScanLimit(Number(event.target.value)));
|
||||
}, [setScanLimit, setScanLimitTouched]);
|
||||
const handleSkipRowsChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSkipRows(clampSkipRows(Number(event.target.value)));
|
||||
}, [setSkipRows]);
|
||||
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.current;
|
||||
const detectedInventoryTotal = latestCapture?.inventoryCount?.total;
|
||||
const inventoryClassName = detectedInventoryCount ? "ok" : "neutral";
|
||||
const inventoryCountText = detectedInventoryCount
|
||||
? `${detectedInventoryCount}/${detectedInventoryTotal || "?"}`
|
||||
: "Noch nicht erkannt";
|
||||
const scanLimitClassName = scanLimit !== detectedInventoryCount ? "ok" : "standard";
|
||||
const skipRowsClassName = skipRows > 0 ? "ok" : "standard";
|
||||
const focusModeText = runtimeInfo?.isElevated ? "Admin" : "Standard";
|
||||
const activeTargetText = `Aktive Zielvorgabe ${activeTargetCount} und Fokusmodus ${focusModeText}.`;
|
||||
const scanSummaryText = "Der Auto-Scan zaehlt \"Positionen\" und \"verified\", bevor die Datenbank in den Save-Pfad laeuft. So wird \"scanned\" nicht mit \"erfolgreich gespeichert\" verwechselt.";
|
||||
|
||||
return {
|
||||
closeSettings,
|
||||
handleScanLimitChange,
|
||||
handleSkipRowsChange,
|
||||
stopPropagation,
|
||||
inventoryCountText,
|
||||
inventoryClassName,
|
||||
scanLimitClassName,
|
||||
skipRowsClassName,
|
||||
activeTargetText,
|
||||
scanSummaryText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, type MouseEvent } from "react";
|
||||
import type { ScanSummaryModalProps } from "../types";
|
||||
|
||||
export interface ScanSummaryModalModel {
|
||||
closeSummary: () => void;
|
||||
stopPropagation: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
iconKind: "running" | "blocked" | "stopped" | "finished";
|
||||
summaryTitle: string;
|
||||
gridSummaryText: string | null;
|
||||
}
|
||||
|
||||
interface UseScanSummaryModalModelInput extends Pick<ScanSummaryModalProps, "setScanSummary"> {
|
||||
scanSummary: ScanSummaryModalProps["controller"]["scanSummary"];
|
||||
}
|
||||
|
||||
export function useScanSummaryModalModel({
|
||||
setScanSummary,
|
||||
scanSummary,
|
||||
}: UseScanSummaryModalModelInput): ScanSummaryModalModel {
|
||||
const closeSummary = useCallback(() => setScanSummary(null), [setScanSummary]);
|
||||
const stopPropagation = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const isBlocked = scanSummary?.status === "blocked";
|
||||
const isStopped = scanSummary?.status === "stopped";
|
||||
const summaryTitle = isStopped ? "Scan gestoppt" : isBlocked ? "Scan abgebrochen" : "Scan fertig";
|
||||
const iconKind = isBlocked ? "blocked" : isStopped ? "stopped" : "finished";
|
||||
const gridSummaryText = scanSummary?.gridLabel ?? null;
|
||||
|
||||
return {
|
||||
closeSummary,
|
||||
stopPropagation,
|
||||
iconKind,
|
||||
summaryTitle,
|
||||
gridSummaryText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { ScanViewProps, ScanViewControllerResult } from "../../types";
|
||||
|
||||
export interface ScanDetailsModalProps {
|
||||
open: boolean;
|
||||
latestCapture: ScanViewProps["latestCapture"];
|
||||
controller: Pick<ScanViewControllerResult, "parsedArtifact">;
|
||||
setDetailsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ScanDiagnosticsModalProps {
|
||||
open: boolean;
|
||||
captureStatus: string;
|
||||
latestCapture: ScanViewProps["latestCapture"];
|
||||
controller: ScanViewControllerResult;
|
||||
setDetailsOpen: (open: boolean) => void;
|
||||
setDiagnosticsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ScanReviewQueueModalProps {
|
||||
open: boolean;
|
||||
controller: ScanViewControllerResult;
|
||||
setReviewQueueOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ScanSettingsModalProps {
|
||||
open: boolean;
|
||||
latestCapture: ScanViewProps["latestCapture"];
|
||||
controller: ScanViewControllerResult;
|
||||
setSettingsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ScanSummaryModalProps {
|
||||
open: boolean;
|
||||
controller: Pick<ScanViewControllerResult, "scanSummary" | "storedTotal">;
|
||||
setScanSummary: Dispatch<SetStateAction<ScanViewControllerResult["scanSummary"]>>;
|
||||
devMode: boolean;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { ReviewSampleRecord } from "../../../types/global";
|
||||
import type { ScanSummary } from "../../../lib/scannerSession";
|
||||
import type { ScanViewControllerResult, ScanViewProps } from "../types";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export interface FieldConfidenceListProps {
|
||||
parsedArtifact: ParsedArtifactCandidate;
|
||||
}
|
||||
|
||||
export interface ArtifactResultCardProps {
|
||||
parsed: ParsedArtifactCandidate;
|
||||
}
|
||||
|
||||
export interface ReviewSampleCardProps {
|
||||
entry: ReviewSampleRecord;
|
||||
}
|
||||
|
||||
export interface ScanSummaryFooterProps {
|
||||
devMode: boolean;
|
||||
scanSummary: ScanSummary;
|
||||
storedTotal: number | null;
|
||||
}
|
||||
|
||||
export interface ScanViewLayoutProps {
|
||||
captureSources: ScanViewProps["captureSources"];
|
||||
selectedSourceId: string;
|
||||
setSelectedSourceId: (value: string) => void;
|
||||
latestCapture: ScanViewProps["latestCapture"];
|
||||
captureStatus: string;
|
||||
refreshCaptureSources: () => Promise<void>;
|
||||
captureSelectedSource: ScanViewProps["captureSelectedSource"];
|
||||
bridgeReady: boolean;
|
||||
controller: ScanViewControllerResult;
|
||||
isScanning: boolean;
|
||||
}
|
||||
|
||||
export interface ScanMainSectionProps {
|
||||
latestCapture: ScanViewProps["latestCapture"];
|
||||
captureStatus: string;
|
||||
parsedArtifact: ScanViewControllerResult["parsedArtifact"];
|
||||
sourceLabel: string;
|
||||
gridLabel: string;
|
||||
inventoryLabel: string;
|
||||
activeTargetCount: number;
|
||||
storedTotal: ScanViewControllerResult["storedTotal"];
|
||||
reviewSampleTotal: number;
|
||||
learningRulesLoaded: ScanViewControllerResult["learningRulesLoaded"];
|
||||
learningRuleCount: number;
|
||||
canOpenReviewQueue: boolean;
|
||||
autoScanRunning: boolean;
|
||||
openReviewQueue: () => void;
|
||||
setDetailsOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ScanTopControlsSectionProps {
|
||||
captureSources: ScanViewProps["captureSources"];
|
||||
selectedSourceId: string;
|
||||
setSelectedSourceId: (value: string) => void;
|
||||
refreshCaptureSources: () => Promise<void>;
|
||||
captureSelectedSource: ScanViewProps["captureSelectedSource"];
|
||||
bridgeReady: boolean;
|
||||
isScanning: boolean;
|
||||
controller: ScanViewControllerResult;
|
||||
}
|
||||
|
||||
export interface ScanModalsSectionProps {
|
||||
captureStatus: string;
|
||||
latestCapture: ScanViewProps["latestCapture"];
|
||||
diagnosticsOpen: boolean;
|
||||
settingsOpen: boolean;
|
||||
detailsOpen: boolean;
|
||||
reviewQueueOpen: boolean;
|
||||
controller: ScanViewControllerResult;
|
||||
setDiagnosticsOpen: (open: boolean) => void;
|
||||
setSettingsOpen: (open: boolean) => void;
|
||||
setDetailsOpen: (open: boolean) => void;
|
||||
setReviewQueueOpen: (open: boolean) => void;
|
||||
setScanSummary: Dispatch<SetStateAction<ScanViewControllerResult["scanSummary"]>>;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { shouldFlagArtifactForReview } from "../../../lib/scannerLearning";
|
||||
import type { ScannerLearningRules } from "../../../lib/scannerLearning";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
|
||||
import type { ScanActionContext } from "./scanViewScanActions";
|
||||
import type {
|
||||
ReviewStateContext,
|
||||
} from "./scanViewReviewHelpers";
|
||||
import type { ArtifactRepositoryPort, ReviewSampleRepositoryPort, LearningRepositoryPort, AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord, RuntimeInfo } from "../../../types/global";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
|
||||
export interface ReviewStateContextInput {
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
reviewSamplesRepo?: ReviewSampleRepositoryPort;
|
||||
learningRepo?: LearningRepositoryPort;
|
||||
onStoredArtifactsChanged?: () => Promise<void> | void;
|
||||
setReviewSampleTotal: Dispatch<SetStateAction<number>>;
|
||||
setReviewSamples: Dispatch<SetStateAction<ReviewSampleRecord[]>>;
|
||||
setLearningRulesLoaded: Dispatch<SetStateAction<boolean>>;
|
||||
setScannerLearningRules: Dispatch<SetStateAction<ScannerLearningRules>>;
|
||||
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||
setStoredTotal: Dispatch<SetStateAction<number | null>>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
}
|
||||
|
||||
export interface ScanActionContextInput {
|
||||
autoScanRunning: boolean;
|
||||
setAutoScanRunning: Dispatch<SetStateAction<boolean>>;
|
||||
isScanning: boolean;
|
||||
stopVisibleScanRef: MutableRefObject<boolean>;
|
||||
selectedSourceId: string;
|
||||
bridgeReady: boolean;
|
||||
automationRepo?: AutomationRepositoryPort;
|
||||
runtimeInfo?: RuntimeInfo | null;
|
||||
runtimeRepo?: RuntimeRepositoryPort;
|
||||
scanLimit: number;
|
||||
skipRows: number;
|
||||
detectedInventoryCount: number;
|
||||
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
|
||||
setAutoScanStats: Dispatch<SetStateAction<AutoScanStats>>;
|
||||
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
persistParsedArtifact: (
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate,
|
||||
source: string,
|
||||
needsReview: boolean,
|
||||
) => Promise<boolean>;
|
||||
saveReviewSample: (
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
reason?: string,
|
||||
) => Promise<BooleanResult | null>;
|
||||
focusDashboard: () => Promise<void>;
|
||||
captureSelectedSource: (
|
||||
delayMs?: number,
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||
}
|
||||
|
||||
export function createReviewContext(input: ReviewStateContextInput): ReviewStateContext {
|
||||
return {
|
||||
artifactRepo: input.artifactRepo,
|
||||
reviewSamplesRepo: input.reviewSamplesRepo,
|
||||
learningRepo: input.learningRepo,
|
||||
onStoredArtifactsChanged: input.onStoredArtifactsChanged,
|
||||
setReviewSampleTotal: input.setReviewSampleTotal,
|
||||
setReviewSamples: input.setReviewSamples,
|
||||
setLearningRulesLoaded: input.setLearningRulesLoaded,
|
||||
setScannerLearningRules: input.setScannerLearningRules,
|
||||
setReviewStatus: input.setReviewStatus,
|
||||
setStoredTotal: input.setStoredTotal,
|
||||
appendAutomationLog: input.appendAutomationLog,
|
||||
};
|
||||
}
|
||||
|
||||
export function createScanActionContext(input: ScanActionContextInput): ScanActionContext {
|
||||
return {
|
||||
autoScanRunning: input.autoScanRunning,
|
||||
setAutoScanRunning: input.setAutoScanRunning,
|
||||
isScanning: input.isScanning,
|
||||
stopVisibleScanRef: input.stopVisibleScanRef,
|
||||
selectedSourceId: input.selectedSourceId,
|
||||
bridgeReady: input.bridgeReady,
|
||||
automationRepo: input.automationRepo,
|
||||
runtimeInfo: input.runtimeInfo,
|
||||
runtimeRepo: input.runtimeRepo,
|
||||
scanLimit: input.scanLimit,
|
||||
skipRows: input.skipRows,
|
||||
detectedInventoryCount: input.detectedInventoryCount,
|
||||
setScanSummary: input.setScanSummary,
|
||||
setAutoScanStats: input.setAutoScanStats,
|
||||
setReviewStatus: input.setReviewStatus,
|
||||
appendAutomationLog: input.appendAutomationLog,
|
||||
appendClickDiagnostics: input.appendClickDiagnostics,
|
||||
parseArtifact: input.parseArtifact,
|
||||
persistParsedArtifact: input.persistParsedArtifact,
|
||||
shouldFlagArtifactForReview: (parsed) => (parsed ? shouldFlagArtifactForReview(parsed) : false),
|
||||
saveReviewSample: input.saveReviewSample,
|
||||
focusDashboard: input.focusDashboard,
|
||||
captureSelectedSource: input.captureSelectedSource,
|
||||
captureFastSelectedSource: input.captureFastSelectedSource,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
applyScannerLearningRules,
|
||||
countScannerLearningRules,
|
||||
deriveScannerLearningRules,
|
||||
deriveScannerLearningRulesFromReviewSamples,
|
||||
shouldFlagArtifactForReview,
|
||||
type ScannerLearningRules,
|
||||
} from "../../../lib/scannerLearning";
|
||||
import { captureRejectionReason, captureSourceRejectionReason, shouldPersistParsedArtifact } from "../../../lib/scannerCaptureQuality";
|
||||
import { parseArtifactCandidate, type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import { isReviewOnlyArtifactSource, storedArtifactStrength, toStoredArtifact } from "../../../lib/artifactStore";
|
||||
import {
|
||||
type ArtifactRepositoryPort,
|
||||
type LearningRepositoryPort,
|
||||
type ReviewSampleRepositoryPort,
|
||||
} from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import type { CaptureResult, ReviewSampleRecord, SaveResultWithPath } from "../../../types/global";
|
||||
import type { StoredArtifactRecord } from "../../../types/storage";
|
||||
|
||||
const REVIEW_SAMPLE_LIMIT_INITIAL = 120;
|
||||
const REVIEW_SAMPLE_LIMIT_QUEUE = 80;
|
||||
|
||||
export interface ReviewStateContext {
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
reviewSamplesRepo?: ReviewSampleRepositoryPort;
|
||||
learningRepo?: LearningRepositoryPort;
|
||||
onStoredArtifactsChanged?: () => Promise<void> | void;
|
||||
setReviewSampleTotal: Dispatch<SetStateAction<number>>;
|
||||
setReviewSamples: Dispatch<SetStateAction<ReviewSampleRecord[]>>;
|
||||
setLearningRulesLoaded: Dispatch<SetStateAction<boolean>>;
|
||||
setScannerLearningRules: Dispatch<SetStateAction<ScannerLearningRules>>;
|
||||
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||
setStoredTotal: Dispatch<SetStateAction<number | null>>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
}
|
||||
|
||||
async function loadReviewSamplesForContext(
|
||||
context: ReviewStateContext,
|
||||
limit: number,
|
||||
): Promise<ReturnType<ReviewSampleRepositoryPort["loadSamples"]> | null> {
|
||||
return context.reviewSamplesRepo?.loadSamples(limit).catch(() => null) ?? null;
|
||||
}
|
||||
|
||||
async function loadReviewSamplesAndSetTotal(context: ReviewStateContext, limit: number) {
|
||||
const result = await loadReviewSamplesForContext(context, limit);
|
||||
if (result?.ok) {
|
||||
context.setReviewSampleTotal(result.total);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseLearnedArtifact(
|
||||
capture: CaptureResult | null,
|
||||
scannerLearningRules: ScannerLearningRules,
|
||||
): ParsedArtifactCandidate | null {
|
||||
return parseArtifactCandidate(applyScannerLearningRules(capture, scannerLearningRules));
|
||||
}
|
||||
|
||||
export function createCaptureFromReviewSample(entry: ReviewSampleRecord): CaptureResult | null {
|
||||
const capture = entry.sample?.capture;
|
||||
if (!capture?.ocr) return null;
|
||||
return {
|
||||
id: `review-${entry.savedAt}`,
|
||||
name: capture.name ?? "review-sample",
|
||||
width: capture.width ?? 0,
|
||||
height: capture.height ?? 0,
|
||||
dataUrl: "",
|
||||
capturedAt: capture.capturedAt ?? entry.savedAt,
|
||||
captureTarget: capture.captureTarget,
|
||||
crops: [],
|
||||
inventoryDataUrl: capture.inventoryDataUrl,
|
||||
ocr: capture.ocr,
|
||||
inventoryGrid: capture.inventoryGrid,
|
||||
};
|
||||
}
|
||||
|
||||
function storedArtifactFingerprint(record: StoredArtifactRecord | null | undefined) {
|
||||
if (!record) return "";
|
||||
return [
|
||||
record.name,
|
||||
record.slot,
|
||||
record.level ?? 0,
|
||||
record.mainStat,
|
||||
record.mainValue,
|
||||
record.setName,
|
||||
record.equipped,
|
||||
(record.substats ?? []).join("|"),
|
||||
record.needsReview ? "review" : "clean",
|
||||
].join("::");
|
||||
}
|
||||
|
||||
function shouldRecoverIntoStore(existing: StoredArtifactRecord | undefined, incoming: StoredArtifactRecord) {
|
||||
if (!existing) return true;
|
||||
if (storedArtifactFingerprint(existing) === storedArtifactFingerprint(incoming)) return false;
|
||||
|
||||
const existingStrength = storedArtifactStrength(existing);
|
||||
const incomingStrength = storedArtifactStrength(incoming);
|
||||
const existingSubstats = existing.substats?.length ?? 0;
|
||||
const incomingSubstats = incoming.substats?.length ?? 0;
|
||||
const existingEquippedKnown = Boolean(existing.equipped && !/not detected/i.test(existing.equipped));
|
||||
const incomingEquippedKnown = Boolean(incoming.equipped && !/not detected/i.test(incoming.equipped));
|
||||
|
||||
if (existing.needsReview && !incoming.needsReview) return true;
|
||||
if (incomingSubstats > existingSubstats) return true;
|
||||
if (!existingEquippedKnown && incomingEquippedKnown) return true;
|
||||
if (incomingStrength >= existingStrength + 6) return true;
|
||||
if (isReviewOnlyArtifactSource(existing.source)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDefaultScannerRules(loadedRules: { rules?: ScannerLearningRules } | null | undefined): ScannerLearningRules {
|
||||
return { textReplacements: { ...(loadedRules?.rules?.textReplacements ?? {}) } };
|
||||
}
|
||||
|
||||
async function loadReviewSamplesAndRecover(context: ReviewStateContext, rules: ScannerLearningRules, limit = REVIEW_SAMPLE_LIMIT_INITIAL) {
|
||||
const reviewResult = await loadReviewSamplesAndSetTotal(context, limit);
|
||||
if (reviewResult?.ok) {
|
||||
await recoverArtifactsFromReviewSamples(reviewResult.samples, rules, context);
|
||||
}
|
||||
return reviewResult;
|
||||
}
|
||||
|
||||
export async function recoverArtifactsFromReviewSamples(
|
||||
samples: ReviewSampleRecord[],
|
||||
rules: ScannerLearningRules,
|
||||
context: ReviewStateContext,
|
||||
): Promise<number> {
|
||||
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
|
||||
if (!artifactRepo?.saveMany || samples.length === 0) return 0;
|
||||
|
||||
const existingResult = await artifactRepo.loadAll().catch(() => null);
|
||||
const existingById = new Map(
|
||||
(existingResult?.artifacts ?? []).map((record: StoredArtifactRecord) => [record.id, record]),
|
||||
);
|
||||
|
||||
const recoveredCandidates = samples.flatMap((entry) => {
|
||||
const capture = createCaptureFromReviewSample(entry);
|
||||
const parsed = parseLearnedArtifact(capture, rules);
|
||||
if (!capture || !parsed) return [];
|
||||
if (captureSourceRejectionReason(capture)) return [];
|
||||
const needsReview = shouldFlagArtifactForReview(parsed);
|
||||
if (!shouldPersistParsedArtifact(parsed, needsReview)) return [];
|
||||
const stored = toStoredArtifact(parsed, "review-reprocess", needsReview);
|
||||
return shouldRecoverIntoStore(existingById.get(stored.id), stored) ? [stored] : [];
|
||||
});
|
||||
|
||||
const recoveredById = new Map<string, StoredArtifactRecord>();
|
||||
for (const record of recoveredCandidates) {
|
||||
const existing = recoveredById.get(record.id);
|
||||
if (!existing || storedArtifactStrength(record) > storedArtifactStrength(existing)) {
|
||||
recoveredById.set(record.id, record);
|
||||
}
|
||||
}
|
||||
const recovered = [...recoveredById.values()];
|
||||
|
||||
if (recovered.length === 0) return 0;
|
||||
const result = await artifactRepo.saveMany(recovered).catch(() => null);
|
||||
if (result?.ok) {
|
||||
setStoredTotal(result.total);
|
||||
void onStoredArtifactsChanged?.();
|
||||
appendAutomationLog(`review reprocess: ${result.added} neu, ${result.updated} aktualisiert`);
|
||||
return result.added + result.updated;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function initializeLearningState(context: ReviewStateContext): Promise<void> {
|
||||
const {
|
||||
learningRepo,
|
||||
setLearningRulesLoaded,
|
||||
setScannerLearningRules,
|
||||
} = context;
|
||||
|
||||
try {
|
||||
const loadedRules = await learningRepo?.loadRules().catch(() => null);
|
||||
const currentRules = getDefaultScannerRules(loadedRules);
|
||||
|
||||
if (countScannerLearningRules(currentRules) > 0) {
|
||||
setScannerLearningRules(currentRules);
|
||||
await loadReviewSamplesAndRecover(context, currentRules);
|
||||
setLearningRulesLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewResult = await loadReviewSamplesAndRecover(context, { ...currentRules }, REVIEW_SAMPLE_LIMIT_INITIAL);
|
||||
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
|
||||
const activeRules = countScannerLearningRules(derived) > 0 ? derived : currentRules;
|
||||
if (countScannerLearningRules(derived) > 0) {
|
||||
setScannerLearningRules(derived);
|
||||
await learningRepo?.saveRules?.(derived).catch(() => null);
|
||||
} else {
|
||||
setScannerLearningRules(currentRules);
|
||||
}
|
||||
setLearningRulesLoaded(true);
|
||||
} catch {
|
||||
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_INITIAL);
|
||||
const derived = deriveScannerLearningRulesFromReviewSamples(reviewResult?.samples ?? []);
|
||||
setScannerLearningRules(derived);
|
||||
if (countScannerLearningRules(derived) > 0) {
|
||||
await learningRepo?.saveRules?.(derived).catch(() => null);
|
||||
}
|
||||
if (reviewResult?.ok) {
|
||||
await recoverArtifactsFromReviewSamples(reviewResult.samples, derived, context);
|
||||
}
|
||||
setLearningRulesLoaded(true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeLearningRules(
|
||||
nextRules: Partial<ScannerLearningRules> | null | undefined,
|
||||
currentRules: ScannerLearningRules,
|
||||
context: ReviewStateContext,
|
||||
) {
|
||||
if (!nextRules || countScannerLearningRules(nextRules) === 0) return null;
|
||||
const merged = {
|
||||
textReplacements: {
|
||||
...currentRules.textReplacements,
|
||||
...(nextRules.textReplacements ?? {}),
|
||||
},
|
||||
};
|
||||
context.setScannerLearningRules(merged);
|
||||
const result = await context.learningRepo?.saveRules?.(merged).catch(() => null);
|
||||
return { result, merged };
|
||||
}
|
||||
|
||||
export async function persistParsedArtifact(
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate,
|
||||
source: string,
|
||||
needsReview: boolean,
|
||||
context: ReviewStateContext,
|
||||
) {
|
||||
const { artifactRepo, onStoredArtifactsChanged, setStoredTotal, appendAutomationLog } = context;
|
||||
if (!artifactRepo?.saveMany) return false;
|
||||
|
||||
const rejection = captureRejectionReason(capture, parsed);
|
||||
if (rejection) {
|
||||
appendAutomationLog(`persist skip: ${rejection}`);
|
||||
return false;
|
||||
}
|
||||
if (!shouldPersistParsedArtifact(parsed, needsReview)) {
|
||||
appendAutomationLog(`persist skip: parsed artifact bleibt vorerst nur Review (${parsed.name})`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const result = await artifactRepo.saveMany([toStoredArtifact(parsed, source, needsReview)]);
|
||||
if (result?.ok) {
|
||||
setStoredTotal(result.total);
|
||||
void onStoredArtifactsChanged?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveReviewSample(
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate | null,
|
||||
reason: string,
|
||||
scannerLearningRules: ScannerLearningRules,
|
||||
context: ReviewStateContext,
|
||||
): Promise<{ result: SaveResultWithPath | null; recoveredParsed: ParsedArtifactCandidate | null; recoveredToDb: boolean }> {
|
||||
const {
|
||||
reviewSamplesRepo,
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
setReviewSamples,
|
||||
onStoredArtifactsChanged,
|
||||
} = context;
|
||||
|
||||
if (!reviewSamplesRepo?.saveSample) {
|
||||
setReviewStatus("Review-Sample speichern ist nur in der Electron-App verfuegbar.");
|
||||
return { result: null, recoveredParsed: null, recoveredToDb: false };
|
||||
}
|
||||
if (!capture) return { result: null, recoveredParsed: null, recoveredToDb: false };
|
||||
|
||||
const result = await reviewSamplesRepo.saveSample({
|
||||
reason,
|
||||
capture: {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
dataUrl: capture.dataUrl,
|
||||
detailDataUrl: capture.detailDataUrl,
|
||||
inventoryDataUrl: capture.inventoryDataUrl,
|
||||
captureTarget: capture.captureTarget,
|
||||
capturedAt: capture.capturedAt,
|
||||
crops: capture.crops?.map((crop: NonNullable<CaptureResult["crops"]>[number]) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
rect: crop.rect,
|
||||
dataUrl: crop.dataUrl,
|
||||
})),
|
||||
inventoryGrid: capture.inventoryGrid,
|
||||
inventoryCount: capture.inventoryCount,
|
||||
ocr: capture.ocr,
|
||||
},
|
||||
parsed,
|
||||
});
|
||||
|
||||
const learned = deriveScannerLearningRules(capture, parsed);
|
||||
const learnedResult = await mergeLearningRules(learned, scannerLearningRules, context);
|
||||
const learnedCount = countScannerLearningRules(learned);
|
||||
const activeRules = learnedResult?.merged ?? scannerLearningRules;
|
||||
const recoveredParsed = parseLearnedArtifact(capture, activeRules);
|
||||
const recoveredNeedsReview = shouldFlagArtifactForReview(recoveredParsed);
|
||||
let recoveredToDb = false;
|
||||
if (recoveredParsed && shouldPersistParsedArtifact(recoveredParsed, recoveredNeedsReview)) {
|
||||
const extendedContext: ReviewStateContext = {
|
||||
...context,
|
||||
artifactRepo: context.artifactRepo,
|
||||
onStoredArtifactsChanged,
|
||||
};
|
||||
recoveredToDb = await persistParsedArtifact(capture, recoveredParsed, "review-recovered", recoveredNeedsReview, extendedContext);
|
||||
}
|
||||
|
||||
setReviewStatus(
|
||||
result?.path
|
||||
? `Review-Sample gespeichert: ${result.path}${learnedCount > 0 ? ` - ${learnedCount} lokale Lernregeln aktualisiert` : ""}${recoveredToDb ? " - Artifact direkt in DB nachgezogen" : ""}`
|
||||
: "Review-Sample konnte nicht gespeichert werden.",
|
||||
);
|
||||
if (learnedResult?.result?.ok) appendAutomationLog(`learning: ${learnedResult.result.total} aktive Textregeln`);
|
||||
if (recoveredToDb && recoveredParsed) appendAutomationLog(`review recovered: ${recoveredParsed.name} +${recoveredParsed.level}`);
|
||||
|
||||
const reviewResult = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
|
||||
if (reviewResult?.ok) {
|
||||
setReviewSamples(reviewResult.samples);
|
||||
}
|
||||
|
||||
return { result, recoveredParsed, recoveredToDb };
|
||||
}
|
||||
|
||||
export async function loadReviewQueue(context: ReviewStateContext): Promise<void> {
|
||||
const { setReviewSamples } = context;
|
||||
const result = await loadReviewSamplesAndSetTotal(context, REVIEW_SAMPLE_LIMIT_QUEUE);
|
||||
if (!result?.ok) return;
|
||||
setReviewSamples(result.samples);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { automationBlockReason, requiresAdminForAutomation } from "../../../lib/automationPlanner";
|
||||
import { captureRejectionReason } from "../../../lib/scannerCaptureQuality";
|
||||
import { runAutoScanLoop } from "../../../lib/autoScanLoop";
|
||||
import { clampScanLimit, emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import { getAutoReviewReason, wait } from "../../../lib/scanReviewUtils";
|
||||
import type { AutomationRepositoryPort, RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
import type { AutomationGuard, BooleanResult, CaptureOptions, CaptureResult, ClickResult, RuntimeInfo, ScrollResult } from "../../../types/global";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export interface ScanActionContext {
|
||||
autoScanRunning: boolean;
|
||||
setAutoScanRunning: Dispatch<SetStateAction<boolean>>;
|
||||
isScanning: boolean;
|
||||
stopVisibleScanRef: MutableRefObject<boolean>;
|
||||
selectedSourceId: string;
|
||||
bridgeReady: boolean;
|
||||
automationRepo?: AutomationRepositoryPort;
|
||||
runtimeRepo?: RuntimeRepositoryPort;
|
||||
runtimeInfo?: RuntimeInfo | null;
|
||||
scanLimit: number;
|
||||
skipRows: number;
|
||||
detectedInventoryCount: number;
|
||||
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
|
||||
setAutoScanStats: Dispatch<SetStateAction<AutoScanStats>>;
|
||||
setReviewStatus: Dispatch<SetStateAction<string>>;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
captureFastSelectedSource: (delayMs?: number, focusGenshin?: boolean) => Promise<CaptureResult | null>;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
persistParsedArtifact: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate, source: string, needsReview: boolean) => Promise<boolean>;
|
||||
saveReviewSample: (capture: CaptureResult | null, parsed: ParsedArtifactCandidate | null, reason?: string) => Promise<BooleanResult | null>;
|
||||
shouldFlagArtifactForReview: (parsed: ParsedArtifactCandidate | null) => boolean;
|
||||
focusDashboard: () => Promise<void>;
|
||||
}
|
||||
|
||||
function buildScanSignature(parsed: ParsedArtifactCandidate) {
|
||||
return `${parsed.name}-${parsed.slot}-${parsed.mainStat}-${parsed.mainValue}-${parsed.level ?? 0}`;
|
||||
}
|
||||
|
||||
export async function runAutoReviewScan(context: ScanActionContext): Promise<void> {
|
||||
const {
|
||||
autoScanRunning,
|
||||
selectedSourceId,
|
||||
stopVisibleScanRef,
|
||||
setAutoScanRunning,
|
||||
setReviewStatus,
|
||||
setScanSummary,
|
||||
setAutoScanStats,
|
||||
appendAutomationLog,
|
||||
scanLimit,
|
||||
detectedInventoryCount,
|
||||
captureSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
saveReviewSample,
|
||||
shouldFlagArtifactForReview,
|
||||
focusDashboard,
|
||||
} = context;
|
||||
|
||||
if (autoScanRunning || !selectedSourceId) return;
|
||||
|
||||
setAutoScanRunning(true);
|
||||
stopVisibleScanRef.current = false;
|
||||
setScanSummary(null);
|
||||
setAutoScanStats(emptyAutoScanStats);
|
||||
setReviewStatus("Manueller Scan laeuft. Klicke in Genshin auf ein anderes Artifact; nur neue Artifacts werden verarbeitet.");
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stats: AutoScanStats = { ...emptyAutoScanStats, pages: 1 };
|
||||
let idleTicks = 0;
|
||||
const maxArtifacts = resolveScanTargetCount(scanLimit, detectedInventoryCount);
|
||||
const maxIdleTicks = 90;
|
||||
|
||||
while (!stopVisibleScanRef.current && stats.parsed < maxArtifacts && idleTicks < maxIdleTicks) {
|
||||
const capture = await captureSelectedSource(0, true);
|
||||
const rejection = captureRejectionReason(capture, parseArtifact(capture));
|
||||
const parsed = parseArtifact(capture);
|
||||
|
||||
if (!capture || !parsed || rejection) {
|
||||
if (capture && rejection) {
|
||||
await saveReviewSample(capture, parsed, `manual:capture-rejected`);
|
||||
stats.review++;
|
||||
setAutoScanStats({ ...stats });
|
||||
}
|
||||
idleTicks++;
|
||||
setReviewStatus(`Manueller Scan wartet auf ein lesbares Artifact... (${stats.parsed}/${maxArtifacts})${rejection ? ` ${rejection}` : ""}`);
|
||||
await wait(700);
|
||||
continue;
|
||||
}
|
||||
|
||||
const signature = buildScanSignature(parsed);
|
||||
if (seen.has(signature)) {
|
||||
idleTicks++;
|
||||
setReviewStatus(`Manueller Scan wartet auf ein neues Artifact... (${stats.parsed}/${maxArtifacts})`);
|
||||
await wait(700);
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(signature);
|
||||
idleTicks = 0;
|
||||
stats.attempted++;
|
||||
stats.verified++;
|
||||
stats.parsed++;
|
||||
|
||||
const reason = getAutoReviewReason(capture, parsed);
|
||||
const needsReview = shouldFlagArtifactForReview(parsed);
|
||||
if (reason) {
|
||||
await saveReviewSample(capture, parsed, `manual:${reason}`);
|
||||
stats.review++;
|
||||
}
|
||||
if (await persistParsedArtifact(capture, parsed, "manual-scan", needsReview)) {
|
||||
stats.stored++;
|
||||
}
|
||||
setAutoScanStats({ ...stats });
|
||||
setReviewStatus(`Manueller Scan: neues Artifact erkannt (${stats.parsed}/${maxArtifacts}). Klicke das naechste Artifact an oder druecke Stop.`);
|
||||
await wait(700);
|
||||
}
|
||||
|
||||
setAutoScanRunning(false);
|
||||
const status: ScanSummary["status"] = stopVisibleScanRef.current ? "stopped" : "done";
|
||||
const idleSuffix = idleTicks >= maxIdleTicks ? " Keine neuen Artifacts erkannt; manueller Scan beendet." : "";
|
||||
await focusDashboard();
|
||||
setReviewStatus(
|
||||
`Manueller Scan ${status}. ${stats.verified} neue Ansichten verifiziert, ${stats.parsed} Artifacts gelesen, ${stats.stored} in der Datenbank gespeichert, ${stats.review} Review-Samples.${idleSuffix}`,
|
||||
);
|
||||
setScanSummary({
|
||||
mode: "Manueller Scan",
|
||||
status,
|
||||
...stats,
|
||||
targetCount: maxArtifacts,
|
||||
gridLabel: "Nur neue, vom User angeklickte Artifacts wurden verarbeitet.",
|
||||
});
|
||||
|
||||
appendAutomationLog(`manual scan finished: ${stats.parsed} parsed, ${stats.stored} stored, ${stats.review} review`);
|
||||
}
|
||||
|
||||
export async function runVisibleGridScan(context: ScanActionContext): Promise<void> {
|
||||
const {
|
||||
autoScanRunning,
|
||||
bridgeReady,
|
||||
selectedSourceId,
|
||||
runtimeInfo,
|
||||
automationRepo,
|
||||
runtimeRepo,
|
||||
stopVisibleScanRef,
|
||||
setAutoScanRunning,
|
||||
setScanSummary,
|
||||
setAutoScanStats,
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
captureSelectedSource,
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
saveReviewSample,
|
||||
shouldFlagArtifactForReview,
|
||||
scanLimit,
|
||||
skipRows,
|
||||
detectedInventoryCount,
|
||||
focusDashboard,
|
||||
} = context;
|
||||
|
||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) return;
|
||||
|
||||
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
|
||||
if (requiresAdminForAutoScan) {
|
||||
setReviewStatus(
|
||||
"App laeuft nicht als Administrator. Bitte die App schliessen und als Administrator neu starten - Auto-Scan braucht Administrator-Rechte, damit Windows die simulierten Eingaben an Genshin nicht blockiert.",
|
||||
);
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
status: "blocked",
|
||||
...emptyAutoScanStats,
|
||||
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
|
||||
gridLabel: "App laeuft nicht als Administrator. Neustart als Administrator noetig.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoScanRunning(true);
|
||||
stopVisibleScanRef.current = false;
|
||||
setScanSummary(null);
|
||||
setAutoScanStats(emptyAutoScanStats);
|
||||
setReviewStatus("Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
|
||||
const freshRuntime = await runtimeRepo?.getRuntimeInfo().catch(() => null);
|
||||
const adminBlockReason = automationBlockReason(freshRuntime);
|
||||
if (adminBlockReason) {
|
||||
setAutoScanRunning(false);
|
||||
setReviewStatus(adminBlockReason);
|
||||
appendAutomationLog("blocked: App laeuft nicht als Administrator, keine In-Game-Klicks ausgefuehrt");
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
status: "blocked",
|
||||
...emptyAutoScanStats,
|
||||
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
|
||||
gridLabel: adminBlockReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (freshRuntime) {
|
||||
const required = freshRuntime.genshinFound ? `found:${freshRuntime.targetProcess || "genshin"}` : "not-found";
|
||||
appendAutomationLog(`runtime ping: elevated=${freshRuntime.isElevated} ${required}`);
|
||||
}
|
||||
|
||||
setReviewStatus("Genshin wird in den Vordergrund geholt...");
|
||||
const focusResult = await automationRepo?.focusGenshin().catch(() => null);
|
||||
if (focusResult) {
|
||||
appendAutomationLog(
|
||||
`focus: ${focusResult.focused ? "ok" : "fehlgeschlagen"} found:${focusResult.genshinFound ? "yes" : "no"} setForeground:${focusResult.setForegroundResult ?? "n/a"} target:${focusResult.targetProcess || "?"} fg:${focusResult.foregroundProcess || "?"}`,
|
||||
);
|
||||
}
|
||||
if (!focusResult?.focused) {
|
||||
setAutoScanRunning(false);
|
||||
const reason = !focusResult?.genshinFound
|
||||
? "Genshin-Prozess wurde nicht gefunden. Bitte pruefen, ob Genshin laeuft, und Auto-Scan erneut starten."
|
||||
: "Genshin konnte nicht in den Vordergrund geholt werden. Bitte Genshin manuell anklicken/fokussieren und Auto-Scan erneut starten.";
|
||||
setReviewStatus(reason);
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
status: "blocked",
|
||||
...emptyAutoScanStats,
|
||||
targetCount: resolveScanTargetCount(scanLimit, detectedInventoryCount),
|
||||
gridLabel: reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setReviewStatus("Genshin ist im Vordergrund. Automatischer Scan startet. ESC gedrueckt halten oder Stop klicken beendet den Scan sofort.");
|
||||
|
||||
const result = await runAutoScanLoop(
|
||||
{
|
||||
api: {
|
||||
clickScreen: (x: number, y: number) => {
|
||||
if (!automationRepo?.clickScreen) {
|
||||
return Promise.resolve<ClickResult>({
|
||||
ok: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
clicked: false,
|
||||
moved: false,
|
||||
focused: false,
|
||||
inputBlocked: false,
|
||||
});
|
||||
}
|
||||
return automationRepo.clickScreen(x, y);
|
||||
},
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => {
|
||||
if (!automationRepo?.scrollScreen) {
|
||||
return Promise.resolve<ScrollResult>({ ok: false, notchesSent: 0, inputBlocked: false });
|
||||
}
|
||||
return automationRepo.scrollScreen(notches, anchorX, anchorY);
|
||||
},
|
||||
getAutomationGuard: () =>
|
||||
automationRepo?.getAutomationGuard?.() ??
|
||||
Promise.resolve<AutomationGuard>({ ok: false, escapePressed: false, enterPressed: false, f9Pressed: false }),
|
||||
},
|
||||
captureSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin),
|
||||
captureFastSelectedSource,
|
||||
parseArtifact,
|
||||
persistParsedArtifact,
|
||||
saveReviewSample,
|
||||
getAutoReviewReason,
|
||||
shouldFlagArtifactForReview,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
setReviewStatus,
|
||||
setAutoScanStats,
|
||||
shouldStop: () => stopVisibleScanRef.current,
|
||||
},
|
||||
{
|
||||
scanLimit,
|
||||
skipRows,
|
||||
detectedInventoryCount,
|
||||
},
|
||||
);
|
||||
|
||||
setAutoScanRunning(false);
|
||||
if (result.blockedReason) appendAutomationLog(`stop: ${result.blockedReason}`);
|
||||
await focusDashboard();
|
||||
setReviewStatus(`Automatischer Scan ${result.status === "stopped" ? "gestoppt" : result.status === "blocked" ? "blockiert" : "fertig"}. ${result.stats.clicked} Klicks, ${result.stats.attempted} Positionen bearbeitet, ${result.stats.verified} Ansichten verifiziert, ${result.stats.parsed} gelesen, ${result.stats.stored} in der Datenbank, ${result.stats.review} Review-Samples, ${result.stats.duplicates} Duplikate, ${result.stats.misses} Misses.${result.blockedReason ? ` ${result.blockedReason}` : ""}`);
|
||||
setScanSummary({
|
||||
mode: "Automatischer Scan",
|
||||
status: result.status,
|
||||
...result.stats,
|
||||
targetCount: result.targetCount,
|
||||
gridLabel: result.gridLabel,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from "react";
|
||||
import type { AutomationRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
|
||||
interface ScanCommandListenerInput {
|
||||
automationRepo?: AutomationRepositoryPort;
|
||||
autoScanRunning: boolean;
|
||||
isScanning: boolean;
|
||||
selectedSourceId: string;
|
||||
requestScanStop: (reason: string) => void;
|
||||
runVisibleGridScan: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useScanCommandListener({
|
||||
automationRepo,
|
||||
autoScanRunning,
|
||||
isScanning,
|
||||
selectedSourceId,
|
||||
requestScanStop,
|
||||
runVisibleGridScan,
|
||||
}: ScanCommandListenerInput) {
|
||||
useEffect(() => {
|
||||
if (!automationRepo?.onCommand) return;
|
||||
return automationRepo.onCommand((command: "start-auto" | "stop") => {
|
||||
if (command === "stop") {
|
||||
requestScanStop("Hotkey/Dev-Stop gedrueckt.");
|
||||
return;
|
||||
}
|
||||
if (command === "start-auto" && !autoScanRunning && !isScanning && selectedSourceId) {
|
||||
void runVisibleGridScan();
|
||||
}
|
||||
});
|
||||
}, [automationRepo, autoScanRunning, isScanning, selectedSourceId, requestScanStop, runVisibleGridScan]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RuntimeInfo } from "../../../types/global";
|
||||
import type { RuntimeRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
|
||||
export function useScanRuntimeInfo(runtimeRepo?: RuntimeRepositoryPort) {
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
runtimeRepo?.getRuntimeInfo().then((result) => {
|
||||
if (mounted) {
|
||||
setRuntimeInfo(result);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
runtimeRepo?.getRuntimeInfo().then((result) => {
|
||||
if (mounted) setRuntimeInfo(result);
|
||||
}).catch(() => undefined);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [runtimeRepo]);
|
||||
|
||||
return runtimeInfo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from "react";
|
||||
import { type AppSnapshot } from "../../../types/domain";
|
||||
import { type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import type { RuntimeInfo } from "../../../types/global";
|
||||
import type { SnapshotRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { CaptureResult } from "../../../types/global";
|
||||
|
||||
type InventoryGrid = NonNullable<CaptureResult["inventoryGrid"]>;
|
||||
|
||||
interface ScanSnapshotPublisherInput {
|
||||
autoScanRunning: boolean;
|
||||
reviewStatus: string;
|
||||
captureStatus: string;
|
||||
selectedSourceName: string | null;
|
||||
autoScanStats: AutoScanStats;
|
||||
scanSummary: ScanSummary | null;
|
||||
snapshot: AppSnapshot;
|
||||
latestInventoryGrid: InventoryGrid | null | undefined;
|
||||
automationLog: string[];
|
||||
runtimeInfo: RuntimeInfo | null;
|
||||
storedTotal: number | null;
|
||||
learningRuleCount: number;
|
||||
snapshotRepo?: SnapshotRepositoryPort;
|
||||
}
|
||||
|
||||
export function useScanSnapshotPublisher({
|
||||
autoScanRunning,
|
||||
reviewStatus,
|
||||
captureStatus,
|
||||
selectedSourceName,
|
||||
autoScanStats,
|
||||
scanSummary,
|
||||
snapshot,
|
||||
latestInventoryGrid,
|
||||
automationLog,
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
snapshotRepo,
|
||||
}: ScanSnapshotPublisherInput) {
|
||||
useEffect(() => {
|
||||
void snapshotRepo?.publishScannerStatus({
|
||||
running: autoScanRunning,
|
||||
reviewStatus,
|
||||
captureStatus,
|
||||
selectedSource: selectedSourceName,
|
||||
stats: autoScanStats,
|
||||
summary: scanSummary,
|
||||
snapshotArtifacts: snapshot.artifacts.length,
|
||||
snapshotCharacters: snapshot.characters.filter((character) => character.owned).length,
|
||||
snapshotRecommendations: snapshot.recommendations.length,
|
||||
snapshotBuilds: snapshot.builds.length,
|
||||
grid: latestInventoryGrid ?? null,
|
||||
automationLog: automationLog.slice(-12),
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).catch(() => undefined);
|
||||
}, [
|
||||
autoScanRunning,
|
||||
reviewStatus,
|
||||
captureStatus,
|
||||
selectedSourceName,
|
||||
autoScanStats,
|
||||
scanSummary,
|
||||
snapshot,
|
||||
latestInventoryGrid,
|
||||
automationLog,
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
snapshotRepo,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { runAutoReviewScan as runAutoReviewScanAction, runVisibleGridScan as runVisibleGridScanAction } from "./scanViewScanActions";
|
||||
import {
|
||||
initializeLearningState,
|
||||
loadReviewQueue as loadReviewQueueFromRepo,
|
||||
persistParsedArtifact as persistParsedArtifactHelper,
|
||||
saveReviewSample as saveReviewSampleHelper,
|
||||
} from "./scanViewReviewHelpers";
|
||||
import { createReviewContext, createScanActionContext } from "./scanViewControllerService";
|
||||
import { useScanCommandListener } from "./useScanCommandListener";
|
||||
import type { ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import type { ScannerLearningRules } from "../../../lib/scannerLearning";
|
||||
import type { BooleanResult, CaptureOptions, CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||
import type {
|
||||
ArtifactRepositoryPort,
|
||||
ReviewSampleRepositoryPort,
|
||||
LearningRepositoryPort,
|
||||
RuntimeRepositoryPort,
|
||||
AutomationRepositoryPort,
|
||||
} from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import type { AutoScanStats, ScanSummary } from "../../../lib/scannerSession";
|
||||
import type { RuntimeInfo } from "../../../types/global";
|
||||
|
||||
type BooleanSetter = Dispatch<SetStateAction<boolean>>;
|
||||
type NumberSetter = Dispatch<SetStateAction<number>>;
|
||||
type StringSetter = Dispatch<SetStateAction<string>>;
|
||||
type NumberOrNullSetter = Dispatch<SetStateAction<number | null>>;
|
||||
type ScannerRulesSetter = Dispatch<SetStateAction<ScannerLearningRules>>;
|
||||
type ReviewSamplesSetter = Dispatch<SetStateAction<ReviewSampleRecord[]>>;
|
||||
type ScanSummarySetter = Dispatch<SetStateAction<ScanSummary | null>>;
|
||||
type AutoScanStatsSetter = Dispatch<SetStateAction<AutoScanStats>>;
|
||||
|
||||
interface ScanViewActionInput {
|
||||
autoScanRunning: boolean;
|
||||
setAutoScanRunning: BooleanSetter;
|
||||
isScanning: boolean;
|
||||
stopVisibleScanRef: MutableRefObject<boolean>;
|
||||
selectedSourceId: string;
|
||||
bridgeReady: boolean;
|
||||
automationRepo?: AutomationRepositoryPort;
|
||||
runtimeInfo?: RuntimeInfo | null;
|
||||
runtimeRepo?: RuntimeRepositoryPort;
|
||||
scanLimit: number;
|
||||
skipRows: number;
|
||||
detectedInventoryCount: number;
|
||||
setScanSummary: ScanSummarySetter;
|
||||
setAutoScanStats: AutoScanStatsSetter;
|
||||
setReviewStatus: StringSetter;
|
||||
appendAutomationLog: (line: string) => void;
|
||||
appendClickDiagnostics: (result: ClickResult, prefix?: string) => void;
|
||||
parseArtifact: (capture: CaptureResult | null) => ParsedArtifactCandidate | null;
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
reviewSamplesRepo?: ReviewSampleRepositoryPort;
|
||||
learningRepo?: LearningRepositoryPort;
|
||||
onStoredArtifactsChanged?: (() => Promise<void>) | (() => void);
|
||||
setReviewSampleTotal: NumberSetter;
|
||||
setReviewSamples: ReviewSamplesSetter;
|
||||
setLearningRulesLoaded: BooleanSetter;
|
||||
setScannerLearningRules: ScannerRulesSetter;
|
||||
setStoredTotal: NumberOrNullSetter;
|
||||
scannerLearningRules: ScannerLearningRules;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
latestCapture: CaptureResult | null;
|
||||
parsedArtifact: ParsedArtifactCandidate | null;
|
||||
canCaptureSource: boolean;
|
||||
setReviewQueueOpen: BooleanSetter;
|
||||
}
|
||||
|
||||
export interface ScanViewActionResult {
|
||||
requestScanStop: (reason?: string) => void;
|
||||
saveReviewSample: (
|
||||
capture?: CaptureResult | null,
|
||||
parsed?: ParsedArtifactCandidate | null,
|
||||
reason?: string,
|
||||
) => Promise<BooleanResult | null>;
|
||||
loadReviewQueue: () => Promise<void>;
|
||||
openReviewQueue: () => Promise<void>;
|
||||
runAutoReviewScan: () => Promise<void>;
|
||||
runVisibleGridScan: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useScanViewActions(input: ScanViewActionInput): ScanViewActionResult {
|
||||
const {
|
||||
autoScanRunning,
|
||||
setAutoScanRunning,
|
||||
isScanning,
|
||||
stopVisibleScanRef,
|
||||
selectedSourceId,
|
||||
bridgeReady,
|
||||
automationRepo,
|
||||
runtimeInfo,
|
||||
runtimeRepo,
|
||||
scanLimit,
|
||||
skipRows,
|
||||
detectedInventoryCount,
|
||||
setScanSummary,
|
||||
setAutoScanStats,
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
parseArtifact,
|
||||
artifactRepo,
|
||||
reviewSamplesRepo,
|
||||
learningRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setReviewSampleTotal,
|
||||
setReviewSamples,
|
||||
setLearningRulesLoaded,
|
||||
setScannerLearningRules,
|
||||
setStoredTotal,
|
||||
scannerLearningRules,
|
||||
captureSelectedSource,
|
||||
latestCapture,
|
||||
parsedArtifact,
|
||||
canCaptureSource,
|
||||
setReviewQueueOpen,
|
||||
} = input;
|
||||
|
||||
const requestScanStop = useCallback((reason = "Stop angefordert.") => {
|
||||
stopVisibleScanRef.current = true;
|
||||
appendAutomationLog(`stop requested: ${reason}`);
|
||||
setReviewStatus(`${reason} Der aktuelle Klick/Capture-Schritt wird noch sauber beendet.`);
|
||||
}, [appendAutomationLog, setReviewStatus, stopVisibleScanRef]);
|
||||
|
||||
const reviewContext = useMemo(
|
||||
() =>
|
||||
createReviewContext({
|
||||
artifactRepo,
|
||||
reviewSamplesRepo,
|
||||
learningRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setReviewSampleTotal,
|
||||
setReviewSamples,
|
||||
setLearningRulesLoaded,
|
||||
setScannerLearningRules,
|
||||
setReviewStatus,
|
||||
setStoredTotal,
|
||||
appendAutomationLog,
|
||||
}),
|
||||
[
|
||||
artifactRepo,
|
||||
reviewSamplesRepo,
|
||||
learningRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setReviewSampleTotal,
|
||||
setReviewSamples,
|
||||
setLearningRulesLoaded,
|
||||
setScannerLearningRules,
|
||||
setReviewStatus,
|
||||
setStoredTotal,
|
||||
appendAutomationLog,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void initializeLearningState(reviewContext);
|
||||
}, [reviewContext]);
|
||||
|
||||
const focusDashboard = useCallback(async () => {
|
||||
try {
|
||||
await automationRepo?.focusMainWindow?.();
|
||||
} catch {
|
||||
// Best-effort fallback; state remains in renderer.
|
||||
}
|
||||
}, [automationRepo?.focusMainWindow]);
|
||||
|
||||
const parseArtifactAndPersist = useCallback(
|
||||
async function parseArtifactAndPersist(
|
||||
capture: CaptureResult | null,
|
||||
parsed: ParsedArtifactCandidate,
|
||||
source: string,
|
||||
needsReview: boolean,
|
||||
) {
|
||||
return persistParsedArtifactHelper(capture, parsed, source, needsReview, reviewContext);
|
||||
},
|
||||
[reviewContext],
|
||||
);
|
||||
|
||||
const handleSaveReviewSample = useCallback(
|
||||
async function handleSaveReviewSample(
|
||||
capture: CaptureResult | null = latestCapture,
|
||||
parsed: ParsedArtifactCandidate | null = parsedArtifact,
|
||||
reason = "manual",
|
||||
): Promise<BooleanResult | null> {
|
||||
const result = await saveReviewSampleHelper(
|
||||
capture,
|
||||
parsed,
|
||||
reason,
|
||||
scannerLearningRules,
|
||||
reviewContext,
|
||||
);
|
||||
return result.result ? { ok: result.result.ok } : null;
|
||||
},
|
||||
[latestCapture, parsedArtifact, reviewContext, scannerLearningRules],
|
||||
);
|
||||
|
||||
const scanActionContext = useMemo(
|
||||
() =>
|
||||
createScanActionContext({
|
||||
autoScanRunning,
|
||||
setAutoScanRunning,
|
||||
isScanning,
|
||||
stopVisibleScanRef,
|
||||
selectedSourceId,
|
||||
bridgeReady,
|
||||
automationRepo,
|
||||
runtimeInfo,
|
||||
runtimeRepo,
|
||||
scanLimit,
|
||||
skipRows,
|
||||
detectedInventoryCount,
|
||||
setScanSummary,
|
||||
setAutoScanStats,
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
parseArtifact,
|
||||
persistParsedArtifact: parseArtifactAndPersist,
|
||||
saveReviewSample: handleSaveReviewSample,
|
||||
focusDashboard,
|
||||
captureSelectedSource,
|
||||
captureFastSelectedSource: (delayMs = 0, focusGenshin = false) => captureSelectedSource(delayMs, focusGenshin, { skipOcr: true }),
|
||||
}),
|
||||
[
|
||||
autoScanRunning,
|
||||
setAutoScanRunning,
|
||||
isScanning,
|
||||
stopVisibleScanRef,
|
||||
selectedSourceId,
|
||||
bridgeReady,
|
||||
automationRepo,
|
||||
runtimeInfo,
|
||||
runtimeRepo,
|
||||
scanLimit,
|
||||
skipRows,
|
||||
detectedInventoryCount,
|
||||
setScanSummary,
|
||||
setAutoScanStats,
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
parseArtifact,
|
||||
parseArtifactAndPersist,
|
||||
handleSaveReviewSample,
|
||||
focusDashboard,
|
||||
captureSelectedSource,
|
||||
],
|
||||
);
|
||||
|
||||
const loadReviewQueueAction = useCallback(async () => {
|
||||
await loadReviewQueueFromRepo(reviewContext);
|
||||
}, [reviewContext]);
|
||||
|
||||
const openReviewQueueModal = useCallback(async () => {
|
||||
await loadReviewQueueAction();
|
||||
setReviewQueueOpen(true);
|
||||
}, [loadReviewQueueAction, setReviewQueueOpen]);
|
||||
|
||||
const runAutoReviewScan = useCallback(async () => {
|
||||
if (autoScanRunning || !selectedSourceId || !canCaptureSource) return;
|
||||
await runAutoReviewScanAction(scanActionContext);
|
||||
}, [autoScanRunning, canCaptureSource, selectedSourceId, scanActionContext]);
|
||||
|
||||
const runVisibleGridScan = useCallback(async () => {
|
||||
if (autoScanRunning || !bridgeReady || !selectedSourceId || !automationRepo?.clickScreen || !automationRepo?.scrollScreen) {
|
||||
return;
|
||||
}
|
||||
await runVisibleGridScanAction(scanActionContext);
|
||||
}, [
|
||||
autoScanRunning,
|
||||
bridgeReady,
|
||||
selectedSourceId,
|
||||
automationRepo?.clickScreen,
|
||||
automationRepo?.scrollScreen,
|
||||
scanActionContext,
|
||||
]);
|
||||
|
||||
useScanCommandListener({
|
||||
automationRepo,
|
||||
autoScanRunning,
|
||||
isScanning,
|
||||
selectedSourceId,
|
||||
requestScanStop,
|
||||
runVisibleGridScan,
|
||||
});
|
||||
|
||||
return {
|
||||
requestScanStop,
|
||||
saveReviewSample: handleSaveReviewSample,
|
||||
loadReviewQueue: loadReviewQueueAction,
|
||||
openReviewQueue: openReviewQueueModal,
|
||||
runAutoReviewScan,
|
||||
runVisibleGridScan,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { requiresAdminForAutomation } from "../../../lib/automationPlanner";
|
||||
import {
|
||||
countScannerLearningRules,
|
||||
type ScannerLearningRules,
|
||||
} from "../../../lib/scannerLearning";
|
||||
import { type ParsedArtifactCandidate } from "../../../lib/artifactOcrParser";
|
||||
import { analyzeReviewSamples } from "../../../lib/reviewSampleAnalysis";
|
||||
import {
|
||||
parseLearnedArtifact as parseLearnedArtifactHelper,
|
||||
} from "./scanViewReviewHelpers";
|
||||
import { useScanRuntimeInfo } from "./useScanRuntimeInfo";
|
||||
import { useScanSnapshotPublisher } from "./useScanSnapshotPublisher";
|
||||
import { useScanViewActions } from "./useScanViewActions";
|
||||
import { useScanViewStateSync } from "./useScanViewStateSync";
|
||||
import { emptyAutoScanStats, resolveScanTargetCount, type AutoScanStats, type ScanSummary } from "../../../lib/scannerSession";
|
||||
import type { ScanViewProps, ScanViewControllerResult } from "../types";
|
||||
import type { CaptureResult, ClickResult, ReviewSampleRecord } from "../../../types/global";
|
||||
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
||||
|
||||
export function useScanViewController({
|
||||
snapshot,
|
||||
isScanning,
|
||||
captureSources,
|
||||
selectedSourceId,
|
||||
latestCapture,
|
||||
captureStatus,
|
||||
captureSelectedSource,
|
||||
bridgeReady,
|
||||
onStoredArtifactsChanged,
|
||||
}: ScanViewProps): ScanViewControllerResult {
|
||||
const repositories = useMemo(() => createRendererRepositories(), []);
|
||||
const artifactRepo = repositories?.artifacts;
|
||||
const runtimeRepo = repositories?.runtime;
|
||||
const reviewSamplesRepo = repositories?.reviewSamples;
|
||||
const learningRepo = repositories?.learning;
|
||||
const snapshotRepo = repositories?.snapshot;
|
||||
const automationRepo = repositories?.automation;
|
||||
const captureRepo = repositories?.capture;
|
||||
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [reviewQueueOpen, setReviewQueueOpen] = useState(false);
|
||||
const [reviewSamples, setReviewSamples] = useState<ReviewSampleRecord[]>([]);
|
||||
const [reviewSampleTotal, setReviewSampleTotal] = useState(0);
|
||||
const [reviewStatus, setReviewStatus] = useState("");
|
||||
const [autoScanRunning, setAutoScanRunning] = useState(false);
|
||||
const stopVisibleScanRef = useRef(false);
|
||||
const [autoScanStats, setAutoScanStats] = useState<AutoScanStats>(emptyAutoScanStats);
|
||||
const [scanSummary, setScanSummary] = useState<ScanSummary | null>(null);
|
||||
const [scanLimit, setScanLimit] = useState(16);
|
||||
const [scanLimitTouched, setScanLimitTouched] = useState(false);
|
||||
const [skipRows, setSkipRows] = useState(0);
|
||||
const [automationLog, setAutomationLog] = useState<string[]>([]);
|
||||
const [storedTotal, setStoredTotal] = useState<number | null>(null);
|
||||
const [devMode, setDevMode] = useState(() => localStorage.getItem("gaa-dev-mode") === "1");
|
||||
const [scannerLearningRules, setScannerLearningRules] = useState<ScannerLearningRules>({ textReplacements: {} });
|
||||
const [learningRulesLoaded, setLearningRulesLoaded] = useState(false);
|
||||
const runtimeInfo = useScanRuntimeInfo(runtimeRepo);
|
||||
|
||||
const parsedArtifact = useMemo(
|
||||
() => parseLearnedArtifactHelper(latestCapture, scannerLearningRules),
|
||||
[latestCapture, scannerLearningRules],
|
||||
);
|
||||
|
||||
const selectedSource = captureSources.find((source) => source.id === selectedSourceId);
|
||||
const genshinSource = captureSources.find((source) => source.isGenshinCandidate);
|
||||
const requiresAdminForAutoScan = requiresAdminForAutomation(runtimeInfo);
|
||||
const hasSourceSelected = selectedSourceId.length > 0;
|
||||
const canCaptureSource = bridgeReady && Boolean(captureRepo?.captureSource);
|
||||
const canReadReviewQueue = bridgeReady && Boolean(reviewSamplesRepo?.loadSamples);
|
||||
const canSaveReviewSample = bridgeReady && Boolean(reviewSamplesRepo?.saveSample);
|
||||
const canAutoScan = bridgeReady && Boolean(automationRepo?.clickScreen) && Boolean(automationRepo?.scrollScreen);
|
||||
const reviewAnalysis = useMemo(() => analyzeReviewSamples(reviewSamples), [reviewSamples]);
|
||||
const learningRuleCount = countScannerLearningRules(scannerLearningRules);
|
||||
const detectedInventoryCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||
const activeTargetCount = autoScanRunning
|
||||
? resolveScanTargetCount(scanLimit, detectedInventoryCount)
|
||||
: scanSummary?.targetCount ?? resolveScanTargetCount(scanLimit, detectedInventoryCount);
|
||||
const scanProgressBase = Math.max(autoScanStats.attempted, autoScanStats.parsed, autoScanStats.verified);
|
||||
const scanProgressPercent = Math.min(100, Math.round((scanProgressBase / Math.max(1, activeTargetCount)) * 100));
|
||||
const scannerModeLabel = autoScanRunning ? "Scan laeuft" : runtimeInfo?.isElevated ? "Admin bereit" : "Bereit";
|
||||
const sourceLabel = selectedSource?.name ?? "Keine Quelle";
|
||||
const gridLabel = latestCapture?.inventoryGrid ? `${latestCapture.inventoryGrid.cols} x ${latestCapture.inventoryGrid.rows}` : "warte auf Capture";
|
||||
const inventoryLabel = latestCapture?.inventoryCount?.current ? `${latestCapture.inventoryCount.current}/${latestCapture.inventoryCount.total || "?"}` : "nicht erkannt";
|
||||
|
||||
const appendAutomationLog = useCallback((line: string) => {
|
||||
setAutomationLog((previous) => [...previous.slice(-11), `${new Date().toLocaleTimeString()} ${line}`]);
|
||||
}, []);
|
||||
|
||||
const appendClickDiagnostics = useCallback((result: ClickResult, prefix = "input") => {
|
||||
const cursor = `${result.cursorX ?? "?"},${result.cursorY ?? "?"}`;
|
||||
const focus = result.focused ? `fg:${result.foregroundProcess || "Genshin"}` : `fg-miss:${result.foregroundProcess || "?"}`;
|
||||
const blocked = result.inputBlocked ? " input:blocked" : "";
|
||||
appendAutomationLog(`${prefix}: ${focus}${blocked} cursor ${cursor} moved:${result.moved ? "yes" : "no"} clicked:${result.clicked ? "yes" : "no"}`);
|
||||
}, [appendAutomationLog]);
|
||||
|
||||
const toggleDevMode = useCallback(() => {
|
||||
setDevMode((previous) => {
|
||||
const next = !previous;
|
||||
localStorage.setItem("gaa-dev-mode", next ? "1" : "0");
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const parseArtifact = useCallback(
|
||||
(capture: CaptureResult | null) => parseLearnedArtifactHelper(capture, scannerLearningRules),
|
||||
[scannerLearningRules],
|
||||
);
|
||||
|
||||
const {
|
||||
requestScanStop,
|
||||
saveReviewSample: handleSaveReviewSample,
|
||||
loadReviewQueue: loadReviewQueueAction,
|
||||
openReviewQueue: openReviewQueueModal,
|
||||
runAutoReviewScan,
|
||||
runVisibleGridScan,
|
||||
} = useScanViewActions({
|
||||
autoScanRunning,
|
||||
setAutoScanRunning,
|
||||
isScanning,
|
||||
stopVisibleScanRef,
|
||||
selectedSourceId,
|
||||
bridgeReady,
|
||||
automationRepo,
|
||||
runtimeInfo,
|
||||
runtimeRepo,
|
||||
scanLimit,
|
||||
skipRows,
|
||||
detectedInventoryCount,
|
||||
setScanSummary,
|
||||
setAutoScanStats,
|
||||
setReviewStatus,
|
||||
appendAutomationLog,
|
||||
appendClickDiagnostics,
|
||||
parseArtifact,
|
||||
artifactRepo,
|
||||
reviewSamplesRepo,
|
||||
learningRepo,
|
||||
onStoredArtifactsChanged,
|
||||
setReviewSampleTotal,
|
||||
setReviewSamples,
|
||||
setLearningRulesLoaded,
|
||||
setScannerLearningRules,
|
||||
setStoredTotal,
|
||||
scannerLearningRules,
|
||||
captureSelectedSource,
|
||||
latestCapture,
|
||||
parsedArtifact,
|
||||
canCaptureSource,
|
||||
setReviewQueueOpen,
|
||||
});
|
||||
|
||||
useScanViewStateSync({
|
||||
artifactRepo,
|
||||
latestCapture,
|
||||
scanLimitTouched,
|
||||
setScanLimit,
|
||||
setStoredTotal,
|
||||
});
|
||||
|
||||
useScanSnapshotPublisher({
|
||||
autoScanRunning,
|
||||
reviewStatus,
|
||||
captureStatus,
|
||||
selectedSourceName: selectedSource?.name ?? null,
|
||||
autoScanStats,
|
||||
scanSummary,
|
||||
snapshot,
|
||||
latestInventoryGrid: latestCapture?.inventoryGrid ?? null,
|
||||
automationLog,
|
||||
runtimeInfo,
|
||||
storedTotal,
|
||||
learningRuleCount,
|
||||
snapshotRepo,
|
||||
});
|
||||
|
||||
return {
|
||||
detailsOpen,
|
||||
diagnosticsOpen,
|
||||
settingsOpen,
|
||||
reviewQueueOpen,
|
||||
reviewSamples,
|
||||
reviewSampleTotal,
|
||||
reviewStatus,
|
||||
autoScanRunning,
|
||||
autoScanStats,
|
||||
scanSummary,
|
||||
scanLimit,
|
||||
scanLimitTouched,
|
||||
skipRows,
|
||||
automationLog,
|
||||
storedTotal,
|
||||
devMode,
|
||||
scannerLearningRules,
|
||||
learningRulesLoaded,
|
||||
runtimeInfo,
|
||||
parsedArtifact,
|
||||
reviewAnalysis,
|
||||
learningRuleCount,
|
||||
detectedInventoryCount,
|
||||
scanProgressPercent,
|
||||
activeTargetCount,
|
||||
requiresAdminForAutoScan,
|
||||
hasSourceSelected,
|
||||
canCaptureSource,
|
||||
canReadReviewQueue,
|
||||
canSaveReviewSample,
|
||||
canAutoScan,
|
||||
scannerModeLabel,
|
||||
sourceLabel,
|
||||
gridLabel,
|
||||
inventoryLabel,
|
||||
selectedSource,
|
||||
genshinSource,
|
||||
setDetailsOpen,
|
||||
setDiagnosticsOpen,
|
||||
setSettingsOpen,
|
||||
setReviewQueueOpen,
|
||||
setScanSummary,
|
||||
setScanLimit,
|
||||
setScanLimitTouched,
|
||||
setSkipRows,
|
||||
toggleDevMode,
|
||||
requestScanStop,
|
||||
saveReviewSample: handleSaveReviewSample,
|
||||
loadReviewQueue: loadReviewQueueAction,
|
||||
openReviewQueue: openReviewQueueModal,
|
||||
runAutoReviewScan,
|
||||
runVisibleGridScan,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { CaptureResult } from "../../../types/global";
|
||||
import type { ArtifactRepositoryPort } from "../../../infrastructure/repositories/rendererBridgeRepositoryTypes";
|
||||
import { clampScanLimit } from "../../../lib/scannerSession";
|
||||
|
||||
interface ScanViewStateSyncInput {
|
||||
artifactRepo?: ArtifactRepositoryPort;
|
||||
latestCapture: CaptureResult | null;
|
||||
scanLimitTouched: boolean;
|
||||
setScanLimit: Dispatch<SetStateAction<number>>;
|
||||
setStoredTotal: Dispatch<SetStateAction<number | null>>;
|
||||
}
|
||||
|
||||
export function useScanViewStateSync({
|
||||
artifactRepo,
|
||||
latestCapture,
|
||||
scanLimitTouched,
|
||||
setScanLimit,
|
||||
setStoredTotal,
|
||||
}: ScanViewStateSyncInput) {
|
||||
useEffect(() => {
|
||||
const detectedCount = latestCapture?.inventoryCount?.current ?? 0;
|
||||
if (!scanLimitTouched && detectedCount > 0) {
|
||||
setScanLimit(clampScanLimit(detectedCount));
|
||||
}
|
||||
}, [latestCapture?.inventoryCount?.current, scanLimitTouched, setScanLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
artifactRepo?.loadAll().then((result) => {
|
||||
if (result?.ok) setStoredTotal(result.total);
|
||||
}).catch(() => undefined);
|
||||
}, [artifactRepo, setStoredTotal]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AppSnapshot } from "../../types/domain";
|
||||
import type { BooleanResult, CaptureOptions, CaptureResult, CaptureSourceInfo, RuntimeInfo, ReviewSampleRecord } from "../../types/global";
|
||||
import type { AutoScanStats, ScanSummary } from "../../lib/scannerSession";
|
||||
import type { ParsedArtifactCandidate } from "../../lib/artifactOcrParser";
|
||||
import type { ScannerLearningRules } from "../../lib/scannerLearning";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { analyzeReviewSamples } from "../../lib/reviewSampleAnalysis";
|
||||
|
||||
export interface ScanViewProps {
|
||||
snapshot: AppSnapshot;
|
||||
isScanning: boolean;
|
||||
captureSources: CaptureSourceInfo[];
|
||||
selectedSourceId: string;
|
||||
setSelectedSourceId: (value: string) => void;
|
||||
latestCapture: CaptureResult | null;
|
||||
captureStatus: string;
|
||||
refreshCaptureSources: () => Promise<void>;
|
||||
captureSelectedSource: (delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult | null>;
|
||||
bridgeReady: boolean;
|
||||
onStoredArtifactsChanged?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ScanViewControllerResult {
|
||||
detailsOpen: boolean;
|
||||
diagnosticsOpen: boolean;
|
||||
settingsOpen: boolean;
|
||||
reviewQueueOpen: boolean;
|
||||
reviewSamples: ReviewSampleRecord[];
|
||||
reviewSampleTotal: number;
|
||||
reviewStatus: string;
|
||||
autoScanRunning: boolean;
|
||||
autoScanStats: AutoScanStats;
|
||||
scanSummary: ScanSummary | null;
|
||||
scanLimit: number;
|
||||
scanLimitTouched: boolean;
|
||||
skipRows: number;
|
||||
automationLog: string[];
|
||||
storedTotal: number | null;
|
||||
devMode: boolean;
|
||||
scannerLearningRules: ScannerLearningRules;
|
||||
learningRulesLoaded: boolean;
|
||||
runtimeInfo: RuntimeInfo | null;
|
||||
parsedArtifact: ParsedArtifactCandidate | null;
|
||||
reviewAnalysis: ReturnType<typeof analyzeReviewSamples>;
|
||||
learningRuleCount: number;
|
||||
detectedInventoryCount: number;
|
||||
scanProgressPercent: number;
|
||||
activeTargetCount: number;
|
||||
requiresAdminForAutoScan: boolean;
|
||||
hasSourceSelected: boolean;
|
||||
canCaptureSource: boolean;
|
||||
canReadReviewQueue: boolean;
|
||||
canSaveReviewSample: boolean;
|
||||
canAutoScan: boolean;
|
||||
scannerModeLabel: string;
|
||||
sourceLabel: string;
|
||||
gridLabel: string;
|
||||
inventoryLabel: string;
|
||||
selectedSource: CaptureSourceInfo | undefined;
|
||||
genshinSource: CaptureSourceInfo | undefined;
|
||||
|
||||
setDetailsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setDiagnosticsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setSettingsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setReviewQueueOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setScanSummary: Dispatch<SetStateAction<ScanSummary | null>>;
|
||||
setScanLimit: Dispatch<SetStateAction<number>>;
|
||||
setScanLimitTouched: Dispatch<SetStateAction<boolean>>;
|
||||
setSkipRows: Dispatch<SetStateAction<number>>;
|
||||
|
||||
toggleDevMode: () => void;
|
||||
requestScanStop: (reason?: string) => void;
|
||||
saveReviewSample: (
|
||||
capture?: CaptureResult | null,
|
||||
parsed?: ParsedArtifactCandidate | null,
|
||||
reason?: string,
|
||||
) => Promise<BooleanResult | null>;
|
||||
loadReviewQueue: () => Promise<void>;
|
||||
openReviewQueue: () => Promise<void>;
|
||||
runAutoReviewScan: () => Promise<void>;
|
||||
runVisibleGridScan: () => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ArtifactRowProps, TriageViewProps } from "./types";
|
||||
import { useArtifactRowModel } from "./hooks/useArtifactRowModel";
|
||||
import { useTriageViewModel } from "./hooks/useTriageViewModel";
|
||||
|
||||
function ArtifactRow({
|
||||
artifact,
|
||||
recommendation,
|
||||
characters,
|
||||
}: ArtifactRowProps) {
|
||||
const { metaLabel, metaClassName, metaIcon, score, characterNames, artifactSummary, substatRows } = useArtifactRowModel({
|
||||
recommendation,
|
||||
artifact,
|
||||
characters,
|
||||
characterIds: recommendation?.bestCharacters,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="artifact-row">
|
||||
<div>
|
||||
<strong>{artifact.setName}</strong>
|
||||
<span>{artifactSummary}</span>
|
||||
</div>
|
||||
<div className="substats">
|
||||
{substatRows.map((substat) => (
|
||||
<span key={`${artifact.id}-${substat}`}>{substat}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={metaClassName}>{metaIcon}{metaLabel}</div>
|
||||
<div className="reason">
|
||||
<strong>{score}</strong>
|
||||
<span>{characterNames}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriageView({ snapshot }: TriageViewProps) {
|
||||
const { panelEyebrow, panelTitle, artifactCountLabel, recommendationByArtifact } = useTriageViewModel({ snapshot });
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{panelEyebrow}</p>
|
||||
<h2>{panelTitle}</h2>
|
||||
</div>
|
||||
<span className="muted">{artifactCountLabel}</span>
|
||||
</div>
|
||||
<div className="artifact-table">
|
||||
{snapshot.artifacts.map((artifact) => (
|
||||
<ArtifactRow
|
||||
key={artifact.id}
|
||||
artifact={artifact}
|
||||
recommendation={recommendationByArtifact.get(artifact.id)}
|
||||
characters={snapshot.characters}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { verdictMeta } from "../../common/verdictMeta";
|
||||
import type { Artifact, Character, Recommendation } from "../../../types/domain";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ArtifactRowModel {
|
||||
metaLabel: string;
|
||||
metaClassName: string;
|
||||
metaIcon: ReactNode;
|
||||
score: number;
|
||||
characterNames: string;
|
||||
artifactSummary: string;
|
||||
substatRows: string[];
|
||||
}
|
||||
|
||||
interface UseArtifactRowModelInput {
|
||||
artifact: Artifact;
|
||||
recommendation?: Recommendation;
|
||||
characters: Character[];
|
||||
characterIds?: string[];
|
||||
}
|
||||
|
||||
export function useArtifactRowModel({
|
||||
recommendation,
|
||||
characters,
|
||||
artifact,
|
||||
characterIds,
|
||||
}: UseArtifactRowModelInput): ArtifactRowModel {
|
||||
const meta = verdictMeta[recommendation?.verdict ?? "needs_review"];
|
||||
const characterNames = !characterIds || characterIds.length === 0
|
||||
? "Review first"
|
||||
: characterIds.map((id) => characters.find((character) => character.id === id)?.name ?? id).join(", ");
|
||||
const score = recommendation ? Math.round(recommendation.score) : 0;
|
||||
|
||||
return {
|
||||
metaLabel: meta.label,
|
||||
metaClassName: meta.className,
|
||||
metaIcon: meta.icon,
|
||||
score,
|
||||
characterNames,
|
||||
artifactSummary: `${artifact.slot} - +${artifact.level} - ${artifact.mainStat}${artifact.equipped ? ` - ${artifact.equipped}` : ""}`,
|
||||
substatRows: artifact.substats.map((substat) => `${substat.key} ${substat.value}${substat.unit === "%" ? "%" : ""}`),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AppSnapshot, Recommendation } from "../../../types/domain";
|
||||
import type { Artifact, Character } from "../../../types/domain";
|
||||
|
||||
export interface TriageViewModel {
|
||||
artifactCount: number;
|
||||
panelEyebrow: string;
|
||||
panelTitle: string;
|
||||
artifactCountLabel: string;
|
||||
recommendationByArtifact: Map<string, Recommendation>;
|
||||
}
|
||||
|
||||
interface UseTriageViewModelInput {
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
|
||||
export function useTriageViewModel({ snapshot }: UseTriageViewModelInput): TriageViewModel {
|
||||
const recommendationByArtifact = new Map(snapshot.recommendations.map((entry) => [entry.artifactId, entry]));
|
||||
const artifactCount = snapshot.artifacts.length;
|
||||
|
||||
return {
|
||||
artifactCount,
|
||||
panelEyebrow: "Artifact triage",
|
||||
panelTitle: "No-brainer decisions",
|
||||
artifactCountLabel: `${artifactCount} scanned pieces`,
|
||||
recommendationByArtifact,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AppSnapshot, Artifact, Character, Recommendation } from "../../types/domain";
|
||||
|
||||
export interface TriageViewProps {
|
||||
snapshot: AppSnapshot;
|
||||
}
|
||||
|
||||
export interface ArtifactRowProps {
|
||||
artifact: Artifact;
|
||||
recommendation?: Recommendation;
|
||||
characters: Character[];
|
||||
}
|
||||
Reference in New Issue
Block a user