feat(DRW-001) initial commit!
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dota Random</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Same-origin API calls from the SPA get forwarded to the backend
|
||||
# container by its compose service name -- the frontend never needs
|
||||
# to know a real hostname/port for the API, and there's no CORS to deal with.
|
||||
location /api/ {
|
||||
proxy_pass http://dota-random-web:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+1942
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"oxlint": "^1.81.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="dice-fire" x1="10" y1="40" x2="38" y2="8" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6b0f0f"/>
|
||||
<stop offset="0.5" stop-color="#e2321a"/>
|
||||
<stop offset="0.8" stop-color="#ff8a1e"/>
|
||||
<stop offset="1" stop-color="#ffcf4d"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="dice-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24 24) rotate(90) scale(23)">
|
||||
<stop offset="0.6" stop-color="#ff5a1f" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#ff5a1f" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="24" cy="24" r="23" fill="url(#dice-glow)"/>
|
||||
|
||||
<g transform="rotate(-8 24 24)">
|
||||
<rect x="8" y="8" width="32" height="32" rx="8" fill="url(#dice-fire)" stroke="#4a0808" stroke-width="1.5"/>
|
||||
|
||||
<circle cx="16.5" cy="16.5" r="2.9" fill="#fff3d6"/>
|
||||
<circle cx="31.5" cy="16.5" r="2.9" fill="#fff3d6"/>
|
||||
<circle cx="24" cy="24" r="2.9" fill="#fff3d6"/>
|
||||
<circle cx="16.5" cy="31.5" r="2.9" fill="#fff3d6"/>
|
||||
<circle cx="31.5" cy="31.5" r="2.9" fill="#fff3d6"/>
|
||||
</g>
|
||||
|
||||
<path d="M37 6c1.8 2 1.6 3.7.2 5.1-1 1-.7 2.6.8 2.6 2.6 0 4.7-2.1 4.7-4.7 0-2.9-2.7-4.2-5.7-3z" fill="#ffb13c"/>
|
||||
<path d="M41.5 13.2c1 1 .9 2-.1 2.9-.6.6-.4 1.5.5 1.5 1.5 0 2.7-1.2 2.7-2.7 0-1.6-1.6-2.4-3.1-1.7z" fill="#ff7a1a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
generateBuild,
|
||||
getTags,
|
||||
rerollEnchantmentStage,
|
||||
rerollHero,
|
||||
rerollItemSlot,
|
||||
rerollNeutralStage,
|
||||
setEnchantmentStageManually,
|
||||
setItemSlotManually,
|
||||
setNeutralStageManually,
|
||||
} from "./api";
|
||||
import { BuildResult } from "./components/BuildResult";
|
||||
import { ConfigPanel } from "./components/ConfigPanel";
|
||||
import { Spinner } from "./components/ui";
|
||||
import type { Build, BuildConfig, Tag } from "./types";
|
||||
|
||||
const DEFAULT_CONFIG: BuildConfig = {
|
||||
attackTypes: [],
|
||||
attributes: [],
|
||||
itemTags: [],
|
||||
disableNeutralArtifactRandomness: false,
|
||||
disableEnchantmentRandomness: false,
|
||||
disableAllNeutralRolls: false,
|
||||
disabledItemSlots: [],
|
||||
disableAghanimScepterRandomness: false,
|
||||
disableAghanimShardRandomness: false,
|
||||
neutralTierRerollEnabled: true,
|
||||
aghanimScepterTiming: "anytime",
|
||||
aghanimShardTiming: "anytime",
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [config, setConfig] = useState<BuildConfig>(DEFAULT_CONFIG);
|
||||
const [build, setBuild] = useState<Build | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getTags().then(setTags);
|
||||
}, []);
|
||||
|
||||
function updateConfig(patch: Partial<BuildConfig>) {
|
||||
setConfig((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
setGenerating(true);
|
||||
try {
|
||||
setBuild(await generateBuild(config));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function withBusy(fn: () => Promise<Build>) {
|
||||
setBusy(true);
|
||||
try {
|
||||
setBuild(await fn());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-6xl flex-col gap-6 p-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<img src="/favicon.svg" alt="" className="h-10 w-10" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-100">Dota Random</h1>
|
||||
<p className="text-sm text-neutral-500">Randomized hero + item builds, with full control over what gets rolled.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[320px_1fr]">
|
||||
<ConfigPanel config={config} onChange={updateConfig} tags={tags} onGenerate={handleGenerate} generating={generating} />
|
||||
|
||||
<div>
|
||||
{!build && !generating && (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-neutral-800 text-neutral-600">
|
||||
Configure your scope, then generate a build.
|
||||
</div>
|
||||
)}
|
||||
{generating && !build && (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-neutral-800">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{build && (
|
||||
<BuildResult
|
||||
build={build}
|
||||
busy={busy}
|
||||
onRerollHero={() => withBusy(() => rerollHero(build.id))}
|
||||
onRerollItemSlot={(slot) => withBusy(() => rerollItemSlot(build.id, slot))}
|
||||
onRerollNeutral={(tier) => withBusy(() => rerollNeutralStage(build.id, tier))}
|
||||
onRerollEnchantment={(tier) => withBusy(() => rerollEnchantmentStage(build.id, tier))}
|
||||
onManualPickItemSlot={(slot, itemId) => withBusy(() => setItemSlotManually(build.id, slot, itemId))}
|
||||
onManualPickNeutral={(tier, itemId) => withBusy(() => setNeutralStageManually(build.id, tier, itemId))}
|
||||
onManualPickEnchantment={(tier, enchantmentId) =>
|
||||
withBusy(() => setEnchantmentStageManually(build.id, tier, enchantmentId))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Build, BuildConfig, Enchantment, Item, PrimaryAttribute, Tag } from "./types";
|
||||
|
||||
/**
|
||||
* Thin fetch wrappers around the real backend. In production nginx proxies
|
||||
* `/api/*` to the backend container (see frontend/nginx.conf); in dev, Vite's
|
||||
* server.proxy does the same thing against a locally-running backend (see
|
||||
* vite.config.ts) -- either way these calls are same-origin, so there's no
|
||||
* CORS to deal with here.
|
||||
*/
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error ?? `Request to ${path} failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function tagsQueryParam(tags: string[]): string {
|
||||
return tags.length ? `?tags=${encodeURIComponent(tags.join(","))}` : "";
|
||||
}
|
||||
|
||||
// GET /api/tags
|
||||
export function getTags(): Promise<Tag[]> {
|
||||
return apiFetch<Tag[]>("/tags");
|
||||
}
|
||||
|
||||
// POST /api/builds
|
||||
export function generateBuild(config: BuildConfig): Promise<Build> {
|
||||
return apiFetch<Build>("/builds", { method: "POST", body: JSON.stringify(config) });
|
||||
}
|
||||
|
||||
// POST /api/builds/{buildId}/reroll/hero
|
||||
export function rerollHero(buildId: string): Promise<Build> {
|
||||
return apiFetch<Build>(`/builds/${buildId}/reroll/hero`, { method: "POST" });
|
||||
}
|
||||
|
||||
// POST /api/builds/{buildId}/reroll/item-slot/{slot}
|
||||
export function rerollItemSlot(buildId: string, slot: number): Promise<Build> {
|
||||
return apiFetch<Build>(`/builds/${buildId}/reroll/item-slot/${slot}`, { method: "POST" });
|
||||
}
|
||||
|
||||
// POST /api/builds/{buildId}/reroll/neutral/{tier}
|
||||
export function rerollNeutralStage(buildId: string, tier: number): Promise<Build> {
|
||||
return apiFetch<Build>(`/builds/${buildId}/reroll/neutral/${tier}`, { method: "POST" });
|
||||
}
|
||||
|
||||
// POST /api/builds/{buildId}/reroll/enchantment/{tier}
|
||||
export function rerollEnchantmentStage(buildId: string, tier: number): Promise<Build> {
|
||||
return apiFetch<Build>(`/builds/${buildId}/reroll/enchantment/${tier}`, { method: "POST" });
|
||||
}
|
||||
|
||||
// GET /api/items?tags=&bootsOnly=
|
||||
export function getManualItemOptions(tags: string[], bootsOnly = false): Promise<Item[]> {
|
||||
const params = bootsOnly ? "?bootsOnly=true" : tagsQueryParam(tags);
|
||||
return apiFetch<Item[]>(`/items${params}`);
|
||||
}
|
||||
|
||||
// GET /api/neutral-items?tier=
|
||||
export function getManualNeutralOptions(tier: number): Promise<Item[]> {
|
||||
return apiFetch<Item[]>(`/neutral-items?tier=${tier}`);
|
||||
}
|
||||
|
||||
// GET /api/enchantments?tier=&primaryAttribute=
|
||||
export function getManualEnchantmentOptions(tier: number, attribute: PrimaryAttribute): Promise<Enchantment[]> {
|
||||
return apiFetch<Enchantment[]>(`/enchantments?tier=${tier}&primaryAttribute=${attribute}`);
|
||||
}
|
||||
|
||||
// PATCH /api/builds/{buildId}/item-slot/{slot}
|
||||
export function setItemSlotManually(buildId: string, slot: number, itemId: number): Promise<Build> {
|
||||
return apiFetch<Build>(`/builds/${buildId}/item-slot/${slot}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ itemId }),
|
||||
});
|
||||
}
|
||||
|
||||
// PATCH /api/builds/{buildId}/neutral/{tier}
|
||||
export function setNeutralStageManually(buildId: string, tier: number, itemId: number): Promise<Build> {
|
||||
return apiFetch<Build>(`/builds/${buildId}/neutral/${tier}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ itemId }),
|
||||
});
|
||||
}
|
||||
|
||||
// PATCH /api/builds/{buildId}/enchantment/{tier}
|
||||
export function setEnchantmentStageManually(buildId: string, tier: number, enchantmentId: number): Promise<Build> {
|
||||
return apiFetch<Build>(`/builds/${buildId}/enchantment/${tier}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ enchantmentId }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useState } from "react";
|
||||
import { getManualEnchantmentOptions, getManualItemOptions, getManualNeutralOptions } from "../api";
|
||||
import type { Build, Item } from "../types";
|
||||
import { ManualPicker } from "./ManualPicker";
|
||||
import { Button } from "./ui";
|
||||
|
||||
type PickerTarget =
|
||||
| { kind: "item-slot"; slot: number }
|
||||
| { kind: "neutral"; tier: number }
|
||||
| { kind: "enchantment"; tier: number };
|
||||
|
||||
export function BuildResult({
|
||||
build,
|
||||
busy,
|
||||
onRerollHero,
|
||||
onRerollItemSlot,
|
||||
onRerollNeutral,
|
||||
onRerollEnchantment,
|
||||
onManualPickItemSlot,
|
||||
onManualPickNeutral,
|
||||
onManualPickEnchantment,
|
||||
}: {
|
||||
build: Build;
|
||||
busy: boolean;
|
||||
onRerollHero: () => void;
|
||||
onRerollItemSlot: (slot: number) => void;
|
||||
onRerollNeutral: (tier: number) => void;
|
||||
onRerollEnchantment: (tier: number) => void;
|
||||
onManualPickItemSlot: (slot: number, itemId: number) => void;
|
||||
onManualPickNeutral: (tier: number, itemId: number) => void;
|
||||
onManualPickEnchantment: (tier: number, enchantmentId: number) => void;
|
||||
}) {
|
||||
const [picker, setPicker] = useState<PickerTarget | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={build.hero.imageUrl}
|
||||
alt={build.hero.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-12 w-20 rounded object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-base font-semibold text-neutral-100">{build.hero.name}</p>
|
||||
<p className="text-xs capitalize text-neutral-500">
|
||||
{build.hero.primaryAttribute} · {build.hero.attackType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button variant="ghost" small onClick={onRerollHero} disabled={busy}>
|
||||
Reroll hero
|
||||
</Button>
|
||||
<p className="text-[10px] text-neutral-600">Also re-rolls neutral items and enchantments.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">Item build</h3>
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||
{build.itemSlots.map((slot) => (
|
||||
<div key={slot.slot} className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/40 p-2">
|
||||
<span className="text-[10px] text-neutral-500">Slot {slot.slot}{slot.slot === 1 ? " (Boots)" : ""}</span>
|
||||
{slot.item ? (
|
||||
<img
|
||||
src={slot.item.imageUrl}
|
||||
alt={slot.item.name}
|
||||
title={slot.item.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-10 w-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-16 items-center justify-center rounded bg-neutral-800 text-[10px] text-neutral-600">empty</div>
|
||||
)}
|
||||
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{slot.item?.name ?? "manual"}</span>
|
||||
{slot.isManual ? (
|
||||
<Button small variant="ghost" onClick={() => setPicker({ kind: "item-slot", slot: slot.slot })} disabled={busy}>
|
||||
Pick
|
||||
</Button>
|
||||
) : (
|
||||
<Button small variant="ghost" onClick={() => onRerollItemSlot(slot.slot)} disabled={busy}>
|
||||
Reroll
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AghanimRow build={build} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">Neutral items by stage</h3>
|
||||
<p className="mb-2 text-xs text-neutral-600">
|
||||
Each tier suggests up to 2 candidates -- click whichever one actually dropped in your game, or browse all.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
{build.neutralItems.map((stage) => (
|
||||
<NeutralStageCard
|
||||
key={stage.tier}
|
||||
tier={stage.tier}
|
||||
resolved={stage.resolved}
|
||||
attempts={stage.attempts}
|
||||
requiresManualPick={stage.requiresManualPick}
|
||||
tierGateWon={stage.tierGateWon}
|
||||
busy={busy}
|
||||
onReroll={() => onRerollNeutral(stage.tier)}
|
||||
onQuickPick={(itemId) => onManualPickNeutral(stage.tier, itemId)}
|
||||
onBrowseAll={() => setPicker({ kind: "neutral", tier: stage.tier })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">Enchantments by stage</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
{build.enchantments.map((stage) => (
|
||||
<EnchantmentStageCard
|
||||
key={stage.tier}
|
||||
tier={stage.tier}
|
||||
resolvedName={stage.resolved?.name}
|
||||
resolvedImage={stage.resolved?.imageUrl}
|
||||
requiresManualPick={stage.requiresManualPick}
|
||||
busy={busy}
|
||||
onReroll={() => onRerollEnchantment(stage.tier)}
|
||||
onManualPick={() => setPicker({ kind: "enchantment", tier: stage.tier })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-neutral-600">
|
||||
Guaranteed once rolling is on -- scoped to {build.hero.name}'s primary attribute ({build.hero.primaryAttribute}).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{picker?.kind === "item-slot" && (
|
||||
<ManualPicker
|
||||
title={`Pick an item for slot ${picker.slot}${picker.slot === 1 ? " (Boots)" : ""}`}
|
||||
loadOptions={() => getManualItemOptions(build.config.itemTags, picker.slot === 1)}
|
||||
onPick={(id) => {
|
||||
onManualPickItemSlot(picker.slot, id);
|
||||
setPicker(null);
|
||||
}}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
{picker?.kind === "neutral" && (
|
||||
<ManualPicker
|
||||
title={`Pick a tier ${picker.tier} neutral item`}
|
||||
loadOptions={() => getManualNeutralOptions(picker.tier)}
|
||||
onPick={(id) => {
|
||||
onManualPickNeutral(picker.tier, id);
|
||||
setPicker(null);
|
||||
}}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
{picker?.kind === "enchantment" && (
|
||||
<ManualPicker
|
||||
title={`Pick a tier ${picker.tier} enchantment`}
|
||||
loadOptions={() => getManualEnchantmentOptions(picker.tier, build.hero.primaryAttribute)}
|
||||
onPick={(id) => {
|
||||
onManualPickEnchantment(picker.tier, id);
|
||||
setPicker(null);
|
||||
}}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Neutral artifacts: up to 2 rolled candidates, always a manual confirm
|
||||
* (the app can't know what actually dropped in-game), plus a full browse
|
||||
* fallback and a reroll for a fresh pair of candidates. */
|
||||
function NeutralStageCard({
|
||||
tier,
|
||||
resolved,
|
||||
attempts,
|
||||
requiresManualPick,
|
||||
tierGateWon,
|
||||
busy,
|
||||
onReroll,
|
||||
onQuickPick,
|
||||
onBrowseAll,
|
||||
}: {
|
||||
tier: number;
|
||||
resolved?: Item | null;
|
||||
attempts: Item[];
|
||||
requiresManualPick: boolean;
|
||||
tierGateWon?: boolean;
|
||||
busy: boolean;
|
||||
onReroll: () => void;
|
||||
onQuickPick: (itemId: number) => void;
|
||||
onBrowseAll: () => void;
|
||||
}) {
|
||||
const noCandidates = requiresManualPick && attempts.length === 0 && !resolved;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/40 p-2">
|
||||
<span className="text-[10px] text-neutral-500">Tier {tier}</span>
|
||||
{tierGateWon === false && <span className="text-[10px] text-amber-500">kept from last tier</span>}
|
||||
|
||||
{resolved ? (
|
||||
<>
|
||||
<img
|
||||
src={resolved.imageUrl}
|
||||
alt={resolved.name}
|
||||
title={resolved.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-10 w-16 rounded object-cover"
|
||||
/>
|
||||
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{resolved.name}</span>
|
||||
<Button small variant="ghost" onClick={onReroll} disabled={busy}>
|
||||
Reroll
|
||||
</Button>
|
||||
</>
|
||||
) : attempts.length > 0 ? (
|
||||
<>
|
||||
<div className="flex gap-1">
|
||||
{attempts.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
title={`Got ${item.name}?`}
|
||||
onClick={() => onQuickPick(item.id)}
|
||||
disabled={busy}
|
||||
className="rounded border border-neutral-700 hover:border-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-10 w-8 rounded object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">click which you got</span>
|
||||
<div className="flex gap-1">
|
||||
<Button small variant="ghost" onClick={onReroll} disabled={busy}>
|
||||
Reroll
|
||||
</Button>
|
||||
<Button small variant="ghost" onClick={onBrowseAll} disabled={busy}>
|
||||
Other
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-10 w-16 items-center justify-center rounded bg-neutral-800 text-[10px] text-neutral-600">
|
||||
pick one
|
||||
</div>
|
||||
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{noCandidates ? "disabled" : "no candidates"}</span>
|
||||
<Button small variant="ghost" onClick={onBrowseAll} disabled={busy}>
|
||||
Pick
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Enchantments: guaranteed once rolling is on for this stage -- the only
|
||||
* choice is whether to roll at all, so this stays a simple resolved/pick binary. */
|
||||
function EnchantmentStageCard({
|
||||
tier,
|
||||
resolvedName,
|
||||
resolvedImage,
|
||||
requiresManualPick,
|
||||
busy,
|
||||
onReroll,
|
||||
onManualPick,
|
||||
}: {
|
||||
tier: number;
|
||||
resolvedName?: string;
|
||||
resolvedImage?: string;
|
||||
requiresManualPick: boolean;
|
||||
busy: boolean;
|
||||
onReroll: () => void;
|
||||
onManualPick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/40 p-2">
|
||||
<span className="text-[10px] text-neutral-500">Tier {tier}</span>
|
||||
{resolvedImage ? (
|
||||
<img
|
||||
src={resolvedImage}
|
||||
alt={resolvedName}
|
||||
title={resolvedName}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-10 w-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-16 items-center justify-center rounded bg-neutral-800 text-[10px] text-neutral-600">
|
||||
{requiresManualPick ? "pick one" : "—"}
|
||||
</div>
|
||||
)}
|
||||
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{resolvedName ?? (requiresManualPick ? "disabled" : "—")}</span>
|
||||
{requiresManualPick ? (
|
||||
<Button small variant="ghost" onClick={onManualPick} disabled={busy}>
|
||||
Pick
|
||||
</Button>
|
||||
) : (
|
||||
<Button small variant="ghost" onClick={onReroll} disabled={busy}>
|
||||
Reroll
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AghanimRow({ build }: { build: Build }) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-3 border-t border-neutral-800 pt-3">
|
||||
{(["aghanimScepter", "aghanimShard"] as const).map((key) => {
|
||||
const result = build[key];
|
||||
const label = key === "aghanimScepter" ? "Aghanim's Scepter" : "Aghanim's Shard";
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2 rounded-md border border-neutral-800 bg-neutral-800/40 px-3 py-1.5">
|
||||
{result.item && (
|
||||
<img
|
||||
src={result.item.imageUrl}
|
||||
alt={label}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-6 w-10 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-neutral-300">
|
||||
{label}: {result.included ? "in build" : "not in this build"} ·{" "}
|
||||
{result.timing === "anytime" ? "purchasable anytime" : "buy only after your 6 slots are full"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { AghanimTiming, AttackType, BuildConfig, PrimaryAttribute, Tag } from "../types";
|
||||
import { Button, PillButton, Section, Toggle } from "./ui";
|
||||
|
||||
const ATTACK_TYPES: AttackType[] = ["melee", "ranged"];
|
||||
const ATTRIBUTES: PrimaryAttribute[] = ["strength", "agility", "intelligence", "universal"];
|
||||
const SLOTS = [1, 2, 3, 4, 5, 6];
|
||||
|
||||
function toggleInArray<T>(arr: T[], value: T): T[] {
|
||||
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
|
||||
}
|
||||
|
||||
export function ConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
tags,
|
||||
onGenerate,
|
||||
generating,
|
||||
}: {
|
||||
config: BuildConfig;
|
||||
onChange: (patch: Partial<BuildConfig>) => void;
|
||||
tags: Tag[];
|
||||
onGenerate: () => void;
|
||||
generating: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Section title="Hero scope">
|
||||
<div className="mb-3">
|
||||
<p className="mb-1.5 text-xs text-neutral-500">Attack type</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ATTACK_TYPES.map((t) => (
|
||||
<PillButton
|
||||
key={t}
|
||||
active={config.attackTypes.includes(t)}
|
||||
onClick={() => onChange({ attackTypes: toggleInArray(config.attackTypes, t) })}
|
||||
>
|
||||
{t}
|
||||
</PillButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-neutral-500">Primary attribute</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ATTRIBUTES.map((a) => (
|
||||
<PillButton
|
||||
key={a}
|
||||
active={config.attributes.includes(a)}
|
||||
onClick={() => onChange({ attributes: toggleInArray(config.attributes, a) })}
|
||||
>
|
||||
{a}
|
||||
</PillButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-neutral-600">No selection = any hero is eligible.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Item scope">
|
||||
<p className="mb-1.5 text-xs text-neutral-500">Only roll items carrying at least one of these tags</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<PillButton
|
||||
key={tag.id}
|
||||
active={config.itemTags.includes(tag.name)}
|
||||
onClick={() => onChange({ itemTags: toggleInArray(config.itemTags, tag.name) })}
|
||||
>
|
||||
{tag.name}
|
||||
</PillButton>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-neutral-600">No selection = any tag is eligible.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Item slots">
|
||||
<p className="mb-1.5 text-xs text-neutral-500">Click a slot to exclude it from randomness (you'll pick it yourself)</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SLOTS.map((slot) => (
|
||||
<PillButton
|
||||
key={slot}
|
||||
active={!config.disabledItemSlots.includes(slot)}
|
||||
onClick={() => onChange({ disabledItemSlots: toggleInArray(config.disabledItemSlots, slot) })}
|
||||
>
|
||||
Slot {slot}
|
||||
</PillButton>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Neutral items">
|
||||
<Toggle
|
||||
label="Randomize neutral artifacts"
|
||||
hint="Off = every tier is left for manual pick (1.4.1 / 1.4.3)"
|
||||
checked={!config.disableAllNeutralRolls && !config.disableNeutralArtifactRandomness}
|
||||
onChange={(checked) => onChange({ disableNeutralArtifactRandomness: !checked })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Randomize enchantments"
|
||||
hint="Off = every tier is left for manual pick (1.4.2 / 1.4.3)"
|
||||
checked={!config.disableAllNeutralRolls && !config.disableEnchantmentRandomness}
|
||||
onChange={(checked) => onChange({ disableEnchantmentRandomness: !checked })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Disable neutral rolls entirely"
|
||||
hint="Overrides both toggles above (1.4.3)"
|
||||
checked={config.disableAllNeutralRolls}
|
||||
onChange={(checked) => onChange({ disableAllNeutralRolls: checked })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Guaranteed roll every tier"
|
||||
hint="Off = 50/50 gate per tier decides if you even get to roll (1.4.6)"
|
||||
checked={config.neutralTierRerollEnabled}
|
||||
onChange={(checked) => onChange({ neutralTierRerollEnabled: checked })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Aghanim's items">
|
||||
<Toggle
|
||||
label="Randomize Aghanim's Scepter"
|
||||
hint="Off = assumed purchasable anytime, outside the roll (1.4.5)"
|
||||
checked={!config.disableAghanimScepterRandomness}
|
||||
onChange={(checked) => onChange({ disableAghanimScepterRandomness: !checked })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Randomize Aghanim's Shard"
|
||||
hint="Off = assumed purchasable anytime, outside the roll (1.4.5)"
|
||||
checked={!config.disableAghanimShardRandomness}
|
||||
onChange={(checked) => onChange({ disableAghanimShardRandomness: !checked })}
|
||||
/>
|
||||
<TimingSelect
|
||||
label="Scepter purchase timing"
|
||||
value={config.aghanimScepterTiming}
|
||||
onChange={(v) => onChange({ aghanimScepterTiming: v })}
|
||||
/>
|
||||
<TimingSelect
|
||||
label="Shard purchase timing"
|
||||
value={config.aghanimShardTiming}
|
||||
onChange={(v) => onChange({ aghanimShardTiming: v })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Button onClick={onGenerate} disabled={generating}>
|
||||
{generating ? "Rolling…" : "Generate random build"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimingSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: AghanimTiming;
|
||||
onChange: (v: AghanimTiming) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-neutral-300">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as AghanimTiming)}
|
||||
className="rounded-md border border-neutral-700 bg-neutral-800 px-2 py-1 text-xs text-neutral-200"
|
||||
>
|
||||
<option value="anytime">Anytime mid-build</option>
|
||||
<option value="after_core_slots">Only after 6 slots filled</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Spinner } from "./ui";
|
||||
|
||||
interface PickableOption {
|
||||
id: number;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export function ManualPicker({
|
||||
title,
|
||||
loadOptions,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
loadOptions: () => Promise<PickableOption[]>;
|
||||
onPick: (id: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [options, setOptions] = useState<PickableOption[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadOptions().then((opts) => {
|
||||
if (!cancelled) setOptions(opts);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadOptions]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-lg border border-neutral-700 bg-neutral-900 p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-200">{title}</h3>
|
||||
<button onClick={onClose} className="text-neutral-500 hover:text-neutral-300">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{!options ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : options.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-500">No eligible options for the current scope.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => onPick(opt.id)}
|
||||
className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/60 p-2 text-center hover:border-emerald-600"
|
||||
>
|
||||
<img
|
||||
src={opt.imageUrl}
|
||||
alt={opt.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-10 w-16 rounded object-cover"
|
||||
/>
|
||||
<span className="text-[11px] leading-tight text-neutral-300">{opt.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
hint,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-3 py-1.5">
|
||||
<span>
|
||||
<span className="block text-sm text-neutral-200">{label}</span>
|
||||
{hint && <span className="block text-xs text-neutral-500">{hint}</span>}
|
||||
</span>
|
||||
<span
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative mt-0.5 h-5 w-9 shrink-0 rounded-full transition-colors ${
|
||||
checked ? "bg-emerald-600" : "bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
checked ? "translate-x-4" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PillButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "border-emerald-500 bg-emerald-500/15 text-emerald-300"
|
||||
: "border-neutral-700 bg-neutral-800/60 text-neutral-400 hover:border-neutral-600"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
onClick,
|
||||
children,
|
||||
variant = "primary",
|
||||
disabled,
|
||||
small,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
variant?: "primary" | "ghost" | "danger";
|
||||
disabled?: boolean;
|
||||
small?: boolean;
|
||||
}) {
|
||||
const base = small ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
|
||||
const styles = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white disabled:bg-neutral-700",
|
||||
ghost: "bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700",
|
||||
danger: "bg-red-700 hover:bg-red-600 text-white",
|
||||
} as const;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-md font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${base} ${styles[variant]}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ small }: { small?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block animate-spin rounded-full border-2 border-neutral-600 border-t-emerald-400 ${
|
||||
small ? "h-3 w-3" : "h-5 w-5"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f1115;
|
||||
color: #e5e7eb;
|
||||
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #14161c;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2e313d;
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
export type PrimaryAttribute = "strength" | "agility" | "intelligence" | "universal";
|
||||
export type AttackType = "melee" | "ranged";
|
||||
export type AghanimTiming = "anytime" | "after_core_slots";
|
||||
|
||||
export interface Hero {
|
||||
id: number;
|
||||
name: string;
|
||||
primaryAttribute: PrimaryAttribute;
|
||||
attackType: AttackType;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: number;
|
||||
name: string;
|
||||
source: "shop" | "neutral";
|
||||
minTier?: number;
|
||||
maxTier?: number;
|
||||
// Grade tracks shop items that upgrade in place rather than being bought
|
||||
// fresh each time (Dagon 1-5, Boots of Travel 1-2). Null for neutral items
|
||||
// and 1-1 for a normal single-grade shop item.
|
||||
minGrade?: number;
|
||||
maxGrade?: number;
|
||||
imageUrl: string;
|
||||
tags: string[];
|
||||
isAghanim?: "scepter" | "shard";
|
||||
}
|
||||
|
||||
export interface Enchantment {
|
||||
id: number;
|
||||
name: string;
|
||||
minTier: number;
|
||||
maxTier: number;
|
||||
attributes: PrimaryAttribute[];
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
/** Everything the user can configure before a build is generated. */
|
||||
export interface BuildConfig {
|
||||
attackTypes: AttackType[]; // empty = no restriction
|
||||
attributes: PrimaryAttribute[]; // empty = no restriction
|
||||
itemTags: string[]; // empty = no restriction
|
||||
disableNeutralArtifactRandomness: boolean; // 1.4.1 - keep enchantment rolls
|
||||
disableEnchantmentRandomness: boolean; // 1.4.2 - keep artifact rolls
|
||||
disableAllNeutralRolls: boolean; // 1.4.3 - overrides the two above
|
||||
disabledItemSlots: number[]; // 1.4.4 - slots (1-6) left for manual pick
|
||||
disableAghanimScepterRandomness: boolean; // 1.4.5
|
||||
disableAghanimShardRandomness: boolean; // 1.4.5
|
||||
neutralTierRerollEnabled: boolean; // 1.4.6
|
||||
aghanimScepterTiming: AghanimTiming; // 2.2.5
|
||||
aghanimShardTiming: AghanimTiming; // 2.2.6
|
||||
}
|
||||
|
||||
export interface ItemSlotResult {
|
||||
slot: number; // 1-6
|
||||
item: Item | null;
|
||||
isManual: boolean; // true when randomness was disabled for this slot (1.4.4)
|
||||
}
|
||||
|
||||
export interface NeutralStageResult {
|
||||
tier: number;
|
||||
attempts: Item[]; // up to 2 rolled candidates (2.2.3)
|
||||
resolved: Item | null;
|
||||
requiresManualPick: boolean;
|
||||
tierGateWon?: boolean; // only meaningful when neutralTierRerollEnabled === false (1.4.6)
|
||||
}
|
||||
|
||||
export interface EnchantmentStageResult {
|
||||
tier: number;
|
||||
resolved: Enchantment | null;
|
||||
requiresManualPick: boolean;
|
||||
}
|
||||
|
||||
export interface AghanimResult {
|
||||
included: boolean;
|
||||
item: Item | null;
|
||||
timing: AghanimTiming;
|
||||
}
|
||||
|
||||
export interface Build {
|
||||
id: string;
|
||||
config: BuildConfig;
|
||||
hero: Hero;
|
||||
itemSlots: ItemSlotResult[];
|
||||
neutralItems: NeutralStageResult[];
|
||||
enchantments: EnchantmentStageResult[];
|
||||
aghanimScepter: AghanimResult;
|
||||
aghanimShard: AghanimResult;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
// Mirrors what nginx does in production (see frontend/nginx.conf) so the
|
||||
// app talks to a real backend the same way in dev and prod.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user