Files
genshin-assistant/src/features/builds/BuildsView.tsx
T

232 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AlertTriangle,
Archive,
Check,
Fingerprint,
LockKeyhole,
Orbit,
RefreshCw,
ShieldCheck,
Sparkles,
Users,
} from "lucide-react";
import { useBuildsViewModel } from "./hooks/useBuildsViewModel";
import type { BuildsViewProps } from "./types";
import { useI18n } from "../../i18n";
import { getBuildCopy } from "./buildsCopy";
export function BuildsView({ snapshot, onOpenInventory }: BuildsViewProps) {
const { locale } = useI18n();
const copy = getBuildCopy(locale);
const {
artifactCount,
hasScanData,
foundationLabel,
foundationDetail,
contractLabel,
contractDetail,
profileLabel,
profileDetail,
nativeResultCount,
isLoadingBuildFit,
buildFitLoadError,
ranking,
aggregateContextFields,
setAggregateContextDraft,
refreshBuildFit,
} = useBuildsViewModel({ snapshot, locale });
const suggestions = ranking.suggestions;
const hasSuggestions = suggestions.length > 0;
return (
<section className="build-preview-page" aria-labelledby="build-preview-title" aria-busy={isLoadingBuildFit}>
<div className="build-preview-hero">
<div className="build-preview-copy">
<span className="build-preview-kicker"><Sparkles size={14} aria-hidden="true" /> {copy.heroKicker}</span>
<h2 id="build-preview-title">{hasSuggestions ? copy.heroReadyTitle : copy.heroEmptyTitle}</h2>
<p>
{hasSuggestions
? copy.heroReadyDescription
: copy.heroEmptyDescription}
</p>
<div className="build-preview-actions">
<button className="ghost-button build-inventory-action" type="button" onClick={onOpenInventory}>
<Archive size={16} aria-hidden="true" />
{artifactCount > 0 ? copy.openArtifacts(artifactCount) : copy.prepareArtifacts}
</button>
<button
className="ghost-button build-refresh-action"
type="button"
onClick={() => void refreshBuildFit()}
disabled={isLoadingBuildFit}
>
<RefreshCw size={16} aria-hidden="true" className={isLoadingBuildFit ? "spin" : ""} />
{isLoadingBuildFit ? copy.loadingEvidence : copy.refreshEvidence}
</button>
</div>
</div>
<div className={`build-preview-lock ${hasSuggestions ? "ready" : ""}`} aria-label={hasSuggestions ? copy.suggestionsAvailable : copy.suggestionsWaiting}>
<div className="build-orbit" aria-hidden="true"><Orbit size={64} /></div>
<div className="build-lock-core">{hasSuggestions ? <ShieldCheck size={27} aria-hidden="true" /> : <LockKeyhole size={27} aria-hidden="true" />}</div>
<strong>{hasSuggestions ? copy.suggestionCount(suggestions.length) : copy.noSuggestionTitle}</strong>
<span>{hasSuggestions ? copy.nativeChecked(nativeResultCount) : copy.blockedDetail}</span>
</div>
</div>
<div className="build-readiness-grid" aria-label={copy.readinessLabel}>
<article className={hasScanData ? "complete" : "pending"}>
<span className="build-stage-icon">{hasScanData ? <Check size={18} /> : <Archive size={18} />}</span>
<p>{copy.stageArtifacts}</p>
<strong>{foundationLabel}</strong>
<small>{foundationDetail}</small>
</article>
<article className="complete">
<span className="build-stage-icon"><Fingerprint size={18} /></span>
<p>{copy.stageContract}</p>
<strong>{contractLabel}</strong>
<small>{contractDetail}</small>
</article>
<article className={hasSuggestions ? "complete" : "active"}>
<span className="build-stage-icon"><Users size={18} /></span>
<p>{copy.stageProfiles}</p>
<strong>{profileLabel}</strong>
<small>{profileDetail}</small>
</article>
</div>
{isLoadingBuildFit ? (
<section className="build-empty-state" role="status" aria-live="polite">
<RefreshCw size={19} className="spin" aria-hidden="true" />
<div><strong>{copy.loadingTitle}</strong><p>{copy.loadingDescription}</p></div>
</section>
) : buildFitLoadError ? (
<section className="build-empty-state warning" role="status">
<AlertTriangle size={19} aria-hidden="true" />
<div><strong>{copy.lockedTitle}</strong><p>{buildFitLoadError}</p></div>
</section>
) : hasSuggestions ? (
<section className="build-suggestion-list" aria-label={copy.suggestionList}>
{suggestions.map((suggestion) => (
<article key={suggestion.id} className="build-suggestion-card">
<header>
<div>
<span>{suggestion.characterName}</span>
<h3>{suggestion.targetLabel}</h3>
</div>
<strong title={copy.coverageTitle}>{suggestion.evidenceCoverage}% {copy.coverageLabel}</strong>
</header>
<p className="build-suggestion-description">{suggestion.targetDescription}</p>
<div className="build-suggestion-meta">
<span><ShieldCheck size={14} aria-hidden="true" />{suggestion.matchedSetPlanLabel}</span>
<span><Fingerprint size={14} aria-hidden="true" />{copy.safeSlots(suggestion.artifacts.length)}</span>
</div>
<ul className="build-suggestion-artifacts" aria-label={copy.selectedArtifacts(suggestion.characterName)}>
{suggestion.artifacts.map((artifact) => (
<li key={artifact.artifactId}>
<span>{slotLabel(artifact.slot, copy)}</span>
<strong>{artifact.name}</strong>
<small>{artifact.setName} · {artifact.mainStat} {artifact.mainValue}</small>
</li>
))}
</ul>
<ul className="build-suggestion-reasons">
{suggestion.reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
{suggestion.warnings.length > 0 ? (
<div className="build-suggestion-warnings">
<AlertTriangle size={14} aria-hidden="true" />
<span>{suggestion.warnings.join(" ")}</span>
</div>
) : null}
<p className="build-suggestion-source">{copy.sourceNote} {suggestion.sourceLabels.join(" · ")}. {copy.sourceDisclaimer}</p>
</article>
))}
</section>
) : (
<section className="build-empty-state" aria-label={copy.blockedProfiles}>
<LockKeyhole size={19} aria-hidden="true" />
<div>
<strong>{copy.noSuggestionTitle}</strong>
<p>{copy.noSuggestionDescription}</p>
<ul className="build-deferred-list">
{ranking.deferredProfiles.map((profile) => (
<li key={profile.profileId}><strong>{profile.characterName}</strong> · {profile.targetLabel}: {deferredLabel(profile.reason, copy)} {profile.detail}</li>
))}
</ul>
</div>
</section>
)}
{aggregateContextFields.length > 0 ? (
<section className="build-context-editor" aria-label={copy.aggregateContext}>
<div className="build-context-editor-head">
<Fingerprint size={17} aria-hidden="true" />
<div>
<strong>{copy.aggregateContext}</strong>
<p>{copy.aggregateContextDescription}</p>
</div>
</div>
{aggregateContextFields.map((field) => (
<div key={`${field.profileId}:${field.targetKey}`} className="build-context-field">
<div>
<span>{field.characterName} · {field.targetLabel}</span>
<strong>{copy.outsideArtifacts(field.stat, targetRangeLabel(field, copy))}</strong>
<small>{field.reason}</small>
</div>
<label className="build-context-number">
<span>{copy.nonArtifactValue}</span>
<input
type="number"
min="0"
step="0.1"
inputMode="decimal"
value={field.value}
onChange={(event) => setAggregateContextDraft(field.profileId, field.targetKey, { value: event.target.value })}
aria-describedby={`${field.profileId}-${field.targetKey}-help`}
/>
<em>{field.unit === "percent" ? "%" : "flat"}</em>
</label>
<label className="build-context-confirm">
<input
type="checkbox"
checked={field.confirmed}
onChange={(event) => setAggregateContextDraft(field.profileId, field.targetKey, { confirmed: event.target.checked })}
/>
<span>{copy.contextConfirm}</span>
</label>
<small id={`${field.profileId}-${field.targetKey}-help`} className={field.error ? "error" : ""}>
{field.error || copy.contextPending}
</small>
</div>
))}
</section>
) : null}
<div className="build-contract-note">
<LockKeyhole size={18} aria-hidden="true" />
<div>
<strong>{copy.contractBoundary}</strong>
<p>{copy.contractDescription}</p>
</div>
</div>
</section>
);
}
function slotLabel(slot: string, copy: ReturnType<typeof getBuildCopy>) {
return copy.slots[slot] ?? slot;
}
function deferredLabel(reason: string, copy: ReturnType<typeof getBuildCopy>) {
return copy.deferredReasons[reason] ?? copy.deferredFallback;
}
function targetRangeLabel(field: { minimum?: number; maximum?: number; unit: "flat" | "percent" }, copy: ReturnType<typeof getBuildCopy>) {
const unit = field.unit === "percent" ? "%" : "";
if (field.minimum !== undefined && field.maximum !== undefined) return `(${field.minimum}${unit}${field.maximum}${unit})`;
if (field.minimum !== undefined) return copy.rangeAtLeast(field.minimum, unit);
if (field.maximum !== undefined) return copy.rangeAtMost(field.maximum, unit);
return "";
}